Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples #44
@@ -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
|
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
|
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.
|
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
|
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`** — `locales/<tag>.ts`
|
||||||
`en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is checked
|
for the core and `locales/plugins/<id>/<tag>.ts` for an installed plugin, each adding a language or
|
||||||
rather than trusted (a mounted `en-US` compared only against itself would boot green with the
|
replacing that tag's catalog wholesale. Adding a language must never require forking the image or a
|
||||||
whole UI rendering keys). A catalog there
|
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
|
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.
|
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,
|
- **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
|
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--access)) reads as
|
||||||
obvious rather than surprising, you're the audience.
|
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 vs. what you add.**
|
||||||
|
|
||||||
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
- **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/en-US.ts the baseline — every other locale is checked against it
|
||||||
src/i18n/locales/sv-SE.ts
|
src/i18n/locales/sv-SE.ts
|
||||||
locales/ drop-in mount root: your own catalogs, ships empty (like plugins/ and config/)
|
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/en-US.ts a plugin's own words, looked up before the host's
|
||||||
plugins/<id>/i18n/sv-SE.ts
|
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
|
`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
|
**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
|
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
|
```yaml
|
||||||
# compose.override.yml
|
# 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)
|
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)
|
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
|
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)
|
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
|
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)
|
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 });
|
const loaded = await loadI18n({ localesDir, pluginIds: ["plain"], pluginsDir });
|
||||||
assert.equal(loaded.plugins.size, 0);
|
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.
|
// 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
|
// 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 { existsSync, readdirSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
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>>();
|
const plugins = new Map<string, Map<string, Catalog>>();
|
||||||
for (const id of options.pluginIds ?? []) {
|
for (const id of options.pluginIds ?? []) {
|
||||||
const dir = join(pluginsDir, id, "i18n");
|
// A plugin's own catalogs, and the operator's for it. Adding a language must not require forking
|
||||||
if (!existsSync(dir)) continue;
|
// a vendored plugin folder, so `locales/plugins/<id>/` extends and overrides the same way
|
||||||
const set = await readSet(dir, `plugins/${id}`, errors);
|
// `locales/` does for the core: a new tag adds it, a tag the plugin ships replaces it wholesale.
|
||||||
if (set.size === 0) continue;
|
const own = await readSet(join(pluginsDir, id, "i18n"), `plugins/${id}`, errors);
|
||||||
if (!set.has(DEFAULT_LOCALE)) errors.push(`plugins/${id}: no ${DEFAULT_LOCALE}.ts — a plugin's own baseline`);
|
const mine = await readSet(join(mountedDir, "plugins", id), `locales/plugins/${id}`, errors);
|
||||||
for (const locale of set.keys()) {
|
if (own.size === 0 && mine.size === 0) continue;
|
||||||
if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`);
|
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
|
// 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.
|
// installed a locale should hear about the gap at deploy time, not see English islands later.
|
||||||
const gaps = available.filter((locale) => !set.has(locale));
|
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);
|
plugins.set(id, set);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user