wip: i18n compiler organization/improvements, separated string & markdown messages in config builder

This commit is contained in:
Benjamin Singleton 2026-03-01 22:32:45 -06:00
parent 44ab34133c
commit 17e1c34a3b
6 changed files with 384 additions and 268 deletions

View file

@ -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

View file

@ -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,46 +45,66 @@ 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, errors.push(
configValue, `Expected message for key \`${messageId}\`, found: ${typeof uncompiledValue}`,
recordValue, );
});
const compiledMessage = ((): GenericMessageFn => { return undefined;
if (compiledMessageRes.type === "ok") {
return compiledMessageRes.ok;
} }
const uncompiledMessage = uncompiledValue.message;
const compiledMessageRes = compileMessage({
bundle,
messageIdChain: valueMessageIdChain,
configValue,
uncompiledMessage,
});
if (compiledMessageRes.type === "err") {
errors.push(compiledMessageRes.err); errors.push(compiledMessageRes.err);
return undefined;
}
const compiledMessage = compiledMessageRes.ok;
return compiledMessage;
})();
if (compiledMessage !== undefined) {
return compiledMessage;
}
if ( if (
fallbackValue !== undefined fallbackValue !== undefined
@ -74,33 +113,30 @@ export function compileLocale<Config extends LocaleConfig>(
return fallbackValue as GenericMessageFn; return fallbackValue as GenericMessageFn;
} }
return () => `[PLACEHOLDER]${messageId}`; return () => `[#${fmtMessageIdChain(valueMessageIdChain)}#]`;
})();
return compiledMessage;
} else { } else {
const subrecord = (() => { const uncompiledSubrecord = (() => {
if (recordValue?.type !== "subrecord") { if (uncompiledValue?.type !== "subrecord") {
errors.push( errors.push(
`Expected subrecord for key \`${messageId}\`, found: ${typeof recordValue}`, `Expected subrecord for key \`${messageId}\`, found: ${typeof uncompiledValue}`,
); );
return undefined; return undefined;
} }
return recordValue.subrecord; 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,89 +233,54 @@ 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({
if (markdown !== null) { bundle,
// typecheck markdown messageIdChain,
allVariants,
const markdownSlots = markdown.slots ?? []; pattern,
});
// check if all variants are valid markdown }
for (const variant of allVariants) { case configMarkdownSymbol: {
const markdownLiteralRes = parseMessageLiteral( return compileMarkdownMessage({
"md", bundle,
variant.string, configMarkdown: configValue,
); messageIdChain,
allVariants,
if (markdownLiteralRes.type === "err") { pattern,
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 interface CompileStringMessageCtx {
return { bundle: FluentBundle;
type: "ok", messageIdChain: readonly [...string[], string];
ok: (args: Record<string, FluentVariable> = {}) => { allVariants: readonly PatternVariant[];
const markdownLiteralRes = parseMessageLiteral( pattern: Pattern;
"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; function compileStringMessage(
const res = parseMarkdown(markdownLiteral, markdownSlots); ctx: CompileStringMessageCtx,
): Result<GenericStringMessageFn, string> {
const { bundle, messageIdChain, allVariants, pattern } = ctx;
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 // typecheck string
// check if all variants are valid markdown // check if all variants are valid markdown
for (const variant of allVariants) { for (const variant of allVariants) {
const stringLiteralRes = parseMessageLiteral( const stringLiteralRes = parseMessageLiteral("string", variant.string);
"string",
variant.string,
);
if (stringLiteralRes.type === "err") { if (stringLiteralRes.type === "err") {
return { return {
type: "err", type: "err",
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${messageId}\`:\n${stringLiteralRes.err}`, err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringLiteralRes.err}`,
}; };
} }
@ -291,7 +289,7 @@ function compileMessage(
if (stringRes.type === "err") { if (stringRes.type === "err") {
return { return {
type: "err", type: "err",
err: `Invalid string for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${messageId}\`:\n${stringRes.err}`, err: `Invalid string for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringRes.err}`,
}; };
} }
} }
@ -330,41 +328,120 @@ function compileMessage(
}, },
}; };
} }
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}`,
};
}
const markdownLiteral = markdownLiteralRes.ok;
const markdownRes = parseMarkdown(
markdownLiteral,
Object.keys(markdownSlots),
);
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> { 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}`,
), ),
); );

View file

@ -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;
}; };

View file

@ -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;
} }

View file

@ -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",

View file

@ -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);