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" },
|
||||
],
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -2,23 +2,42 @@ import type { Result, Value } from "@/utils/types";
|
|||
import {
|
||||
computeAllVariants,
|
||||
selectionChainToString,
|
||||
type L10nRecord,
|
||||
type PatternVariant,
|
||||
type UncompiledLocale,
|
||||
} from "./setup";
|
||||
import type { FluentBundle, FluentVariable } from "@fluent/bundle";
|
||||
import type { FluentBundle, FluentVariable, Message } from "@fluent/bundle";
|
||||
import { parseMarkdown, type Markdown } from "./markdown";
|
||||
import {
|
||||
configMessageSymbol,
|
||||
type ConfigMessage,
|
||||
type InferLocale,
|
||||
configMarkdownSymbol,
|
||||
configMessageTypeSymbol,
|
||||
configStringSymbol,
|
||||
type ConfigMarkdown,
|
||||
type ConfigString,
|
||||
type InferLocaleFromConfig,
|
||||
type LocaleConfig,
|
||||
} 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> {
|
||||
bundle: FluentBundle;
|
||||
record: L10nRecord | undefined;
|
||||
uncompiled: UncompiledLocale;
|
||||
config: Config;
|
||||
fallback?: InferLocale<Config>;
|
||||
fallback: InferLocaleFromConfig<Config> | undefined;
|
||||
messageIdChain: readonly string[];
|
||||
}
|
||||
|
||||
export interface CompileLocaleRes<Locale> {
|
||||
|
|
@ -26,81 +45,98 @@ export interface CompileLocaleRes<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;
|
||||
): CompileLocaleRes<InferLocaleFromConfig<Config>> {
|
||||
const {
|
||||
bundle,
|
||||
uncompiled,
|
||||
config,
|
||||
fallback,
|
||||
messageIdChain: localeMessageIdChain = [],
|
||||
} = ctx;
|
||||
|
||||
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 excessKeys = recordKeys.difference(configKeys);
|
||||
const excessKeys = uncompiledKeys.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 uncompiledValue = uncompiled?.[messageId];
|
||||
const fallbackValue = fallback?.[messageId];
|
||||
const valueMessageIdChain = [
|
||||
...localeMessageIdChain,
|
||||
messageId,
|
||||
] as const;
|
||||
|
||||
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") {
|
||||
if (configMessageTypeSymbol in configValue) {
|
||||
const compiledMessage = (() => {
|
||||
if (uncompiledValue?.type !== "message") {
|
||||
errors.push(
|
||||
`Expected subrecord for key \`${messageId}\`, found: ${typeof recordValue}`,
|
||||
`Expected message for key \`${messageId}\`, found: ${typeof uncompiledValue}`,
|
||||
);
|
||||
|
||||
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({
|
||||
subconfig: configValue,
|
||||
recordSublocale: subrecord,
|
||||
uncompiledSublocale: uncompiledSubrecord,
|
||||
fallbackSublocale:
|
||||
typeof fallbackValue === "function" ? undefined : (
|
||||
fallbackValue
|
||||
),
|
||||
errors,
|
||||
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
|
||||
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 {
|
||||
bundle: FluentBundle;
|
||||
messageId: string;
|
||||
configValue: ConfigMessage;
|
||||
recordValue: Value<UncompiledLocale["record"]> | undefined;
|
||||
messageIdChain: readonly [...string[], string];
|
||||
configValue: ConfigString<object> | ConfigMarkdown<object, object>;
|
||||
uncompiledMessage: Message;
|
||||
}
|
||||
|
||||
function compileMessage(
|
||||
ctx: CompileMessageCtx,
|
||||
): Result<GenericMessageFn, string> {
|
||||
const { bundle, messageId, configValue, recordValue } = ctx;
|
||||
const { bundle, messageIdChain, configValue, uncompiledMessage } = 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;
|
||||
const pattern = uncompiledMessage.value;
|
||||
if (pattern === null) {
|
||||
return {
|
||||
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") {
|
||||
return {
|
||||
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 {
|
||||
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 {
|
||||
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") {
|
||||
return {
|
||||
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;
|
||||
|
||||
// create function
|
||||
const markdown = configValue.markdown;
|
||||
switch (configValue[configMessageTypeSymbol]) {
|
||||
case configStringSymbol: {
|
||||
return compileStringMessage({
|
||||
bundle,
|
||||
messageIdChain,
|
||||
allVariants,
|
||||
pattern,
|
||||
});
|
||||
}
|
||||
case configMarkdownSymbol: {
|
||||
return compileMarkdownMessage({
|
||||
bundle,
|
||||
configMarkdown: configValue,
|
||||
messageIdChain,
|
||||
allVariants,
|
||||
pattern,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (markdown !== null) {
|
||||
// typecheck markdown
|
||||
interface CompileStringMessageCtx {
|
||||
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
|
||||
for (const variant of allVariants) {
|
||||
const markdownLiteralRes = parseMessageLiteral(
|
||||
"md",
|
||||
variant.string,
|
||||
);
|
||||
// typecheck string
|
||||
// check if all variants are valid markdown
|
||||
for (const variant of allVariants) {
|
||||
const stringLiteralRes = parseMessageLiteral("string", 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}`,
|
||||
};
|
||||
}
|
||||
if (stringLiteralRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringLiteralRes.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),
|
||||
);
|
||||
const stringLiteral = stringLiteralRes.ok;
|
||||
const stringRes = parseString(stringLiteral);
|
||||
if (stringRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid string for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringRes.err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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",
|
||||
variant.string,
|
||||
bundle.formatPattern(pattern, args),
|
||||
);
|
||||
|
||||
if (stringLiteralRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${messageId}\`:\n${stringLiteralRes.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 stringRes = parseString(stringLiteral);
|
||||
if (stringRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid string for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${messageId}\`:\n${stringRes.err}`,
|
||||
};
|
||||
|
||||
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 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
|
||||
return {
|
||||
type: "ok",
|
||||
ok: (args: Record<string, FluentVariable> = {}) => {
|
||||
const stringLiteralRes = parseMessageLiteral(
|
||||
"string",
|
||||
bundle.formatPattern(pattern, args),
|
||||
);
|
||||
const markdownLiteral = markdownLiteralRes.ok;
|
||||
const markdownRes = parseMarkdown(
|
||||
markdownLiteral,
|
||||
Object.keys(markdownSlots),
|
||||
);
|
||||
|
||||
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 (markdownRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid markdown for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\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,
|
||||
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> {
|
||||
subconfig: Subconfig;
|
||||
recordSublocale: L10nRecord | undefined;
|
||||
fallbackSublocale: InferLocale<Subconfig> | undefined;
|
||||
uncompiledSublocale: UncompiledLocale | undefined;
|
||||
fallbackSublocale: InferLocaleFromConfig<Subconfig> | undefined;
|
||||
errors: string[];
|
||||
bundle: FluentBundle;
|
||||
messageId: string;
|
||||
messageIdChain: readonly [...string[], string];
|
||||
}
|
||||
|
||||
function compileSublocale<Subconfig extends LocaleConfig>(
|
||||
ctx: CompileSublocaleCtx<Subconfig>,
|
||||
): InferLocale<Subconfig> {
|
||||
): InferLocaleFromConfig<Subconfig> {
|
||||
const {
|
||||
subconfig: configValue,
|
||||
recordSublocale: recordValue,
|
||||
uncompiledSublocale: recordValue,
|
||||
fallbackSublocale: fallbackValue,
|
||||
errors,
|
||||
bundle,
|
||||
messageId,
|
||||
messageIdChain,
|
||||
} = ctx;
|
||||
|
||||
const subrecord = recordValue;
|
||||
const compiledSubrecordRes = compileLocale({
|
||||
bundle,
|
||||
record: subrecord,
|
||||
uncompiled: subrecord ?? {},
|
||||
config: configValue,
|
||||
fallback: fallbackValue,
|
||||
messageIdChain,
|
||||
});
|
||||
|
||||
errors.push(
|
||||
...compiledSubrecordRes.errors.map(
|
||||
(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";
|
||||
|
||||
export const configMessageSymbol: unique symbol = Symbol("configMessage");
|
||||
export interface ConfigMessage<
|
||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
||||
Markdown extends
|
||||
ConfigMarkdown<string> | null = ConfigMarkdown<string> | null,
|
||||
export const configMessageTypeSymbol: unique symbol =
|
||||
Symbol("configMessageType");
|
||||
|
||||
export const configStringSymbol: unique symbol = Symbol("configString");
|
||||
export interface ConfigString<
|
||||
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
> {
|
||||
[configMessageSymbol]: true;
|
||||
[configMessageTypeSymbol]: typeof configStringSymbol;
|
||||
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<
|
||||
|
|
@ -17,47 +27,56 @@ export interface ConfigPlaceableInfo<
|
|||
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;
|
||||
italic?: boolean;
|
||||
header?: boolean;
|
||||
link?: boolean;
|
||||
ulist?: boolean;
|
||||
slots?: Slot[];
|
||||
slots: Slots;
|
||||
}
|
||||
|
||||
export function message<
|
||||
const Placeables extends {
|
||||
[name in string]?: ConfigPlaceableInfo;
|
||||
} = object,
|
||||
const Markdown extends ConfigMarkdown<string> | null = null,
|
||||
export function string<
|
||||
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
>(
|
||||
opt: Partial<
|
||||
Omit<
|
||||
ConfigMessage<Placeables, ConfigMarkdown<never> | Markdown>,
|
||||
typeof configMessageSymbol
|
||||
>
|
||||
> = {},
|
||||
): ConfigMessage<Placeables, Markdown> {
|
||||
opt: Omit<ConfigString<Placeables>, typeof configMessageTypeSymbol>,
|
||||
): ConfigString<Placeables> {
|
||||
return {
|
||||
[configMessageSymbol]: true,
|
||||
placeables: opt.placeables ?? {},
|
||||
markdown: opt.markdown ?? null,
|
||||
[configMessageTypeSymbol]: configStringSymbol,
|
||||
placeables: opt.placeables,
|
||||
};
|
||||
}
|
||||
|
||||
export type LocaleConfig<
|
||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
||||
Markdown extends
|
||||
ConfigMarkdown<string> | null = ConfigMarkdown<string> | null,
|
||||
> = {
|
||||
export function markdown<
|
||||
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
const Slots extends Partial<Record<string, ConfigSlotInfo>>,
|
||||
>(
|
||||
opt: Omit<
|
||||
ConfigMarkdown<Placeables, Slots>,
|
||||
typeof configMessageTypeSymbol
|
||||
>,
|
||||
): ConfigMarkdown<Placeables, Slots> {
|
||||
return {
|
||||
[configMessageTypeSymbol]: configMarkdownSymbol,
|
||||
placeables: opt.placeables,
|
||||
features: opt.features,
|
||||
};
|
||||
}
|
||||
|
||||
export type LocaleConfig = {
|
||||
[id: string]:
|
||||
| ConfigMessage<Placeables, Markdown>
|
||||
| LocaleConfig<Placeables, Markdown>;
|
||||
| ConfigString<object>
|
||||
| ConfigMarkdown<object, object>
|
||||
| LocaleConfig;
|
||||
};
|
||||
|
||||
type MessageCtx<
|
||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
||||
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
> = {
|
||||
[K in keyof Placeables]: ResolvedPlaceableType<
|
||||
PlaceableType<Exclude<Placeables[K], undefined>>
|
||||
|
|
@ -72,18 +91,17 @@ type ResolvedPlaceableType<Type extends "string" | "number"> =
|
|||
: Type extends "number" ? number
|
||||
: never;
|
||||
|
||||
export type InferLocale<Config extends LocaleConfig> = {
|
||||
[K in keyof Config]: Config[K] extends LocaleConfig ? InferLocale<Config[K]>
|
||||
: Config[K] extends ConfigMessage<infer Placeables, infer Md> ?
|
||||
export type InferLocaleFromConfig<Config extends LocaleConfig> = {
|
||||
[K in keyof Config]: Config[K] extends LocaleConfig ?
|
||||
InferLocaleFromConfig<Config[K]>
|
||||
: Config[K] extends ConfigString<infer Placeables> ?
|
||||
object extends MessageCtx<Placeables> ?
|
||||
() => null extends Md ? string
|
||||
: Md extends ConfigMarkdown<never> ? Markdown<never>
|
||||
: Md extends ConfigMarkdown<infer Slot> ? Markdown<Slot>
|
||||
: never
|
||||
: (ctx: MessageCtx<Placeables>) => null extends Md ? string
|
||||
: Md extends ConfigMarkdown<never> ? Markdown<never>
|
||||
: Md extends ConfigMarkdown<infer Slot> ? Markdown<Slot>
|
||||
: never
|
||||
() => string
|
||||
: (ctx: MessageCtx<Placeables>) => string
|
||||
: Config[K] extends ConfigMarkdown<infer Placeables, object> ?
|
||||
object extends MessageCtx<Placeables> ?
|
||||
() => Markdown
|
||||
: (ctx: MessageCtx<Placeables>) => Markdown
|
||||
: never;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -36,21 +36,16 @@ export async function loadFluentBundle(
|
|||
return { type: "ok", ok: bundle };
|
||||
}
|
||||
|
||||
export type L10nRecord = {
|
||||
export type UncompiledLocale = {
|
||||
[id: string]:
|
||||
| { type: "message"; message: Message }
|
||||
| { type: "subrecord"; subrecord: L10nRecord };
|
||||
| { type: "subrecord"; subrecord: UncompiledLocale };
|
||||
};
|
||||
|
||||
export interface UncompiledLocale {
|
||||
bundle: FluentBundle;
|
||||
record: L10nRecord;
|
||||
}
|
||||
|
||||
export function bundleToUncompiledLocale(
|
||||
export function bundleToUncompiledLocaleRecord(
|
||||
bundle: FluentBundle,
|
||||
): Result<UncompiledLocale, string> {
|
||||
const record: L10nRecord = {};
|
||||
const record: UncompiledLocale = {};
|
||||
for (const [id, message] of bundle._messages) {
|
||||
const idChain = id.split("-");
|
||||
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)[];
|
||||
|
|
@ -109,7 +104,7 @@ export function selectionChainToString(chain: SelectionChain): string {
|
|||
.join("+");
|
||||
}
|
||||
|
||||
interface PatternVariant {
|
||||
export interface PatternVariant {
|
||||
selectionChain: SelectionChain;
|
||||
string: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,62 @@
|
|||
import {
|
||||
message,
|
||||
string,
|
||||
markdown,
|
||||
record,
|
||||
type InferLocale,
|
||||
type InferLocaleFromConfig,
|
||||
type LocaleConfig,
|
||||
type ConfigString,
|
||||
} from "@/new-i18n-lib/config";
|
||||
|
||||
const homeSectionConfig = { title: message(), body: message() };
|
||||
function plainString(): ConfigString<object> {
|
||||
return string({ placeables: {} });
|
||||
}
|
||||
|
||||
const imageConfig = { alt: message() };
|
||||
export interface Image extends InferLocale<typeof imageConfig> {}
|
||||
const homeSectionConfig = { title: plainString(), body: plainString() };
|
||||
|
||||
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[]) => ({
|
||||
title: message(),
|
||||
subtitle: message(),
|
||||
desc: message(),
|
||||
title: plainString(),
|
||||
subtitle: plainString(),
|
||||
desc: plainString(),
|
||||
buttons: record(buttonKeys, () => buttonConfig),
|
||||
});
|
||||
|
||||
const discordRuleConfig = {
|
||||
overview: {
|
||||
text: message({ markdown: { bold: true, italic: true, link: true } }),
|
||||
subtext: message({
|
||||
markdown: { bold: true, italic: true, link: true },
|
||||
text: markdown({
|
||||
placeables: {},
|
||||
features: { bold: true, italic: true, link: true, slots: {} },
|
||||
}),
|
||||
subtext: markdown({
|
||||
placeables: {},
|
||||
features: { bold: true, italic: true, link: true, slots: {} },
|
||||
}),
|
||||
},
|
||||
section: {
|
||||
header: message({ placeables: { ruleNumber: { type: "number" } } }),
|
||||
body: message({
|
||||
markdown: { bold: true, header: true, italic: true, link: true },
|
||||
header: string({ placeables: { ruleNumber: { type: "number" } } }),
|
||||
body: markdown({
|
||||
placeables: {},
|
||||
features: {
|
||||
bold: true,
|
||||
header: true,
|
||||
italic: true,
|
||||
link: true,
|
||||
slots: {},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const localeConfig = {
|
||||
localeName: message(),
|
||||
vilanticLangs: record(["viossa", "wodox"], () => message()),
|
||||
navbar: record(["whatIsViossa", "resources", "kotoba"], () => message()),
|
||||
localeName: plainString(),
|
||||
vilanticLangs: record(["viossa", "wodox"], () => plainString()),
|
||||
navbar: record(["whatIsViossa", "resources", "kotoba"], () =>
|
||||
plainString(),
|
||||
),
|
||||
home: {
|
||||
sections: record(
|
||||
["whatIsViossa", "historyOfViossa", "community"],
|
||||
|
|
@ -46,15 +65,15 @@ export const localeConfig = {
|
|||
images: record(["viossaFlag"], () => imageConfig),
|
||||
},
|
||||
resources: {
|
||||
title: message(),
|
||||
title: plainString(),
|
||||
resources: { discord: resourceConfig(["join", "rules"]) },
|
||||
images: record(["discordLogo"], () => imageConfig),
|
||||
},
|
||||
kotoba: { title: message(), searchHelp: message() },
|
||||
kotoba: { title: plainString(), searchHelp: plainString() },
|
||||
discord: {
|
||||
rulesPage: {
|
||||
title: message(),
|
||||
overview: { title: message(), help: message() },
|
||||
title: plainString(),
|
||||
overview: { title: plainString(), help: plainString() },
|
||||
rules: record(
|
||||
[
|
||||
"noTranslation",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type InferLocale } from "@/new-i18n-lib/config";
|
||||
import { type InferLocaleFromConfig } from "@/new-i18n-lib/config";
|
||||
import {
|
||||
bundleToUncompiledLocale,
|
||||
bundleToUncompiledLocaleRecord,
|
||||
loadFluentBundle,
|
||||
} from "@/new-i18n-lib/setup";
|
||||
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(
|
||||
localeId: LocaleId,
|
||||
|
|
@ -95,18 +95,21 @@ function setupLocale(
|
|||
return localeBundle;
|
||||
})();
|
||||
|
||||
const uncompiledRes = bundleToUncompiledLocale(maybeFallbackedBundle);
|
||||
if (uncompiledRes.type === "err") {
|
||||
return uncompiledRes;
|
||||
const uncompiledLocaleRecordRes = bundleToUncompiledLocaleRecord(
|
||||
maybeFallbackedBundle,
|
||||
);
|
||||
if (uncompiledLocaleRecordRes.type === "err") {
|
||||
return uncompiledLocaleRecordRes;
|
||||
}
|
||||
|
||||
const uncompiled = uncompiledRes.ok;
|
||||
const uncompiledLocaleRecord = uncompiledLocaleRecordRes.ok;
|
||||
|
||||
const localeRes = compileLocale({
|
||||
config: localeConfig,
|
||||
bundle: uncompiled.bundle,
|
||||
record: uncompiled.record,
|
||||
bundle: maybeFallbackedBundle,
|
||||
uncompiled: uncompiledLocaleRecord,
|
||||
fallback: fallbackLocale,
|
||||
messageIdChain: [],
|
||||
});
|
||||
|
||||
console.error(localeRes.errors);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue