wip: working new i18n system (need to polish still)

This commit is contained in:
Benjamin Singleton 2026-02-23 01:29:47 -06:00
parent a240a1b954
commit d258014471
28 changed files with 1025 additions and 1769 deletions

View file

@ -1,268 +1,72 @@
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;
};
message,
record,
type InferLocale,
type LocaleConfig,
} from "@/new-i18n-lib/config";
const homeSectionConfig = { title: message(), body: message() };
const imageConfig = { alt: message() };
export interface Image extends InferLocale<typeof imageConfig> {}
const buttonConfig = { label: message() };
const resourceConfig = <ButtonKey extends string>(buttonKeys: ButtonKey[]) => ({
title: message(),
subtitle: message(),
desc: message(),
buttons: record(buttonKeys, () => buttonConfig),
});
const discordRuleConfig = {
overview: {
text: message({ markdown: { bold: true, italic: true, link: true } }),
subtext: message({
markdown: { bold: true, italic: true, link: true },
}),
},
section: {
header: message({ placeables: { ruleNumber: { type: "number" } } }),
body: message({
markdown: { bold: true, header: true, italic: true, link: true },
}),
},
};
export const localeConfig = {
localeName: message(),
vilanticLangs: { viossa: message(), wodox: message() },
navbar: {
whatIsViossa: message(),
resources: message(),
kotoba: message(),
},
vilanticLangs: record(["viossa", "wodox"], () => message()),
navbar: record(["whatIsViossa", "resources", "kotoba"], () => message()),
home: {
sections: {
whatIsViossa: homeSectionConfig,
historyOfViossa: homeSectionConfig,
community: homeSectionConfig,
},
images: { viossaFlag: imageConfig },
sections: record(
["whatIsViossa", "historyOfViossa", "community"],
() => homeSectionConfig,
),
images: record(["viossaFlag"], () => imageConfig),
},
richTest: {
slot: message({ slots: ["slot"] }),
placeable: message({
placeables: {
wow: { type: "string" },
placeable: { type: "number" },
},
}),
bold: message({ markdown: { bold: true } }),
resources: {
title: message(),
resources: { discord: resourceConfig(["join", "rules"]) },
images: record(["discordLogo"], () => imageConfig),
},
kotoba: { title: message(), searchHelp: message() },
discord: {
rulesPage: {
title: message(),
overview: { title: message(), help: message() },
rules: record(
[
"noTranslation",
"lfsv",
"viossaOnlyChats",
"sfw",
"respectOthers",
"respectStaff",
"controversialTopics",
],
() => discordRuleConfig,
),
},
},
} 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,23 @@
import type { VilanticId } from "./vilantic";
export interface Greeting {
title: string;
subtitle: string;
author: string;
lang: VilanticId;
}
export const GREETINGS = [
{
title: "BRÅTULA VIOSSA.NET MÅDE",
subtitle: "Hadjiplas per lera para Viossa glossa fu vi",
author: "Jez",
lang: "viossa",
},
{
title: "akka po viossa.net!",
subtitle: "kenomasufobo o gen wi tropos o viosox",
author: "Tetro",
lang: "wodox",
},
] as const satisfies Greeting[];

View file

@ -0,0 +1,161 @@
import { compileLocale, type InferLocale } from "@/new-i18n-lib/config";
import {
bundleToUncompiledLocale,
loadFluentBundle,
} from "@/new-i18n-lib/setup";
import { localeConfig } from "./config";
import type { Result } from "@/utils/types";
import { useLocalStorage } from "@vueuse/core";
import { computed, type DeepReadonly } from "vue";
import { type } from "arktype";
import enUsFtlSrc from "@/assets/locale/en_US.ftl";
import vpVlFtlSrc from "@/assets/locale/vp_VL.ftl";
import wpVlFtlSrc from "@/assets/locale/wp_VL.ftl";
import type { FluentBundle } from "@fluent/bundle";
export const LOCALE_IDS = ["en-US", "vp-VL", "wp-VL"] as const;
export type LocaleId = typeof LocaleId.infer;
export const LocaleId = type.enumerated(...LOCALE_IDS);
export const DEFAULT_LOCALE_ID = "en-US" satisfies LocaleId;
// users could manually edit localStorage to make this value anything, so we need to validate it
const localStorageLocaleId = useLocalStorage<unknown>(
"localeId",
DEFAULT_LOCALE_ID,
);
export const localeId = computed({
get: (): LocaleId => {
const localeIdRes = LocaleId(localStorageLocaleId.value);
if (localeIdRes instanceof type.errors) {
// if invalid LocaleId, reset to default
localStorageLocaleId.value = DEFAULT_LOCALE_ID;
return DEFAULT_LOCALE_ID;
}
// else return user's selection
const localeId = localeIdRes;
return localeId;
},
// custom setter to ensure it is only set to a valid LocaleId by our code
// (since the localStorage ref is typed as `unknown`, it can be set to any value)
set: (id: LocaleId) => {
localStorageLocaleId.value = id;
},
});
export interface Locale extends InferLocale<typeof localeConfig> {}
async function loadLocale(
localeId: LocaleId,
localeFtlSrc: string,
): Promise<Result<FluentBundle, string>> {
const bundleRes = await loadFluentBundle(localeId, localeFtlSrc);
if (bundleRes.type === "err") {
return bundleRes;
}
const bundle = bundleRes.ok;
return { type: "ok", ok: bundle };
}
function setupLocale(
localeBundle: FluentBundle,
fallbackLocaleBundle: FluentBundle | undefined,
): Result<Locale, string> {
const maybeFallbackedBundle = (() => {
if (fallbackLocaleBundle === undefined) {
return localeBundle;
}
console.log(localeBundle);
console.log(fallbackLocaleBundle);
const localeMessageIds = new Set(localeBundle._messages.keys());
const fallbackMessageIds = new Set(
fallbackLocaleBundle._messages.keys(),
);
const missingMessageIds =
fallbackMessageIds.difference(localeMessageIds);
for (const id of missingMessageIds) {
const fallbackMessage = fallbackLocaleBundle._messages.get(id);
if (fallbackMessage) {
localeBundle._messages.set(id, fallbackMessage);
}
}
return localeBundle;
})();
const uncompiledRes = bundleToUncompiledLocale(maybeFallbackedBundle);
if (uncompiledRes.type === "err") {
return uncompiledRes;
}
const uncompiled = uncompiledRes.ok;
const localeRes = compileLocale({ config: localeConfig, uncompiled });
if (localeRes.type === "err") {
return localeRes;
}
const locale = localeRes.ok;
return { type: "ok", ok: locale };
}
function unwrap<T, E>(result: Result<T, E>): T {
switch (result.type) {
case "ok": {
return result.ok;
}
case "err": {
throw new Error(String(result.err));
}
}
}
function deepReadonly<T>(value: T): DeepReadonly<T> {
// SAFETY: we're just making an immutable view to the type, this isn't dangerous
return value as DeepReadonly<T>;
}
const DEFAULT_LOCALE = unwrap(await loadLocale("en-US", enUsFtlSrc));
const doItAllForLocale = async (
localeId: LocaleId,
localeFtlSrc: string,
): Promise<DeepReadonly<Locale>> =>
deepReadonly(
unwrap(
setupLocale(
unwrap(await loadLocale(localeId, localeFtlSrc)),
DEFAULT_LOCALE,
),
),
);
const [vpVl, wpVl] = await Promise.all([
doItAllForLocale("vp-VL", vpVlFtlSrc),
doItAllForLocale("wp-VL", wpVlFtlSrc),
]);
const localeIdToLocale = {
"en-US": deepReadonly(unwrap(setupLocale(DEFAULT_LOCALE, DEFAULT_LOCALE))),
"vp-VL": vpVl,
"wp-VL": wpVl,
} as const satisfies Record<LocaleId, DeepReadonly<Locale>>;
export interface UseLocaleOptions {
locale?: LocaleId;
}
export const useLocale = (opt: UseLocaleOptions = {}) =>
computed<DeepReadonly<Locale>>(() => {
const localLocaleId = opt.locale ?? localeId.value;
return localeIdToLocale[localLocaleId];
});

View file

@ -1,541 +0,0 @@
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

@ -1,179 +0,0 @@
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

@ -0,0 +1,9 @@
import viossaFlag from "@/assets/flag_vp.webp";
import wodoxFlag from "@/assets/flag_wp.webp";
export type VilanticId = "viossa" | "wodox";
export const VILANTIC_ID_TO_FLAG = {
viossa: viossaFlag,
wodox: wodoxFlag,
} as const satisfies Record<VilanticId, string>;