wip: fallback behavior for missing/malformed messages instead of panicking
This commit is contained in:
parent
e48b6c8708
commit
44ab34133c
6 changed files with 548 additions and 405 deletions
437
apps/vdn-static/src/new-i18n-lib/compile.ts
Normal file
437
apps/vdn-static/src/new-i18n-lib/compile.ts
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
import type { Result, Value } from "@/utils/types";
|
||||
import {
|
||||
computeAllVariants,
|
||||
selectionChainToString,
|
||||
type L10nRecord,
|
||||
type UncompiledLocale,
|
||||
} from "./setup";
|
||||
import type { FluentBundle, FluentVariable } from "@fluent/bundle";
|
||||
import { parseMarkdown, type Markdown } from "./markdown";
|
||||
import {
|
||||
configMessageSymbol,
|
||||
type ConfigMessage,
|
||||
type InferLocale,
|
||||
type LocaleConfig,
|
||||
} from "./config";
|
||||
|
||||
export interface CompileLocaleCtx<Config extends LocaleConfig> {
|
||||
bundle: FluentBundle;
|
||||
record: L10nRecord | undefined;
|
||||
config: Config;
|
||||
fallback?: InferLocale<Config>;
|
||||
}
|
||||
|
||||
export interface CompileLocaleRes<Locale> {
|
||||
locale: Locale;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type GenericLocale = { [id: string]: GenericLocale | GenericMessageFn };
|
||||
|
||||
type GenericMessageFn =
|
||||
| ((args?: Record<string, FluentVariable>) => string)
|
||||
| ((args?: Record<string, FluentVariable>) => Markdown);
|
||||
|
||||
export function compileLocale<Config extends LocaleConfig>(
|
||||
ctx: CompileLocaleCtx<Config>,
|
||||
): CompileLocaleRes<InferLocale<Config>> {
|
||||
const { bundle, record, config, fallback } = ctx;
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
const recordKeys = new Set(Object.keys(record ?? {}));
|
||||
const configKeys = new Set(Object.keys(config));
|
||||
const excessKeys = recordKeys.difference(configKeys);
|
||||
if (excessKeys.size > 0) {
|
||||
errors.push(`Excess keys in record: ${[...excessKeys].join(", ")}`);
|
||||
}
|
||||
|
||||
const locale: GenericLocale = {};
|
||||
for (const [messageId, configValue] of Object.entries(config)) {
|
||||
const recordValue = record?.[messageId];
|
||||
const fallbackValue = fallback?.[messageId];
|
||||
|
||||
const compiledValue = ((): Value<GenericLocale> => {
|
||||
if (configMessageSymbol in configValue) {
|
||||
const compiledMessageRes = compileMessage({
|
||||
bundle,
|
||||
messageId,
|
||||
configValue,
|
||||
recordValue,
|
||||
});
|
||||
|
||||
const compiledMessage = ((): GenericMessageFn => {
|
||||
if (compiledMessageRes.type === "ok") {
|
||||
return compiledMessageRes.ok;
|
||||
}
|
||||
|
||||
errors.push(compiledMessageRes.err);
|
||||
|
||||
if (
|
||||
fallbackValue !== undefined
|
||||
&& typeof fallbackValue === "function"
|
||||
) {
|
||||
return fallbackValue as GenericMessageFn;
|
||||
}
|
||||
|
||||
return () => `[PLACEHOLDER]${messageId}`;
|
||||
})();
|
||||
|
||||
return compiledMessage;
|
||||
} else {
|
||||
const subrecord = (() => {
|
||||
if (recordValue?.type !== "subrecord") {
|
||||
errors.push(
|
||||
`Expected subrecord for key \`${messageId}\`, found: ${typeof recordValue}`,
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return recordValue.subrecord;
|
||||
})();
|
||||
|
||||
return compileSublocale({
|
||||
subconfig: configValue,
|
||||
recordSublocale: subrecord,
|
||||
fallbackSublocale:
|
||||
typeof fallbackValue === "function" ? undefined : (
|
||||
fallbackValue
|
||||
),
|
||||
errors,
|
||||
bundle,
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
locale[messageId] = compiledValue;
|
||||
}
|
||||
|
||||
// SAFETY: validated above that all keys exist and are the correct type
|
||||
return { locale: locale as InferLocale<Config>, errors };
|
||||
}
|
||||
|
||||
interface CompileMessageCtx {
|
||||
bundle: FluentBundle;
|
||||
messageId: string;
|
||||
configValue: ConfigMessage;
|
||||
recordValue: Value<UncompiledLocale["record"]> | undefined;
|
||||
}
|
||||
|
||||
function compileMessage(
|
||||
ctx: CompileMessageCtx,
|
||||
): Result<GenericMessageFn, string> {
|
||||
const { bundle, messageId, configValue, recordValue } = ctx;
|
||||
|
||||
if (recordValue?.type !== "message") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected message for key \`${messageId}\`, found: ${typeof recordValue}`,
|
||||
};
|
||||
}
|
||||
|
||||
const message = recordValue.message;
|
||||
|
||||
const pattern = message.value;
|
||||
if (pattern === null) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Pattern is null for message with ID: ${messageId}`,
|
||||
};
|
||||
}
|
||||
|
||||
// validate placeables
|
||||
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: ${messageId}; Found: ${selector.type}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.keys(configValue.placeables).includes(
|
||||
selector.name,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found unexpected placeable name \`${selector.name}\` for key: ${messageId}`,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "var": {
|
||||
if (
|
||||
!Object.keys(configValue.placeables).includes(
|
||||
element.name,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found unexpected placeable name \`${element.name}\` for key: ${messageId}`,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "term":
|
||||
case "mesg":
|
||||
case "func":
|
||||
case "str":
|
||||
case "num": {
|
||||
break; // ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allVariantsRes = computeAllVariants(pattern);
|
||||
if (allVariantsRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Failed to compute variants for key \`${messageId}\`:\n${allVariantsRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allVariants = allVariantsRes.ok;
|
||||
|
||||
// create function
|
||||
const markdown = configValue.markdown;
|
||||
|
||||
if (markdown !== null) {
|
||||
// typecheck markdown
|
||||
|
||||
const markdownSlots = markdown.slots ?? [];
|
||||
|
||||
// check if all variants are valid markdown
|
||||
for (const variant of allVariants) {
|
||||
const markdownLiteralRes = parseMessageLiteral(
|
||||
"md",
|
||||
variant.string,
|
||||
);
|
||||
|
||||
if (markdownLiteralRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${messageId}\`:\n${markdownLiteralRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const markdownLiteral = markdownLiteralRes.ok;
|
||||
const markdownRes = parseMarkdown(markdownLiteral, markdownSlots);
|
||||
|
||||
if (markdownRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid markdown for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${messageId}\`:\n${markdownRes.err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||
return {
|
||||
type: "ok",
|
||||
ok: (args: Record<string, FluentVariable> = {}) => {
|
||||
const markdownLiteralRes = parseMessageLiteral(
|
||||
"md",
|
||||
bundle.formatPattern(pattern, args),
|
||||
);
|
||||
|
||||
if (markdownLiteralRes.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid markdown above
|
||||
throw new Error(
|
||||
`Failed to parse markdown literal after compilation!\n${markdownLiteralRes.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
const markdownLiteral = markdownLiteralRes.ok;
|
||||
const res = parseMarkdown(markdownLiteral, markdownSlots);
|
||||
|
||||
if (res.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid markdown above
|
||||
throw new Error(
|
||||
`Failed to parse markdown after compilation!\n${res.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.ok;
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// typecheck string
|
||||
// check if all variants are valid markdown
|
||||
for (const variant of allVariants) {
|
||||
const stringLiteralRes = parseMessageLiteral(
|
||||
"string",
|
||||
variant.string,
|
||||
);
|
||||
|
||||
if (stringLiteralRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${messageId}\`:\n${stringLiteralRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const stringLiteral = stringLiteralRes.ok;
|
||||
const stringRes = parseString(stringLiteral);
|
||||
if (stringRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid string for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${messageId}\`:\n${stringRes.err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||
return {
|
||||
type: "ok",
|
||||
ok: (args: Record<string, FluentVariable> = {}) => {
|
||||
const stringLiteralRes = parseMessageLiteral(
|
||||
"string",
|
||||
bundle.formatPattern(pattern, args),
|
||||
);
|
||||
|
||||
if (stringLiteralRes.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid strings above
|
||||
throw new Error(
|
||||
`Failed to parse string literal after compilation!\n${stringLiteralRes.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
const stringLiteral = stringLiteralRes.ok;
|
||||
|
||||
const res = parseString(stringLiteral);
|
||||
|
||||
if (res.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid strings above
|
||||
// TODO: no we dont, do that
|
||||
throw new Error(
|
||||
`Failed to parse string after compilation!\n${res.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.ok;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface CompileSublocaleCtx<Subconfig extends LocaleConfig> {
|
||||
subconfig: Subconfig;
|
||||
recordSublocale: L10nRecord | undefined;
|
||||
fallbackSublocale: InferLocale<Subconfig> | undefined;
|
||||
errors: string[];
|
||||
bundle: FluentBundle;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
function compileSublocale<Subconfig extends LocaleConfig>(
|
||||
ctx: CompileSublocaleCtx<Subconfig>,
|
||||
): InferLocale<Subconfig> {
|
||||
const {
|
||||
subconfig: configValue,
|
||||
recordSublocale: recordValue,
|
||||
fallbackSublocale: fallbackValue,
|
||||
errors,
|
||||
bundle,
|
||||
messageId,
|
||||
} = ctx;
|
||||
|
||||
const subrecord = recordValue;
|
||||
const compiledSubrecordRes = compileLocale({
|
||||
bundle,
|
||||
record: subrecord,
|
||||
config: configValue,
|
||||
fallback: fallbackValue,
|
||||
});
|
||||
|
||||
errors.push(
|
||||
...compiledSubrecordRes.errors.map(
|
||||
(err) =>
|
||||
`Error when compiling subrecord with key: \`${messageId}\`:\n${err}`,
|
||||
),
|
||||
);
|
||||
|
||||
return compiledSubrecordRes.locale;
|
||||
}
|
||||
|
||||
function parseMessageLiteral(
|
||||
type: "string" | "md",
|
||||
message: string,
|
||||
): Result<string, string> {
|
||||
const trimmedMessage = message.trim();
|
||||
|
||||
const maybeStartIndexes: number[] = [];
|
||||
const firstQuoteIndex = trimmedMessage.indexOf('"');
|
||||
if (firstQuoteIndex !== -1) {
|
||||
maybeStartIndexes.push(firstQuoteIndex);
|
||||
}
|
||||
|
||||
const firstDashIndex = trimmedMessage.indexOf("-");
|
||||
if (firstDashIndex !== -1) {
|
||||
maybeStartIndexes.push(firstDashIndex);
|
||||
}
|
||||
|
||||
const stringStartIndex = Math.min(...maybeStartIndexes);
|
||||
|
||||
const actualPrefix = trimmedMessage.substring(0, stringStartIndex).trim();
|
||||
const expectedPrefix = (() => {
|
||||
switch (type) {
|
||||
case "string": {
|
||||
return "";
|
||||
}
|
||||
case "md": {
|
||||
return "md";
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (actualPrefix !== expectedPrefix) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected prefix "${expectedPrefix}" for message with type \`${type}\`; Found: "${actualPrefix}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: trimmedMessage.substring(actualPrefix.length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseString(message: string): Result<string, string> {
|
||||
const AFFIX = '"';
|
||||
if (!message.startsWith(AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `String message expected to start with \`${AFFIX}\``,
|
||||
};
|
||||
}
|
||||
|
||||
if (!message.endsWith(AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `String message expected to end with \`${AFFIX}\``,
|
||||
};
|
||||
}
|
||||
|
||||
const deprefixed = message.substring(AFFIX.length);
|
||||
const dequoted = deprefixed.substring(0, deprefixed.length - AFFIX.length);
|
||||
return { type: "ok", ok: dequoted };
|
||||
}
|
||||
|
|
@ -1,18 +1,8 @@
|
|||
import type { Result } from "@/utils/types";
|
||||
import {
|
||||
computeAllVariants,
|
||||
selectionChainToString,
|
||||
type UncompiledLocale,
|
||||
} from "./setup";
|
||||
import type { FluentVariable } from "@fluent/bundle";
|
||||
import { parseMarkdown, type Markdown } from "./markdown";
|
||||
import { ignore } from "@/utils/ignore";
|
||||
import { type Markdown } from "./markdown";
|
||||
|
||||
export const configMessageSymbol: unique symbol = Symbol("configMessage");
|
||||
export interface ConfigMessage<
|
||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = {
|
||||
[name in string]: ConfigPlaceableInfo;
|
||||
},
|
||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
||||
Markdown extends
|
||||
ConfigMarkdown<string> | null = ConfigMarkdown<string> | null,
|
||||
> {
|
||||
|
|
@ -109,364 +99,3 @@ export function record<const Key extends PropertyKey, T>(
|
|||
// SAFETY: we set all properties from keys array above
|
||||
return obj as Record<Key, T>;
|
||||
}
|
||||
|
||||
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)
|
||||
| ((args: Record<string, FluentVariable>) => Markdown);
|
||||
};
|
||||
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allVariantsRes = computeAllVariants(pattern);
|
||||
if (allVariantsRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Failed to compute variants for key \`${id}\`:\n${allVariantsRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allVariants = allVariantsRes.ok;
|
||||
console.log(allVariants);
|
||||
|
||||
// create function
|
||||
const markdown = configValue.markdown;
|
||||
const compiledFnRes = ((): Result<
|
||||
| ((args?: Record<string, FluentVariable>) => string)
|
||||
| ((args?: Record<string, FluentVariable>) => Markdown),
|
||||
string
|
||||
> => {
|
||||
if (markdown !== null) {
|
||||
// typecheck markdown
|
||||
|
||||
const markdownSlots = markdown.slots ?? [];
|
||||
|
||||
// check if all variants are valid markdown
|
||||
for (const variant of allVariants) {
|
||||
const markdownLiteralRes = parseMessageLiteral(
|
||||
"md",
|
||||
variant.string,
|
||||
);
|
||||
|
||||
if (markdownLiteralRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${id}\`:\n${markdownLiteralRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const markdownLiteral = markdownLiteralRes.ok;
|
||||
const markdownRes = parseMarkdown(
|
||||
markdownLiteral,
|
||||
markdownSlots,
|
||||
);
|
||||
console.log(markdownRes);
|
||||
if (markdownRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid markdown for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${id}\`:\n${markdownRes.err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||
return {
|
||||
type: "ok",
|
||||
ok: (args: Record<string, FluentVariable> = {}) => {
|
||||
const markdownLiteralRes = parseMessageLiteral(
|
||||
"md",
|
||||
bundle.formatPattern(pattern, args),
|
||||
);
|
||||
|
||||
if (markdownLiteralRes.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid markdown above
|
||||
throw new Error(
|
||||
`Failed to parse markdown literal after compilation!\n${markdownLiteralRes.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
const markdownLiteral = markdownLiteralRes.ok;
|
||||
const res = parseMarkdown(
|
||||
markdownLiteral,
|
||||
markdownSlots,
|
||||
);
|
||||
|
||||
if (res.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid markdown above
|
||||
throw new Error(
|
||||
`Failed to parse markdown after compilation!\n${res.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.ok;
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// typecheck string
|
||||
// check if all variants are valid markdown
|
||||
for (const variant of allVariants) {
|
||||
const stringLiteralRes = parseMessageLiteral(
|
||||
"string",
|
||||
variant.string,
|
||||
);
|
||||
|
||||
if (stringLiteralRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${id}\`:\n${stringLiteralRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const stringLiteral = stringLiteralRes.ok;
|
||||
const stringRes = parseString(stringLiteral);
|
||||
console.log(stringRes);
|
||||
if (stringRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid string for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${id}\`:\n${stringRes.err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||
return {
|
||||
type: "ok",
|
||||
ok: (args: Record<string, FluentVariable> = {}) => {
|
||||
const stringLiteralRes = parseMessageLiteral(
|
||||
"string",
|
||||
bundle.formatPattern(pattern, args),
|
||||
);
|
||||
|
||||
if (stringLiteralRes.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid strings above
|
||||
throw new Error(
|
||||
`Failed to parse string literal after compilation!\n${stringLiteralRes.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
const stringLiteral = stringLiteralRes.ok;
|
||||
|
||||
const res = parseString(stringLiteral);
|
||||
|
||||
if (res.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid strings above
|
||||
// TODO: no we dont, do that
|
||||
throw new Error(
|
||||
`Failed to parse string after compilation!\n${res.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.ok;
|
||||
},
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
if (compiledFnRes.type === "err") {
|
||||
return compiledFnRes;
|
||||
}
|
||||
|
||||
const compiledFn = compiledFnRes.ok;
|
||||
locale[id] = compiledFn;
|
||||
} 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> };
|
||||
}
|
||||
|
||||
function parseMessageLiteral(
|
||||
type: "string" | "md",
|
||||
message: string,
|
||||
): Result<string, string> {
|
||||
const trimmedMessage = message.trim();
|
||||
|
||||
console.log(trimmedMessage);
|
||||
|
||||
const maybeStartIndexes: number[] = [];
|
||||
const firstQuoteIndex = trimmedMessage.indexOf('"');
|
||||
if (firstQuoteIndex !== -1) {
|
||||
maybeStartIndexes.push(firstQuoteIndex);
|
||||
}
|
||||
|
||||
const firstDashIndex = trimmedMessage.indexOf("-");
|
||||
if (firstDashIndex !== -1) {
|
||||
maybeStartIndexes.push(firstDashIndex);
|
||||
}
|
||||
|
||||
const stringStartIndex = Math.min(...maybeStartIndexes);
|
||||
|
||||
console.log(stringStartIndex);
|
||||
const actualPrefix = trimmedMessage.substring(0, stringStartIndex).trim();
|
||||
const expectedPrefix = (() => {
|
||||
switch (type) {
|
||||
case "string": {
|
||||
return "";
|
||||
}
|
||||
case "md": {
|
||||
return "md";
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (actualPrefix !== expectedPrefix) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected prefix "${expectedPrefix}" for message with type \`${type}\`; Found: "${actualPrefix}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: trimmedMessage.substring(actualPrefix.length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseString(message: string): Result<string, string> {
|
||||
const AFFIX = '"';
|
||||
if (!message.startsWith(AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `String message expected to start with \`${AFFIX}\``,
|
||||
};
|
||||
}
|
||||
|
||||
if (!message.endsWith(AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `String message expected to end with \`${AFFIX}\``,
|
||||
};
|
||||
}
|
||||
|
||||
const deprefixed = message.substring(AFFIX.length);
|
||||
const dequoted = deprefixed.substring(0, deprefixed.length - AFFIX.length);
|
||||
return { type: "ok", ok: dequoted };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import type { Result } from "@/utils/types";
|
||||
import {
|
||||
FluentBundle,
|
||||
FluentResource,
|
||||
type FluentVariable,
|
||||
} from "@fluent/bundle";
|
||||
import { FluentBundle, FluentResource } from "@fluent/bundle";
|
||||
import { unsafeAsync } from "@/utils/unsafe";
|
||||
import type { Literal, Message, Pattern } from "@fluent/bundle/esm/ast";
|
||||
|
||||
|
|
@ -26,11 +22,8 @@ export async function loadFluentBundle(
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -129,7 +122,6 @@ export function computeAllVariants(
|
|||
}
|
||||
|
||||
let variants: PatternVariant[] = [{ selectionChain: [], string: "" }];
|
||||
console.log(pattern);
|
||||
for (const element of pattern) {
|
||||
if (typeof element === "string") {
|
||||
variants = variants.map((variant) => ({
|
||||
|
|
|
|||
79
apps/vdn-static/src/new-i18n-lib/spec.md
Normal file
79
apps/vdn-static/src/new-i18n-lib/spec.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Viossa I18n Message Spec
|
||||
|
||||
## Types & Literals
|
||||
There are two types of messages, `string` & `markdown`. Each type is specified by a prefix:
|
||||
- `string` literal (no prefix): `"Hello world!"`
|
||||
- `markdown` literal (`md` prefix): `md "Hello world!"`
|
||||
|
||||
Message literals are made up of lines. Each line is surrounded by quotes.
|
||||
|
||||
## String Literals
|
||||
String literals take exactly one line. They have no special formatting or behavior, exactly what is in the string will be what is displayed:
|
||||
- `"Hello world!"` => Hello world!
|
||||
- `"123 *456* **789**"` => 123 \*456\* \*\*789\*\*
|
||||
|
||||
## Markdown Literals
|
||||
Markdown literals can take any number of lines. Lines are separated by newline characters.
|
||||
```
|
||||
example-markdownMessage = md
|
||||
"Line 1"
|
||||
"Line 2"
|
||||
"Line 3"
|
||||
```
|
||||
|
||||
They can also consist of a single line:
|
||||
```
|
||||
example-markdownMessage = md "Line 1"
|
||||
```
|
||||
|
||||
A special sigil exists for denoting that no lines exist:
|
||||
```
|
||||
example-markdownMessage = md --
|
||||
```
|
||||
|
||||
### Line Types
|
||||
- Paragraph: `Example`
|
||||
- Header: `# Example` (subheaders are not supported)
|
||||
- Unordered List Item: `- Example`
|
||||
|
||||
### Line Features
|
||||
- Italic: `*Example*` => *Example*
|
||||
- Bold: `**Example**` => **Example**
|
||||
- Bold + Italic: `***Example***` => ***Example***
|
||||
- Links: `[Example](external.new:https://example.com/)` => [Example](https://example.com/)
|
||||
- Slots: `<example>`
|
||||
|
||||
Characters used for line feature syntax can be escaped to remove their effect and place the raw character in the string: `\*Example\*` => \*Example\*
|
||||
|
||||
### Links
|
||||
Links are made up of 4 components:
|
||||
```
|
||||
[Example](external.new:https://example.com/)
|
||||
^^^^^^^ ^^^^^^^^ ^^^ ^^^^^^^^^^^^^^^^^^^^
|
||||
name type tab destination
|
||||
```
|
||||
|
||||
`name` is the text displayed to the user on the webpage. It is optional; If blank, it will display the destination directly to the user.
|
||||
|
||||
`type` can be either `internal` or `external`, and changes what is deemed a valid `destination`.
|
||||
|
||||
If `tab` is `new`, the link will open the `destination` in a new tab. If `tab` is `replace`, it will open in the current tab.
|
||||
|
||||
`destination` is where the link will take the user when clicked. Its value depends on the value of `type` as follows:
|
||||
- If `type` is `external`: `destination` is any link starting with `http://` or `https://`
|
||||
- `http://example.com/`
|
||||
- `https://google.com/`
|
||||
- `https://viossa.net/`
|
||||
- If `type` is `internal`: `destination` consists of a `route` and `id`, in any of the following patterns:
|
||||
- `route`-only: brings the user to another route on the website
|
||||
- `/`
|
||||
- `/resources`
|
||||
- `/kotoba`
|
||||
- `/discord/rules`
|
||||
- `id`-only: jumps the user to a specific element ID on the current route
|
||||
- `#top`
|
||||
- `#header`
|
||||
- `#rule-1`
|
||||
- `route` with `id`: brings the user to another route and jumps to an element ID on that page
|
||||
- `/discord/rules#rule-1`
|
||||
- `/#top`
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { compileLocale, type InferLocale } from "@/new-i18n-lib/config";
|
||||
import { type InferLocale } from "@/new-i18n-lib/config";
|
||||
import {
|
||||
bundleToUncompiledLocale,
|
||||
loadFluentBundle,
|
||||
|
|
@ -12,6 +12,7 @@ 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";
|
||||
import { compileLocale } from "@/new-i18n-lib/compile";
|
||||
|
||||
export const LOCALE_IDS = ["en-US", "vp-VL", "wp-VL"] as const;
|
||||
|
||||
|
|
@ -61,28 +62,31 @@ async function loadLocale(
|
|||
return { type: "ok", ok: bundle };
|
||||
}
|
||||
|
||||
interface SetupLocaleFallback {
|
||||
bundle: FluentBundle;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
function setupLocale(
|
||||
localeBundle: FluentBundle,
|
||||
fallbackLocaleBundle: FluentBundle | undefined,
|
||||
fallback: SetupLocaleFallback | undefined,
|
||||
): Result<Locale, string> {
|
||||
const fallbackBundle = fallback?.bundle;
|
||||
const fallbackLocale = fallback?.locale;
|
||||
|
||||
const maybeFallbackedBundle = (() => {
|
||||
if (fallbackLocaleBundle === undefined) {
|
||||
if (fallbackBundle === undefined) {
|
||||
return localeBundle;
|
||||
}
|
||||
|
||||
console.log(localeBundle);
|
||||
console.log(fallbackLocaleBundle);
|
||||
|
||||
const localeMessageIds = new Set(localeBundle._messages.keys());
|
||||
const fallbackMessageIds = new Set(
|
||||
fallbackLocaleBundle._messages.keys(),
|
||||
);
|
||||
const fallbackMessageIds = new Set(fallbackBundle._messages.keys());
|
||||
|
||||
const missingMessageIds =
|
||||
fallbackMessageIds.difference(localeMessageIds);
|
||||
|
||||
for (const id of missingMessageIds) {
|
||||
const fallbackMessage = fallbackLocaleBundle._messages.get(id);
|
||||
const fallbackMessage = fallbackBundle._messages.get(id);
|
||||
if (fallbackMessage) {
|
||||
localeBundle._messages.set(id, fallbackMessage);
|
||||
}
|
||||
|
|
@ -98,13 +102,16 @@ function setupLocale(
|
|||
|
||||
const uncompiled = uncompiledRes.ok;
|
||||
|
||||
const localeRes = compileLocale({ config: localeConfig, uncompiled });
|
||||
const localeRes = compileLocale({
|
||||
config: localeConfig,
|
||||
bundle: uncompiled.bundle,
|
||||
record: uncompiled.record,
|
||||
fallback: fallbackLocale,
|
||||
});
|
||||
|
||||
if (localeRes.type === "err") {
|
||||
return localeRes;
|
||||
}
|
||||
console.error(localeRes.errors);
|
||||
|
||||
const locale = localeRes.ok;
|
||||
const locale = localeRes.locale;
|
||||
return { type: "ok", ok: locale };
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +131,8 @@ function deepReadonly<T>(value: T): DeepReadonly<T> {
|
|||
return value as DeepReadonly<T>;
|
||||
}
|
||||
|
||||
const DEFAULT_LOCALE = unwrap(await loadLocale("en-US", enUsFtlSrc));
|
||||
const DEFAULT_LOCALE_BUNDLE = unwrap(await loadLocale("en-US", enUsFtlSrc));
|
||||
const DEFAULT_LOCALE = unwrap(setupLocale(DEFAULT_LOCALE_BUNDLE, undefined));
|
||||
|
||||
const doItAllForLocale = async (
|
||||
localeId: LocaleId,
|
||||
|
|
@ -132,10 +140,10 @@ const doItAllForLocale = async (
|
|||
): Promise<DeepReadonly<Locale>> =>
|
||||
deepReadonly(
|
||||
unwrap(
|
||||
setupLocale(
|
||||
unwrap(await loadLocale(localeId, localeFtlSrc)),
|
||||
DEFAULT_LOCALE,
|
||||
),
|
||||
setupLocale(unwrap(await loadLocale(localeId, localeFtlSrc)), {
|
||||
bundle: DEFAULT_LOCALE_BUNDLE,
|
||||
locale: DEFAULT_LOCALE,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -145,7 +153,7 @@ const [vpVl, wpVl] = await Promise.all([
|
|||
]);
|
||||
|
||||
const localeIdToLocale = {
|
||||
"en-US": deepReadonly(unwrap(setupLocale(DEFAULT_LOCALE, DEFAULT_LOCALE))),
|
||||
"en-US": deepReadonly(DEFAULT_LOCALE),
|
||||
"vp-VL": vpVl,
|
||||
"wp-VL": wpVl,
|
||||
} as const satisfies Record<LocaleId, DeepReadonly<Locale>>;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue