Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples #44

Merged
lilleman merged 14 commits from i18n into main 2026-08-04 19:58:32 +02:00
48 changed files with 337 additions and 118 deletions
Showing only changes of commit 6440c543e5 - Show all commits
+4
View File
@@ -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/ is a drop-in mount point for your menu/branding override — keep it empty (see examples/config/ for the template)
/config/* /config/*
!/config/.gitkeep !/config/.gitkeep
# locales/ is a drop-in mount point for extra (or replacement) language catalogs — keep it empty
/locales/*
!/locales/.gitkeep
+19
View File
@@ -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 — — 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 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. 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, - **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. 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. 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 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. exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
`node:24.16.0-alpine3.24`). `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 - 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 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 `HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
+35 -4
View File
@@ -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/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/)
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
``` ```
`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`. **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` Matching is exact on a full tag — `?locale=sv-FI` with only `sv-SE` installed matches nothing and
rather than a neighbouring region — but a lone language (`sv`, as browsers send) resolves to the falls through to `Accept-Language` (and from there to `en-US`), rather than being served a
first regional catalog for it. There is **no locale cookie**: the URL is the only place a choice 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 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 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 redirects) and `ctx.localeHref(href)` does the same for a plugin's links. The picker in the
@@ -961,6 +976,11 @@ include depth:
```ts ```ts
// handler // handler
return { data: { title: ctx.t("shop.title"), lead: ctx.t("shop.greeting", { name }) }, view: "shop" }; 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 ```html
<!-- view --> <!-- view -->
@@ -978,6 +998,16 @@ Three rules worth knowing:
that deliberately carries markup is rendered with `<%- %>` — and must never interpolate that deliberately carries markup is rendered with `<%- %>` — and must never interpolate
untrusted data, since nothing escapes it there. untrusted data, since nothing escapes it there.
- **Dates and numbers are `Intl`'s job**, not the catalog's: `new Intl.DateTimeFormat(ctx.locale)`. - **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 **Kratos writes the auth flow's own text** (field labels, validation errors) and tags each string
with a stable numeric id; a `kratos.<id>` key replaces it, and anything unmapped renders Kratos' with a stable numeric id; a `kratos.<id>` 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 } 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 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) 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
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)
+33
View File
@@ -24,6 +24,19 @@ async function loginPassword(page: Page): Promise<void> {
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL); // waits through the redirect chain 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", () => { test.describe.serial("authenticated admin journey", () => {
let browser: Browser; let browser: Browser;
let page: Page; let page: Page;
@@ -36,6 +49,26 @@ test.describe.serial("authenticated admin journey", () => {
}); });
test.afterAll(async () => { await page.context().close(); }); 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 () => { 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 // 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). // in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
+6
View File
@@ -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("heading", { name: "Pass" })).toBeVisible();
await expect(page.getByRole("button", { name: "Sök" })).toBeVisible(); // the core filter bar, in Swedish 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. // …and back to English the same way.
await page.locator('summary[aria-label="Språk"]').click(); await page.locator('summary[aria-label="Språk"]').click();
await page.getByRole("link", { name: /English/i }).click(); await page.getByRole("link", { name: /English/i }).click();
+4 -4
View File
@@ -143,9 +143,9 @@ function listTable(rows: ClientView[], t: Translate) {
function listFilterBar(state: ListState, t: Translate) { function listFilterBar(state: ListState, t: Translate) {
const pills: { label: string; remove: string; value: string }[] = []; 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 { return {
applyLabel: t("admin.common.apply"), applyLabel: t("filter.apply"),
clearHref: ADMIN_CLIENTS_BASE, clearHref: ADMIN_CLIENTS_BASE,
label: t("admin.clients.filter"), label: t("admin.clients.filter"),
pills, pills,
@@ -167,7 +167,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>, t:
: p.current ? { current: true, label: String(p.page) } : p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), 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 }, 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 }, 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 name = toClientView(client).name;
const tt = deps.ctx.t; const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ 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"), cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"),
message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"), message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"),
}) }, view: "confirm" }); }) }, view: "confirm" });
+5 -5
View File
@@ -163,9 +163,9 @@ function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "de
function listFilterBar(state: ListState, t: Translate) { function listFilterBar(state: ListState, t: Translate) {
const pills: { label: string; remove: string; value: string }[] = []; 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 { return {
applyLabel: t("admin.common.apply"), applyLabel: t("filter.apply"),
clearHref: ADMIN_GROUPS_BASE, clearHref: ADMIN_GROUPS_BASE,
label: t("admin.groups.filter"), label: t("admin.groups.filter"),
pills, pills,
@@ -188,7 +188,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>, t:
: p.current ? { current: true, label: String(p.page) } : p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), 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 }, 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 }, 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 ?? "", id: "name", label: t("admin.groups.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
}; };
return { 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, error: opts.error,
form: { form: {
action: ADMIN_GROUPS_BASE, action: ADMIN_GROUPS_BASE,
@@ -358,7 +358,7 @@ export const groupsDeleteConfirm = withGroupName((deps, name) => {
const base = detailHref(name); const base = detailHref(name);
const tt = deps.ctx.t; const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ 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"), cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"), message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
}) }, view: "confirm" }); }) }, view: "confirm" });
+5 -5
View File
@@ -148,9 +148,9 @@ function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc"
function listFilterBar(state: ListState, t: Translate) { function listFilterBar(state: ListState, t: Translate) {
const pills: { label: string; remove: string; value: string }[] = []; 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 { return {
applyLabel: t("admin.common.apply"), applyLabel: t("filter.apply"),
clearHref: ADMIN_PERMISSIONS_BASE, clearHref: ADMIN_PERMISSIONS_BASE,
label: t("admin.permissions.filter"), label: t("admin.permissions.filter"),
pills, pills,
@@ -173,7 +173,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>, t:
: p.current ? { current: true, label: String(p.page) } : p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), 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 }, 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 }, 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 ?? "", id: "name", label: t("admin.permissions.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
}; };
return { 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, error: opts.error,
form: { form: {
action: ADMIN_PERMISSIONS_BASE, action: ADMIN_PERMISSIONS_BASE,
@@ -343,7 +343,7 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => {
const base = detailHref(name); const base = detailHref(name);
const tt = deps.ctx.t; const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ 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"), cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.permissions.delete"),
message: tt("admin.permissions.deleteMessage", { name }), title: tt("admin.permissions.delete"), message: tt("admin.permissions.deleteMessage", { name }), title: tt("admin.permissions.delete"),
}) }, view: "confirm" }); }) }, view: "confirm" });
+5 -4
View File
@@ -3,12 +3,13 @@
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens; // (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
// everything imports the host only through the #plugin-api barrel. // 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"; 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 // This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and
// handlers pass ctx.t, which reads this catalog in the visitor's locale first, then the host's. // Search), for a view model built outside a request: its unit tests. At runtime the handlers pass
export const ADMIN_EN: Translate = createTranslator({ catalogs: [enUS], locale: "en-US" }); // 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_PERMISSION = "admin"; // the permission gating the whole admin section
export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_USERS_BASE = "/admin/users";
+5 -5
View File
@@ -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 }; return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
}), }),
rows: rows.map((u) => ({ 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: [ cells: [
{ user: { initials: u.initials, name: u.name } }, { user: { initials: u.initials, name: u.name } },
u.email, u.email,
@@ -164,7 +164,7 @@ function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "des
function listFilterBar(state: ListState, total: number, t: Translate) { function listFilterBar(state: ListState, total: number, t: Translate) {
const pills: { label: string; remove: string; value: string }[] = []; 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}`) }); 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 { return {
applyLabel: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback 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<typeof paginate>, t:
: p.current ? { current: true, label: String(p.page) } : p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), 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 }, 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 }, 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" }); 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 { 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 ? { edit: editing ? {
deleteAction: `${idPath}/delete`, deleteAction: `${idPath}/delete`,
id: view!.id, id: view!.id,
@@ -351,7 +351,7 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
const view = toUserView(identity); const view = toUserView(identity);
const tt = deps.ctx.t; const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ 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"), cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"),
message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"), message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"),
}) }, view: "confirm" }); }) }, view: "confirm" });
-11
View File
@@ -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.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.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.chooseMember": "Choose a user or group…",
"admin.common.delete": "Delete",
"admin.common.edit": "Edit",
"admin.common.go": "Go",
"admin.common.group": "Group", "admin.common.group": "Group",
"admin.common.member": "Member", "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.type": "Type",
"admin.common.user": "User", "admin.common.user": "User",
-11
View File
@@ -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.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.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.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.group": "Grupp",
"admin.common.member": "Medlem", "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.type": "Typ",
"admin.common.user": "Användare", "admin.common.user": "Användare",
@@ -23,7 +23,7 @@
<span class="field-hint"><%= t("admin.clients.field.typeHint") %></span> <span class="field-hint"><%= t("admin.clients.field.typeHint") %></span>
<label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label> <label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label>
<div class="form-actions"> <div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("admin.common.cancel") %></a> <a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button> <button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div> </div>
</form> </form>
@@ -10,7 +10,7 @@
<section class="form-card admin-actions" aria-label="<%= t("admin.users.confirm") %>"> <section class="form-card admin-actions" aria-label="<%= t("admin.users.confirm") %>">
<p><%= locals.message %></p> <p><%= locals.message %></p>
<div class="form-actions"> <div class="form-actions">
<a class="btn" href="<%= localeHref(locals.cancelHref) %>"><%= t("admin.common.cancel") %></a> <a class="btn" href="<%= localeHref(locals.cancelHref) %>"><%= t("common.cancel") %></a>
<form method="post" action="<%= locals.confirm.action %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form> <form method="post" action="<%= locals.confirm.action %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
</div> </div>
</section> </section>
@@ -19,9 +19,9 @@
<section class="form-card" aria-labelledby="members-h"> <section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h"><%= t("admin.groups.members") %></h2> <h2 class="card-title" id="members-h"><%= t("admin.groups.members") %></h2>
<% if (members.rows.length) { -%> <% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("admin.common.actions") %></span></th></tr></thead><tbody> <div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%> <% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.common.remove") %></button></form></td></tr> <tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form></td></tr>
<% }) -%> <% }) -%>
</tbody></table></div> </tbody></table></div>
<% } else { -%> <% } else { -%>
@@ -31,7 +31,7 @@
<section class="form-card" aria-labelledby="add-h"> <section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2> <h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
<% if (add.options.length) { -%> <% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.common.add") %></button></form> <form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("common.add") %></button></form>
<% } else { -%> <% } else { -%>
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p> <p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
<% } -%> <% } -%>
@@ -19,7 +19,7 @@
<span class="field-hint"><%= t("admin.groups.firstMemberHint") %></span> <span class="field-hint"><%= t("admin.groups.firstMemberHint") %></span>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("admin.common.cancel") %></a> <a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button> <button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div> </div>
</form> </form>
@@ -21,7 +21,7 @@
<section class="form-card" aria-labelledby="members-h"> <section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h"><%= t("admin.permissions.assignedTo") %></h2> <h2 class="card-title" id="members-h"><%= t("admin.permissions.assignedTo") %></h2>
<% if (members.rows.length) { -%> <% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: permission.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("admin.common.actions") %></span></th></tr></thead><tbody> <div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: permission.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%> <% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.permissions.revoke") %></button></form></td></tr> <tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.permissions.revoke") %></button></form></td></tr>
<% }) -%> <% }) -%>
@@ -19,7 +19,7 @@
<span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span> <span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("admin.common.cancel") %></a> <a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button> <button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div> </div>
</form> </form>
@@ -22,7 +22,7 @@
<%- include("partials/field", field) %> <%- include("partials/field", field) %>
<% }) -%> <% }) -%>
<div class="form-actions"> <div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("admin.common.cancel") %></a> <a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button> <button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div> </div>
</form> </form>
@@ -5,13 +5,11 @@
import type { PluralMessage } from "#plugin-api"; import type { PluralMessage } from "#plugin-api";
const messages = { const messages = {
"scheduling.cancel": "Cancel",
"scheduling.field.assignee": "Assignee", "scheduling.field.assignee": "Assignee",
"scheduling.field.end": "End", "scheduling.field.end": "End",
"scheduling.field.start": "Start", "scheduling.field.start": "Start",
"scheduling.field.title": "Shift title", "scheduling.field.title": "Shift title",
"scheduling.filter.label": "Filter shifts", "scheduling.filter.label": "Filter shifts",
"scheduling.filter.search": "Search",
"scheduling.filter.searchLabel": "Search shifts", "scheduling.filter.searchLabel": "Search shifts",
"scheduling.filter.searchPlaceholder": "Search title or assignee…", "scheduling.filter.searchPlaceholder": "Search title or assignee…",
"scheduling.form.submit": "Create shift", "scheduling.form.submit": "Create shift",
@@ -1,13 +1,11 @@
import type { SchedulingMessages } from "./en-US.ts"; import type { SchedulingMessages } from "./en-US.ts";
const messages: SchedulingMessages = { const messages: SchedulingMessages = {
"scheduling.cancel": "Avbryt",
"scheduling.field.assignee": "Tilldelad", "scheduling.field.assignee": "Tilldelad",
"scheduling.field.end": "Slut", "scheduling.field.end": "Slut",
"scheduling.field.start": "Start", "scheduling.field.start": "Start",
"scheduling.field.title": "Passets namn", "scheduling.field.title": "Passets namn",
"scheduling.filter.label": "Filtrera pass", "scheduling.filter.label": "Filtrera pass",
"scheduling.filter.search": "Sök",
"scheduling.filter.searchLabel": "Sök pass", "scheduling.filter.searchLabel": "Sök pass",
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…", "scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
"scheduling.form.submit": "Skapa pass", "scheduling.form.submit": "Skapa pass",
+2 -2
View File
@@ -4,14 +4,14 @@ import { Readable } from "node:stream";
import test from "node:test"; import test from "node:test";
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may // 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. // 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 enUS from "./i18n/en-US.ts";
import { import {
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts"; } 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" } }; 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 { function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
+8 -7
View File
@@ -6,12 +6,13 @@
// pure functions against a mock upstream with no network (README.md → Local dev & test story). // 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). // 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"; 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 // The plugin's own English (its catalog, then the host's), for a view model built outside a request:
// handler passes ctx.t, which reads this plugin's catalog for the visitor's locale first. // its unit tests. At runtime a handler passes ctx.t, which reads this catalog in the visitor's
const EN: Translate = createTranslator({ catalogs: [enUS], locale: "en-US" }); // locale first, then the host's.
const EN: Translate = englishTranslator(enUS);
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts"; 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 }), count: t("scheduling.shifts.count", { count: opts.shifts.length }),
...(opts.error ? { error: opts.error } : {}), ...(opts.error ? { error: opts.error } : {}),
filterBar: { filterBar: {
applyLabel: t("scheduling.filter.search"), applyLabel: t("filter.search"),
clearHref: SHIFTS_PATH, clearHref: SHIFTS_PATH,
label: t("scheduling.filter.label"), 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: [[ rows: [[
{ label: t("scheduling.filter.searchLabel"), name: "q", placeholder: t("scheduling.filter.searchPlaceholder"), type: "search", value: opts.q }, { label: t("scheduling.filter.searchLabel"), name: "q", placeholder: t("scheduling.filter.searchPlaceholder"), type: "search", value: opts.q },
{ type: "spacer" }, { type: "spacer" },
@@ -139,7 +140,7 @@ export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<strin
action: SHIFTS_PATH, action: SHIFTS_PATH,
cancelHref: SHIFTS_PATH, cancelHref: SHIFTS_PATH,
csrfToken: opts.chrome.csrfToken, csrfToken: opts.chrome.csrfToken,
cancelLabel: t("scheduling.cancel"), cancelLabel: t("common.cancel"),
fields: [ fields: [
field({ icon: "i-cal", id: "title", label: t("scheduling.field.title"), value: v.title ?? "" }), field({ icon: "i-cal", id: "title", label: t("scheduling.field.title"), value: v.title ?? "" }),
field({ icon: "i-user", id: "assignee", label: t("scheduling.field.assignee"), value: v.assignee ?? "" }), field({ icon: "i-user", id: "assignee", label: t("scheduling.field.assignee"), value: v.assignee ?? "" }),
View File
+8 -6
View File
@@ -115,11 +115,13 @@ export function createApp(options: AppOptions = {}): Server {
// building-block partials (resolved from viewsDir) and their own partials/subfolders. // building-block partials (resolved from viewsDir) and their own partials/subfolders.
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir }); const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch) merged in, so a // Every view renders with its context's i18n locals (t/locale/dir/localeSwitch/localeParam) merged
// view — core or plugin, at any include depth — calls `t(...)` without its handler passing it. // in, so a view — core or plugin, at any include depth — calls `t(...)` without its handler passing
// A plugin's context carries that plugin's translator, so its own catalog wins in its own views. // it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...i18nLocals(ctx), ...data }); // They are merged LAST: these names are reserved (README → Building plugins), and a handler that
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...i18nLocals(ctx), ...data }); // 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 => { const sendHtml = (res: ServerResponse, status: number, html: string): void => {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); 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<void> => { const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
// Error pages can render before this request has a context at all (a throw on the way to one), // 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. // 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 { try {
const method = req.method ?? "GET"; const method = req.method ?? "GET";
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
+8 -5
View File
@@ -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 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). // page renders the native app shell; the host builds it per request (anonymous default otherwise).
chrome: PageChrome; chrome: PageChrome;
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to // The locale this request is served in, e.g. "sv-SE" — also what <html lang> says.
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by locale: string;
// 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 <html lang>
// Carry the visitor's chosen locale onto a link this page renders. A no-op unless the request // 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 // 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 // on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
// wraps the hrefs it builds itself. // wraps the hrefs it builds itself.
localeHref(href: string): string; 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; log: Log;
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id } params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
+15 -5
View File
@@ -11,7 +11,7 @@ test("a complete translation reports nothing", () => {
}); });
test("a missing or unknown key is reported", () => { 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.equal(missing.length, 1);
assert.match(missing[0] ?? "", /missing key "greeting"/); 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", () => { 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.equal(flat.length, 1);
assert.match(flat[0] ?? "", /"shifts.count" must be a plural message/); 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.equal(plural.length, 1);
assert.match(plural[0] ?? "", /"greeting" must be a string/); assert.match(plural[0] ?? "", /"greeting" must be a string/);
}); });
test("a plural message must cover exactly its own locale's categories", () => { 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.equal(short.length, 1);
assert.match(short[0] ?? "", /"shifts\.count".*cs-CZ.*few, many/); 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.equal(long.length, 1);
assert.match(long[0] ?? "", /"shifts\.count".*few/); 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(null), false);
assert.equal(isCatalog([]), 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
});
+24
View File
@@ -12,6 +12,7 @@ export type Catalog = Record<string, Message>;
export const DEFAULT_LOCALE = "en-US"; export const DEFAULT_LOCALE = "en-US";
const CATEGORIES: ReadonlySet<string> = new Set(["few", "many", "one", "other", "two", "zero"]); const CATEGORIES: ReadonlySet<string> = new Set(["few", "many", "one", "other", "two", "zero"]);
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
export function isPluralMessage(value: Message): value is PluralMessage { export function isPluralMessage(value: Message): value is PluralMessage {
return typeof value !== "string"; 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}`); problems.push(`"${key}" must be a ${isPluralMessage(expected) ? "plural message" : "string"}, like ${baselineLocale}`);
continue; continue;
} }
for (const problem of placeholderProblems(key, expected, actual, baselineLocale)) problems.push(problem);
if (!isPluralMessage(actual)) continue; if (!isPluralMessage(actual)) continue;
const forms = new Set(Object.keys(actual)); const forms = new Set(Object.keys(actual));
const missing = categories.filter((category) => !forms.has(category)); const missing = categories.filter((category) => !forms.has(category));
@@ -67,6 +69,28 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari
return problems; 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<string> {
const names = new Set<string>();
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. // The plural categories a locale actually selects, sorted; unknown tags fall back to English's.
export function pluralCategories(locale: string): Intl.LDMLPluralRule[] { export function pluralCategories(locale: string): Intl.LDMLPluralRule[] {
try { try {
+8 -1
View File
@@ -3,13 +3,20 @@
// view model built outside a request, an app created without `i18n` — so an unwired path renders // 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. // 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 enUS from "./locales/en-US.ts";
import { createI18n, type I18n } from "./runtime.ts"; import { createI18n, type I18n } from "./runtime.ts";
import { createTranslator, type Translate } from "./translate.ts"; import { createTranslator, type Translate } from "./translate.ts";
export const ENGLISH: Translate = createTranslator({ catalogs: [enUS], locale: DEFAULT_LOCALE }); 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({ export const ENGLISH_I18N: I18n = createI18n({
available: [DEFAULT_LOCALE], available: [DEFAULT_LOCALE],
core: new Map([[DEFAULT_LOCALE, enUS]]), core: new Map([[DEFAULT_LOCALE, enUS]]),
+20
View File
@@ -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/); 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 () => { test("a plugin without an i18n folder is fine", async () => {
const { localesDir, pluginsDir } = await fixture({ "locales/en-US.ts": catalog(`{ hello: "Hello" }`) }); const { localesDir, pluginsDir } = await fixture({ "locales/en-US.ts": catalog(`{ hello: "Hello" }`) });
const loaded = await loadI18n({ localesDir, pluginIds: ["plain"], pluginsDir }); const loaded = await loadI18n({ localesDir, pluginIds: ["plain"], pluginsDir });
+16
View File
@@ -12,7 +12,14 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { checkCatalog, DEFAULT_LOCALE, isCatalog, type Catalog } from "./catalog.ts"; import { checkCatalog, DEFAULT_LOCALE, isCatalog, type Catalog } from "./catalog.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.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 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 // 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. // 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 { export interface LoadI18nOptions {
localesDir?: string; localesDir?: string;
logger?: Pick<Console, "warn">; // 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 pluginIds?: string[]; // discovered plugins; their i18n/ folders are loaded under their id
pluginsDir?: string; pluginsDir?: string;
} }
@@ -32,10 +41,13 @@ export interface LoadedI18n {
export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18n> { export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18n> {
const localesDir = options.localesDir ?? LOCALES_DIR; const localesDir = options.localesDir ?? LOCALES_DIR;
const mountedDir = options.mountedLocalesDir ?? MOUNTED_LOCALES_DIR;
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR; const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
const logger = options.logger ?? console;
const errors: string[] = []; const errors: string[] = [];
const core = await readSet(localesDir, "core", errors); 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`); 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); checkSet(core, "core", errors);
const available = [...core.keys()].sort(); const available = [...core.keys()].sort();
@@ -51,6 +63,10 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`); if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`);
} }
checkSet(set, `plugins/${id}`, errors); checkSet(set, `plugins/${id}`, 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}`);
plugins.set(id, set); plugins.set(id, set);
} }
+10
View File
@@ -37,6 +37,16 @@ const messages = {
"brand.sub": "Console", "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.allow": "Allow",
"consent.deny": "Deny", "consent.deny": "Deny",
"consent.notYou": "Not you?", "consent.notYou": "Not you?",
+8
View File
@@ -30,6 +30,14 @@ const messages: CoreMessages = {
"brand.sub": "Konsol", "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.allow": "Tillåt",
"consent.deny": "Neka", "consent.deny": "Neka",
"consent.notYou": "Inte du?", "consent.notYou": "Inte du?",
Binary file not shown.
+19 -2
View File
@@ -4,7 +4,6 @@
import { DEFAULT_LOCALE } from "./catalog.ts"; import { DEFAULT_LOCALE } from "./catalog.ts";
import { ENGLISH } from "./english.ts"; import { ENGLISH } from "./english.ts";
import type { RequestContext } from "../http/context.ts";
import { localeHref, localeLabel, textDirection } from "./locale.ts"; import { localeHref, localeLabel, textDirection } from "./locale.ts";
import type { Translate } from "./translate.ts"; import type { Translate } from "./translate.ts";
@@ -19,28 +18,46 @@ export interface I18nLocals {
dir: "ltr" | "rtl"; dir: "ltr" | "rtl";
locale: string; locale: string;
localeHref: (href: string) => 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[]; localeSwitch: LocaleChoice[];
locales: string[]; locales: string[];
t: Translate; 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, // For a render with no request behind it — a partial exercised directly, a one-off render: English,
// left-to-right, no language picker. // left-to-right, no language picker.
export const ENGLISH_LOCALS: I18nLocals = { export const ENGLISH_LOCALS: I18nLocals = {
dir: "ltr", dir: "ltr",
locale: DEFAULT_LOCALE, locale: DEFAULT_LOCALE,
localeHref: (href) => href, localeHref: (href) => href,
localeParam: null,
localeSwitch: [], localeSwitch: [],
locales: [DEFAULT_LOCALE], locales: [DEFAULT_LOCALE],
t: ENGLISH, t: ENGLISH,
}; };
export function i18nLocals(ctx: RequestContext): I18nLocals { export function i18nLocals(ctx: I18nRequest): I18nLocals {
const here = `${ctx.url.pathname}${ctx.url.search}`; 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 { return {
dir: textDirection(ctx.locale), dir: textDirection(ctx.locale),
locale: ctx.locale, locale: ctx.locale,
localeHref: (href) => ctx.localeHref(href), localeHref: (href) => ctx.localeHref(href),
localeParam: carried,
localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })), localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })),
locales: ctx.locales, locales: ctx.locales,
t: ctx.t, t: ctx.t,
+6
View File
@@ -14,6 +14,12 @@ export { can, check, GuardError, requireSession } from "../auth/guards.ts";
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator // authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
// in a unit test. `PluralMessage` types a message that varies with a count. // in a unit test. `PluralMessage` types a message that varies with a count.
export { createTranslator } from "../i18n/translate.ts"; 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 { Translate, TranslateVars } from "../i18n/translate.ts";
export type { Catalog, PluralMessage } from "../i18n/catalog.ts"; export type { Catalog, PluralMessage } from "../i18n/catalog.ts";
export { parseListQuery } from "../ui/list-query.ts"; export { parseListQuery } from "../ui/list-query.ts";
+5 -4
View File
@@ -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 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(", ") }); 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 // 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. // drifts from its en-US baseline, so a half-translated deploy never reaches a visitor. Loaded
const i18n = createI18n(await loadI18n({ pluginIds: plugins.map((p) => p.id) })); // 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(", ") }); log.info("locales loaded", { locales: i18n.available.join(", ") });
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
const server = createApp({ const server = createApp({
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless // 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. // APP_URL is set, so the redirect is fully off — and costs nothing — when unconfigured.
+4 -4
View File
@@ -11,7 +11,7 @@ import type { Translate } from "../i18n/translate.ts";
import { type MenuConfig } from "./menu-config.ts"; import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts"; import { composeNav, type NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.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 // 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 // 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); 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. // 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 returnTo = opts.currentPath ? `/login?return_to=${encodeURIComponent(carryLocale(opts.currentPath))}` : "/login";
const { theme, ...brand } = branding(opts.menu, t);
return { return {
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: t(b.name), ...(b.sub != null ? { sub: t(b.sub) } : {}) }, brand,
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
nav: carryLocaleInto(nav, carryLocale), nav: carryLocaleInto(nav, carryLocale),
signInHref: carryLocale(returnTo), signInHref: carryLocale(returnTo),
...(b.theme != null ? { theme: b.theme } : {}), ...(theme != null ? { theme } : {}),
user: shellUser(opts.user, t), user: shellUser(opts.user, t),
}; };
} }
+2 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import ejs from "ejs"; 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 menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, { ...ENGLISH_LOCALS, ...data });
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim(); const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
test("menu renders trigger, positioning, the item matrix and check groups", async () => { test("menu renders trigger, positioning, the item matrix and check groups", async () => {
+2 -1
View File
@@ -6,6 +6,7 @@
// the override (+ branding); this helper only transforms data, so its result is per-deployment // 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). // 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"; import type { Translate } from "../i18n/translate.ts";
export interface NavNode { export interface NavNode {
@@ -42,7 +43,7 @@ export function composeNav(
fragments: NavNode[][] = [], fragments: NavNode[][] = [],
override: NavOverride = {}, override: NavOverride = {},
permissions: string[] = [], permissions: string[] = [],
t: Translate = (key) => key, t: Translate = ENGLISH,
): NavNode[] { ): NavNode[] {
let nodes: NavNode[] = fragments.flat(); let nodes: NavNode[] = fragments.flat();
if (override.rename) nodes = renameTree(nodes, override.rename); if (override.rename) nodes = renameTree(nodes, override.rename);
+15 -3
View File
@@ -10,6 +10,18 @@ import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts"; import type { Translate } from "../i18n/translate.ts";
import { type MenuConfig } from "./menu-config.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 { export interface ShellUser {
email: string; email: string;
initials: string; initials: string;
@@ -44,14 +56,14 @@ export function buildShellContext(opts: {
title: string; title: string;
user?: User | null; user?: User | null;
}): ShellModel { }): ShellModel {
const b = opts.menu.branding;
const t = opts.t ?? ENGLISH; const t = opts.t ?? ENGLISH;
const { theme, ...brand } = branding(opts.menu, t);
return { 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 } : {}), ...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}),
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}), ...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
...(b.theme != null ? { theme: b.theme } : {}), ...(theme != null ? { theme } : {}),
title: opts.title, title: opts.title,
user: shellUser(opts.user, t), user: shellUser(opts.user, t),
}; };
+3 -3
View File
@@ -18,15 +18,15 @@
const alt = locals.alt; const alt = locals.alt;
-%> -%>
<form class="auth-card" method="<%= method %>"<% if (locals.action) { %> action="<%= locals.action %>"<% } %>> <form class="auth-card" method="<%= method %>"<% if (locals.action) { %> action="<%= locals.action %>"<% } %>>
<div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= back.href %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div> <div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= localeHref(back.href) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div>
<% if (providers.length) { -%> <% if (providers.length) { -%>
<div class="sso" aria-label="<%= sso.label || t("auth.sso.label") %>"> <div class="sso" aria-label="<%= sso.label || t("auth.sso.label") %>">
<ul class="sso-list"><% providers.forEach((p) => { %><li><% if (p.href) { %><a class="sso-btn" href="<%= p.href %>"><% } else { %><button type="<%= p.name ? "submit" : "button" %>" class="sso-btn"<% if (p.name) { %> name="<%= p.name %>" value="<%= p.value %>" formnovalidate<% } %>><% } %><span class="sso-logo" aria-hidden="true"><% if (p.icon) { %><svg class="ico ico-sm"><use href="#<%= p.icon %>"/></svg><% } else { %><%= p.logo %><% } %></span><span class="sso-label"><%= p.label %></span><% if (p.href) { %></a><% } else { %></button><% } %></li><% }) %></ul> <ul class="sso-list"><% providers.forEach((p) => { %><li><% if (p.href) { %><a class="sso-btn" href="<%= localeHref(p.href) %>"><% } else { %><button type="<%= p.name ? "submit" : "button" %>" class="sso-btn"<% if (p.name) { %> name="<%= p.name %>" value="<%= p.value %>" formnovalidate<% } %>><% } %><span class="sso-logo" aria-hidden="true"><% if (p.icon) { %><svg class="ico ico-sm"><use href="#<%= p.icon %>"/></svg><% } else { %><%= p.logo %><% } %></span><span class="sso-label"><%= p.label %></span><% if (p.href) { %></a><% } else { %></button><% } %></li><% }) %></ul>
<div class="auth-divider"><%= sso.divider || t("auth.sso.divider") %></div> <div class="auth-divider"><%= sso.divider || t("auth.sso.divider") %></div>
</div> </div>
<% } -%> <% } -%>
<div class="auth-form"><%- locals.body || "" %></div> <div class="auth-form"><%- locals.body || "" %></div>
<% if (alt) { -%> <% if (alt) { -%>
<p class="auth-alt"><%= alt.text %> <a href="<%= alt.href %>"><%= alt.label %></a></p> <p class="auth-alt"><%= alt.text %> <a href="<%= localeHref(alt.href) %>"><%= alt.label %></a></p>
<% } -%> <% } -%>
</form> </form>
+5 -3
View File
@@ -8,6 +8,8 @@
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? } Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
user + rowHeader cells render as <th scope="row"> — they identify the row (the row header). user + rowHeader cells render as <th scope="row"> — they identify the row (the row header).
Action = { label, icon?, href?, danger?, separatorBefore? } 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 caption = locals.caption;
const selectable = !!locals.selectable; const selectable = !!locals.selectable;
@@ -28,7 +30,7 @@
<% } -%> <% } -%>
<% columns.forEach((col) => { -%> <% columns.forEach((col) => { -%>
<% if (col.sortable) { -%> <% if (col.sortable) { -%>
<th scope="col"<% if (col.sort === "asc") { %> aria-sort="ascending"<% } else if (col.sort === "desc") { %> aria-sort="descending"<% } %><% if (col.className) { %> class="<%= col.className %>"<% } %>><a class="th-sort" href="<%= col.href %>"><%= col.label %> <svg class="ico ico-sm sort-ico"><use href="#<%= col.sort ? "i-up" : "i-sort" %>"/></svg></a></th> <th scope="col"<% if (col.sort === "asc") { %> aria-sort="ascending"<% } else if (col.sort === "desc") { %> aria-sort="descending"<% } %><% if (col.className) { %> class="<%= col.className %>"<% } %>><a class="th-sort" href="<%= localeHref(col.href) %>"><%= col.label %> <svg class="ico ico-sm sort-ico"><use href="#<%= col.sort ? "i-up" : "i-sort" %>"/></svg></a></th>
<% } else { -%> <% } else { -%>
<th scope="col"<% if (col.className) { %> class="<%= col.className %>"<% } %>><%= col.label %></th> <th scope="col"<% if (col.className) { %> class="<%= col.className %>"<% } %>><%= col.label %></th>
<% } -%> <% } -%>
@@ -53,7 +55,7 @@
<% } else if (cell.user) { -%> <% } else if (cell.user) { -%>
<th scope="row"><span class="cell-user"><span class="avatar" aria-hidden="true"><%= cell.user.initials %></span><span class="cell-strong"><%= cell.user.name %></span></span></th> <th scope="row"><span class="cell-user"><span class="avatar" aria-hidden="true"><%= cell.user.initials %></span><span class="cell-strong"><%= cell.user.name %></span></span></th>
<% } else if (cell.rowHeader) { -%> <% } else if (cell.rowHeader) { -%>
<th scope="row"><% if (cell.rowHeader.href) { %><a class="cell-strong" href="<%= cell.rowHeader.href %>"><%= cell.rowHeader.text %></a><% } else { %><span class="cell-strong"><%= cell.rowHeader.text %></span><% } %></th> <th scope="row"><% if (cell.rowHeader.href) { %><a class="cell-strong" href="<%= localeHref(cell.rowHeader.href) %>"><%= cell.rowHeader.text %></a><% } else { %><span class="cell-strong"><%= cell.rowHeader.text %></span><% } %></th>
<% } else if (cell.badge) { -%> <% } else if (cell.badge) { -%>
<td><span class="badge <%= cell.badge.tone %>"><span class="dot"></span><%= cell.badge.label %></span></td> <td><span class="badge <%= cell.badge.tone %>"><span class="dot"></span><%= cell.badge.label %></span></td>
<% } else if (cell.html != null) { -%> <% } else if (cell.html != null) { -%>
@@ -65,7 +67,7 @@
<% if (withActions) { -%> <% if (withActions) { -%>
<% if ((row.actions || []).length) { -%> <% if ((row.actions || []).length) { -%>
<td class="col-actions"><details class="menu kebab"><summary aria-label="<%= t("table.rowActions", { name: row.name || t("table.row") }) %>"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary><div class="menu-pop"><% row.actions.forEach((a) => { -%> <td class="col-actions"><details class="menu kebab"><summary aria-label="<%= t("table.rowActions", { name: row.name || t("table.row") }) %>"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary><div class="menu-pop"><% row.actions.forEach((a) => { -%>
<% if (a.separatorBefore) { %><div class="menu-sep"></div><% } %><% if (a.href) { %><a class="menu-item<% if (a.danger) { %> danger<% } %>" href="<%= a.href %>"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></a><% } else { %><button class="menu-item<% if (a.danger) { %> danger<% } %>" type="button"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></button><% } %><% }) -%> <% if (a.separatorBefore) { %><div class="menu-sep"></div><% } %><% if (a.href) { %><a class="menu-item<% if (a.danger) { %> danger<% } %>" href="<%= localeHref(a.href) %>"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></a><% } else { %><button class="menu-item<% if (a.danger) { %> danger<% } %>" type="button"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></button><% } %><% }) -%>
</div></details></td> </div></details></td>
<% } else { -%> <% } else { -%>
<td class="col-actions"></td> <td class="col-actions"></td>
+7 -2
View File
@@ -8,6 +8,8 @@
select { name, label, value?, options:{value,label}[] } select { name, label, value?, options:{value,label}[] }
chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes) chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes)
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} } 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 action = locals.action || "";
const label = locals.label || t("filter.label"); const label = locals.label || t("filter.label");
@@ -17,7 +19,10 @@
const applyLabel = locals.applyLabel || t("filter.apply"); const applyLabel = locals.applyLabel || t("filter.apply");
const eq = (a, b) => String(a ?? "") === String(b); const eq = (a, b) => String(a ?? "") === String(b);
-%> -%>
<form class="filters" method="get"<% if (action) { %> action="<%= action %>"<% } %> aria-label="<%= label %>"> <form class="filters" method="get"<% if (action) { %> action="<%= localeHref(action) %>"<% } %> aria-label="<%= label %>">
<% if (localeParam) { -%>
<input type="hidden" name="locale" value="<%= localeParam %>">
<% } -%>
<% rows.forEach((row) => { -%> <% rows.forEach((row) => { -%>
<div class="filter-row"> <div class="filter-row">
<% row.forEach((c) => { -%> <% row.forEach((c) => { -%>
@@ -39,7 +44,7 @@
<% }) -%> <% }) -%>
<div class="filter-row filter-foot"> <div class="filter-row filter-foot">
<% if (pills.length) { -%> <% if (pills.length) { -%>
<div class="active-pills" aria-label="<%= t("filter.appliedFilters") %>"><span class="filter-legend"><%= t("filter.applied") %></span><% pills.forEach((p) => { %><span class="pill"><b><%= p.label %>:</b> <%= p.value %> <a class="pill-x" href="<%= p.remove %>" aria-label="<%= t("filter.remove", { label: p.label }) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span><% }) %><a class="pill-clear" href="<%= clearHref %>"><%= t("filter.clearAll") %></a></div> <div class="active-pills" aria-label="<%= t("filter.appliedFilters") %>"><span class="filter-legend"><%= t("filter.applied") %></span><% pills.forEach((p) => { %><span class="pill"><b><%= p.label %>:</b> <%= p.value %> <a class="pill-x" href="<%= localeHref(p.remove) %>" aria-label="<%= t("filter.remove", { label: p.label }) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span><% }) %><a class="pill-clear" href="<%= localeHref(clearHref) %>"><%= t("filter.clearAll") %></a></div>
<% } -%> <% } -%>
<div class="spacer"></div> <div class="spacer"></div>
<div class="filter-actions"> <div class="filter-actions">
+1 -1
View File
@@ -13,7 +13,7 @@
<%- include("field", field) %> <%- include("field", field) %>
<% }) -%> <% }) -%>
<% if (flow.recoverHref) { -%> <% if (flow.recoverHref) { -%>
<p class="auth-aside"><a href="<%= flow.recoverHref %>"><%= t("auth.forgotPassword") %></a></p> <p class="auth-aside"><a href="<%= localeHref(flow.recoverHref) %>"><%= t("auth.forgotPassword") %></a></p>
<% } -%> <% } -%>
<% flow.buttons.forEach((b, i) => { -%> <% flow.buttons.forEach((b, i) => { -%>
<button type="submit" class="btn btn-block<%= i === 0 ? " btn-primary" : "" %>"<% if (b.name) { %> name="<%= b.name %>"<% } %><% if (b.value != null) { %> value="<%= b.value %>"<% } %>><%= b.label %></button> <button type="submit" class="btn btn-block<%= i === 0 ? " btn-primary" : "" %>"<% if (b.name) { %> name="<%= b.name %>"<% } %><% if (b.value != null) { %> value="<%= b.value %>"<% } %>><%= b.label %></button>
+1 -1
View File
@@ -28,7 +28,7 @@
<% } else if (it.group) { const g = it.group; -%> <% } else if (it.group) { const g = it.group; -%>
<fieldset class="menu-field"><% if (g.legend) { %><legend class="menu-head"><%= g.legend %></legend><% } %><% g.options.forEach((o) => { %><label class="menu-check"><input type="<%= g.control || "checkbox" %>" name="<%= g.name %>" value="<%= o.value %>"<%= o.checked ? " checked" : "" %>><%= o.label %></label><% }) %></fieldset> <fieldset class="menu-field"><% if (g.legend) { %><legend class="menu-head"><%= g.legend %></legend><% } %><% g.options.forEach((o) => { %><label class="menu-check"><input type="<%= g.control || "checkbox" %>" name="<%= g.name %>" value="<%= o.value %>"<%= o.checked ? " checked" : "" %>><%= o.label %></label><% }) %></fieldset>
<% } else if (it.href) { -%> <% } else if (it.href) { -%>
<a class="menu-item<%= it.danger ? " danger" : "" %>" href="<%= it.href %>"<% if (it.hreflang) { %> hreflang="<%= it.hreflang %>" lang="<%= it.hreflang %>"<% } %><% if (it.current) { %> aria-current="true"<% } %>><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a> <a class="menu-item<%= it.danger ? " danger" : "" %>" 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) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a>
<% } else { -%> <% } else { -%>
<button class="menu-item<%= it.danger ? " danger" : "" %>" type="button"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></button> <button class="menu-item<%= it.danger ? " danger" : "" %>" type="button"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></button>
<% } -%> <% } -%>
+9 -4
View File
@@ -8,6 +8,8 @@
options: (number | { value, label })[]; hidden: { name, value }[] carries list state options: (number | { value, label })[]; hidden: { name, value }[] carries list state
prev?, next? { href? } page step; omit href ⇒ disabled prev?, next? { href? } page step; omit href ⇒ disabled
pages? { label, href?, current?, ellipsis? }[] 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 label = locals.label || t("pagination.label");
const summary = locals.summary; const summary = locals.summary;
@@ -22,10 +24,13 @@
<span><%= summary.from %><%= summary.to %> <%= t("pagination.of") %> <b><%= summary.total %></b></span> <span><%= summary.from %><%= summary.to %> <%= t("pagination.of") %> <b><%= summary.total %></b></span>
<% } -%> <% } -%>
<% if (rows) { -%> <% if (rows) { -%>
<form class="pager-rows" method="get"<% if (rows.action) { %> action="<%= rows.action %>"<% } %>> <form class="pager-rows" method="get"<% if (rows.action) { %> action="<%= localeHref(rows.action) %>"<% } %>>
<% (rows.hidden || []).forEach((h) => { -%> <% (rows.hidden || []).forEach((h) => { -%>
<input type="hidden" name="<%= h.name %>" value="<%= h.value %>"> <input type="hidden" name="<%= h.name %>" value="<%= h.value %>">
<% }) -%> <% }) -%>
<% if (localeParam) { -%>
<input type="hidden" name="locale" value="<%= localeParam %>">
<% } -%>
<label for="pager-rows"><%= rows.label || t("pagination.rows") %></label> <label for="pager-rows"><%= rows.label || t("pagination.rows") %></label>
<span class="select"><select id="pager-rows" name="<%= rows.name %>"><% (rows.options || []).forEach((o) => { const v = o && o.value != null ? o.value : o; const text = o && o.label != null ? o.label : o; %><option value="<%= v %>"<% if (eq(rows.value, v)) { %> selected<% } %>><%= text %></option><% }) %></select></span> <span class="select"><select id="pager-rows" name="<%= rows.name %>"><% (rows.options || []).forEach((o) => { const v = o && o.value != null ? o.value : o; const text = o && o.label != null ? o.label : o; %><option value="<%= v %>"<% if (eq(rows.value, v)) { %> selected<% } %>><%= text %></option><% }) %></select></span>
<button class="page-btn" type="submit"><%= rows.submitLabel || t("pagination.go") %></button> <button class="page-btn" type="submit"><%= rows.submitLabel || t("pagination.go") %></button>
@@ -35,7 +40,7 @@
<nav class="page-nums" aria-label="<%= label %>"> <nav class="page-nums" aria-label="<%= label %>">
<% if (prev) { -%> <% if (prev) { -%>
<% if (prev.href) { -%> <% if (prev.href) { -%>
<a class="page-btn" href="<%= prev.href %>" aria-label="<%= t("pagination.previous") %>"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></a> <a class="page-btn" href="<%= localeHref(prev.href) %>" aria-label="<%= t("pagination.previous") %>"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></a>
<% } else { -%> <% } else { -%>
<button class="page-btn" type="button" disabled aria-label="<%= t("pagination.previous") %>"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></button> <button class="page-btn" type="button" disabled aria-label="<%= t("pagination.previous") %>"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></button>
<% } -%> <% } -%>
@@ -46,12 +51,12 @@
<% } else if (p.current) { -%> <% } else if (p.current) { -%>
<span class="page-btn" aria-current="page"><%= p.label %></span> <span class="page-btn" aria-current="page"><%= p.label %></span>
<% } else { -%> <% } else { -%>
<a class="page-btn" href="<%= p.href %>"><%= p.label %></a> <a class="page-btn" href="<%= localeHref(p.href) %>"><%= p.label %></a>
<% } -%> <% } -%>
<% }) -%> <% }) -%>
<% if (next) { -%> <% if (next) { -%>
<% if (next.href) { -%> <% if (next.href) { -%>
<a class="page-btn" href="<%= next.href %>" aria-label="<%= t("pagination.next") %>"><svg class="ico ico-sm"><use href="#i-chev"/></svg></a> <a class="page-btn" href="<%= localeHref(next.href) %>" aria-label="<%= t("pagination.next") %>"><svg class="ico ico-sm"><use href="#i-chev"/></svg></a>
<% } else { -%> <% } else { -%>
<button class="page-btn" type="button" disabled aria-label="<%= t("pagination.next") %>"><svg class="ico ico-sm"><use href="#i-chev"/></svg></button> <button class="page-btn" type="button" disabled aria-label="<%= t("pagination.next") %>"><svg class="ico ico-sm"><use href="#i-chev"/></svg></button>
<% } -%> <% } -%>
+1 -1
View File
@@ -10,7 +10,7 @@
(theme-switch default), `user`, `breadcrumbs`, `csrfToken` (the Sign-out form's hidden field), (theme-switch default), `user`, `breadcrumbs`, `csrfToken` (the Sign-out form's hidden field),
`signInHref` (anonymous "Sign in" target; default /login). `menu` (default true) — set false to `signInHref` (anonymous "Sign in" target; default /login). `menu` (default true) — set false to
drop the sidebar and render a focused single-column page. `t`, `locale`, `dir` and `localeSwitch` drop the sidebar and render a focused single-column page. `t`, `locale`, `dir` and `localeSwitch`
come from the host with every render. come from the host with every render (README → Languages).
%><% %><%
const brand = locals.brand || { name: "Plainpages" }; const brand = locals.brand || { name: "Plainpages" };
const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it) const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it)