Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples #44
@@ -13,3 +13,7 @@ e2e-tests/artifacts/
|
||||
# config/ is a drop-in mount point for your menu/branding override — keep it empty (see examples/config/ for the template)
|
||||
/config/*
|
||||
!/config/.gitkeep
|
||||
|
||||
# locales/ is a drop-in mount point for extra (or replacement) language catalogs — keep it empty
|
||||
/locales/*
|
||||
!/locales/.gitkeep
|
||||
|
||||
@@ -109,6 +109,20 @@ them. Revisit only if the stated reason stops holding.
|
||||
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs —
|
||||
and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer
|
||||
locales than the host (its strings fall back to `en-US` per key), never one the host lacks.
|
||||
- **The core building blocks carry the locale; a plugin doesn't have to.** `pagination`,
|
||||
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `menu` and the nav wrap every href they
|
||||
render in `localeHref`, and the two GET forms carry it as a hidden `locale` input (a GET submit
|
||||
replaces the whole query string, so no href wrapper can reach it). Putting the obligation on each
|
||||
call site was tried first and missed five of eight sites inside one commit — including the admin
|
||||
screens. `ctx.localeHref` remains for hrefs a plugin's own markup emits. Decided 2026-08-03 after
|
||||
an architecture review.
|
||||
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set, so it never
|
||||
shows up as a plugin filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
|
||||
`localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
|
||||
so a collision loses the key instead of breaking the shell.
|
||||
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`.** A catalog there
|
||||
for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held
|
||||
to the same parity check. Adding a language must not require forking the image.
|
||||
- **An unknown translation key renders as itself.** That single rule is what lets a nav label,
|
||||
branding, or a menu `rename` be either a key or plain text without a second field or a migration.
|
||||
Don't "fix" it into a loud failure: a manifest with plain labels must keep working.
|
||||
@@ -194,6 +208,11 @@ Same test before adding a row to a table or the file map — a clause, not a par
|
||||
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
|
||||
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
|
||||
`node:24.16.0-alpine3.24`).
|
||||
- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive
|
||||
contract changes (i18n added four `RequestContext` fields and several barrel exports without a
|
||||
minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild,
|
||||
a version bump can only produce noise. The promotion trigger is the first external plugin — from
|
||||
then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03.
|
||||
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the
|
||||
plugin was built against — bumped by hand on rebuild, **never** the host's
|
||||
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
|
||||
|
||||
@@ -913,14 +913,29 @@ dropping another file next to them.
|
||||
```
|
||||
src/i18n/locales/en-US.ts the baseline — every other locale is checked against it
|
||||
src/i18n/locales/sv-SE.ts
|
||||
locales/ drop-in mount root: your own catalogs, ships empty (like plugins/ and config/)
|
||||
plugins/<id>/i18n/en-US.ts a plugin's own words, looked up before the host's
|
||||
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`.
|
||||
Matching is exact on a full tag — `?locale=sv-FI` with only `sv-SE` installed lands on `en-US`
|
||||
rather than a neighbouring region — but a lone language (`sv`, as browsers send) resolves to the
|
||||
first regional catalog for it. There is **no locale cookie**: the URL is the only place a choice
|
||||
Matching is exact on a full tag — `?locale=sv-FI` with only `sv-SE` installed matches nothing and
|
||||
falls through to `Accept-Language` (and from there to `en-US`), rather than being served a
|
||||
neighbouring region — but a lone language (`sv`, as browsers send) resolves to the first regional
|
||||
catalog for it. There is **no locale cookie**: the URL is the only place a choice
|
||||
is stored, so a link is shareable and a page is what its address says it is. When the URL asked
|
||||
for a language, the host carries `?locale=` onto every link *it* renders (menu, sign-in, its own
|
||||
redirects) and `ctx.localeHref(href)` does the same for a plugin's links. The picker in the
|
||||
@@ -961,6 +976,11 @@ include depth:
|
||||
```ts
|
||||
// handler
|
||||
return { data: { title: ctx.t("shop.title"), lead: ctx.t("shop.greeting", { name }) }, view: "shop" };
|
||||
|
||||
// a pure view model built outside a request (its unit test) defaults to the plugin's own English:
|
||||
import { englishTranslator, type Translate } from "#plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
const EN: Translate = englishTranslator(enUS); // your catalog, then the host's
|
||||
```
|
||||
```html
|
||||
<!-- view -->
|
||||
@@ -978,6 +998,16 @@ Three rules worth knowing:
|
||||
that deliberately carries markup is rendered with `<%- %>` — and must never interpolate
|
||||
untrusted data, since nothing escapes it there.
|
||||
- **Dates and numbers are `Intl`'s job**, not the catalog's: `new Intl.DateTimeFormat(ctx.locale)`.
|
||||
- **The core building blocks carry the locale for you.** `pagination`, `filter-bar`, `data-table`,
|
||||
`auth-card` and the nav wrap every href they render, and the two GET forms carry it as a hidden
|
||||
`locale` input (a GET submit replaces the whole query string). `ctx.localeHref` is only for hrefs
|
||||
your own markup emits — and `localeParam` (a view local: the tag, or null) for your own GET forms.
|
||||
- **Reuse the core words.** Generic UI verbs live in the core catalog — `common.add/cancel/delete/
|
||||
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
|
||||
to them. Keep your catalog for your domain words, so N plugins don't re-translate "Cancel" N times.
|
||||
- **These view locals are reserved:** `t`, `locale`, `locales`, `localeHref`, `localeParam`,
|
||||
`localeSwitch`, `dir`. They are merged after your `data`, so a key of yours with one of those names
|
||||
is ignored rather than breaking the shell.
|
||||
|
||||
**Kratos writes the auth flow's own text** (field labels, validation errors) and tags each string
|
||||
with a stable numeric id; a `kratos.<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 }
|
||||
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
||||
|
||||
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies)
|
||||
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, language picker, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies)
|
||||
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
||||
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent
|
||||
locales/ Drop-in mount point for extra (or replacement) language catalogs — a <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)
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// The themed Kratos page in another language: our own chrome, Kratos' own strings mapped by id, and
|
||||
// the card's own links keeping the choice (they are rendered by the flow body, not by the menu).
|
||||
test("the login page speaks the visitor's language, links included", async ({ browser }) => {
|
||||
const page = await (await browser.newContext()).newPage();
|
||||
await page.goto("/login?locale=sv-SE");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
await expect(page.getByRole("heading", { name: "Logga in" })).toBeVisible();
|
||||
await expect(page.getByLabel("Lösenord", { exact: true })).toBeVisible(); // Kratos' own field, labelled via auth.field.password
|
||||
await expect(page.getByRole("link", { name: "Glömt lösenordet?" })).toHaveAttribute("href", /locale=sv-SE/);
|
||||
await expect(page.getByRole("link", { name: "Skapa ett" })).toHaveAttribute("href", /locale=sv-SE/);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test.describe.serial("authenticated admin journey", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
@@ -36,6 +49,26 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
});
|
||||
test.afterAll(async () => { await page.context().close(); });
|
||||
|
||||
// The list screens rebuild their query from the list state (sort/page/filter), so they are where
|
||||
// a chosen language used to get dropped — the core building blocks carry it now.
|
||||
test("a sorted, paged admin list keeps the visitor's language", async () => {
|
||||
await page.goto("/admin/users?locale=sv-SE");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: /E-postadress/ }).click(); // a sort header
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
await page.getByRole("button", { name: "Använd filter" }).click(); // the filter bar's GET form
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
});
|
||||
|
||||
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
|
||||
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
|
||||
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
|
||||
|
||||
@@ -51,6 +51,12 @@ test("the switcher changes language, and the choice survives clicking through th
|
||||
await expect(page.getByRole("heading", { name: "Pass" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Sök" })).toBeVisible(); // the core filter bar, in Swedish
|
||||
|
||||
// The filter bar is a GET form: submitting it replaces the whole query string, so the choice
|
||||
// survives only because the form carries it as a hidden field.
|
||||
await page.getByRole("button", { name: "Sök" }).click();
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
// …and back to English the same way.
|
||||
await page.locator('summary[aria-label="Språk"]').click();
|
||||
await page.getByRole("link", { name: /English/i }).click();
|
||||
|
||||
@@ -143,9 +143,9 @@ function listTable(rows: ClientView[], t: Translate) {
|
||||
|
||||
function listFilterBar(state: ListState, t: Translate) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: t("admin.common.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: t("admin.common.apply"),
|
||||
applyLabel: t("filter.apply"),
|
||||
clearHref: ADMIN_CLIENTS_BASE,
|
||||
label: t("admin.clients.filter"),
|
||||
pills,
|
||||
@@ -167,7 +167,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>, t:
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: t("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.go"), value: state.pageSize },
|
||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
@@ -302,7 +302,7 @@ export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
||||
const name = toClientView(client).name;
|
||||
const tt = deps.ctx.t;
|
||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: tt("admin.clients.title") }, { href: base, label: name }, { label: tt("admin.common.delete") }],
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: tt("admin.clients.title") }, { href: base, label: name }, { label: tt("common.delete") }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"),
|
||||
message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"),
|
||||
}) }, view: "confirm" });
|
||||
|
||||
@@ -163,9 +163,9 @@ function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "de
|
||||
|
||||
function listFilterBar(state: ListState, t: Translate) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: t("admin.common.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: t("admin.common.apply"),
|
||||
applyLabel: t("filter.apply"),
|
||||
clearHref: ADMIN_GROUPS_BASE,
|
||||
label: t("admin.groups.filter"),
|
||||
pills,
|
||||
@@ -188,7 +188,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>, t:
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: t("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.go"), value: state.pageSize },
|
||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
@@ -208,7 +208,7 @@ export function buildGroupFormModel(opts: {
|
||||
id: "name", label: t("admin.groups.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
|
||||
};
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: t("admin.common.new") }],
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: t("common.new") }],
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_GROUPS_BASE,
|
||||
@@ -358,7 +358,7 @@ export const groupsDeleteConfirm = withGroupName((deps, name) => {
|
||||
const base = detailHref(name);
|
||||
const tt = deps.ctx.t;
|
||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("admin.common.delete") }],
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
|
||||
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
|
||||
}) }, view: "confirm" });
|
||||
|
||||
@@ -148,9 +148,9 @@ function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc"
|
||||
|
||||
function listFilterBar(state: ListState, t: Translate) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: t("admin.common.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: t("admin.common.apply"),
|
||||
applyLabel: t("filter.apply"),
|
||||
clearHref: ADMIN_PERMISSIONS_BASE,
|
||||
label: t("admin.permissions.filter"),
|
||||
pills,
|
||||
@@ -173,7 +173,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>, t:
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: t("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.go"), value: state.pageSize },
|
||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
@@ -193,7 +193,7 @@ export function buildPermissionFormModel(opts: {
|
||||
id: "name", label: t("admin.permissions.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
|
||||
};
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: t("admin.common.new") }],
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: t("common.new") }],
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_PERMISSIONS_BASE,
|
||||
@@ -343,7 +343,7 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||
const base = detailHref(name);
|
||||
const tt = deps.ctx.t;
|
||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: tt("admin.permissions.title") }, { href: base, label: name }, { label: tt("admin.common.delete") }],
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: tt("admin.permissions.title") }, { href: base, label: name }, { label: tt("common.delete") }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.permissions.delete"),
|
||||
message: tt("admin.permissions.deleteMessage", { name }), title: tt("admin.permissions.delete"),
|
||||
}) }, view: "confirm" });
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
|
||||
// everything imports the host only through the #plugin-api barrel.
|
||||
|
||||
import { can, createTranslator, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
|
||||
// This plugin's English, for a view model built outside a request (its unit tests). At runtime the
|
||||
// handlers pass ctx.t, which reads this catalog in the visitor's locale first, then the host's.
|
||||
export const ADMIN_EN: Translate = createTranslator({ catalogs: [enUS], locale: "en-US" });
|
||||
// This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and
|
||||
// Search), for a view model built outside a request: its unit tests. At runtime the handlers pass
|
||||
// ctx.t, which reads this catalog in the visitor's locale first, then the host's.
|
||||
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
||||
|
||||
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
|
||||
export const ADMIN_USERS_BASE = "/admin/users";
|
||||
|
||||
@@ -151,7 +151,7 @@ function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "des
|
||||
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
|
||||
}),
|
||||
rows: rows.map((u) => ({
|
||||
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("admin.common.edit") }],
|
||||
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }],
|
||||
cells: [
|
||||
{ user: { initials: u.initials, name: u.name } },
|
||||
u.email,
|
||||
@@ -164,7 +164,7 @@ function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "des
|
||||
|
||||
function listFilterBar(state: ListState, total: number, t: Translate) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: t("admin.common.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.status !== "all") pills.push({ label: t("admin.users.status.label"), remove: listHref(state, { page: 1, status: "all" }), value: t(`admin.users.status.${state.status}`) });
|
||||
return {
|
||||
applyLabel: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback
|
||||
@@ -196,7 +196,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>, t:
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: t("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.go"), value: state.pageSize },
|
||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
@@ -239,7 +239,7 @@ export function buildUserFormModel(opts: {
|
||||
if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" });
|
||||
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("admin.common.edit") : t("admin.common.new") }],
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }],
|
||||
edit: editing ? {
|
||||
deleteAction: `${idPath}/delete`,
|
||||
id: view!.id,
|
||||
@@ -351,7 +351,7 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
||||
const view = toUserView(identity);
|
||||
const tt = deps.ctx.t;
|
||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: tt("admin.users.title") }, { href: back, label: view.name }, { label: tt("admin.common.delete") }],
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: tt("admin.users.title") }, { href: back, label: view.name }, { label: tt("common.delete") }],
|
||||
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"),
|
||||
message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"),
|
||||
}) }, view: "confirm" });
|
||||
|
||||
@@ -42,20 +42,9 @@ const messages = {
|
||||
"admin.clients.validation.redirectUri": "\"{{uri}}\" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.",
|
||||
"admin.clients.validation.redirectUris": "Add at least one redirect URI.",
|
||||
|
||||
"admin.common.actions": "Actions",
|
||||
"admin.common.add": "Add",
|
||||
"admin.common.apply": "Apply",
|
||||
"admin.common.cancel": "Cancel",
|
||||
"admin.common.chooseMember": "Choose a user or group…",
|
||||
"admin.common.delete": "Delete",
|
||||
"admin.common.edit": "Edit",
|
||||
"admin.common.go": "Go",
|
||||
"admin.common.group": "Group",
|
||||
"admin.common.member": "Member",
|
||||
"admin.common.new": "New",
|
||||
"admin.common.remove": "Remove",
|
||||
"admin.common.rows": "Rows",
|
||||
"admin.common.search": "Search",
|
||||
"admin.common.type": "Type",
|
||||
"admin.common.user": "User",
|
||||
|
||||
|
||||
@@ -42,20 +42,9 @@ const messages: AdminMessages = {
|
||||
"admin.clients.validation.redirectUri": "\"{{uri}}\" är inte en giltig omdirigerings-URI — använd en absolut URL som https://app.example.com/callback.",
|
||||
"admin.clients.validation.redirectUris": "Lägg till minst en omdirigerings-URI.",
|
||||
|
||||
"admin.common.actions": "Åtgärder",
|
||||
"admin.common.add": "Lägg till",
|
||||
"admin.common.apply": "Använd",
|
||||
"admin.common.cancel": "Avbryt",
|
||||
"admin.common.chooseMember": "Välj en användare eller grupp…",
|
||||
"admin.common.delete": "Ta bort",
|
||||
"admin.common.edit": "Redigera",
|
||||
"admin.common.go": "Visa",
|
||||
"admin.common.group": "Grupp",
|
||||
"admin.common.member": "Medlem",
|
||||
"admin.common.new": "Ny",
|
||||
"admin.common.remove": "Ta bort",
|
||||
"admin.common.rows": "Rader",
|
||||
"admin.common.search": "Sök",
|
||||
"admin.common.type": "Typ",
|
||||
"admin.common.user": "Användare",
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<section class="form-card admin-actions" aria-label="<%= t("admin.users.confirm") %>">
|
||||
<p><%= locals.message %></p>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<section class="form-card" aria-labelledby="members-h">
|
||||
<h2 class="card-title" id="members-h"><%= t("admin.groups.members") %></h2>
|
||||
<% 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) => { -%>
|
||||
<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>
|
||||
<% } else { -%>
|
||||
@@ -31,7 +31,7 @@
|
||||
<section class="form-card" aria-labelledby="add-h">
|
||||
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
|
||||
<% 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 { -%>
|
||||
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
|
||||
<% } -%>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<span class="field-hint"><%= t("admin.groups.firstMemberHint") %></span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<section class="form-card" aria-labelledby="members-h">
|
||||
<h2 class="card-title" id="members-h"><%= t("admin.permissions.assignedTo") %></h2>
|
||||
<% 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) => { -%>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<%- include("partials/field", field) %>
|
||||
<% }) -%>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,13 +5,11 @@
|
||||
import type { PluralMessage } from "#plugin-api";
|
||||
|
||||
const messages = {
|
||||
"scheduling.cancel": "Cancel",
|
||||
"scheduling.field.assignee": "Assignee",
|
||||
"scheduling.field.end": "End",
|
||||
"scheduling.field.start": "Start",
|
||||
"scheduling.field.title": "Shift title",
|
||||
"scheduling.filter.label": "Filter shifts",
|
||||
"scheduling.filter.search": "Search",
|
||||
"scheduling.filter.searchLabel": "Search shifts",
|
||||
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
|
||||
"scheduling.form.submit": "Create shift",
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { SchedulingMessages } from "./en-US.ts";
|
||||
|
||||
const messages: SchedulingMessages = {
|
||||
"scheduling.cancel": "Avbryt",
|
||||
"scheduling.field.assignee": "Tilldelad",
|
||||
"scheduling.field.end": "Slut",
|
||||
"scheduling.field.start": "Start",
|
||||
"scheduling.field.title": "Passets namn",
|
||||
"scheduling.filter.label": "Filtrera pass",
|
||||
"scheduling.filter.search": "Sök",
|
||||
"scheduling.filter.searchLabel": "Sök pass",
|
||||
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
|
||||
"scheduling.form.submit": "Skapa pass",
|
||||
|
||||
@@ -4,14 +4,14 @@ import { Readable } from "node:stream";
|
||||
import test from "node:test";
|
||||
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||
import { createTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
|
||||
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
import {
|
||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||
} from "./shifts.ts";
|
||||
|
||||
const t = createTranslator({ catalogs: [enUS], locale: "en-US" }); // this plugin's own catalog, as the host would pass it
|
||||
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
|
||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||
|
||||
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
||||
|
||||
// One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
||||
import { can, createTranslator, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api";
|
||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
|
||||
// The plugin's own English, for a view model built outside a request (its unit tests). At runtime a
|
||||
// handler passes ctx.t, which reads this plugin's catalog for the visitor's locale first.
|
||||
const EN: Translate = createTranslator({ catalogs: [enUS], locale: "en-US" });
|
||||
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
||||
// its unit tests. At runtime a handler passes ctx.t, which reads this catalog in the visitor's
|
||||
// locale first, then the host's.
|
||||
const EN: Translate = englishTranslator(enUS);
|
||||
|
||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||
@@ -102,10 +103,10 @@ export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; er
|
||||
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
|
||||
...(opts.error ? { error: opts.error } : {}),
|
||||
filterBar: {
|
||||
applyLabel: t("scheduling.filter.search"),
|
||||
applyLabel: t("filter.search"),
|
||||
clearHref: SHIFTS_PATH,
|
||||
label: t("scheduling.filter.label"),
|
||||
pills: opts.q ? [{ label: t("scheduling.filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [],
|
||||
pills: opts.q ? [{ label: t("filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [],
|
||||
rows: [[
|
||||
{ label: t("scheduling.filter.searchLabel"), name: "q", placeholder: t("scheduling.filter.searchPlaceholder"), type: "search", value: opts.q },
|
||||
{ type: "spacer" },
|
||||
@@ -139,7 +140,7 @@ export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<strin
|
||||
action: SHIFTS_PATH,
|
||||
cancelHref: SHIFTS_PATH,
|
||||
csrfToken: opts.chrome.csrfToken,
|
||||
cancelLabel: t("scheduling.cancel"),
|
||||
cancelLabel: t("common.cancel"),
|
||||
fields: [
|
||||
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 ?? "" }),
|
||||
|
||||
+8
-6
@@ -115,11 +115,13 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
||||
|
||||
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch) merged in, so a
|
||||
// view — core or plugin, at any include depth — calls `t(...)` without its handler passing it.
|
||||
// A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
|
||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...i18nLocals(ctx), ...data });
|
||||
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...i18nLocals(ctx), ...data });
|
||||
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch/localeParam) merged
|
||||
// in, so a view — core or plugin, at any include depth — calls `t(...)` without its handler passing
|
||||
// it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
|
||||
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
|
||||
// happens to use one loses that key rather than breaking the shell that renders around it.
|
||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(ctx) });
|
||||
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(ctx) });
|
||||
|
||||
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||
@@ -172,7 +174,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
|
||||
// Error pages can render before this request has a context at all (a throw on the way to one),
|
||||
// so they start on the built-in English and switch to the visitor's locale once it is resolved.
|
||||
let renderPage: ViewRenderer = (view, data) => render(view, { ...ENGLISH_LOCALS, ...data });
|
||||
let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS });
|
||||
try {
|
||||
const method = req.method ?? "GET";
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
|
||||
+8
-5
@@ -23,16 +23,19 @@ export interface RequestContext {
|
||||
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
|
||||
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
|
||||
chrome: PageChrome;
|
||||
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
|
||||
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
|
||||
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
|
||||
locale: string; // the locale this request is served in, e.g. "sv-SE" — also <html lang>
|
||||
// The locale this request is served in, e.g. "sv-SE" — also what <html lang> says.
|
||||
locale: string;
|
||||
// Carry the visitor's chosen locale onto a link this page renders. A no-op unless the request
|
||||
// asked for one with ?locale (there is no locale cookie — the URL is where the choice lives), and
|
||||
// on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
|
||||
// wraps the hrefs it builds itself.
|
||||
localeHref(href: string): string;
|
||||
locales: string[]; // every installed locale, sorted — for a plugin building its own language picker
|
||||
// Every installed locale, sorted. With `localeLabel` (from #plugin-api) it is what a plugin needs
|
||||
// to build its own language picker; the host's own picker is already in the shell.
|
||||
locales: string[];
|
||||
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
|
||||
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
|
||||
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
|
||||
log: Log;
|
||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||
|
||||
@@ -11,7 +11,7 @@ test("a complete translation reports nothing", () => {
|
||||
});
|
||||
|
||||
test("a missing or unknown key is reported", () => {
|
||||
const missing = parity("sv-SE", { "shifts.count": { one: "a", other: "b" } });
|
||||
const missing = parity("sv-SE", { "shifts.count": { one: "{{count}} pass", other: "{{count}} pass" } });
|
||||
assert.equal(missing.length, 1);
|
||||
assert.match(missing[0] ?? "", /missing key "greeting"/);
|
||||
|
||||
@@ -21,21 +21,21 @@ test("a missing or unknown key is reported", () => {
|
||||
});
|
||||
|
||||
test("a key must stay the same kind as in the baseline", () => {
|
||||
const flat = parity("sv-SE", { greeting: "Hej", "shifts.count": "pass" });
|
||||
const flat = parity("sv-SE", { greeting: "Hej", "shifts.count": "{{count}} pass" });
|
||||
assert.equal(flat.length, 1);
|
||||
assert.match(flat[0] ?? "", /"shifts.count" must be a plural message/);
|
||||
|
||||
const plural = parity("sv-SE", { greeting: { one: "Hej", other: "Hej" }, "shifts.count": { one: "a", other: "b" } });
|
||||
const plural = parity("sv-SE", { greeting: { one: "Hej", other: "Hej" }, "shifts.count": { one: "{{count}} pass", other: "{{count}} pass" } });
|
||||
assert.equal(plural.length, 1);
|
||||
assert.match(plural[0] ?? "", /"greeting" must be a string/);
|
||||
});
|
||||
|
||||
test("a plural message must cover exactly its own locale's categories", () => {
|
||||
const short = parity("cs-CZ", { greeting: "Ahoj", "shifts.count": { one: "a", other: "b" } });
|
||||
const short = parity("cs-CZ", { greeting: "Ahoj", "shifts.count": { one: "{{count}} směna", other: "{{count}} směn" } });
|
||||
assert.equal(short.length, 1);
|
||||
assert.match(short[0] ?? "", /"shifts\.count".*cs-CZ.*few, many/);
|
||||
|
||||
const long = parity("sv-SE", { greeting: "Hej", "shifts.count": { few: "x", one: "a", other: "b" } });
|
||||
const long = parity("sv-SE", { greeting: "Hej", "shifts.count": { few: "{{count}} pass", one: "{{count}} pass", other: "{{count}} pass" } });
|
||||
assert.equal(long.length, 1);
|
||||
assert.match(long[0] ?? "", /"shifts\.count".*few/);
|
||||
});
|
||||
@@ -55,3 +55,13 @@ test("isCatalog accepts strings and plural objects, rejects anything else", () =
|
||||
assert.equal(isCatalog(null), false);
|
||||
assert.equal(isCatalog([]), false);
|
||||
});
|
||||
|
||||
test("a translation must interpolate exactly what the baseline does", () => {
|
||||
const withVars: Catalog = { hi: "Hi {{name}}, you have {{n}} left" };
|
||||
const check = (catalog: Catalog): string[] => checkCatalog({ baseline: withVars, baselineLocale: "en-US", catalog, locale: "sv-SE" });
|
||||
|
||||
assert.deepEqual(check({ hi: "Hej {{name}}, du har {{n}} kvar" }), []);
|
||||
assert.match(check({ hi: "Hej, du har {{n}} kvar" })[0] ?? "", /"hi" never uses \{\{name\}\}/); // dropped ⇒ a blank on screen
|
||||
assert.match(check({ hi: "Hej {{namn}}, du har {{n}} kvar" })[0] ?? "", /never uses \{\{name\}\}/); // misspelled ⇒ both problems
|
||||
assert.match(check({ hi: "Hej {{name}} {{n}} {{extra}}" })[0] ?? "", /uses \{\{extra\}\}/); // never supplied ⇒ renders raw
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export type Catalog = Record<string, Message>;
|
||||
export const DEFAULT_LOCALE = "en-US";
|
||||
|
||||
const CATEGORIES: ReadonlySet<string> = new Set(["few", "many", "one", "other", "two", "zero"]);
|
||||
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
|
||||
|
||||
export function isPluralMessage(value: Message): value is PluralMessage {
|
||||
return typeof value !== "string";
|
||||
@@ -51,6 +52,7 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari
|
||||
problems.push(`"${key}" must be a ${isPluralMessage(expected) ? "plural message" : "string"}, like ${baselineLocale}`);
|
||||
continue;
|
||||
}
|
||||
for (const problem of placeholderProblems(key, expected, actual, baselineLocale)) problems.push(problem);
|
||||
if (!isPluralMessage(actual)) continue;
|
||||
const forms = new Set(Object.keys(actual));
|
||||
const missing = categories.filter((category) => !forms.has(category));
|
||||
@@ -67,6 +69,28 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari
|
||||
return problems;
|
||||
}
|
||||
|
||||
// A translation must interpolate exactly what the baseline does: a dropped {{name}} renders
|
||||
// "Signed in as ", a misspelled one renders the placeholder itself — the half-translated class this
|
||||
// check exists to stop, and neither is visible from the key set alone.
|
||||
function placeholderProblems(key: string, expected: Message, actual: Message, baselineLocale: string): string[] {
|
||||
const wanted = placeholders(expected);
|
||||
const got = placeholders(actual);
|
||||
const missing = [...wanted].filter((name) => !got.has(name));
|
||||
const unknown = [...got].filter((name) => !wanted.has(name));
|
||||
return [
|
||||
...(missing.length ? [`"${key}" never uses ${missing.map((n) => `{{${n}}}`).join(", ")}, which ${baselineLocale} does`] : []),
|
||||
...(unknown.length ? [`"${key}" uses ${unknown.map((n) => `{{${n}}}`).join(", ")}, which ${baselineLocale} does not supply`] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function placeholders(message: Message): Set<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.
|
||||
export function pluralCategories(locale: string): Intl.LDMLPluralRule[] {
|
||||
try {
|
||||
|
||||
+8
-1
@@ -3,13 +3,20 @@
|
||||
// view model built outside a request, an app created without `i18n` — so an unwired path renders
|
||||
// real English rather than bare keys. server.ts replaces it with the discovered catalogs at boot.
|
||||
|
||||
import { DEFAULT_LOCALE } from "./catalog.ts";
|
||||
import { type Catalog, DEFAULT_LOCALE } from "./catalog.ts";
|
||||
import enUS from "./locales/en-US.ts";
|
||||
import { createI18n, type I18n } from "./runtime.ts";
|
||||
import { createTranslator, type Translate } from "./translate.ts";
|
||||
|
||||
export const ENGLISH: Translate = createTranslator({ catalogs: [enUS], locale: DEFAULT_LOCALE });
|
||||
|
||||
// A plugin's own English: its catalog first, the host's behind it — the same chain the host builds
|
||||
// per request, minus the locale. A plugin uses it as the default for a view model built outside a
|
||||
// request (its unit tests), so the generic words it reuses from core still read as words.
|
||||
export function englishTranslator(catalog: Catalog): Translate {
|
||||
return createTranslator({ catalogs: [catalog, enUS], locale: DEFAULT_LOCALE });
|
||||
}
|
||||
|
||||
export const ENGLISH_I18N: I18n = createI18n({
|
||||
available: [DEFAULT_LOCALE],
|
||||
core: new Map([[DEFAULT_LOCALE, enUS]]),
|
||||
|
||||
@@ -99,6 +99,26 @@ test("a plugin with translations but no en-US baseline is an error", async () =>
|
||||
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /shop/);
|
||||
});
|
||||
|
||||
test("a mounted locales/ adds a language, and replaces a shipped one wholesale", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({
|
||||
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
|
||||
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
|
||||
"mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`),
|
||||
"mounted/sv-SE.ts": catalog(`{ hello: "Tjena" }`),
|
||||
});
|
||||
const loaded = await loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginsDir });
|
||||
assert.deepEqual(loaded.available, ["en-US", "nb-NO", "sv-SE"]);
|
||||
assert.deepEqual(loaded.core.get("sv-SE"), { hello: "Tjena" }); // the operator's file wins outright
|
||||
});
|
||||
|
||||
test("a mounted catalog is held to the same baseline as a shipped one", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({
|
||||
"locales/en-US.ts": catalog(`{ hello: "Hello", bye: "Bye" }`),
|
||||
"mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`), // no `bye` ⇒ half the app would be English
|
||||
});
|
||||
await assert.rejects(loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginsDir }), /nb-NO.*missing key "bye"/s);
|
||||
});
|
||||
|
||||
test("a plugin without an i18n folder is fine", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({ "locales/en-US.ts": catalog(`{ hello: "Hello" }`) });
|
||||
const loaded = await loadI18n({ localesDir, pluginIds: ["plain"], pluginsDir });
|
||||
|
||||
@@ -12,7 +12,14 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { checkCatalog, DEFAULT_LOCALE, isCatalog, type Catalog } from "./catalog.ts";
|
||||
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
// The shipped catalogs, and the drop-in mount root an operator adds their own to — a folder there
|
||||
// is a whole locale: a new tag adds a language, an existing one replaces the shipped catalog for it
|
||||
// (and is held to the same parity check, so a partial replacement fails the boot rather than
|
||||
// leaving half the app in English). Mirrors plugins/ and config/; ships empty.
|
||||
export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
|
||||
export const MOUNTED_LOCALES_DIR = join(rootDir, "locales");
|
||||
|
||||
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts. Anything else in
|
||||
// the folder is a mistake worth stopping for.
|
||||
@@ -20,6 +27,8 @@ const LOCALE_FILE = /^([a-z]{2,3}-[A-Z]{2})\.ts$/;
|
||||
|
||||
export interface LoadI18nOptions {
|
||||
localesDir?: string;
|
||||
logger?: Pick<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
|
||||
pluginsDir?: string;
|
||||
}
|
||||
@@ -32,10 +41,13 @@ export interface LoadedI18n {
|
||||
|
||||
export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18n> {
|
||||
const localesDir = options.localesDir ?? LOCALES_DIR;
|
||||
const mountedDir = options.mountedLocalesDir ?? MOUNTED_LOCALES_DIR;
|
||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||
const logger = options.logger ?? console;
|
||||
const errors: string[] = [];
|
||||
|
||||
const core = await readSet(localesDir, "core", errors);
|
||||
for (const [locale, catalog] of await readSet(mountedDir, "locales", errors)) core.set(locale, catalog);
|
||||
if (!core.has(DEFAULT_LOCALE)) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
|
||||
checkSet(core, "core", errors);
|
||||
const available = [...core.keys()].sort();
|
||||
@@ -51,6 +63,10 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
|
||||
if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ const messages = {
|
||||
|
||||
"brand.sub": "Console",
|
||||
|
||||
// Generic UI verbs every screen needs. A plugin reuses these (the lookup falls through to core)
|
||||
// and keeps its own catalog for its domain words — see README → Languages.
|
||||
"common.add": "Add",
|
||||
"common.cancel": "Cancel",
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
"common.new": "New",
|
||||
"common.remove": "Remove",
|
||||
"common.save": "Save",
|
||||
|
||||
"consent.allow": "Allow",
|
||||
"consent.deny": "Deny",
|
||||
"consent.notYou": "Not you?",
|
||||
|
||||
@@ -30,6 +30,14 @@ const messages: CoreMessages = {
|
||||
|
||||
"brand.sub": "Konsol",
|
||||
|
||||
"common.add": "Lägg till",
|
||||
"common.cancel": "Avbryt",
|
||||
"common.delete": "Ta bort",
|
||||
"common.edit": "Redigera",
|
||||
"common.new": "Ny",
|
||||
"common.remove": "Ta bort",
|
||||
"common.save": "Spara",
|
||||
|
||||
"consent.allow": "Tillåt",
|
||||
"consent.deny": "Neka",
|
||||
"consent.notYou": "Inte du?",
|
||||
|
||||
Binary file not shown.
+19
-2
@@ -4,7 +4,6 @@
|
||||
|
||||
import { DEFAULT_LOCALE } from "./catalog.ts";
|
||||
import { ENGLISH } from "./english.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { localeHref, localeLabel, textDirection } from "./locale.ts";
|
||||
import type { Translate } from "./translate.ts";
|
||||
|
||||
@@ -19,28 +18,46 @@ export interface I18nLocals {
|
||||
dir: "ltr" | "rtl";
|
||||
locale: string;
|
||||
localeHref: (href: string) => string;
|
||||
// The locale to carry as a hidden field, or null when the visitor never asked for one. A GET form
|
||||
// replaces the whole query string, so a link-carrying wrapper can't reach it — the form must.
|
||||
localeParam: string | null;
|
||||
localeSwitch: LocaleChoice[];
|
||||
locales: string[];
|
||||
t: Translate;
|
||||
}
|
||||
|
||||
// Just the request fields a render needs, so this module stays a leaf of src/i18n/ rather than
|
||||
// depending on the HTTP layer that calls it.
|
||||
export interface I18nRequest {
|
||||
locale: string;
|
||||
localeHref: (href: string) => string;
|
||||
locales: string[];
|
||||
t: Translate;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
// For a render with no request behind it — a partial exercised directly, a one-off render: English,
|
||||
// left-to-right, no language picker.
|
||||
export const ENGLISH_LOCALS: I18nLocals = {
|
||||
dir: "ltr",
|
||||
locale: DEFAULT_LOCALE,
|
||||
localeHref: (href) => href,
|
||||
localeParam: null,
|
||||
localeSwitch: [],
|
||||
locales: [DEFAULT_LOCALE],
|
||||
t: ENGLISH,
|
||||
};
|
||||
|
||||
export function i18nLocals(ctx: RequestContext): I18nLocals {
|
||||
export function i18nLocals(ctx: I18nRequest): I18nLocals {
|
||||
const here = `${ctx.url.pathname}${ctx.url.search}`;
|
||||
// ctx.localeHref is a no-op unless the URL asked for a locale, so it is also the honest answer to
|
||||
// "did it?" — asking the function that decides keeps the two from drifting apart.
|
||||
const carried = ctx.localeHref("/") === "/" ? null : ctx.locale;
|
||||
return {
|
||||
dir: textDirection(ctx.locale),
|
||||
locale: ctx.locale,
|
||||
localeHref: (href) => ctx.localeHref(href),
|
||||
localeParam: carried,
|
||||
localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })),
|
||||
locales: ctx.locales,
|
||||
t: ctx.t,
|
||||
|
||||
@@ -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
|
||||
// in a unit test. `PluralMessage` types a message that varies with a count.
|
||||
export { createTranslator } from "../i18n/translate.ts";
|
||||
// `englishTranslator(yourCatalog)` chains your catalog in front of the host's English — the default
|
||||
// for a view model built outside a request, so core words you reuse still read as words in a test.
|
||||
export { englishTranslator } from "../i18n/english.ts";
|
||||
// `localeLabel(tag)` names a locale in its own language ("svenska (Sverige)") — what ctx.locales
|
||||
// needs to become a language picker of your own.
|
||||
export { localeLabel } from "../i18n/locale.ts";
|
||||
export type { Translate, TranslateVars } from "../i18n/translate.ts";
|
||||
export type { Catalog, PluralMessage } from "../i18n/catalog.ts";
|
||||
export { parseListQuery } from "../ui/list-query.ts";
|
||||
|
||||
+5
-4
@@ -38,13 +38,14 @@ const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.rev
|
||||
|
||||
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
|
||||
log.info("plugins discovered", { count: plugins.length, ids: plugins.map((p) => p.id).join(", ") });
|
||||
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
|
||||
|
||||
// Translation catalogs: the core locales plus each discovered plugin's — fails loud if a locale
|
||||
// drifts from its en-US baseline, so a half-translated deploy never reaches a visitor.
|
||||
const i18n = createI18n(await loadI18n({ pluginIds: plugins.map((p) => p.id) }));
|
||||
// drifts from its en-US baseline, so a half-translated deploy never reaches a visitor. Loaded
|
||||
// before the boot hooks, so a catalog mismatch aborts before a plugin's onBoot has any side effect.
|
||||
const i18n = createI18n(await loadI18n({ logger: log, pluginIds: plugins.map((p) => p.id) }));
|
||||
log.info("locales loaded", { locales: i18n.available.join(", ") });
|
||||
|
||||
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
|
||||
|
||||
const server = createApp({
|
||||
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless
|
||||
// APP_URL is set, so the redirect is fully off — and costs nothing — when unconfigured.
|
||||
|
||||
+4
-4
@@ -11,7 +11,7 @@ import type { Translate } from "../i18n/translate.ts";
|
||||
import { type MenuConfig } from "./menu-config.ts";
|
||||
import { composeNav, type NavNode } from "./nav.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { shellUser, type ShellUser } from "./shell-context.ts";
|
||||
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
||||
|
||||
// The "Dashboard" link to the gated app home (/dashboard). It targets a gated route, so it's shown
|
||||
// only to a signed-in user (an anonymous click would only dead-end at /login). Its label is a
|
||||
@@ -62,15 +62,15 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
if (target) markCurrent(nav, target);
|
||||
}
|
||||
|
||||
const b = opts.menu.branding;
|
||||
// The sign-in link keeps the visitor's locale, and brings it back afterwards via return_to.
|
||||
const returnTo = opts.currentPath ? `/login?return_to=${encodeURIComponent(carryLocale(opts.currentPath))}` : "/login";
|
||||
const { theme, ...brand } = branding(opts.menu, t);
|
||||
return {
|
||||
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: t(b.name), ...(b.sub != null ? { sub: t(b.sub) } : {}) },
|
||||
brand,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
nav: carryLocaleInto(nav, carryLocale),
|
||||
signInHref: carryLocale(returnTo),
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
...(theme != null ? { theme } : {}),
|
||||
user: shellUser(opts.user, t),
|
||||
};
|
||||
}
|
||||
|
||||
+2
-1
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
|
||||
const menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs");
|
||||
const render = (data: Record<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();
|
||||
|
||||
test("menu renders trigger, positioning, the item matrix and check groups", async () => {
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@
|
||||
// the override (+ branding); this helper only transforms data, so its result is per-deployment
|
||||
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
|
||||
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
|
||||
export interface NavNode {
|
||||
@@ -42,7 +43,7 @@ export function composeNav(
|
||||
fragments: NavNode[][] = [],
|
||||
override: NavOverride = {},
|
||||
permissions: string[] = [],
|
||||
t: Translate = (key) => key,
|
||||
t: Translate = ENGLISH,
|
||||
): NavNode[] {
|
||||
let nodes: NavNode[] = fragments.flat();
|
||||
if (override.rename) nodes = renameTree(nodes, override.rename);
|
||||
|
||||
+15
-3
@@ -10,6 +10,18 @@ import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
import { type MenuConfig } from "./menu-config.ts";
|
||||
|
||||
// The brand block both the chrome and the shell model carry. `name`/`sub` pass through `t`, so a
|
||||
// catalog key is translated and an operator's own wording renders as written.
|
||||
export function branding(menu: MenuConfig, t: Translate): { logo?: string; name: string; sub?: string; theme?: string } {
|
||||
const b = menu.branding;
|
||||
return {
|
||||
...(b.logo != null ? { logo: b.logo } : {}),
|
||||
name: t(b.name),
|
||||
...(b.sub != null ? { sub: t(b.sub) } : {}),
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ShellUser {
|
||||
email: string;
|
||||
initials: string;
|
||||
@@ -44,14 +56,14 @@ export function buildShellContext(opts: {
|
||||
title: string;
|
||||
user?: User | null;
|
||||
}): ShellModel {
|
||||
const b = opts.menu.branding;
|
||||
const t = opts.t ?? ENGLISH;
|
||||
const { theme, ...brand } = branding(opts.menu, t);
|
||||
return {
|
||||
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: t(b.name), ...(b.sub != null ? { sub: t(b.sub) } : {}) },
|
||||
brand,
|
||||
...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}),
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
...(theme != null ? { theme } : {}),
|
||||
title: opts.title,
|
||||
user: shellUser(opts.user, t),
|
||||
};
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
const alt = locals.alt;
|
||||
-%>
|
||||
<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) { -%>
|
||||
<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>
|
||||
<% } -%>
|
||||
<div class="auth-form"><%- locals.body || "" %></div>
|
||||
<% 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>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
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).
|
||||
Action = { label, icon?, href?, danger?, separatorBefore? }
|
||||
Every href (sort headers, row-header links, row actions) is run through localeHref, so a sorted or
|
||||
paged list stays in the visitor's language without the caller wiring it.
|
||||
%><%
|
||||
const caption = locals.caption;
|
||||
const selectable = !!locals.selectable;
|
||||
@@ -28,7 +30,7 @@
|
||||
<% } -%>
|
||||
<% columns.forEach((col) => { -%>
|
||||
<% if (col.sortable) { -%>
|
||||
<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 { -%>
|
||||
<th scope="col"<% if (col.className) { %> class="<%= col.className %>"<% } %>><%= col.label %></th>
|
||||
<% } -%>
|
||||
@@ -53,7 +55,7 @@
|
||||
<% } 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>
|
||||
<% } 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) { -%>
|
||||
<td><span class="badge <%= cell.badge.tone %>"><span class="dot"></span><%= cell.badge.label %></span></td>
|
||||
<% } else if (cell.html != null) { -%>
|
||||
@@ -65,7 +67,7 @@
|
||||
<% if (withActions) { -%>
|
||||
<% 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) => { -%>
|
||||
<% 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>
|
||||
<% } else { -%>
|
||||
<td class="col-actions"></td>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
select { name, label, value?, options:{value,label}[] }
|
||||
chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes)
|
||||
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} }
|
||||
The form is a GET, which replaces the whole query string — so the visitor's chosen language rides
|
||||
along as a hidden input, and every href here (pills, clear) is run through localeHref.
|
||||
%><%
|
||||
const action = locals.action || "";
|
||||
const label = locals.label || t("filter.label");
|
||||
@@ -17,7 +19,10 @@
|
||||
const applyLabel = locals.applyLabel || t("filter.apply");
|
||||
const eq = (a, b) => String(a ?? "") === String(b);
|
||||
-%>
|
||||
<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) => { -%>
|
||||
<div class="filter-row">
|
||||
<% row.forEach((c) => { -%>
|
||||
@@ -39,7 +44,7 @@
|
||||
<% }) -%>
|
||||
<div class="filter-row filter-foot">
|
||||
<% 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="filter-actions">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<%- include("field", field) %>
|
||||
<% }) -%>
|
||||
<% 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) => { -%>
|
||||
<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>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<% } 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>
|
||||
<% } 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 { -%>
|
||||
<button class="menu-item<%= it.danger ? " danger" : "" %>" type="button"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></button>
|
||||
<% } -%>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
options: (number | { value, label })[]; hidden: { name, value }[] carries list state
|
||||
prev?, next? { href? } page step; omit href ⇒ disabled
|
||||
pages? { label, href?, current?, ellipsis? }[]
|
||||
Every href is run through localeHref, and the rows form carries the locale as a hidden input —
|
||||
a GET submit replaces the whole query string, so it would drop the visitor's language otherwise.
|
||||
%><%
|
||||
const label = locals.label || t("pagination.label");
|
||||
const summary = locals.summary;
|
||||
@@ -22,10 +24,13 @@
|
||||
<span><%= summary.from %>–<%= summary.to %> <%= t("pagination.of") %> <b><%= summary.total %></b></span>
|
||||
<% } -%>
|
||||
<% 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) => { -%>
|
||||
<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>
|
||||
<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>
|
||||
@@ -35,7 +40,7 @@
|
||||
<nav class="page-nums" aria-label="<%= label %>">
|
||||
<% if (prev) { -%>
|
||||
<% 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 { -%>
|
||||
<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) { -%>
|
||||
<span class="page-btn" aria-current="page"><%= p.label %></span>
|
||||
<% } 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.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 { -%>
|
||||
<button class="page-btn" type="button" disabled aria-label="<%= t("pagination.next") %>"><svg class="ico ico-sm"><use href="#i-chev"/></svg></button>
|
||||
<% } -%>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
(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
|
||||
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 title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it)
|
||||
|
||||
Reference in New Issue
Block a user