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
+12 -4
View File
@@ -140,10 +140,18 @@ them. Revisit only if the stated reason stops holding.
context with `contextFor(pluginId)` exactly as a plugin route does — otherwise `ctx.t` is the core
translator and the plugin's own keys render as bare keys on the pages it owns. Found by review
2026-08-03 after all three paths shipped with the host's context.
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`.** The SHIPPED
`en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is checked
rather than trusted (a mounted `en-US` compared only against itself would boot green with the
whole UI rendering keys). A catalog there
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`** `locales/<tag>.ts`
for the core and `locales/plugins/<id>/<tag>.ts` for an installed plugin, each adding a language or
replacing that tag's catalog wholesale. Adding a language must never require forking the image or a
vendored plugin folder. The SHIPPED `en-US` (core's, or the plugin's own) stays the parity baseline
even when the mount replaces it, so a mounted catalog is checked rather than trusted (one compared
only against itself would boot green with the whole UI rendering keys), and each half is reported
under the folder it actually lives in.
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` from the
locale's script because that is free and correct, but the stylesheet keeps physical `left`/`right`
properties — a genuine RTL locale needs those moved to logical ones first. Don't convert the CSS or
file findings about it on spec. Maintainer's call 2026-08-04; valid while no deployment needs an
RTL language. A catalog there
for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held
to the same parity check. Adding a language must not require forking the image.
- **An unknown translation key renders as itself.** That single rule is what lets a nav label,
+24 -2
View File
@@ -132,6 +132,25 @@ hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--access)) reads as
obvious rather than surprising, you're the audience.
**Who *they* build for.** The people who end up in front of a Plainpages app are not the
audience above, and three of them shape the design more than any feature request does:
- **The end user** — anyone using the product you assemble from Plainpages + your plugins.
They never hear the word "plugin": to them the menu, the screens and the sign-in are one app,
which is why the shell, the auth pages and every plugin share one design system, one menu and
one language.
- **The power user** — lives in the app all day. Ctrl-clicks a row to open it in a new tab,
bookmarks a filtered-and-sorted list to come back to on Monday, sends that URL to a colleague,
and edits the query string by hand when it's faster. This is why list state and the chosen
language live **in the URL** and why every navigation is a real `<a href>`: middle-click, "open
in new tab", back, and bookmark all have to work without a second thought.
- **The non-technical user** — clicks a button twice when nothing happens fast enough, never
touches the tab key, doesn't distinguish a link from a button, and won't recognise an error
code. This is why destructive actions go through a confirm page instead of an inline
`?confirm=1`, why a form's labels are clickable and its errors sit next to the field they
belong to, and why a page must never depend on keyboard-only affordances. A double-clicked
submit is a real event, not a misuse.
**Included vs. what you add.**
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
@@ -914,6 +933,7 @@ dropping another file next to them.
src/i18n/locales/en-US.ts the baseline — every other locale is checked against it
src/i18n/locales/sv-SE.ts
locales/ drop-in mount root: your own catalogs, ships empty (like plugins/ and config/)
locales/plugins/<id>/sv-SE.ts the same, for a plugin's words — so adding a language never forks a plugin
plugins/<id>/i18n/en-US.ts a plugin's own words, looked up before the host's
plugins/<id>/i18n/sv-SE.ts
```
@@ -921,7 +941,9 @@ plugins/<id>/i18n/sv-SE.ts
`locales/` is the operator's, mounted like `plugins/` and `config/` — a file there for a new tag
**adds** a language, one for a tag the image already ships **replaces** that catalog wholesale (and
is held to the same parity check, so a partial replacement fails the boot instead of leaving half
the app in English):
the app in English). `locales/plugins/<id>/<tag>.ts` does the same for an installed plugin's words,
checked against *that plugin's* `en-US` — so translating a vendored plugin, or fixing its wording,
never means forking its folder:
```yaml
# compose.override.yml
@@ -1819,7 +1841,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, language picker, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent
locales/ Drop-in mount point for extra (or replacement) language catalogs — a <locale>.ts here adds a language, or replaces the shipped catalog for that tag wholesale. Ships empty (.gitkeep, git-ignored otherwise); see Languages
locales/ Drop-in mount point for extra (or replacement) language catalogs — a <locale>.ts here adds a language for the core, or replaces the shipped catalog for that tag wholesale; plugins/<id>/<locale>.ts does the same for an installed plugin. Ships empty (.gitkeep, git-ignored otherwise); see Languages
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — permission/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Permissions/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
+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);
}