Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout

This commit is contained in:
2026-06-24 00:23:55 +02:00
parent 6d316c4888
commit de22f51c12
113 changed files with 282 additions and 265 deletions
+51
View File
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
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 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 () => {
const html = flat(await render({
title: "Sign in", sub: "Welcome back.", action: "/login",
sso: { providers: [
{ label: "Continue with Google", logo: "G" },
{ label: "Continue with SAML SSO", icon: "i-shield", href: "/sso/saml" },
{ label: "Sign in with Microsoft", logo: "M", name: "provider", value: "microsoft" },
] },
body: '<div id="fields">FORM</div><button class="btn btn-primary btn-block">Sign in</button>',
alt: { text: "Don't have an account?", href: "/register", label: "Create one" },
}));
assert.match(html, /<form class="auth-card" method="post" action="\/login"><div class="auth-head"><h1>Sign in<\/h1><p class="auth-sub">Welcome back\.<\/p><\/div>/);
// SSO: text-logo button vs icon-logo link, then the divider.
assert.match(html, /<div class="sso" aria-label="Single sign-on options"><ul class="sso-list">/);
assert.match(html, /<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G<\/span><span class="sso-label">Continue with Google<\/span><\/button><\/li>/);
assert.match(html, /<li><a class="sso-btn" href="\/sso\/saml"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"\s*\/?><\/svg><\/span><span class="sso-label">Continue with SAML SSO<\/span><\/a><\/li>/);
// A provider with name/value submits to the form (Kratos OIDC) — type="submit", not a decorative
// button; `formnovalidate` so it bypasses the required email/password fields (SSO needs neither).
assert.match(html, /<li><button type="submit" class="sso-btn" name="provider" value="microsoft" formnovalidate><span class="sso-logo" aria-hidden="true">M<\/span><span class="sso-label">Sign in with Microsoft<\/span><\/button><\/li>/);
assert.match(html, /<\/ul><div class="auth-divider">or<\/div><\/div>/);
// Body slot lands inside .auth-form; alt footer renders text + link.
assert.match(html, /<div class="auth-form"><div id="fields">FORM<\/div><button class="btn btn-primary btn-block">Sign in<\/button><\/div>/);
assert.match(html, /<p class="auth-alt">Don&#39;t have an account\? <a href="\/register">Create one<\/a><\/p><\/form>/); // apostrophe is escaped
});
test("auth-card renders a back link, omits SSO/alt when absent, escapes title, and never throws", async () => {
const back = flat(await render({
title: "Reset password", sub: "Enter your email.",
back: { href: "/login", label: "Back to sign in" },
body: "<button>Send</button>",
}));
assert.match(back, /<div class="auth-head"><a class="auth-back" href="\/login"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"\s*\/?><\/svg>Back to sign in<\/a><h1>Reset password<\/h1>/);
assert.doesNotMatch(back, /class="sso"|auth-alt/);
// Defaults: post method, empty form, escaped title, no throw.
assert.match(flat(await render({ title: "<x>" })), /<form class="auth-card" method="post"><div class="auth-head"><h1>&lt;x&gt;<\/h1><\/div><div class="auth-form"><\/div><\/form>/);
assert.match(flat(await render()), /<form class="auth-card" method="post">/);
});
+64
View File
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildPluginChrome } from "./chrome.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
const scheduling: Plugin = {
apiVersion: "1.0.0",
id: "scheduling",
nav: [{
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
icon: "i-cal", id: "scheduling", label: "Scheduling",
}],
};
// A plugin with a public nav node (reachable by anyone, signed in or not).
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label);
test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden, a public node still shows", () => {
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, plugins: [scheduling, portal] });
assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name);
assert.equal(chrome.user.name, "Guest");
// Dashboard points at the gated /dashboard — showing it to an anonymous visitor only dead-ends them
// at /login, so it's dropped. Scheduling's only child is gated (dropped), admin gated (dropped);
// the explicitly public Portal node remains.
assert.deepEqual(labels(chrome.nav), ["Portal"]);
});
test("anonymous shell Sign-in link carries the current page as return_to", () => {
assert.equal(buildPluginChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
});
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
const chrome = buildPluginChrome({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
});
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
assert.equal(section.open, true); // ancestor of the current leaf opened
assert.equal(section.children!.find((c) => c.label === "Shifts")!.current, true);
assert.equal(chrome.user.name, "ada"); // email local part
});
test("an admin sees the gated admin section; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened
// /admin/users/new is under the Users base (/admin/users) → that leaf is current, not Groups/Roles.
assert.equal(admin.children!.find((c) => c.label === "Users")!.current, true);
assert.equal(admin.children!.find((c) => c.label === "Groups")!.current, undefined);
});
test("branding logo + default theme flow through when set", () => {
const menu: MenuConfig = { branding: { logo: "/logo.svg", name: "Acme", theme: "dark" }, override: {} };
const chrome = buildPluginChrome({ menu });
assert.equal(chrome.brand.logo, "/logo.svg");
assert.equal(chrome.brand.name, "Acme");
assert.equal(chrome.theme, "dark");
});
+91
View File
@@ -0,0 +1,91 @@
// Page chrome for plugin pages: the brand / global-nav / user / theme / csrf block a
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
// admin screens render. Pure; the host builds it per plugin request and exposes it on ctx.chrome.
// nav is the global menu — Dashboard + every plugin's fragment + the gated admin section — run
// through composeNav (override + per-user filter) and current-marked for the request path.
import { adminSection, DASHBOARD_NAV } from "../admin/admin-nav.ts";
import type { User } from "../http/context.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";
export interface PageChrome {
brand: { logo?: string; name: string; sub?: string };
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs
signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to
theme?: string;
user: ShellUser;
}
export interface ChromeOptions {
csrfToken?: string;
currentPath?: string; // request pathname; the matching nav leaf is marked current
menu: MenuConfig;
plugins?: Plugin[];
user?: User | null;
}
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
// 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.
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
fragments.push([adminSection()]);
const roles = opts.user?.roles ?? [];
const nav = composeNav(fragments, opts.menu.override, roles);
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.
const target = bestHref(nav, opts.currentPath);
if (target) markCurrent(nav, target);
}
const b = opts.menu.branding;
return {
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: 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",
...(b.theme != null ? { theme: b.theme } : {}),
user: shellUser(opts.user),
};
}
// 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.
function bestHref(nodes: NavNode[], path: string): string | undefined {
let best: string | undefined;
const visit = (ns: NavNode[]): void => {
for (const n of ns) {
if (n.href != null && (n.href === path || (n.href !== "/" && path.startsWith(`${n.href}/`)))) {
if (best === undefined || n.href.length > best.length) best = n.href;
}
if (n.children) visit(n.children);
}
};
visit(nodes);
return best;
}
// Mark the leaf whose href equals `target` as current and open every ancestor header so the active
// page is revealed. Mutates the freshly-composed nodes (composeNav returns new objects each call).
// Returns whether this subtree contains the current node.
function markCurrent(nodes: NavNode[], target: string): boolean {
let hit = false;
for (const node of nodes) {
const here = node.href === target;
const inChild = node.children ? markCurrent(node.children, target) : false;
if (here) node.current = true;
if (here || inChild) {
if (node.children) node.open = true;
hit = true;
}
}
return hit;
}
+28
View File
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildDashboardModel } from "./dashboard.ts";
import type { NavNode } from "./nav.ts";
// The default /dashboard is an instructional starter: no mock data, just the unified
// menu + shell. The host passes the one global menu (ctx.chrome.nav); the model passes it through.
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
const m = buildDashboardModel({ csrfToken: "tok.sig", nav: NAV, user: { email: "ada@x.io", id: "u1", roles: ["admin"] } });
assert.equal(m.shell.title, "Dashboard");
assert.equal(m.shell.csrfToken, "tok.sig");
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
assert.deepEqual(m.nav, NAV); // the host's menu is used verbatim — the dashboard builds no nav of its own
});
test("dashboard model: sensible defaults (empty nav, no token, Guest) and branding from menu", () => {
const m = buildDashboardModel();
assert.deepEqual(m.nav, []);
assert.equal(m.shell.csrfToken, "");
assert.equal(m.shell.user.name, "Guest");
assert.equal(m.shell.brand.name, "Plainpages");
const branded = buildDashboardModel({ menu: { branding: { name: "Acme Ops", sub: "Admin", theme: "dark" }, override: {} } });
assert.equal(branded.shell.brand.name, "Acme Ops");
assert.equal(branded.shell.theme, "dark");
});
+25
View File
@@ -0,0 +1,25 @@
// Dashboard view model: the gated "/dashboard" app home. By default a short instructional
// starter — what the dashboard is and how to replace it by exporting a `dashboard` handler from a
// plugin (views/index.ejs holds the prose). A plugin owns the real one (PluginManifest.dashboard);
// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
// 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 { 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 } = {}) {
return {
nav: opts.nav ?? [],
shell: buildShellContext({
breadcrumbs: [{ label: "Dashboard" }],
csrfToken: opts.csrfToken ?? "",
menu: opts.menu ?? DEFAULT_MENU,
title: "Dashboard",
user: opts.user ?? null,
}),
};
}
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
+96
View File
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
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 flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const config = {
caption: "People in the directory",
selectable: true,
actions: true,
columns: [
{ label: "Name", sortable: true, sort: "asc", href: "?sort=name&dir=desc" }, // active ascending
{ label: "Email", sortable: true, href: "?sort=email&dir=asc" }, // sortable, inactive
{ label: "Team" }, // not sortable
{ label: "Status" },
{ label: "Detail" },
],
rows: [
{
name: "Mara Delgado",
cells: [
{ user: { name: "Mara Delgado", initials: "MD" } },
{ text: "mara@x.io", className: "cell-muted cell-mono" },
{ text: "Engineering", className: "cell-muted" },
{ badge: { tone: "pos", label: "Active" } },
{ html: '<a href="/x">open</a>' },
],
actions: [
{ label: "Edit", icon: "i-edit", href: "/people/1/edit" },
{ label: "Delete", icon: "i-trash", danger: true, separatorBefore: true },
],
},
],
};
test("data-table renders sortable headers, row-select, typed cells, badges and kebab actions", async () => {
const html = flat(await render(config));
assert.match(html, /<div class="table-wrap"><table class="table"><caption class="sr-only">People in the directory<\/caption>/);
// Row-select: header select-all + per-row checkbox with a descriptive label.
assert.match(html, /<th class="col-check" scope="col"><input type="checkbox" aria-label="Select all rows"><\/th>/);
assert.match(html, /<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Mara Delgado"><\/td>/);
// Sortable header — active ascending: aria-sort + link + up icon (& escaped in href).
assert.match(html, /<th scope="col" aria-sort="ascending"><a class="th-sort" href="\?sort=name&amp;dir=desc">Name <svg class="ico ico-sm sort-ico"><use href="#i-up"\s*\/?><\/svg><\/a><\/th>/);
// Sortable header — inactive: no aria-sort, neutral sort icon.
assert.match(html, /<th scope="col"><a class="th-sort" href="\?sort=email&amp;dir=asc">Email <svg class="ico ico-sm sort-ico"><use href="#i-sort"\s*\/?><\/svg><\/a><\/th>/);
// Non-sortable header — plain text, no button/link.
assert.match(html, /<th scope="col">Team<\/th>/);
// Actions header.
assert.match(html, /<th class="col-actions" scope="col"><span class="sr-only">Actions<\/span><\/th>/);
// Typed cells: user identifies the row → <th scope="row">; classed text, badge tone, raw html.
assert.match(html, /<th scope="row"><span class="cell-user"><span class="avatar" aria-hidden="true">MD<\/span><span class="cell-strong">Mara Delgado<\/span><\/span><\/th>/);
assert.match(html, /<td class="cell-muted cell-mono">mara@x.io<\/td>/);
assert.match(html, /<td class="cell-muted">Engineering<\/td>/);
assert.match(html, /<td><span class="badge pos"><span class="dot"><\/span>Active<\/span><\/td>/);
assert.match(html, /<td><a href="\/x">open<\/a><\/td>/);
// Kebab row actions: link item, danger button, separator.
assert.match(html, /<td class="col-actions"><details class="menu kebab"><summary aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/summary><div class="menu-pop">/);
assert.match(html, /<a class="menu-item" href="\/people\/1\/edit"><svg class="ico"><use href="#i-edit"\s*\/?><\/svg>Edit<\/a>/);
assert.match(html, /<div class="menu-sep"><\/div><button class="menu-item danger" type="button"><svg class="ico"><use href="#i-trash"\s*\/?><\/svg>Delete<\/button>/);
});
test("data-table rowHeader cell is a <th scope=row> identifier — a link when given href, else plain text", async () => {
const linked = flat(await render({ columns: [{ label: "Group" }], rows: [{ cells: [{ rowHeader: { href: "/admin/groups/eng", text: "eng" } }] }] }));
assert.match(linked, /<th scope="row"><a class="cell-strong" href="\/admin\/groups\/eng">eng<\/a><\/th>/);
const plain = flat(await render({ columns: [{ label: "Group" }], rows: [{ cells: [{ rowHeader: { text: "eng" } }] }] }));
assert.match(plain, /<th scope="row"><span class="cell-strong">eng<\/span><\/th>/);
});
test("data-table renders a minimal table (plain string cells, no select/actions) and never throws", async () => {
const html = flat(await render({ columns: [{ label: "Name" }], rows: [{ cells: ["Plain"] }] }));
assert.match(html, /<table class="table"><thead><tr><th scope="col">Name<\/th><\/tr><\/thead><tbody><tr><td>Plain<\/td><\/tr><\/tbody><\/table>/);
assert.doesNotMatch(html, /col-check|col-actions/);
assert.match(flat(await render()), /<table class="table"><thead><tr><\/tr><\/thead><tbody><\/tbody><\/table>/);
});
test("data-table shows an empty-state row spanning all columns when there are no rows", async () => {
// colspan covers the data columns + the select + actions columns (2 + 1 + 1 = 4).
const html = flat(await render({ actions: true, columns: [{ label: "Name" }, { label: "Email" }], rows: [], selectable: true }));
assert.match(html, /<tbody><tr><td class="table-empty" colspan="4">Nothing here yet\.<\/td><\/tr><\/tbody>/);
// a caller-supplied message overrides the default
assert.match(flat(await render({ columns: [{ label: "Shift" }], emptyText: "No shifts yet.", rows: [] })), /<td class="table-empty" colspan="1">No shifts yet\.<\/td>/);
// a populated table has no empty-state row
assert.doesNotMatch(flat(await render({ columns: [{ label: "Name" }], rows: [{ cells: ["A"] }] })), /table-empty/);
});
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
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 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 () => {
// Error field: aria-invalid + aria-describedby wiring, icon, error markup with raw HTML.
const errored = flat(await render({
id: "reg-email", name: "email", label: "Email", type: "email",
autocomplete: "email", placeholder: "you@company.com", required: true, icon: "i-mail",
error: { html: 'Already used. <a href="/login">Sign in</a>.' },
}));
assert.match(errored, /<div class="field has-error"><label for="reg-email">Email<\/label>/);
assert.match(errored, /<div class="input-wrap"><svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"\s*\/?><\/svg>/);
assert.match(errored, /<input class="input has-ico" id="reg-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" aria-invalid="true" aria-describedby="reg-email-err" required>/);
assert.match(errored, /<p class="field-error" id="reg-email-err" role="alert"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"\s*\/?><\/svg><span>Already used\. <a href="\/login">Sign in<\/a>\.<\/span><\/p>/);
// field-top with an inline link, hint, minlength; no error → no has-error / aria-invalid.
const withLink = flat(await render({
id: "login-password", name: "password", label: "Password", type: "password",
autocomplete: "current-password", placeholder: "••••••••", required: true, minlength: 8, icon: "i-lock",
link: { href: "/forgot", label: "Forgot password?" }, hint: "Use 8 or more characters.",
}));
assert.match(withLink, /<div class="field"><div class="field-top"><label for="login-password">Password<\/label><a class="field-link" href="\/forgot">Forgot password\?<\/a><\/div>/);
assert.match(withLink, /<input class="input has-ico" id="login-password" name="password" type="password" autocomplete="current-password" placeholder="••••••••" minlength="8" required>/);
assert.match(withLink, /<span class="field-hint">Use 8 or more characters\.<\/span><\/div>/);
assert.doesNotMatch(withLink, /has-error|aria-invalid/);
// field-top with an "Optional" tag, a value, no icon → plain input.
const optional = flat(await render({
id: "reg-name", name: "name", label: "Name", optional: true, value: "Avery", autocomplete: "name", placeholder: "Avery Kline",
}));
assert.match(optional, /<div class="field-top"><label for="reg-name">Name<\/label><span class="optional">Optional<\/span><\/div>/);
assert.match(optional, /<div class="input-wrap"><input class="input" id="reg-name" name="name" type="text" autocomplete="name" placeholder="Avery Kline" value="Avery"><\/div>/);
});
test("field defaults to a bare text input, escapes a string error, and never throws", async () => {
const bare = flat(await render({ id: "x", name: "x", label: "X" }));
assert.match(bare, /<div class="field"><label for="x">X<\/label><div class="input-wrap"><input class="input" id="x" name="x" type="text"><\/div><\/div>/);
// OTP code field: inputmode + pattern render (both after autocomplete, before required).
const code = flat(await render({ id: "field-code", name: "code", label: "Verification code", autocomplete: "one-time-code", inputmode: "numeric", pattern: "[0-9]*", required: true, icon: "i-shield" }));
assert.match(code, /<input class="input has-ico" id="field-code" name="code" type="text" autocomplete="one-time-code" inputmode="numeric" pattern="\[0-9\]\*" required>/);
const stringErr = flat(await render({ id: "x", name: "x", label: "X", error: "<b>Required</b>." }));
assert.match(stringErr, /<span>&lt;b&gt;Required&lt;\/b&gt;\.<\/span>/); // string error is escaped
assert.match(stringErr, /aria-describedby="x-err"/);
});
+84
View File
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
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 flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const config = {
label: "Filter people",
rows: [
[
{ type: "search", name: "q", placeholder: "Search people…", value: "ann", label: "Search people" },
{
type: "segmented",
name: "status",
legend: "Status",
value: "active",
options: [
{ value: "all", label: "All", count: "1,284" },
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
{ type: "select", name: "team", label: "Team", value: "design", options: [{ value: "", label: "All teams" }, { value: "design", label: "Design" }] },
{ type: "spacer" },
],
[
{
type: "chips",
name: "tag",
legend: "Tags",
value: ["engineering", "oncall"],
options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }],
},
{ type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } },
],
],
pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }],
clearHref: "?",
};
test("filter-bar renders a GET form with every control type, reflecting current values", async () => {
const html = flat(await render(config));
// GET form (server-side filtering, zero-JS).
assert.match(html, /<form class="filters" method="get" aria-label="Filter people">/);
// search — icon + value reflected.
assert.match(html, /<label class="search"><span class="sr-only">Search people<\/span><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"\s*\/?><\/svg><input type="search" name="q" placeholder="Search people…" value="ann"><\/label>/);
// segmented — current value checked, others not, optional count badge.
assert.match(html, /<input type="radio" name="status" value="all"><span>All<\/span><span class="seg-count">1,284<\/span>/);
assert.match(html, /<input type="radio" name="status" value="active" checked><span>Active<\/span>/);
// select — matching option selected.
assert.match(html, /<select id="f-team" name="team"><option value="">All teams<\/option><option value="design" selected>Design<\/option><\/select>/);
// chips — checkbox group, only current values checked.
assert.match(html, /<input type="checkbox" name="tag" value="engineering" checked>Engineering/);
assert.match(html, /<input type="checkbox" name="tag" value="oncall" checked>On-call/);
assert.match(html, /<input type="checkbox" name="tag" value="design">Design/);
// daterange — calendar icon + two date inputs with values.
assert.match(html, /<div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"\s*\/?><\/svg>.*?<input type="date" id="f-joined_from" name="joined_from" value="2026-01-01">.*?<span class="to" aria-hidden="true">to<\/span>.*?<input type="date" id="f-joined_to" name="joined_to" value="2026-06-14">/);
// spacer.
assert.match(html, /<div class="spacer"><\/div>/);
// applied pills + clear-all + Reset/Apply actions.
assert.match(html, /<div class="active-pills" aria-label="Applied filters"><span class="filter-legend">Applied<\/span><span class="pill"><b>Team:<\/b> Engineering <a class="pill-x" href="\?tag=oncall" aria-label="Remove Team filter">/);
assert.match(html, /<a class="pill-clear" href="\?">Clear all<\/a>/);
assert.match(html, /<button type="reset" class="btn">Reset<\/button>/);
assert.match(html, /<button type="submit" class="btn btn-primary"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"\s*\/?><\/svg>Apply filters<\/button>/);
});
test("filter-bar renders with defaults: form + actions, no pills, never throws", async () => {
const html = flat(await render());
assert.match(html, /<form class="filters" method="get"/);
assert.match(html, /<button type="submit" class="btn btn-primary"/);
assert.doesNotMatch(html, /active-pills/);
});
+33
View File
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
import { ICON_NAMES, buildIconSprite } from "./icons.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const lucideDir = join(rootDir, "node_modules", "lucide-static", "icons");
const partial = join(rootDir, "views", "partials", "icons.ejs");
const symbolInner = (sprite: string, id: string): string =>
sprite.match(new RegExp(`<symbol id="${id}"[^>]*>(.*?)</symbol>`))?.[1] ?? "";
test("icons partial inlines exactly the used lucide-static icons", async () => {
const built = buildIconSprite(lucideDir);
// The committed partial must be exactly the generator output — proves provenance
// and flags drift when the pinned lucide-static is bumped without regenerating.
assert.equal(readFileSync(partial, "utf8"), built);
const html = await ejs.renderFile(partial, {});
const ids = [...html.matchAll(/<symbol id="(i-[a-z-]+)"/g)].map((m) => m[1]);
assert.deepEqual(ids, Object.keys(ICON_NAMES)); // only the used icons, complete + ordered
assert.match(html.trimStart(), /^<svg width="0" height="0"[^>]*aria-hidden="true"/);
// Independent spot-checks: a wrong id→icon mapping is caught regardless of the builder.
assert.match(symbolInner(built, "i-x"), /M18 6 6 18/);
assert.match(symbolInner(built, "i-search"), /circle cx="11" cy="11" r="8"/);
assert.match(symbolInner(built, "i-kebab"), /circle cx="12" cy="12" r="1"/);
assert.match(symbolInner(built, "i-bell"), /M10\.268 21/); // lucide v1.18 path, not an older one
});
+59
View File
@@ -0,0 +1,59 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
// Sprite id → lucide-static icon, the icons the UI actually references (alphabetical by id).
// Inlined as <symbol> so pages stay zero-JS: <svg><use href="#i-search"/></svg>.
export const ICON_NAMES: Record<string, string> = {
"i-alert": "circle-alert",
"i-arrow-left": "arrow-left",
"i-bell": "bell",
"i-box": "box",
"i-cal": "calendar",
"i-chart": "chart-no-axes-column",
"i-check-circle": "circle-check",
"i-chev": "chevron-right",
"i-cols": "columns-3",
"i-copy": "copy",
"i-download": "download",
"i-edit": "pencil",
"i-gear": "settings",
"i-globe": "globe",
"i-grid": "layout-grid",
"i-kebab": "ellipsis-vertical",
"i-layers": "layers",
"i-lock": "lock",
"i-logout": "log-out",
"i-mail": "mail",
"i-menu": "menu",
"i-plus": "plus",
"i-search": "search",
"i-shield": "shield",
"i-sliders": "sliders-horizontal",
"i-sort": "chevrons-up-down",
"i-trash": "trash-2",
"i-up": "chevron-up",
"i-user": "user",
"i-users": "users",
"i-x": "x",
};
// Drop lucide's license comment + <svg> wrapper, keep the drawing children (compacted).
function inner(svg: string): string {
const open = svg.indexOf(">", svg.indexOf("<svg"));
return svg.slice(open + 1, svg.lastIndexOf("</svg>")).replace(/\s*\n\s*/g, "").trim();
}
// Hidden <symbol> sprite for the used icons, sourced from the pinned lucide-static.
// Regenerates views/partials/icons.ejs; icons.test.ts asserts the committed file matches.
export function buildIconSprite(iconsDir: string): string {
const symbols = Object.entries(ICON_NAMES).map(
([id, name]) => ` <symbol id="${id}" viewBox="0 0 24 24">${inner(readFileSync(join(iconsDir, `${name}.svg`), "utf8"))}</symbol>`,
);
return [
"<%# Generated from lucide-static by src/ui/icons.ts — regenerate on dep bump (guarded by icons.test.ts). %>",
'<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">',
...symbols,
"</svg>",
"",
].join("\n");
}
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseListQuery } from "./list-query.ts";
test("parseListQuery reads search, multi-value filters, sort and pagination from the URL", () => {
// q is trimmed; chips repeat a key; daterange is two keys; "-field" ⇒ desc sort.
assert.deepEqual(
parseListQuery("?q= ada &status=active&tag=oncall&tag=lead&joined_from=2026-01-01&sort=-last_active&page=3&pageSize=50"),
{
filters: { joined_from: ["2026-01-01"], status: ["active"], tag: ["oncall", "lead"] },
page: 3,
pageSize: 50,
q: "ada",
sort: { dir: "desc", field: "last_active" },
},
);
});
test("parseListQuery applies defaults, clamps, drops empties and accepts URL/URLSearchParams/string", () => {
// Empty query → all defaults, never throws.
assert.deepEqual(parseListQuery("?"), { filters: {}, page: 1, pageSize: 25, q: "", sort: null });
// Empty values dropped (status, q); page<1 → 1; oversized pageSize clamped to max; bare sort ⇒ asc.
assert.deepEqual(
parseListQuery("status=&q=&page=0&pageSize=99999&sort=name"),
{ filters: {}, page: 1, pageSize: 100, q: "", sort: { dir: "asc", field: "name" } },
);
// A URL works (searchParams), multi-value preserved.
assert.deepEqual(
parseListQuery(new URL("http://x/users?team=engineering&team=design")),
{ filters: { team: ["engineering", "design"] }, page: 1, pageSize: 25, q: "", sort: null },
);
// URLSearchParams works; non-integer page/pageSize fall back to defaults; lone "-" sort ⇒ null.
assert.deepEqual(
parseListQuery(new URLSearchParams("page=abc&pageSize=-5&sort=-")),
{ filters: {}, page: 1, pageSize: 25, q: "", sort: null },
);
});
test("parseListQuery honours custom reserved names and page-size bounds", () => {
assert.deepEqual(
parseListQuery("?search=hi&p=2&n=10&order=-x&status=active", {
defaultPageSize: 20, maxPageSize: 50, pageParam: "p", pageSizeParam: "n", qParam: "search", sortParam: "order",
}),
{ filters: { status: ["active"] }, page: 2, pageSize: 10, q: "hi", sort: { dir: "desc", field: "x" } },
);
// Custom n clamps to the custom max; the now-unreserved default names become plain filters.
assert.equal(parseListQuery("?n=999", { maxPageSize: 50, pageSizeParam: "n" }).pageSize, 50);
assert.deepEqual(parseListQuery("?q=hi", { qParam: "search" }).filters, { q: ["hi"] });
});
+72
View File
@@ -0,0 +1,72 @@
// parseListQuery: read a list-page URL into the state the building blocks render
// from — search, filters, sort, pagination. The URL is the only list state (README
// "Interactivity"), so this is the inverse of the filter-bar GET form, the sort links and the
// pagination links: bookmarkable, shareable, reproducible. Pure; never throws.
export interface ListSort {
dir: "asc" | "desc";
field: string;
}
export interface ListQuery {
filters: Record<string, string[]>; // every non-reserved param; multi-value kept, empties dropped
page: number; // ≥ 1
pageSize: number; // clamped to [1, maxPageSize]
q: string; // trimmed search text, "" when absent
sort: ListSort | null; // "field" ⇒ asc, "-field" ⇒ desc
}
export interface ListQueryOptions {
defaultPageSize?: number; // used when pageSize is absent/invalid (default 25)
maxPageSize?: number; // upper clamp (default 100)
pageParam?: string; // default "page"
pageSizeParam?: string; // default "pageSize"
qParam?: string; // default "q"
sortParam?: string; // default "sort"
}
export function parseListQuery(url: URL | URLSearchParams | string, options: ListQueryOptions = {}): ListQuery {
const params = toParams(url);
const qParam = options.qParam ?? "q";
const sortParam = options.sortParam ?? "sort";
const pageParam = options.pageParam ?? "page";
const pageSizeParam = options.pageSizeParam ?? "pageSize";
const reserved = new Set([pageParam, pageSizeParam, qParam, sortParam]);
const filters: Record<string, string[]> = {};
for (const key of new Set(params.keys())) {
if (reserved.has(key)) continue;
const values = params.getAll(key).filter((v) => v !== "");
if (values.length) filters[key] = values;
}
return {
filters,
page: positiveInt(params.get(pageParam)) ?? 1,
pageSize: Math.min(positiveInt(params.get(pageSizeParam)) ?? (options.defaultPageSize ?? 25), options.maxPageSize ?? 100),
q: (params.get(qParam) ?? "").trim(),
sort: parseSort(params.get(sortParam)),
};
}
function toParams(url: URL | URLSearchParams | string): URLSearchParams {
if (typeof url === "string") {
const i = url.indexOf("?");
return new URLSearchParams(i >= 0 ? url.slice(i + 1) : url);
}
return url instanceof URL ? url.searchParams : url;
}
// A strictly positive integer, else null so the caller falls back to a default.
function positiveInt(raw: string | null): number | null {
if (raw == null || raw.trim() === "") return null;
const n = Number(raw);
return Number.isInteger(n) && n >= 1 ? n : null;
}
function parseSort(raw: string | null): ListSort | null {
if (raw == null) return null;
const desc = raw.startsWith("-");
const field = (desc ? raw.slice(1) : raw).trim();
return field ? { dir: desc ? "desc" : "asc", field } : null;
}
+39
View File
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { DEFAULT_MENU, loadMenuConfig } from "./menu-config.ts";
// Write a throwaway menu.ts (a plain object — defineMenu is identity) and clean it up after.
function scaffold(t: TestContext, source: string): string {
const dir = mkdtempSync(join(tmpdir(), "pp-menu-"));
t.after(() => rmSync(dir, { force: true, recursive: true }));
const file = join(dir, "menu.ts");
writeFileSync(file, source);
return file;
}
test("loadMenuConfig returns defaults when no config file exists (clean clone)", async () => {
assert.deepEqual(await loadMenuConfig({ file: join(tmpdir(), "pp-no-such-menu-xyz.ts") }), DEFAULT_MENU);
});
test("loadMenuConfig reads branding + override, merging branding over defaults", async (t) => {
const file = scaffold(t, `export default {
branding: { name: "Acme Ops", theme: "dark" },
override: { hide: ["teams"], order: ["reports", "people"], rename: { people: "Staff" } },
};`);
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.theme, "dark");
assert.deepEqual(menu.override.hide, ["teams"]);
assert.deepEqual(menu.override.rename, { people: "Staff" });
});
test("loadMenuConfig fails loud on a malformed config", async (t) => {
await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default [];`) }), /config object/);
await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { branding: { theme: "neon" } };`) }), /theme/);
await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { override: { hide: "teams" } };`) }), /hide.*array/s);
});
+105
View File
@@ -0,0 +1,105 @@
// Central menu config: config/menu.ts lets an operator set branding (app name, logo,
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
// group/hide part is the NavOverride composeNav already applies (the override always wins, before
// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at
// boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config).
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { NavOverride } from "./nav.ts";
export type Theme = "auto" | "dark" | "light";
export interface Branding {
logo?: string; // optional logo asset path/URL, rendered in the sidebar brand
name: string; // app name shown in the sidebar brand
sub?: string; // optional brand subtitle
theme?: Theme; // default color theme for the theme-switch
}
export interface MenuConfig {
branding: Branding;
override: NavOverride;
}
// What config/menu.ts authors — every field optional; the loader fills branding defaults.
export interface MenuConfigInput {
branding?: Partial<Branding>;
override?: NavOverride;
}
export const DEFAULT_BRANDING: Branding = { name: "Plainpages", sub: "Console" };
export const DEFAULT_MENU: MenuConfig = { branding: DEFAULT_BRANDING, override: {} };
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export const MENU_CONFIG_FILE = join(rootDir, "config", "menu.ts");
// Identity helper: types the authored config, returns it unchanged (mirrors definePlugin).
export function defineMenu(config: MenuConfigInput): MenuConfigInput {
return config;
}
export interface LoadMenuOptions {
file?: string;
}
export async function loadMenuConfig(options: LoadMenuOptions = {}): Promise<MenuConfig> {
const file = options.file ?? MENU_CONFIG_FILE;
if (!existsSync(file)) return DEFAULT_MENU; // clean clone: no central override
let mod: { default?: unknown };
try {
mod = await import(pathToFileURL(file).href);
} catch (err) {
throw new Error(`config/menu.ts failed to import — ${err instanceof Error ? err.message : String(err)}`);
}
const errors = validate(mod.default);
if (errors.length) throw new Error(`config/menu.ts is invalid:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
const authored = mod.default as MenuConfigInput;
return {
branding: { ...DEFAULT_BRANDING, ...authored.branding },
override: authored.override ?? {},
};
}
const THEMES = new Set<string>(["auto", "dark", "light"]);
// Validate the authored shape so a typo fails at boot, not silently at render. Only the fields an
// operator commonly mis-types; composeNav consumes the override defensively beyond that.
function validate(input: unknown): string[] {
if (!isObject(input)) return ["default export must be a config object (use defineMenu)"];
const errors: string[] = [];
if (input.branding !== undefined) {
if (!isObject(input.branding)) errors.push("branding must be an object");
else {
for (const key of ["logo", "name", "sub"] as const) {
if (input.branding[key] !== undefined && typeof input.branding[key] !== "string") errors.push(`branding.${key} must be a string`);
}
if (input.branding.theme !== undefined && !THEMES.has(input.branding.theme as string)) errors.push("branding.theme must be one of auto/dark/light");
}
}
if (input.override !== undefined) {
if (!isObject(input.override)) errors.push("override must be an object");
else {
for (const key of ["hide", "order"] as const) {
if (input.override[key] !== undefined && !isStringArray(input.override[key])) errors.push(`override.${key} must be an array of strings`);
}
if (input.override.groups !== undefined && !Array.isArray(input.override.groups)) errors.push("override.groups must be an array");
if (input.override.rename !== undefined && !isObject(input.override.rename)) errors.push("override.rename must be an object");
}
}
return errors;
}
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function isStringArray(v: unknown): boolean {
return Array.isArray(v) && v.every((x) => typeof x === "string");
}
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
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 flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
test("menu renders trigger, positioning, the item matrix and check groups", async () => {
const html = flat(await render({
trigger: { icon: "i-cols", text: "Columns", label: "Column settings" },
align: "left", up: true, width: 240,
items: [
{ head: "Actions" },
{ label: "Profile", icon: "i-user" }, // button (default), with icon
{ label: "Docs", href: "/docs" }, // link
{ sep: true },
{ label: "Sign out", icon: "i-logout", danger: true },
{ group: { legend: "Role", name: "role", control: "radio", options: [
{ value: "", label: "Any role", checked: true },
{ value: "admin", label: "Admin" },
] } },
{ group: { name: "col", options: [{ value: "name", label: "Name", checked: true }] } }, // checkbox default, no legend
],
}));
// Trigger: icon + text + aria-label; popover carries align/up classes + width.
assert.match(html, /<details class="menu"><summary class="btn" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<\/summary>/);
assert.match(html, /<div class="menu-pop left up" style="min-width:240px">/);
// Item matrix: head, button-with-icon, link, separator, danger button.
assert.match(html, /<div class="menu-head">Actions<\/div>/);
assert.match(html, /<button class="menu-item" type="button"><svg class="ico"><use href="#i-user"\s*\/?><\/svg>Profile<\/button>/);
assert.match(html, /<a class="menu-item" href="\/docs">Docs<\/a>/);
assert.match(html, /<div class="menu-sep"><\/div>/);
assert.match(html, /<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout"\s*\/?><\/svg>Sign out<\/button>/);
// Check group: radios reflect `checked`; legend optional; control defaults to checkbox.
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Role<\/legend><label class="menu-check"><input type="radio" name="role" value="" checked>Any role<\/label><label class="menu-check"><input type="radio" name="role" value="admin">Admin<\/label><\/fieldset>/);
assert.match(html, /<fieldset class="menu-field"><label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name<\/label><\/fieldset>/);
});
test("menu supports a raw/kebab trigger, escapes labels, and renders empty by default", async () => {
// Raw trigger HTML, no summary class, kebab + open flags.
const kebab = flat(await render({
kebab: true, open: true,
trigger: { class: "", label: "Row actions", html: '<svg class="ico ico-sm"><use href="#i-kebab"/></svg>' },
items: [{ label: "Edit", href: "/e" }],
}));
assert.match(kebab, /<details class="menu kebab" open><summary aria-label="Row actions"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/summary>/);
// Labels are escaped (item text + trigger text).
assert.match(flat(await render({ trigger: { text: "<x>" }, items: [{ label: "<y>" }] })), /<summary class="btn">&lt;x&gt;<\/summary>.*&lt;y&gt;/);
// No locals → a valid empty menu, never throws.
assert.equal(flat(await render()), '<details class="menu"><summary class="btn"></summary><div class="menu-pop"></div></details>');
});
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
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 flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const nodes = [
{ label: "Overview", href: "/overview", icon: "i-grid" }, // leaf · clickable · icon
{
label: "Workspace",
open: true, // header · static · open
children: [
{
label: "Directory",
href: "/dir",
icon: "i-users",
count: 4,
open: true, // header · clickable · icon · count
children: [
{ label: "People", href: "/people", count: "1,284", current: true }, // leaf · clickable · current
{ label: "Webhooks (soon)" }, // leaf · static
],
},
{ label: "Roles & Access", children: [{ label: "Roles", href: "/roles" }] }, // header · static · closed
],
},
];
test("nav-tree renders the header/leaf × clickable/static matrix with counts, icons and aria-current", async () => {
const html = flat(await render({ nodes }));
// Root list vs. recursive child lists.
assert.match(html, /<ul class="nav-tree">/);
assert.match(html, /<ul class="nav-children">/);
// Leaf · clickable · icon — spacer (no toggle), <a>, inlined sprite ref.
assert.match(
html,
/<span class="nav-spacer" aria-hidden="true"><\/span><a class="nav-self" href="\/overview"><svg class="ico"><use href="#i-grid"\s*\/?><\/svg><span class="nav-label">Overview<\/span><\/a>/,
);
// Header · static · open — disclosure with [open] + escaped aria-label, <span> self.
assert.match(
html,
/<details class="nav-disc" open><summary class="nav-tog" aria-label="Toggle Workspace">.*?<\/summary><\/details><span class="nav-self"><span class="nav-label">Workspace<\/span><\/span>/,
);
// Header · clickable · icon · count.
assert.match(html, /<a class="nav-self" href="\/dir"><svg class="ico"><use href="#i-users"\s*\/?><\/svg><span class="nav-label">Directory<\/span><span class="nav-count">4<\/span><\/a>/);
// Leaf · clickable · current · count.
assert.match(html, /<a class="nav-self" href="\/people" aria-current="page"><span class="nav-label">People<\/span><span class="nav-count">1,284<\/span><\/a>/);
// Leaf · static (no href → <span>, no toggle).
assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/);
// Header · static · closed (no [open]) + label escaping in both label and aria-label.
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Roles &amp; Access">/);
assert.match(html, /<span class="nav-label">Roles &amp; Access<\/span>/);
});
test("nav-tree renders an empty root list with no nodes and never throws", async () => {
assert.match(flat(await render()), /<ul class="nav-tree"><\/ul>/);
});
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { composeNav, type NavNode } from "./nav.ts";
// Two plugin fragments; ids let the override target nodes, `permission` gates per role.
const fragments: NavNode[][] = [
[{
icon: "i-cal", id: "sched", label: "Scheduling",
children: [
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
{ href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" },
],
}],
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
];
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => {
const tree = composeNav(fragments, {}, ["scheduling:read"]);
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
assert.deepEqual(tree, [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
]);
});
test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => {
// A header the user can't reach takes its whole subtree, even visible children.
const gatedHeader: NavNode[][] = [[
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
]];
assert.deepEqual(composeNav(gatedHeader, {}, []), [
{ label: "Free", children: [{ href: "/d", label: "Docs" }] },
]);
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
const emptyHeader: NavNode[][] = [[
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
]];
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
// No fragments / no roles → empty tree, never throws.
assert.deepEqual(composeNav(), []);
});
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
// A header with one public child + one gated child: with no roles, the public child keeps the
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
const frag: NavNode[][] = [[{
icon: "i-cal", id: "sched", label: "Scheduling",
children: [
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
],
}]];
// `public` is filter-only (like id/permission) — never rendered into the output node.
assert.deepEqual(composeNav(frag, {}, []), [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
]);
});
test("composeNav applies the override: rename, group, order, hide (then filters)", () => {
const base: NavNode[][] = [[
{ href: "/a", id: "a", label: "Alpha" },
{ href: "/b", id: "b", label: "Beta" },
{ href: "/c", id: "c", label: "Gamma" },
{ href: "/secret", id: "secret", label: "Secret", permission: "root" },
]];
const tree = composeNav(base, {
rename: { a: "First" }, // relabel by id
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
order: ["grp", "a"], // grp before the lone a
hide: ["c"], // remove c from inside the group
}, ["root"]);
// grp emitted (b only, c hidden), reordered before a; Secret kept now that role "root" is present.
assert.deepEqual(tree, [
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
{ href: "/a", label: "First" },
{ href: "/secret", label: "Secret" },
]);
});
+129
View File
@@ -0,0 +1,129 @@
// composeNav: merge each plugin's nav fragment into one tree, apply the central
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `permission`, or `roles` includes that permission token; a gated header hides its whole
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
// the override (+ branding); this helper only transforms data, so its result is per-deployment
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
export interface NavNode {
id?: string; // stable key for override targeting; stripped from the rendered tree
children?: NavNode[];
count?: number;
current?: boolean;
href?: string;
icon?: string;
label: string;
open?: boolean;
permission?: string; // required role token; consumed by the filter, never rendered
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
}
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
// order → hide, then the per-user permission filter runs last.
export interface NavOverride {
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
order?: string[]; // reorder top-level nodes by id; unlisted keep their order, after
rename?: Record<string, string>; // id → replacement label
}
export interface NavGroupSpec {
id: string;
children: string[]; // ids of top-level nodes to pull under the group, in this order
icon?: string;
label: string;
open?: boolean;
}
export function composeNav(
fragments: NavNode[][] = [],
override: NavOverride = {},
roles: string[] = [],
): 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(roles)).map(toRenderNode);
}
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
return nodes.map((n) => {
const renamed = n.id != null ? rename[n.id] : undefined;
return {
...n,
label: renamed != null ? renamed : n.label,
...(n.children ? { children: renameTree(n.children, rename) } : {}),
};
});
}
// Top-level only: each group becomes a header node placed where its first member sat;
// members are pulled out of the top level into the group, in the group's declared order.
function applyGroups(nodes: NavNode[], groups: NavGroupSpec[]): NavNode[] {
const ofChild = new Map<string, NavGroupSpec>();
for (const g of groups) for (const id of g.children) ofChild.set(id, g);
const byId = new Map(nodes.filter((n) => n.id != null).map((n) => [n.id as string, n]));
const build = (g: NavGroupSpec): NavNode => ({
id: g.id,
label: g.label,
...(g.icon ? { icon: g.icon } : {}),
...(g.open ? { open: g.open } : {}),
children: g.children.map((id) => byId.get(id)).filter((n): n is NavNode => n != null),
});
const out: NavNode[] = [];
const emitted = new Set<string>();
for (const n of nodes) {
const g = n.id != null ? ofChild.get(n.id) : undefined;
if (!g) { out.push(n); continue; }
if (!emitted.has(g.id)) { out.push(build(g)); emitted.add(g.id); }
}
return out;
}
function applyOrder(nodes: NavNode[], order: string[]): NavNode[] {
const rank = new Map(order.map((id, i) => [id, i]));
const rankOf = (n: NavNode): number => (n.id != null && rank.has(n.id) ? (rank.get(n.id) as number) : Infinity);
return nodes
.map((n, i) => ({ i, n }))
.sort((a, b) => rankOf(a.n) - rankOf(b.n) || a.i - b.i) // stable: equal ranks keep input order
.map((x) => x.n);
}
function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
const out: NavNode[] = [];
for (const n of nodes) {
if (n.id != null && hide.has(n.id)) continue;
out.push(n.children ? { ...n, children: hideTree(n.children, hide) } : n);
}
return out;
}
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
const out: NavNode[] = [];
for (const n of nodes) {
if (n.public !== true && n.permission != null && !roles.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
if (!n.children) { out.push(n); continue; }
const children = filterByRoles(n.children, roles);
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
out.push({ ...n, children });
}
return out;
}
// 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 };
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);
return out;
}
+43
View File
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { paginate, type PageModel } from "./paginate.ts";
// Compact view of the page sequence: ellipsis → "…", current → "[n]", else the number.
const shape = (m: PageModel): (number | string | null)[] =>
m.pages.map((p) => (p.ellipsis ? "…" : p.current ? `[${p.page}]` : p.page));
test("paginate computes the page model: counts, row window, prev/next, page sequence", () => {
const m = paginate(1284, 3, 12);
assert.deepEqual(
{ from: m.from, next: m.next, page: m.page, pageCount: m.pageCount, pageSize: m.pageSize, prev: m.prev, to: m.to, total: m.total },
{ from: 25, next: 4, page: 3, pageCount: 107, pageSize: 12, prev: 2, to: 36, total: 1284 },
);
assert.deepEqual(shape(m), [1, 2, "[3]", 4, "…", 107]);
});
test("paginate clamps out-of-range input, handles the empty list and guards sizes", () => {
// Page past the end clamps to the last page; no next.
const last = paginate(50, 99, 25);
assert.deepEqual([last.page, last.pageCount, last.from, last.to, last.prev, last.next], [2, 2, 26, 50, 1, null]);
assert.deepEqual(shape(last), [1, "[2]"]);
// Empty list → one empty page, row window 00, no prev/next.
const empty = paginate(0, 1, 25);
assert.deepEqual([empty.page, empty.pageCount, empty.from, empty.to, empty.prev, empty.next], [1, 1, 0, 0, null, null]);
assert.deepEqual(shape(empty), ["[1]"]);
// page < 1 → 1; pageSize < 1 coerces to 1; non-finite page → 1.
assert.equal(paginate(50, 0, 25).page, 1);
assert.equal(paginate(10, 1, 0).pageCount, 10);
assert.equal(paginate(50, Number.NaN, 25).page, 1);
});
test("paginate windows the sequence: single gaps fill, wider gaps ellipsize, siblings/boundaries tune it", () => {
// A one-page gap is filled, never collapsed to an ellipsis.
assert.deepEqual(shape(paginate(70, 4, 10)), [1, 2, 3, "[4]", 5, 6, 7]);
// Gaps on both sides → an ellipsis each side.
assert.deepEqual(shape(paginate(200, 10, 10)), [1, "…", 9, "[10]", 11, "…", 20]);
// Wider sibling window and more boundary pages.
assert.deepEqual(shape(paginate(200, 10, 10, { siblings: 2 })), [1, "…", 8, 9, "[10]", 11, 12, "…", 20]);
assert.deepEqual(shape(paginate(200, 10, 10, { boundaries: 2 })), [1, 2, "…", 9, "[10]", 11, "…", 19, 20]);
});
+70
View File
@@ -0,0 +1,70 @@
// paginate: pagination math → the model pagination.ejs renders. Pure and
// URL-free (README signature `paginate(total, page, pageSize)`); the caller maps each
// page number to an href. Inputs are clamped/guarded so it never produces a broken model:
// page is pinned to [1, pageCount], total/pageSize coerced to sane integers.
export interface PageItem {
current: boolean;
ellipsis: boolean; // a gap; `page` is null
page: number | null;
}
export interface PageModel {
from: number; // 1-based index of the first row on this page (0 when empty)
next: number | null; // next page, or null on the last page
page: number; // clamped current page (≥ 1)
pageCount: number; // total pages (≥ 1)
pageSize: number; // effective page size (≥ 1)
pages: PageItem[]; // page-number sequence with ellipsis gaps
prev: number | null; // previous page, or null on the first page
to: number; // 1-based index of the last row on this page (0 when empty)
total: number; // effective total rows (≥ 0)
}
export interface PaginateOptions {
boundaries?: number; // pages always shown at each end (default 1)
siblings?: number; // pages shown each side of the current page (default 1)
}
export function paginate(total: number, page: number, pageSize: number, options: PaginateOptions = {}): PageModel {
const t = Math.max(0, Math.floor(total) || 0);
const size = Math.max(1, Math.floor(pageSize) || 0);
const pageCount = Math.max(1, Math.ceil(t / size));
const reqPage = Number.isFinite(page) ? Math.floor(page) : 1;
const current = Math.min(Math.max(reqPage, 1), pageCount);
return {
from: t === 0 ? 0 : (current - 1) * size + 1,
next: current < pageCount ? current + 1 : null,
page: current,
pageCount,
pageSize: size,
pages: pageItems(current, pageCount, options.siblings ?? 1, options.boundaries ?? 1),
prev: current > 1 ? current - 1 : null,
to: Math.min(current * size, t),
total: t,
};
}
const item = (page: number, current = false): PageItem => ({ current, ellipsis: false, page });
const gap = (): PageItem => ({ current: false, ellipsis: true, page: null });
// First/last `boundaries` pages + a `siblings`-wide window around current, deduped and sorted;
// gaps wider than one page become an ellipsis, a lone missing page is shown instead.
function pageItems(current: number, pageCount: number, siblings: number, boundaries: number): PageItem[] {
const show = new Set<number>();
const add = (n: number): void => { if (n >= 1 && n <= pageCount) show.add(n); };
for (let i = 1; i <= boundaries; i++) { add(i); add(pageCount - i + 1); }
for (let n = current - siblings; n <= current + siblings; n++) add(n);
const items: PageItem[] = [];
let prev = 0;
for (const n of [...show].sort((a, b) => a - b)) {
if (prev) {
if (n - prev === 2) items.push(item(prev + 1)); // single hole → show the page
else if (n - prev > 2) items.push(gap());
}
items.push(item(n, n === current));
prev = n;
}
return items;
}
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
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 flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const config = {
summary: { from: "1", to: 12, total: "1,284" },
rows: {
name: "rows",
value: 25, // active option
options: [12, 25, 50, 100],
hidden: [{ name: "q", value: "ada" }, { name: "status", value: "active" }], // list state carried forward
},
prev: {}, // first page → disabled (no href)
pages: [
{ label: "1", current: true },
{ label: "2", href: "?sort=name&page=2" }, // & must be escaped
{ label: "3", href: "?page=3" },
{ ellipsis: true },
{ label: "107", href: "?page=107" },
],
next: { href: "?page=2" },
};
test("pagination renders summary, rows-per-page form, page links, current, ellipsis and prev/next", async () => {
const html = flat(await render(config));
assert.match(html, /<footer class="pager"><span>112 of <b>1,284<\/b><\/span>/);
// Rows-per-page: GET form carrying list state, active option selected, zero-JS submit.
assert.match(html, /<form class="pager-rows" method="get"><input type="hidden" name="q" value="ada"><input type="hidden" name="status" value="active">/);
assert.match(html, /<label for="pager-rows">Rows<\/label><span class="select"><select id="pager-rows" name="rows">/);
assert.match(html, /<option value="12">12<\/option><option value="25" selected>25<\/option><option value="50">50<\/option><option value="100">100<\/option><\/select><\/span>/);
assert.match(html, /<button class="page-btn" type="submit">Go<\/button><\/form>/);
assert.match(html, /<div class="spacer"><\/div><nav class="page-nums" aria-label="Pagination">/);
// Prev disabled at the first page.
assert.match(html, /<button class="page-btn" type="button" disabled aria-label="Previous page"><svg class="ico ico-sm" style="transform:rotate\(180deg\)"><use href="#i-chev"\s*\/?><\/svg><\/button>/);
// Page items: current as inert span, links for the rest (& escaped), ellipsis hidden from SR.
assert.match(html, /<span class="page-btn" aria-current="page">1<\/span>/);
assert.match(html, /<a class="page-btn" href="\?sort=name&amp;page=2">2<\/a>/);
assert.match(html, /<a class="page-btn" href="\?page=3">3<\/a>/);
assert.match(html, /<span class="page-btn" aria-hidden="true">…<\/span>/);
assert.match(html, /<a class="page-btn" href="\?page=107">107<\/a>/);
// Next is a link when a target exists.
assert.match(html, /<a class="page-btn" href="\?page=2" aria-label="Next page"><svg class="ico ico-sm"><use href="#i-chev"\s*\/?><\/svg><\/a><\/nav><\/footer>/);
});
test("pagination renders a valid empty footer and never throws on missing config", async () => {
const expected = /<footer class="pager"><div class="spacer"><\/div><nav class="page-nums" aria-label="Pagination"><\/nav><\/footer>/;
assert.match(flat(await render()), expected);
assert.match(flat(await render({})), expected);
// Object options + custom labels; value coercion (number vs string) still selects.
const html = flat(await render({
rows: { name: "rows", value: "50", options: [{ value: 50, label: "50 / page" }], label: "Per page", submitLabel: "Set" },
}));
assert.match(html, /<label for="pager-rows">Per page<\/label>/);
assert.match(html, /<option value="50" selected>50 \/ page<\/option>/);
assert.match(html, /<button class="page-btn" type="submit">Set<\/button>/);
});
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildShellContext, shellUser } from "./shell-context.ts";
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
// Real user: name = email local part, email kept, initials = first two letters of the local part.
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", roles: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
});
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
const bare = buildShellContext({ menu: { branding: { name: "Plainpages" }, override: {} }, title: "Users" });
assert.deepEqual(bare.brand, { name: "Plainpages" }); // no logo/sub when unset
assert.equal(bare.theme, undefined);
assert.equal(bare.csrfToken, "");
assert.equal(bare.user.name, "Guest");
assert.equal(bare.signInHref, undefined); // omitted unless supplied (a public built-in screen would set it)
const full = buildShellContext({
breadcrumbs: [{ href: "/", label: "Home" }, { label: "Users" }],
csrfToken: "tok.sig",
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
signInHref: "/login?return_to=%2Fx",
title: "Users",
user: { email: "a@b.c", id: "u1", roles: ["admin"] },
});
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
assert.equal(full.theme, "dark");
assert.equal(full.csrfToken, "tok.sig");
assert.equal(full.breadcrumbs?.length, 2);
assert.equal(full.signInHref, "/login?return_to=%2Fx");
});
+51
View File
@@ -0,0 +1,51 @@
// Shell view-model builder: the brand/theme/user/title block every app-shell page
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
// shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is
// read from one place. The User carries no display name (the JWT holds only id/email/roles), so
// the profile shows the email's local part as the name with the full email beneath, initials from
// the local part; anonymous ⇒ "Guest".
import type { User } from "../http/context.ts";
import { type MenuConfig } from "./menu-config.ts";
export interface ShellUser {
email: string;
initials: string;
name: string;
}
export interface ShellModel {
brand: { logo?: string; name: string; sub?: string };
breadcrumbs?: { href?: string; label: string }[];
csrfToken: string;
signInHref?: string; // anonymous "Sign in" target (mirrors PageChrome.signInHref); a gated screen omits it
theme?: string;
title: string;
user: ShellUser;
}
export function shellUser(user: User | null | undefined): ShellUser {
if (!user) return { email: "", initials: "G", name: "Guest" };
const local = user.email.split("@")[0] || user.email;
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
}
export function buildShellContext(opts: {
breadcrumbs?: { href?: string; label: string }[];
csrfToken?: string;
menu: MenuConfig;
signInHref?: string;
title: string;
user?: User | null;
}): ShellModel {
const b = opts.menu.branding;
return {
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: 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),
};
}
+108
View File
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, data);
test("app shell renders sidebar, topbar and the content slot", async () => {
const html = await render({
title: "People",
brand: { name: "Acme Console", sub: "v2" },
csrfToken: "tok.sig",
user: { email: "ada@acme.io", initials: "AD", name: "ada" }, // a signed-in identity → profile + Sign out
nav: '<a id="nav-marker" href="/x">Overview</a>',
body: '<section id="body-marker">page</section>',
actions: '<button id="action-marker">Add</button>',
});
// Skip link is the first focusable element, targeting the main landmark.
assert.match(html, /<a class="skip-link" href="#main-content">Skip to content<\/a>/);
// Three structural landmarks of the shell; the page title is the page's <h1>.
assert.match(html, /<aside class="sidebar"/);
assert.match(html, /<header class="topbar"/);
assert.match(html, /<main class="content" id="main-content"/);
assert.match(html, /<h1 class="page-title">People<\/h1>/);
// Slots render their raw HTML where the page injects it.
assert.match(html, /<a id="nav-marker"/); // sidebar nav slot
assert.match(html, /<section id="body-marker">page<\/section>/); // content slot
assert.match(html, /<button id="action-marker"/); // topbar actions slot
// Sign out is a CSRF-guarded POST form (state change, not a GET link), carrying the token.
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
assert.match(html, /<input type="hidden" name="_csrf" value="tok\.sig" \/>/);
// Branding, document title, and the inlined icon sprite (so <use> resolves).
assert.match(html, /Acme Console/);
assert.match(html, /<title>People<\/title>/);
assert.match(html, /<symbol id="i-menu"/);
assert.match(html, /<use href="#i-menu"\s*\/?>/); // hamburger references the menu icon
});
test("app shell offers Sign in (not Sign out) to an anonymous visitor — so a public page in the shell works", async () => {
const html = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x" }); // no user, no signInHref → default
assert.match(html, /href="\/login"[^>]*>[\s\S]*?Sign in/); // a path to sign in (default target)
assert.doesNotMatch(html, /action="\/logout"/); // a guest has no session to end
// When chrome supplies signInHref (the current page as return_to), the link carries it.
const withReturn = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x", signInHref: "/login?return_to=%2Fscheduling" });
assert.match(withReturn, /href="\/login\?return_to=%2Fscheduling"[^>]*>[\s\S]*?Sign in/);
// hideSignIn (the auth pages): no footer Sign-in — a Sign-in on the login page only loops back.
const onAuth = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, nav: "", body: "x", hideSignIn: true });
assert.doesNotMatch(onAuth, />[\s\S]*?Sign in<\/a>/);
});
test("app shell renders a configured logo + default theme, falls back to the brand mark", async () => {
const branded = await render({ brand: { logo: "/public/brand/logo.svg", name: "Acme" }, theme: "dark" });
assert.match(branded, /<img class="brand-logo" src="\/public\/brand\/logo\.svg"/);
assert.doesNotMatch(branded, /brand-mark/); // a logo replaces the default mark
assert.match(branded, /id="theme-dark"\s+checked/); // default theme applied to the switch
const plain = await render({ brand: { name: "Acme" } }); // no logo, no theme
assert.match(plain, /<span class="brand-mark">/); // default mark
assert.match(plain, /id="theme-auto"\s+checked/); // theme-switch default
});
test("app shell links extra per-page stylesheets via the styles slot (e.g. a plugin's own CSS)", async () => {
const withCss = await render({ styles: ["/public/scheduling/scheduling.css"] });
assert.match(withCss, /<link rel="stylesheet" href="\/public\/css\/styles\.css" \/>/); // core stylesheet always present
assert.match(withCss, /<link rel="stylesheet" href="\/public\/scheduling\/scheduling\.css" \/>/); // the extra one
const none = await render(); // no styles → only the core stylesheet
assert.equal((none.match(/rel="stylesheet"/g) ?? []).length, 1);
});
test("app shell can disable the menu: no sidebar, focused single-column layout", async () => {
const bare = await render({ menu: false, title: "Focus", body: '<section id="b">x</section>', nav: '<a href="/x">Overview</a>' });
assert.doesNotMatch(bare, /<aside class="sidebar"/); // sidebar dropped
assert.doesNotMatch(bare, /class="hamburger"/); // and its mobile toggle
assert.match(bare, /<div class="app app-bare">/); // single-column variant
assert.match(bare, /<main class="content" id="main-content"/); // content still renders
assert.match(bare, /<section id="b">x<\/section>/);
});
test("app shell: an empty title yields no topbar <h1> so the body owns the single heading; docTitle sets <title>", async () => {
// Auth/landing pass title:"" (their card/hero is the <h1>) + an explicit docTitle for the tab.
const html = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, body: "<h1>Sign in</h1>" });
assert.doesNotMatch(html, /<h1 class="page-title"/); // topbar carries no heading
assert.match(html, /<title>Sign in<\/title>/); // explicit document title
assert.match(html, /<aside class="sidebar"/); // menu still shown (the whole point)
const fallback = await render({ title: "", brand: { name: "Acme" } }); // no docTitle → brand
assert.match(fallback, /<title>Acme<\/title>/);
});
test("app shell escapes text but passes slot HTML through, and renders with defaults", async () => {
const escaped = await render({ title: "<x>", body: "<p>raw</p>" });
assert.match(escaped, /<title>&lt;x&gt;<\/title>/); // user text is escaped
assert.match(escaped, /<p>raw<\/p>/); // slot HTML is not
const bare = await render(); // no locals → defaults, must not throw
assert.match(bare, /<aside class="sidebar"/);
assert.match(bare, /<main class="content"/);
});
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
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 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 () => {
// ids must be theme-light/auto/dark — styles.css keys html:has(#theme-…:checked) off them.
const html = flat(await render({ value: "dark", label: "Appearance" }));
assert.match(html, /<div class="theme-switch" role="radiogroup" aria-label="Appearance">/);
assert.match(html, /<label><input type="radio" name="theme" id="theme-light">\s*<span>Light<\/span><\/label>/);
assert.match(html, /<label><input type="radio" name="theme" id="theme-auto">\s*<span>Auto<\/span><\/label>/);
assert.match(html, /<label><input type="radio" name="theme" id="theme-dark" checked>\s*<span>Dark<\/span><\/label>/);
});
test("theme switch defaults to Auto checked and a default label", async () => {
const html = flat(await render());
assert.match(html, /aria-label="Color theme"/);
assert.match(html, /id="theme-auto" checked/);
assert.doesNotMatch(html, /id="theme-light" checked|id="theme-dark" checked/);
});