Add config/menu.ts central override + branding (todo §2); loadMenuConfig validates+merges, override applied to nav, branding into shell

This commit is contained in:
2026-06-16 15:52:03 +02:00
parent 3cdefff233
commit 952dd03cc2
11 changed files with 209 additions and 21 deletions

View File

@@ -5,6 +5,7 @@ import * as ejs from "ejs";
import { buildContext } from "./context.ts";
import { buildDashboardModel } from "./dashboard.ts";
import { PLUGINS_DIR } from "./discovery.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
import { routePublic, serveStatic } from "./static.ts";
@@ -16,6 +17,7 @@ export interface AppOptions {
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
// Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean;
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
publicDir?: string;
@@ -24,6 +26,7 @@ export interface AppOptions {
export function createApp(options: AppOptions = {}): Server {
const cache = options.cache ?? false;
const menu = options.menu ?? DEFAULT_MENU;
const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id));
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
@@ -69,8 +72,8 @@ export function createApp(options: AppOptions = {}): Server {
}
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// Mock data + no roles until auth (§4) lands.
sendHtml(res, 200, await render("index", { model: buildDashboardModel(url) }));
// Mock data + no roles until auth (§4) lands; branding/override come from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel(url, [], menu) }));
return;
}

View File

@@ -57,6 +57,18 @@ test("dashboard sorts by a column, reflects direction, and the header toggles",
assert.equal(col0(bad).sort, undefined);
});
test("dashboard applies the central menu config: branding + nav override (rename/hide)", () => {
const m = buildDashboardModel(new URL("http://x/"), [], {
branding: { name: "Acme Ops", sub: "Admin" },
override: { hide: ["teams"], rename: { people: "Staff" } },
});
assert.deepEqual(m.shell.brand, { name: "Acme Ops", sub: "Admin" });
const labels = m.nav.map((n) => n.label);
assert.ok(labels.includes("Staff")); // "People" renamed
assert.ok(!labels.includes("Teams")); // "Teams" hidden
});
test("dashboard paginates: page 2 slices the next rows and preserves state in links", () => {
const p2 = buildDashboardModel(new URL("http://x/?sort=-name&page=2"));
assert.equal(p2.pagination.summary.from, 13); // 30 rows / 12 per page → page 2 starts at 13

View File

@@ -4,7 +4,8 @@
// composeNav. The dataset stands in for upstream data until plugins/§4 land; everything below
// is real, so the filter form, sortable headers and pager round-trip through the URL (zero-JS).
import { composeNav, type NavNode } from "./nav.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode, type NavOverride } from "./nav.ts";
import { parseListQuery } from "./list-query.ts";
import { paginate } from "./paginate.ts";
@@ -77,7 +78,7 @@ function href(state: State, overrides: Partial<State> = {}): string {
return qs ? `?${qs}` : "?";
}
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = []) {
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU) {
const query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const status = query.filters.status?.[0] ?? "all";
const team = query.filters.team?.[0] ?? "";
@@ -102,10 +103,10 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
return {
filterBar: filterBar(state),
nav: nav(roles),
nav: nav(roles, menu.override),
pagination: pagination(state, page),
shell: {
brand: { name: "Plainpages", sub: "Console" },
brand: { name: menu.branding.name, ...(menu.branding.sub != null ? { sub: menu.branding.sub } : {}) },
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
title: "People",
user: { email: "sam.rivers@example.com", initials: "SR", name: "Sam Rivers" }, // demo until §4
@@ -116,7 +117,7 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
function nav(roles: string[]): NavNode[] {
function nav(roles: string[], override: NavOverride): NavNode[] {
return composeNav([[
{ count: PEOPLE.length, current: true, href: "/", icon: "i-users", id: "people", label: "People" },
{ href: "#teams", icon: "i-grid", id: "teams", label: "Teams" },
@@ -125,7 +126,7 @@ function nav(roles: string[]): NavNode[] {
{ href: "#exports", id: "exports", label: "Exports" },
], icon: "i-chart", id: "reports", label: "Reports", open: true },
{ href: "#settings", icon: "i-gear", id: "settings", label: "Settings", permission: "admin" },
]], {}, roles);
]], override, roles);
}
function table(rows: Person[], state: State, sort: { dir: "asc" | "desc"; field: string } | null) {

39
src/menu-config.test.ts Normal file
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
src/menu-config.ts Normal file
View File

@@ -0,0 +1,105 @@
// Central menu config (todo §2): 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 shell — next §2 branding item)
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");
}

View File

@@ -1,13 +1,15 @@
import { createApp } from "./app.ts";
import { loadConfig } from "./config.ts";
import { discoverPlugins } from "./discovery.ts";
import { loadMenuConfig } from "./menu-config.ts";
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
const server = createApp({ cache: config.cacheTemplates, plugins }).listen(config.port, () => {
const server = createApp({ cache: config.cacheTemplates, menu, plugins }).listen(config.port, () => {
console.log(`Listening on http://localhost:${config.port}`);
});