Let an operator mount plugin catalogs; document the end-user personas
CI / full-gate (push) Successful in 2m37s

This commit is contained in:
2026-08-04 10:31:08 +02:00
parent bd76c981ee
commit e03c1d1a2f
4 changed files with 86 additions and 16 deletions
+31
View File
@@ -124,3 +124,34 @@ test("a plugin without an i18n folder is fine", async () => {
const loaded = await loadI18n({ localesDir, pluginIds: ["plain"], pluginsDir });
assert.equal(loaded.plugins.size, 0);
});
test("an operator adds a language for a plugin without forking it, and may replace one it ships", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
"plugins/shop/i18n/en-US.ts": catalog(`{ "shop.title": "Shop" }`),
"plugins/shop/i18n/sv-SE.ts": catalog(`{ "shop.title": "Butik" }`),
"mounted/plugins/shop/sv-SE.ts": catalog(`{ "shop.title": "Affär" }`), // replaces the plugin's
"mounted/plugins/shop/nb-NO.ts": catalog(`{ "shop.title": "Butikk" }`), // …and adds one
"mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`), // the core side of the same language
});
const loaded = await loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginIds: ["shop"], pluginsDir });
assert.deepEqual(loaded.available, ["en-US", "nb-NO", "sv-SE"]);
assert.deepEqual(loaded.plugins.get("shop")?.get("sv-SE"), { "shop.title": "Affär" });
assert.deepEqual(loaded.plugins.get("shop")?.get("nb-NO"), { "shop.title": "Butikk" });
assert.deepEqual(loaded.plugins.get("shop")?.get("en-US"), { "shop.title": "Shop" }); // untouched
});
test("an operator's plugin catalog is held to the plugin's own baseline, and named by where it lives", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
"plugins/shop/i18n/en-US.ts": catalog(`{ "shop.title": "Shop", "shop.new": "New" }`),
"mounted/plugins/shop/sv-SE.ts": catalog(`{ "shop.title": "Butik" }`), // shop.new missing
});
await assert.rejects(
loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginIds: ["shop"], pluginsDir }),
/locales\/plugins\/shop sv-SE: missing key "shop.new"/, // the folder the operator actually edited
);
});
+19 -10
View File
@@ -4,7 +4,9 @@
// so a half-translated deploy is caught at startup rather than as a stray English word in production.
//
// Installed locales are whatever the core folder holds; a plugin may translate fewer of them (its
// strings then render in en-US on that page) but never one the host does not have.
// strings then render in en-US on that page) but never one the host does not have. The operator's
// `locales/` mount extends both sides — `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts`
// for a plugin — so adding a language never means forking the image or a vendored plugin.
import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
@@ -64,19 +66,26 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
const plugins = new Map<string, Map<string, Catalog>>();
for (const id of options.pluginIds ?? []) {
const dir = join(pluginsDir, id, "i18n");
if (!existsSync(dir)) continue;
const set = await readSet(dir, `plugins/${id}`, errors);
if (set.size === 0) continue;
if (!set.has(DEFAULT_LOCALE)) errors.push(`plugins/${id}: no ${DEFAULT_LOCALE}.ts — a plugin's own baseline`);
for (const locale of set.keys()) {
if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`);
// A plugin's own catalogs, and the operator's for it. Adding a language must not require forking
// a vendored plugin folder, so `locales/plugins/<id>/` extends and overrides the same way
// `locales/` does for the core: a new tag adds it, a tag the plugin ships replaces it wholesale.
const own = await readSet(join(pluginsDir, id, "i18n"), `plugins/${id}`, errors);
const mine = await readSet(join(mountedDir, "plugins", id), `locales/plugins/${id}`, errors);
if (own.size === 0 && mine.size === 0) continue;
const set = new Map([...own, ...mine]);
// The plugin's own en-US is the baseline; an operator who supplies the only one is translating a
// plugin that ships no words of its own, which is nothing this can check.
const pluginBaseline = own.get(DEFAULT_LOCALE) ?? mine.get(DEFAULT_LOCALE);
if (!pluginBaseline) errors.push(`plugins/${id}: no ${DEFAULT_LOCALE}.ts — a plugin's own baseline, which its other locales are checked against`);
for (const [locale, from] of [...[...own.keys()].map((l) => [l, `plugins/${id}`] as const), ...[...mine.keys()].map((l) => [l, `locales/plugins/${id}`] as const)]) {
if (!available.includes(locale)) errors.push(`${from}: ${locale} is not installed — add locales/${locale}.ts first`);
}
checkSet(set, `plugins/${id}`, set.get(DEFAULT_LOCALE), errors);
checkSet(new Map([...own].filter(([locale]) => !mine.has(locale))), `plugins/${id}`, pluginBaseline, errors);
checkSet(mine, `locales/plugins/${id}`, pluginBaseline, 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}`);
if (gaps.length) logger.warn(`[i18n] plugins/${id}: no ${gaps.join(", ")} — those strings render in ${DEFAULT_LOCALE} (add locales/plugins/${id}/<locale>.ts)`);
plugins.set(id, set);
}