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**, `/`, which is its landing view and never itself a declared page, so no * page has two addresses; * - each **declared page**, `//`; * - any route **beneath** a declared page, `///…`, 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; 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); }