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 17 additions and 5 deletions
Showing only changes of commit 7e4c6940c9 - Show all commits
+3 -2
View File
@@ -147,8 +147,9 @@ them. Revisit only if the stated reason stops holding.
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.
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value,
so nothing is double-escaped; a message carrying markup uses `<%- %>` and must not interpolate
untrusted data. Don't move escaping into `t()`.
so nothing is double-escaped; a message carrying markup uses `<%- %>`, and then its `{{vars}}` are
escaped at the call site (see `views/partials/pagination.ejs`). Don't move escaping into `t()`
every other value in a view would then be the odd one out.
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
+2 -2
View File
@@ -998,8 +998,8 @@ Three rules worth knowing:
plain text — `nav: [{ label: "shop.title" }]` is translated, `label: "Shop"` is not, and neither
breaks. The same holds for `config/menu.ts` branding and its `rename` overrides.
- **`t()` returns raw text; the view escapes it.** Use `<%= %>` as for any other value. A message
that deliberately carries markup is rendered with `<%- %>` — and must never interpolate
untrusted data, since nothing escapes it there.
that deliberately carries markup is rendered with `<%- %>` — and its `{{vars}}` must then be
escaped at the call site, since nothing escapes them there (`pagination.ejs` is the worked example).
- **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** — every href they render (menu, breadcrumbs,
pagination, sort headers, row actions, the auth card's links) goes through `localeHref`, and their
+7
View File
@@ -68,3 +68,10 @@ test("pagination renders a valid empty footer and never throws on missing config
assert.match(html, /<option value="50" selected>50 \/ page<\/option>/);
assert.match(html, /<button class="page-btn" type="submit">Set<\/button>/);
});
test("the summary message renders its markup but escapes the values interpolated into it", async () => {
// It is the one message rendered raw (<%- %>), so a value that arrives as text can't inject.
const html = await render({ summary: { from: 1, to: 12, total: '<img src=x onerror=alert(1)>' } });
assert.match(html, /<b>&lt;img src=x onerror=alert\(1\)&gt;<\/b>/);
assert.doesNotMatch(html, /<img/);
});
+5 -1
View File
@@ -18,10 +18,14 @@
const next = locals.next;
const pages = locals.pages || [];
const eq = (a, b) => String(a ?? "") === String(b);
// The summary message carries markup (<b> around the total), so it renders raw — which means its
// values must be escaped here instead. Callers pass paginate() numbers today; a building block
// shouldn't depend on that.
const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
-%>
<footer class="pager">
<% if (summary) { -%>
<span><%- t("pagination.summary", { from: summary.from, to: summary.to, total: summary.total }) %></span>
<span><%- t("pagination.summary", { from: esc(summary.from), to: esc(summary.to), total: esc(summary.total) }) %></span>
<% } -%>
<% if (rows) { -%>
<form class="pager-rows" method="get"<% if (rows.action) { %> action="<%= localeHref(rows.action) %>"<% } %>>