Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples
CI / full-gate (push) Successful in 2m37s

This commit is contained in:
2026-08-03 22:37:27 +02:00
parent c30cd95ebd
commit 245d1ad5b5
93 changed files with 2480 additions and 464 deletions
+67 -3
View File
@@ -22,6 +22,8 @@ import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
import adminManifest from "../../examples/plugins/admin/plugin.ts";
import { createI18n } from "../i18n/runtime.ts";
import { loadI18n } from "../i18n/load.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
@@ -822,7 +824,7 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google" formnovalidate>.*Sign in with Google<\/span><\/button>/s);
// The flow-level error renders as an alert.
assert.match(html, /class="alert alert-neg"/);
assert.match(html, /The provided credentials are invalid\./);
assert.match(html, /The credentials are invalid\./); // 4000006 → our wording (README → Translating)
});
// Login completion: /auth/complete is where Kratos lands the browser after login.
@@ -859,7 +861,9 @@ const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockK
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
const ADMIN_CSRF = "admin-secret";
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
// Mount the plugin's catalogs the way server.ts does, so its screens render words, not keys.
const i18n = createI18n(await loadI18n({ pluginIds: [adminPlugin.id], pluginsDir: examplesPluginsDir }));
const app = createApp({ csrfSecret: ADMIN_CSRF, i18n, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
@@ -1343,7 +1347,7 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
// client and shows the one-time secret + id.
const formHtml = await (await get("/admin/clients/new")).text();
assert.match(formHtml, /Register client/);
assert.match(formHtml, /can't keep a secret/i); // guidance on the public-vs-confidential choice
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
assert.equal(created.status, 200); // not a redirect — the secret is shown once
const createdHtml = await created.text();
@@ -1395,3 +1399,63 @@ test("routePublic sends a plugin-id segment to its public/ dir, everything else
assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/<id>, no file
assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core
});
// ---- language (i18n) ----
// The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE).
async function localeApp(t: TestContext): Promise<string> {
const app = createApp({ i18n: createI18n(await loadI18n()), jwks: staticJwks([ecJwk]) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
return `http://localhost:${(app.address() as AddressInfo).port}`;
}
test("?locale serves that language and carries the choice onto the links the page renders", async (t) => {
const url = await localeApp(t);
const html = await (await fetch(`${url}/?locale=sv-SE`)).text();
assert.match(html, /<html lang="sv-SE" dir="ltr">/); // the document says what language it is in
assert.match(html, /Logga in/); // the landing page's own words
assert.doesNotMatch(html, /Operational web apps/);
// The chosen locale rides along, so clicking through the app stays in Swedish without a cookie.
assert.match(html, /href="\/login\?locale=sv-SE"/);
// …and the picker offers the other installed locale, pointing at this same page.
assert.match(html, /hreflang="en-US"/);
assert.match(html, /href="\/\?locale=en-US"/);
});
test("Accept-Language decides when the URL doesn't, and a lone language matches its region", async (t) => {
const url = await localeApp(t);
const swedish = await (await fetch(`${url}/`, { headers: { "accept-language": "sv;q=0.9, en;q=0.4" } })).text();
assert.match(swedish, /<html lang="sv-SE"/);
// The visitor never asked for a locale in the URL, so the links stay clean.
assert.match(swedish, /href="\/login"/);
const english = await (await fetch(`${url}/`, { headers: { "accept-language": "de-DE" } })).text();
assert.match(english, /<html lang="en-US"/); // nothing matches ⇒ the baseline
});
test("an uninstalled or malformed ?locale falls back instead of failing", async (t) => {
const url = await localeApp(t);
for (const bad of ["sv-FI", "klingon", "../../etc"]) {
const res = await fetch(`${url}/?locale=${encodeURIComponent(bad)}`);
assert.equal(res.status, 200);
assert.match(await res.text(), /<html lang="en-US"/, `expected en-US for ${bad}`);
}
});
test("a redirect the host emits keeps the visitor's language", async (t) => {
const url = await localeApp(t);
const res = await fetch(`${url}/dashboard?locale=sv-SE`, { redirect: "manual" }); // anonymous ⇒ sign in first
assert.equal(res.status, 303);
const location = res.headers.get("location") ?? "";
assert.match(location, /^\/login\?/);
assert.match(location, /locale=sv-SE/);
});
test("the error pages speak the visitor's language too", async (t) => {
const url = await localeApp(t);
const html = await (await fetch(`${url}/no-such-page?locale=sv-SE`)).text();
assert.match(html, /<html lang="sv-SE"/);
assert.match(html, /Sidan hittades inte/);
});
+53 -16
View File
@@ -11,6 +11,10 @@ import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "../ui/dashboard.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
import { GuardError, loginRedirect } from "../auth/guards.ts";
import { ENGLISH_I18N } from "../i18n/english.ts";
import type { I18n } from "../i18n/runtime.ts";
import { localeHref } from "../i18n/locale.ts";
import { ENGLISH_LOCALS, i18nLocals } from "../i18n/view-locals.ts";
import { runRequestHooks, runResponseHooks } from "../plugin-host/hooks.ts";
import type { HydraAdmin } from "../auth/hydra-admin.ts";
import type { JwksProvider } from "../auth/jwks.ts";
@@ -40,6 +44,9 @@ export interface AppOptions {
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
// Loaded translation catalogs (server.ts passes the discovered ones). Omitted ⇒ the built-in
// en-US catalog only, so an unwired app still renders real English.
i18n?: I18n;
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
@@ -69,6 +76,7 @@ export function createApp(options: AppOptions = {}): Server {
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
const secureCookies = options.secureCookies ?? false;
const hydra = options.hydra;
const i18n = options.i18n ?? ENGLISH_I18N;
const jwks = options.jwks;
const keto = options.keto;
const kratos = options.kratos;
@@ -107,6 +115,12 @@ export function createApp(options: AppOptions = {}): Server {
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch) merged in, so a
// view — core or plugin, at any include depth — calls `t(...)` without its handler passing it.
// A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...i18nLocals(ctx), ...data });
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...i18nLocals(ctx), ...data });
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
res.end(html);
@@ -121,7 +135,7 @@ export function createApp(options: AppOptions = {}): Server {
if (homePlugin) {
const result = (await homePlugin.home(ctx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
await sendResult(ctx.res, result, (view, data) => renderView(homePlugin.id, view, data));
await sendResult(ctx.res, result, pluginViewsFor(ctx, homePlugin.id), ctx.localeHref);
return null;
}
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
@@ -138,10 +152,10 @@ export function createApp(options: AppOptions = {}): Server {
if (dashboardPlugin) {
const result = (await dashboardPlugin.dashboard(ctx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
await sendResult(ctx.res, result, pluginViewsFor(ctx, dashboardPlugin.id), ctx.localeHref);
return null;
}
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav }) }, view: "index" };
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav, t: ctx.t }) }, view: "index" };
};
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
@@ -156,9 +170,13 @@ export function createApp(options: AppOptions = {}): Server {
// outbound fetch (the Ory clients via tracedFetch) and any deep module joins this request's trace
// and correlation with no logger threaded through their signatures.
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
// Error pages can render before this request has a context at all (a throw on the way to one),
// so they start on the built-in English and switch to the visitor's locale once it is resolved.
let renderPage: ViewRenderer = (view, data) => render(view, { ...ENGLISH_LOCALS, ...data });
try {
const method = req.method ?? "GET";
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
const url = new URL(req.url ?? "/", "http://localhost");
const pathname = url.pathname;
// Set before any branch so every response — static/redirect/error included — inherits them
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
@@ -186,6 +204,13 @@ export function createApp(options: AppOptions = {}): Server {
}
}
// Which language this request is served in: ?locale wins, else Accept-Language, else en-US.
// `explicit` (the URL asked) is what makes the choice travel: the chrome, this request's
// redirects and ctx.localeHref then carry ?locale onto the links they emit.
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
const t = i18n.translator(locale);
// Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
@@ -223,10 +248,20 @@ export function createApp(options: AppOptions = {}): Server {
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
// or the public "/" with a standalone home, never composes the menu).
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
// The i18n half of every context: the locale, its translator, and the link carrier. A plugin
// route swaps in the plugin's own translator (its catalog first, then core).
const i18nFor = (pluginId?: string) => ({
locale,
localeHref: carryLocale,
locales: i18n.available,
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
});
// base context (no route params yet); reused for onRequest hooks and the landing routes.
const ctx = buildContext(req, res, { chrome, user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const ctx = buildContext(req, res, { chrome, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
renderPage = viewsFor(ctx);
// Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) {
@@ -235,7 +270,7 @@ export function createApp(options: AppOptions = {}): Server {
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
csrfMint.setCookie();
await sendResult(res, short.result, (view, data) => renderView(short.plugin.id, view, data));
await sendResult(res, short.result, pluginViewsFor(ctx, short.plugin.id), carryLocale);
return;
}
}
@@ -245,19 +280,19 @@ export function createApp(options: AppOptions = {}): Server {
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
const routeCtx = buildContext(req, res, { chrome, user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
const routeCtx = buildContext(req, res, { chrome, user, ...i18nFor(match.plugin.id), log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
if (!isAuthorized(match.route, routeCtx.permissions)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
// return_to; a signed-in user who simply lacks the permission gets the 403 page.
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
sendHtml(res, 403, await renderPage("403", {}));
return;
}
csrfMint.setCookie();
const result = (await match.route.handler(routeCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
await sendResult(res, result, (view, data) => renderView(match.plugin.id, view, data));
await sendResult(res, result, pluginViewsFor(routeCtx, match.plugin.id), carryLocale);
return;
}
@@ -266,7 +301,7 @@ export function createApp(options: AppOptions = {}): Server {
// null means the handler wrote to ctx.res itself.
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
if (builtin) {
await sendResult(res, await builtin.handler(ctx, csrfMint), render);
await sendResult(res, await builtin.handler(ctx, csrfMint), viewsFor(ctx), carryLocale);
return;
}
@@ -276,21 +311,21 @@ export function createApp(options: AppOptions = {}): Server {
res.writeHead(405, { allow: allow.join(", "), "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
return;
}
sendHtml(res, 404, await render("404", { title: "Not found" }));
sendHtml(res, 404, await renderPage("404", {}));
} catch (err) {
// A guard thrown anywhere in handling maps to a response (not a 500): a `location` ⇒ a
// redirect (requireSession → /login), otherwise the status renders the error page.
if (err instanceof GuardError) {
if (res.headersSent) return void res.end();
if (err.location) return void res.writeHead(303, { location: err.location }).end();
return void sendHtml(res, err.status, await render("403", { title: "Forbidden" }));
return void sendHtml(res, err.status, await renderPage("403", {}));
}
reqLog.error("unhandled request error", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
if (res.headersSent) return void res.end(); // a partial body is already on the wire
try {
// Render before writing: if the 500 page itself throws, headers stay unsent
// and we fall back to plain text below instead of a half-written response.
sendHtml(res, 500, await render("500", { title: "Server error" }));
sendHtml(res, 500, await renderPage("500", {}));
} catch (renderErr) {
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal Server Error");
@@ -337,10 +372,12 @@ type ViewRenderer = (view: string, data: Record<string, unknown>) => Promise<str
// Turn a handler's RouteResult into the HTTP response. `null` = the handler took over `ctx.res`
// itself (the void escape hatch). Author `headers` override the content-type default.
async function sendResult(res: ServerResponse, result: RouteResult | null, renderView: ViewRenderer): Promise<void> {
async function sendResult(res: ServerResponse, result: RouteResult | null, renderView: ViewRenderer, carryLocale: (href: string) => string = (href) => href): Promise<void> {
if (result == null || res.writableEnded) return;
if ("redirect" in result) {
res.writeHead(result.status ?? 303, { location: result.redirect }).end();
// A redirect to one of our own pages keeps the visitor's chosen locale (a POST→redirect→GET
// would otherwise drop it); an off-site target is left exactly as the handler wrote it.
res.writeHead(result.status ?? 303, { location: carryLocale(result.redirect) }).end();
return;
}
if ("json" in result) {
+22
View File
@@ -1,6 +1,9 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
import { createLogger, type Log } from "../logger.ts";
// The request context threaded to every route handler (plugin + built-in), built once
@@ -23,6 +26,13 @@ export interface RequestContext {
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
locale: string; // the locale this request is served in, e.g. "sv-SE" — also <html lang>
// Carry the visitor's chosen locale onto a link this page renders. A no-op unless the request
// asked for one with ?locale (there is no locale cookie — the URL is where the choice lives), and
// on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
// wraps the hrefs it builds itself.
localeHref(href: string): string;
locales: string[]; // every installed locale, sorted — for a plugin building its own language picker
log: Log;
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
@@ -32,6 +42,10 @@ export interface RequestContext {
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
system?: SystemCapabilities;
// Translate a key in this request's locale: `ctx.t("shifts.title")`, `ctx.t("greeting", { name })`.
// Returns raw text — escape it like any other value when rendering. An unknown key renders as
// itself, so a plain string is always safe to pass.
t: Translate;
url: URL;
user: User | null; // the signed-in user, or null when anonymous
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
@@ -45,9 +59,13 @@ export interface BuildContextOptions {
// The host's factory is memoised, so the menu composes at most once per request across contexts.
chrome?: () => PageChrome;
user?: User | null;
locale?: string;
localeHref?: (href: string) => string;
locales?: string[];
log?: Log;
params?: Record<string, string>;
system?: SystemCapabilities;
t?: Translate;
verifyCsrf?: (submitted: string | null | undefined) => boolean;
}
@@ -69,6 +87,9 @@ export function buildContext(
return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
user,
locale: options.locale ?? DEFAULT_LOCALE,
localeHref: options.localeHref ?? ((href) => href),
locales: options.locales ?? [DEFAULT_LOCALE],
log: options.log ?? SILENT_LOG,
params: options.params ?? {},
query: url.searchParams,
@@ -76,6 +97,7 @@ export function buildContext(
res,
permissions: user?.permissions ?? [],
...(options.system ? { system: options.system } : {}),
t: options.t ?? ENGLISH,
url,
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
};