diff --git a/AGENTS.md b/AGENTS.md index 2383c9e..0217604 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.ts` + for the core and `locales/plugins//.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 `` 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, diff --git a/README.md b/README.md index d10193a..5b8a4fc 100644 --- a/README.md +++ b/README.md @@ -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 ``: 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//sv-SE.ts the same, for a plugin's words — so adding a language never forks a plugin plugins//i18n/en-US.ts a plugin's own words, looked up before the host's plugins//i18n/sv-SE.ts ``` @@ -921,7 +941,9 @@ plugins//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//.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 .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 .ts here adds a language for the core, or replaces the shipped catalog for that tag wholesale; plugins//.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) diff --git a/src/i18n/load.test.ts b/src/i18n/load.test.ts index 42ea40e..84275af 100644 --- a/src/i18n/load.test.ts +++ b/src/i18n/load.test.ts @@ -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 + ); +}); diff --git a/src/i18n/load.ts b/src/i18n/load.ts index 6dcf4c3..b7ed0eb 100644 --- a/src/i18n/load.ts +++ b/src/i18n/load.ts @@ -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/.ts` for the core, `locales/plugins//.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>(); 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//` 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}/.ts)`); plugins.set(id, set); }