wip: fluent setup, locale compiler & typechecker, markdown parser
This commit is contained in:
parent
4d875b1416
commit
a240a1b954
12 changed files with 1083 additions and 0 deletions
268
apps/vdn-static/src/new-i18n/config.ts
Normal file
268
apps/vdn-static/src/new-i18n/config.ts
Normal 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> };
|
||||
}
|
||||
541
apps/vdn-static/src/new-i18n/markdown.ts
Normal file
541
apps/vdn-static/src/new-i18n/markdown.ts
Normal 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 };
|
||||
}
|
||||
179
apps/vdn-static/src/new-i18n/setup.ts
Normal file
179
apps/vdn-static/src/new-i18n/setup.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue