Let an operator mount plugin catalogs; document the end-user personas
CI / full-gate (push) Successful in 2m37s
CI / full-gate (push) Successful in 2m37s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user