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 { type Markdown } from "./markdown";
|
||||||
import {
|
|
||||||
computeAllVariants,
|
|
||||||
selectionChainToString,
|
|
||||||
type UncompiledLocale,
|
|
||||||
} from "./setup";
|
|
||||||
import type { FluentVariable } from "@fluent/bundle";
|
|
||||||
import { parseMarkdown, type Markdown } from "./markdown";
|
|
||||||
import { ignore } from "@/utils/ignore";
|
|
||||||
|
|
||||||
export const configMessageSymbol: unique symbol = Symbol("configMessage");
|
export const configMessageSymbol: unique symbol = Symbol("configMessage");
|
||||||
export interface ConfigMessage<
|
export interface ConfigMessage<
|
||||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = {
|
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
||||||
[name in string]: ConfigPlaceableInfo;
|
|
||||||
},
|
|
||||||
Markdown extends
|
Markdown extends
|
||||||
ConfigMarkdown<string> | null = ConfigMarkdown<string> | null,
|
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
|
// SAFETY: we set all properties from keys array above
|
||||||
return obj as Record<Key, T>;
|
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 type { Result } from "@/utils/types";
|
||||||
import {
|
import { FluentBundle, FluentResource } from "@fluent/bundle";
|
||||||
FluentBundle,
|
|
||||||
FluentResource,
|
|
||||||
type FluentVariable,
|
|
||||||
} from "@fluent/bundle";
|
|
||||||
import { unsafeAsync } from "@/utils/unsafe";
|
import { unsafeAsync } from "@/utils/unsafe";
|
||||||
import type { Literal, Message, Pattern } from "@fluent/bundle/esm/ast";
|
import type { Literal, Message, Pattern } from "@fluent/bundle/esm/ast";
|
||||||
|
|
||||||
|
|
@ -26,11 +22,8 @@ export async function loadFluentBundle(
|
||||||
}
|
}
|
||||||
|
|
||||||
const ftlFileText = ftlFileTextRes.ok;
|
const ftlFileText = ftlFileTextRes.ok;
|
||||||
console.log(ftlFileText);
|
|
||||||
const resource = new FluentResource(ftlFileText);
|
const resource = new FluentResource(ftlFileText);
|
||||||
|
|
||||||
console.log(resource.body);
|
|
||||||
|
|
||||||
const bundle = new FluentBundle(localeId);
|
const bundle = new FluentBundle(localeId);
|
||||||
const errors = bundle.addResource(resource);
|
const errors = bundle.addResource(resource);
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
|
|
@ -129,7 +122,6 @@ export function computeAllVariants(
|
||||||
}
|
}
|
||||||
|
|
||||||
let variants: PatternVariant[] = [{ selectionChain: [], string: "" }];
|
let variants: PatternVariant[] = [{ selectionChain: [], string: "" }];
|
||||||
console.log(pattern);
|
|
||||||
for (const element of pattern) {
|
for (const element of pattern) {
|
||||||
if (typeof element === "string") {
|
if (typeof element === "string") {
|
||||||
variants = variants.map((variant) => ({
|
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 {
|
import {
|
||||||
bundleToUncompiledLocale,
|
bundleToUncompiledLocale,
|
||||||
loadFluentBundle,
|
loadFluentBundle,
|
||||||
|
|
@ -12,6 +12,7 @@ import enUsFtlSrc from "@/assets/locale/en_US.ftl";
|
||||||
import vpVlFtlSrc from "@/assets/locale/vp_VL.ftl";
|
import vpVlFtlSrc from "@/assets/locale/vp_VL.ftl";
|
||||||
import wpVlFtlSrc from "@/assets/locale/wp_VL.ftl";
|
import wpVlFtlSrc from "@/assets/locale/wp_VL.ftl";
|
||||||
import type { FluentBundle } from "@fluent/bundle";
|
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;
|
export const LOCALE_IDS = ["en-US", "vp-VL", "wp-VL"] as const;
|
||||||
|
|
||||||
|
|
@ -61,28 +62,31 @@ async function loadLocale(
|
||||||
return { type: "ok", ok: bundle };
|
return { type: "ok", ok: bundle };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SetupLocaleFallback {
|
||||||
|
bundle: FluentBundle;
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
|
||||||
function setupLocale(
|
function setupLocale(
|
||||||
localeBundle: FluentBundle,
|
localeBundle: FluentBundle,
|
||||||
fallbackLocaleBundle: FluentBundle | undefined,
|
fallback: SetupLocaleFallback | undefined,
|
||||||
): Result<Locale, string> {
|
): Result<Locale, string> {
|
||||||
|
const fallbackBundle = fallback?.bundle;
|
||||||
|
const fallbackLocale = fallback?.locale;
|
||||||
|
|
||||||
const maybeFallbackedBundle = (() => {
|
const maybeFallbackedBundle = (() => {
|
||||||
if (fallbackLocaleBundle === undefined) {
|
if (fallbackBundle === undefined) {
|
||||||
return localeBundle;
|
return localeBundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(localeBundle);
|
|
||||||
console.log(fallbackLocaleBundle);
|
|
||||||
|
|
||||||
const localeMessageIds = new Set(localeBundle._messages.keys());
|
const localeMessageIds = new Set(localeBundle._messages.keys());
|
||||||
const fallbackMessageIds = new Set(
|
const fallbackMessageIds = new Set(fallbackBundle._messages.keys());
|
||||||
fallbackLocaleBundle._messages.keys(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const missingMessageIds =
|
const missingMessageIds =
|
||||||
fallbackMessageIds.difference(localeMessageIds);
|
fallbackMessageIds.difference(localeMessageIds);
|
||||||
|
|
||||||
for (const id of missingMessageIds) {
|
for (const id of missingMessageIds) {
|
||||||
const fallbackMessage = fallbackLocaleBundle._messages.get(id);
|
const fallbackMessage = fallbackBundle._messages.get(id);
|
||||||
if (fallbackMessage) {
|
if (fallbackMessage) {
|
||||||
localeBundle._messages.set(id, fallbackMessage);
|
localeBundle._messages.set(id, fallbackMessage);
|
||||||
}
|
}
|
||||||
|
|
@ -98,13 +102,16 @@ function setupLocale(
|
||||||
|
|
||||||
const uncompiled = uncompiledRes.ok;
|
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") {
|
console.error(localeRes.errors);
|
||||||
return localeRes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const locale = localeRes.ok;
|
const locale = localeRes.locale;
|
||||||
return { type: "ok", ok: locale };
|
return { type: "ok", ok: locale };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +131,8 @@ function deepReadonly<T>(value: T): DeepReadonly<T> {
|
||||||
return value as 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 (
|
const doItAllForLocale = async (
|
||||||
localeId: LocaleId,
|
localeId: LocaleId,
|
||||||
|
|
@ -132,10 +140,10 @@ const doItAllForLocale = async (
|
||||||
): Promise<DeepReadonly<Locale>> =>
|
): Promise<DeepReadonly<Locale>> =>
|
||||||
deepReadonly(
|
deepReadonly(
|
||||||
unwrap(
|
unwrap(
|
||||||
setupLocale(
|
setupLocale(unwrap(await loadLocale(localeId, localeFtlSrc)), {
|
||||||
unwrap(await loadLocale(localeId, localeFtlSrc)),
|
bundle: DEFAULT_LOCALE_BUNDLE,
|
||||||
DEFAULT_LOCALE,
|
locale: DEFAULT_LOCALE,
|
||||||
),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -145,7 +153,7 @@ const [vpVl, wpVl] = await Promise.all([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const localeIdToLocale = {
|
const localeIdToLocale = {
|
||||||
"en-US": deepReadonly(unwrap(setupLocale(DEFAULT_LOCALE, DEFAULT_LOCALE))),
|
"en-US": deepReadonly(DEFAULT_LOCALE),
|
||||||
"vp-VL": vpVl,
|
"vp-VL": vpVl,
|
||||||
"wp-VL": wpVl,
|
"wp-VL": wpVl,
|
||||||
} as const satisfies Record<LocaleId, DeepReadonly<Locale>>;
|
} as const satisfies Record<LocaleId, DeepReadonly<Locale>>;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import { routes, handleHotUpdate } from "vue-router/auto-routes";
|
import { routes, handleHotUpdate } from "vue-router/auto-routes";
|
||||||
|
|
||||||
console.log(routes);
|
|
||||||
|
|
||||||
const router = createRouter({ history: createWebHistory(), routes });
|
const router = createRouter({ history: createWebHistory(), routes });
|
||||||
|
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue