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]>
114 lines
5.1 KiB
TypeScript
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}`;
|
|
}
|
|
}
|