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. 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), - **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 `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 (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 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. 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 that deliberately carries markup is rendered with `<%- %>` — and must never interpolate
untrusted data, since nothing escapes it there. untrusted data, since nothing escapes it there.
- **Dates and numbers are `Intl`'s job**, not the catalog's: `new Intl.DateTimeFormat(ctx.locale)`. - **Dates and numbers are `Intl`'s job**, not the catalog's: `new Intl.DateTimeFormat(ctx.locale)`.
- **The core building blocks carry the locale for you.** The shell's breadcrumbs, `pagination`, - **The core building blocks carry the locale for you** — every href they render (menu, breadcrumbs,
`filter-bar`, `data-table`, `auth-card`, `field` and the nav wrap every href they render, and the pagination, sort headers, row actions, the auth card's links) goes through `localeHref`, and their
two GET forms (filter bar, rows-per-page) carry it as a hidden `locale` input — a GET submit GET forms carry it as a hidden field, since a GET submit replaces the whole query string.
replaces the whole query string, so no href wrapper can reach it. `ctx.localeHref` is only for `ctx.localeHref` is for hrefs your own markup emits, and `localeParam` (a view local: the tag, or
hrefs your own markup emits, and `localeParam` (a view local: the tag, or null) for your own GET null) for your own GET forms. `locale` is reserved: `parseListQuery` never returns it as a filter.
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/ - **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 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. 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, /<span>&lt;b&gt;Required&lt;\/b&gt;\.<\/span>/); // string error is escaped
assert.match(stringErr, /aria-describedby="x-err"/); 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 ejs from "ejs";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts"; 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 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 }); 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, /<aside class="sidebar"/);
assert.match(bare, /<main class="content"/); 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
});