Retire shell-context and render the dashboard from ctx.chrome #188
@@ -1825,8 +1825,8 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
|
||||
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
|
||||
(ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
|
||||
storage-provisioning (the DDL; bootstrap-only, holds the driver)
|
||||
ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) ·
|
||||
menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
||||
ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config
|
||||
(`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
||||
|
||||
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
|
||||
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
|
||||
|
||||
@@ -72,3 +72,20 @@ Read most (opens and searches):
|
||||
- `CHANGELOG.md` 2
|
||||
- `AGENTS.md` 1
|
||||
- `src/auth/jwt-middleware.ts` 1
|
||||
|
||||
## Reading cost, chunk 9e8ffd0aa8dc, finished 2026-10-31T07:36:53Z
|
||||
|
||||
98975 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `README.md` 9
|
||||
- `ci.sh` 3
|
||||
- `e2e-tests/visual.spec.ts` 3
|
||||
- `todo.md` 3
|
||||
- `CHANGELOG.md` 2
|
||||
- `e2e-tests/compose.visual.yml` 2
|
||||
- `e2e-tests/language.spec.ts` 2
|
||||
- `e2e-tests/mock-oidc.ts` 2
|
||||
- `src/ci-gate.test.ts` 2
|
||||
- `.dockerignore` 1
|
||||
|
||||
@@ -65,6 +65,8 @@ test("the dashboard at /dashboard: the instructional starter in the unified shel
|
||||
assert.match(html, /<aside class="sidebar"/);
|
||||
// The default is a short instructional starter, not a mock-data list.
|
||||
assert.match(html, /Starter dashboard/);
|
||||
assert.match(html, /<h1 class="page-title">Dashboard<\/h1>/); // the topbar heading …
|
||||
assert.match(html, /<nav class="crumbs"[^>]*>\s*<span>Dashboard<\/span>/); // … and its breadcrumb
|
||||
assert.match(html, /export default definePlugin/); // shows how to replace it from a plugin
|
||||
assert.doesNotMatch(html, /<form class="filters"/); // the old mock People list is gone
|
||||
assert.doesNotMatch(html, /Avery Kline/);
|
||||
|
||||
+1
-2
@@ -8,7 +8,6 @@ import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
||||
import type { Denylist } from "../auth/denylist.ts";
|
||||
import { buildDashboardModel } from "../ui/dashboard.ts";
|
||||
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
|
||||
import { GuardError, loginRedirect } from "../auth/guards.ts";
|
||||
import { ENGLISH_I18N } from "../i18n/english.ts";
|
||||
@@ -168,7 +167,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
|
||||
return null;
|
||||
}
|
||||
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav, t: ctx.t }) }, view: "index" };
|
||||
return { data: { chrome: ctx.chrome }, view: "index" };
|
||||
};
|
||||
|
||||
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
|
||||
|
||||
+14
-1
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildPluginChrome } from "./chrome.ts";
|
||||
import { buildPluginChrome, shellUser } 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";
|
||||
@@ -74,3 +74,16 @@ test("branding logo + default theme flow through when set", () => {
|
||||
assert.equal(chrome.brand.name, "Acme");
|
||||
assert.equal(chrome.theme, "dark");
|
||||
});
|
||||
|
||||
test("branding name and sub are translated; an unknown key renders as itself", () => {
|
||||
const t = (key: string): string => (key === "brand.sub" ? "Undertitel" : key);
|
||||
const chrome = buildPluginChrome({ menu: { branding: { name: "Acme", sub: "brand.sub" }, override: {} }, t });
|
||||
assert.equal(chrome.brand.name, "Acme");
|
||||
assert.equal(chrome.brand.sub, "Undertitel");
|
||||
assert.equal(buildPluginChrome({ menu: { branding: { name: "Acme" }, override: {} } }).brand.sub, undefined);
|
||||
});
|
||||
|
||||
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
|
||||
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
|
||||
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
||||
});
|
||||
|
||||
+26
-1
@@ -8,10 +8,15 @@ import type { Translate } from "../i18n/translate.ts";
|
||||
import { type MenuConfig } from "./menu-config.ts";
|
||||
import { composeNav, type NavNode } from "./nav.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
||||
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true };
|
||||
|
||||
export interface ShellUser {
|
||||
email: string;
|
||||
initials: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -65,6 +70,26 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
};
|
||||
}
|
||||
|
||||
// The JWT holds only id/email/permissions, so the profile name is the email's local part.
|
||||
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
|
||||
if (!user) {
|
||||
const guest = t("shell.guest");
|
||||
return { email: "", initials: ([...guest][0] ?? "?").toUpperCase(), name: guest }; // by character: the word is translated
|
||||
}
|
||||
const local = user.email.split("@")[0] || user.email;
|
||||
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
|
||||
}
|
||||
|
||||
function branding(menu: MenuConfig, t: Translate): { logo?: string; name: string; sub?: string; theme?: string } {
|
||||
const b = menu.branding;
|
||||
return {
|
||||
...(b.logo != null ? { logo: b.logo } : {}),
|
||||
name: t(b.name),
|
||||
...(b.sub != null ? { sub: t(b.sub) } : {}),
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function translateNav(nodes: NavNode[], t: Translate): NavNode[] {
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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", user: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
|
||||
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");
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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 { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
import { buildShellContext } from "./shell-context.ts";
|
||||
|
||||
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; t?: Translate; user?: User | null } = {}) {
|
||||
const t = opts.t ?? ENGLISH;
|
||||
return {
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ label: t("dashboard.title") }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu: opts.menu ?? DEFAULT_MENU,
|
||||
t,
|
||||
title: t("dashboard.title"),
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
|
||||
@@ -1,32 +0,0 @@
|
||||
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", permissions: [] }), { 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", permissions: ["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");
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
// 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/permissions), 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 { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
import { type MenuConfig } from "./menu-config.ts";
|
||||
|
||||
// The brand block both the chrome and the shell model carry. `name`/`sub` pass through `t`, so a
|
||||
// catalog key is translated and an operator's own wording renders as written.
|
||||
export function branding(menu: MenuConfig, t: Translate): { logo?: string; name: string; sub?: string; theme?: string } {
|
||||
const b = menu.branding;
|
||||
return {
|
||||
...(b.logo != null ? { logo: b.logo } : {}),
|
||||
name: t(b.name),
|
||||
...(b.sub != null ? { sub: t(b.sub) } : {}),
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ShellUser {
|
||||
email: string;
|
||||
initials: string;
|
||||
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, t: Translate = ENGLISH): ShellUser {
|
||||
if (!user) {
|
||||
const guest = t("shell.guest");
|
||||
return { email: "", initials: ([...guest][0] ?? "?").toUpperCase(), name: guest }; // by character: the word is translated
|
||||
}
|
||||
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;
|
||||
t?: Translate;
|
||||
title: string;
|
||||
user?: User | null;
|
||||
}): ShellModel {
|
||||
const t = opts.t ?? ENGLISH;
|
||||
const { theme, ...brand } = branding(opts.menu, t);
|
||||
return {
|
||||
brand,
|
||||
...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}),
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
|
||||
...(theme != null ? { theme } : {}),
|
||||
title: opts.title,
|
||||
user: shellUser(opts.user, t),
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 24 | 0.4.21 | | | **Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.** | 2 | 1 | 2 | 3 | 4 | code health by 3 | 9.0 |
|
||||
| 21 | 0.4.21 | | | **Fail the gate on a function that exceeds a size or complexity limit.** | 3 | 1 | 1 | 3 | 5 | code health by 4 | 7.2 |
|
||||
| 32 | 0.4.21 | | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 3 | 1 | 1 | 3 | 4 | code health by 2 | 6.0 |
|
||||
| 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 |
|
||||
@@ -34,11 +33,6 @@
|
||||
|
||||
## Details
|
||||
|
||||
### 24. Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.
|
||||
|
||||
`ShellModel`/`buildShellContext` has one consumer, the dashboard, and duplicates `PageChrome` on
|
||||
almost every field. Keep `shellUser` as the shared primitive.
|
||||
|
||||
### 21. Fail the gate on a function that exceeds a size or complexity limit.
|
||||
|
||||
Only `tsc --strict` runs today. A new tool is a new dependency, so justify it.
|
||||
|
||||
+9
-12
@@ -1,10 +1,8 @@
|
||||
<%#
|
||||
Dashboard: the gated post-login app home. The built-in default is a short instructional
|
||||
starter — what /dashboard is and how to replace it from a plugin. Composes the app shell (the one
|
||||
global menu via model.nav) around the prose. A plugin owns the real dashboard via a `dashboard`
|
||||
handler; this renders until then. Data: model { nav, shell }.
|
||||
The built-in /dashboard starter, shown until a plugin's `dashboard` handler replaces it.
|
||||
Data: chrome (PageChrome).
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
// These four messages carry markup (<code> spans), so they render raw — the documented
|
||||
// markup-carrying case. They interpolate nothing, so there is no untrusted data to escape.
|
||||
const body = `
|
||||
@@ -27,12 +25,11 @@
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
brand: model.shell.brand,
|
||||
breadcrumbs: model.shell.breadcrumbs,
|
||||
csrfToken: model.shell.csrfToken,
|
||||
brand: chrome.brand,
|
||||
breadcrumbs: [{ label: t("dashboard.title") }],
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav,
|
||||
signInHref: model.shell.signInHref,
|
||||
theme: model.shell.theme,
|
||||
title: model.shell.title,
|
||||
user: model.shell.user,
|
||||
theme: chrome.theme,
|
||||
title: t("dashboard.title"),
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
|
||||
Reference in New Issue
Block a user