From 6440c543e5537726b136e333fa3e5572c0131168 Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 3 Aug 2026 23:12:18 +0200 Subject: [PATCH] Architecture review fixes: partials carry the locale, mountable locales/, shared core words --- .gitignore | 4 ++ AGENTS.md | 19 +++++++++ README.md | 39 ++++++++++++++++-- e2e-tests/full-flow.spec.ts | 33 +++++++++++++++ e2e-tests/language.spec.ts | 6 +++ examples/plugins/admin/admin-clients.ts | 8 ++-- examples/plugins/admin/admin-groups.ts | 10 ++--- examples/plugins/admin/admin-permissions.ts | 10 ++--- examples/plugins/admin/admin-shared.ts | 9 ++-- examples/plugins/admin/admin-users.ts | 10 ++--- examples/plugins/admin/i18n/en-US.ts | 11 ----- examples/plugins/admin/i18n/sv-SE.ts | 11 ----- .../admin/views/partials/client-form-body.ejs | 2 +- .../admin/views/partials/confirm-body.ejs | 2 +- .../views/partials/group-detail-body.ejs | 6 +-- .../admin/views/partials/group-form-body.ejs | 2 +- .../views/partials/permission-detail-body.ejs | 2 +- .../views/partials/permission-form-body.ejs | 2 +- .../admin/views/partials/user-form-body.ejs | 2 +- examples/plugins/scheduling/i18n/en-US.ts | 2 - examples/plugins/scheduling/i18n/sv-SE.ts | 2 - examples/plugins/scheduling/shifts.test.ts | 4 +- examples/plugins/scheduling/shifts.ts | 15 +++---- locales/.gitkeep | 0 src/http/app.ts | 14 ++++--- src/http/context.ts | 13 +++--- src/i18n/catalog.test.ts | 20 ++++++--- src/i18n/catalog.ts | 24 +++++++++++ src/i18n/english.ts | 9 +++- src/i18n/load.test.ts | 20 +++++++++ src/i18n/load.ts | 16 +++++++ src/i18n/locales/en-US.ts | 10 +++++ src/i18n/locales/sv-SE.ts | 8 ++++ src/i18n/runtime.ts | Bin 1904 -> 1904 bytes src/i18n/view-locals.ts | 21 +++++++++- src/plugin-host/plugin-api.ts | 6 +++ src/server.ts | 9 ++-- src/ui/chrome.ts | 8 ++-- src/ui/menu.test.ts | 3 +- src/ui/nav.ts | 3 +- src/ui/shell-context.ts | 18 ++++++-- views/partials/auth-card.ejs | 6 +-- views/partials/data-table.ejs | 8 ++-- views/partials/filter-bar.ejs | 9 +++- views/partials/flow-body.ejs | 2 +- views/partials/menu.ejs | 2 +- views/partials/pagination.ejs | 13 ++++-- views/partials/shell.ejs | 2 +- 48 files changed, 337 insertions(+), 118 deletions(-) create mode 100644 locales/.gitkeep diff --git a/.gitignore b/.gitignore index b58ff42..aa5c1fc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ e2e-tests/artifacts/ # config/ is a drop-in mount point for your menu/branding override — keep it empty (see examples/config/ for the template) /config/* !/config/.gitkeep + +# locales/ is a drop-in mount point for extra (or replacement) language catalogs — keep it empty +/locales/* +!/locales/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index 0b907da..de1254c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,20 @@ them. Revisit only if the stated reason stops holding. — keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs — and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer locales than the host (its strings fall back to `en-US` per key), never one the host lacks. +- **The core building blocks carry the locale; a plugin doesn't have to.** `pagination`, + `filter-bar`, `data-table`, `auth-card`, `flow-body`, `menu` and the nav wrap every href they + render in `localeHref`, and the two GET forms carry it as a hidden `locale` input (a GET submit + replaces the whole query string, so no href wrapper can reach it). Putting the obligation on each + call site was tried first and missed five of eight sites inside one commit — including the admin + screens. `ctx.localeHref` remains for hrefs a plugin's own markup emits. Decided 2026-08-03 after + an architecture review. +- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set, so it never + shows up as a plugin filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`, + `localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data` + so a collision loses the key instead of breaking the shell. +- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`.** 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, branding, or a menu `rename` be either a key or plain text without a second field or a migration. Don't "fix" it into a loud failure: a manifest with plain labels must keep working. @@ -194,6 +208,11 @@ Same test before adding a row to a table or the file map — a clause, not a par versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g. `node:24.16.0-alpine3.24`). +- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive + contract changes (i18n added four `RequestContext` fields and several barrel exports without a + minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild, + a version bump can only produce noise. The promotion trigger is the first external plugin — from + then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03. - A plugin's `apiVersion` is a **hand-written literal** semver — the host version the plugin was built against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the diff --git a/README.md b/README.md index 7489109..7e85206 100644 --- a/README.md +++ b/README.md @@ -913,14 +913,29 @@ 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/) plugins//i18n/en-US.ts a plugin's own words, looked up before the host's 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): + +```yaml +# compose.override.yml +services: + web: + volumes: + - ./locales:/app/locales:ro +``` + **Which language a request gets:** `?locale=sv-SE` wins, else `Accept-Language`, else `en-US`. -Matching is exact on a full tag — `?locale=sv-FI` with only `sv-SE` installed lands on `en-US` -rather than a neighbouring region — but a lone language (`sv`, as browsers send) resolves to the -first regional catalog for it. There is **no locale cookie**: the URL is the only place a choice +Matching is exact on a full tag — `?locale=sv-FI` with only `sv-SE` installed matches nothing and +falls through to `Accept-Language` (and from there to `en-US`), rather than being served a +neighbouring region — but a lone language (`sv`, as browsers send) resolves to the first regional +catalog for it. There is **no locale cookie**: the URL is the only place a choice is stored, so a link is shareable and a page is what its address says it is. When the URL asked for a language, the host carries `?locale=` onto every link *it* renders (menu, sign-in, its own redirects) and `ctx.localeHref(href)` does the same for a plugin's links. The picker in the @@ -961,6 +976,11 @@ include depth: ```ts // handler return { data: { title: ctx.t("shop.title"), lead: ctx.t("shop.greeting", { name }) }, view: "shop" }; + +// a pure view model built outside a request (its unit test) defaults to the plugin's own English: +import { englishTranslator, type Translate } from "#plugin-api"; +import enUS from "./i18n/en-US.ts"; +const EN: Translate = englishTranslator(enUS); // your catalog, then the host's ``` ```html @@ -978,6 +998,16 @@ Three rules worth knowing: that deliberately carries markup is rendered with `<%- %>` — and must never interpolate untrusted data, since nothing escapes it there. - **Dates and numbers are `Intl`'s job**, not the catalog's: `new Intl.DateTimeFormat(ctx.locale)`. +- **The core building blocks carry the locale for you.** `pagination`, `filter-bar`, `data-table`, + `auth-card` and the nav wrap every href they render, and the two GET forms carry it as a hidden + `locale` input (a GET submit replaces the whole query string). `ctx.localeHref` is only for hrefs + your own markup emits — and `localeParam` (a view local: the tag, or null) for your own GET forms. +- **Reuse the core words.** Generic UI verbs live in the core catalog — `common.add/cancel/delete/ + edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through + to them. Keep your catalog for your domain words, so N plugins don't re-translate "Cancel" N times. +- **These view locals are reserved:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, + `localeSwitch`, `dir`. They are merged after your `data`, so a key of yours with one of those names + is ignored rather than breaking the shell. **Kratos writes the auth flow's own text** (field labels, validation errors) and tags each string with a stable numeric id; a `kratos.` key replaces it, and anything unmapped renders Kratos' @@ -1774,9 +1804,10 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *. list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs -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, 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) 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 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/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index 229f36a..ebb3edb 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -24,6 +24,19 @@ async function loginPassword(page: Page): Promise { await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL); // waits through the redirect chain } +// The themed Kratos page in another language: our own chrome, Kratos' own strings mapped by id, and +// the card's own links keeping the choice (they are rendered by the flow body, not by the menu). +test("the login page speaks the visitor's language, links included", async ({ browser }) => { + const page = await (await browser.newContext()).newPage(); + await page.goto("/login?locale=sv-SE"); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + await expect(page.getByRole("heading", { name: "Logga in" })).toBeVisible(); + await expect(page.getByLabel("Lösenord", { exact: true })).toBeVisible(); // Kratos' own field, labelled via auth.field.password + await expect(page.getByRole("link", { name: "Glömt lösenordet?" })).toHaveAttribute("href", /locale=sv-SE/); + await expect(page.getByRole("link", { name: "Skapa ett" })).toHaveAttribute("href", /locale=sv-SE/); + await page.context().close(); +}); + test.describe.serial("authenticated admin journey", () => { let browser: Browser; let page: Page; @@ -36,6 +49,26 @@ test.describe.serial("authenticated admin journey", () => { }); test.afterAll(async () => { await page.context().close(); }); + // The list screens rebuild their query from the list state (sort/page/filter), so they are where + // a chosen language used to get dropped — the core building blocks carry it now. + test("a sorted, paged admin list keeps the visitor's language", async () => { + await page.goto("/admin/users?locale=sv-SE"); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible(); + + await page.getByRole("link", { name: /E-postadress/ }).click(); // a sort header + await expect(page).toHaveURL(/locale=sv-SE/); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + + await page.getByRole("button", { name: "Använd filter" }).click(); // the filter bar's GET form + await expect(page).toHaveURL(/locale=sv-SE/); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + + await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form + await expect(page).toHaveURL(/locale=sv-SE/); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + }); + test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => { // The signed-in admin holds admin + scheduling:read/write, so both gated sections are present // in the menu (collapsed by default → assert they're in the DOM, not necessarily visible). diff --git a/e2e-tests/language.spec.ts b/e2e-tests/language.spec.ts index e288825..181e659 100644 --- a/e2e-tests/language.spec.ts +++ b/e2e-tests/language.spec.ts @@ -51,6 +51,12 @@ test("the switcher changes language, and the choice survives clicking through th await expect(page.getByRole("heading", { name: "Pass" })).toBeVisible(); await expect(page.getByRole("button", { name: "Sök" })).toBeVisible(); // the core filter bar, in Swedish + // The filter bar is a GET form: submitting it replaces the whole query string, so the choice + // survives only because the form carries it as a hidden field. + await page.getByRole("button", { name: "Sök" }).click(); + await expect(page).toHaveURL(/locale=sv-SE/); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + // …and back to English the same way. await page.locator('summary[aria-label="Språk"]').click(); await page.getByRole("link", { name: /English/i }).click(); diff --git a/examples/plugins/admin/admin-clients.ts b/examples/plugins/admin/admin-clients.ts index b51966e..e200692 100644 --- a/examples/plugins/admin/admin-clients.ts +++ b/examples/plugins/admin/admin-clients.ts @@ -143,9 +143,9 @@ function listTable(rows: ClientView[], t: Translate) { function listFilterBar(state: ListState, t: Translate) { const pills: { label: string; remove: string; value: string }[] = []; - if (state.q) pills.push({ label: t("admin.common.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); + if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); return { - applyLabel: t("admin.common.apply"), + applyLabel: t("filter.apply"), clearHref: ADMIN_CLIENTS_BASE, label: t("admin.clients.filter"), pills, @@ -167,7 +167,7 @@ function listPagination(state: ListState, page: ReturnType, t: : p.current ? { current: true, label: String(p.page) } : { href: listHref(state, { page: p.page as number }), label: String(p.page) }), prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined }, - rows: { hidden, label: t("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.go"), value: state.pageSize }, + rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize }, summary: { from: page.from, to: page.to, total: page.total }, }; } @@ -302,7 +302,7 @@ export const clientsDeleteConfirm = withClient((deps, client, id) => { const name = toClientView(client).name; const tt = deps.ctx.t; return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ - breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: tt("admin.clients.title") }, { href: base, label: name }, { label: tt("admin.common.delete") }], + breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: tt("admin.clients.title") }, { href: base, label: name }, { label: tt("common.delete") }], cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"), message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"), }) }, view: "confirm" }); diff --git a/examples/plugins/admin/admin-groups.ts b/examples/plugins/admin/admin-groups.ts index 73ce9d9..12ff1e4 100644 --- a/examples/plugins/admin/admin-groups.ts +++ b/examples/plugins/admin/admin-groups.ts @@ -163,9 +163,9 @@ function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "de function listFilterBar(state: ListState, t: Translate) { const pills: { label: string; remove: string; value: string }[] = []; - if (state.q) pills.push({ label: t("admin.common.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); + if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); return { - applyLabel: t("admin.common.apply"), + applyLabel: t("filter.apply"), clearHref: ADMIN_GROUPS_BASE, label: t("admin.groups.filter"), pills, @@ -188,7 +188,7 @@ function listPagination(state: ListState, page: ReturnType, t: : p.current ? { current: true, label: String(p.page) } : { href: listHref(state, { page: p.page as number }), label: String(p.page) }), prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined }, - rows: { hidden, label: t("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.go"), value: state.pageSize }, + rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize }, summary: { from: page.from, to: page.to, total: page.total }, }; } @@ -208,7 +208,7 @@ export function buildGroupFormModel(opts: { id: "name", label: t("admin.groups.field.name"), name: "name", required: true, value: opts.values?.name ?? "", }; return { - breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: t("admin.common.new") }], + breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: t("common.new") }], error: opts.error, form: { action: ADMIN_GROUPS_BASE, @@ -358,7 +358,7 @@ export const groupsDeleteConfirm = withGroupName((deps, name) => { const base = detailHref(name); const tt = deps.ctx.t; return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ - breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("admin.common.delete") }], + breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }], cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"), message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"), }) }, view: "confirm" }); diff --git a/examples/plugins/admin/admin-permissions.ts b/examples/plugins/admin/admin-permissions.ts index ab36c59..7a52411 100644 --- a/examples/plugins/admin/admin-permissions.ts +++ b/examples/plugins/admin/admin-permissions.ts @@ -148,9 +148,9 @@ function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" function listFilterBar(state: ListState, t: Translate) { const pills: { label: string; remove: string; value: string }[] = []; - if (state.q) pills.push({ label: t("admin.common.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); + if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); return { - applyLabel: t("admin.common.apply"), + applyLabel: t("filter.apply"), clearHref: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.filter"), pills, @@ -173,7 +173,7 @@ function listPagination(state: ListState, page: ReturnType, t: : p.current ? { current: true, label: String(p.page) } : { href: listHref(state, { page: p.page as number }), label: String(p.page) }), prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined }, - rows: { hidden, label: t("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.go"), value: state.pageSize }, + rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize }, summary: { from: page.from, to: page.to, total: page.total }, }; } @@ -193,7 +193,7 @@ export function buildPermissionFormModel(opts: { id: "name", label: t("admin.permissions.field.name"), name: "name", required: true, value: opts.values?.name ?? "", }; return { - breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: t("admin.common.new") }], + breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: t("common.new") }], error: opts.error, form: { action: ADMIN_PERMISSIONS_BASE, @@ -343,7 +343,7 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => { const base = detailHref(name); const tt = deps.ctx.t; return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ - breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: tt("admin.permissions.title") }, { href: base, label: name }, { label: tt("admin.common.delete") }], + breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: tt("admin.permissions.title") }, { href: base, label: name }, { label: tt("common.delete") }], cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.permissions.delete"), message: tt("admin.permissions.deleteMessage", { name }), title: tt("admin.permissions.delete"), }) }, view: "confirm" }); diff --git a/examples/plugins/admin/admin-shared.ts b/examples/plugins/admin/admin-shared.ts index 50a5e4d..a4d112d 100644 --- a/examples/plugins/admin/admin-shared.ts +++ b/examples/plugins/admin/admin-shared.ts @@ -3,12 +3,13 @@ // (themed not-found / capability-unavailable). Ported from the former built-in admin screens; // everything imports the host only through the #plugin-api barrel. -import { can, createTranslator, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api"; +import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api"; import enUS from "./i18n/en-US.ts"; -// This plugin's English, for a view model built outside a request (its unit tests). At runtime the -// handlers pass ctx.t, which reads this catalog in the visitor's locale first, then the host's. -export const ADMIN_EN: Translate = createTranslator({ catalogs: [enUS], locale: "en-US" }); +// This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and +// Search), for a view model built outside a request: its unit tests. At runtime the handlers pass +// ctx.t, which reads this catalog in the visitor's locale first, then the host's. +export const ADMIN_EN: Translate = englishTranslator(enUS); export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section export const ADMIN_USERS_BASE = "/admin/users"; diff --git a/examples/plugins/admin/admin-users.ts b/examples/plugins/admin/admin-users.ts index 3f58bf5..d963d1b 100644 --- a/examples/plugins/admin/admin-users.ts +++ b/examples/plugins/admin/admin-users.ts @@ -151,7 +151,7 @@ function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "des return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true }; }), rows: rows.map((u) => ({ - actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("admin.common.edit") }], + actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }], cells: [ { user: { initials: u.initials, name: u.name } }, u.email, @@ -164,7 +164,7 @@ function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "des function listFilterBar(state: ListState, total: number, t: Translate) { const pills: { label: string; remove: string; value: string }[] = []; - if (state.q) pills.push({ label: t("admin.common.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); + if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); if (state.status !== "all") pills.push({ label: t("admin.users.status.label"), remove: listHref(state, { page: 1, status: "all" }), value: t(`admin.users.status.${state.status}`) }); return { applyLabel: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback @@ -196,7 +196,7 @@ function listPagination(state: ListState, page: ReturnType, t: : p.current ? { current: true, label: String(p.page) } : { href: listHref(state, { page: p.page as number }), label: String(p.page) }), prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined }, - rows: { hidden, label: t("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.go"), value: state.pageSize }, + rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize }, summary: { from: page.from, to: page.to, total: page.total }, }; } @@ -239,7 +239,7 @@ export function buildUserFormModel(opts: { if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" }); return { - breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("admin.common.edit") : t("admin.common.new") }], + breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }], edit: editing ? { deleteAction: `${idPath}/delete`, id: view!.id, @@ -351,7 +351,7 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => { const view = toUserView(identity); const tt = deps.ctx.t; return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ - breadcrumbs: [{ href: ADMIN_USERS_BASE, label: tt("admin.users.title") }, { href: back, label: view.name }, { label: tt("admin.common.delete") }], + breadcrumbs: [{ href: ADMIN_USERS_BASE, label: tt("admin.users.title") }, { href: back, label: view.name }, { label: tt("common.delete") }], cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"), message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"), }) }, view: "confirm" }); diff --git a/examples/plugins/admin/i18n/en-US.ts b/examples/plugins/admin/i18n/en-US.ts index 9ebd73e..800e371 100644 --- a/examples/plugins/admin/i18n/en-US.ts +++ b/examples/plugins/admin/i18n/en-US.ts @@ -42,20 +42,9 @@ const messages = { "admin.clients.validation.redirectUri": "\"{{uri}}\" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.", "admin.clients.validation.redirectUris": "Add at least one redirect URI.", - "admin.common.actions": "Actions", - "admin.common.add": "Add", - "admin.common.apply": "Apply", - "admin.common.cancel": "Cancel", "admin.common.chooseMember": "Choose a user or group…", - "admin.common.delete": "Delete", - "admin.common.edit": "Edit", - "admin.common.go": "Go", "admin.common.group": "Group", "admin.common.member": "Member", - "admin.common.new": "New", - "admin.common.remove": "Remove", - "admin.common.rows": "Rows", - "admin.common.search": "Search", "admin.common.type": "Type", "admin.common.user": "User", diff --git a/examples/plugins/admin/i18n/sv-SE.ts b/examples/plugins/admin/i18n/sv-SE.ts index 6a2b85f..819036f 100644 --- a/examples/plugins/admin/i18n/sv-SE.ts +++ b/examples/plugins/admin/i18n/sv-SE.ts @@ -42,20 +42,9 @@ const messages: AdminMessages = { "admin.clients.validation.redirectUri": "\"{{uri}}\" är inte en giltig omdirigerings-URI — använd en absolut URL som https://app.example.com/callback.", "admin.clients.validation.redirectUris": "Lägg till minst en omdirigerings-URI.", - "admin.common.actions": "Åtgärder", - "admin.common.add": "Lägg till", - "admin.common.apply": "Använd", - "admin.common.cancel": "Avbryt", "admin.common.chooseMember": "Välj en användare eller grupp…", - "admin.common.delete": "Ta bort", - "admin.common.edit": "Redigera", - "admin.common.go": "Visa", "admin.common.group": "Grupp", "admin.common.member": "Medlem", - "admin.common.new": "Ny", - "admin.common.remove": "Ta bort", - "admin.common.rows": "Rader", - "admin.common.search": "Sök", "admin.common.type": "Typ", "admin.common.user": "Användare", diff --git a/examples/plugins/admin/views/partials/client-form-body.ejs b/examples/plugins/admin/views/partials/client-form-body.ejs index 8fdd21e..63eae55 100644 --- a/examples/plugins/admin/views/partials/client-form-body.ejs +++ b/examples/plugins/admin/views/partials/client-form-body.ejs @@ -23,7 +23,7 @@ <%= t("admin.clients.field.typeHint") %> diff --git a/examples/plugins/admin/views/partials/confirm-body.ejs b/examples/plugins/admin/views/partials/confirm-body.ejs index 2bab2aa..84a1e51 100644 --- a/examples/plugins/admin/views/partials/confirm-body.ejs +++ b/examples/plugins/admin/views/partials/confirm-body.ejs @@ -10,7 +10,7 @@
">

<%= locals.message %>

diff --git a/examples/plugins/admin/views/partials/group-detail-body.ejs b/examples/plugins/admin/views/partials/group-detail-body.ejs index 2cbf474..73a37e0 100644 --- a/examples/plugins/admin/views/partials/group-detail-body.ejs +++ b/examples/plugins/admin/views/partials/group-detail-body.ejs @@ -19,9 +19,9 @@

<%= t("admin.groups.members") %>

<% if (members.rows.length) { -%> -
+
<%= t("admin.groups.membersOf", { name: group.name }) %>
<%= t("admin.common.member") %><%= t("admin.common.type") %><%= t("admin.common.actions") %>
<% members.rows.forEach((m) => { -%> - + <% }) -%>
<%= t("admin.groups.membersOf", { name: group.name }) %>
<%= t("admin.common.member") %><%= t("admin.common.type") %><%= t("table.actions") %>
<%= m.label %><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %>
<%= m.label %><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %>
<% } else { -%> @@ -31,7 +31,7 @@

<%= t("admin.groups.addMember") %>

<% if (add.options.length) { -%> -
+
<% } else { -%>

<%= t("admin.groups.allMembers") %>

<% } -%> diff --git a/examples/plugins/admin/views/partials/group-form-body.ejs b/examples/plugins/admin/views/partials/group-form-body.ejs index 28b98f0..dd10dea 100644 --- a/examples/plugins/admin/views/partials/group-form-body.ejs +++ b/examples/plugins/admin/views/partials/group-form-body.ejs @@ -19,7 +19,7 @@ <%= t("admin.groups.firstMemberHint") %> diff --git a/examples/plugins/admin/views/partials/permission-detail-body.ejs b/examples/plugins/admin/views/partials/permission-detail-body.ejs index 00c4c5c..1061264 100644 --- a/examples/plugins/admin/views/partials/permission-detail-body.ejs +++ b/examples/plugins/admin/views/partials/permission-detail-body.ejs @@ -21,7 +21,7 @@

<%= t("admin.permissions.assignedTo") %>

<% if (members.rows.length) { -%> -
+
<%= t("admin.groups.membersOf", { name: permission.name }) %>
<%= t("admin.common.member") %><%= t("admin.common.type") %><%= t("admin.common.actions") %>
<% members.rows.forEach((m) => { -%> <% }) -%> diff --git a/examples/plugins/admin/views/partials/permission-form-body.ejs b/examples/plugins/admin/views/partials/permission-form-body.ejs index 1be450b..dfb99da 100644 --- a/examples/plugins/admin/views/partials/permission-form-body.ejs +++ b/examples/plugins/admin/views/partials/permission-form-body.ejs @@ -19,7 +19,7 @@ A permission exists once assigned; add more users or groups after creating it. diff --git a/examples/plugins/admin/views/partials/user-form-body.ejs b/examples/plugins/admin/views/partials/user-form-body.ejs index bb889a6..f4de774 100644 --- a/examples/plugins/admin/views/partials/user-form-body.ejs +++ b/examples/plugins/admin/views/partials/user-form-body.ejs @@ -22,7 +22,7 @@ <%- include("partials/field", field) %> <% }) -%> diff --git a/examples/plugins/scheduling/i18n/en-US.ts b/examples/plugins/scheduling/i18n/en-US.ts index 3f72e47..e3120e7 100644 --- a/examples/plugins/scheduling/i18n/en-US.ts +++ b/examples/plugins/scheduling/i18n/en-US.ts @@ -5,13 +5,11 @@ import type { PluralMessage } from "#plugin-api"; const messages = { - "scheduling.cancel": "Cancel", "scheduling.field.assignee": "Assignee", "scheduling.field.end": "End", "scheduling.field.start": "Start", "scheduling.field.title": "Shift title", "scheduling.filter.label": "Filter shifts", - "scheduling.filter.search": "Search", "scheduling.filter.searchLabel": "Search shifts", "scheduling.filter.searchPlaceholder": "Search title or assignee…", "scheduling.form.submit": "Create shift", diff --git a/examples/plugins/scheduling/i18n/sv-SE.ts b/examples/plugins/scheduling/i18n/sv-SE.ts index 7754a08..24edc86 100644 --- a/examples/plugins/scheduling/i18n/sv-SE.ts +++ b/examples/plugins/scheduling/i18n/sv-SE.ts @@ -1,13 +1,11 @@ import type { SchedulingMessages } from "./en-US.ts"; const messages: SchedulingMessages = { - "scheduling.cancel": "Avbryt", "scheduling.field.assignee": "Tilldelad", "scheduling.field.end": "Slut", "scheduling.field.start": "Start", "scheduling.field.title": "Passets namn", "scheduling.filter.label": "Filtrera pass", - "scheduling.filter.search": "Sök", "scheduling.filter.searchLabel": "Sök pass", "scheduling.filter.searchPlaceholder": "Sök på namn eller person…", "scheduling.form.submit": "Skapa pass", diff --git a/examples/plugins/scheduling/shifts.test.ts b/examples/plugins/scheduling/shifts.test.ts index f4420e1..a91b06c 100644 --- a/examples/plugins/scheduling/shifts.test.ts +++ b/examples/plugins/scheduling/shifts.test.ts @@ -4,14 +4,14 @@ import { Readable } from "node:stream"; import test from "node:test"; // Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may // refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches. -import { createTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api"; +import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api"; import enUS from "./i18n/en-US.ts"; import { assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, } from "./shifts.ts"; -const t = createTranslator({ catalogs: [enUS], locale: "en-US" }); // this plugin's own catalog, as the host would pass it +const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } }; function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { diff --git a/examples/plugins/scheduling/shifts.ts b/examples/plugins/scheduling/shifts.ts index 1835e09..2db2540 100644 --- a/examples/plugins/scheduling/shifts.ts +++ b/examples/plugins/scheduling/shifts.ts @@ -6,12 +6,13 @@ // pure functions against a mock upstream with no network (README.md → Local dev & test story). // One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins). -import { can, createTranslator, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api"; +import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api"; import enUS from "./i18n/en-US.ts"; -// The plugin's own English, for a view model built outside a request (its unit tests). At runtime a -// handler passes ctx.t, which reads this plugin's catalog for the visitor's locale first. -const EN: Translate = createTranslator({ catalogs: [enUS], locale: "en-US" }); +// The plugin's own English (its catalog, then the host's), for a view model built outside a request: +// its unit tests. At runtime a handler passes ctx.t, which reads this catalog in the visitor's +// locale first, then the host's. +const EN: Translate = englishTranslator(enUS); export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SHIFTS_PATH = "/scheduling/shifts"; @@ -102,10 +103,10 @@ export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; er count: t("scheduling.shifts.count", { count: opts.shifts.length }), ...(opts.error ? { error: opts.error } : {}), filterBar: { - applyLabel: t("scheduling.filter.search"), + applyLabel: t("filter.search"), clearHref: SHIFTS_PATH, label: t("scheduling.filter.label"), - pills: opts.q ? [{ label: t("scheduling.filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [], + pills: opts.q ? [{ label: t("filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [], rows: [[ { label: t("scheduling.filter.searchLabel"), name: "q", placeholder: t("scheduling.filter.searchPlaceholder"), type: "search", value: opts.q }, { type: "spacer" }, @@ -139,7 +140,7 @@ export function buildFormModel(opts: { chrome: PageChrome; errors?: Record (view, data) => render(view, { ...i18nLocals(ctx), ...data }); - const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...i18nLocals(ctx), ...data }); + // Every view renders with its context's i18n locals (t/locale/dir/localeSwitch/localeParam) merged + // in, so a view — core or plugin, at any include depth — calls `t(...)` without its handler passing + // it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views. + // They are merged LAST: these names are reserved (README → Building plugins), and a handler that + // happens to use one loses that key rather than breaking the shell that renders around it. + const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(ctx) }); + const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(ctx) }); const sendHtml = (res: ServerResponse, status: number, html: string): void => { res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); @@ -172,7 +174,7 @@ export function createApp(options: AppOptions = {}): Server { const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise => { // Error pages can render before this request has a context at all (a throw on the way to one), // so they start on the built-in English and switch to the visitor's locale once it is resolved. - let renderPage: ViewRenderer = (view, data) => render(view, { ...ENGLISH_LOCALS, ...data }); + let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS }); try { const method = req.method ?? "GET"; const url = new URL(req.url ?? "/", "http://localhost"); diff --git a/src/http/context.ts b/src/http/context.ts index c48fec3..bb92138 100644 --- a/src/http/context.ts +++ b/src/http/context.ts @@ -23,16 +23,19 @@ export interface RequestContext { // Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its // page renders the native app shell; the host builds it per request (anonymous default otherwise). chrome: PageChrome; - // Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to - // log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by - // requestId. Additive, stable per the contract; defaults to a silent logger off the request path. - locale: string; // the locale this request is served in, e.g. "sv-SE" — also + // The locale this request is served in, e.g. "sv-SE" — also what says. + locale: string; // Carry the visitor's chosen locale onto a link this page renders. A no-op unless the request // asked for one with ?locale (there is no locale cookie — the URL is where the choice lives), and // on off-site URLs. The host already does this for the chrome and its own redirects; a plugin // wraps the hrefs it builds itself. localeHref(href: string): string; - locales: string[]; // every installed locale, sorted — for a plugin building its own language picker + // Every installed locale, sorted. With `localeLabel` (from #plugin-api) it is what a plugin needs + // to build its own language picker; the host's own picker is already in the shell. + locales: string[]; + // Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to + // log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by + // requestId. Additive, stable per the contract; defaults to a silent logger off the request path. log: Log; params: Record; // path params from the route match, e.g. /users/:id → { id } permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check diff --git a/src/i18n/catalog.test.ts b/src/i18n/catalog.test.ts index ec63df1..25b3303 100644 --- a/src/i18n/catalog.test.ts +++ b/src/i18n/catalog.test.ts @@ -11,7 +11,7 @@ test("a complete translation reports nothing", () => { }); test("a missing or unknown key is reported", () => { - const missing = parity("sv-SE", { "shifts.count": { one: "a", other: "b" } }); + const missing = parity("sv-SE", { "shifts.count": { one: "{{count}} pass", other: "{{count}} pass" } }); assert.equal(missing.length, 1); assert.match(missing[0] ?? "", /missing key "greeting"/); @@ -21,21 +21,21 @@ test("a missing or unknown key is reported", () => { }); test("a key must stay the same kind as in the baseline", () => { - const flat = parity("sv-SE", { greeting: "Hej", "shifts.count": "pass" }); + const flat = parity("sv-SE", { greeting: "Hej", "shifts.count": "{{count}} pass" }); assert.equal(flat.length, 1); assert.match(flat[0] ?? "", /"shifts.count" must be a plural message/); - const plural = parity("sv-SE", { greeting: { one: "Hej", other: "Hej" }, "shifts.count": { one: "a", other: "b" } }); + const plural = parity("sv-SE", { greeting: { one: "Hej", other: "Hej" }, "shifts.count": { one: "{{count}} pass", other: "{{count}} pass" } }); assert.equal(plural.length, 1); assert.match(plural[0] ?? "", /"greeting" must be a string/); }); test("a plural message must cover exactly its own locale's categories", () => { - const short = parity("cs-CZ", { greeting: "Ahoj", "shifts.count": { one: "a", other: "b" } }); + const short = parity("cs-CZ", { greeting: "Ahoj", "shifts.count": { one: "{{count}} směna", other: "{{count}} směn" } }); assert.equal(short.length, 1); assert.match(short[0] ?? "", /"shifts\.count".*cs-CZ.*few, many/); - const long = parity("sv-SE", { greeting: "Hej", "shifts.count": { few: "x", one: "a", other: "b" } }); + const long = parity("sv-SE", { greeting: "Hej", "shifts.count": { few: "{{count}} pass", one: "{{count}} pass", other: "{{count}} pass" } }); assert.equal(long.length, 1); assert.match(long[0] ?? "", /"shifts\.count".*few/); }); @@ -55,3 +55,13 @@ test("isCatalog accepts strings and plural objects, rejects anything else", () = assert.equal(isCatalog(null), false); assert.equal(isCatalog([]), false); }); + +test("a translation must interpolate exactly what the baseline does", () => { + const withVars: Catalog = { hi: "Hi {{name}}, you have {{n}} left" }; + const check = (catalog: Catalog): string[] => checkCatalog({ baseline: withVars, baselineLocale: "en-US", catalog, locale: "sv-SE" }); + + assert.deepEqual(check({ hi: "Hej {{name}}, du har {{n}} kvar" }), []); + assert.match(check({ hi: "Hej, du har {{n}} kvar" })[0] ?? "", /"hi" never uses \{\{name\}\}/); // dropped ⇒ a blank on screen + assert.match(check({ hi: "Hej {{namn}}, du har {{n}} kvar" })[0] ?? "", /never uses \{\{name\}\}/); // misspelled ⇒ both problems + assert.match(check({ hi: "Hej {{name}} {{n}} {{extra}}" })[0] ?? "", /uses \{\{extra\}\}/); // never supplied ⇒ renders raw +}); diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 09c1413..b8d58c0 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -12,6 +12,7 @@ export type Catalog = Record; export const DEFAULT_LOCALE = "en-US"; const CATEGORIES: ReadonlySet = new Set(["few", "many", "one", "other", "two", "zero"]); +const PLACEHOLDER = /\{\{(\w+)\}\}/g; export function isPluralMessage(value: Message): value is PluralMessage { return typeof value !== "string"; @@ -51,6 +52,7 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari problems.push(`"${key}" must be a ${isPluralMessage(expected) ? "plural message" : "string"}, like ${baselineLocale}`); continue; } + for (const problem of placeholderProblems(key, expected, actual, baselineLocale)) problems.push(problem); if (!isPluralMessage(actual)) continue; const forms = new Set(Object.keys(actual)); const missing = categories.filter((category) => !forms.has(category)); @@ -67,6 +69,28 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari return problems; } +// A translation must interpolate exactly what the baseline does: a dropped {{name}} renders +// "Signed in as ", a misspelled one renders the placeholder itself — the half-translated class this +// check exists to stop, and neither is visible from the key set alone. +function placeholderProblems(key: string, expected: Message, actual: Message, baselineLocale: string): string[] { + const wanted = placeholders(expected); + const got = placeholders(actual); + const missing = [...wanted].filter((name) => !got.has(name)); + const unknown = [...got].filter((name) => !wanted.has(name)); + return [ + ...(missing.length ? [`"${key}" never uses ${missing.map((n) => `{{${n}}}`).join(", ")}, which ${baselineLocale} does`] : []), + ...(unknown.length ? [`"${key}" uses ${unknown.map((n) => `{{${n}}}`).join(", ")}, which ${baselineLocale} does not supply`] : []), + ]; +} + +function placeholders(message: Message): Set { + const names = new Set(); + for (const text of typeof message === "string" ? [message] : Object.values(message)) { + for (const match of (text ?? "").matchAll(PLACEHOLDER)) names.add(match[1] as string); + } + return names; +} + // The plural categories a locale actually selects, sorted; unknown tags fall back to English's. export function pluralCategories(locale: string): Intl.LDMLPluralRule[] { try { diff --git a/src/i18n/english.ts b/src/i18n/english.ts index 5879df3..da4e336 100644 --- a/src/i18n/english.ts +++ b/src/i18n/english.ts @@ -3,13 +3,20 @@ // view model built outside a request, an app created without `i18n` — so an unwired path renders // real English rather than bare keys. server.ts replaces it with the discovered catalogs at boot. -import { DEFAULT_LOCALE } from "./catalog.ts"; +import { type Catalog, DEFAULT_LOCALE } from "./catalog.ts"; import enUS from "./locales/en-US.ts"; import { createI18n, type I18n } from "./runtime.ts"; import { createTranslator, type Translate } from "./translate.ts"; export const ENGLISH: Translate = createTranslator({ catalogs: [enUS], locale: DEFAULT_LOCALE }); +// A plugin's own English: its catalog first, the host's behind it — the same chain the host builds +// per request, minus the locale. A plugin uses it as the default for a view model built outside a +// request (its unit tests), so the generic words it reuses from core still read as words. +export function englishTranslator(catalog: Catalog): Translate { + return createTranslator({ catalogs: [catalog, enUS], locale: DEFAULT_LOCALE }); +} + export const ENGLISH_I18N: I18n = createI18n({ available: [DEFAULT_LOCALE], core: new Map([[DEFAULT_LOCALE, enUS]]), diff --git a/src/i18n/load.test.ts b/src/i18n/load.test.ts index 646a62a..42ea40e 100644 --- a/src/i18n/load.test.ts +++ b/src/i18n/load.test.ts @@ -99,6 +99,26 @@ test("a plugin with translations but no en-US baseline is an error", async () => await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /shop/); }); +test("a mounted locales/ adds a language, and replaces a shipped one wholesale", async () => { + const { localesDir, pluginsDir } = await fixture({ + "locales/en-US.ts": catalog(`{ hello: "Hello" }`), + "locales/sv-SE.ts": catalog(`{ hello: "Hej" }`), + "mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`), + "mounted/sv-SE.ts": catalog(`{ hello: "Tjena" }`), + }); + const loaded = await loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginsDir }); + assert.deepEqual(loaded.available, ["en-US", "nb-NO", "sv-SE"]); + assert.deepEqual(loaded.core.get("sv-SE"), { hello: "Tjena" }); // the operator's file wins outright +}); + +test("a mounted catalog is held to the same baseline as a shipped one", async () => { + const { localesDir, pluginsDir } = await fixture({ + "locales/en-US.ts": catalog(`{ hello: "Hello", bye: "Bye" }`), + "mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`), // no `bye` ⇒ half the app would be English + }); + await assert.rejects(loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginsDir }), /nb-NO.*missing key "bye"/s); +}); + test("a plugin without an i18n folder is fine", async () => { const { localesDir, pluginsDir } = await fixture({ "locales/en-US.ts": catalog(`{ hello: "Hello" }`) }); const loaded = await loadI18n({ localesDir, pluginIds: ["plain"], pluginsDir }); diff --git a/src/i18n/load.ts b/src/i18n/load.ts index a85f014..c3be096 100644 --- a/src/i18n/load.ts +++ b/src/i18n/load.ts @@ -12,7 +12,14 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { checkCatalog, DEFAULT_LOCALE, isCatalog, type Catalog } from "./catalog.ts"; import { PLUGINS_DIR } from "../plugin-host/discovery.ts"; +const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +// The shipped catalogs, and the drop-in mount root an operator adds their own to — a folder there +// is a whole locale: a new tag adds a language, an existing one replaces the shipped catalog for it +// (and is held to the same parity check, so a partial replacement fails the boot rather than +// leaving half the app in English). Mirrors plugins/ and config/; ships empty. export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales"); +export const MOUNTED_LOCALES_DIR = join(rootDir, "locales"); // A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts. Anything else in // the folder is a mistake worth stopping for. @@ -20,6 +27,8 @@ const LOCALE_FILE = /^([a-z]{2,3}-[A-Z]{2})\.ts$/; export interface LoadI18nOptions { localesDir?: string; + logger?: Pick; // warn-level diagnostics (a plugin missing an installed locale); defaults to console + mountedLocalesDir?: string; pluginIds?: string[]; // discovered plugins; their i18n/ folders are loaded under their id pluginsDir?: string; } @@ -32,10 +41,13 @@ export interface LoadedI18n { export async function loadI18n(options: LoadI18nOptions = {}): Promise { const localesDir = options.localesDir ?? LOCALES_DIR; + const mountedDir = options.mountedLocalesDir ?? MOUNTED_LOCALES_DIR; const pluginsDir = options.pluginsDir ?? PLUGINS_DIR; + const logger = options.logger ?? console; const errors: string[] = []; const core = await readSet(localesDir, "core", errors); + for (const [locale, catalog] of await readSet(mountedDir, "locales", errors)) core.set(locale, catalog); if (!core.has(DEFAULT_LOCALE)) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`); checkSet(core, "core", errors); const available = [...core.keys()].sort(); @@ -51,6 +63,10 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise !set.has(locale)); + if (gaps.length) logger.warn(`[i18n] plugins/${id}: no ${gaps.join(", ")} — those strings render in ${DEFAULT_LOCALE}`); plugins.set(id, set); } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 258035b..084f13d 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -37,6 +37,16 @@ const messages = { "brand.sub": "Console", + // Generic UI verbs every screen needs. A plugin reuses these (the lookup falls through to core) + // and keeps its own catalog for its domain words — see README → Languages. + "common.add": "Add", + "common.cancel": "Cancel", + "common.delete": "Delete", + "common.edit": "Edit", + "common.new": "New", + "common.remove": "Remove", + "common.save": "Save", + "consent.allow": "Allow", "consent.deny": "Deny", "consent.notYou": "Not you?", diff --git a/src/i18n/locales/sv-SE.ts b/src/i18n/locales/sv-SE.ts index 970d5ad..f8ed9da 100644 --- a/src/i18n/locales/sv-SE.ts +++ b/src/i18n/locales/sv-SE.ts @@ -30,6 +30,14 @@ const messages: CoreMessages = { "brand.sub": "Konsol", + "common.add": "Lägg till", + "common.cancel": "Avbryt", + "common.delete": "Ta bort", + "common.edit": "Redigera", + "common.new": "Ny", + "common.remove": "Ta bort", + "common.save": "Spara", + "consent.allow": "Tillåt", "consent.deny": "Neka", "consent.notYou": "Inte du?", diff --git a/src/i18n/runtime.ts b/src/i18n/runtime.ts index 9419c2b9506a2a304dc96b5ac0211efd633b81f7..d03420c394d8a187fdd55ef1065249cea8101198 100644 GIT binary patch delta 14 Wcmeys_knN2Z5Bp_&39O)FaZEDT?N(v delta 14 Wcmeys_knN2Z5Bp`&39O)FaZECodvc4 diff --git a/src/i18n/view-locals.ts b/src/i18n/view-locals.ts index 9a482c7..a76415c 100644 --- a/src/i18n/view-locals.ts +++ b/src/i18n/view-locals.ts @@ -4,7 +4,6 @@ import { DEFAULT_LOCALE } from "./catalog.ts"; import { ENGLISH } from "./english.ts"; -import type { RequestContext } from "../http/context.ts"; import { localeHref, localeLabel, textDirection } from "./locale.ts"; import type { Translate } from "./translate.ts"; @@ -19,28 +18,46 @@ export interface I18nLocals { dir: "ltr" | "rtl"; locale: string; localeHref: (href: string) => string; + // The locale to carry as a hidden field, or null when the visitor never asked for one. A GET form + // replaces the whole query string, so a link-carrying wrapper can't reach it — the form must. + localeParam: string | null; localeSwitch: LocaleChoice[]; locales: string[]; t: Translate; } +// Just the request fields a render needs, so this module stays a leaf of src/i18n/ rather than +// depending on the HTTP layer that calls it. +export interface I18nRequest { + locale: string; + localeHref: (href: string) => string; + locales: string[]; + t: Translate; + url: URL; +} + // For a render with no request behind it — a partial exercised directly, a one-off render: English, // left-to-right, no language picker. export const ENGLISH_LOCALS: I18nLocals = { dir: "ltr", locale: DEFAULT_LOCALE, localeHref: (href) => href, + localeParam: null, localeSwitch: [], locales: [DEFAULT_LOCALE], t: ENGLISH, }; -export function i18nLocals(ctx: RequestContext): I18nLocals { +export function i18nLocals(ctx: I18nRequest): I18nLocals { const here = `${ctx.url.pathname}${ctx.url.search}`; + // ctx.localeHref is a no-op unless the URL asked for a locale, so it is also the honest answer to + // "did it?" — asking the function that decides keeps the two from drifting apart. + const carried = ctx.localeHref("/") === "/" ? null : ctx.locale; return { dir: textDirection(ctx.locale), locale: ctx.locale, localeHref: (href) => ctx.localeHref(href), + localeParam: carried, localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })), locales: ctx.locales, t: ctx.t, diff --git a/src/plugin-host/plugin-api.ts b/src/plugin-host/plugin-api.ts index a7a929b..5f2cc6a 100644 --- a/src/plugin-host/plugin-api.ts +++ b/src/plugin-host/plugin-api.ts @@ -14,6 +14,12 @@ export { can, check, GuardError, requireSession } from "../auth/guards.ts"; // authoring a plugin's own catalogs (plugins//i18n/.ts) and for building a translator // in a unit test. `PluralMessage` types a message that varies with a count. export { createTranslator } from "../i18n/translate.ts"; +// `englishTranslator(yourCatalog)` chains your catalog in front of the host's English — the default +// for a view model built outside a request, so core words you reuse still read as words in a test. +export { englishTranslator } from "../i18n/english.ts"; +// `localeLabel(tag)` names a locale in its own language ("svenska (Sverige)") — what ctx.locales +// needs to become a language picker of your own. +export { localeLabel } from "../i18n/locale.ts"; export type { Translate, TranslateVars } from "../i18n/translate.ts"; export type { Catalog, PluralMessage } from "../i18n/catalog.ts"; export { parseListQuery } from "../ui/list-query.ts"; diff --git a/src/server.ts b/src/server.ts index 4644b55..d8d38f6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -38,13 +38,14 @@ const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.rev const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin log.info("plugins discovered", { count: plugins.length, ids: plugins.map((p) => p.id).join(", ") }); -await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot - // Translation catalogs: the core locales plus each discovered plugin's — fails loud if a locale -// drifts from its en-US baseline, so a half-translated deploy never reaches a visitor. -const i18n = createI18n(await loadI18n({ pluginIds: plugins.map((p) => p.id) })); +// drifts from its en-US baseline, so a half-translated deploy never reaches a visitor. Loaded +// before the boot hooks, so a catalog mismatch aborts before a plugin's onBoot has any side effect. +const i18n = createI18n(await loadI18n({ logger: log, pluginIds: plugins.map((p) => p.id) })); log.info("locales loaded", { locales: i18n.available.join(", ") }); +await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot + const server = createApp({ // Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless // APP_URL is set, so the redirect is fully off — and costs nothing — when unconfigured. diff --git a/src/ui/chrome.ts b/src/ui/chrome.ts index 961db62..f8f8ae5 100644 --- a/src/ui/chrome.ts +++ b/src/ui/chrome.ts @@ -11,7 +11,7 @@ import type { Translate } from "../i18n/translate.ts"; import { type MenuConfig } from "./menu-config.ts"; import { composeNav, type NavNode } from "./nav.ts"; import type { Plugin } from "../plugin-host/plugin.ts"; -import { shellUser, type ShellUser } from "./shell-context.ts"; +import { branding, shellUser, type ShellUser } from "./shell-context.ts"; // The "Dashboard" link to the gated app home (/dashboard). It targets a gated route, so it's shown // only to a signed-in user (an anonymous click would only dead-end at /login). Its label is a @@ -62,15 +62,15 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome { if (target) markCurrent(nav, target); } - const b = opts.menu.branding; // The sign-in link keeps the visitor's locale, and brings it back afterwards via return_to. const returnTo = opts.currentPath ? `/login?return_to=${encodeURIComponent(carryLocale(opts.currentPath))}` : "/login"; + const { theme, ...brand } = branding(opts.menu, t); return { - brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: t(b.name), ...(b.sub != null ? { sub: t(b.sub) } : {}) }, + brand, csrfToken: opts.csrfToken ?? "", nav: carryLocaleInto(nav, carryLocale), signInHref: carryLocale(returnTo), - ...(b.theme != null ? { theme: b.theme } : {}), + ...(theme != null ? { theme } : {}), user: shellUser(opts.user, t), }; } diff --git a/src/ui/menu.test.ts b/src/ui/menu.test.ts index 2eae158..4459b88 100644 --- a/src/ui/menu.test.ts +++ b/src/ui/menu.test.ts @@ -3,9 +3,10 @@ import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; import ejs from "ejs"; +import { ENGLISH_LOCALS } from "../i18n/view-locals.ts"; const menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs"); -const render = (data: Record = {}): Promise => ejs.renderFile(menu, data); +const render = (data: Record = {}): Promise => ejs.renderFile(menu, { ...ENGLISH_LOCALS, ...data }); const flat = (s: string): string => s.replace(/>\s+<").replace(/\s+/g, " ").trim(); test("menu renders trigger, positioning, the item matrix and check groups", async () => { diff --git a/src/ui/nav.ts b/src/ui/nav.ts index cd484f8..c39d9bc 100644 --- a/src/ui/nav.ts +++ b/src/ui/nav.ts @@ -6,6 +6,7 @@ // the override (+ branding); this helper only transforms data, so its result is per-deployment // up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission). +import { ENGLISH } from "../i18n/english.ts"; import type { Translate } from "../i18n/translate.ts"; export interface NavNode { @@ -42,7 +43,7 @@ export function composeNav( fragments: NavNode[][] = [], override: NavOverride = {}, permissions: string[] = [], - t: Translate = (key) => key, + t: Translate = ENGLISH, ): NavNode[] { let nodes: NavNode[] = fragments.flat(); if (override.rename) nodes = renameTree(nodes, override.rename); diff --git a/src/ui/shell-context.ts b/src/ui/shell-context.ts index f017e23..3d96abf 100644 --- a/src/ui/shell-context.ts +++ b/src/ui/shell-context.ts @@ -10,6 +10,18 @@ import { ENGLISH } from "../i18n/english.ts"; import type { Translate } from "../i18n/translate.ts"; import { type MenuConfig } from "./menu-config.ts"; +// The brand block both the chrome and the shell model carry. `name`/`sub` pass through `t`, so a +// catalog key is translated and an operator's own wording renders as written. +export function branding(menu: MenuConfig, t: Translate): { logo?: string; name: string; sub?: string; theme?: string } { + const b = menu.branding; + return { + ...(b.logo != null ? { logo: b.logo } : {}), + name: t(b.name), + ...(b.sub != null ? { sub: t(b.sub) } : {}), + ...(b.theme != null ? { theme: b.theme } : {}), + }; +} + export interface ShellUser { email: string; initials: string; @@ -44,14 +56,14 @@ export function buildShellContext(opts: { title: string; user?: User | null; }): ShellModel { - const b = opts.menu.branding; const t = opts.t ?? ENGLISH; + const { theme, ...brand } = branding(opts.menu, t); return { - brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: t(b.name), ...(b.sub != null ? { sub: t(b.sub) } : {}) }, + brand, ...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}), csrfToken: opts.csrfToken ?? "", ...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}), - ...(b.theme != null ? { theme: b.theme } : {}), + ...(theme != null ? { theme } : {}), title: opts.title, user: shellUser(opts.user, t), }; diff --git a/views/partials/auth-card.ejs b/views/partials/auth-card.ejs index 484d3bf..358c9be 100644 --- a/views/partials/auth-card.ejs +++ b/views/partials/auth-card.ejs @@ -18,15 +18,15 @@ const alt = locals.alt; -%>
action="<%= locals.action %>"<% } %>> -
<% if (back) { %><%= back.label %><% } %>

<%= locals.title %>

<% if (locals.sub) { %>

<%= locals.sub %>

<% } %>
+
<% if (back) { %><%= back.label %><% } %>

<%= locals.title %>

<% if (locals.sub) { %>

<%= locals.sub %>

<% } %>
<% if (providers.length) { -%> <% } -%>
<%- locals.body || "" %>
<% if (alt) { -%> -

<%= alt.text %> <%= alt.label %>

+

<%= alt.text %> <%= alt.label %>

<% } -%> diff --git a/views/partials/data-table.ejs b/views/partials/data-table.ejs index 4fcbe2f..3018b19 100644 --- a/views/partials/data-table.ejs +++ b/views/partials/data-table.ejs @@ -8,6 +8,8 @@ Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? } user + rowHeader cells render as
+ <% } else { -%> <% } -%> @@ -53,7 +55,7 @@ <% } else if (cell.user) { -%> <% } else if (cell.rowHeader) { -%> - + <% } else if (cell.badge) { -%> <% } else if (cell.html != null) { -%> @@ -65,7 +67,7 @@ <% if (withActions) { -%> <% if ((row.actions || []).length) { -%> <% } else { -%> diff --git a/views/partials/filter-bar.ejs b/views/partials/filter-bar.ejs index bcd9668..85dc033 100644 --- a/views/partials/filter-bar.ejs +++ b/views/partials/filter-bar.ejs @@ -8,6 +8,8 @@ select { name, label, value?, options:{value,label}[] } chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes) daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} } + The form is a GET, which replaces the whole query string — so the visitor's chosen language rides + along as a hidden input, and every href here (pills, clear) is run through localeHref. %><% const action = locals.action || ""; const label = locals.label || t("filter.label"); @@ -17,7 +19,10 @@ const applyLabel = locals.applyLabel || t("filter.apply"); const eq = (a, b) => String(a ?? "") === String(b); -%> - action="<%= action %>"<% } %> aria-label="<%= label %>"> + action="<%= localeHref(action) %>"<% } %> aria-label="<%= label %>"> +<% if (localeParam) { -%> + +<% } -%> <% rows.forEach((row) => { -%>
<% row.forEach((c) => { -%> @@ -39,7 +44,7 @@ <% }) -%>
<% if (pills.length) { -%> -
"><%= t("filter.applied") %><% pills.forEach((p) => { %><%= p.label %>: <%= p.value %> "><% }) %><%= t("filter.clearAll") %>
+
"><%= t("filter.applied") %><% pills.forEach((p) => { %><%= p.label %>: <%= p.value %> "><% }) %><%= t("filter.clearAll") %>
<% } -%>
diff --git a/views/partials/flow-body.ejs b/views/partials/flow-body.ejs index 5c74a2d..ba88806 100644 --- a/views/partials/flow-body.ejs +++ b/views/partials/flow-body.ejs @@ -13,7 +13,7 @@ <%- include("field", field) %> <% }) -%> <% if (flow.recoverHref) { -%> -

<%= t("auth.forgotPassword") %>

+

<%= t("auth.forgotPassword") %>

<% } -%> <% flow.buttons.forEach((b, i) => { -%> diff --git a/views/partials/menu.ejs b/views/partials/menu.ejs index 12685a0..d630138 100644 --- a/views/partials/menu.ejs +++ b/views/partials/menu.ejs @@ -28,7 +28,7 @@ <% } else if (it.group) { const g = it.group; -%> <% } else if (it.href) { -%> - " href="<%= it.href %>"<% if (it.hreflang) { %> hreflang="<%= it.hreflang %>" lang="<%= it.hreflang %>"<% } %><% if (it.current) { %> aria-current="true"<% } %>><% if (it.icon) { %><% } %><%= it.label %> + " href="<%= it.hreflang ? it.href : localeHref(it.href) %>"<% if (it.hreflang) { %> hreflang="<%= it.hreflang %>" lang="<%= it.hreflang %>"<% } %><% if (it.current) { %> aria-current="true"<% } %>><% if (it.icon) { %><% } %><%= it.label %> <% } else { -%> <% } -%> diff --git a/views/partials/pagination.ejs b/views/partials/pagination.ejs index dcca66f..f05e2f8 100644 --- a/views/partials/pagination.ejs +++ b/views/partials/pagination.ejs @@ -8,6 +8,8 @@ options: (number | { value, label })[]; hidden: { name, value }[] carries list state prev?, next? { href? } page step; omit href ⇒ disabled pages? { label, href?, current?, ellipsis? }[] + Every href is run through localeHref, and the rows form carries the locale as a hidden input — + a GET submit replaces the whole query string, so it would drop the visitor's language otherwise. %><% const label = locals.label || t("pagination.label"); const summary = locals.summary; @@ -22,10 +24,13 @@ <%= summary.from %>–<%= summary.to %> <%= t("pagination.of") %> <%= summary.total %> <% } -%> <% if (rows) { -%> - action="<%= rows.action %>"<% } %>> + action="<%= localeHref(rows.action) %>"<% } %>> <% (rows.hidden || []).forEach((h) => { -%> <% }) -%> +<% if (localeParam) { -%> + +<% } -%> @@ -35,7 +40,7 @@
<%= t("admin.groups.membersOf", { name: permission.name }) %>
<%= t("admin.common.member") %><%= t("admin.common.type") %><%= t("table.actions") %>
<%= m.label %><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %>
— they identify the row (the row header). Action = { label, icon?, href?, danger?, separatorBefore? } + Every href (sort headers, row-header links, row actions) is run through localeHref, so a sorted or + paged list stays in the visitor's language without the caller wiring it. %><% const caption = locals.caption; const selectable = !!locals.selectable; @@ -28,7 +30,7 @@ <% } -%> <% columns.forEach((col) => { -%> <% if (col.sortable) { -%> - aria-sort="ascending"<% } else if (col.sort === "desc") { %> aria-sort="descending"<% } %><% if (col.className) { %> class="<%= col.className %>"<% } %>><%= col.label %> "/> aria-sort="ascending"<% } else if (col.sort === "desc") { %> aria-sort="descending"<% } %><% if (col.className) { %> class="<%= col.className %>"<% } %>><%= col.label %> "/> class="<%= col.className %>"<% } %>><%= col.label %><%= cell.user.name %><% if (cell.rowHeader.href) { %><%= cell.rowHeader.text %><% } else { %><%= cell.rowHeader.text %><% } %><% if (cell.rowHeader.href) { %><%= cell.rowHeader.text %><% } else { %><%= cell.rowHeader.text %><% } %><%= cell.badge.label %>