Files
Ekklesia/packages/module-ui-sdk/src/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

114 lines
5.1 KiB
TypeScript

/**
* Where a mounted module UI is in the Dashboard, and how it moves (ADR 0063).
*
* A **host capability**, not a router. The host owns the address bar, the sidebar, and every
* platform route; a module owns the pages beneath its own root. So a module never touches
* `window.location` or `window.history`, never spells a platform route, and never builds another
* module's address: it names paths **relative to its own root**, and the host maps them to real,
* refreshable, bookmarkable Dashboard addresses — or refuses them.
*
* Paths:
*
* - `""` is the module's root, its landing view.
* - `"timelines"` is a declared page; `"media/sermons/123"` is a route **beneath** the declared page
* `media`, which a module may use for detail views without listing every record in its manifest.
* - A leading `/` means the same thing as none: `"/timelines"` is still this module's `timelines`.
* There is no way to name anything outside this module.
* - Segments are unreserved URL characters only (letters, digits, `-`, `.`, `_`, `~`), never `.` or
* `..`, never empty; no query, fragment, scheme, or percent-encoding.
*
* A path the host would not open — malformed, or outside the module's root and its declared pages —
* is refused with a {@link ModuleUiNavigationError} rather than silently opening something else.
*/
export interface ModuleUiNavigation {
/** Where this module is now. */
location(): ModuleUiLocation;
/** Notifies after the location changes — a link, Back or Forward, or a refresh of the sidebar.
* Returns an unsubscribe. The module stays mounted while it moves between its own pages. */
subscribe(listener: () => void): () => void;
/**
* Opens one of this module's own routes. `replace` corrects the current history entry instead of
* adding one — for a route the module just respelled, not for ordinary navigation.
*
* @throws {ModuleUiNavigationError} for a path this module may not open.
*/
navigate(path: string, options?: ModuleUiNavigateOptions): void;
/**
* A link to one of this module's own routes, for a real `<a>`: `href` is the host's address for it,
* so the link can be opened in a new tab, copied, or bookmarked, and `onClick` opens it in place on
* an ordinary click while leaving a modified or middle click to the browser.
*
* @throws {ModuleUiNavigationError} for a path this module may not open.
*/
link(path: string): ModuleUiLink;
/**
* This module's page in the Dashboard's centralized Settings, or `undefined` when there is none for
* this viewer — the module declares no configuration, or the viewer may not change it. A
* presentation hint like {@link ModuleUiPermissions}: Settings enforces its own authority.
*
* Opening it carries the module's current location as the place to come back to, so Settings'
* Back returns exactly here. The host validates that destination; the module neither builds nor
* checks it.
*/
readonly settings: ModuleUiSettingsLocation | undefined;
}
export interface ModuleUiLocation {
/** This module's route, relative to its root: `""`, `"timelines"`, `"media/sermons/123"`. Never a
* leading or trailing `/`. */
readonly path: string;
/** The declared page that route is, or lies beneath; `undefined` at the module's root. */
readonly page: string | undefined;
}
export interface ModuleUiNavigateOptions {
readonly replace?: boolean;
}
/** What a link's click handler reads: satisfied by a DOM `MouseEvent` and by React's synthetic one. */
export interface ModuleUiLinkClickEvent {
readonly button: number;
readonly metaKey: boolean;
readonly ctrlKey: boolean;
readonly shiftKey: boolean;
readonly altKey: boolean;
readonly defaultPrevented: boolean;
preventDefault(): void;
}
export interface ModuleUiLink {
readonly href: string;
onClick(event: ModuleUiLinkClickEvent): void;
}
export interface ModuleUiSettingsLocation {
/** Opens this module's Settings page, remembering where the module was. */
open(): void;
/** The same, as a link: its `href` includes the place to return to, so it also works in a new tab. */
link(): ModuleUiLink;
}
export type ModuleUiNavigationErrorCode =
/** Not a path this contract can express: absolute, traversing, malformed, or out of bounds. */
| "MODULE_NAVIGATION_PATH_INVALID"
/** A well-formed path that is neither the module's root nor a declared page or beneath one. */
| "MODULE_NAVIGATION_PATH_UNKNOWN";
const navigationErrorMessages: Readonly<Record<ModuleUiNavigationErrorCode, string>> =
Object.freeze({
MODULE_NAVIGATION_PATH_INVALID: "A module asked to open a path outside its own routes.",
MODULE_NAVIGATION_PATH_UNKNOWN: "A module asked to open a page it does not declare.",
});
/** Carries a stable code and a fixed message — never the path the module asked for. */
export class ModuleUiNavigationError extends Error {
readonly code: ModuleUiNavigationErrorCode;
constructor(code: ModuleUiNavigationErrorCode) {
super(navigationErrorMessages[code]);
this.name = "ModuleUiNavigationError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
}
}