Architecture review fixes: partials carry the locale, mountable locales/, shared core words
CI / full-gate (push) Successful in 2m36s

This commit is contained in:
2026-08-03 23:12:18 +02:00
parent 245d1ad5b5
commit 6440c543e5
48 changed files with 337 additions and 118 deletions
+15 -5
View File
@@ -11,7 +11,7 @@ test("a complete translation reports nothing", () => {
});
test("a missing or unknown key is reported", () => {
const missing = parity("sv-SE", { "shifts.count": { one: "a", other: "b" } });
const missing = parity("sv-SE", { "shifts.count": { one: "{{count}} pass", other: "{{count}} pass" } });
assert.equal(missing.length, 1);
assert.match(missing[0] ?? "", /missing key "greeting"/);
@@ -21,21 +21,21 @@ test("a missing or unknown key is reported", () => {
});
test("a key must stay the same kind as in the baseline", () => {
const flat = parity("sv-SE", { greeting: "Hej", "shifts.count": "pass" });
const flat = parity("sv-SE", { greeting: "Hej", "shifts.count": "{{count}} pass" });
assert.equal(flat.length, 1);
assert.match(flat[0] ?? "", /"shifts.count" must be a plural message/);
const plural = parity("sv-SE", { greeting: { one: "Hej", other: "Hej" }, "shifts.count": { one: "a", other: "b" } });
const plural = parity("sv-SE", { greeting: { one: "Hej", other: "Hej" }, "shifts.count": { one: "{{count}} pass", other: "{{count}} pass" } });
assert.equal(plural.length, 1);
assert.match(plural[0] ?? "", /"greeting" must be a string/);
});
test("a plural message must cover exactly its own locale's categories", () => {
const short = parity("cs-CZ", { greeting: "Ahoj", "shifts.count": { one: "a", other: "b" } });
const short = parity("cs-CZ", { greeting: "Ahoj", "shifts.count": { one: "{{count}} směna", other: "{{count}} směn" } });
assert.equal(short.length, 1);
assert.match(short[0] ?? "", /"shifts\.count".*cs-CZ.*few, many/);
const long = parity("sv-SE", { greeting: "Hej", "shifts.count": { few: "x", one: "a", other: "b" } });
const long = parity("sv-SE", { greeting: "Hej", "shifts.count": { few: "{{count}} pass", one: "{{count}} pass", other: "{{count}} pass" } });
assert.equal(long.length, 1);
assert.match(long[0] ?? "", /"shifts\.count".*few/);
});
@@ -55,3 +55,13 @@ test("isCatalog accepts strings and plural objects, rejects anything else", () =
assert.equal(isCatalog(null), false);
assert.equal(isCatalog([]), false);
});
test("a translation must interpolate exactly what the baseline does", () => {
const withVars: Catalog = { hi: "Hi {{name}}, you have {{n}} left" };
const check = (catalog: Catalog): string[] => checkCatalog({ baseline: withVars, baselineLocale: "en-US", catalog, locale: "sv-SE" });
assert.deepEqual(check({ hi: "Hej {{name}}, du har {{n}} kvar" }), []);
assert.match(check({ hi: "Hej, du har {{n}} kvar" })[0] ?? "", /"hi" never uses \{\{name\}\}/); // dropped ⇒ a blank on screen
assert.match(check({ hi: "Hej {{namn}}, du har {{n}} kvar" })[0] ?? "", /never uses \{\{name\}\}/); // misspelled ⇒ both problems
assert.match(check({ hi: "Hej {{name}} {{n}} {{extra}}" })[0] ?? "", /uses \{\{extra\}\}/); // never supplied ⇒ renders raw
});
+24
View File
@@ -12,6 +12,7 @@ export type Catalog = Record<string, Message>;
export const DEFAULT_LOCALE = "en-US";
const CATEGORIES: ReadonlySet<string> = new Set(["few", "many", "one", "other", "two", "zero"]);
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
export function isPluralMessage(value: Message): value is PluralMessage {
return typeof value !== "string";
@@ -51,6 +52,7 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari
problems.push(`"${key}" must be a ${isPluralMessage(expected) ? "plural message" : "string"}, like ${baselineLocale}`);
continue;
}
for (const problem of placeholderProblems(key, expected, actual, baselineLocale)) problems.push(problem);
if (!isPluralMessage(actual)) continue;
const forms = new Set(Object.keys(actual));
const missing = categories.filter((category) => !forms.has(category));
@@ -67,6 +69,28 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari
return problems;
}
// A translation must interpolate exactly what the baseline does: a dropped {{name}} renders
// "Signed in as ", a misspelled one renders the placeholder itself — the half-translated class this
// check exists to stop, and neither is visible from the key set alone.
function placeholderProblems(key: string, expected: Message, actual: Message, baselineLocale: string): string[] {
const wanted = placeholders(expected);
const got = placeholders(actual);
const missing = [...wanted].filter((name) => !got.has(name));
const unknown = [...got].filter((name) => !wanted.has(name));
return [
...(missing.length ? [`"${key}" never uses ${missing.map((n) => `{{${n}}}`).join(", ")}, which ${baselineLocale} does`] : []),
...(unknown.length ? [`"${key}" uses ${unknown.map((n) => `{{${n}}}`).join(", ")}, which ${baselineLocale} does not supply`] : []),
];
}
function placeholders(message: Message): Set<string> {
const names = new Set<string>();
for (const text of typeof message === "string" ? [message] : Object.values(message)) {
for (const match of (text ?? "").matchAll(PLACEHOLDER)) names.add(match[1] as string);
}
return names;
}
// The plural categories a locale actually selects, sorted; unknown tags fall back to English's.
export function pluralCategories(locale: string): Intl.LDMLPluralRule[] {
try {
+8 -1
View File
@@ -3,13 +3,20 @@
// view model built outside a request, an app created without `i18n` — so an unwired path renders
// real English rather than bare keys. server.ts replaces it with the discovered catalogs at boot.
import { DEFAULT_LOCALE } from "./catalog.ts";
import { type Catalog, DEFAULT_LOCALE } from "./catalog.ts";
import enUS from "./locales/en-US.ts";
import { createI18n, type I18n } from "./runtime.ts";
import { createTranslator, type Translate } from "./translate.ts";
export const ENGLISH: Translate = createTranslator({ catalogs: [enUS], locale: DEFAULT_LOCALE });
// A plugin's own English: its catalog first, the host's behind it — the same chain the host builds
// per request, minus the locale. A plugin uses it as the default for a view model built outside a
// request (its unit tests), so the generic words it reuses from core still read as words.
export function englishTranslator(catalog: Catalog): Translate {
return createTranslator({ catalogs: [catalog, enUS], locale: DEFAULT_LOCALE });
}
export const ENGLISH_I18N: I18n = createI18n({
available: [DEFAULT_LOCALE],
core: new Map([[DEFAULT_LOCALE, enUS]]),
+20
View File
@@ -99,6 +99,26 @@ test("a plugin with translations but no en-US baseline is an error", async () =>
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /shop/);
});
test("a mounted locales/ adds a language, and replaces a shipped one wholesale", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
"mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`),
"mounted/sv-SE.ts": catalog(`{ hello: "Tjena" }`),
});
const loaded = await loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginsDir });
assert.deepEqual(loaded.available, ["en-US", "nb-NO", "sv-SE"]);
assert.deepEqual(loaded.core.get("sv-SE"), { hello: "Tjena" }); // the operator's file wins outright
});
test("a mounted catalog is held to the same baseline as a shipped one", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello", bye: "Bye" }`),
"mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`), // no `bye` ⇒ half the app would be English
});
await assert.rejects(loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginsDir }), /nb-NO.*missing key "bye"/s);
});
test("a plugin without an i18n folder is fine", async () => {
const { localesDir, pluginsDir } = await fixture({ "locales/en-US.ts": catalog(`{ hello: "Hello" }`) });
const loaded = await loadI18n({ localesDir, pluginIds: ["plain"], pluginsDir });
+16
View File
@@ -12,7 +12,14 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { checkCatalog, DEFAULT_LOCALE, isCatalog, type Catalog } from "./catalog.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
// The shipped catalogs, and the drop-in mount root an operator adds their own to — a folder there
// is a whole locale: a new tag adds a language, an existing one replaces the shipped catalog for it
// (and is held to the same parity check, so a partial replacement fails the boot rather than
// leaving half the app in English). Mirrors plugins/ and config/; ships empty.
export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
export const MOUNTED_LOCALES_DIR = join(rootDir, "locales");
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts. Anything else in
// the folder is a mistake worth stopping for.
@@ -20,6 +27,8 @@ const LOCALE_FILE = /^([a-z]{2,3}-[A-Z]{2})\.ts$/;
export interface LoadI18nOptions {
localesDir?: string;
logger?: Pick<Console, "warn">; // warn-level diagnostics (a plugin missing an installed locale); defaults to console
mountedLocalesDir?: string;
pluginIds?: string[]; // discovered plugins; their i18n/ folders are loaded under their id
pluginsDir?: string;
}
@@ -32,10 +41,13 @@ export interface LoadedI18n {
export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18n> {
const localesDir = options.localesDir ?? LOCALES_DIR;
const mountedDir = options.mountedLocalesDir ?? MOUNTED_LOCALES_DIR;
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
const logger = options.logger ?? console;
const errors: string[] = [];
const core = await readSet(localesDir, "core", errors);
for (const [locale, catalog] of await readSet(mountedDir, "locales", errors)) core.set(locale, catalog);
if (!core.has(DEFAULT_LOCALE)) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
checkSet(core, "core", errors);
const available = [...core.keys()].sort();
@@ -51,6 +63,10 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`);
}
checkSet(set, `plugins/${id}`, errors);
// Legitimate — the plugin's strings fall back to en-US on that page — but an operator who
// installed a locale should hear about the gap at deploy time, not see English islands later.
const gaps = available.filter((locale) => !set.has(locale));
if (gaps.length) logger.warn(`[i18n] plugins/${id}: no ${gaps.join(", ")} — those strings render in ${DEFAULT_LOCALE}`);
plugins.set(id, set);
}
+10
View File
@@ -37,6 +37,16 @@ const messages = {
"brand.sub": "Console",
// Generic UI verbs every screen needs. A plugin reuses these (the lookup falls through to core)
// and keeps its own catalog for its domain words — see README → Languages.
"common.add": "Add",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.edit": "Edit",
"common.new": "New",
"common.remove": "Remove",
"common.save": "Save",
"consent.allow": "Allow",
"consent.deny": "Deny",
"consent.notYou": "Not you?",
+8
View File
@@ -30,6 +30,14 @@ const messages: CoreMessages = {
"brand.sub": "Konsol",
"common.add": "Lägg till",
"common.cancel": "Avbryt",
"common.delete": "Ta bort",
"common.edit": "Redigera",
"common.new": "Ny",
"common.remove": "Ta bort",
"common.save": "Spara",
"consent.allow": "Tillåt",
"consent.deny": "Neka",
"consent.notYou": "Inte du?",
Binary file not shown.
+19 -2
View File
@@ -4,7 +4,6 @@
import { DEFAULT_LOCALE } from "./catalog.ts";
import { ENGLISH } from "./english.ts";
import type { RequestContext } from "../http/context.ts";
import { localeHref, localeLabel, textDirection } from "./locale.ts";
import type { Translate } from "./translate.ts";
@@ -19,28 +18,46 @@ export interface I18nLocals {
dir: "ltr" | "rtl";
locale: string;
localeHref: (href: string) => string;
// The locale to carry as a hidden field, or null when the visitor never asked for one. A GET form
// replaces the whole query string, so a link-carrying wrapper can't reach it — the form must.
localeParam: string | null;
localeSwitch: LocaleChoice[];
locales: string[];
t: Translate;
}
// Just the request fields a render needs, so this module stays a leaf of src/i18n/ rather than
// depending on the HTTP layer that calls it.
export interface I18nRequest {
locale: string;
localeHref: (href: string) => string;
locales: string[];
t: Translate;
url: URL;
}
// For a render with no request behind it — a partial exercised directly, a one-off render: English,
// left-to-right, no language picker.
export const ENGLISH_LOCALS: I18nLocals = {
dir: "ltr",
locale: DEFAULT_LOCALE,
localeHref: (href) => href,
localeParam: null,
localeSwitch: [],
locales: [DEFAULT_LOCALE],
t: ENGLISH,
};
export function i18nLocals(ctx: RequestContext): I18nLocals {
export function i18nLocals(ctx: I18nRequest): I18nLocals {
const here = `${ctx.url.pathname}${ctx.url.search}`;
// ctx.localeHref is a no-op unless the URL asked for a locale, so it is also the honest answer to
// "did it?" — asking the function that decides keeps the two from drifting apart.
const carried = ctx.localeHref("/") === "/" ? null : ctx.locale;
return {
dir: textDirection(ctx.locale),
locale: ctx.locale,
localeHref: (href) => ctx.localeHref(href),
localeParam: carried,
localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })),
locales: ctx.locales,
t: ctx.t,