Architecture review fixes: partials carry the locale, mountable locales/, shared core words
CI / full-gate (push) Successful in 2m36s
CI / full-gate (push) Successful in 2m36s
This commit is contained in:
+8
-6
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
@@ -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]]),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user