wip: fluent setup, locale compiler & typechecker, markdown parser

This commit is contained in:
Benjamin Singleton 2026-02-22 21:25:07 -06:00
parent 4d875b1416
commit a240a1b954
12 changed files with 1083 additions and 0 deletions

View file

@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fluent/bundle": "^0.19.1",
"@tailwindcss/vite": "^4.1.6",
"@types/node": "^22.15.31",
"@vueuse/components": "^13.3.0",

4
apps/vdn-static/src/assets.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.ftl" {
const src: string;
export default src;
}

View file

@ -0,0 +1,29 @@
localeName = English
vilanticLangs-viossa = Viossa
vilanticLangs-wodox = Wodoch
navbar-whatIsViossa = What is Viossa?
navbar-resources = Resources
navbar-kotoba = Kotoba
home-sections-whatIsViossa-title = What is Viossa?
home-sections-whatIsViossa-body = Viossa is a community-created artificial pidgin language, created to simulate the formation of natural pidgin languages. Viossa is characterized by its lack of standardization, with each speaker developing a personal idiolect. Spelling and pronunciation can vary greatly, and serve as a form of personal self-expression. Viossa is learnt and taught entirely by immersion — translation is prohibited while learning.
home-sections-historyOfViossa-title = History of Viossa
home-sections-historyOfViossa-body = Viossa began as a Skype group in 2014, created by members of the r/conlangs community on Reddit, as an experiment to simulate the formation of a pidgin language. Pidgins are simplified languages resulting from contact between populations with no shared common language. Unlike most pidgins, which usually have two to three contributor languages, Viossa comes from many diverse languages. This is because people from all around the world helped to contribute to Viossa's vocabulary.
home-sections-community-title = Community
home-sections-community-body = The Viossa community is rich and colourful, drawing from many global traditions due to its worldwide online membership. Since the teaching culture puts an emphasis on linguistic immersion, and discourages prescriptivism, the culture of Viossa is as diverse and varied as the language and the people who speak it. For many, their personal dialect is a key form of identity and expression. The fluid nature of Viossa and lack of defined meanings makes Viossa popular for creative purposes, such as poetry and songwriting.
home-images-viossaFlag-alt = Flag of the Viossa Language
richTest-slot = This is a \<slot\>!
richTest-placeable = This is a { $wow } { $placeable ->
[one] <
*[other] multiple
} { $placeable ->
[on] single
*[other] multiple
} { $placeable ->
[one] single
*[other] multiple
} { $placeable ->
[one] single
*[other] >
} thing!
richTest-bold = This is \*\*bolded\*\*!

View file

@ -1,5 +1,41 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import { bundleToUncompiledLocale, loadFluentBundle } from "./new-i18n/setup";
import enUsLocaleSrc from "@/assets/locale/en_US.ftl";
import { localeConfig, compileLocale } from "./new-i18n/config";
import { parseMarkdown } from "./new-i18n/markdown";
const bundleRes = await loadFluentBundle("en-US", enUsLocaleSrc);
if (bundleRes.type === "err") {
throw new Error(bundleRes.err);
}
const bundle = bundleRes.ok;
const uncompiledRes = bundleToUncompiledLocale(bundle);
if (uncompiledRes.type === "err") {
throw new Error(uncompiledRes.err);
}
const uncompiled = uncompiledRes.ok;
const localeRes = compileLocale({ config: localeConfig, uncompiled });
if (localeRes.type === "err") {
throw new Error(localeRes.err);
}
const locale = localeRes.ok;
// console.log(locale.richTest.placeable({}));
const markdownRes = parseMarkdown("# [internal:#hello](Google)");
if (markdownRes.type === "err") {
throw new Error(markdownRes.err);
}
const markdown = markdownRes.ok;
console.log(markdown);
console.log(uncompiled);
createApp(App).use(router).mount("#app");

View file

@ -0,0 +1,268 @@
import type { Result } from "@/utils/types";
import {
computeAllVariants,
type L10nRecord,
type UncompiledLocale,
} from "./setup";
import type { FluentVariable } from "@fluent/bundle";
export const configMessageSymbol: unique symbol = Symbol("configMessage");
export interface ConfigMessage<
Slot extends string = string,
Placeables extends { [name in string]?: ConfigPlaceableInfo } = {
[name in string]: ConfigPlaceableInfo;
},
Markdown extends ConfigMarkdown = ConfigMarkdown,
> {
[configMessageSymbol]: true;
slots: Slot[];
placeables: Placeables;
markdown: Markdown;
}
export interface ConfigPlaceableInfo<
Type extends "string" | "number" = "string" | "number",
> {
type: Type;
}
export interface ConfigMarkdown<Bold extends boolean = boolean> {
bold?: Bold;
}
function message<
const Slot extends string = never,
const Placeables extends {
[name in string]?: ConfigPlaceableInfo;
} = object,
const Markdown extends ConfigMarkdown = object,
>(
opt: Partial<
Omit<
ConfigMessage<Slot, Placeables, Markdown>,
typeof configMessageSymbol
>
> = {},
): ConfigMessage<Slot, Placeables, Markdown> {
return {
[configMessageSymbol]: true,
slots: opt.slots ?? [],
placeables: opt.placeables ?? {},
markdown: opt.markdown ?? {},
};
}
type LocaleConfig<
Slot extends string = string,
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
Markdown extends ConfigMarkdown = object,
> = {
[id: string]:
| ConfigMessage<Slot, Placeables, Markdown>
| LocaleConfig<Slot, Placeables, Markdown>;
};
type MessageCtx<
Slot extends string = string,
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
> = {
[K in keyof Placeables]: ResolvedPlaceableType<
PlaceableType<Exclude<Placeables[K], undefined>>
>;
};
type PlaceableType<Var extends ConfigPlaceableInfo> =
Var extends ConfigPlaceableInfo<infer Type> ? Type : never;
type ResolvedPlaceableType<Type extends "string" | "number"> =
Type extends "string" ? string
: Type extends "number" ? number
: never;
type InferLocale<Config extends LocaleConfig> = {
[K in keyof Config]: Config[K] extends LocaleConfig ? InferLocale<Config[K]>
: Config[K] extends ConfigMessage<infer Slot, infer Placeables> ?
object extends MessageCtx<Slot, Placeables> ?
() => string
: (ctx: MessageCtx<Slot, Placeables>) => string
: never;
};
const homeSectionConfig = { title: message(), body: message() };
const imageConfig = { alt: message() };
export const localeConfig = {
localeName: message(),
vilanticLangs: { viossa: message(), wodox: message() },
navbar: {
whatIsViossa: message(),
resources: message(),
kotoba: message(),
},
home: {
sections: {
whatIsViossa: homeSectionConfig,
historyOfViossa: homeSectionConfig,
community: homeSectionConfig,
},
images: { viossaFlag: imageConfig },
},
richTest: {
slot: message({ slots: ["slot"] }),
placeable: message({
placeables: {
wow: { type: "string" },
placeable: { type: "number" },
},
}),
bold: message({ markdown: { bold: true } }),
},
} as const satisfies LocaleConfig;
export interface CompileLocaleCtx<Config extends LocaleConfig> {
uncompiled: UncompiledLocale;
config: Config;
}
export function compileLocale<Config extends LocaleConfig>(
ctx: CompileLocaleCtx<Config>,
): Result<InferLocale<Config>, string> {
const { uncompiled, config } = ctx;
const { record, bundle } = uncompiled;
const recordKeys = new Set(Object.keys(record));
const configKeys = new Set(Object.keys(config));
const excessKeys = recordKeys.difference(configKeys);
console.log(recordKeys, configKeys, excessKeys);
if (excessKeys.size > 0) {
return {
type: "err",
err: `Excess keys in record: ${[...excessKeys].join(", ")}`,
};
}
type GenericLocale = {
[id: string]:
| GenericLocale
| ((args: Record<string, FluentVariable>) => string);
};
const locale: GenericLocale = {};
for (const [id, configValue] of Object.entries(config)) {
const recordValue = record[id];
if (configMessageSymbol in configValue) {
if (recordValue?.type !== "message") {
return {
type: "err",
err: `Expected message for key \`${id}\`, found: ${typeof recordValue}`,
};
}
const message = recordValue.message;
console.log(message);
const pattern = message.value;
if (pattern === null) {
return {
type: "err",
err: `Pattern is null for message with ID: ${id}`,
};
}
const allVariants = computeAllVariants(pattern);
console.log(allVariants);
// validate placeables
console.log(pattern);
if (typeof pattern !== "string") {
for (const element of pattern) {
if (typeof element === "string") {
continue;
}
switch (element.type) {
case "select": {
const { selector } = element;
if (selector.type !== "var") {
return {
type: "err",
err: `Expected selector to be a var expression for key: ${id}; Found: ${selector.type}`,
};
}
if (
!Object.keys(configValue.placeables).includes(
selector.name,
)
) {
return {
type: "err",
err: `Found unexpected placeable name \`${selector.name}\` for key: ${id}`,
};
}
break;
}
case "var": {
if (
!Object.keys(configValue.placeables).includes(
element.name,
)
) {
return {
type: "err",
err: `Found unexpected placeable name \`${element.name}\` for key: ${id}`,
};
}
break;
}
case "term":
case "mesg":
case "func":
case "str":
case "num": {
break; // ignore
}
}
}
}
// check if all variants are valid markdown
// create function
locale[id] = (args: Record<string, FluentVariable> = {}) =>
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
bundle.formatPattern(pattern, args);
} else {
if (recordValue?.type !== "subrecord") {
return {
type: "err",
err: `Expected subrecord for key \`${id}\`, found: ${typeof recordValue}`,
};
}
const subrecord = recordValue.subrecord;
const compiledSubrecordRes = compileLocale({
uncompiled: { bundle, record: subrecord },
config: configValue,
});
if (compiledSubrecordRes.type === "err") {
return {
type: "err",
err: `Error when compiling subrecord with key: \`${id}\`:\n${compiledSubrecordRes.err}`,
};
}
const compiledSubrecord = compiledSubrecordRes.ok;
locale[id] = compiledSubrecord;
}
}
// SAFETY: validated above that all keys exist and are the correct type
return { type: "ok", ok: locale as InferLocale<Config> };
}

View file

@ -0,0 +1,541 @@
import type {
SmartDest,
SmartExternalDest,
SmartInternalDest,
} from "@/utils/smart-dest";
import type { Result } from "@/utils/types";
import type { RouteNamedMap } from "vue-router/auto-routes";
import { routes } from "vue-router/auto-routes";
export type MarkdownLine = {
type: "paragraph" | "header";
elements: MarkdownLineElement[];
};
export type MarkdownFeature =
| Exclude<MarkdownLine["type"], "paragraph">
| Exclude<MarkdownLineElement["type"], "plain">;
export type MarkdownLineElement =
| { type: "plain"; plain: string }
| { type: "italic"; italic: MarkdownLineElement[] }
| { type: "bold"; bold: MarkdownLineElement[] }
| { type: "link"; link: { name: string; to: SmartDest } }
| { type: "slot"; slot: string };
export function parseMarkdown(
markdownString: string,
): Result<MarkdownLine[], string> {
const lines = markdownString.split("\n");
const markdownLines: MarkdownLine[] = [];
for (const line of lines) {
const markdownLineRes = parseMarkdownLine(line);
if (markdownLineRes.type === "err") {
return {
type: "err",
err: `On line ${String(markdownLines.length + 1)}:\n${markdownLineRes.err}`,
};
}
const markdownLine = markdownLineRes.ok;
markdownLines.push(markdownLine);
}
return { type: "ok", ok: markdownLines };
}
function parseMarkdownLine(line: string): Result<MarkdownLine, string> {
if (line.startsWith("#")) {
const elementsRes = parseMarkdownLineElements(line.substring(1));
if (elementsRes.type === "err") {
return {
type: "err",
err: `While parsing elements:\n${elementsRes.err}`,
};
}
const elements = elementsRes.ok;
return { type: "ok", ok: { type: "header", elements } };
}
const elementsRes = parseMarkdownLineElements(line);
if (elementsRes.type === "err") {
return {
type: "err",
err: `While parsing elements:\n${elementsRes.err}`,
};
}
const elements = elementsRes.ok;
return { type: "ok", ok: { type: "paragraph", elements } };
}
function parseMarkdownLineElements(
line: string,
): Result<MarkdownLineElement[], string> {
if (line.startsWith("#")) {
// subheaders may be supported in the future,
// so ignoring them or treating them as h1 headers now would be a breaking change when
// subheader support is implemented.
// making subheaders a compile error for now ensures
// all current i18n is backwards-compatible when/if they are implemented
return { type: "err", err: "Subheaders are not supported." };
}
const trimmedLine = line.trim();
const chars = trimmedLine.split("");
const elementsRes = readMarkdownLineElements(chars, {
inItalic: false,
inBold: false,
});
if (elementsRes.type === "err") {
return elementsRes;
}
const elements = elementsRes.ok;
return { type: "ok", ok: elements };
}
interface ReadMarkdownLineElementCtx {
inItalic: boolean;
inBold: boolean;
}
function readMarkdownLineElements(
chars: string[],
ctx: ReadMarkdownLineElementCtx,
): Result<MarkdownLineElement[], string> {
const elements: MarkdownLineElement[] = [];
while (true) {
const elementRes = readMarkdownLineElement(chars, ctx);
if (elementRes.type === "err") {
return elementRes;
}
const element = elementRes.ok;
if (element.length === 0) {
break;
}
elements.push(...element);
}
return { type: "ok", ok: elements };
}
function readMarkdownLineElement(
chars: string[],
ctx: ReadMarkdownLineElementCtx,
): Result<MarkdownLineElement[], string> {
const [firstChar, secondChar] = chars;
if (firstChar === undefined) {
return { type: "ok", ok: [] };
} else if (firstChar === "<") {
return readMarkdownLineElementSlot(chars);
} else if (firstChar === "[") {
return readMarkdownLineElementLink(chars);
} else if (firstChar === "*" && secondChar === "*" && !ctx.inBold) {
return readMarkdownLineElementBold(chars, ctx);
} else if (firstChar === "*" && secondChar !== "*" && !ctx.inItalic) {
return readMarkdownLineElementItalic(chars, ctx);
} else {
return readMarkdownLineElementPlain(chars);
}
}
function readMarkdownLineElementSlot(
chars: string[],
): Result<MarkdownLineElement[], string> {
const openAngleRes = expectReadChar(chars, "<");
if (openAngleRes.type === "err") {
return openAngleRes;
}
const slotNameRes = readUntilClosing({
chars,
elementName: "slot",
closingChar: ">",
});
if (slotNameRes.type === "err") {
return slotNameRes;
}
const slotName = slotNameRes.ok;
return { type: "ok", ok: [{ type: "slot", slot: slotName }] };
}
function readMarkdownLineElementLink(
chars: string[],
): Result<MarkdownLineElement[], string> {
const openSquareRes = expectReadChar(chars, "[");
if (openSquareRes.type === "err") {
return openSquareRes;
}
const destRes = ((): Result<SmartDest, string> => {
if (peekStringEq(chars, "external:")) {
const externalRes = expectReadString(chars, "external:");
if (externalRes.type === "err") {
return externalRes;
}
const destRes = readUntilClosing({
chars,
elementName: "link dest",
closingChar: "]",
});
if (destRes.type === "err") {
return destRes;
}
const dest = destRes.ok;
const externalDestRes = validateExternalDest(dest);
if (externalDestRes.type === "err") {
return externalDestRes;
}
const externalDest = externalDestRes.ok;
return {
type: "ok",
ok: { type: "external", external: externalDest },
};
} else if (peekStringEq(chars, "internal:")) {
const internalRes = expectReadString(chars, "internal:");
if (internalRes.type === "err") {
return internalRes;
}
const destRes = readUntilClosing({
chars,
elementName: "link dest",
closingChar: "]",
});
if (destRes.type === "err") {
return destRes;
}
const dest = destRes.ok;
const internalDestRes = validateInternalDest(dest);
if (internalDestRes.type === "err") {
return internalDestRes;
}
const internalDest = internalDestRes.ok;
return {
type: "ok",
ok: { type: "internal", internal: internalDest },
};
} else {
return {
type: "err",
err: `Expected external: or internal: link prefix; Found: "${chars.slice(0, 10).join("")}..."`,
};
}
})();
if (destRes.type === "err") {
return destRes;
}
const dest = destRes.ok;
const openParenRes = expectReadChar(chars, "(");
if (openParenRes.type === "err") {
return openParenRes;
}
const nameRes = readUntilClosing({
chars,
elementName: "link name",
closingChar: ")",
});
if (nameRes.type === "err") {
return nameRes;
}
const name = nameRes.ok;
return { type: "ok", ok: [{ type: "link", link: { name, to: dest } }] };
}
function validateExternalDest(dest: string): Result<SmartExternalDest, string> {
const HTTPS_PREFIX = "https://";
const HTTP_PREFIX = "http://";
if (dest.startsWith(HTTPS_PREFIX)) {
return {
type: "ok",
ok: `${HTTPS_PREFIX}${dest.substring(HTTPS_PREFIX.length)}`,
};
}
if (dest.startsWith(HTTP_PREFIX)) {
return {
type: "ok",
ok: `${HTTP_PREFIX}${dest.substring(HTTP_PREFIX.length)}`,
};
}
return {
type: "err",
err: `External dest must start with https:// or http://`,
};
}
function validateInternalDest(dest: string): Result<SmartInternalDest, string> {
const [routeString, id] = dest.split("#");
const validatedRouteRes = ((): Result<
keyof RouteNamedMap | undefined,
string
> => {
if (routeString === undefined || routeString.length === 0) {
return { type: "ok", ok: undefined };
}
const route = routes.find((route) => route.path === routeString);
if (route === undefined) {
return {
type: "err",
err: `Route with ID \`${routeString}\` does not exist`,
};
}
return {
type: "ok",
// SAFETY: we validated the route exists in the router about
ok: route.path as keyof RouteNamedMap,
};
})();
if (validatedRouteRes.type === "err") {
return validatedRouteRes;
}
const validatedRoute = validatedRouteRes.ok;
if (validatedRoute !== undefined) {
return { type: "ok", ok: { route: validatedRoute, id } };
} else if (id !== undefined) {
return { type: "ok", ok: { route: validatedRoute, id } };
} else {
return {
type: "err",
err: `Either route or ID must be defined for internal dest`,
};
}
}
function readMarkdownLineElementBold(
chars: string[],
ctx: ReadMarkdownLineElementCtx,
): Result<MarkdownLineElement[], string> {
const firstStarRes = expectReadChar(chars, "*");
if (firstStarRes.type === "err") {
return firstStarRes;
}
const secondStarRes = expectReadChar(chars, "*");
if (secondStarRes.type === "err") {
return secondStarRes;
}
ctx.inBold = true;
const elements: MarkdownLineElement[] = [];
let closed = false;
while (true) {
const elementRes = readMarkdownLineElement(chars, ctx);
if (elementRes.type === "err") {
return elementRes;
}
const element = elementRes.ok;
if (element.length === 0) {
break;
}
elements.push(...element);
const [firstChar, secondChar] = chars;
if (firstChar === "*" && secondChar === "*") {
chars.shift();
chars.shift();
closed = true;
break;
} else if (firstChar === undefined) {
closed = false;
break;
}
}
ctx.inBold = !closed;
if (closed) {
return { type: "ok", ok: [{ type: "bold", bold: elements }] };
} else {
return {
type: "ok",
ok: [{ type: "plain", plain: "**" }, ...elements],
};
}
}
function readMarkdownLineElementItalic(
chars: string[],
ctx: ReadMarkdownLineElementCtx,
): Result<MarkdownLineElement[], string> {
const starRes = expectReadChar(chars, "*");
if (starRes.type === "err") {
return starRes;
}
ctx.inItalic = true;
const elements: MarkdownLineElement[] = [];
let closed = false;
while (true) {
const elementRes = readMarkdownLineElement(chars, ctx);
if (elementRes.type === "err") {
return elementRes;
}
const element = elementRes.ok;
if (element.length === 0) {
break;
}
elements.push(...element);
const [firstChar] = chars;
if (firstChar === "*") {
chars.shift();
closed = true;
break;
} else if (firstChar === undefined) {
closed = false;
break;
}
}
ctx.inItalic = !closed;
if (closed) {
return { type: "ok", ok: [{ type: "italic", italic: elements }] };
} else {
return { type: "ok", ok: [{ type: "plain", plain: "*" }, ...elements] };
}
}
function readMarkdownLineElementPlain(
chars: string[],
): Result<MarkdownLineElement[], string> {
let plain = "";
let escaped = false;
while (true) {
const peek = chars[0];
if (peek === undefined) {
break;
}
if (escaped) {
escaped = false;
} else {
if (peek === "\\") {
escaped = true;
chars.shift();
continue;
}
if (peek === "*" || peek === "<") {
break;
}
}
plain += peek;
chars.shift();
}
return { type: "ok", ok: [{ type: "plain", plain }] };
}
function expectReadChar(
chars: string[],
expectedChar: string,
): Result<void, string> {
const nextChar = chars.shift();
if (nextChar !== expectedChar) {
return {
type: "err",
err: `Expected: "${expectedChar}"; Found: ${nextChar === undefined ? "undefined" : `"${nextChar}"`}`,
};
}
return { type: "ok", ok: undefined };
}
function peekStringEq(chars: string[], expectedString: string): boolean {
return chars.slice(0, expectedString.length).join("") === expectedString;
}
function expectReadString(
chars: string[],
expectedString: string,
): Result<void, string> {
let foundString: string | undefined = undefined;
for (const expectedChar of expectedString) {
const nextChar = chars.shift();
if (nextChar !== undefined) {
foundString = (foundString ?? "") + nextChar;
}
if (expectedChar !== nextChar) {
return {
type: "err",
err: `Expected: "${expectedString}"; Found: ${foundString === undefined ? "undefined" : `"${foundString}"`}`,
};
}
}
return { type: "ok", ok: undefined };
}
interface ReadUntilClosingCtx {
chars: string[];
elementName: string;
closingChar: string;
}
function readUntilClosing(ctx: ReadUntilClosingCtx): Result<string, string> {
const { chars, elementName, closingChar } = ctx;
let value = "";
let closed = false;
let escaped = false;
while (true) {
const char = chars.shift();
if (char === undefined) {
closed = false;
break;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === closingChar && !escaped) {
closed = true;
break;
}
value += char;
}
if (!closed) {
return {
type: "err",
err: `Unclosed ${elementName}: <${value.replaceAll(closingChar, `\\${closingChar}`)}`,
};
}
return { type: "ok", ok: value };
}

View file

@ -0,0 +1,179 @@
import type { Result } from "@/utils/types";
import {
FluentBundle,
FluentResource,
type FluentVariable,
} from "@fluent/bundle";
import { unsafeAsync } from "@/utils/unsafe";
import type { Literal, Message, Pattern } from "@fluent/bundle/esm/ast";
export async function loadFluentBundle(
localeId: string,
src: string,
): Promise<Result<FluentBundle, string>> {
const ftlFileResponseRes = await unsafeAsync(() => fetch(src));
if (ftlFileResponseRes.type === "err") {
return { type: "err", err: `Failed to fetch locale from src: ${src}` };
}
const ftlFileResponse = ftlFileResponseRes.ok;
const ftlFileTextRes = await unsafeAsync(() => ftlFileResponse.text());
if (ftlFileTextRes.type === "err") {
return {
type: "err",
err: `Failed to fetch text content of FTL file from src: ${src}`,
};
}
const ftlFileText = ftlFileTextRes.ok;
console.log(ftlFileText);
const resource = new FluentResource(ftlFileText);
console.log(resource.body);
const bundle = new FluentBundle(localeId);
const errors = bundle.addResource(resource);
if (errors.length > 0) {
return {
type: "err",
err: `Failed to add Fluent resource to bundle:\n${errors.join("\n")}`,
};
}
return { type: "ok", ok: bundle };
}
export type L10nRecord = {
[id: string]:
| { type: "message"; message: Message }
| { type: "subrecord"; subrecord: L10nRecord };
};
export interface UncompiledLocale {
bundle: FluentBundle;
record: L10nRecord;
}
export function bundleToUncompiledLocale(
bundle: FluentBundle,
): Result<UncompiledLocale, string> {
const record: L10nRecord = {};
for (const [id, message] of bundle._messages) {
const idChain = id.split("-");
let subrecord = record;
while (true) {
const subId = idChain.shift();
if (subId === undefined) {
return {
type: "err",
err: `Reached end of message ID chain before terminating for message ID: ${id}`,
};
}
if (idChain.length === 0) {
subrecord[subId] = { type: "message", message };
break;
} else {
const maybeSubrecord = (subrecord[subId] ??= {
type: "subrecord",
subrecord: {},
});
if (maybeSubrecord.type === "subrecord") {
subrecord = maybeSubrecord.subrecord;
} else {
return {
type: "err",
err: `Found message when expected subrecord for message ID: ${id} @ subId: ${subId}`,
};
}
}
}
}
return { type: "ok", ok: { bundle, record } };
}
type SelectionChain = (Literal | SelectionChain)[];
interface PatternVariant {
selectionChain: SelectionChain;
string: string;
}
export function computeAllVariants(
pattern: Pattern,
): Result<PatternVariant[], string> {
if (typeof pattern === "string") {
return { type: "ok", ok: [{ selectionChain: [], string: pattern }] };
}
let variants: PatternVariant[] = [{ selectionChain: [], string: "" }];
for (const element of pattern) {
if (typeof element === "string") {
variants = variants.map((variant) => ({
selectionChain: variant.selectionChain,
string: variant.string + element,
}));
continue;
}
switch (element.type) {
case "select": {
const selectVariants: PatternVariant[] = [];
for (const selectVariant of element.variants) {
const variantComputedRes = computeAllVariants(
selectVariant.value,
);
if (variantComputedRes.type === "err") {
return {
type: "err",
err: `Failed to compute select variants:\n${variantComputedRes.err}`,
};
}
const variantComputed = variantComputedRes.ok;
selectVariants.push(
...variantComputed.map(
(v): PatternVariant => ({
selectionChain: [
selectVariant.key,
...v.selectionChain,
],
string: v.string,
}),
),
);
}
variants = variants.flatMap((variant) =>
selectVariants.map(
(selectVariant): PatternVariant => ({
selectionChain: [
...variant.selectionChain,
selectVariant.selectionChain,
],
string: variant.string + selectVariant.string,
}),
),
);
break;
}
case "var": {
continue;
}
default: {
return {
type: "err",
err: `Unhandled PatternElement type: ${element.type}`,
};
}
}
}
return { type: "ok", ok: variants };
}

View file

@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from "vue-router";
import { routes, handleHotUpdate } from "vue-router/auto-routes";
console.log(routes);
const router = createRouter({ history: createWebHistory(), routes });
if (import.meta.hot) {

View file

@ -4,3 +4,5 @@ export type DeepPartial<T extends object> =
export type Prettify<T> = T extends object ? { [K in keyof T]: T[K] } & {} : T;
export type Value<T> = T[keyof T];
export type Result<T, E> = { type: "ok"; ok: T } | { type: "err"; err: E };

View file

@ -0,0 +1,11 @@
import type { Result } from "./types";
export async function unsafeAsync<R>(
f: () => Promise<R>,
): Promise<Result<R, unknown>> {
try {
return { type: "ok", ok: await f() };
} catch (e) {
return { type: "err", err: e };
}
}

View file

@ -7,4 +7,5 @@ export default defineConfig({
plugins: [vueRouter({ root: "src", routesFolder: "pages" }), vue({})],
resolve: { alias: { "@": path.resolve(import.meta.dirname, "src") } },
server: { port: 1224 },
assetsInclude: ["**/*.ftl"],
});

9
pnpm-lock.yaml generated
View file

@ -69,6 +69,9 @@ importers:
apps/vdn-static:
dependencies:
'@fluent/bundle':
specifier: ^0.19.1
version: 0.19.1
'@tailwindcss/vite':
specifier: ^4.1.6
version: 4.1.10(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(tsx@4.20.2)(yaml@2.8.0))
@ -397,6 +400,10 @@ packages:
resolution: {integrity: sha512-kLfWnuhbC25CPkR1/TDcVs0rSiv0JLNxrpUivLwc7FUnkyeciRi5VOmC1SOzL2SOagcozu3+m4VQiONyzgfY7w==}
engines: {node: '>= 14'}
'@fluent/bundle@0.19.1':
resolution: {integrity: sha512-SWJLZrPamDPsJlFFOW1nkgN0j0rbPbmSdmK0XAoXlyqKieLtMVl4vzng3aR5pwKoUx0scug8+YY2oct3fdfy9A==}
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
'@gar/promisify@1.1.3':
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
@ -3057,6 +3064,8 @@ snapshots:
'@feathersjs/hooks@0.9.0': {}
'@fluent/bundle@0.19.1': {}
'@gar/promisify@1.1.3':
optional: true