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
18 changed files with 70 additions and 27 deletions
Showing only changes of commit bd76c981ee - Show all commits
+5 -1
View File
@@ -948,7 +948,11 @@ language there therefore leaves the POST's own result behind — a re-rendered f
one-time code — which is the accepted cost of having the picker everywhere.
**Writing a catalog.** `en-US.ts` exports the object and its type; every other locale is written
against that type, so a missing or misspelled key is a type error before the app ever boots:
against that type, so a missing or misspelled key is a type error before the app ever boots. For a
language of your own: copy `src/i18n/locales/en-US.ts` into `locales/<tag>.ts`, type it
`CoreMessages` (from `#plugin-api`), and translate. The `as PluralMessage` cast below is required —
without it the inferred type pins the plural forms to English's two, and a locale that selects more
(Polish, Arabic) becomes unwritable:
```ts
// plugins/shop/i18n/en-US.ts
+1 -1
View File
@@ -37,7 +37,7 @@ test("the switcher changes language, and the choice survives clicking through th
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.getByRole("heading", { name: "Startpanel" })).toBeVisible(); // the starter dashboard, in Swedish
await expect(page.getByRole("link", { name: "Panel" })).toBeVisible(); // the menu too
await expect(page.getByRole("link", { name: "Översikt", exact: true })).toBeVisible(); // the menu too
await mkdir(SHOTS, { recursive: true });
await page.screenshot({ fullPage: true, path: `${SHOTS}/live-05-swedish.png` });
+5 -5
View File
@@ -14,7 +14,7 @@ const messages: AdminMessages = {
"admin.clients.consent.screen": "Visar godkännandesidan",
"admin.clients.created": "Klienten är registrerad",
"admin.clients.createdNotice": "Klienten är registrerad.",
"admin.clients.delete": "Ta bort klient",
"admin.clients.delete": "Radera klient",
"admin.clients.deleteMessage": "Ta bort klienten {{name}}? Appar som använder den kan inte längre logga in via Plainpages.",
"admin.clients.error.rejected": "Hydra nekade klienten — kontrollera omdirigerings-URI:erna och scopen.",
"admin.clients.field.name": "Namn",
@@ -36,7 +36,7 @@ const messages: AdminMessages = {
"admin.clients.searchLabel": "Sök klienter",
"admin.clients.searchPlaceholder": "Sök på namn eller klient-ID…",
"admin.clients.secret": "Klienthemlighet",
"admin.clients.secretHint": "Kopiera nu — hemligheten kan inte visas igen. Spara dem där appen läser sina uppgifter.",
"admin.clients.secretHint": "Kopiera nu — hemligheten kan inte visas igen. Spara uppgifterna där appen läser dem.",
"admin.clients.title": "OAuth2-klienter",
"admin.clients.validation.name": "Ange ett namn för klienten.",
"admin.clients.validation.redirectUri": "\"{{uri}}\" är inte en giltig omdirigerings-URI — använd en absolut URL som https://app.example.com/callback.",
@@ -54,7 +54,7 @@ const messages: AdminMessages = {
"admin.groups.column.members": "Medlemmar",
"admin.groups.column.name": "Grupp",
"admin.groups.create": "Skapa grupp",
"admin.groups.delete": "Ta bort grupp",
"admin.groups.delete": "Radera grupp",
"admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det tar bort gruppen och alla dess medlemskap.",
"admin.groups.field.name": "Gruppnamn",
"admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
@@ -90,7 +90,7 @@ const messages: AdminMessages = {
"admin.permissions.column.members": "Medlemmar",
"admin.permissions.column.name": "Behörighet",
"admin.permissions.create": "Skapa behörighet",
"admin.permissions.delete": "Ta bort behörighet",
"admin.permissions.delete": "Radera behörighet",
"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.",
@@ -120,7 +120,7 @@ const messages: AdminMessages = {
"admin.users.confirm": "Bekräfta åtgärden",
"admin.users.create": "Skapa användare",
"admin.users.deactivate": "Inaktivera",
"admin.users.delete": "Ta bort användare",
"admin.users.delete": "Radera användare",
"admin.users.deleteMessage": "Ta bort {{email}}? Kontot tas bort permanent och det går inte att ångra.",
"admin.users.edit": "Redigera användare",
"admin.users.error.create": "Användaren kunde inte skapas — kontrollera e-postadressen och försök igen.",
+1
View File
@@ -500,6 +500,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
}
.menu-item-form { display: contents; } /* form wraps the Sign-out button without changing layout */
.menu-item:hover { background: var(--surface-2); }
.menu-item[aria-current] { font-weight: 600; color: var(--text); } /* the language you are in */
.menu-item.danger { color: var(--neg); }
.menu-item .ico { color: var(--text-faint); }
.menu-item.danger .ico { color: var(--neg); }
+9 -2
View File
@@ -7,6 +7,7 @@ import { readFormBody } from "../http/body.ts";
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
import type { RequestContext } from "../http/context.ts";
import { CSRF_FIELD } from "./csrf.ts";
import { chosenLocale } from "../i18n/locale.ts";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
import type { KetoClient } from "./keto-client.ts";
@@ -60,10 +61,16 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
const raw = ctx.url.searchParams.get("return_to");
const local = localPath(raw);
const chosen = chosenLocale(ctx);
let returnTo: string | undefined;
if (local) {
if (local || chosen) {
// The flow's return target is the host's, not Kratos' — so the language the visitor picked
// on the sign-in page survives the round-trip through Kratos and lands on the page after
// it. Without this the most-travelled path in the product (pick Swedish → sign in) drops
// straight back to Accept-Language.
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
complete.searchParams.set("return_to", local);
if (local) complete.searchParams.set("return_to", ctx.localeHref(local));
if (chosen) complete.searchParams.set("locale", chosen);
returnTo = complete.toString();
} else if (raw) returnTo = raw;
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
+2 -2
View File
@@ -372,8 +372,8 @@ test("/error renders a themed sign-in error page (Kratos' flow error sink), not
test("renders the 500 HTML page when a handler throws", async () => {
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // the dashboard view
cpSync(join(viewsDir, "500.ejs"), join(dir, "500.ejs"));
cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
try {
await new Promise<void>((resolve) => app.listen(0, resolve));
+6 -2
View File
@@ -54,8 +54,12 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
const baseline = shipped.get(DEFAULT_LOCALE);
if (!baseline) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
const core = new Map(shipped);
for (const [locale, catalog] of await readSet(mountedDir, "locales", errors)) core.set(locale, catalog);
checkSet(core, "core", baseline, errors);
const mounted = await readSet(mountedDir, "locales", errors);
for (const [locale, catalog] of mounted) core.set(locale, catalog);
// Checked under the folder they actually live in: telling an operator "core de-DE: missing key …"
// sends them to src/i18n/locales/, which holds no de-DE.ts at all.
checkSet(new Map([...core].filter(([locale]) => !mounted.has(locale))), "core", baseline, errors);
checkSet(mounted, "locales", baseline, errors);
const available = [...core.keys()].sort();
const plugins = new Map<string, Map<string, Catalog>>();
+9 -2
View File
@@ -76,13 +76,20 @@ export function localeHref(href: string, locale: string | null): string {
const directions = new Map<string, "ltr" | "rtl">();
const labels = new Map<string, string>();
// The locale this request explicitly asked for, or null. `localeHref` is a no-op unless one was
// chosen, so asking the function that decides keeps callers from re-deriving the rule.
export function chosenLocale(ctx: { locale: string; localeHref: (href: string) => string }): string | null {
return ctx.localeHref("/") === "/" ? null : ctx.locale;
}
interface TextInfoLocale {
getTextInfo?: () => { direction?: string };
textInfo?: { direction?: string };
}
// The document direction for <html dir>. Derived from the locale's script, so an RTL catalog flips
// the document the day it is added.
// The document direction for <html dir>, from the locale's script. It states the direction — the
// shipped stylesheet still uses physical left/right properties, so an RTL locale also needs those
// moved to logical ones before it lays out correctly.
export function textDirection(locale: string): "ltr" | "rtl" {
const cached = directions.get(locale);
if (cached !== undefined) return cached;
+2 -1
View File
@@ -61,7 +61,7 @@ const messages = {
"dashboard.starter.intro":
"This is the built-in <code>/dashboard</code> — the gated home shown to a signed-in user. It's a placeholder so a fresh clone has something here; it holds no real data.",
"dashboard.starter.reference":
"See the plugin contract in <code>docs/plugin-contract.md</code> (the landing-pages section) and the bundled <code>plugins/scheduling/</code> reference.",
"See the plugin contract in <code>README.md</code> (Building plugins → the landing pages) and the <code>examples/plugins/scheduling/</code> reference.",
"dashboard.starter.replace":
"Replace it from a plugin: export a <code>dashboard</code> handler from your plugin's manifest and it owns this page, rendered against your own views with the native app shell (the same menu you see now) via <code>ctx.chrome</code>.",
"dashboard.starter.title": "Starter dashboard",
@@ -131,6 +131,7 @@ const messages = {
"landing.title": "Operational web apps, without the boilerplate.",
"locale.label": "Language",
"locale.leavesPage": "Switching leaves this page",
"nav.dashboard": "Dashboard",
+7 -6
View File
@@ -32,7 +32,7 @@ const messages: CoreMessages = {
"common.add": "Lägg till",
"common.cancel": "Avbryt",
"common.delete": "Ta bort",
"common.delete": "Radera",
"common.edit": "Redigera",
"common.new": "Ny",
"common.remove": "Ta bort",
@@ -52,14 +52,14 @@ const messages: CoreMessages = {
"dashboard.starter.intro":
"Detta är den inbyggda <code>/dashboard</code> — den inloggade startsidan. Den är en platshållare så att en färsk klon har något här; den innehåller inga riktiga data.",
"dashboard.starter.reference":
"Se plugin-kontraktet i <code>docs/plugin-contract.md</code> (avsnittet om startsidorna) och referensen <code>plugins/scheduling/</code>.",
"Se plugin-kontraktet i <code>README.md</code> (Building plugins → startsidorna) och referensen <code>examples/plugins/scheduling/</code>.",
"dashboard.starter.replace":
"Ersätt den från ett plugin: exportera en <code>dashboard</code>-hanterare från pluginets manifest så äger det den här sidan, renderad mot dina egna vyer med appens eget skal (samma meny du ser nu) via <code>ctx.chrome</code>.",
"dashboard.starter.title": "Startpanel",
"dashboard.title": "Panel",
"dashboard.title": "Översikt",
"error.403.body": "Du har inte behörighet att se det här (403).",
"error.403.docTitle": "Åtkomst nekad",
"error.403.docTitle": "Förbjudet",
"error.403.title": "Åtkomst nekad",
"error.404.body": "Vi hittade inte sidan (404).",
"error.404.docTitle": "Sidan finns inte",
@@ -114,11 +114,12 @@ const messages: CoreMessages = {
"{{brand}} är en självhostad grund för administrativa och operativa gränssnitt — inloggning, en konfigurationsstyrd meny och ett serverrenderat designsystem utan JavaScript. Du lägger till de verksamhetsnära skärmarna genom att släppa in plugin-mappar.",
"landing.register": "Skapa konto",
"landing.signIn": "Logga in",
"landing.title": "Operativa webbappar, utan all pannplåt.",
"landing.title": "Operativa webbappar, utan allt grundarbete.",
"locale.label": "Språk",
"locale.leavesPage": "Byter du språk lämnar du den här sidan",
"nav.dashboard": "Panel",
"nav.dashboard": "Översikt",
"oauth.consentExpired": "Den här behörighetsbegäran har gått ut. Börja om från appen du skulle logga in i.",
"oauth.loginExpired": "Den här inloggningsbegäran har gått ut. Börja om från appen du skulle logga in i.",
+8 -4
View File
@@ -4,7 +4,7 @@
import { DEFAULT_LOCALE } from "./catalog.ts";
import { ENGLISH } from "./english.ts";
import { localeHref, localeLabel, textDirection } from "./locale.ts";
import { chosenLocale, localeHref, localeLabel, textDirection } from "./locale.ts";
import type { Translate } from "./translate.ts";
export interface LocaleChoice {
@@ -16,6 +16,10 @@ export interface LocaleChoice {
export interface I18nLocals {
dir: "ltr" | "rtl";
// True when switching language cannot stay on this page (its URL answers no GET, so the picker
// points elsewhere) — the page then says so, because what it leaves behind may be a one-time
// secret that cannot be shown again.
leavesPage: boolean;
locale: string;
localeHref: (href: string) => string;
// The locale to carry as a hidden field, or null when the visitor never asked for one. A GET form
@@ -43,6 +47,7 @@ export interface I18nRequest {
// left-to-right, no language picker.
export const ENGLISH_LOCALS: I18nLocals = {
dir: "ltr",
leavesPage: false,
locale: DEFAULT_LOCALE,
localeHref: (href) => href,
localeParam: null,
@@ -52,11 +57,10 @@ export const ENGLISH_LOCALS: I18nLocals = {
};
export function i18nLocals(ctx: I18nRequest): I18nLocals {
// ctx.localeHref is a no-op unless the URL asked for a locale, so it is also the honest answer to
// "did it?" — asking the function that decides keeps the two from drifting apart.
const carried = ctx.localeHref("/") === "/" ? null : ctx.locale;
const carried = chosenLocale(ctx);
return {
dir: textDirection(ctx.locale),
leavesPage: ctx.switchBase !== `${ctx.url.pathname}${ctx.url.search}`,
locale: ctx.locale,
localeHref: (href) => ctx.localeHref(href),
localeParam: carried,
+3
View File
@@ -22,6 +22,9 @@ export { englishTranslator } from "../i18n/english.ts";
export { localeLabel } from "../i18n/locale.ts";
export type { Translate, TranslateVars } from "../i18n/translate.ts";
export type { Catalog, PluralMessage } from "../i18n/catalog.ts";
// The shape of the core catalog — what an operator's own locales/<tag>.ts is written against, so a
// missing key is a type error in the editor rather than a wall of boot errors.
export type { CoreMessages } from "../i18n/locales/en-US.ts";
export { parseListQuery } from "../ui/list-query.ts";
export { paginate } from "../ui/paginate.ts";
export type { PageModel } from "../ui/paginate.ts";
+1
View File
@@ -13,6 +13,7 @@
<main>
<h1><%= t("error.403.title") %></h1>
<p><%= t("error.403.body") %></p>
<%- include("partials/locale-switch", { up: false }) %>
<p><a href="<%= localeHref("/") %>"><%= t("error.backHome") %></a></p>
</main>
</body>
+1
View File
@@ -13,6 +13,7 @@
<main>
<h1><%= t("error.404.title") %></h1>
<p><%= t("error.404.body") %></p>
<%- include("partials/locale-switch", { up: false }) %>
<p><a href="<%= localeHref("/") %>"><%= t("error.backHome") %></a></p>
</main>
</body>
+1
View File
@@ -13,6 +13,7 @@
<main>
<h1><%= t("error.500.title") %></h1>
<p><%= t("error.500.body") %></p>
<%- include("partials/locale-switch", { up: false }) %>
<p><a href="<%= localeHref("/") %>"><%= t("error.backHome") %></a></p>
</main>
</body>
+1
View File
@@ -13,6 +13,7 @@
<main>
<h1><%= t("error.503.title") %></h1>
<p><%= t("error.503.body") %></p>
<%- include("partials/locale-switch", { up: false }) %>
<p><a href="<%= localeHref("/login") %>"><%= t("error.tryAgain") %></a></p>
</main>
</body>
+1
View File
@@ -13,6 +13,7 @@
<main>
<h1><%= t("error.flow.title") %></h1>
<p><%= t("error.flow.body") %></p>
<%- include("partials/locale-switch", { up: false }) %>
<p><a href="<%= localeHref("/login") %>"><%= t("error.backToSignIn") %></a></p>
<% if (locals.id) { %><p><small><%= t("error.reference", { id }) %></small></p><% } %>
</main>
+7 -1
View File
@@ -9,6 +9,12 @@
<%- include("menu", {
up: locals.up !== false,
trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") },
items: [{ head: t("locale.label") }, ...choices.map((c) => ({ current: c.current, href: c.href, hreflang: c.tag, label: c.label, ownLocale: true }))],
items: [
{ head: t("locale.label") },
...choices.map((c) => ({ current: c.current, href: c.href, hreflang: c.tag, label: c.label, ownLocale: true })),
// This page can't be re-rendered in another language (its URL answers no GET), so switching
// navigates away — and may leave a one-time secret behind. Say it before the click.
...(locals.leavesPage ? [{ head: t("locale.leavesPage") }] : []),
],
}) %>
<% } -%>