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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7447381c8f
commit
4671622b9d
@@ -0,0 +1,99 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user