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
13 changed files with 62 additions and 27 deletions
Showing only changes of commit b3df7084c4 - Show all commits
+16 -9
View File
@@ -109,15 +109,17 @@ 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`,
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
`pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
href they render in `localeHref`; the nav is wrapped upstream in `chrome.ts`; and the two GET forms
(filter bar, rows-per-page) carry it as a hidden `locale` input, since a GET submit replaces the
whole query string and 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 (the admin example's delete links).
Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw,
so: when a link renders from the core chrome, it is the chrome's job to carry the locale.
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`),
so a localized list page doesn't hand a plugin a phantom `locale` 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
@@ -223,6 +225,11 @@ Same test before adding a row to a table or the file map — a clause, not a par
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what
remains is orchestration. Planned split along those seams; don't grow it further without taking one
out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change.
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
+6 -4
View File
@@ -998,10 +998,12 @@ 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.
- **The core building blocks carry the locale for you.** The shell's breadcrumbs, `pagination`,
`filter-bar`, `data-table`, `auth-card`, `field` and the nav wrap every href they render, and the
two GET forms (filter bar, rows-per-page) carry it as a hidden `locale` input — a GET submit
replaces the whole query string, so no href wrapper can reach it. `ctx.localeHref` is only for
hrefs your own markup emits, and `localeParam` (a view local: the tag, or null) for your own GET
forms. `locale` is reserved: `parseListQuery` never returns it as a filter.
- **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.
+5
View File
@@ -67,6 +67,11 @@ test.describe.serial("authenticated admin journey", () => {
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");
// The breadcrumb is the chrome's way back up — it is rendered by the shell, not by the screen.
await page.getByRole("navigation", { name: "Sidsökväg" }).getByRole("link").first().click();
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 () => {
+7
View File
@@ -81,6 +81,10 @@ const messages = {
"admin.notFound.message": "That item doesn't exist.",
"admin.notFound.title": "Not found",
"admin.permissions.actions": "Permission actions",
"admin.permissions.allAssigned": "All users and groups already have this permission.",
"admin.permissions.assign": "Assign the permission",
"admin.permissions.assignAction": "Assign",
"admin.permissions.assignTo": "Assign to",
"admin.permissions.assignedTo": "Assigned to",
"admin.permissions.column.members": "Members",
@@ -90,10 +94,13 @@ const messages = {
"admin.permissions.deleteMessage": "Delete permission {{name}}? This revokes it from everyone it's assigned to.",
"admin.permissions.error.adminUndeletable": "The admin permission can't be deleted — it would remove all admin access.",
"admin.permissions.error.selfRevoke": "You can't revoke your own admin access.",
"admin.permissions.effective": "Effective access",
"admin.permissions.effectiveHint": "Everyone who holds this permission — directly or through a group (resolved by Keto).",
"admin.permissions.field.name": "Permission name",
"admin.permissions.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
"admin.permissions.filter": "Filter permissions",
"admin.permissions.new": "New permission",
"admin.permissions.noEffective": "No users hold this permission yet.",
"admin.permissions.noMembers": "Not assigned to anyone yet.",
"admin.permissions.pagination": "Permissions pagination",
"admin.permissions.revoke": "Revoke",
+7
View File
@@ -81,6 +81,10 @@ const messages: AdminMessages = {
"admin.notFound.message": "Objektet finns inte.",
"admin.notFound.title": "Hittades inte",
"admin.permissions.actions": "Behörighetsåtgärder",
"admin.permissions.allAssigned": "Alla användare och grupper har redan den här behörigheten.",
"admin.permissions.assign": "Tilldela behörigheten",
"admin.permissions.assignAction": "Tilldela",
"admin.permissions.assignTo": "Tilldela till",
"admin.permissions.assignedTo": "Tilldelad till",
"admin.permissions.column.members": "Medlemmar",
@@ -90,10 +94,13 @@ const messages: AdminMessages = {
"admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.",
"admin.permissions.error.adminUndeletable": "Behörigheten admin kan inte tas bort — det skulle ta bort all administratörsåtkomst.",
"admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen administratörsåtkomst.",
"admin.permissions.effective": "Faktisk åtkomst",
"admin.permissions.effectiveHint": "Alla som har behörigheten — direkt eller via en grupp (uppslaget av Keto).",
"admin.permissions.field.name": "Behörighetens namn",
"admin.permissions.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
"admin.permissions.filter": "Filtrera behörigheter",
"admin.permissions.new": "Ny behörighet",
"admin.permissions.noEffective": "Ingen användare har den här behörigheten ännu.",
"admin.permissions.noMembers": "Inte tilldelad till någon ännu.",
"admin.permissions.pagination": "Sidnavigering för behörigheter",
"admin.permissions.revoke": "Återkalla",
@@ -33,6 +33,6 @@
</section>
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
<p class="field-hint"><%= t("admin.clients.rereg") %></p>
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
</section>
</div>
@@ -37,6 +37,6 @@
<% } -%>
</section>
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
</section>
</div>
@@ -31,8 +31,8 @@
<% } -%>
</section>
<section class="form-card" aria-labelledby="effective-h">
<h2 class="card-title" id="effective-h">Effective access</h2>
<p class="field-hint">Everyone who holds this permission — directly or through a group (resolved by Keto).</p>
<h2 class="card-title" id="effective-h"><%= t("admin.permissions.effective") %></h2>
<p class="field-hint"><%= t("admin.permissions.effectiveHint") %></p>
<% if (effective.length) { -%>
<ul class="plain-list">
<% effective.forEach((u) => { -%>
@@ -40,18 +40,18 @@
<% }) -%>
</ul>
<% } else { -%>
<p class="cell-muted">No users hold this permission yet.</p>
<p class="cell-muted"><%= t("admin.permissions.noEffective") %></p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h">Assign the permission</h2>
<h2 class="card-title" id="add-h"><%= t("admin.permissions.assign") %></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">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</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>Assign</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("admin.permissions.assignAction") %></button></form>
<% } else { -%>
<p class="cell-muted">All users and groups already have this permission.</p>
<p class="cell-muted"><%= t("admin.permissions.allAssigned") %></p>
<% } -%>
</section>
<section class="form-card admin-actions" aria-label="Permission actions">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete permission</a>
<section class="form-card admin-actions" aria-label="<%= t("admin.permissions.actions") %>">
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.permissions.delete") %></a>
</section>
</div>
@@ -30,7 +30,7 @@
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
<a class="btn btn-danger" href="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
<a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
</section>
<% } -%>
</div>
+5
View File
@@ -51,3 +51,8 @@ test("parseListQuery honours custom reserved names and page-size bounds", () =>
assert.equal(parseListQuery("?n=999", { maxPageSize: 50, pageSizeParam: "n" }).pageSize, 50);
assert.deepEqual(parseListQuery("?q=hi", { qParam: "search" }).filters, { q: ["hi"] });
});
test("`locale` is the host's, not a filter — every localized list link carries it", () => {
const query = parseListQuery("/admin/users?locale=sv-SE&status=active");
assert.deepEqual(Object.keys(query.filters), ["status"]);
});
+3 -1
View File
@@ -31,7 +31,9 @@ export function parseListQuery(url: URL | URLSearchParams | string, options: Lis
const sortParam = options.sortParam ?? "sort";
const pageParam = options.pageParam ?? "page";
const pageSizeParam = options.pageSizeParam ?? "pageSize";
const reserved = new Set([pageParam, pageSizeParam, qParam, sortParam]);
// `locale` is host-owned (README → Languages): every list link and both GET forms carry it, so
// without this it would arrive as a phantom filter on every localized list page.
const reserved = new Set([pageParam, pageSizeParam, qParam, sortParam, "locale"]);
const filters: Record<string, string[]> = {};
for (const key of new Set(params.keys())) {
+1 -1
View File
@@ -21,7 +21,7 @@
-%>
<div class="field<% if (error) { %> has-error<% } %>">
<% if (link || optional) { -%>
<div class="field-top"><label for="<%= id %>"><%= locals.label %></label><% if (link) { %><a class="field-link" href="<%= link.href %>"><%= link.label %></a><% } else { %><span class="optional"><%= t("field.optional") %></span><% } %></div>
<div class="field-top"><label for="<%= id %>"><%= locals.label %></label><% if (link) { %><a class="field-link" href="<%= localeHref(link.href) %>"><%= link.label %></a><% } else { %><span class="optional"><%= t("field.optional") %></span><% } %></div>
<% } else { -%>
<label for="<%= id %>"><%= locals.label %></label>
<% } -%>
+1 -1
View File
@@ -104,7 +104,7 @@
<% if (title) { %><h1 class="page-title"><%= title %></h1><% } %>
<% if (breadcrumbs.length) { %>
<nav class="crumbs" aria-label="<%= t("shell.breadcrumb") %>">
<% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= c.href %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %>
<% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= localeHref(c.href) %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %>
</nav>
<% } %>
<div class="topbar-spacer"></div>