Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples
CI / full-gate (push) Successful in 2m37s
CI / full-gate (push) Successful in 2m37s
This commit is contained in:
+67
-3
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user