diff --git a/AGENTS.md b/AGENTS.md index 4baa587..8669410 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 1679284..7ae4dd1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/ui/field.test.ts b/src/ui/field.test.ts index 78d6258..2bf7ec1 100644 --- a/src/ui/field.test.ts +++ b/src/ui/field.test.ts @@ -52,3 +52,11 @@ test("field defaults to a bare text input, escapes a string error, and never thr assert.match(stringErr, /<b>Required<\/b>\.<\/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"/); +}); diff --git a/src/ui/shell.test.ts b/src/ui/shell.test.ts index fd46024..d654288 100644 --- a/src/ui/shell.test.ts +++ b/src/ui/shell.test.ts @@ -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 = {}): Promise => 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, /