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:
@@ -34,9 +34,11 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s
|
||||
assert.equal(view.method, "post");
|
||||
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok123" }]);
|
||||
|
||||
// Visible fields carry label, type, required, autocomplete + a themed input icon.
|
||||
// Visible fields carry label, type, required, autocomplete + a themed input icon. The label is
|
||||
// ours (auth.field.identifier) rather than Kratos' wording — Kratos' generic trait-label id is
|
||||
// ambiguous, so field labels are keyed on the input name.
|
||||
assert.equal(view.fields.length, 2);
|
||||
assert.deepEqual(view.fields[0], { autocomplete: "username", icon: "i-mail", id: "field-identifier", label: "E-Mail", name: "identifier", required: true, type: "email" });
|
||||
assert.deepEqual(view.fields[0], { autocomplete: "username", icon: "i-mail", id: "field-identifier", label: "Email", name: "identifier", required: true, type: "email" });
|
||||
assert.equal(view.fields[1]?.icon, "i-lock");
|
||||
assert.equal(view.fields[1]?.type, "password");
|
||||
|
||||
@@ -53,7 +55,7 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s
|
||||
assert.equal(view.messages.length, 0);
|
||||
});
|
||||
|
||||
test("maps field errors and flow-level messages by tone", () => {
|
||||
test("maps field errors and flow-level messages by tone, translating the ids we cover", () => {
|
||||
const view = buildFlowView(
|
||||
flow(
|
||||
[
|
||||
@@ -65,13 +67,15 @@ test("maps field errors and flow-level messages by tone", () => {
|
||||
"login",
|
||||
);
|
||||
|
||||
// Submitted value is preserved; the node's error rides on the field.
|
||||
// Submitted value is preserved; the node's error rides on the field — with our wording for the
|
||||
// id (4000002), since Kratos writes "Property password is missing." for every required field.
|
||||
assert.equal(view.fields[0]?.value, "taken@example.com");
|
||||
assert.deepEqual(view.fields[0]?.error, { text: "This email is already in use." });
|
||||
assert.deepEqual(view.fields[0]?.error, { text: "This field is required." });
|
||||
|
||||
// Flow messages map error→neg, info→info (success→pos covered by the tone map).
|
||||
// Flow messages map error→neg, info→info (success→pos covered by the tone map). A mapped id
|
||||
// (4000006) is replaced; an id we hold no key for keeps Kratos' own text.
|
||||
assert.deepEqual(view.messages, [
|
||||
{ text: "The provided credentials are invalid.", tone: "neg" },
|
||||
{ text: "The credentials are invalid. Check for typos in your email address or password.", tone: "neg" },
|
||||
{ text: "Check your email.", tone: "info" },
|
||||
]);
|
||||
});
|
||||
|
||||
+44
-14
@@ -4,6 +4,8 @@
|
||||
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
|
||||
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
|
||||
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
|
||||
|
||||
export interface FlowField {
|
||||
@@ -66,14 +68,39 @@ export const AUTH_FLOWS: Record<string, FlowType> = {
|
||||
"/verification": "verification",
|
||||
};
|
||||
|
||||
const CHROME: Record<FlowType, FlowChrome> = {
|
||||
login: { alt: { href: "/registration", label: "Create one", text: "Don't have an account?" }, sub: "Welcome back. Enter your details to continue.", title: "Sign in" },
|
||||
recovery: { alt: { href: "/login", label: "Sign in", text: "Remembered it?" }, back: { href: "/login", label: "Back to sign in" }, sub: "Enter your email and we'll send you a recovery code.", title: "Reset password" },
|
||||
registration: { alt: { href: "/login", label: "Sign in", text: "Already have an account?" }, sub: "Get started — it only takes a minute.", title: "Create account" },
|
||||
settings: { sub: "Update your account details.", title: "Account settings" },
|
||||
verification: { back: { href: "/login", label: "Back to sign in" }, sub: "Enter the code we sent you.", title: "Verify your email" },
|
||||
// Where each flow's card links; its words come from the catalog under `auth.<flow>.*`.
|
||||
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
|
||||
login: { alt: "/registration" },
|
||||
recovery: { alt: "/login", back: true },
|
||||
registration: { alt: "/login" },
|
||||
settings: {},
|
||||
verification: { back: true },
|
||||
};
|
||||
|
||||
function chromeFor(type: FlowType, t: Translate): FlowChrome {
|
||||
const links = LINKS[type];
|
||||
return {
|
||||
...(links.alt ? { alt: { href: links.alt, label: t(`auth.${type}.altLabel`), text: t(`auth.${type}.altText`) } } : {}),
|
||||
...(links.back ? { back: { href: "/login", label: t(`auth.${type}.back`) } } : {}),
|
||||
sub: t(`auth.${type}.sub`),
|
||||
title: t(`auth.${type}.title`),
|
||||
};
|
||||
}
|
||||
|
||||
// A string Kratos authored (a field label, a button, a validation message). Kratos writes English
|
||||
// and tags it with a stable numeric id, so the first key we hold a translation for wins and
|
||||
// anything unmapped keeps Kratos' own words — never a bare key on screen.
|
||||
function kratosText(t: Translate, fallback: string, ...keys: (string | undefined)[]): string {
|
||||
for (const key of keys) {
|
||||
if (key === undefined) continue;
|
||||
const text = t(key);
|
||||
if (text !== key) return text;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const idKey = (id: number | undefined): string | undefined => (id === undefined ? undefined : `kratos.${id}`);
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
||||
|
||||
// Themed input icon by field semantics; undefined ⇒ no icon.
|
||||
@@ -93,7 +120,7 @@ function tone(type: string): FlowMessage["tone"] {
|
||||
|
||||
const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase();
|
||||
|
||||
function toField(node: UiNode, name: string, type: string): FlowField {
|
||||
function toField(node: UiNode, name: string, type: string, t: Translate): FlowField {
|
||||
const value = str(node.attributes["value"]);
|
||||
// The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted
|
||||
// space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in
|
||||
@@ -104,11 +131,13 @@ function toField(node: UiNode, name: string, type: string): FlowField {
|
||||
const errorMsg = node.messages.find((m) => m.type === "error");
|
||||
return {
|
||||
id: "field-" + name.replace(/[^a-z0-9]+/gi, "-"),
|
||||
label: node.meta.label?.text ?? name,
|
||||
// Kratos' generic trait label (id 1070002) is "Email" here and "First name" on a schema with
|
||||
// that trait, so a field falls back to its input name — the one thing that is unambiguous.
|
||||
label: kratosText(t, node.meta.label?.text ?? name, idKey(node.meta.label?.id), `auth.field.${name}`),
|
||||
name,
|
||||
type,
|
||||
...(autocomplete ? { autocomplete } : {}),
|
||||
...(errorMsg ? { error: { text: errorMsg.text } } : {}),
|
||||
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
|
||||
...(icon ? { icon } : {}),
|
||||
...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}),
|
||||
...(node.attributes["required"] === true ? { required: true } : {}),
|
||||
@@ -116,7 +145,7 @@ function toField(node: UiNode, name: string, type: string): FlowField {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFlowView(flow: Flow, type: FlowType): FlowView {
|
||||
export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH): FlowView {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
const fields: FlowField[] = [];
|
||||
const buttons: FlowButton[] = [];
|
||||
@@ -136,9 +165,10 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView {
|
||||
hidden.push({ name, value: str(node.attributes["value"]) ?? "" });
|
||||
} else if (inputType === "submit" || inputType === "button") {
|
||||
const value = str(node.attributes["value"]);
|
||||
buttons.push({ label: node.meta.label?.text ?? "Continue", ...(name ? { name } : {}), ...(value != null ? { value } : {}) });
|
||||
const label = kratosText(t, node.meta.label?.text ?? t("auth.continue"), idKey(node.meta.label?.id));
|
||||
buttons.push({ label, ...(name ? { name } : {}), ...(value != null ? { value } : {}) });
|
||||
} else {
|
||||
fields.push(toField(node, name, inputType));
|
||||
fields.push(toField(node, name, inputType, t));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,10 +177,10 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView {
|
||||
buttons,
|
||||
fields,
|
||||
hidden,
|
||||
messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })),
|
||||
messages: (flow.ui.messages ?? []).map((m) => ({ text: kratosText(t, m.text, idKey(m.id)), tone: tone(m.type) })),
|
||||
method: flow.ui.method || "post",
|
||||
sso,
|
||||
...(type === "login" ? { recoverHref: "/recovery" } : {}),
|
||||
...CHROME[type],
|
||||
...chromeFor(type, t),
|
||||
};
|
||||
}
|
||||
|
||||
+10
-10
@@ -29,7 +29,7 @@ export interface AuthRouteDeps {
|
||||
}
|
||||
|
||||
const TEXT_PLAIN = { "content-type": "text/plain; charset=utf-8" };
|
||||
const FORBIDDEN: RouteResult = { data: { title: "Forbidden" }, status: 403, view: "403" };
|
||||
const FORBIDDEN: RouteResult = { status: 403, view: "403" };
|
||||
|
||||
// Scheme + host for a self-referencing absolute URL (Kratos/Hydra return targets). Host reflects
|
||||
// what the browser used (so it matches the allow-lists); scheme follows SECURE_COOKIES. A spoofed
|
||||
@@ -87,14 +87,14 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
|
||||
// documented, so render an honest 503 rather than the catch-all "error on our end" 500.
|
||||
if (!(err instanceof KratosError) || err.status >= 500) {
|
||||
ctx.log.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname });
|
||||
return { data: { title: "Sign-in unavailable" }, status: 503, view: "503" };
|
||||
return { status: 503, view: "503" };
|
||||
}
|
||||
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
|
||||
}
|
||||
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
|
||||
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
|
||||
csrf.setCookie();
|
||||
return { data: { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }, view: "auth" };
|
||||
return { data: { chrome: ctx.chrome, flow: buildFlowView(flow, flowType, ctx.t) }, view: "auth" };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ function oauthLogin(deps: { hydra: HydraAdmin; kratos: KratosPublic }, secureCoo
|
||||
// A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL) is
|
||||
// user-reachable: tell them to restart rather than 500. A 5xx (Hydra down) rethrows → 500.
|
||||
if (err instanceof HydraError && err.status < 500) {
|
||||
return { headers: TEXT_PLAIN, html: "This sign-in request has expired. Please start again from the application you were signing in to.", status: 400 };
|
||||
return { headers: TEXT_PLAIN, html: ctx.t("oauth.loginExpired"), status: 400 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -122,9 +122,9 @@ function oauthLogin(deps: { hydra: HydraAdmin; kratos: KratosPublic }, secureCoo
|
||||
}
|
||||
|
||||
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500 (as /oauth2/login).
|
||||
function consentError(err: unknown): RouteResult {
|
||||
function consentError(err: unknown, ctx: RequestContext): RouteResult {
|
||||
if (err instanceof HydraError && err.status < 500) {
|
||||
return { headers: TEXT_PLAIN, html: "This authorization request has expired. Please start again from the application you were signing in to.", status: 400 };
|
||||
return { headers: TEXT_PLAIN, html: ctx.t("oauth.consentExpired"), status: 400 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -143,7 +143,7 @@ function consentScreen(deps: { hydra: HydraAdmin; kratos: KratosPublic }, brand:
|
||||
csrf.setCookie();
|
||||
return { data: { brand, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }, view: "oauth-consent" };
|
||||
} catch (err) {
|
||||
return consentError(err);
|
||||
return consentError(err, ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -164,7 +164,7 @@ function consentDecision(deps: { hydra: HydraAdmin; kratos: KratosPublic }): Bui
|
||||
: await rejectConsent(deps, challenge);
|
||||
return { redirect };
|
||||
} catch (err) {
|
||||
return consentError(err);
|
||||
return consentError(err, ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -184,7 +184,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
|
||||
} catch (err) {
|
||||
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500.
|
||||
if (err instanceof HydraError && err.status < 500) {
|
||||
return { headers: TEXT_PLAIN, html: "This logout request has expired. Please start again from the application you were signing out of.", status: 400 };
|
||||
return { headers: TEXT_PLAIN, html: ctx.t("oauth.logoutExpired"), status: 400 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -229,7 +229,7 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
|
||||
// canonical-host redirect prevents the common cause (a lost cross-host CSRF cookie); this is the
|
||||
// honest fallback for any genuine flow error. The id is shown only for support reference.
|
||||
const errorSink = (ctx: RequestContext): RouteResult =>
|
||||
({ data: { id: ctx.url.searchParams.get("id"), title: "Sign-in problem" }, view: "error" });
|
||||
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
|
||||
|
||||
export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }: AuthRouteDeps): BuiltinRoute[] {
|
||||
const routes: BuiltinRoute[] = [];
|
||||
|
||||
+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
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { type Catalog, checkCatalog, isCatalog } from "./catalog.ts";
|
||||
|
||||
const baseline: Catalog = { greeting: "Hello", "shifts.count": { one: "{{count}} shift", other: "{{count}} shifts" } };
|
||||
const parity = (locale: string, catalog: Catalog): string[] =>
|
||||
checkCatalog({ baseline, baselineLocale: "en-US", catalog, locale });
|
||||
|
||||
test("a complete translation reports nothing", () => {
|
||||
assert.deepEqual(parity("sv-SE", { greeting: "Hej", "shifts.count": { one: "{{count}} pass", other: "{{count}} pass" } }), []);
|
||||
});
|
||||
|
||||
test("a missing or unknown key is reported", () => {
|
||||
const missing = parity("sv-SE", { "shifts.count": { one: "a", other: "b" } });
|
||||
assert.equal(missing.length, 1);
|
||||
assert.match(missing[0] ?? "", /missing key "greeting"/);
|
||||
|
||||
const extra = parity("sv-SE", { ...baseline, stray: "x" });
|
||||
assert.equal(extra.length, 1);
|
||||
assert.match(extra[0] ?? "", /unknown key "stray".*en-US/);
|
||||
});
|
||||
|
||||
test("a key must stay the same kind as in the baseline", () => {
|
||||
const flat = parity("sv-SE", { greeting: "Hej", "shifts.count": "pass" });
|
||||
assert.equal(flat.length, 1);
|
||||
assert.match(flat[0] ?? "", /"shifts.count" must be a plural message/);
|
||||
|
||||
const plural = parity("sv-SE", { greeting: { one: "Hej", other: "Hej" }, "shifts.count": { one: "a", other: "b" } });
|
||||
assert.equal(plural.length, 1);
|
||||
assert.match(plural[0] ?? "", /"greeting" must be a string/);
|
||||
});
|
||||
|
||||
test("a plural message must cover exactly its own locale's categories", () => {
|
||||
const short = parity("cs-CZ", { greeting: "Ahoj", "shifts.count": { one: "a", other: "b" } });
|
||||
assert.equal(short.length, 1);
|
||||
assert.match(short[0] ?? "", /"shifts\.count".*cs-CZ.*few, many/);
|
||||
|
||||
const long = parity("sv-SE", { greeting: "Hej", "shifts.count": { few: "x", one: "a", other: "b" } });
|
||||
assert.equal(long.length, 1);
|
||||
assert.match(long[0] ?? "", /"shifts\.count".*few/);
|
||||
});
|
||||
|
||||
test("the baseline is checked against itself, so an incomplete plural fails at home too", () => {
|
||||
assert.deepEqual(checkCatalog({ baseline, baselineLocale: "en-US", catalog: baseline, locale: "en-US" }), []);
|
||||
const bad: Catalog = { greeting: "Hello", "shifts.count": { one: "{{count}} shift" } };
|
||||
assert.match(checkCatalog({ baseline: bad, baselineLocale: "en-US", catalog: bad, locale: "en-US" })[0] ?? "", /other/);
|
||||
});
|
||||
|
||||
test("isCatalog accepts strings and plural objects, rejects anything else", () => {
|
||||
assert.equal(isCatalog({ a: "x", b: { other: "y" } }), true);
|
||||
assert.equal(isCatalog({ a: 1 }), false);
|
||||
assert.equal(isCatalog({ a: { other: 1 } }), false);
|
||||
assert.equal(isCatalog({ a: {} }), false); // an empty plural message says nothing
|
||||
assert.equal(isCatalog({ a: { bogus: "x" } }), false); // not a plural category
|
||||
assert.equal(isCatalog(null), false);
|
||||
assert.equal(isCatalog([]), false);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// What a translation catalog is, and the boot-time parity rules that keep every locale
|
||||
// in step with its en-US baseline. Pure: `load.ts` reads the files, this decides whether they are
|
||||
// sound. A plural message carries exactly the categories its own locale needs (Intl.PluralRules),
|
||||
// so a translator can't ship half a plural and a Czech catalog isn't held to English's two forms.
|
||||
|
||||
export type PluralMessage = Partial<Record<Intl.LDMLPluralRule, string>>;
|
||||
export type Message = PluralMessage | string;
|
||||
export type Catalog = Record<string, Message>;
|
||||
|
||||
// The baseline every catalog set is checked against, and the locale served when a request matches
|
||||
// nothing. A core catalog for it must exist — the host refuses to boot otherwise.
|
||||
export const DEFAULT_LOCALE = "en-US";
|
||||
|
||||
const CATEGORIES: ReadonlySet<string> = new Set(["few", "many", "one", "other", "two", "zero"]);
|
||||
|
||||
export function isPluralMessage(value: Message): value is PluralMessage {
|
||||
return typeof value !== "string";
|
||||
}
|
||||
|
||||
// Shape guard for an imported catalog module — a mounted plugin's file is untyped at runtime.
|
||||
export function isCatalog(value: unknown): value is Catalog {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
return Object.values(value).every((message) => {
|
||||
if (typeof message === "string") return true;
|
||||
if (typeof message !== "object" || message === null || Array.isArray(message)) return false;
|
||||
const forms = Object.entries(message);
|
||||
return forms.length > 0 && forms.every(([category, text]) => CATEGORIES.has(category) && typeof text === "string");
|
||||
});
|
||||
}
|
||||
|
||||
export interface ParityInput {
|
||||
baseline: Catalog;
|
||||
baselineLocale: string;
|
||||
catalog: Catalog;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
// Every problem with `catalog` relative to `baseline`, as ready-to-print lines. Empty ⇒ sound.
|
||||
// Run the baseline against itself too: that is what validates its own plural completeness.
|
||||
export function checkCatalog({ baseline, baselineLocale, catalog, locale }: ParityInput): string[] {
|
||||
const problems: string[] = [];
|
||||
const categories = pluralCategories(locale);
|
||||
|
||||
for (const [key, expected] of Object.entries(baseline)) {
|
||||
const actual = catalog[key];
|
||||
if (actual === undefined) {
|
||||
problems.push(`missing key "${key}"`);
|
||||
continue;
|
||||
}
|
||||
if (isPluralMessage(expected) !== isPluralMessage(actual)) {
|
||||
problems.push(`"${key}" must be a ${isPluralMessage(expected) ? "plural message" : "string"}, like ${baselineLocale}`);
|
||||
continue;
|
||||
}
|
||||
if (!isPluralMessage(actual)) continue;
|
||||
const forms = new Set(Object.keys(actual));
|
||||
const missing = categories.filter((category) => !forms.has(category));
|
||||
const selected = new Set<string>(categories);
|
||||
const unknown = [...forms].filter((category) => !selected.has(category)).sort();
|
||||
if (missing.length) problems.push(`"${key}" is missing the ${locale} plural forms: ${missing.join(", ")}`);
|
||||
if (unknown.length) problems.push(`"${key}" has plural forms ${locale} never selects: ${unknown.join(", ")}`);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(catalog)) {
|
||||
if (!(key in baseline)) problems.push(`unknown key "${key}" — add it to ${baselineLocale} first`);
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
// The plural categories a locale actually selects, sorted; unknown tags fall back to English's.
|
||||
export function pluralCategories(locale: string): Intl.LDMLPluralRule[] {
|
||||
try {
|
||||
return [...new Intl.PluralRules(locale).resolvedOptions().pluralCategories].sort();
|
||||
} catch {
|
||||
return ["one", "other"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// The shipped en-US catalog, ready to use without loading anything from disk. This is what the
|
||||
// host falls back to wherever the loaded catalogs haven't been wired — a context built ad hoc, a
|
||||
// view model built outside a request, an app created without `i18n` — so an unwired path renders
|
||||
// real English rather than bare keys. server.ts replaces it with the discovered catalogs at boot.
|
||||
|
||||
import { DEFAULT_LOCALE } from "./catalog.ts";
|
||||
import enUS from "./locales/en-US.ts";
|
||||
import { createI18n, type I18n } from "./runtime.ts";
|
||||
import { createTranslator, type Translate } from "./translate.ts";
|
||||
|
||||
export const ENGLISH: Translate = createTranslator({ catalogs: [enUS], locale: DEFAULT_LOCALE });
|
||||
|
||||
export const ENGLISH_I18N: I18n = createI18n({
|
||||
available: [DEFAULT_LOCALE],
|
||||
core: new Map([[DEFAULT_LOCALE, enUS]]),
|
||||
plugins: new Map(),
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { loadI18n } from "./load.ts";
|
||||
|
||||
const catalog = (body: string): string => `const messages = ${body};\nexport default messages;\n`;
|
||||
|
||||
// A throwaway host tree: <root>/locales/*.ts and <root>/plugins/<id>/i18n/*.ts.
|
||||
async function fixture(files: Record<string, string>): Promise<{ localesDir: string; pluginsDir: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), "i18n-"));
|
||||
for (const [path, body] of Object.entries(files)) {
|
||||
const file = join(root, path);
|
||||
await mkdir(join(file, ".."), { recursive: true });
|
||||
await writeFile(file, body);
|
||||
}
|
||||
return { localesDir: join(root, "locales"), pluginsDir: join(root, "plugins") };
|
||||
}
|
||||
|
||||
test("the shipped core catalogs load and agree key for key", async () => {
|
||||
const loaded = await loadI18n(); // no args ⇒ the real src/i18n/locales + plugins/
|
||||
assert.ok(loaded.available.includes("en-US"));
|
||||
assert.ok(loaded.available.includes("sv-SE"));
|
||||
assert.deepEqual([...loaded.available].sort(), loaded.available); // sorted, so "sv" resolves deterministically
|
||||
assert.ok(Object.keys(loaded.core.get("en-US") ?? {}).length > 20);
|
||||
});
|
||||
|
||||
test("a plugin's catalogs load under its id and may cover fewer locales than the host", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({
|
||||
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
|
||||
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
|
||||
"plugins/shop/i18n/en-US.ts": catalog(`{ "shop.title": "Shop" }`),
|
||||
});
|
||||
const loaded = await loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir });
|
||||
assert.deepEqual(loaded.available, ["en-US", "sv-SE"]);
|
||||
assert.deepEqual(loaded.plugins.get("shop")?.get("en-US"), { "shop.title": "Shop" });
|
||||
assert.equal(loaded.plugins.get("shop")?.has("sv-SE"), false);
|
||||
});
|
||||
|
||||
test("a locale that disagrees with the en-US baseline stops the boot", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({
|
||||
"locales/en-US.ts": catalog(`{ hello: "Hello", bye: "Bye" }`),
|
||||
"locales/sv-SE.ts": catalog(`{ hello: "Hej", hej: "Hej" }`),
|
||||
});
|
||||
await assert.rejects(loadI18n({ localesDir, pluginsDir }), (err: Error) => {
|
||||
assert.match(err.message, /sv-SE/);
|
||||
assert.match(err.message, /missing key "bye"/);
|
||||
assert.match(err.message, /unknown key "hej"/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("the en-US baseline itself must exist", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({ "locales/sv-SE.ts": catalog(`{ hello: "Hej" }`) });
|
||||
await assert.rejects(loadI18n({ localesDir, pluginsDir }), /en-US\.ts/);
|
||||
});
|
||||
|
||||
test("a file in locales/ that is not a locale is an error, never silently skipped", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({
|
||||
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
|
||||
"locales/swedish.ts": catalog(`{ hello: "Hej" }`),
|
||||
});
|
||||
await assert.rejects(loadI18n({ localesDir, pluginsDir }), /swedish\.ts/);
|
||||
});
|
||||
|
||||
test("a catalog that is not a catalog is an error", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({
|
||||
"locales/en-US.ts": catalog(`{ hello: 42 }`),
|
||||
});
|
||||
await assert.rejects(loadI18n({ localesDir, pluginsDir }), /en-US/);
|
||||
});
|
||||
|
||||
test("a plugin locale the host does not have is an error", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({
|
||||
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
|
||||
"plugins/shop/i18n/en-US.ts": catalog(`{ "shop.title": "Shop" }`),
|
||||
"plugins/shop/i18n/fr-FR.ts": catalog(`{ "shop.title": "Boutique" }`),
|
||||
});
|
||||
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /fr-FR/);
|
||||
});
|
||||
|
||||
test("a plugin translation is checked against the plugin's own en-US", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({
|
||||
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
|
||||
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
|
||||
"plugins/shop/i18n/en-US.ts": catalog(`{ "shop.title": "Shop" }`),
|
||||
"plugins/shop/i18n/sv-SE.ts": catalog(`{ "shop.name": "Butik" }`),
|
||||
});
|
||||
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /shop.*sv-SE|sv-SE.*shop/s);
|
||||
});
|
||||
|
||||
test("a plugin with translations but no en-US baseline is an error", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({
|
||||
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
|
||||
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
|
||||
"plugins/shop/i18n/sv-SE.ts": catalog(`{ "shop.title": "Butik" }`),
|
||||
});
|
||||
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /shop/);
|
||||
});
|
||||
|
||||
test("a plugin without an i18n folder is fine", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({ "locales/en-US.ts": catalog(`{ hello: "Hello" }`) });
|
||||
const loaded = await loadI18n({ localesDir, pluginIds: ["plain"], pluginsDir });
|
||||
assert.equal(loaded.plugins.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then
|
||||
// check every one against its set's en-US baseline. The imperative shell over catalog.ts's pure
|
||||
// rules — the same contract as plugin discovery: one boot-stopping Error listing every problem,
|
||||
// so a half-translated deploy is caught at startup rather than as a stray English word in production.
|
||||
//
|
||||
// Installed locales are whatever the core folder holds; a plugin may translate fewer of them (its
|
||||
// strings then render in en-US on that page) but never one the host does not have.
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { checkCatalog, DEFAULT_LOCALE, isCatalog, type Catalog } from "./catalog.ts";
|
||||
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
|
||||
|
||||
export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
|
||||
|
||||
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts. Anything else in
|
||||
// the folder is a mistake worth stopping for.
|
||||
const LOCALE_FILE = /^([a-z]{2,3}-[A-Z]{2})\.ts$/;
|
||||
|
||||
export interface LoadI18nOptions {
|
||||
localesDir?: string;
|
||||
pluginIds?: string[]; // discovered plugins; their i18n/ folders are loaded under their id
|
||||
pluginsDir?: string;
|
||||
}
|
||||
|
||||
export interface LoadedI18n {
|
||||
available: string[]; // installed locales, sorted — the switcher's list, and "sv" resolution order
|
||||
core: Map<string, Catalog>;
|
||||
plugins: Map<string, Map<string, Catalog>>; // plugin id → locale → catalog
|
||||
}
|
||||
|
||||
export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18n> {
|
||||
const localesDir = options.localesDir ?? LOCALES_DIR;
|
||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||
const errors: string[] = [];
|
||||
|
||||
const core = await readSet(localesDir, "core", errors);
|
||||
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", errors);
|
||||
const available = [...core.keys()].sort();
|
||||
|
||||
const plugins = new Map<string, Map<string, Catalog>>();
|
||||
for (const id of options.pluginIds ?? []) {
|
||||
const dir = join(pluginsDir, id, "i18n");
|
||||
if (!existsSync(dir)) continue;
|
||||
const set = await readSet(dir, `plugins/${id}`, errors);
|
||||
if (set.size === 0) continue;
|
||||
if (!set.has(DEFAULT_LOCALE)) errors.push(`plugins/${id}: no ${DEFAULT_LOCALE}.ts — a plugin's own baseline`);
|
||||
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`);
|
||||
}
|
||||
checkSet(set, `plugins/${id}`, errors);
|
||||
plugins.set(id, set);
|
||||
}
|
||||
|
||||
if (errors.length) throw new Error(`Translation catalogs failed to load:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
|
||||
return { available, core, plugins };
|
||||
}
|
||||
|
||||
// Import every catalog in one folder. A stray file, a failed import or a value that is not a
|
||||
// catalog is collected as an error — never skipped, or the locale would just go quietly missing.
|
||||
async function readSet(dir: string, label: string, errors: string[]): Promise<Map<string, Catalog>> {
|
||||
const set = new Map<string, Catalog>();
|
||||
if (!existsSync(dir)) return set;
|
||||
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (entry.isDirectory() || entry.name.startsWith(".")) continue;
|
||||
const locale = LOCALE_FILE.exec(entry.name)?.[1];
|
||||
if (locale === undefined) {
|
||||
errors.push(`${label}: "${entry.name}" is not a locale catalog — name it <language>-<REGION>.ts (e.g. sv-SE.ts)`);
|
||||
continue;
|
||||
}
|
||||
let mod: { default?: unknown };
|
||||
try {
|
||||
mod = (await import(pathToFileURL(join(dir, entry.name)).href)) as { default?: unknown };
|
||||
} catch (err) {
|
||||
errors.push(`${label}: ${entry.name} failed to import — ${err instanceof Error ? err.message : String(err)}`);
|
||||
continue;
|
||||
}
|
||||
if (!isCatalog(mod.default)) {
|
||||
errors.push(`${label}: ${entry.name} must default-export an object of strings (or plural forms)`);
|
||||
continue;
|
||||
}
|
||||
set.set(locale, mod.default);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function checkSet(set: Map<string, Catalog>, label: string, errors: string[]): void {
|
||||
const baseline = set.get(DEFAULT_LOCALE);
|
||||
if (baseline === undefined) return; // already reported; nothing to compare against
|
||||
for (const [locale, catalog] of set) {
|
||||
for (const problem of checkCatalog({ baseline, baselineLocale: DEFAULT_LOCALE, catalog, locale })) {
|
||||
errors.push(`${label} ${locale}: ${problem}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { localeHref, localeLabel, matchLocale, parseAcceptLanguage, resolveLocale, textDirection } from "./locale.ts";
|
||||
|
||||
const available = ["en-US", "sv-FI", "sv-SE"];
|
||||
|
||||
test("parseAcceptLanguage orders tags by q, dropping wildcards and junk", () => {
|
||||
assert.deepEqual(parseAcceptLanguage("sv-SE,sv;q=0.9,en-US;q=0.8"), ["sv-SE", "sv", "en-US"]);
|
||||
assert.deepEqual(parseAcceptLanguage("en;q=0.2, sv;q=0.9, de"), ["de", "sv", "en"]); // no q ⇒ 1.0
|
||||
assert.deepEqual(parseAcceptLanguage("*, sv;q=0.5"), ["sv"]);
|
||||
assert.deepEqual(parseAcceptLanguage(""), []);
|
||||
assert.deepEqual(parseAcceptLanguage(undefined), []);
|
||||
});
|
||||
|
||||
test("matchLocale takes an exact tag, case-insensitively", () => {
|
||||
assert.equal(matchLocale("sv-SE", available), "sv-SE");
|
||||
assert.equal(matchLocale("SV-se", available), "sv-SE");
|
||||
});
|
||||
|
||||
test("matchLocale never substitutes another region", () => {
|
||||
assert.equal(matchLocale("sv-NO", available), null); // sv-SE exists, but the request asked for Norway
|
||||
assert.equal(matchLocale("de-DE", available), null);
|
||||
});
|
||||
|
||||
test("matchLocale resolves a lone language to the first matching regional catalog", () => {
|
||||
assert.equal(matchLocale("sv", available), "sv-FI"); // alphabetically first of sv-FI / sv-SE
|
||||
assert.equal(matchLocale("sv", ["en-US", "sv-SE"]), "sv-SE");
|
||||
assert.equal(matchLocale("sv", ["sv-SE", "sv-FI"]), "sv-FI"); // input order must not matter
|
||||
assert.equal(matchLocale("en", available), "en-US");
|
||||
});
|
||||
|
||||
test("matchLocale rejects malformed input instead of guessing", () => {
|
||||
for (const bad of ["", "!!", "sv_SE", "e", "../../etc", undefined, null]) {
|
||||
assert.equal(matchLocale(bad, available), null, `expected null for ${JSON.stringify(bad)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveLocale: ?locale wins over Accept-Language", () => {
|
||||
const got = resolveLocale({ acceptLanguage: "en-US", available, param: "sv-SE" });
|
||||
assert.deepEqual(got, { explicit: true, locale: "sv-SE" });
|
||||
});
|
||||
|
||||
test("resolveLocale: an unmatched ?locale falls through to Accept-Language", () => {
|
||||
const got = resolveLocale({ acceptLanguage: "de-DE;q=0.9, sv;q=0.8", available, param: "es-ES" });
|
||||
assert.deepEqual(got, { explicit: false, locale: "sv-FI" });
|
||||
});
|
||||
|
||||
test("resolveLocale: nothing matches ⇒ en-US, and no request carried a locale", () => {
|
||||
assert.deepEqual(resolveLocale({ available, param: null }), { explicit: false, locale: "en-US" });
|
||||
assert.deepEqual(resolveLocale({ acceptLanguage: "de-DE", available, param: "" }), { explicit: false, locale: "en-US" });
|
||||
});
|
||||
|
||||
test("localeHref carries the locale on host-relative links only", () => {
|
||||
assert.equal(localeHref("/admin/users", "sv-SE"), "/admin/users?locale=sv-SE");
|
||||
assert.equal(localeHref("/admin/users?q=a", "sv-SE"), "/admin/users?q=a&locale=sv-SE");
|
||||
assert.equal(localeHref("/admin/users?locale=en-US", "sv-SE"), "/admin/users?locale=sv-SE"); // replaced, never doubled
|
||||
assert.equal(localeHref("/docs#top", "sv-SE"), "/docs?locale=sv-SE#top");
|
||||
assert.equal(localeHref("/admin/users", null), "/admin/users"); // no explicit locale ⇒ untouched
|
||||
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("", "sv-SE"), "");
|
||||
});
|
||||
|
||||
test("textDirection reads the script direction, defaulting to ltr", () => {
|
||||
assert.equal(textDirection("en-US"), "ltr");
|
||||
assert.equal(textDirection("sv-SE"), "ltr");
|
||||
assert.equal(textDirection("ar-EG"), "rtl");
|
||||
assert.equal(textDirection("not a locale"), "ltr");
|
||||
});
|
||||
|
||||
test("localeLabel names a locale in its own language", () => {
|
||||
assert.match(localeLabel("sv-SE"), /svenska/i);
|
||||
assert.equal(localeLabel("not a locale"), "not a locale"); // fail soft: the tag itself
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// Which language a request is served in, and how a chosen one travels.
|
||||
//
|
||||
// Precedence: `?locale=sv-SE` → Accept-Language (by q) → en-US. Matching is exact on a full tag —
|
||||
// asking for sv-FI when only sv-SE is installed lands on en-US rather than a neighbouring region —
|
||||
// but a lone language ("sv", as browsers send) resolves to the first regional catalog for it.
|
||||
// There is no locale cookie: the URL is the only place a choice is stored, so a link is shareable
|
||||
// and a page is what its address says it is. `localeHref` is how the choice survives a click.
|
||||
|
||||
import { DEFAULT_LOCALE } from "./catalog.ts";
|
||||
|
||||
// Accept-Language tags, best first. Wildcards and malformed entries are dropped, not guessed at.
|
||||
export function parseAcceptLanguage(header: string | undefined): string[] {
|
||||
if (!header) return [];
|
||||
return header
|
||||
.split(",")
|
||||
.map((part, index) => {
|
||||
const [tag = "", ...params] = part.trim().split(";");
|
||||
const q = params.map((p) => /^\s*q=([0-9.]+)\s*$/.exec(p)).find((m) => m !== null);
|
||||
return { index, q: q ? Number(q[1]) : 1, tag: tag.trim() };
|
||||
})
|
||||
.filter((entry) => /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(entry.tag) && Number.isFinite(entry.q))
|
||||
.sort((a, b) => b.q - a.q || a.index - b.index)
|
||||
.map((entry) => entry.tag);
|
||||
}
|
||||
|
||||
// The installed locale a request for `requested` should be served in, or null when none fits.
|
||||
export function matchLocale(requested: string | null | undefined, available: string[]): string | null {
|
||||
const canonical = canonicalize(requested);
|
||||
if (canonical === null) return null;
|
||||
const exact = available.find((tag) => tag.toLowerCase() === canonical.toLowerCase());
|
||||
if (exact !== undefined) return exact;
|
||||
if (canonical.includes("-")) return null; // a region was asked for; another region is a different locale
|
||||
const language = `${canonical.toLowerCase()}-`;
|
||||
return [...available].sort().find((tag) => tag.toLowerCase().startsWith(language)) ?? null;
|
||||
}
|
||||
|
||||
export interface ResolveInput {
|
||||
acceptLanguage?: string | undefined;
|
||||
available: string[];
|
||||
param?: string | null | undefined; // the ?locale query value
|
||||
}
|
||||
|
||||
export interface ResolvedLocale {
|
||||
explicit: boolean; // the URL asked for this locale — the host then carries it on the links it renders
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function resolveLocale({ acceptLanguage, available, param }: ResolveInput): ResolvedLocale {
|
||||
const asked = matchLocale(param, available);
|
||||
if (asked !== null) return { explicit: true, locale: asked };
|
||||
for (const tag of parseAcceptLanguage(acceptLanguage)) {
|
||||
const matched = matchLocale(tag, available);
|
||||
if (matched !== null) return { explicit: false, locale: matched };
|
||||
}
|
||||
return { explicit: false, locale: DEFAULT_LOCALE };
|
||||
}
|
||||
|
||||
// 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.
|
||||
export function localeHref(href: string, locale: string | null): string {
|
||||
if (locale === null || href === "" || !href.startsWith("/") || href.startsWith("//")) return href;
|
||||
const url = new URL(href, "http://localhost");
|
||||
url.searchParams.set("locale", locale);
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
// Both are asked for on every render (the <html> tag, the language picker) but depend only on the
|
||||
// tag, so each locale pays the ICU lookup once per process.
|
||||
const directions = new Map<string, "ltr" | "rtl">();
|
||||
const labels = new Map<string, string>();
|
||||
|
||||
interface TextInfoLocale {
|
||||
getTextInfo?: () => { direction?: string };
|
||||
textInfo?: { direction?: string };
|
||||
}
|
||||
|
||||
// The document direction for <html dir>. Derived from the locale's script, so an RTL catalog flips
|
||||
// the document the day it is added.
|
||||
export function textDirection(locale: string): "ltr" | "rtl" {
|
||||
const cached = directions.get(locale);
|
||||
if (cached !== undefined) return cached;
|
||||
const direction = readDirection(locale);
|
||||
directions.set(locale, direction);
|
||||
return direction;
|
||||
}
|
||||
|
||||
function readDirection(locale: string): "ltr" | "rtl" {
|
||||
try {
|
||||
const info = new Intl.Locale(locale) as Intl.Locale & TextInfoLocale;
|
||||
const direction = info.getTextInfo?.().direction ?? info.textInfo?.direction;
|
||||
return direction === "rtl" ? "rtl" : "ltr";
|
||||
} catch {
|
||||
return "ltr";
|
||||
}
|
||||
}
|
||||
|
||||
// A locale named in its own language ("svenska (Sverige)") — what a language picker should show.
|
||||
export function localeLabel(locale: string): string {
|
||||
const cached = labels.get(locale);
|
||||
if (cached !== undefined) return cached;
|
||||
let label: string;
|
||||
try {
|
||||
label = new Intl.DisplayNames([locale], { type: "language" }).of(locale) ?? locale;
|
||||
} catch {
|
||||
label = locale;
|
||||
}
|
||||
labels.set(locale, label);
|
||||
return label;
|
||||
}
|
||||
|
||||
function canonicalize(tag: string | null | undefined): string | null {
|
||||
if (typeof tag !== "string" || tag === "") return null;
|
||||
try {
|
||||
return Intl.getCanonicalLocales(tag)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// The core catalog: every string the host itself renders, and the baseline every other locale
|
||||
// is checked against at boot (its keys and its plural/string kinds are the contract). Add a key
|
||||
// here first, then to each sv-SE.ts et al — a locale that drifts stops the boot.
|
||||
//
|
||||
// Values are raw text; views escape them. A value carrying markup is rendered with <%- %> and must
|
||||
// never interpolate untrusted data (see README → Translating).
|
||||
|
||||
const messages = {
|
||||
"auth.continue": "Continue",
|
||||
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
|
||||
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
|
||||
"auth.field.email": "Email",
|
||||
"auth.field.identifier": "Email",
|
||||
"auth.field.password": "Password",
|
||||
"auth.field.traits.email": "Email",
|
||||
"auth.forgotPassword": "Forgot password?",
|
||||
"auth.login.altLabel": "Create one",
|
||||
"auth.login.altText": "Don't have an account?",
|
||||
"auth.login.sub": "Welcome back. Enter your details to continue.",
|
||||
"auth.login.title": "Sign in",
|
||||
"auth.recovery.altLabel": "Sign in",
|
||||
"auth.recovery.altText": "Remembered it?",
|
||||
"auth.recovery.back": "Back to sign in",
|
||||
"auth.recovery.sub": "Enter your email and we'll send you a recovery code.",
|
||||
"auth.recovery.title": "Reset password",
|
||||
"auth.registration.altLabel": "Sign in",
|
||||
"auth.registration.altText": "Already have an account?",
|
||||
"auth.registration.sub": "Get started — it only takes a minute.",
|
||||
"auth.registration.title": "Create account",
|
||||
"auth.settings.sub": "Update your account details.",
|
||||
"auth.settings.title": "Account settings",
|
||||
"auth.sso.divider": "or",
|
||||
"auth.sso.label": "Single sign-on options",
|
||||
"auth.verification.back": "Back to sign in",
|
||||
"auth.verification.sub": "Enter the code we sent you.",
|
||||
"auth.verification.title": "Verify your email",
|
||||
|
||||
"brand.sub": "Console",
|
||||
|
||||
"consent.allow": "Allow",
|
||||
"consent.deny": "Deny",
|
||||
"consent.notYou": "Not you?",
|
||||
"consent.scope.email": "Your email address",
|
||||
"consent.scope.offline_access": "Stay signed in (offline access)",
|
||||
"consent.scope.openid": "Verify your identity",
|
||||
"consent.scope.profile": "Your basic profile (name)",
|
||||
"consent.signedInAs": "Signed in as",
|
||||
"consent.sub": "{{client}} wants access to your account.",
|
||||
"consent.title": "Authorize {{client}}",
|
||||
|
||||
"dashboard.starter.browse": "Browse the example plugin",
|
||||
"dashboard.starter.intro":
|
||||
"This is the built-in <code>/dashboard</code> — the gated home shown to a signed-in user. It's a placeholder so a fresh clone has something here; it holds no real data.",
|
||||
"dashboard.starter.reference":
|
||||
"See the plugin contract in <code>docs/plugin-contract.md</code> (the landing-pages section) and the bundled <code>plugins/scheduling/</code> reference.",
|
||||
"dashboard.starter.replace":
|
||||
"Replace it from a plugin: export a <code>dashboard</code> handler from your plugin's manifest and it owns this page, rendered against your own views with the native app shell (the same menu you see now) via <code>ctx.chrome</code>.",
|
||||
"dashboard.starter.title": "Starter dashboard",
|
||||
"dashboard.title": "Dashboard",
|
||||
|
||||
"error.403.body": "You don't have permission to view that (403).",
|
||||
"error.403.docTitle": "Forbidden",
|
||||
"error.403.title": "Access denied",
|
||||
"error.404.body": "We couldn't find that page (404).",
|
||||
"error.404.docTitle": "Not found",
|
||||
"error.404.title": "Page not found",
|
||||
"error.500.body": "An unexpected error occurred on our end (500).",
|
||||
"error.500.docTitle": "Server error",
|
||||
"error.500.title": "Something went wrong",
|
||||
"error.503.body": "We can't reach the identity service right now (503). Please try again in a moment.",
|
||||
"error.503.docTitle": "Sign-in unavailable",
|
||||
"error.503.title": "Sign-in is temporarily unavailable",
|
||||
"error.backHome": "Back home",
|
||||
"error.backToSignIn": "Back to sign in",
|
||||
"error.flow.body": "We couldn't complete that sign-in step. It may have expired or been opened twice — please try again.",
|
||||
"error.flow.docTitle": "Sign-in error",
|
||||
"error.flow.title": "Something went wrong",
|
||||
"error.reference": "Reference: {{id}}",
|
||||
"error.tryAgain": "Try again",
|
||||
|
||||
"field.optional": "Optional",
|
||||
|
||||
"filter.applied": "Applied",
|
||||
"filter.appliedFilters": "Applied filters",
|
||||
"filter.apply": "Apply filters",
|
||||
"filter.clearAll": "Clear all",
|
||||
"filter.dateRange": "Date range",
|
||||
"filter.from": "From",
|
||||
"filter.label": "Filter",
|
||||
"filter.remove": "Remove {{label}} filter",
|
||||
"filter.reset": "Reset",
|
||||
"filter.search": "Search",
|
||||
"filter.to": "To",
|
||||
"filter.toSeparator": "to",
|
||||
|
||||
// Kratos writes the auth flow's own text and returns it with a stable numeric id. A key here
|
||||
// replaces that text; anything unmapped renders Kratos' English as-is (README → Translating).
|
||||
// Ids not in this list are deliberate: 1070002 is Kratos' generic identity-trait label — it is
|
||||
// "Email" on the login form and "First name" on a registration form with that trait, so it can
|
||||
// only be translated per field (auth.field.* above), never per id.
|
||||
"kratos.1010022": "Sign in with password",
|
||||
"kratos.1040001": "Sign up",
|
||||
"kratos.1060003":
|
||||
"An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.",
|
||||
"kratos.1070008": "Resend code",
|
||||
"kratos.1070009": "Continue",
|
||||
"kratos.1070010": "Recovery code",
|
||||
"kratos.1070011": "Verification code",
|
||||
"kratos.1080003":
|
||||
"An email containing a verification code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.",
|
||||
"kratos.4000002": "This field is required.",
|
||||
"kratos.4000006": "The credentials are invalid. Check for typos in your email address or password.",
|
||||
"kratos.4000007": "An account with that email address already exists.",
|
||||
"kratos.4060006": "That recovery code is invalid or has already been used. Please try again.",
|
||||
"kratos.4070006": "That verification code is invalid or has already been used. Please try again.",
|
||||
|
||||
"landing.dashboard": "Go to your dashboard",
|
||||
"landing.lead":
|
||||
"{{brand}} is a self-hostable foundation for admin and operational UIs — sign-in, a config-driven menu, and a server-rendered, zero-JS design system. You add the domain-specific screens by dropping in plugin folders.",
|
||||
"landing.register": "Create account",
|
||||
"landing.signIn": "Log in",
|
||||
"landing.title": "Operational web apps, without the boilerplate.",
|
||||
|
||||
"locale.label": "Language",
|
||||
|
||||
"nav.dashboard": "Dashboard",
|
||||
|
||||
"oauth.consentExpired": "This authorization request has expired. Please start again from the application you were signing in to.",
|
||||
"oauth.loginExpired": "This sign-in request has expired. Please start again from the application you were signing in to.",
|
||||
"oauth.logoutExpired": "This logout request has expired. Please start again from the application you were signing out of.",
|
||||
|
||||
"pagination.go": "Go",
|
||||
"pagination.label": "Pagination",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.of": "of",
|
||||
"pagination.previous": "Previous page",
|
||||
"pagination.rows": "Rows",
|
||||
|
||||
"shell.breadcrumb": "Breadcrumb",
|
||||
"shell.closeMenu": "Close menu",
|
||||
"shell.guest": "Guest",
|
||||
"shell.mainNav": "Main navigation",
|
||||
"shell.openMenu": "Open menu",
|
||||
"shell.preferences": "Preferences",
|
||||
"shell.profile": "Profile",
|
||||
"shell.settings": "Settings",
|
||||
"shell.sidebar": "Primary",
|
||||
"shell.signedInAs": "Signed in as {{name}}",
|
||||
"shell.signIn": "Sign in",
|
||||
"shell.signOut": "Sign out",
|
||||
"shell.skipToContent": "Skip to content",
|
||||
"shell.toggleSection": "Toggle {{label}}",
|
||||
|
||||
"table.actions": "Actions",
|
||||
"table.empty": "Nothing here yet.",
|
||||
"table.row": "row",
|
||||
"table.rowActions": "Row actions for {{name}}",
|
||||
"table.select": "Select {{name}}",
|
||||
"table.selectAll": "Select all rows",
|
||||
|
||||
"theme.auto": "Auto",
|
||||
"theme.dark": "Dark",
|
||||
"theme.label": "Color theme",
|
||||
"theme.light": "Light",
|
||||
};
|
||||
|
||||
// The shape every other core locale is written against: `const messages: CoreMessages = { … }` in
|
||||
// sv-SE.ts et al, so a missing or misspelled key is a type error before the boot check ever runs.
|
||||
export type CoreMessages = typeof messages;
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { CoreMessages } from "./en-US.ts";
|
||||
|
||||
const messages: CoreMessages = {
|
||||
"auth.continue": "Fortsätt",
|
||||
"auth.field.email": "E-postadress",
|
||||
"auth.field.identifier": "E-postadress",
|
||||
"auth.field.password": "Lösenord",
|
||||
"auth.field.traits.email": "E-postadress",
|
||||
"auth.forgotPassword": "Glömt lösenordet?",
|
||||
"auth.login.altLabel": "Skapa ett",
|
||||
"auth.login.altText": "Har du inget konto?",
|
||||
"auth.login.sub": "Välkommen tillbaka. Fyll i dina uppgifter för att fortsätta.",
|
||||
"auth.login.title": "Logga in",
|
||||
"auth.recovery.altLabel": "Logga in",
|
||||
"auth.recovery.altText": "Kom du på det?",
|
||||
"auth.recovery.back": "Tillbaka till inloggningen",
|
||||
"auth.recovery.sub": "Ange din e-postadress så skickar vi en återställningskod.",
|
||||
"auth.recovery.title": "Återställ lösenord",
|
||||
"auth.registration.altLabel": "Logga in",
|
||||
"auth.registration.altText": "Har du redan ett konto?",
|
||||
"auth.registration.sub": "Kom igång — det tar bara en minut.",
|
||||
"auth.registration.title": "Skapa konto",
|
||||
"auth.settings.sub": "Uppdatera dina kontouppgifter.",
|
||||
"auth.settings.title": "Kontoinställningar",
|
||||
"auth.sso.divider": "eller",
|
||||
"auth.sso.label": "Alternativ för enkel inloggning",
|
||||
"auth.verification.back": "Tillbaka till inloggningen",
|
||||
"auth.verification.sub": "Ange koden vi skickade till dig.",
|
||||
"auth.verification.title": "Verifiera din e-postadress",
|
||||
|
||||
"brand.sub": "Konsol",
|
||||
|
||||
"consent.allow": "Tillåt",
|
||||
"consent.deny": "Neka",
|
||||
"consent.notYou": "Inte du?",
|
||||
"consent.scope.email": "Din e-postadress",
|
||||
"consent.scope.offline_access": "Håll dig inloggad (offlineåtkomst)",
|
||||
"consent.scope.openid": "Verifiera din identitet",
|
||||
"consent.scope.profile": "Din grundläggande profil (namn)",
|
||||
"consent.signedInAs": "Inloggad som",
|
||||
"consent.sub": "{{client}} vill få åtkomst till ditt konto.",
|
||||
"consent.title": "Godkänn {{client}}",
|
||||
|
||||
"dashboard.starter.browse": "Utforska exempelpluginet",
|
||||
"dashboard.starter.intro":
|
||||
"Detta är den inbyggda <code>/dashboard</code> — den inloggade startsidan. Den är en platshållare så att en färsk klon har något här; den innehåller inga riktiga data.",
|
||||
"dashboard.starter.reference":
|
||||
"Se plugin-kontraktet i <code>docs/plugin-contract.md</code> (avsnittet om startsidorna) och referensen <code>plugins/scheduling/</code>.",
|
||||
"dashboard.starter.replace":
|
||||
"Ersätt den från ett plugin: exportera en <code>dashboard</code>-hanterare från pluginets manifest så äger det den här sidan, renderad mot dina egna vyer med appens eget skal (samma meny du ser nu) via <code>ctx.chrome</code>.",
|
||||
"dashboard.starter.title": "Startpanel",
|
||||
"dashboard.title": "Panel",
|
||||
|
||||
"error.403.body": "Du har inte behörighet att se det här (403).",
|
||||
"error.403.docTitle": "Åtkomst nekad",
|
||||
"error.403.title": "Åtkomst nekad",
|
||||
"error.404.body": "Vi hittade inte sidan (404).",
|
||||
"error.404.docTitle": "Sidan finns inte",
|
||||
"error.404.title": "Sidan hittades inte",
|
||||
"error.500.body": "Ett oväntat fel uppstod hos oss (500).",
|
||||
"error.500.docTitle": "Serverfel",
|
||||
"error.500.title": "Något gick fel",
|
||||
"error.503.body": "Vi når inte identitetstjänsten just nu (503). Försök igen om en liten stund.",
|
||||
"error.503.docTitle": "Inloggning otillgänglig",
|
||||
"error.503.title": "Inloggningen är tillfälligt otillgänglig",
|
||||
"error.backHome": "Tillbaka till startsidan",
|
||||
"error.backToSignIn": "Tillbaka till inloggningen",
|
||||
"error.flow.body": "Vi kunde inte slutföra det inloggningssteget. Det kan ha gått ut eller öppnats två gånger — försök igen.",
|
||||
"error.flow.docTitle": "Inloggningsfel",
|
||||
"error.flow.title": "Något gick fel",
|
||||
"error.reference": "Referens: {{id}}",
|
||||
"error.tryAgain": "Försök igen",
|
||||
|
||||
"field.optional": "Frivilligt",
|
||||
|
||||
"filter.applied": "Aktiva",
|
||||
"filter.appliedFilters": "Aktiva filter",
|
||||
"filter.apply": "Använd filter",
|
||||
"filter.clearAll": "Rensa alla",
|
||||
"filter.dateRange": "Datumintervall",
|
||||
"filter.from": "Från",
|
||||
"filter.label": "Filter",
|
||||
"filter.remove": "Ta bort filtret {{label}}",
|
||||
"filter.reset": "Återställ",
|
||||
"filter.search": "Sök",
|
||||
"filter.to": "Till",
|
||||
"filter.toSeparator": "till",
|
||||
|
||||
"kratos.1010022": "Logga in med lösenord",
|
||||
"kratos.1040001": "Skapa konto",
|
||||
"kratos.1060003":
|
||||
"Ett mejl med en återställningskod har skickats till adressen du angav. Har du inte fått något mejl, kontrollera stavningen och att du använder adressen du registrerade dig med.",
|
||||
"kratos.1070008": "Skicka koden igen",
|
||||
"kratos.1070009": "Fortsätt",
|
||||
"kratos.1070010": "Återställningskod",
|
||||
"kratos.1070011": "Verifieringskod",
|
||||
"kratos.1080003":
|
||||
"Ett mejl med en verifieringskod har skickats till adressen du angav. Har du inte fått något mejl, kontrollera stavningen och att du använder adressen du registrerade dig med.",
|
||||
"kratos.4000002": "Fältet är obligatoriskt.",
|
||||
"kratos.4000006": "Uppgifterna stämmer inte. Kontrollera att e-postadressen och lösenordet är rätt stavade.",
|
||||
"kratos.4000007": "Det finns redan ett konto med den e-postadressen.",
|
||||
"kratos.4060006": "Återställningskoden är ogiltig eller redan använd. Försök igen.",
|
||||
"kratos.4070006": "Verifieringskoden är ogiltig eller redan använd. Försök igen.",
|
||||
|
||||
"landing.dashboard": "Gå till din panel",
|
||||
"landing.lead":
|
||||
"{{brand}} är en självhostad grund för administrativa och operativa gränssnitt — inloggning, en konfigurationsstyrd meny och ett serverrenderat designsystem utan JavaScript. Du lägger till de verksamhetsnära skärmarna genom att släppa in plugin-mappar.",
|
||||
"landing.register": "Skapa konto",
|
||||
"landing.signIn": "Logga in",
|
||||
"landing.title": "Operativa webbappar, utan all pannplåt.",
|
||||
|
||||
"locale.label": "Språk",
|
||||
|
||||
"nav.dashboard": "Panel",
|
||||
|
||||
"oauth.consentExpired": "Den här behörighetsbegäran har gått ut. Börja om från appen du skulle logga in i.",
|
||||
"oauth.loginExpired": "Den här inloggningsbegäran har gått ut. Börja om från appen du skulle logga in i.",
|
||||
"oauth.logoutExpired": "Den här utloggningsbegäran har gått ut. Börja om från appen du skulle logga ut från.",
|
||||
|
||||
"pagination.go": "Visa",
|
||||
"pagination.label": "Sidnavigering",
|
||||
"pagination.next": "Nästa sida",
|
||||
"pagination.of": "av",
|
||||
"pagination.previous": "Föregående sida",
|
||||
"pagination.rows": "Rader",
|
||||
|
||||
"shell.breadcrumb": "Sidsökväg",
|
||||
"shell.closeMenu": "Stäng menyn",
|
||||
"shell.guest": "Gäst",
|
||||
"shell.mainNav": "Huvudmeny",
|
||||
"shell.openMenu": "Öppna menyn",
|
||||
"shell.preferences": "Inställningar",
|
||||
"shell.profile": "Profil",
|
||||
"shell.settings": "Inställningar",
|
||||
"shell.sidebar": "Primär",
|
||||
"shell.signedInAs": "Inloggad som {{name}}",
|
||||
"shell.signIn": "Logga in",
|
||||
"shell.signOut": "Logga ut",
|
||||
"shell.skipToContent": "Hoppa till innehållet",
|
||||
"shell.toggleSection": "Visa eller dölj {{label}}",
|
||||
|
||||
"table.actions": "Åtgärder",
|
||||
"table.empty": "Inget här ännu.",
|
||||
"table.row": "raden",
|
||||
"table.rowActions": "Radåtgärder för {{name}}",
|
||||
"table.select": "Markera {{name}}",
|
||||
"table.selectAll": "Markera alla rader",
|
||||
|
||||
"theme.auto": "Auto",
|
||||
"theme.dark": "Mörkt",
|
||||
"theme.label": "Färgtema",
|
||||
"theme.light": "Ljust",
|
||||
};
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Catalog } from "./catalog.ts";
|
||||
import { createI18n } from "./runtime.ts";
|
||||
|
||||
const core = new Map<string, Catalog>([
|
||||
["en-US", { "shell.signOut": "Sign out", "shop.title": "Core" }],
|
||||
["sv-SE", { "shell.signOut": "Logga ut", "shop.title": "Kärna" }],
|
||||
]);
|
||||
const plugins = new Map<string, Map<string, Catalog>>([
|
||||
["shop", new Map<string, Catalog>([["en-US", { "shop.new": "New order", "shop.title": "Shop" }], ["sv-SE", { "shop.new": "Ny order", "shop.title": "Butik" }]])],
|
||||
["thin", new Map<string, Catalog>([["en-US", { "thin.title": "Thin" }]])],
|
||||
]);
|
||||
const i18n = createI18n({ available: ["en-US", "sv-SE"], core, plugins });
|
||||
|
||||
test("resolve applies the request precedence over the installed locales", () => {
|
||||
assert.deepEqual(i18n.resolve({ param: "sv-SE" }), { explicit: true, locale: "sv-SE" });
|
||||
assert.deepEqual(i18n.resolve({ acceptLanguage: "sv,en;q=0.5" }), { explicit: false, locale: "sv-SE" });
|
||||
assert.deepEqual(i18n.resolve({}), { explicit: false, locale: "en-US" });
|
||||
});
|
||||
|
||||
test("a plugin's own translation wins over the core one", () => {
|
||||
assert.equal(i18n.translator("sv-SE", "shop")("shop.title"), "Butik");
|
||||
assert.equal(i18n.translator("sv-SE")("shop.title"), "Kärna");
|
||||
});
|
||||
|
||||
test("a plugin key untranslated in this locale falls back to the plugin's en-US, not to core", () => {
|
||||
assert.equal(i18n.translator("sv-SE", "thin")("thin.title"), "Thin");
|
||||
assert.equal(i18n.translator("sv-SE", "thin")("shell.signOut"), "Logga ut"); // core still speaks Swedish
|
||||
});
|
||||
|
||||
test("an unknown plugin or locale still translates what it can", () => {
|
||||
assert.equal(i18n.translator("sv-SE", "nope")("shell.signOut"), "Logga ut");
|
||||
assert.equal(i18n.translator("de-DE")("shell.signOut"), "Sign out"); // uninstalled locale ⇒ the baseline
|
||||
});
|
||||
|
||||
test("translators are memoised per locale and plugin", () => {
|
||||
assert.equal(i18n.translator("sv-SE", "shop"), i18n.translator("sv-SE", "shop"));
|
||||
assert.notEqual(i18n.translator("sv-SE", "shop"), i18n.translator("sv-SE"));
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Catalog } from "./catalog.ts";
|
||||
import { createTranslator } from "./translate.ts";
|
||||
|
||||
const core: Catalog = {
|
||||
"greeting": "Hello, {{name}}!",
|
||||
"shell.signOut": "Sign out",
|
||||
"shifts.count": { one: "{{count}} shift", other: "{{count}} shifts" },
|
||||
};
|
||||
const coreSv: Catalog = {
|
||||
"greeting": "Hej, {{name}}!",
|
||||
"shell.signOut": "Logga ut",
|
||||
"shifts.count": { one: "{{count}} pass", other: "{{count}} pass" },
|
||||
};
|
||||
|
||||
test("a key resolves from the first catalog that has it", () => {
|
||||
const t = createTranslator({ catalogs: [coreSv, core], locale: "sv-SE" });
|
||||
assert.equal(t("shell.signOut"), "Logga ut");
|
||||
});
|
||||
|
||||
test("a key missing from the active locale falls back down the chain", () => {
|
||||
const t = createTranslator({ catalogs: [{ "shell.signOut": "Logga ut" }, core], locale: "sv-SE" });
|
||||
assert.equal(t("greeting", { name: "Li" }), "Hello, Li!");
|
||||
});
|
||||
|
||||
test("a plugin catalog wins over the core one", () => {
|
||||
const plugin: Catalog = { "shell.signOut": "Leave" };
|
||||
const t = createTranslator({ catalogs: [plugin, core], locale: "en-US" });
|
||||
assert.equal(t("shell.signOut"), "Leave");
|
||||
});
|
||||
|
||||
test("a key missing everywhere renders as itself", () => {
|
||||
const t = createTranslator({ catalogs: [core], locale: "en-US" });
|
||||
assert.equal(t("nope.at.all"), "nope.at.all");
|
||||
assert.equal(t("Shifts"), "Shifts"); // the nav-label contract: a plain label is its own fallback
|
||||
});
|
||||
|
||||
test("{{vars}} interpolate; an unsupplied one stays visible", () => {
|
||||
const t = createTranslator({ catalogs: [{ both: "{{a}} and {{b}}", n: "n={{n}}" }], locale: "en-US" });
|
||||
assert.equal(t("both", { a: "x", b: "y" }), "x and y");
|
||||
assert.equal(t("both", { a: "x" }), "x and {{b}}");
|
||||
assert.equal(t("n", { n: 3 }), "n=3");
|
||||
});
|
||||
|
||||
test("t returns raw text — escaping is the view's job", () => {
|
||||
const t = createTranslator({ catalogs: [{ hi: "Hi {{name}}" }], locale: "en-US" });
|
||||
assert.equal(t("hi", { name: "<b>ok</b>" }), "Hi <b>ok</b>");
|
||||
});
|
||||
|
||||
test("plural messages select on count via Intl.PluralRules", () => {
|
||||
const t = createTranslator({ catalogs: [core], locale: "en-US" });
|
||||
assert.equal(t("shifts.count", { count: 1 }), "1 shift");
|
||||
assert.equal(t("shifts.count", { count: 0 }), "0 shifts");
|
||||
assert.equal(t("shifts.count", { count: 7 }), "7 shifts");
|
||||
});
|
||||
|
||||
test("plural selection follows the active locale's own categories", () => {
|
||||
const cs: Catalog = { files: { few: "{{count}} soubory", many: "{{count}} souboru", one: "{{count}} soubor", other: "{{count}} souborů" } };
|
||||
const t = createTranslator({ catalogs: [cs], locale: "cs-CZ" });
|
||||
assert.equal(t("files", { count: 1 }), "1 soubor");
|
||||
assert.equal(t("files", { count: 3 }), "3 soubory");
|
||||
assert.equal(t("files", { count: 10 }), "10 souborů");
|
||||
});
|
||||
|
||||
test("a plural message without a count, or without the selected category, falls back to other", () => {
|
||||
const t = createTranslator({ catalogs: [core], locale: "en-US" });
|
||||
assert.equal(t("shifts.count"), "{{count}} shifts");
|
||||
const partial = createTranslator({ catalogs: [{ x: { other: "many" } }], locale: "en-US" });
|
||||
assert.equal(partial("x", { count: 1 }), "many");
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// The translator: a key + vars → the string to render. Pure and synchronous — views call it
|
||||
// as `t("shell.signOut")` and handlers as `ctx.t(...)`.
|
||||
//
|
||||
// Two rules the rest of the app leans on:
|
||||
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US) and,
|
||||
// when nothing has the key, returns the key itself. That is what makes a plain nav label like
|
||||
// "Shifts" its own fallback — a manifest needs no catalog to keep working.
|
||||
// · the result is raw text. Views escape with <%= %> exactly as they do for any other value, so a
|
||||
// translation is never double-escaped, and a message that carries markup is rendered with <%- %>.
|
||||
|
||||
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
|
||||
|
||||
export type TranslateVars = Record<string, number | string>;
|
||||
export type Translate = (key: string, vars?: TranslateVars) => string;
|
||||
|
||||
export interface TranslatorOptions {
|
||||
catalogs: Catalog[]; // lookup order, most specific first
|
||||
locale: string;
|
||||
}
|
||||
|
||||
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
|
||||
const pluralRules = new Map<string, Intl.PluralRules>();
|
||||
|
||||
export function createTranslator({ catalogs, locale }: TranslatorOptions): Translate {
|
||||
return (key, vars) => {
|
||||
for (const catalog of catalogs) {
|
||||
const message = catalog[key];
|
||||
if (message === undefined) continue;
|
||||
return interpolate(isPluralMessage(message) ? selectPlural(message, locale, vars?.["count"]) : message, vars);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
}
|
||||
|
||||
// The form for `count` in this locale, falling back to `other` (and then to any form present, so a
|
||||
// half-filled catalog still renders words rather than a blank).
|
||||
function selectPlural(message: PluralMessage, locale: string, count: number | string | undefined): string {
|
||||
const category = count === undefined ? "other" : rulesFor(locale).select(Number(count));
|
||||
return message[category] ?? message.other ?? Object.values(message)[0] ?? "";
|
||||
}
|
||||
|
||||
function rulesFor(locale: string): Intl.PluralRules {
|
||||
let rules = pluralRules.get(locale);
|
||||
if (!rules) {
|
||||
try {
|
||||
rules = new Intl.PluralRules(locale);
|
||||
} catch {
|
||||
rules = new Intl.PluralRules("en-US");
|
||||
}
|
||||
pluralRules.set(locale, rules);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
// An unsupplied {{var}} is left standing: a visible placeholder beats a silent blank.
|
||||
function interpolate(text: string, vars: TranslateVars | undefined): string {
|
||||
if (vars === undefined) return text;
|
||||
return text.replace(PLACEHOLDER, (whole, name: string) => {
|
||||
const value = vars[name];
|
||||
return value === undefined ? whole : String(value);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// The i18n block every rendered view receives. EJS passes a template's locals down into its
|
||||
// includes, so injecting this at the top level is what lets any partial — core or plugin — call
|
||||
// `t(...)` and read `locale` without its caller threading them through.
|
||||
|
||||
import { DEFAULT_LOCALE } from "./catalog.ts";
|
||||
import { ENGLISH } from "./english.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { localeHref, localeLabel, textDirection } from "./locale.ts";
|
||||
import type { Translate } from "./translate.ts";
|
||||
|
||||
export interface LocaleChoice {
|
||||
current: boolean;
|
||||
href: string; // this same page in that locale
|
||||
label: string; // the locale named in its own language
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface I18nLocals {
|
||||
dir: "ltr" | "rtl";
|
||||
locale: string;
|
||||
localeHref: (href: string) => string;
|
||||
localeSwitch: LocaleChoice[];
|
||||
locales: string[];
|
||||
t: Translate;
|
||||
}
|
||||
|
||||
// For a render with no request behind it — a partial exercised directly, a one-off render: English,
|
||||
// left-to-right, no language picker.
|
||||
export const ENGLISH_LOCALS: I18nLocals = {
|
||||
dir: "ltr",
|
||||
locale: DEFAULT_LOCALE,
|
||||
localeHref: (href) => href,
|
||||
localeSwitch: [],
|
||||
locales: [DEFAULT_LOCALE],
|
||||
t: ENGLISH,
|
||||
};
|
||||
|
||||
export function i18nLocals(ctx: RequestContext): I18nLocals {
|
||||
const here = `${ctx.url.pathname}${ctx.url.search}`;
|
||||
return {
|
||||
dir: textDirection(ctx.locale),
|
||||
locale: ctx.locale,
|
||||
localeHref: (href) => ctx.localeHref(href),
|
||||
localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })),
|
||||
locales: ctx.locales,
|
||||
t: ctx.t,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,12 @@ export type { RequestContext, User } from "../http/context.ts";
|
||||
export type { PageChrome } from "../ui/chrome.ts";
|
||||
export type { NavNode } from "../ui/nav.ts";
|
||||
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
|
||||
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
|
||||
// in a unit test. `PluralMessage` types a message that varies with a count.
|
||||
export { createTranslator } from "../i18n/translate.ts";
|
||||
export type { Translate, TranslateVars } from "../i18n/translate.ts";
|
||||
export type { Catalog, PluralMessage } from "../i18n/catalog.ts";
|
||||
export { parseListQuery } from "../ui/list-query.ts";
|
||||
export { paginate } from "../ui/paginate.ts";
|
||||
export type { PageModel } from "../ui/paginate.ts";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test, type TestContext } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
import { renderPluginView, resolveViewPath } from "./view-resolver.ts";
|
||||
|
||||
const coreViewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||
@@ -30,7 +31,7 @@ test("renderPluginView: a (nested) view includes a core building-block partial a
|
||||
);
|
||||
|
||||
const render = renderPluginView({ cache: false, coreViewsDir, pluginsDir });
|
||||
const html = await render("demo", "sub/page", { who: "Plug" });
|
||||
const html = await render("demo", "sub/page", { ...ENGLISH_LOCALS, who: "Plug" }); // the host merges these into every view's data
|
||||
assert.match(html, /role="radiogroup"/); // core partial, resolved from coreViewsDir
|
||||
assert.match(html, /<span class=local>Plug<\/span>/); // the plugin's own partial, with data
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { discoverPlugins } from "./plugin-host/discovery.ts";
|
||||
import { withTimeout } from "./auth/fetch-timeout.ts";
|
||||
import { runBootHooks } from "./plugin-host/hooks.ts";
|
||||
import { createHydraAdmin } from "./auth/hydra-admin.ts";
|
||||
import { createI18n } from "./i18n/runtime.ts";
|
||||
import { loadI18n } from "./i18n/load.ts";
|
||||
import { createJwksProvider } from "./auth/jwks.ts";
|
||||
import { createKetoClient } from "./auth/keto-client.ts";
|
||||
import { createKratosAdmin } from "./auth/kratos-admin.ts";
|
||||
@@ -38,6 +40,11 @@ const plugins = await discoverPlugins(); // scans plugins/, validates — fails
|
||||
log.info("plugins discovered", { count: plugins.length, ids: plugins.map((p) => p.id).join(", ") });
|
||||
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
|
||||
|
||||
// Translation catalogs: the core locales plus each discovered plugin's — fails loud if a locale
|
||||
// drifts from its en-US baseline, so a half-translated deploy never reaches a visitor.
|
||||
const i18n = createI18n(await loadI18n({ pluginIds: plugins.map((p) => p.id) }));
|
||||
log.info("locales loaded", { locales: i18n.available.join(", ") });
|
||||
|
||||
const server = createApp({
|
||||
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless
|
||||
// APP_URL is set, so the redirect is fully off — and costs nothing — when unconfigured.
|
||||
@@ -47,6 +54,7 @@ const server = createApp({
|
||||
csrfSecret: config.csrfSecret,
|
||||
...(denylist ? { denylist } : {}),
|
||||
hydra,
|
||||
i18n,
|
||||
jwks,
|
||||
keto,
|
||||
kratos,
|
||||
|
||||
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
|
||||
const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "auth-card.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data);
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, { ...ENGLISH_LOCALS, ...data });
|
||||
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||
|
||||
test("auth-card renders head, SSO providers (text logo + icon link), body slot and alt footer", async () => {
|
||||
|
||||
+40
-9
@@ -6,14 +6,17 @@
|
||||
// current-marked for the request path.
|
||||
|
||||
import type { User } from "../http/context.ts";
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
import { type MenuConfig } from "./menu-config.ts";
|
||||
import { composeNav, type NavNode } from "./nav.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { shellUser, type ShellUser } from "./shell-context.ts";
|
||||
|
||||
// The "Dashboard" link to the gated app home (/dashboard). It targets a gated route, so it's shown
|
||||
// only to a signed-in user (an anonymous click would only dead-end at /login).
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
|
||||
// only to a signed-in user (an anonymous click would only dead-end at /login). Its label is a
|
||||
// catalog key — composeNav translates every label, and an unknown one renders as written.
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
|
||||
|
||||
export interface PageChrome {
|
||||
brand: { logo?: string; name: string; sub?: string };
|
||||
@@ -27,39 +30,67 @@ export interface PageChrome {
|
||||
export interface ChromeOptions {
|
||||
csrfToken?: string;
|
||||
currentPath?: string; // request pathname; the matching nav leaf is marked current
|
||||
localeHref?: (href: string) => string; // carries an explicitly chosen locale onto every chrome link
|
||||
menu: MenuConfig;
|
||||
plugins?: Plugin[];
|
||||
t?: Translate; // the core translator: the built-in nodes, the central override's labels, branding
|
||||
translatorFor?: (pluginId: string) => Translate; // a plugin's own translator, for its nav fragment
|
||||
user?: User | null;
|
||||
}
|
||||
|
||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
const t = opts.t ?? ENGLISH;
|
||||
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
||||
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
|
||||
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
|
||||
// section, when present, is just another plugin's nav fragment (examples/plugins/admin).
|
||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
||||
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
||||
// translator before they are merged. composeNav then runs the core one over the result for the
|
||||
// built-in nodes and the central override's labels; already-translated text passes through it.
|
||||
for (const p of opts.plugins ?? []) {
|
||||
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
||||
}
|
||||
|
||||
const permissions = opts.user?.permissions ?? [];
|
||||
const nav = composeNav(fragments, opts.menu.override, permissions);
|
||||
const nav = composeNav(fragments, opts.menu.override, permissions, t);
|
||||
if (opts.currentPath) {
|
||||
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
||||
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
||||
// Marked before the locale rides along, so an href still matches the plain request path.
|
||||
const target = bestHref(nav, opts.currentPath);
|
||||
if (target) markCurrent(nav, target);
|
||||
}
|
||||
|
||||
const b = opts.menu.branding;
|
||||
// The sign-in link keeps the visitor's locale, and brings it back afterwards via return_to.
|
||||
const returnTo = opts.currentPath ? `/login?return_to=${encodeURIComponent(carryLocale(opts.currentPath))}` : "/login";
|
||||
return {
|
||||
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
|
||||
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: t(b.name), ...(b.sub != null ? { sub: t(b.sub) } : {}) },
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
nav,
|
||||
// Anonymous "Sign in" returns to the current page (it's host-relative, our own pathname).
|
||||
signInHref: opts.currentPath ? `/login?return_to=${encodeURIComponent(opts.currentPath)}` : "/login",
|
||||
nav: carryLocaleInto(nav, carryLocale),
|
||||
signInHref: carryLocale(returnTo),
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
user: shellUser(opts.user),
|
||||
user: shellUser(opts.user, t),
|
||||
};
|
||||
}
|
||||
|
||||
function translateNav(nodes: NavNode[], t: Translate): NavNode[] {
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
label: t(node.label),
|
||||
...(node.children ? { children: translateNav(node.children, t) } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function carryLocaleInto(nodes: NavNode[], carryLocale: (href: string) => string): NavNode[] {
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
...(node.href != null ? { href: carryLocale(node.href) } : {}),
|
||||
...(node.children ? { children: carryLocaleInto(node.children, carryLocale) } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// The href of the leaf that owns `path`: an exact match, else the longest href that is a parent of
|
||||
// it (href + "/" prefixes path), so /admin/users/123 resolves to the /admin/users leaf. "/" never
|
||||
// counts as a parent (it would own everything). Returns undefined when nothing matches.
|
||||
|
||||
+7
-3
@@ -5,18 +5,22 @@
|
||||
// once per request by the host, so the dashboard shows the exact same menu as every other page.
|
||||
|
||||
import type { User } from "../http/context.ts";
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
import { buildShellContext } from "./shell-context.ts";
|
||||
|
||||
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; user?: User | null } = {}) {
|
||||
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; t?: Translate; user?: User | null } = {}) {
|
||||
const t = opts.t ?? ENGLISH;
|
||||
return {
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ label: "Dashboard" }],
|
||||
breadcrumbs: [{ label: t("dashboard.title") }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu: opts.menu ?? DEFAULT_MENU,
|
||||
title: "Dashboard",
|
||||
t,
|
||||
title: t("dashboard.title"),
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
|
||||
const dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "data-table.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, data);
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, { ...ENGLISH_LOCALS, ...data });
|
||||
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||
|
||||
const config = {
|
||||
|
||||
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
|
||||
const field = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "field.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data);
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, { ...ENGLISH_LOCALS, ...data });
|
||||
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||
|
||||
test("field renders label, icon input, hint, inline link/optional, and a server-driven error", async () => {
|
||||
|
||||
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
|
||||
const filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "filter-bar.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, data);
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, { ...ENGLISH_LOCALS, ...data });
|
||||
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||
|
||||
const config = {
|
||||
|
||||
@@ -26,7 +26,7 @@ test("loadMenuConfig reads branding + override, merging branding over defaults",
|
||||
const menu = await loadMenuConfig({ file });
|
||||
|
||||
assert.equal(menu.branding.name, "Acme Ops");
|
||||
assert.equal(menu.branding.sub, "Console"); // default kept (only `name`/`theme` overridden)
|
||||
assert.equal(menu.branding.sub, "brand.sub"); // default kept (only `name`/`theme` overridden); chrome translates it
|
||||
assert.equal(menu.branding.theme, "dark");
|
||||
assert.deepEqual(menu.override.hide, ["teams"]);
|
||||
assert.deepEqual(menu.override.rename, { people: "Staff" });
|
||||
|
||||
@@ -29,7 +29,9 @@ export interface MenuConfigInput {
|
||||
override?: NavOverride;
|
||||
}
|
||||
|
||||
export const DEFAULT_BRANDING: Branding = { name: "Plainpages", sub: "Console" };
|
||||
// The shipped default. `sub` is a catalog key so a clean clone reads in the visitor's language;
|
||||
// an operator's own text in config/menu.ts renders as written (chrome runs both through t()).
|
||||
export const DEFAULT_BRANDING: Branding = { name: "Plainpages", sub: "brand.sub" };
|
||||
export const DEFAULT_MENU: MenuConfig = { branding: DEFAULT_BRANDING, override: {} };
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
|
||||
const navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "nav-tree.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data);
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, { ...ENGLISH_LOCALS, ...data });
|
||||
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||
|
||||
const nodes = [
|
||||
|
||||
+9
-5
@@ -6,6 +6,8 @@
|
||||
// the override (+ branding); this helper only transforms data, so its result is per-deployment
|
||||
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
|
||||
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
|
||||
export interface NavNode {
|
||||
id?: string; // stable key for override targeting; stripped from the rendered tree
|
||||
children?: NavNode[];
|
||||
@@ -40,13 +42,14 @@ export function composeNav(
|
||||
fragments: NavNode[][] = [],
|
||||
override: NavOverride = {},
|
||||
permissions: string[] = [],
|
||||
t: Translate = (key) => key,
|
||||
): NavNode[] {
|
||||
let nodes: NavNode[] = fragments.flat();
|
||||
if (override.rename) nodes = renameTree(nodes, override.rename);
|
||||
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
||||
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
||||
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
||||
return filterByRoles(nodes, new Set(permissions)).map(toRenderNode);
|
||||
return filterByRoles(nodes, new Set(permissions)).map((node) => toRenderNode(node, t));
|
||||
}
|
||||
|
||||
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
||||
@@ -116,14 +119,15 @@ function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
|
||||
}
|
||||
|
||||
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
|
||||
// what nav-tree.ejs reads.
|
||||
function toRenderNode(n: NavNode): NavNode {
|
||||
const out: NavNode = { label: n.label };
|
||||
// what nav-tree.ejs reads. Labels (a manifest's, or the central override's rename) pass through
|
||||
// `t` on the way out: a label that names a catalog key is translated, any other renders as written.
|
||||
function toRenderNode(n: NavNode, t: Translate): NavNode {
|
||||
const out: NavNode = { label: t(n.label) };
|
||||
if (n.icon != null) out.icon = n.icon;
|
||||
if (n.href != null) out.href = n.href;
|
||||
if (n.count != null) out.count = n.count;
|
||||
if (n.current != null) out.current = n.current;
|
||||
if (n.open != null) out.open = n.open;
|
||||
if (n.children && n.children.length) out.children = n.children.map(toRenderNode);
|
||||
if (n.children && n.children.length) out.children = n.children.map((child) => toRenderNode(child, t));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
|
||||
const pagination = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "pagination.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, data);
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, { ...ENGLISH_LOCALS, ...data });
|
||||
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||
|
||||
const config = {
|
||||
|
||||
+11
-4
@@ -6,6 +6,8 @@
|
||||
// the local part; anonymous ⇒ "Guest".
|
||||
|
||||
import type { User } from "../http/context.ts";
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
import { type MenuConfig } from "./menu-config.ts";
|
||||
|
||||
export interface ShellUser {
|
||||
@@ -24,8 +26,11 @@ export interface ShellModel {
|
||||
user: ShellUser;
|
||||
}
|
||||
|
||||
export function shellUser(user: User | null | undefined): ShellUser {
|
||||
if (!user) return { email: "", initials: "G", name: "Guest" };
|
||||
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
|
||||
if (!user) {
|
||||
const guest = t("shell.guest");
|
||||
return { email: "", initials: guest.slice(0, 1).toUpperCase(), name: guest };
|
||||
}
|
||||
const local = user.email.split("@")[0] || user.email;
|
||||
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
|
||||
}
|
||||
@@ -35,17 +40,19 @@ export function buildShellContext(opts: {
|
||||
csrfToken?: string;
|
||||
menu: MenuConfig;
|
||||
signInHref?: string;
|
||||
t?: Translate;
|
||||
title: string;
|
||||
user?: User | null;
|
||||
}): ShellModel {
|
||||
const b = opts.menu.branding;
|
||||
const t = opts.t ?? ENGLISH;
|
||||
return {
|
||||
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
|
||||
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: t(b.name), ...(b.sub != null ? { sub: t(b.sub) } : {}) },
|
||||
...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}),
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
title: opts.title,
|
||||
user: shellUser(opts.user),
|
||||
user: shellUser(opts.user, t),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
|
||||
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, data);
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, { ...ENGLISH_LOCALS, ...data });
|
||||
|
||||
test("app shell renders sidebar, topbar and the content slot", async () => {
|
||||
const html = await render({
|
||||
|
||||
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
|
||||
const themeSwitch = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "theme-switch.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, data);
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, { ...ENGLISH_LOCALS, ...data });
|
||||
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||
|
||||
test("theme switch renders the Light/Auto/Dark radiogroup with CSS-coupled ids", async () => {
|
||||
|
||||
Reference in New Issue
Block a user