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
28 changed files with 176 additions and 58 deletions
Showing only changes of commit be3bc2bdbb - Show all commits
+13 -1
View File
@@ -117,13 +117,25 @@ them. Revisit only if the stated reason stops holding.
whole query string and no href wrapper can reach it. Putting the obligation on each call site was 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. tried first and missed five of eight sites inside one commit — including the admin screens.
`ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links). `ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links).
**A form's `action` counts as a link** — a POST replaces the URL as completely as a GET submit, so
the sign-out, consent and auth-card forms carry it too; without that, picking a language and then
saving anything drops back to `Accept-Language`. The one round-trip that cannot carry it is the
Kratos sign-in POST, whose action is an absolute off-site URL.
Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw, Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw,
so: when a link renders from the core chrome, it is the chrome's job to carry the locale. so: when a link renders from the core chrome, it is the chrome's job to carry the locale.
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`), - **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`),
so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`, so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
`localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data` `localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
so a collision loses the key instead of breaking the shell. so a collision loses the key instead of breaking the shell.
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`.** A catalog there - **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
`dashboard`) and an `onRequest` short-circuit dispatch a plugin's handler, so they build the
context with `contextFor(pluginId)` exactly as a plugin route does — otherwise `ctx.t` is the core
translator and the plugin's own keys render as bare keys on the pages it owns. Found by review
2026-08-03 after all three paths shipped with the host's context.
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`.** The SHIPPED
`en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is checked
rather than trusted (a mounted `en-US` compared only against itself would boot green with the
whole UI rendering keys). A catalog there
for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held
to the same parity check. Adding a language must not require forking the image. to the same parity check. Adding a language must not require forking the image.
- **An unknown translation key renders as itself.** That single rule is what lets a nav label, - **An unknown translation key renders as itself.** That single rule is what lets a nav label,
+4 -2
View File
@@ -1001,8 +1001,10 @@ Three rules worth knowing:
- **The core building blocks carry the locale for you** — every href they render (menu, breadcrumbs, - **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 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. 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 `ctx.localeHref` is for hrefs and form actions your own markup emits (a POST replaces the URL just
null) for your own GET forms. `locale` is reserved: `parseListQuery` never returns it as a filter. as a GET submit does), and `localeParam` (a view local: the tag, or null) for your own GET forms.
`locale` is reserved: `parseListQuery` never returns it as a filter. Responses carry
`Vary: Accept-Language`, so a cache in front of the app keys on the language too.
- **Reuse the core words.** Generic UI verbs live in the core catalog — `common.add/cancel/delete/ - **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 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. to them. Keep your catalog for your domain words, so N plugins don't re-translate "Cancel" N times.
@@ -10,7 +10,7 @@
<% if (locals.error) { -%> <% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%> <% } -%>
<form class="form-card" method="post" action="<%= form.action %>"> <form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>"> <input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %> <%- include("partials/field", form.nameField) %>
<div class="field"> <div class="field">
@@ -21,7 +21,7 @@
<% if (members.rows.length) { -%> <% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody> <div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%> <% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form></td></tr> <tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form></td></tr>
<% }) -%> <% }) -%>
</tbody></table></div> </tbody></table></div>
<% } else { -%> <% } else { -%>
@@ -31,7 +31,7 @@
<section class="form-card" aria-labelledby="add-h"> <section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2> <h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
<% if (add.options.length) { -%> <% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("common.add") %></button></form> <form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("common.add") %></button></form>
<% } else { -%> <% } else { -%>
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p> <p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
<% } -%> <% } -%>
@@ -10,7 +10,7 @@
<% if (locals.error) { -%> <% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%> <% } -%>
<form class="form-card" method="post" action="<%= form.action %>"> <form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>"> <input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %> <%- include("partials/field", form.nameField) %>
<div class="field"> <div class="field">
@@ -23,7 +23,7 @@
<% if (members.rows.length) { -%> <% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: permission.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody> <div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: permission.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%> <% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.permissions.revoke") %></button></form></td></tr> <tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.permissions.revoke") %></button></form></td></tr>
<% }) -%> <% }) -%>
</tbody></table></div> </tbody></table></div>
<% } else { -%> <% } else { -%>
@@ -46,7 +46,7 @@
<section class="form-card" aria-labelledby="add-h"> <section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h"><%= t("admin.permissions.assign") %></h2> <h2 class="card-title" id="add-h"><%= t("admin.permissions.assign") %></h2>
<% if (add.options.length) { -%> <% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.permissions.assignAction") %></button></form> <form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.permissions.assignAction") %></button></form>
<% } else { -%> <% } else { -%>
<p class="cell-muted"><%= t("admin.permissions.allAssigned") %></p> <p class="cell-muted"><%= t("admin.permissions.allAssigned") %></p>
<% } -%> <% } -%>
@@ -10,7 +10,7 @@
<% if (locals.error) { -%> <% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%> <% } -%>
<form class="form-card" method="post" action="<%= form.action %>"> <form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>"> <input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %> <%- include("partials/field", form.nameField) %>
<div class="field"> <div class="field">
@@ -16,7 +16,7 @@
<% if (recovery) { -%> <% if (recovery) { -%>
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%- t("admin.users.recovery.body") %></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div> <div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%- t("admin.users.recovery.body") %></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
<% } -%> <% } -%>
<form class="form-card" method="post" action="<%= form.action %>"> <form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>"> <input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<% form.fields.forEach((field) => { -%> <% form.fields.forEach((field) => { -%>
<%- include("partials/field", field) %> <%- include("partials/field", field) %>
@@ -28,8 +28,8 @@
</form> </form>
<% if (edit) { -%> <% if (edit) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>"> <section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form> <form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form> <form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
<a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a> <a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
</section> </section>
<% } -%> <% } -%>
@@ -9,7 +9,7 @@
<% if (locals.formError) { -%> <% if (locals.formError) { -%>
<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %> <%- include("partials/alert", { text: locals.formError, tone: "neg" }) %>
<% } -%> <% } -%>
<form class="form-card" method="post" action="<%= form.action %>"> <form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>"> <input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<% form.fields.forEach((field) => { -%> <% form.fields.forEach((field) => { -%>
<%- include("partials/field", field) %> <%- include("partials/field", field) %>
+27
View File
@@ -1459,3 +1459,30 @@ test("the error pages speak the visitor's language too", async (t) => {
assert.match(html, /<html lang="sv-SE"/); assert.match(html, /<html lang="sv-SE"/);
assert.match(html, /Sidan hittades inte/); assert.match(html, /Sidan hittades inte/);
}); });
test("a plugin that owns a landing page, or short-circuits a hook, translates from its own catalog", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-i18n-plugin-"));
mkdirSync(join(dir, "demo", "i18n"), { recursive: true });
t.after(() => rmSync(dir, { force: true, recursive: true }));
writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n');
// Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit.
const demo: Plugin = {
apiVersion: "1.0.0",
dashboard: (ctx) => ({ html: ctx.t("demo.hello") }),
home: (ctx) => ({ html: ctx.t("demo.hello") }),
hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) },
id: "demo",
};
const i18n = createI18n(await loadI18n({ pluginIds: ["demo"], pluginsDir: dir }));
const app = createApp({ i18n, jwks: staticJwks([ecJwk]), plugins: [demo], pluginsDir: dir });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const cookie = `${SESSION_COOKIE}=${mintJwt({ email: "a@b", exp: nowSec + 600, permissions: [], sub: "u1" })}`;
assert.equal(await (await fetch(`${url}/`)).text(), "Hello from the plugin");
assert.equal(await (await fetch(`${url}/hooked`)).text(), "Hello from the plugin");
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
});
+24 -14
View File
@@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import ejs from "ejs"; import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts"; import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-routes.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts"; import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts"; import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts"; import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
@@ -132,12 +132,14 @@ export function createApp(options: AppOptions = {}): Server {
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for // (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
// any form it ships). Else the built-in intro page with prominent sign-in / register links // any form it ships). Else the built-in intro page with prominent sign-in / register links
// (`user` picks "go to dashboard" vs sign-in; the shell's Sign-out form needs the CSRF cookie). // (`user` picks "go to dashboard" vs sign-in; the shell's Sign-out form needs the CSRF cookie).
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => { const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
csrf.setCookie(); csrf.setCookie();
if (homePlugin) { if (homePlugin) {
const result = (await homePlugin.home(ctx)) ?? null; // The plugin owns this page, so it runs on its own context — its catalog first, then core.
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result); const pluginCtx = contextFor(homePlugin.id);
await sendResult(ctx.res, result, pluginViewsFor(ctx, homePlugin.id), ctx.localeHref); const result = (await homePlugin.home(pluginCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, pluginCtx, result);
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref);
return null; return null;
} }
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" }; return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
@@ -147,14 +149,15 @@ export function createApp(options: AppOptions = {}): Server {
// in, remembering /dashboard as return_to. A plugin may fully own it via `dashboard` — its // in, remembering /dashboard as return_to. A plugin may fully own it via `dashboard` — its
// handler renders against its own views, same path as a plugin route. Else the built-in // handler renders against its own views, same path as a plugin route. Else the built-in
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts. // mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => { const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 }; if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent. // The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
csrf.setCookie(); csrf.setCookie();
if (dashboardPlugin) { if (dashboardPlugin) {
const result = (await dashboardPlugin.dashboard(ctx)) ?? null; const pluginCtx = contextFor(dashboardPlugin.id); // as serveHome: the owner's own translator
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result); const result = (await dashboardPlugin.dashboard(pluginCtx)) ?? null;
await sendResult(ctx.res, result, pluginViewsFor(ctx, dashboardPlugin.id), ctx.localeHref); if (anyResponseHooks) await runResponseHooks(plugins, pluginCtx, result);
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
return null; return null;
} }
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav, t: ctx.t }) }, view: "index" }; return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav, t: ctx.t }) }, view: "index" };
@@ -183,6 +186,9 @@ export function createApp(options: AppOptions = {}): Server {
// Set before any branch so every response — static/redirect/error included — inherits them // 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). // (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
for (const [name, value] of secHeaderEntries) res.setHeader(name, value); for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
// The same URL renders in different languages depending on Accept-Language, so a cache in
// front of us must key on it — otherwise the first visitor's language is served to everyone.
res.setHeader("vary", "accept-language");
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) { if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
// /public/<id>/… serves a plugin's public/; everything else the core public/. // /public/<id>/… serves a plugin's public/; everything else the core public/.
@@ -261,18 +267,22 @@ export function createApp(options: AppOptions = {}): Server {
t: pluginId === undefined ? t : i18n.translator(locale, pluginId), t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
}); });
// base context (no route params yet); reused for onRequest hooks and the landing routes. // base context (no route params yet); reused for the built-in routes. A plugin-owned render
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
// own catalog is what `ctx.t` reads.
const ctx = buildContext(req, res, { chrome, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); const ctx = buildContext(req, res, { chrome, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
buildContext(req, res, { chrome, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
renderPage = viewsFor(ctx); renderPage = viewsFor(ctx);
// Plugin onRequest hooks run before routing and may short-circuit the request. // Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) { if (anyRequestHooks) {
const short = await runRequestHooks(plugins, ctx); const short = await runRequestHooks(plugins, contextFor);
if (short) { if (short) {
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook // 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. // renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
csrfMint.setCookie(); csrfMint.setCookie();
await sendResult(res, short.result, pluginViewsFor(ctx, short.plugin.id), carryLocale); await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
return; return;
} }
} }
@@ -282,7 +292,7 @@ export function createApp(options: AppOptions = {}): Server {
// CSRF cookie is set so those forms have a valid double-submit token. // CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname); const match = matchRoute(plugins, method, pathname);
if (match) { if (match) {
const routeCtx = buildContext(req, res, { chrome, user, ...i18nFor(match.plugin.id), log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) }); const routeCtx = contextFor(match.plugin.id, match.params);
if (!isAuthorized(match.route, routeCtx.permissions)) { if (!isAuthorized(match.route, routeCtx.permissions)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as // 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. // return_to; a signed-in user who simply lacks the permission gets the 403 page.
@@ -303,7 +313,7 @@ export function createApp(options: AppOptions = {}): Server {
// null means the handler wrote to ctx.res itself. // null means the handler wrote to ctx.res itself.
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname); const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
if (builtin) { if (builtin) {
await sendResult(res, await builtin.handler(ctx, csrfMint), viewsFor(ctx), carryLocale); await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
return; return;
} }
+6 -1
View File
@@ -14,10 +14,15 @@ export interface RequestCsrf {
token: string; token: string;
} }
// A context scoped to a plugin: same request, but `t` reads that plugin's catalog first. The
// landing slots run a plugin's handler, so they must hand it one of these rather than the host's
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
export type PluginContextFactory = (pluginId: string) => RequestContext;
export interface BuiltinRoute { export interface BuiltinRoute {
// Returns a RouteResult, or null when the handler wrote to ctx.res itself // Returns a RouteResult, or null when the handler wrote to ctx.res itself
// (the landing slots dispatch a plugin's own result against that plugin's views). // (the landing slots dispatch a plugin's own result against that plugin's views).
handler: (ctx: RequestContext, csrf: RequestCsrf) => Promise<RouteResult | null> | RouteResult | null; handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
method: "GET" | "POST"; // a GET route also answers HEAD, like plugin routes method: "GET" | "POST"; // a GET route also answers HEAD, like plugin routes
path: string; // exact pathname path: string; // exact pathname
} }
+1 -1
View File
@@ -63,7 +63,7 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari
} }
for (const key of Object.keys(catalog)) { for (const key of Object.keys(catalog)) {
if (!(key in baseline)) problems.push(`unknown key "${key}" — add it to ${baselineLocale} first`); if (!Object.hasOwn(baseline, key)) problems.push(`unknown key "${key}" — add it to ${baselineLocale} first`);
} }
return problems; return problems;
+14 -9
View File
@@ -21,9 +21,10 @@ const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales"); export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
export const MOUNTED_LOCALES_DIR = join(rootDir, "locales"); export const MOUNTED_LOCALES_DIR = join(rootDir, "locales");
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts. Anything else in // A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts — with the script
// the folder is a mistake worth stopping for. // subtag when the language needs one (sr-Latn-RS). Anything else in the folder is a mistake worth
const LOCALE_FILE = /^([a-z]{2,3}-[A-Z]{2})\.ts$/; // stopping for.
const LOCALE_FILE = /^([a-z]{2,3}(?:-[A-Z][a-z]{3})?-[A-Z]{2})\.ts$/;
export interface LoadI18nOptions { export interface LoadI18nOptions {
localesDir?: string; localesDir?: string;
@@ -46,10 +47,15 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
const logger = options.logger ?? console; const logger = options.logger ?? console;
const errors: string[] = []; const errors: string[] = [];
const core = await readSet(localesDir, "core", errors); const shipped = await readSet(localesDir, "core", errors);
// The SHIPPED en-US stays the baseline even when the mount replaces it — otherwise a mounted
// en-US would only ever be compared against itself, and a one-key rewording would boot green with
// the whole UI rendering bare keys.
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); for (const [locale, catalog] of await readSet(mountedDir, "locales", errors)) core.set(locale, catalog);
if (!core.has(DEFAULT_LOCALE)) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`); checkSet(core, "core", baseline, errors);
checkSet(core, "core", errors);
const available = [...core.keys()].sort(); const available = [...core.keys()].sort();
const plugins = new Map<string, Map<string, Catalog>>(); const plugins = new Map<string, Map<string, Catalog>>();
@@ -62,7 +68,7 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
for (const locale of set.keys()) { for (const locale of set.keys()) {
if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`); if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`);
} }
checkSet(set, `plugins/${id}`, errors); checkSet(set, `plugins/${id}`, set.get(DEFAULT_LOCALE), errors);
// Legitimate — the plugin's strings fall back to en-US on that page — but an operator who // Legitimate — the plugin's strings fall back to en-US on that page — but an operator who
// installed a locale should hear about the gap at deploy time, not see English islands later. // installed a locale should hear about the gap at deploy time, not see English islands later.
const gaps = available.filter((locale) => !set.has(locale)); const gaps = available.filter((locale) => !set.has(locale));
@@ -103,8 +109,7 @@ async function readSet(dir: string, label: string, errors: string[]): Promise<Ma
return set; return set;
} }
function checkSet(set: Map<string, Catalog>, label: string, errors: string[]): void { function checkSet(set: Map<string, Catalog>, label: string, baseline: Catalog | undefined, errors: string[]): void {
const baseline = set.get(DEFAULT_LOCALE);
if (baseline === undefined) return; // already reported; nothing to compare against if (baseline === undefined) return; // already reported; nothing to compare against
for (const [locale, catalog] of set) { for (const [locale, catalog] of set) {
for (const problem of checkCatalog({ baseline, baselineLocale: DEFAULT_LOCALE, catalog, locale })) { for (const problem of checkCatalog({ baseline, baselineLocale: DEFAULT_LOCALE, catalog, locale })) {
+4
View File
@@ -59,6 +59,10 @@ test("localeHref carries the locale on host-relative links only", () => {
assert.equal(localeHref("https://example.com/x", "sv-SE"), "https://example.com/x"); // off-site assert.equal(localeHref("https://example.com/x", "sv-SE"), "https://example.com/x"); // off-site
assert.equal(localeHref("//example.com/x", "sv-SE"), "//example.com/x"); // protocol-relative is off-site too assert.equal(localeHref("//example.com/x", "sv-SE"), "//example.com/x"); // protocol-relative is off-site too
assert.equal(localeHref("", "sv-SE"), ""); assert.equal(localeHref("", "sv-SE"), "");
// The building blocks document href as optional (an unlinked page item, a header with no sort
// target) — an absent one must not throw, or the page breaks only for visitors who chose a language.
assert.equal(localeHref(undefined as unknown as string, "sv-SE"), undefined);
assert.equal(localeHref(null as unknown as string, "sv-SE"), null);
}); });
test("textDirection reads the script direction, defaulting to ltr", () => { test("textDirection reads the script direction, defaulting to ltr", () => {
+10 -3
View File
@@ -58,10 +58,17 @@ export function resolveLocale({ acceptLanguage, available, param }: ResolveInput
// Carry `locale` on a host-relative link. Off-site and protocol-relative URLs are left alone — the // Carry `locale` on a host-relative link. Off-site and protocol-relative URLs are left alone — the
// locale is ours to state, not theirs. `locale` null (the visitor never asked for one) ⇒ unchanged. // locale is ours to state, not theirs. `locale` null (the visitor never asked for one) ⇒ unchanged.
export function localeHref(href: string, locale: string | null): string { export function localeHref(href: string, locale: string | null): string {
if (locale === null || href === "" || !href.startsWith("/") || href.startsWith("//")) return href; // An absent href is a shape the building blocks document as optional (a non-linked page item, a
const url = new URL(href, "http://localhost"); // header with no sort target) — it must not throw here, or a page renders for every visitor
// except the ones who chose a language.
if (locale === null || !href || href.startsWith("//")) return href;
// A query-only href ("?" — the filter bar's documented "clear" target) keeps that shape; anything
// else must be host-relative, or it is someone else's URL to state a language for.
const queryOnly = href.startsWith("?");
if (!queryOnly && !href.startsWith("/")) return href;
const url = new URL(href, "http://localhost/");
url.searchParams.set("locale", locale); url.searchParams.set("locale", locale);
return `${url.pathname}${url.search}${url.hash}`; return queryOnly ? `${url.search}${url.hash}` : `${url.pathname}${url.search}${url.hash}`;
} }
// Both are asked for on every render (the <html> tag, the language picker) but depend only on the // Both are asked for on every render (the <html> tag, the language picker) but depend only on the
-1
View File
@@ -45,7 +45,6 @@ const messages = {
"common.edit": "Edit", "common.edit": "Edit",
"common.new": "New", "common.new": "New",
"common.remove": "Remove", "common.remove": "Remove",
"common.save": "Save",
"consent.allow": "Allow", "consent.allow": "Allow",
"consent.deny": "Deny", "consent.deny": "Deny",
-1
View File
@@ -36,7 +36,6 @@ const messages: CoreMessages = {
"common.edit": "Redigera", "common.edit": "Redigera",
"common.new": "Ny", "common.new": "Ny",
"common.remove": "Ta bort", "common.remove": "Ta bort",
"common.save": "Spara",
"consent.allow": "Tillåt", "consent.allow": "Tillåt",
"consent.deny": "Neka", "consent.deny": "Neka",
+4
View File
@@ -24,6 +24,9 @@ const pluralRules = new Map<string, Intl.PluralRules>();
export function createTranslator({ catalogs, locale }: TranslatorOptions): Translate { export function createTranslator({ catalogs, locale }: TranslatorOptions): Translate {
return (key, vars) => { return (key, vars) => {
for (const catalog of catalogs) { for (const catalog of catalogs) {
// Own keys only: `t("toString")` must fall through to the key itself like any other unknown
// one, not pick up Object.prototype.
if (!Object.hasOwn(catalog, key)) continue;
const message = catalog[key]; const message = catalog[key];
if (message === undefined) continue; if (message === undefined) continue;
return interpolate(isPluralMessage(message) ? selectPlural(message, locale, vars?.["count"]) : message, vars); return interpolate(isPluralMessage(message) ? selectPlural(message, locale, vars?.["count"]) : message, vars);
@@ -56,6 +59,7 @@ function rulesFor(locale: string): Intl.PluralRules {
function interpolate(text: string, vars: TranslateVars | undefined): string { function interpolate(text: string, vars: TranslateVars | undefined): string {
if (vars === undefined) return text; if (vars === undefined) return text;
return text.replace(PLACEHOLDER, (whole, name: string) => { return text.replace(PLACEHOLDER, (whole, name: string) => {
if (!Object.hasOwn(vars, name)) return whole;
const value = vars[name]; const value = vars[name];
return value === undefined ? whole : String(value); return value === undefined ? whole : String(value);
}); });
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { ENGLISH } from "./english.ts";
import { localeHref } from "./locale.ts";
import { i18nLocals, type I18nRequest } from "./view-locals.ts";
const request = (overrides: Partial<I18nRequest> = {}): I18nRequest => ({
locale: "sv-SE",
localeHref: (href) => href,
locales: ["en-US", "sv-SE"],
t: ENGLISH,
url: new URL("http://localhost/admin/users?q=ada"),
...overrides,
});
test("localeSwitch offers this same page in every installed locale, marking the current one", () => {
const locals = i18nLocals(request());
assert.deepEqual(locals.localeSwitch.map((c) => c.href), ["/admin/users?q=ada&locale=en-US", "/admin/users?q=ada&locale=sv-SE"]);
assert.deepEqual(locals.localeSwitch.map((c) => c.current), [false, true]);
assert.match(locals.localeSwitch[1]?.label ?? "", /svenska/i); // named in its own language
});
test("localeParam is the tag only when the URL asked — it is what the GET forms carry", () => {
// The probe asks the very function that decides, so the two can't drift apart.
assert.equal(i18nLocals(request()).localeParam, null); // identity localeHref ⇒ nothing was chosen
assert.equal(i18nLocals(request({ localeHref: (href) => localeHref(href, "sv-SE") })).localeParam, "sv-SE");
});
test("dir follows the locale's script", () => {
assert.equal(i18nLocals(request()).dir, "ltr");
assert.equal(i18nLocals(request({ locale: "ar-EG" })).dir, "rtl");
});
+6 -2
View File
@@ -24,18 +24,22 @@ test("runBootHooks runs each onBoot in order, skips plugins without one, and a t
test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => { test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => {
const calls: string[] = []; const calls: string[] = [];
const scoped: string[] = []; // each hook is handed a context built for its own plugin
const contextFor = (pluginId: string) => { scoped.push(pluginId); return ctx; };
const short = await runRequestHooks([ const short = await runRequestHooks([
plugin("a", { onRequest: () => void calls.push("a") }), // returns void → continue plugin("a", { onRequest: () => void calls.push("a") }), // returns void → continue
plugin("b", { onRequest: () => { calls.push("b"); return { html: "stop" }; } }), plugin("b", { onRequest: () => { calls.push("b"); return { html: "stop" }; } }),
plugin("c", { onRequest: () => void calls.push("c") }), // never reached plugin("c", { onRequest: () => void calls.push("c") }), // never reached
], ctx); ], contextFor);
assert.deepEqual(short?.result, { html: "stop" }); assert.deepEqual(short?.result, { html: "stop" });
assert.equal(short?.plugin.id, "b"); // the owning plugin (so a `view` result resolves correctly) assert.equal(short?.plugin.id, "b"); // the owning plugin (so a `view` result resolves correctly)
assert.equal(short?.ctx, ctx); // …and the context it ran on, for rendering its view
assert.deepEqual(calls, ["a", "b"]); assert.deepEqual(calls, ["a", "b"]);
assert.deepEqual(scoped, ["a", "b"]); // a plugin without the hook never builds a context
// No hook short-circuits → null (proceed with normal routing). // No hook short-circuits → null (proceed with normal routing).
assert.equal(await runRequestHooks([plugin("a", { onRequest: () => {} })], ctx), null); assert.equal(await runRequestHooks([plugin("a", { onRequest: () => {} })], contextFor), null);
}); });
test("runResponseHooks runs every onResponse as an observer with the result; a throw fails", async () => { test("runResponseHooks runs every onResponse as an observer with the result; a throw fails", async () => {
+10 -4
View File
@@ -13,11 +13,17 @@ export async function runBootHooks(plugins: Plugin[]): Promise<void> {
// Before route matching. The first hook to return a RouteResult short-circuits the request — its // Before route matching. The first hook to return a RouteResult short-circuits the request — its
// result becomes the response and later hooks + the route handler are skipped. Returns that result // result becomes the response and later hooks + the route handler are skipped. Returns that result
// with its owning plugin (so a `view` result resolves against that plugin's views), or null to proceed. // with its owning plugin (so a `view` result resolves against that plugin's views), or null to
export async function runRequestHooks(plugins: Plugin[], ctx: RequestContext): Promise<{ plugin: Plugin; result: RouteResult } | null> { // proceed. Each hook gets a context scoped to its own plugin, so `ctx.t` reads that plugin's catalog.
export async function runRequestHooks(
plugins: Plugin[],
contextFor: (pluginId: string) => RequestContext,
): Promise<{ ctx: RequestContext; plugin: Plugin; result: RouteResult } | null> {
for (const plugin of plugins) { for (const plugin of plugins) {
const result = await plugin.hooks?.onRequest?.(ctx); if (!plugin.hooks?.onRequest) continue;
if (result != null) return { plugin, result }; const ctx = contextFor(plugin.id);
const result = await plugin.hooks.onRequest(ctx);
if (result != null) return { ctx, plugin, result };
} }
return null; return null;
} }
+1 -1
View File
@@ -41,7 +41,7 @@ export interface ShellModel {
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser { export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
if (!user) { if (!user) {
const guest = t("shell.guest"); const guest = t("shell.guest");
return { email: "", initials: guest.slice(0, 1).toUpperCase(), name: guest }; return { email: "", initials: ([...guest][0] ?? "?").toUpperCase(), name: guest }; // by character: the word is translated
} }
const local = user.email.split("@")[0] || user.email; const local = user.email.split("@")[0] || user.email;
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local }; return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
+2
View File
@@ -5,6 +5,8 @@
handler; this renders until then. Data: model { nav, shell }. handler; this renders until then. Data: model { nav, shell }.
%><% %><%
const nav = include("partials/nav-tree", { nodes: model.nav }); const nav = include("partials/nav-tree", { nodes: model.nav });
// These four messages carry markup (<code> spans), so they render raw — the documented
// markup-carrying case. They interpolate nothing, so there is no untrusted data to escape.
const body = ` const body = `
<div class="form-page"> <div class="form-page">
<section class="form-card"> <section class="form-card">
+2 -2
View File
@@ -25,14 +25,14 @@
<span class="brand-name"><%= brand %></span> <span class="brand-name"><%= brand %></span>
</div> </div>
<%- include("partials/auth-card", { <%- include("partials/auth-card", {
action: "/oauth2/consent", action: localeHref("/oauth2/consent"),
body, body,
method: "post", method: "post",
sub: t("consent.sub", { client: consent.client }), sub: t("consent.sub", { client: consent.client }),
title: t("consent.title", { client: consent.client }), title: t("consent.title", { client: consent.client }),
}) %> }) %>
<% if (consent.account) { %> <% if (consent.account) { %>
<form class="auth-alt" method="post" action="/logout"> <form class="auth-alt" method="post" action="<%= localeHref("/logout") %>">
<input type="hidden" name="<%= csrfField %>" value="<%= csrfToken %>"> <input type="hidden" name="<%= csrfField %>" value="<%= csrfToken %>">
<%= t("consent.notYou") %> <button type="submit"><%= t("shell.signOut") %></button> <%= t("consent.notYou") %> <button type="submit"><%= t("shell.signOut") %></button>
</form> </form>
+1 -1
View File
@@ -17,7 +17,7 @@
const providers = (sso && sso.providers) || []; const providers = (sso && sso.providers) || [];
const alt = locals.alt; const alt = locals.alt;
-%> -%>
<form class="auth-card" method="<%= method %>"<% if (locals.action) { %> action="<%= locals.action %>"<% } %>> <form class="auth-card" method="<%= method %>"<% if (locals.action) { %> action="<%= localeHref(locals.action) %>"<% } %>>
<div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= localeHref(back.href) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div> <div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= localeHref(back.href) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div>
<% if (providers.length) { -%> <% if (providers.length) { -%>
<div class="sso" aria-label="<%= sso.label || t("auth.sso.label") %>"> <div class="sso" aria-label="<%= sso.label || t("auth.sso.label") %>">
+3 -3
View File
@@ -11,14 +11,14 @@
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, hreflang?, current?, danger? } (default: menu-item button) Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, hreflang?, current?, danger? } (default: menu-item button)
· { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } } · { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } }
%><% %><%
const t = locals.trigger || {}; const trigger = locals.trigger || {}; // not `t` — that name is the translator in every view
const sumCls = "class" in t ? t.class : "btn"; const sumCls = "class" in trigger ? trigger.class : "btn";
const items = locals.items || []; const items = locals.items || [];
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : ""); const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
const width = locals.width; const width = locals.width;
-%> -%>
<details class="menu<%= locals.kebab ? " kebab" : "" %>"<%= locals.open ? " open" : "" %>> <details class="menu<%= locals.kebab ? " kebab" : "" %>"<%= locals.open ? " open" : "" %>>
<summary<% if (sumCls) { %> class="<%= sumCls %>"<% } %><% if (t.label) { %> aria-label="<%= t.label %>"<% } %>><% if (t.html != null) { %><%- t.html %><% } else { if (t.icon) { %><svg class="ico ico-sm"><use href="#<%= t.icon %>"/></svg><% } if (t.text) { %><%= t.text %><% } } %></summary> <summary<% if (sumCls) { %> class="<%= sumCls %>"<% } %><% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } } %></summary>
<div class="<%= popCls %>"<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>> <div class="<%= popCls %>"<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
<% items.forEach((it) => { -%> <% items.forEach((it) => { -%>
<% if (it.head != null) { -%> <% if (it.head != null) { -%>
+1 -1
View File
@@ -70,7 +70,7 @@
<div class="menu-head"><%= t("shell.signedInAs", { name: user.name }) %></div> <div class="menu-head"><%= t("shell.signedInAs", { name: user.name }) %></div>
<button class="menu-item" type="button"><svg class="ico"><use href="#i-user" /></svg><%= t("shell.profile") %></button> <button class="menu-item" type="button"><svg class="ico"><use href="#i-user" /></svg><%= t("shell.profile") %></button>
<%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %> <%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %>
<form class="menu-item-form" method="post" action="/logout"> <form class="menu-item-form" method="post" action="<%= localeHref("/logout") %>">
<input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" /> <input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" />
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button> <button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button>
</form> </form>