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
4 changed files with 24 additions and 7 deletions
Showing only changes of commit 2b20497785 - Show all commits
+2 -1
View File
@@ -111,7 +111,8 @@ them. Revisit only if the stated reason stops holding.
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.** 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
href they render in `localeHref`; the nav and the sign-in link are 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.
+5 -6
View File
@@ -998,12 +998,11 @@ 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.** 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.
- **The core building blocks carry the locale for you** — every href they render (menu, breadcrumbs,
pagination, sort headers, row actions, the auth card's links) goes through `localeHref`, and their
GET forms carry it as a hidden field, since a GET submit replaces the whole query string.
`ctx.localeHref` is 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.
+8
View File
@@ -52,3 +52,11 @@ test("field defaults to a bare text input, escapes a string error, and never thr
assert.match(stringErr, /<span>&lt;b&gt;Required&lt;\/b&gt;\.<\/span>/); // string error is escaped
assert.match(stringErr, /aria-describedby="x-err"/);
});
test("an inline field link carries the visitor's language", async () => {
const html = await ejs.renderFile(field, {
...ENGLISH_LOCALS, localeHref: (href: string) => `${href}?locale=sv-SE`,
id: "password", label: "Password", link: { href: "/recovery", label: "Forgot password?" }, name: "password",
});
assert.match(html, /href="\/recovery\?locale=sv-SE"/);
});
+9
View File
@@ -5,6 +5,9 @@ import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
// A localeHref that marks what it touches, so a raw href in the chrome is visible to a test.
const CARRYING = { ...ENGLISH_LOCALS, localeHref: (href: string) => `${href}?locale=sv-SE` };
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, { ...ENGLISH_LOCALS, ...data });
@@ -107,3 +110,9 @@ test("app shell escapes text but passes slot HTML through, and renders with defa
assert.match(bare, /<aside class="sidebar"/);
assert.match(bare, /<main class="content"/);
});
test("the chrome carries the visitor's language: breadcrumb links go through localeHref", async () => {
const html = await ejs.renderFile(shell, { ...CARRYING, breadcrumbs: [{ href: "/admin/users", label: "Users" }, { label: "Ada" }], title: "Ada" });
assert.match(html, /<a href="\/admin\/users\?locale=sv-SE">Users<\/a>/);
assert.match(html, /<span>Ada<\/span>/); // the current crumb has no href to carry
});