Architecture review fixes: partials carry the locale, mountable locales/, shared core words
CI / full-gate (push) Successful in 2m36s
CI / full-gate (push) Successful in 2m36s
This commit is contained in:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
@@ -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]]),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user