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:
@@ -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