Add config/menu.ts central override + branding (todo §2); loadMenuConfig validates+merges, override applied to nav, branding into shell
This commit is contained in:
14
README.md
14
README.md
@@ -258,21 +258,22 @@ and reproducible; mount a volume only to add plugins to an already-built image.
|
|||||||
> at `/public/<id>/` (`src/static.ts`). The mount mechanics above are how the files get into the
|
> at `/public/<id>/` (`src/static.ts`). The mount mechanics above are how the files get into the
|
||||||
> container either way.
|
> container either way.
|
||||||
|
|
||||||
## The menu system _(planned)_
|
## The menu system
|
||||||
|
|
||||||
The menu is **driven entirely by config** and assembled from two sources:
|
The menu is **driven entirely by config** and assembled from two sources:
|
||||||
|
|
||||||
1. **Plugin fragments** — each plugin contributes its own `nav` (above).
|
1. **Plugin fragments** — each plugin contributes its own `nav` (above).
|
||||||
2. **A central override** — `config/menu.ts` — where the operator reorders,
|
2. **A central override** — `config/menu.ts` (loaded by `src/menu-config.ts`, validated at boot)
|
||||||
renames, groups, or hides items, and sets branding (app name, logo, default
|
— where the operator reorders, renames, groups, or hides items (by node `id`), and sets
|
||||||
theme). The override always wins.
|
branding (app name, logo, default theme). The override always wins, applied before the
|
||||||
|
per-user filter. A clean clone needs no `config/menu.ts`; defaults apply.
|
||||||
|
|
||||||
Every nav item may carry a `permission`; the rendered tree is **filtered per
|
Every nav item may carry a `permission`; the rendered tree is **filtered per
|
||||||
user** by reading the roles in the session JWT (no per-request authz call — see
|
user** by reading the roles in the session JWT (no per-request authz call — see
|
||||||
[Auth, sessions & permissions](#auth-sessions--permissions-planned)), so the menu
|
[Auth, sessions & permissions](#auth-sessions--permissions-planned)), so the menu
|
||||||
only ever shows what that person can reach. The markup is the recursive, zero-JS
|
only ever shows what that person can reach. The markup is the recursive, zero-JS
|
||||||
nav tree from the design foundation (header/leaf × clickable/static, counts,
|
nav tree from the design foundation (header/leaf × clickable/static, counts,
|
||||||
arbitrary depth).
|
arbitrary depth). _(Branding values are wired into the app shell — logo + default theme — next.)_
|
||||||
|
|
||||||
## Building blocks _(partly designed, planned to extract)_
|
## Building blocks _(partly designed, planned to extract)_
|
||||||
|
|
||||||
@@ -438,9 +439,10 @@ src/plugin.ts Plugin contract: manifest types, definePlugin(), version +
|
|||||||
src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2)
|
src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2)
|
||||||
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
|
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
|
||||||
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
|
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
|
||||||
|
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
|
||||||
views/ Core EJS templates (index = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite)
|
views/ Core EJS templates (index = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite)
|
||||||
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
||||||
config/menu.ts Central menu override + branding (planned)
|
config/menu.ts Central menu override + branding (optional; defaults apply if absent)
|
||||||
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in) (planned)
|
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in) (planned)
|
||||||
docs/ Reference docs (plugin-contract.md — the authoritative plugin API)
|
docs/ Reference docs (plugin-contract.md — the authoritative plugin API)
|
||||||
e2e/ Playwright visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it)
|
e2e/ Playwright visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it)
|
||||||
|
|||||||
23
config/menu.ts
Normal file
23
config/menu.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Central menu override + branding (todo §2). Brand the app and reorder/rename/group/hide nav
|
||||||
|
// nodes (by their `id`) across all plugins — the override always wins, applied before the
|
||||||
|
// per-user permission filter. Every field is optional; delete one to fall back to the default.
|
||||||
|
// See src/menu-config.ts (types), src/nav.ts (NavOverride), docs/plugin-contract.md.
|
||||||
|
|
||||||
|
import { defineMenu } from "../src/menu-config.ts";
|
||||||
|
|
||||||
|
export default defineMenu({
|
||||||
|
branding: {
|
||||||
|
name: "Plainpages", // app name shown in the sidebar
|
||||||
|
sub: "Console", // optional subtitle under the name
|
||||||
|
// logo: "/public/logo.svg", // optional logo asset (rendered in the shell — next §2 item)
|
||||||
|
// theme: "auto", // default color theme: auto | light | dark
|
||||||
|
},
|
||||||
|
|
||||||
|
// Operator override (rename → group → order → hide), keyed by node id.
|
||||||
|
override: {
|
||||||
|
// rename: { people: "Staff" }, // node id → new label
|
||||||
|
// groups: [{ id: "admin", label: "Admin", children: ["users", "roles"] }],
|
||||||
|
// order: ["people", "reports"], // top-level order by id
|
||||||
|
// hide: ["teams"], // remove nodes (any depth)
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -18,9 +18,10 @@ time, not in production.
|
|||||||
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params,
|
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params,
|
||||||
> permission gate, `RouteResult` → response), and the **per-plugin view resolver**
|
> permission gate, `RouteResult` → response), and the **per-plugin view resolver**
|
||||||
> (`src/view-resolver.ts` — a `view` result renders `plugins/<id>/views/`, with the core partials
|
> (`src/view-resolver.ts` — a `view` result renders `plugins/<id>/views/`, with the core partials
|
||||||
> reachable via `include()`), and **per-plugin static serving** (`/public/<id>/` → the plugin's
|
> reachable via `include()`), **per-plugin static serving** (`/public/<id>/` → the plugin's
|
||||||
> `public/`, `routePublic` in `src/static.ts`) are wired. The central menu override + branding
|
> `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding**
|
||||||
> (`config/menu.ts`) is the next §2 item.
|
> (`config/menu.ts`, loaded by `src/menu-config.ts`) are wired. Rendering branding (logo, default
|
||||||
|
> theme) into the app shell is the next §2 item.
|
||||||
|
|
||||||
## Anatomy of a plugin
|
## Anatomy of a plugin
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ convention) its nav/permission tokens.
|
|||||||
|
|
||||||
Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart."
|
Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart."
|
||||||
Nothing else references it; the operator stays in control through the central menu override
|
Nothing else references it; the operator stays in control through the central menu override
|
||||||
(`config/menu.ts`, §2).
|
(`config/menu.ts`).
|
||||||
|
|
||||||
## The manifest
|
## The manifest
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as ejs from "ejs";
|
|||||||
import { buildContext } from "./context.ts";
|
import { buildContext } from "./context.ts";
|
||||||
import { buildDashboardModel } from "./dashboard.ts";
|
import { buildDashboardModel } from "./dashboard.ts";
|
||||||
import { PLUGINS_DIR } from "./discovery.ts";
|
import { PLUGINS_DIR } from "./discovery.ts";
|
||||||
|
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||||
import type { Plugin, RouteResult } from "./plugin.ts";
|
import type { Plugin, RouteResult } from "./plugin.ts";
|
||||||
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
||||||
import { routePublic, serveStatic } from "./static.ts";
|
import { routePublic, serveStatic } from "./static.ts";
|
||||||
@@ -16,6 +17,7 @@ export interface AppOptions {
|
|||||||
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
||||||
// Off by default so edits show live; the app itself never inspects the environment.
|
// Off by default so edits show live; the app itself never inspects the environment.
|
||||||
cache?: boolean;
|
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
|
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
|
||||||
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
||||||
publicDir?: string;
|
publicDir?: string;
|
||||||
@@ -24,6 +26,7 @@ export interface AppOptions {
|
|||||||
|
|
||||||
export function createApp(options: AppOptions = {}): Server {
|
export function createApp(options: AppOptions = {}): Server {
|
||||||
const cache = options.cache ?? false;
|
const cache = options.cache ?? false;
|
||||||
|
const menu = options.menu ?? DEFAULT_MENU;
|
||||||
const plugins = options.plugins ?? [];
|
const plugins = options.plugins ?? [];
|
||||||
const pluginIds = new Set(plugins.map((p) => p.id));
|
const pluginIds = new Set(plugins.map((p) => p.id));
|
||||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||||
@@ -69,8 +72,8 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
||||||
// Mock data + no roles until auth (§4) lands.
|
// Mock data + no roles until auth (§4) lands; branding/override come from config/menu.ts.
|
||||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel(url) }));
|
sendHtml(res, 200, await render("index", { model: buildDashboardModel(url, [], menu) }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ test("dashboard sorts by a column, reflects direction, and the header toggles",
|
|||||||
assert.equal(col0(bad).sort, undefined);
|
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", () => {
|
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"));
|
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
|
assert.equal(p2.pagination.summary.from, 13); // 30 rows / 12 per page → page 2 starts at 13
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
// composeNav. The dataset stands in for upstream data until plugins/§4 land; everything below
|
// 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).
|
// 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 { parseListQuery } from "./list-query.ts";
|
||||||
import { paginate } from "./paginate.ts";
|
import { paginate } from "./paginate.ts";
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ function href(state: State, overrides: Partial<State> = {}): string {
|
|||||||
return qs ? `?${qs}` : "?";
|
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 query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const status = query.filters.status?.[0] ?? "all";
|
const status = query.filters.status?.[0] ?? "all";
|
||||||
const team = query.filters.team?.[0] ?? "";
|
const team = query.filters.team?.[0] ?? "";
|
||||||
@@ -102,10 +103,10 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
filterBar: filterBar(state),
|
filterBar: filterBar(state),
|
||||||
nav: nav(roles),
|
nav: nav(roles, menu.override),
|
||||||
pagination: pagination(state, page),
|
pagination: pagination(state, page),
|
||||||
shell: {
|
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" }],
|
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
|
||||||
title: "People",
|
title: "People",
|
||||||
user: { email: "sam.rivers@example.com", initials: "SR", name: "Sam Rivers" }, // demo until §4
|
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>;
|
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
|
||||||
|
|
||||||
function nav(roles: string[]): NavNode[] {
|
function nav(roles: string[], override: NavOverride): NavNode[] {
|
||||||
return composeNav([[
|
return composeNav([[
|
||||||
{ count: PEOPLE.length, current: true, href: "/", icon: "i-users", id: "people", label: "People" },
|
{ count: PEOPLE.length, current: true, href: "/", icon: "i-users", id: "people", label: "People" },
|
||||||
{ href: "#teams", icon: "i-grid", id: "teams", label: "Teams" },
|
{ href: "#teams", icon: "i-grid", id: "teams", label: "Teams" },
|
||||||
@@ -125,7 +126,7 @@ function nav(roles: string[]): NavNode[] {
|
|||||||
{ href: "#exports", id: "exports", label: "Exports" },
|
{ href: "#exports", id: "exports", label: "Exports" },
|
||||||
], icon: "i-chart", id: "reports", label: "Reports", open: true },
|
], icon: "i-chart", id: "reports", label: "Reports", open: true },
|
||||||
{ href: "#settings", icon: "i-gear", id: "settings", label: "Settings", permission: "admin" },
|
{ 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) {
|
function table(rows: Person[], state: State, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||||
|
|||||||
39
src/menu-config.test.ts
Normal file
39
src/menu-config.test.ts
Normal 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
105
src/menu-config.ts
Normal 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");
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { createApp } from "./app.ts";
|
import { createApp } from "./app.ts";
|
||||||
import { loadConfig } from "./config.ts";
|
import { loadConfig } from "./config.ts";
|
||||||
import { discoverPlugins } from "./discovery.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 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
|
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(", ")}` : ""}`);
|
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}`);
|
console.log(`Listening on http://localhost:${config.port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -50,7 +50,7 @@ everything via Docker.
|
|||||||
- [x] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context. → `src/router.ts`: the pure core (`matchRoute`/`allowedMethods`/`isAuthorized`), wired by `app.ts` (the imperative shell). A route mounts at `/<id>` + its path via the now-exported `fullPath` (shared with `findConflicts`, so they can't drift); `:name` segments → `ctx.params.name` (percent-decoded, malformed ⇒ no match). Specificity: a literal segment beats a `:param` (`/users/new` wins over `/users/:id` regardless of declaration order), ties keep discovery order. HEAD answers a GET route; known-path/wrong-method ⇒ 405 + `Allow`. `isAuthorized` = composeNav's gate (no `permission` ⇒ open, else `roles` must include it); fail-closed today since auth (§4) supplies no user yet (gated ⇒ 403). `app.ts` builds the context, gates, calls the handler, and maps `RouteResult` → response (`sendResult`: html/json/redirect/view/void; author headers override; the void escape hatch lets a handler own `ctx.res`); `view` renders the plugin's own `views/<view>.ejs` (the richer resolver — core-partial includes, subfolders — is the next §2 item). Dropped the global non-GET/HEAD 405 (plugins bring other methods). Wired into `server.ts` (`createApp({ plugins })`). Tests-first: `router.test.ts` (match/params/specificity/HEAD/methods/gate) + an `app.test.ts` integration mounting a demo plugin (every RouteResult shape + 403/405/404); typecheck + 98 units green.
|
- [x] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context. → `src/router.ts`: the pure core (`matchRoute`/`allowedMethods`/`isAuthorized`), wired by `app.ts` (the imperative shell). A route mounts at `/<id>` + its path via the now-exported `fullPath` (shared with `findConflicts`, so they can't drift); `:name` segments → `ctx.params.name` (percent-decoded, malformed ⇒ no match). Specificity: a literal segment beats a `:param` (`/users/new` wins over `/users/:id` regardless of declaration order), ties keep discovery order. HEAD answers a GET route; known-path/wrong-method ⇒ 405 + `Allow`. `isAuthorized` = composeNav's gate (no `permission` ⇒ open, else `roles` must include it); fail-closed today since auth (§4) supplies no user yet (gated ⇒ 403). `app.ts` builds the context, gates, calls the handler, and maps `RouteResult` → response (`sendResult`: html/json/redirect/view/void; author headers override; the void escape hatch lets a handler own `ctx.res`); `view` renders the plugin's own `views/<view>.ejs` (the richer resolver — core-partial includes, subfolders — is the next §2 item). Dropped the global non-GET/HEAD 405 (plugins bring other methods). Wired into `server.ts` (`createApp({ plugins })`). Tests-first: `router.test.ts` (match/params/specificity/HEAD/methods/gate) + an `app.test.ts` integration mounting a demo plugin (every RouteResult shape + 403/405/404); typecheck + 98 units green.
|
||||||
- [x] Per-plugin view resolver (`plugins/<id>/views/*.ejs`) and also all possible partials for ejs in the views folder and sub folderes. → `src/view-resolver.ts` (`renderPluginView`/`resolveViewPath`), wired into `app.ts` for a `view` RouteResult (replaces the router's minimal stub). `resolveViewPath` (pure) maps a view name → `plugins/<id>/views/<view>.ejs`, supports nested names (`shifts/edit`), defaults the `.ejs` extension, and refuses traversal/control-char names (same guard as `static.ts`). Rendering passes EJS `views: [<plugin>/views, coreViewsDir]`: EJS resolves an `include()` relative to the current file first, then those roots — so a plugin view reaches **every core building-block partial** (shell, nav-tree, data-table, …) *and* its own partials/subfolders, plugin-root first so it can deliberately shadow a core partial. Out-of-bounds name ⇒ reject (fail loud). Tests-first: `view-resolver.test.ts` (resolve/nest/extension/traversal/control-char + a nested view that includes both a core partial and its own) + the `app.test.ts` plugin integration now asserts the live `view` page includes `partials/theme-switch`; typecheck + 102 units green. Per-plugin static serving is the next §2 item.
|
- [x] Per-plugin view resolver (`plugins/<id>/views/*.ejs`) and also all possible partials for ejs in the views folder and sub folderes. → `src/view-resolver.ts` (`renderPluginView`/`resolveViewPath`), wired into `app.ts` for a `view` RouteResult (replaces the router's minimal stub). `resolveViewPath` (pure) maps a view name → `plugins/<id>/views/<view>.ejs`, supports nested names (`shifts/edit`), defaults the `.ejs` extension, and refuses traversal/control-char names (same guard as `static.ts`). Rendering passes EJS `views: [<plugin>/views, coreViewsDir]`: EJS resolves an `include()` relative to the current file first, then those roots — so a plugin view reaches **every core building-block partial** (shell, nav-tree, data-table, …) *and* its own partials/subfolders, plugin-root first so it can deliberately shadow a core partial. Out-of-bounds name ⇒ reject (fail loud). Tests-first: `view-resolver.test.ts` (resolve/nest/extension/traversal/control-char + a nested view that includes both a core partial and its own) + the `app.test.ts` plugin integration now asserts the live `view` page includes `partials/theme-switch`; typecheck + 102 units green. Per-plugin static serving is the next §2 item.
|
||||||
- [x] Per-plugin static serving: `plugins/<id>/public/` → `/public/<id>/`. → `routePublic` (pure, in `src/static.ts`), wired into `app.ts`'s existing `/public/` branch. A request `/public/<rest>` whose leading segment names a discovered plugin serves from `plugins/<id>/public/<rest>`; anything else (e.g. `css/styles.css`) stays on the core `public/`. Disambiguates by the discovered plugin-id set, so only mounted plugins expose assets and core paths are unaffected; plugin ids are URL-safe so the raw segment compares directly (no decode needed). Reuses `serveStatic` unchanged, so the sub-path keeps its decode + traversal/control-char guard (encoded `..` ⇒ 403) and HEAD support; a missing `public/` or file ⇒ 404. Tests-first: a `routePublic` unit (plugin/core split, nested asset, bare `/public/<id>`) + the `app.test.ts` plugin integration now serves a real `demo/public/app.css` (200 + `text/css`) and still 403s a traversal; typecheck + 103 units green. `config/menu.ts` central override is the next §2 item.
|
- [x] Per-plugin static serving: `plugins/<id>/public/` → `/public/<id>/`. → `routePublic` (pure, in `src/static.ts`), wired into `app.ts`'s existing `/public/` branch. A request `/public/<rest>` whose leading segment names a discovered plugin serves from `plugins/<id>/public/<rest>`; anything else (e.g. `css/styles.css`) stays on the core `public/`. Disambiguates by the discovered plugin-id set, so only mounted plugins expose assets and core paths are unaffected; plugin ids are URL-safe so the raw segment compares directly (no decode needed). Reuses `serveStatic` unchanged, so the sub-path keeps its decode + traversal/control-char guard (encoded `..` ⇒ 403) and HEAD support; a missing `public/` or file ⇒ 404. Tests-first: a `routePublic` unit (plugin/core split, nested asset, bare `/public/<id>`) + the `app.test.ts` plugin integration now serves a real `demo/public/app.css` (200 + `text/css`) and still 403s a traversal; typecheck + 103 units green. `config/menu.ts` central override is the next §2 item.
|
||||||
- [ ] `config/menu.ts` central override: reorder/rename/hide/group + branding (app name, logo, default theme).
|
- [x] `config/menu.ts` central override: reorder/rename/hide/group + branding (app name, logo, default theme). → `src/menu-config.ts` (`MenuConfig`/`Branding`/`MenuConfigInput`, `defineMenu()` identity helper, `DEFAULT_MENU`, `loadMenuConfig()`) + the operator file `config/menu.ts`. The override is `composeNav`'s existing `NavOverride` (reorder/rename/group/hide by node id, applied before the per-user filter); branding = `{ name, logo?, sub?, theme? }`. `loadMenuConfig` (imperative shell) dynamically imports `config/menu.ts` if present, validates the authored shape fail-loud (branding field types + `theme` enum, override `hide`/`order` string-arrays / `groups` array / `rename` object), merges branding over defaults; **absent file ⇒ `DEFAULT_MENU`** (clean clone). Wired: `server.ts` loads it at boot → `createApp({ menu })` → `buildDashboardModel(url, roles, menu)` feeds `menu.override` into `composeNav` and `menu.branding` (name/sub) into the shell brand. `config/menu.ts` ships defaults matching prior behaviour (name "Plainpages"/sub "Console", empty override), so a clean clone is unchanged. Added `config` to tsconfig `include` so the authored file is type-checked (Dockerfile `COPY . .` already bakes it). Tests-first: `menu-config.test.ts` (absent⇒defaults / read+merge / malformed⇒throws) + a `dashboard.test.ts` case asserting rename+hide+branding take effect; typecheck (incl. `config/`) + 107 units green; smoke-loaded the real file at boot. **Rendering branding (logo, default theme) into the app shell is the next §2 item.**
|
||||||
- [ ] Wire branding into the app shell.
|
- [ ] Wire branding into the app shell.
|
||||||
- [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
- [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
||||||
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
||||||
|
|||||||
@@ -24,5 +24,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["config", "src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user