wip: i18n compiler organization/improvements, separated string & markdown messages in config builder
This commit is contained in:
parent
44ab34133c
commit
17e1c34a3b
6 changed files with 384 additions and 268 deletions
|
|
@ -48,6 +48,10 @@ export default defineConfig([
|
||||||
{ allowInterfaces: "with-single-extends" },
|
{ allowInterfaces: "with-single-extends" },
|
||||||
],
|
],
|
||||||
"vue/no-ref-object-reactivity-loss": ["error"],
|
"vue/no-ref-object-reactivity-loss": ["error"],
|
||||||
|
"@typescript/no-unnecessary-conditions": [
|
||||||
|
"error",
|
||||||
|
{ allowConstantLoopConditions: "only-allowed-literals" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// disable multi-word-component-names for unplugin-vue-router
|
// disable multi-word-component-names for unplugin-vue-router
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,42 @@ import type { Result, Value } from "@/utils/types";
|
||||||
import {
|
import {
|
||||||
computeAllVariants,
|
computeAllVariants,
|
||||||
selectionChainToString,
|
selectionChainToString,
|
||||||
type L10nRecord,
|
type PatternVariant,
|
||||||
type UncompiledLocale,
|
type UncompiledLocale,
|
||||||
} from "./setup";
|
} from "./setup";
|
||||||
import type { FluentBundle, FluentVariable } from "@fluent/bundle";
|
import type { FluentBundle, FluentVariable, Message } from "@fluent/bundle";
|
||||||
import { parseMarkdown, type Markdown } from "./markdown";
|
import { parseMarkdown, type Markdown } from "./markdown";
|
||||||
import {
|
import {
|
||||||
configMessageSymbol,
|
configMarkdownSymbol,
|
||||||
type ConfigMessage,
|
configMessageTypeSymbol,
|
||||||
type InferLocale,
|
configStringSymbol,
|
||||||
|
type ConfigMarkdown,
|
||||||
|
type ConfigString,
|
||||||
|
type InferLocaleFromConfig,
|
||||||
type LocaleConfig,
|
type LocaleConfig,
|
||||||
} from "./config";
|
} from "./config";
|
||||||
|
import type { Pattern } from "@fluent/bundle/esm/ast";
|
||||||
|
|
||||||
|
type GenericLocale = { [id: string]: GenericLocale | GenericMessageFn };
|
||||||
|
|
||||||
|
type GenericMessageFn = GenericStringMessageFn | GenericMarkdownMessageFn;
|
||||||
|
|
||||||
|
type GenericStringMessageFn = (
|
||||||
|
placeableArgs?: GenericMessageFnPlaceableArgs,
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
type GenericMarkdownMessageFn = (
|
||||||
|
placeableArgs?: GenericMessageFnPlaceableArgs,
|
||||||
|
) => Markdown;
|
||||||
|
|
||||||
|
type GenericMessageFnPlaceableArgs = Record<string, FluentVariable>;
|
||||||
|
|
||||||
export interface CompileLocaleCtx<Config extends LocaleConfig> {
|
export interface CompileLocaleCtx<Config extends LocaleConfig> {
|
||||||
bundle: FluentBundle;
|
bundle: FluentBundle;
|
||||||
record: L10nRecord | undefined;
|
uncompiled: UncompiledLocale;
|
||||||
config: Config;
|
config: Config;
|
||||||
fallback?: InferLocale<Config>;
|
fallback: InferLocaleFromConfig<Config> | undefined;
|
||||||
|
messageIdChain: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompileLocaleRes<Locale> {
|
export interface CompileLocaleRes<Locale> {
|
||||||
|
|
@ -26,81 +45,98 @@ export interface CompileLocaleRes<Locale> {
|
||||||
errors: string[];
|
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>(
|
export function compileLocale<Config extends LocaleConfig>(
|
||||||
ctx: CompileLocaleCtx<Config>,
|
ctx: CompileLocaleCtx<Config>,
|
||||||
): CompileLocaleRes<InferLocale<Config>> {
|
): CompileLocaleRes<InferLocaleFromConfig<Config>> {
|
||||||
const { bundle, record, config, fallback } = ctx;
|
const {
|
||||||
|
bundle,
|
||||||
|
uncompiled,
|
||||||
|
config,
|
||||||
|
fallback,
|
||||||
|
messageIdChain: localeMessageIdChain = [],
|
||||||
|
} = ctx;
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
const recordKeys = new Set(Object.keys(record ?? {}));
|
const uncompiledKeys = new Set(Object.keys(uncompiled ?? {}));
|
||||||
const configKeys = new Set(Object.keys(config));
|
const configKeys = new Set(Object.keys(config));
|
||||||
const excessKeys = recordKeys.difference(configKeys);
|
const excessKeys = uncompiledKeys.difference(configKeys);
|
||||||
if (excessKeys.size > 0) {
|
if (excessKeys.size > 0) {
|
||||||
errors.push(`Excess keys in record: ${[...excessKeys].join(", ")}`);
|
errors.push(`Excess keys in record: ${[...excessKeys].join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const locale: GenericLocale = {};
|
const locale: GenericLocale = {};
|
||||||
for (const [messageId, configValue] of Object.entries(config)) {
|
for (const [messageId, configValue] of Object.entries(config)) {
|
||||||
const recordValue = record?.[messageId];
|
const uncompiledValue = uncompiled?.[messageId];
|
||||||
const fallbackValue = fallback?.[messageId];
|
const fallbackValue = fallback?.[messageId];
|
||||||
|
const valueMessageIdChain = [
|
||||||
|
...localeMessageIdChain,
|
||||||
|
messageId,
|
||||||
|
] as const;
|
||||||
|
|
||||||
const compiledValue = ((): Value<GenericLocale> => {
|
const compiledValue = ((): Value<GenericLocale> => {
|
||||||
if (configMessageSymbol in configValue) {
|
if (configMessageTypeSymbol in configValue) {
|
||||||
const compiledMessageRes = compileMessage({
|
const compiledMessage = (() => {
|
||||||
bundle,
|
if (uncompiledValue?.type !== "message") {
|
||||||
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(
|
errors.push(
|
||||||
`Expected subrecord for key \`${messageId}\`, found: ${typeof recordValue}`,
|
`Expected message for key \`${messageId}\`, found: ${typeof uncompiledValue}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return recordValue.subrecord;
|
const uncompiledMessage = uncompiledValue.message;
|
||||||
|
const compiledMessageRes = compileMessage({
|
||||||
|
bundle,
|
||||||
|
messageIdChain: valueMessageIdChain,
|
||||||
|
configValue,
|
||||||
|
uncompiledMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (compiledMessageRes.type === "err") {
|
||||||
|
errors.push(compiledMessageRes.err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compiledMessage = compiledMessageRes.ok;
|
||||||
|
return compiledMessage;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (compiledMessage !== undefined) {
|
||||||
|
return compiledMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
fallbackValue !== undefined
|
||||||
|
&& typeof fallbackValue === "function"
|
||||||
|
) {
|
||||||
|
return fallbackValue as GenericMessageFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => `[#${fmtMessageIdChain(valueMessageIdChain)}#]`;
|
||||||
|
} else {
|
||||||
|
const uncompiledSubrecord = (() => {
|
||||||
|
if (uncompiledValue?.type !== "subrecord") {
|
||||||
|
errors.push(
|
||||||
|
`Expected subrecord for key \`${messageId}\`, found: ${typeof uncompiledValue}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uncompiledValue.subrecord;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return compileSublocale({
|
return compileSublocale({
|
||||||
subconfig: configValue,
|
subconfig: configValue,
|
||||||
recordSublocale: subrecord,
|
uncompiledSublocale: uncompiledSubrecord,
|
||||||
fallbackSublocale:
|
fallbackSublocale:
|
||||||
typeof fallbackValue === "function" ? undefined : (
|
typeof fallbackValue === "function" ? undefined : (
|
||||||
fallbackValue
|
fallbackValue
|
||||||
),
|
),
|
||||||
errors,
|
errors,
|
||||||
bundle,
|
bundle,
|
||||||
messageId,
|
messageIdChain: valueMessageIdChain,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
@ -109,35 +145,32 @@ export function compileLocale<Config extends LocaleConfig>(
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: validated above that all keys exist and are the correct type
|
// SAFETY: validated above that all keys exist and are the correct type
|
||||||
return { locale: locale as InferLocale<Config>, errors };
|
return { locale: locale as InferLocaleFromConfig<Config>, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMessageIdChain(
|
||||||
|
messageIdChain: readonly [...string[], string],
|
||||||
|
): string {
|
||||||
|
return messageIdChain.join("-");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompileMessageCtx {
|
interface CompileMessageCtx {
|
||||||
bundle: FluentBundle;
|
bundle: FluentBundle;
|
||||||
messageId: string;
|
messageIdChain: readonly [...string[], string];
|
||||||
configValue: ConfigMessage;
|
configValue: ConfigString<object> | ConfigMarkdown<object, object>;
|
||||||
recordValue: Value<UncompiledLocale["record"]> | undefined;
|
uncompiledMessage: Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compileMessage(
|
function compileMessage(
|
||||||
ctx: CompileMessageCtx,
|
ctx: CompileMessageCtx,
|
||||||
): Result<GenericMessageFn, string> {
|
): Result<GenericMessageFn, string> {
|
||||||
const { bundle, messageId, configValue, recordValue } = ctx;
|
const { bundle, messageIdChain, configValue, uncompiledMessage } = ctx;
|
||||||
|
|
||||||
if (recordValue?.type !== "message") {
|
const pattern = uncompiledMessage.value;
|
||||||
return {
|
|
||||||
type: "err",
|
|
||||||
err: `Expected message for key \`${messageId}\`, found: ${typeof recordValue}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = recordValue.message;
|
|
||||||
|
|
||||||
const pattern = message.value;
|
|
||||||
if (pattern === null) {
|
if (pattern === null) {
|
||||||
return {
|
return {
|
||||||
type: "err",
|
type: "err",
|
||||||
err: `Pattern is null for message with ID: ${messageId}`,
|
err: `Pattern is null for message with ID: ${fmtMessageIdChain(messageIdChain)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,7 +187,7 @@ function compileMessage(
|
||||||
if (selector.type !== "var") {
|
if (selector.type !== "var") {
|
||||||
return {
|
return {
|
||||||
type: "err",
|
type: "err",
|
||||||
err: `Expected selector to be a var expression for key: ${messageId}; Found: ${selector.type}`,
|
err: `Expected selector to be a var expression for ID: ${fmtMessageIdChain(messageIdChain)}; Found: ${selector.type}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +198,7 @@ function compileMessage(
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
type: "err",
|
type: "err",
|
||||||
err: `Found unexpected placeable name \`${selector.name}\` for key: ${messageId}`,
|
err: `Found unexpected placeable name \`${selector.name}\` for ID: ${fmtMessageIdChain(messageIdChain)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,7 +212,7 @@ function compileMessage(
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
type: "err",
|
type: "err",
|
||||||
err: `Found unexpected placeable name \`${element.name}\` for key: ${messageId}`,
|
err: `Found unexpected placeable name \`${element.name}\` for ID: ${fmtMessageIdChain(messageIdChain)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,171 +233,215 @@ function compileMessage(
|
||||||
if (allVariantsRes.type === "err") {
|
if (allVariantsRes.type === "err") {
|
||||||
return {
|
return {
|
||||||
type: "err",
|
type: "err",
|
||||||
err: `Failed to compute variants for key \`${messageId}\`:\n${allVariantsRes.err}`,
|
err: `Failed to compute variants for ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${allVariantsRes.err}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const allVariants = allVariantsRes.ok;
|
const allVariants = allVariantsRes.ok;
|
||||||
|
|
||||||
// create function
|
switch (configValue[configMessageTypeSymbol]) {
|
||||||
const markdown = configValue.markdown;
|
case configStringSymbol: {
|
||||||
|
return compileStringMessage({
|
||||||
|
bundle,
|
||||||
|
messageIdChain,
|
||||||
|
allVariants,
|
||||||
|
pattern,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case configMarkdownSymbol: {
|
||||||
|
return compileMarkdownMessage({
|
||||||
|
bundle,
|
||||||
|
configMarkdown: configValue,
|
||||||
|
messageIdChain,
|
||||||
|
allVariants,
|
||||||
|
pattern,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (markdown !== null) {
|
interface CompileStringMessageCtx {
|
||||||
// typecheck markdown
|
bundle: FluentBundle;
|
||||||
|
messageIdChain: readonly [...string[], string];
|
||||||
|
allVariants: readonly PatternVariant[];
|
||||||
|
pattern: Pattern;
|
||||||
|
}
|
||||||
|
|
||||||
const markdownSlots = markdown.slots ?? [];
|
function compileStringMessage(
|
||||||
|
ctx: CompileStringMessageCtx,
|
||||||
|
): Result<GenericStringMessageFn, string> {
|
||||||
|
const { bundle, messageIdChain, allVariants, pattern } = ctx;
|
||||||
|
|
||||||
// check if all variants are valid markdown
|
// typecheck string
|
||||||
for (const variant of allVariants) {
|
// check if all variants are valid markdown
|
||||||
const markdownLiteralRes = parseMessageLiteral(
|
for (const variant of allVariants) {
|
||||||
"md",
|
const stringLiteralRes = parseMessageLiteral("string", variant.string);
|
||||||
variant.string,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (markdownLiteralRes.type === "err") {
|
if (stringLiteralRes.type === "err") {
|
||||||
return {
|
return {
|
||||||
type: "err",
|
type: "err",
|
||||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${messageId}\`:\n${markdownLiteralRes.err}`,
|
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringLiteralRes.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
|
const stringLiteral = stringLiteralRes.ok;
|
||||||
return {
|
const stringRes = parseString(stringLiteral);
|
||||||
type: "ok",
|
if (stringRes.type === "err") {
|
||||||
ok: (args: Record<string, FluentVariable> = {}) => {
|
return {
|
||||||
const markdownLiteralRes = parseMessageLiteral(
|
type: "err",
|
||||||
"md",
|
err: `Invalid string for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringRes.err}`,
|
||||||
bundle.formatPattern(pattern, args),
|
};
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (markdownLiteralRes.type === "err") {
|
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||||
// This should hopefully never happen since we've already
|
return {
|
||||||
// verified all message variants parse as valid markdown above
|
type: "ok",
|
||||||
throw new Error(
|
ok: (args: Record<string, FluentVariable> = {}) => {
|
||||||
`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(
|
const stringLiteralRes = parseMessageLiteral(
|
||||||
"string",
|
"string",
|
||||||
variant.string,
|
bundle.formatPattern(pattern, args),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (stringLiteralRes.type === "err") {
|
if (stringLiteralRes.type === "err") {
|
||||||
return {
|
// This should hopefully never happen since we've already
|
||||||
type: "err",
|
// verified all message variants parse as valid strings above
|
||||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${messageId}\`:\n${stringLiteralRes.err}`,
|
throw new Error(
|
||||||
};
|
`Failed to parse string literal after compilation!\n${stringLiteralRes.err}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stringLiteral = stringLiteralRes.ok;
|
const stringLiteral = stringLiteralRes.ok;
|
||||||
const stringRes = parseString(stringLiteral);
|
|
||||||
if (stringRes.type === "err") {
|
const res = parseString(stringLiteral);
|
||||||
return {
|
|
||||||
type: "err",
|
if (res.type === "err") {
|
||||||
err: `Invalid string for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${messageId}\`:\n${stringRes.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 CompileMarkdownMessageCtx {
|
||||||
|
bundle: FluentBundle;
|
||||||
|
messageIdChain: readonly [...string[], string];
|
||||||
|
configMarkdown: ConfigMarkdown<object, object>;
|
||||||
|
allVariants: readonly PatternVariant[];
|
||||||
|
pattern: Pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileMarkdownMessage(
|
||||||
|
ctx: CompileMarkdownMessageCtx,
|
||||||
|
): Result<GenericMarkdownMessageFn, string> {
|
||||||
|
const { bundle, messageIdChain, configMarkdown, allVariants, pattern } =
|
||||||
|
ctx;
|
||||||
|
|
||||||
|
// typecheck markdown
|
||||||
|
|
||||||
|
const markdownSlots = configMarkdown.features.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 ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${markdownLiteralRes.err}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
const markdownLiteral = markdownLiteralRes.ok;
|
||||||
return {
|
const markdownRes = parseMarkdown(
|
||||||
type: "ok",
|
markdownLiteral,
|
||||||
ok: (args: Record<string, FluentVariable> = {}) => {
|
Object.keys(markdownSlots),
|
||||||
const stringLiteralRes = parseMessageLiteral(
|
);
|
||||||
"string",
|
|
||||||
bundle.formatPattern(pattern, args),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (stringLiteralRes.type === "err") {
|
if (markdownRes.type === "err") {
|
||||||
// This should hopefully never happen since we've already
|
return {
|
||||||
// verified all message variants parse as valid strings above
|
type: "err",
|
||||||
throw new Error(
|
err: `Invalid markdown for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${markdownRes.err}`,
|
||||||
`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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
Object.keys(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;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompileSublocaleCtx<Subconfig extends LocaleConfig> {
|
interface CompileSublocaleCtx<Subconfig extends LocaleConfig> {
|
||||||
subconfig: Subconfig;
|
subconfig: Subconfig;
|
||||||
recordSublocale: L10nRecord | undefined;
|
uncompiledSublocale: UncompiledLocale | undefined;
|
||||||
fallbackSublocale: InferLocale<Subconfig> | undefined;
|
fallbackSublocale: InferLocaleFromConfig<Subconfig> | undefined;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
bundle: FluentBundle;
|
bundle: FluentBundle;
|
||||||
messageId: string;
|
messageIdChain: readonly [...string[], string];
|
||||||
}
|
}
|
||||||
|
|
||||||
function compileSublocale<Subconfig extends LocaleConfig>(
|
function compileSublocale<Subconfig extends LocaleConfig>(
|
||||||
ctx: CompileSublocaleCtx<Subconfig>,
|
ctx: CompileSublocaleCtx<Subconfig>,
|
||||||
): InferLocale<Subconfig> {
|
): InferLocaleFromConfig<Subconfig> {
|
||||||
const {
|
const {
|
||||||
subconfig: configValue,
|
subconfig: configValue,
|
||||||
recordSublocale: recordValue,
|
uncompiledSublocale: recordValue,
|
||||||
fallbackSublocale: fallbackValue,
|
fallbackSublocale: fallbackValue,
|
||||||
errors,
|
errors,
|
||||||
bundle,
|
bundle,
|
||||||
messageId,
|
messageIdChain,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
const subrecord = recordValue;
|
const subrecord = recordValue;
|
||||||
const compiledSubrecordRes = compileLocale({
|
const compiledSubrecordRes = compileLocale({
|
||||||
bundle,
|
bundle,
|
||||||
record: subrecord,
|
uncompiled: subrecord ?? {},
|
||||||
config: configValue,
|
config: configValue,
|
||||||
fallback: fallbackValue,
|
fallback: fallbackValue,
|
||||||
|
messageIdChain,
|
||||||
});
|
});
|
||||||
|
|
||||||
errors.push(
|
errors.push(
|
||||||
...compiledSubrecordRes.errors.map(
|
...compiledSubrecordRes.errors.map(
|
||||||
(err) =>
|
(err) =>
|
||||||
`Error when compiling subrecord with key: \`${messageId}\`:\n${err}`,
|
`Error when compiling subrecord with ID: \`${fmtMessageIdChain(messageIdChain)}\`:\n${err}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
import { type Markdown } from "./markdown";
|
import { type Markdown } from "./markdown";
|
||||||
|
|
||||||
export const configMessageSymbol: unique symbol = Symbol("configMessage");
|
export const configMessageTypeSymbol: unique symbol =
|
||||||
export interface ConfigMessage<
|
Symbol("configMessageType");
|
||||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
|
||||||
Markdown extends
|
export const configStringSymbol: unique symbol = Symbol("configString");
|
||||||
ConfigMarkdown<string> | null = ConfigMarkdown<string> | null,
|
export interface ConfigString<
|
||||||
|
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||||
> {
|
> {
|
||||||
[configMessageSymbol]: true;
|
[configMessageTypeSymbol]: typeof configStringSymbol;
|
||||||
placeables: Placeables;
|
placeables: Placeables;
|
||||||
markdown: Markdown;
|
}
|
||||||
|
|
||||||
|
export const configMarkdownSymbol: unique symbol = Symbol("configMarkdown");
|
||||||
|
export interface ConfigMarkdown<
|
||||||
|
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||||
|
Slots extends Partial<Record<string, ConfigSlotInfo>>,
|
||||||
|
> {
|
||||||
|
[configMessageTypeSymbol]: typeof configMarkdownSymbol;
|
||||||
|
placeables: Placeables;
|
||||||
|
features: ConfigMarkdownFeatures<Slots>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigPlaceableInfo<
|
export interface ConfigPlaceableInfo<
|
||||||
|
|
@ -17,47 +27,56 @@ export interface ConfigPlaceableInfo<
|
||||||
type: Type;
|
type: Type;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigMarkdown<Slot extends string> {
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
export interface ConfigSlotInfo {}
|
||||||
|
|
||||||
|
export interface ConfigMarkdownFeatures<
|
||||||
|
Slots extends Partial<Record<string, ConfigSlotInfo>>,
|
||||||
|
> {
|
||||||
bold?: boolean;
|
bold?: boolean;
|
||||||
italic?: boolean;
|
italic?: boolean;
|
||||||
header?: boolean;
|
header?: boolean;
|
||||||
link?: boolean;
|
link?: boolean;
|
||||||
ulist?: boolean;
|
ulist?: boolean;
|
||||||
slots?: Slot[];
|
slots: Slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function message<
|
export function string<
|
||||||
const Placeables extends {
|
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||||
[name in string]?: ConfigPlaceableInfo;
|
|
||||||
} = object,
|
|
||||||
const Markdown extends ConfigMarkdown<string> | null = null,
|
|
||||||
>(
|
>(
|
||||||
opt: Partial<
|
opt: Omit<ConfigString<Placeables>, typeof configMessageTypeSymbol>,
|
||||||
Omit<
|
): ConfigString<Placeables> {
|
||||||
ConfigMessage<Placeables, ConfigMarkdown<never> | Markdown>,
|
|
||||||
typeof configMessageSymbol
|
|
||||||
>
|
|
||||||
> = {},
|
|
||||||
): ConfigMessage<Placeables, Markdown> {
|
|
||||||
return {
|
return {
|
||||||
[configMessageSymbol]: true,
|
[configMessageTypeSymbol]: configStringSymbol,
|
||||||
placeables: opt.placeables ?? {},
|
placeables: opt.placeables,
|
||||||
markdown: opt.markdown ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocaleConfig<
|
export function markdown<
|
||||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||||
Markdown extends
|
const Slots extends Partial<Record<string, ConfigSlotInfo>>,
|
||||||
ConfigMarkdown<string> | null = ConfigMarkdown<string> | null,
|
>(
|
||||||
> = {
|
opt: Omit<
|
||||||
|
ConfigMarkdown<Placeables, Slots>,
|
||||||
|
typeof configMessageTypeSymbol
|
||||||
|
>,
|
||||||
|
): ConfigMarkdown<Placeables, Slots> {
|
||||||
|
return {
|
||||||
|
[configMessageTypeSymbol]: configMarkdownSymbol,
|
||||||
|
placeables: opt.placeables,
|
||||||
|
features: opt.features,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocaleConfig = {
|
||||||
[id: string]:
|
[id: string]:
|
||||||
| ConfigMessage<Placeables, Markdown>
|
| ConfigString<object>
|
||||||
| LocaleConfig<Placeables, Markdown>;
|
| ConfigMarkdown<object, object>
|
||||||
|
| LocaleConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MessageCtx<
|
type MessageCtx<
|
||||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||||
> = {
|
> = {
|
||||||
[K in keyof Placeables]: ResolvedPlaceableType<
|
[K in keyof Placeables]: ResolvedPlaceableType<
|
||||||
PlaceableType<Exclude<Placeables[K], undefined>>
|
PlaceableType<Exclude<Placeables[K], undefined>>
|
||||||
|
|
@ -72,18 +91,17 @@ type ResolvedPlaceableType<Type extends "string" | "number"> =
|
||||||
: Type extends "number" ? number
|
: Type extends "number" ? number
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export type InferLocale<Config extends LocaleConfig> = {
|
export type InferLocaleFromConfig<Config extends LocaleConfig> = {
|
||||||
[K in keyof Config]: Config[K] extends LocaleConfig ? InferLocale<Config[K]>
|
[K in keyof Config]: Config[K] extends LocaleConfig ?
|
||||||
: Config[K] extends ConfigMessage<infer Placeables, infer Md> ?
|
InferLocaleFromConfig<Config[K]>
|
||||||
|
: Config[K] extends ConfigString<infer Placeables> ?
|
||||||
object extends MessageCtx<Placeables> ?
|
object extends MessageCtx<Placeables> ?
|
||||||
() => null extends Md ? string
|
() => string
|
||||||
: Md extends ConfigMarkdown<never> ? Markdown<never>
|
: (ctx: MessageCtx<Placeables>) => string
|
||||||
: Md extends ConfigMarkdown<infer Slot> ? Markdown<Slot>
|
: Config[K] extends ConfigMarkdown<infer Placeables, object> ?
|
||||||
: never
|
object extends MessageCtx<Placeables> ?
|
||||||
: (ctx: MessageCtx<Placeables>) => null extends Md ? string
|
() => Markdown
|
||||||
: Md extends ConfigMarkdown<never> ? Markdown<never>
|
: (ctx: MessageCtx<Placeables>) => Markdown
|
||||||
: Md extends ConfigMarkdown<infer Slot> ? Markdown<Slot>
|
|
||||||
: never
|
|
||||||
: never;
|
: never;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,21 +36,16 @@ export async function loadFluentBundle(
|
||||||
return { type: "ok", ok: bundle };
|
return { type: "ok", ok: bundle };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type L10nRecord = {
|
export type UncompiledLocale = {
|
||||||
[id: string]:
|
[id: string]:
|
||||||
| { type: "message"; message: Message }
|
| { type: "message"; message: Message }
|
||||||
| { type: "subrecord"; subrecord: L10nRecord };
|
| { type: "subrecord"; subrecord: UncompiledLocale };
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UncompiledLocale {
|
export function bundleToUncompiledLocaleRecord(
|
||||||
bundle: FluentBundle;
|
|
||||||
record: L10nRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bundleToUncompiledLocale(
|
|
||||||
bundle: FluentBundle,
|
bundle: FluentBundle,
|
||||||
): Result<UncompiledLocale, string> {
|
): Result<UncompiledLocale, string> {
|
||||||
const record: L10nRecord = {};
|
const record: UncompiledLocale = {};
|
||||||
for (const [id, message] of bundle._messages) {
|
for (const [id, message] of bundle._messages) {
|
||||||
const idChain = id.split("-");
|
const idChain = id.split("-");
|
||||||
let subrecord = record;
|
let subrecord = record;
|
||||||
|
|
@ -85,7 +80,7 @@ export function bundleToUncompiledLocale(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: "ok", ok: { bundle, record } };
|
return { type: "ok", ok: record };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectionChain = (Literal | SelectionChain)[];
|
export type SelectionChain = (Literal | SelectionChain)[];
|
||||||
|
|
@ -109,7 +104,7 @@ export function selectionChainToString(chain: SelectionChain): string {
|
||||||
.join("+");
|
.join("+");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PatternVariant {
|
export interface PatternVariant {
|
||||||
selectionChain: SelectionChain;
|
selectionChain: SelectionChain;
|
||||||
string: string;
|
string: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,62 @@
|
||||||
import {
|
import {
|
||||||
message,
|
string,
|
||||||
|
markdown,
|
||||||
record,
|
record,
|
||||||
type InferLocale,
|
type InferLocaleFromConfig,
|
||||||
type LocaleConfig,
|
type LocaleConfig,
|
||||||
|
type ConfigString,
|
||||||
} from "@/new-i18n-lib/config";
|
} from "@/new-i18n-lib/config";
|
||||||
|
|
||||||
const homeSectionConfig = { title: message(), body: message() };
|
function plainString(): ConfigString<object> {
|
||||||
|
return string({ placeables: {} });
|
||||||
|
}
|
||||||
|
|
||||||
const imageConfig = { alt: message() };
|
const homeSectionConfig = { title: plainString(), body: plainString() };
|
||||||
export interface Image extends InferLocale<typeof imageConfig> {}
|
|
||||||
|
|
||||||
const buttonConfig = { label: message() };
|
const imageConfig = { alt: plainString() };
|
||||||
|
export interface Image extends InferLocaleFromConfig<typeof imageConfig> {}
|
||||||
|
|
||||||
|
const buttonConfig = { label: plainString() };
|
||||||
|
|
||||||
const resourceConfig = <ButtonKey extends string>(buttonKeys: ButtonKey[]) => ({
|
const resourceConfig = <ButtonKey extends string>(buttonKeys: ButtonKey[]) => ({
|
||||||
title: message(),
|
title: plainString(),
|
||||||
subtitle: message(),
|
subtitle: plainString(),
|
||||||
desc: message(),
|
desc: plainString(),
|
||||||
buttons: record(buttonKeys, () => buttonConfig),
|
buttons: record(buttonKeys, () => buttonConfig),
|
||||||
});
|
});
|
||||||
|
|
||||||
const discordRuleConfig = {
|
const discordRuleConfig = {
|
||||||
overview: {
|
overview: {
|
||||||
text: message({ markdown: { bold: true, italic: true, link: true } }),
|
text: markdown({
|
||||||
subtext: message({
|
placeables: {},
|
||||||
markdown: { bold: true, italic: true, link: true },
|
features: { bold: true, italic: true, link: true, slots: {} },
|
||||||
|
}),
|
||||||
|
subtext: markdown({
|
||||||
|
placeables: {},
|
||||||
|
features: { bold: true, italic: true, link: true, slots: {} },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
header: message({ placeables: { ruleNumber: { type: "number" } } }),
|
header: string({ placeables: { ruleNumber: { type: "number" } } }),
|
||||||
body: message({
|
body: markdown({
|
||||||
markdown: { bold: true, header: true, italic: true, link: true },
|
placeables: {},
|
||||||
|
features: {
|
||||||
|
bold: true,
|
||||||
|
header: true,
|
||||||
|
italic: true,
|
||||||
|
link: true,
|
||||||
|
slots: {},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const localeConfig = {
|
export const localeConfig = {
|
||||||
localeName: message(),
|
localeName: plainString(),
|
||||||
vilanticLangs: record(["viossa", "wodox"], () => message()),
|
vilanticLangs: record(["viossa", "wodox"], () => plainString()),
|
||||||
navbar: record(["whatIsViossa", "resources", "kotoba"], () => message()),
|
navbar: record(["whatIsViossa", "resources", "kotoba"], () =>
|
||||||
|
plainString(),
|
||||||
|
),
|
||||||
home: {
|
home: {
|
||||||
sections: record(
|
sections: record(
|
||||||
["whatIsViossa", "historyOfViossa", "community"],
|
["whatIsViossa", "historyOfViossa", "community"],
|
||||||
|
|
@ -46,15 +65,15 @@ export const localeConfig = {
|
||||||
images: record(["viossaFlag"], () => imageConfig),
|
images: record(["viossaFlag"], () => imageConfig),
|
||||||
},
|
},
|
||||||
resources: {
|
resources: {
|
||||||
title: message(),
|
title: plainString(),
|
||||||
resources: { discord: resourceConfig(["join", "rules"]) },
|
resources: { discord: resourceConfig(["join", "rules"]) },
|
||||||
images: record(["discordLogo"], () => imageConfig),
|
images: record(["discordLogo"], () => imageConfig),
|
||||||
},
|
},
|
||||||
kotoba: { title: message(), searchHelp: message() },
|
kotoba: { title: plainString(), searchHelp: plainString() },
|
||||||
discord: {
|
discord: {
|
||||||
rulesPage: {
|
rulesPage: {
|
||||||
title: message(),
|
title: plainString(),
|
||||||
overview: { title: message(), help: message() },
|
overview: { title: plainString(), help: plainString() },
|
||||||
rules: record(
|
rules: record(
|
||||||
[
|
[
|
||||||
"noTranslation",
|
"noTranslation",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { type InferLocale } from "@/new-i18n-lib/config";
|
import { type InferLocaleFromConfig } from "@/new-i18n-lib/config";
|
||||||
import {
|
import {
|
||||||
bundleToUncompiledLocale,
|
bundleToUncompiledLocaleRecord,
|
||||||
loadFluentBundle,
|
loadFluentBundle,
|
||||||
} from "@/new-i18n-lib/setup";
|
} from "@/new-i18n-lib/setup";
|
||||||
import { localeConfig } from "./config";
|
import { localeConfig } from "./config";
|
||||||
|
|
@ -47,7 +47,7 @@ export const localeId = computed({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface Locale extends InferLocale<typeof localeConfig> {}
|
export interface Locale extends InferLocaleFromConfig<typeof localeConfig> {}
|
||||||
|
|
||||||
async function loadLocale(
|
async function loadLocale(
|
||||||
localeId: LocaleId,
|
localeId: LocaleId,
|
||||||
|
|
@ -95,18 +95,21 @@ function setupLocale(
|
||||||
return localeBundle;
|
return localeBundle;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const uncompiledRes = bundleToUncompiledLocale(maybeFallbackedBundle);
|
const uncompiledLocaleRecordRes = bundleToUncompiledLocaleRecord(
|
||||||
if (uncompiledRes.type === "err") {
|
maybeFallbackedBundle,
|
||||||
return uncompiledRes;
|
);
|
||||||
|
if (uncompiledLocaleRecordRes.type === "err") {
|
||||||
|
return uncompiledLocaleRecordRes;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uncompiled = uncompiledRes.ok;
|
const uncompiledLocaleRecord = uncompiledLocaleRecordRes.ok;
|
||||||
|
|
||||||
const localeRes = compileLocale({
|
const localeRes = compileLocale({
|
||||||
config: localeConfig,
|
config: localeConfig,
|
||||||
bundle: uncompiled.bundle,
|
bundle: maybeFallbackedBundle,
|
||||||
record: uncompiled.record,
|
uncompiled: uncompiledLocaleRecord,
|
||||||
fallback: fallbackLocale,
|
fallback: fallbackLocale,
|
||||||
|
messageIdChain: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.error(localeRes.errors);
|
console.error(localeRes.errors);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue