Architecture review fixes: partials carry the locale, mountable locales/, shared core words
CI / full-gate (push) Successful in 2m36s

This commit is contained in:
2026-08-03 23:12:18 +02:00
parent 245d1ad5b5
commit 6440c543e5
48 changed files with 337 additions and 118 deletions
+8 -6
View File
@@ -115,11 +115,13 @@ 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 });
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch/localeParam) 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.
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
// happens to use one loses that key rather than breaking the shell that renders around it.
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(ctx) });
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(ctx) });
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
@@ -172,7 +174,7 @@ export function createApp(options: AppOptions = {}): Server {
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 });
let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS });
try {
const method = req.method ?? "GET";
const url = new URL(req.url ?? "/", "http://localhost");
+8 -5
View File
@@ -23,16 +23,19 @@ export interface RequestContext {
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
chrome: PageChrome;
// 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>
// The locale this request is served in, e.g. "sv-SE" — also what <html lang> says.
locale: string;
// 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
// Every installed locale, sorted. With `localeLabel` (from #plugin-api) it is what a plugin needs
// to build its own language picker; the host's own picker is already in the shell.
locales: string[];
// 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.
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
+15 -5
View File
@@ -11,7 +11,7 @@ test("a complete translation reports nothing", () => {
});
test("a missing or unknown key is reported", () => {
const missing = parity("sv-SE", { "shifts.count": { one: "a", other: "b" } });
const missing = parity("sv-SE", { "shifts.count": { one: "{{count}} pass", other: "{{count}} pass" } });
assert.equal(missing.length, 1);
assert.match(missing[0] ?? "", /missing key "greeting"/);
@@ -21,21 +21,21 @@ test("a missing or unknown key is reported", () => {
});
test("a key must stay the same kind as in the baseline", () => {
const flat = parity("sv-SE", { greeting: "Hej", "shifts.count": "pass" });
const flat = parity("sv-SE", { greeting: "Hej", "shifts.count": "{{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" } });
const plural = parity("sv-SE", { greeting: { one: "Hej", other: "Hej" }, "shifts.count": { one: "{{count}} pass", other: "{{count}} pass" } });
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" } });
const short = parity("cs-CZ", { greeting: "Ahoj", "shifts.count": { one: "{{count}} směna", other: "{{count}} směn" } });
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" } });
const long = parity("sv-SE", { greeting: "Hej", "shifts.count": { few: "{{count}} pass", one: "{{count}} pass", other: "{{count}} pass" } });
assert.equal(long.length, 1);
assert.match(long[0] ?? "", /"shifts\.count".*few/);
});
@@ -55,3 +55,13 @@ test("isCatalog accepts strings and plural objects, rejects anything else", () =
assert.equal(isCatalog(null), false);
assert.equal(isCatalog([]), false);
});
test("a translation must interpolate exactly what the baseline does", () => {
const withVars: Catalog = { hi: "Hi {{name}}, you have {{n}} left" };
const check = (catalog: Catalog): string[] => checkCatalog({ baseline: withVars, baselineLocale: "en-US", catalog, locale: "sv-SE" });
assert.deepEqual(check({ hi: "Hej {{name}}, du har {{n}} kvar" }), []);
assert.match(check({ hi: "Hej, du har {{n}} kvar" })[0] ?? "", /"hi" never uses \{\{name\}\}/); // dropped ⇒ a blank on screen
assert.match(check({ hi: "Hej {{namn}}, du har {{n}} kvar" })[0] ?? "", /never uses \{\{name\}\}/); // misspelled ⇒ both problems
assert.match(check({ hi: "Hej {{name}} {{n}} {{extra}}" })[0] ?? "", /uses \{\{extra\}\}/); // never supplied ⇒ renders raw
});
+24
View File
@@ -12,6 +12,7 @@ export type Catalog = Record<string, Message>;
export const DEFAULT_LOCALE = "en-US";
const CATEGORIES: ReadonlySet<string> = new Set(["few", "many", "one", "other", "two", "zero"]);
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
export function isPluralMessage(value: Message): value is PluralMessage {
return typeof value !== "string";
@@ -51,6 +52,7 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari
problems.push(`"${key}" must be a ${isPluralMessage(expected) ? "plural message" : "string"}, like ${baselineLocale}`);
continue;
}
for (const problem of placeholderProblems(key, expected, actual, baselineLocale)) problems.push(problem);
if (!isPluralMessage(actual)) continue;
const forms = new Set(Object.keys(actual));
const missing = categories.filter((category) => !forms.has(category));
@@ -67,6 +69,28 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari
return problems;
}
// A translation must interpolate exactly what the baseline does: a dropped {{name}} renders
// "Signed in as ", a misspelled one renders the placeholder itself — the half-translated class this
// check exists to stop, and neither is visible from the key set alone.
function placeholderProblems(key: string, expected: Message, actual: Message, baselineLocale: string): string[] {
const wanted = placeholders(expected);
const got = placeholders(actual);
const missing = [...wanted].filter((name) => !got.has(name));
const unknown = [...got].filter((name) => !wanted.has(name));
return [
...(missing.length ? [`"${key}" never uses ${missing.map((n) => `{{${n}}}`).join(", ")}, which ${baselineLocale} does`] : []),
...(unknown.length ? [`"${key}" uses ${unknown.map((n) => `{{${n}}}`).join(", ")}, which ${baselineLocale} does not supply`] : []),
];
}
function placeholders(message: Message): Set<string> {
const names = new Set<string>();
for (const text of typeof message === "string" ? [message] : Object.values(message)) {
for (const match of (text ?? "").matchAll(PLACEHOLDER)) names.add(match[1] as string);
}
return names;
}
// The plural categories a locale actually selects, sorted; unknown tags fall back to English's.
export function pluralCategories(locale: string): Intl.LDMLPluralRule[] {
try {
+8 -1
View File
@@ -3,13 +3,20 @@
// 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 { type Catalog, 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 });
// A plugin's own English: its catalog first, the host's behind it — the same chain the host builds
// per request, minus the locale. A plugin uses it as the default for a view model built outside a
// request (its unit tests), so the generic words it reuses from core still read as words.
export function englishTranslator(catalog: Catalog): Translate {
return createTranslator({ catalogs: [catalog, enUS], locale: DEFAULT_LOCALE });
}
export const ENGLISH_I18N: I18n = createI18n({
available: [DEFAULT_LOCALE],
core: new Map([[DEFAULT_LOCALE, enUS]]),
+20
View File
@@ -99,6 +99,26 @@ test("a plugin with translations but no en-US baseline is an error", async () =>
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /shop/);
});
test("a mounted locales/ adds a language, and replaces a shipped one wholesale", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
"mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`),
"mounted/sv-SE.ts": catalog(`{ hello: "Tjena" }`),
});
const loaded = await loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginsDir });
assert.deepEqual(loaded.available, ["en-US", "nb-NO", "sv-SE"]);
assert.deepEqual(loaded.core.get("sv-SE"), { hello: "Tjena" }); // the operator's file wins outright
});
test("a mounted catalog is held to the same baseline as a shipped one", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello", bye: "Bye" }`),
"mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`), // no `bye` ⇒ half the app would be English
});
await assert.rejects(loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginsDir }), /nb-NO.*missing key "bye"/s);
});
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 });
+16
View File
@@ -12,7 +12,14 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { checkCatalog, DEFAULT_LOCALE, isCatalog, type Catalog } from "./catalog.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
// The shipped catalogs, and the drop-in mount root an operator adds their own to — a folder there
// is a whole locale: a new tag adds a language, an existing one replaces the shipped catalog for it
// (and is held to the same parity check, so a partial replacement fails the boot rather than
// leaving half the app in English). Mirrors plugins/ and config/; ships empty.
export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
export const MOUNTED_LOCALES_DIR = join(rootDir, "locales");
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts. Anything else in
// the folder is a mistake worth stopping for.
@@ -20,6 +27,8 @@ const LOCALE_FILE = /^([a-z]{2,3}-[A-Z]{2})\.ts$/;
export interface LoadI18nOptions {
localesDir?: string;
logger?: Pick<Console, "warn">; // warn-level diagnostics (a plugin missing an installed locale); defaults to console
mountedLocalesDir?: string;
pluginIds?: string[]; // discovered plugins; their i18n/ folders are loaded under their id
pluginsDir?: string;
}
@@ -32,10 +41,13 @@ export interface LoadedI18n {
export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18n> {
const localesDir = options.localesDir ?? LOCALES_DIR;
const mountedDir = options.mountedLocalesDir ?? MOUNTED_LOCALES_DIR;
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
const logger = options.logger ?? console;
const errors: string[] = [];
const core = await readSet(localesDir, "core", errors);
for (const [locale, catalog] of await readSet(mountedDir, "locales", errors)) core.set(locale, catalog);
if (!core.has(DEFAULT_LOCALE)) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
checkSet(core, "core", errors);
const available = [...core.keys()].sort();
@@ -51,6 +63,10 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`);
}
checkSet(set, `plugins/${id}`, errors);
// Legitimate — the plugin's strings fall back to en-US on that page — but an operator who
// installed a locale should hear about the gap at deploy time, not see English islands later.
const gaps = available.filter((locale) => !set.has(locale));
if (gaps.length) logger.warn(`[i18n] plugins/${id}: no ${gaps.join(", ")} — those strings render in ${DEFAULT_LOCALE}`);
plugins.set(id, set);
}
+10
View File
@@ -37,6 +37,16 @@ const messages = {
"brand.sub": "Console",
// Generic UI verbs every screen needs. A plugin reuses these (the lookup falls through to core)
// and keeps its own catalog for its domain words — see README → Languages.
"common.add": "Add",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.edit": "Edit",
"common.new": "New",
"common.remove": "Remove",
"common.save": "Save",
"consent.allow": "Allow",
"consent.deny": "Deny",
"consent.notYou": "Not you?",
+8
View File
@@ -30,6 +30,14 @@ const messages: CoreMessages = {
"brand.sub": "Konsol",
"common.add": "Lägg till",
"common.cancel": "Avbryt",
"common.delete": "Ta bort",
"common.edit": "Redigera",
"common.new": "Ny",
"common.remove": "Ta bort",
"common.save": "Spara",
"consent.allow": "Tillåt",
"consent.deny": "Neka",
"consent.notYou": "Inte du?",
Binary file not shown.
+19 -2
View File
@@ -4,7 +4,6 @@
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";
@@ -19,28 +18,46 @@ export interface I18nLocals {
dir: "ltr" | "rtl";
locale: string;
localeHref: (href: string) => string;
// The locale to carry as a hidden field, or null when the visitor never asked for one. A GET form
// replaces the whole query string, so a link-carrying wrapper can't reach it — the form must.
localeParam: string | null;
localeSwitch: LocaleChoice[];
locales: string[];
t: Translate;
}
// Just the request fields a render needs, so this module stays a leaf of src/i18n/ rather than
// depending on the HTTP layer that calls it.
export interface I18nRequest {
locale: string;
localeHref: (href: string) => string;
locales: string[];
t: Translate;
url: URL;
}
// 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,
localeParam: null,
localeSwitch: [],
locales: [DEFAULT_LOCALE],
t: ENGLISH,
};
export function i18nLocals(ctx: RequestContext): I18nLocals {
export function i18nLocals(ctx: I18nRequest): I18nLocals {
const here = `${ctx.url.pathname}${ctx.url.search}`;
// ctx.localeHref is a no-op unless the URL asked for a locale, so it is also the honest answer to
// "did it?" — asking the function that decides keeps the two from drifting apart.
const carried = ctx.localeHref("/") === "/" ? null : ctx.locale;
return {
dir: textDirection(ctx.locale),
locale: ctx.locale,
localeHref: (href) => ctx.localeHref(href),
localeParam: carried,
localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })),
locales: ctx.locales,
t: ctx.t,
+6
View File
@@ -14,6 +14,12 @@ export { can, check, GuardError, requireSession } from "../auth/guards.ts";
// 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";
// `englishTranslator(yourCatalog)` chains your catalog in front of the host's English — the default
// for a view model built outside a request, so core words you reuse still read as words in a test.
export { englishTranslator } from "../i18n/english.ts";
// `localeLabel(tag)` names a locale in its own language ("svenska (Sverige)") — what ctx.locales
// needs to become a language picker of your own.
export { localeLabel } from "../i18n/locale.ts";
export type { Translate, TranslateVars } from "../i18n/translate.ts";
export type { Catalog, PluralMessage } from "../i18n/catalog.ts";
export { parseListQuery } from "../ui/list-query.ts";
+5 -4
View File
@@ -38,13 +38,14 @@ const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.rev
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
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) }));
// drifts from its en-US baseline, so a half-translated deploy never reaches a visitor. Loaded
// before the boot hooks, so a catalog mismatch aborts before a plugin's onBoot has any side effect.
const i18n = createI18n(await loadI18n({ logger: log, pluginIds: plugins.map((p) => p.id) }));
log.info("locales loaded", { locales: i18n.available.join(", ") });
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
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.
+4 -4
View File
@@ -11,7 +11,7 @@ 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";
import { branding, 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). Its label is a
@@ -62,15 +62,15 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
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";
const { theme, ...brand } = branding(opts.menu, t);
return {
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: t(b.name), ...(b.sub != null ? { sub: t(b.sub) } : {}) },
brand,
csrfToken: opts.csrfToken ?? "",
nav: carryLocaleInto(nav, carryLocale),
signInHref: carryLocale(returnTo),
...(b.theme != null ? { theme: b.theme } : {}),
...(theme != null ? { theme } : {}),
user: shellUser(opts.user, t),
};
}
+2 -1
View File
@@ -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 menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data);
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, { ...ENGLISH_LOCALS, ...data });
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
test("menu renders trigger, positioning, the item matrix and check groups", async () => {
+2 -1
View File
@@ -6,6 +6,7 @@
// 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 { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
export interface NavNode {
@@ -42,7 +43,7 @@ export function composeNav(
fragments: NavNode[][] = [],
override: NavOverride = {},
permissions: string[] = [],
t: Translate = (key) => key,
t: Translate = ENGLISH,
): NavNode[] {
let nodes: NavNode[] = fragments.flat();
if (override.rename) nodes = renameTree(nodes, override.rename);
+15 -3
View File
@@ -10,6 +10,18 @@ import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
import { type MenuConfig } from "./menu-config.ts";
// The brand block both the chrome and the shell model carry. `name`/`sub` pass through `t`, so a
// catalog key is translated and an operator's own wording renders as written.
export function branding(menu: MenuConfig, t: Translate): { logo?: string; name: string; sub?: string; theme?: string } {
const b = menu.branding;
return {
...(b.logo != null ? { logo: b.logo } : {}),
name: t(b.name),
...(b.sub != null ? { sub: t(b.sub) } : {}),
...(b.theme != null ? { theme: b.theme } : {}),
};
}
export interface ShellUser {
email: string;
initials: string;
@@ -44,14 +56,14 @@ export function buildShellContext(opts: {
title: string;
user?: User | null;
}): ShellModel {
const b = opts.menu.branding;
const t = opts.t ?? ENGLISH;
const { theme, ...brand } = branding(opts.menu, t);
return {
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: t(b.name), ...(b.sub != null ? { sub: t(b.sub) } : {}) },
brand,
...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}),
csrfToken: opts.csrfToken ?? "",
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
...(b.theme != null ? { theme: b.theme } : {}),
...(theme != null ? { theme } : {}),
title: opts.title,
user: shellUser(opts.user, t),
};