Files
Ekklesia/surfaces/shell/src/module-navigation.ts
T
ekklesiaandClaude Opus 5.5 4671622b9d Add the module navigation contract: manifest v9, module routes, return-to-origin
Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard.

- Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated
  MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe
  label, kebab-case path relative to the module root; no traversal, schemes,
  queries, duplicates, or nesting). Validated before activation.
- Version 8 stays accepted beside version 9, each read strictly as itself
  (discriminated union); no released manifest is rewritten. The template
  moves to v9; the released Signage 0.1.5 manifest is a test fixture.
- Real module routes: /<id> is the landing view, /<id>/<page> a declared
  page, anything beneath a page a module-owned detail route; anything else
  is not-found and mounts nothing. api/assets/display are never pages.
- Sidebar: declared icons, pages nested under an open module, one
  aria-current, collapsed and drawer behaviour; v8 modules unchanged.
- module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe,
  navigate, link, settings), refusing anything outside the module's routes;
  undefined on the display host. Mount ABI unchanged.
- Return-to-origin: one `?return=` carrier on Settings URLs, validated by
  parseReturnTarget and against running modules' declared routes; Settings
  Back returns to exactly the originating module page, across refreshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:21:50 -05:00

100 lines
4.0 KiB
TypeScript

import { type DashboardDestination, parseModuleRoute } from "./dashboard-routes.js";
/**
* Resolving a module's own routes (ADR 0063), free of React and browser globals.
*
* A module declares its primary pages; the shell opens:
*
* - its **root**, `/<module-id>`, which is its landing view and never itself a declared page, so no
* page has two addresses;
* - each **declared page**, `/<module-id>/<page-path>`;
* - any route **beneath** a declared page, `/<module-id>/<page-path>/…`, which the module owns —
* detail views (`media/sermons/123`) need no manifest entry and no sidebar entry.
*
* Everything else under a module is the host's `not-found`, and the module is not mounted for it:
* an undeclared path never silently opens some other page. A module that declares no pages — every
* manifest v8 module — has its root and nothing else, exactly as before.
*/
export interface DashboardModulePage {
readonly id: string;
readonly label: string;
/** Relative to the module root: `timelines`, `media/library`. */
readonly path: string;
}
/** Where a route lands inside its module: the declared page it is or lies beneath, `undefined` at the
* root. `undefined` as a whole means the module has no such route. */
export interface ModuleRouteResolution {
readonly page: DashboardModulePage | undefined;
}
export function resolveModuleRoute(
pages: readonly DashboardModulePage[],
path: string,
): ModuleRouteResolution | undefined {
if (path === "") {
return { page: undefined };
}
const page = pages.find(
(candidate) => path === candidate.path || path.startsWith(`${candidate.path}/`),
);
return page === undefined ? undefined : { page };
}
/** `path` as one of this module's routes the shell would open, or `undefined`. Accepts the same
* spellings `parseModuleRoute` does: an optional leading `/`, unreserved segments, no traversal. */
export function moduleRouteFor(
pages: readonly DashboardModulePage[],
path: unknown,
): { readonly path: string; readonly resolution: ModuleRouteResolution } | "invalid" | "unknown" {
const route = parseModuleRoute(path);
if (route === undefined) {
return "invalid";
}
const resolution = resolveModuleRoute(pages, route);
return resolution === undefined ? "unknown" : { path: route, resolution };
}
export function moduleDestination(moduleId: string, path = ""): DashboardDestination {
return { kind: "module", moduleId, path };
}
const PAGE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const PAGE_PATH_SEGMENT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const ICON_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused.
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u;
/** The manifest's page grammar (`@ekklesia/contracts`), re-checked on what the server sends. */
export function isDashboardModulePage(value: unknown): value is DashboardModulePage {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
if (keys.length !== 3 || keys[0] !== "id" || keys[1] !== "label" || keys[2] !== "path") {
return false;
}
const { id, label, path } = record;
return (
typeof id === "string" &&
id.length <= 64 &&
PAGE_ID_PATTERN.test(id) &&
typeof label === "string" &&
label.trim().length > 0 &&
label.length <= 60 &&
!CONTROL_CHARACTER_PATTERN.test(label) &&
typeof path === "string" &&
path.length <= 120 &&
path.split("/").length <= 4 &&
path.split("/").every((segment) => PAGE_PATH_SEGMENT_PATTERN.test(segment))
);
}
/** A syntactically valid Lucide name. Whether the Dashboard can draw it is the icon map's question:
* a name it does not know is drawn as the generic module icon, never refused. */
export function isModuleIconName(value: unknown): value is string {
return typeof value === "string" && value.length <= 64 && ICON_NAME_PATTERN.test(value);
}