/** * The Dashboard's URL space (ADR 0057): what each Dashboard path means. * * Framework-free and free of browser globals, because two programs read it. The shell parses the * address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a * refreshed or bookmarked deep link reaches the shell instead of a `404`. * * Module pages and platform pages share one top-level namespace: * * ```text * / Overview * /settings Settings * /settings/
a platform Settings section: general, branding, displays, system * /settings/discover/ finding, installing, and managing modules: browse, installed, updates * /settings/ a module's settings (and later its own deeper settings pages) * /account your account * /account/
a personal settings page: appearance * /[/…] a module's page, and later its own sub-pages * ``` * * The server serves the page at `/` only for a module that is actually installed with a * Dashboard UI, and Core refuses any module whose id is a platform-owned name (`RESERVED_MODULE_IDS` * in `@ekklesia/core/modules`), so a module page can never shadow a platform page and an arbitrary * root path never becomes one. */ /** * The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page * uses yet, so adding a Settings or Account page needs no server or proxy change. Each is also a * reserved module id in Core; `apps/api` tests hold the two lists together. */ export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const); /** * The platform's own Settings sections, in their navigation order (ADR 0061). Every other name under * `/settings/` is a module's settings, at `/settings/`. Module lifecycle — finding, * installing, turning on and off, updating — is Discover, at `/settings/discover/…`; configuring a * module is its own `/settings/`. Each section name is also a reserved module id in Core, * so no module can take one, and `apps/api` tests hold the two lists together. */ export const SETTINGS_SECTIONS = Object.freeze([ "general", "branding", "displays", "discover", "system", ] as const); export type SettingsSection = (typeof SETTINGS_SECTIONS)[number]; /** The sections that are one page each. Discover is a group of views instead. */ export type SettingsPageSection = Exclude; /** * The account area's personal settings pages, in navigation order (ADR 0062). Personal, never * church administration: that is Settings. They live beneath `/account/`, so none can collide with a * module id and none is reserved. */ export const ACCOUNT_SECTIONS = Object.freeze(["appearance"] as const); export type AccountSection = (typeof ACCOUNT_SECTIONS)[number]; /** * Discover's views, in navigation order: Browse (find and install modules), Installed (see and * manage installed modules), and Updates (module and platform updates). Browse is where Discover * opens. They live beneath `/settings/discover/`, so none can collide with a module id. */ export const DISCOVER_VIEWS = Object.freeze(["browse", "installed", "updates"] as const); export type DiscoverView = (typeof DISCOVER_VIEWS)[number]; /** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */ export type DashboardRoute = | { readonly kind: "overview" } | { readonly kind: "module"; readonly moduleId: string } | { readonly kind: "settings" } | { readonly kind: "settings-section"; readonly section: SettingsPageSection } | { readonly kind: "discover"; readonly view: DiscoverView } | { readonly kind: "module-settings"; readonly moduleId: string } | { readonly kind: "account" } | { readonly kind: "account-section"; readonly section: AccountSection } | { readonly kind: "not-found" }; /** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */ export type DashboardDestination = Exclude; const platformPages: ReadonlySet = new Set(DASHBOARD_PLATFORM_PAGES); const settingsSections: ReadonlySet = new Set(SETTINGS_SECTIONS); const discoverViews: ReadonlySet = new Set(DISCOVER_VIEWS); const accountSections: ReadonlySet = new Set(ACCOUNT_SECTIONS); function isSettingsPageSection(value: string): value is SettingsPageSection { return value !== "discover" && settingsSections.has(value); } function isDiscoverView(value: string): value is DiscoverView { return discoverViews.has(value); } function isAccountSection(value: string): value is AccountSection { return accountSections.has(value); } function decodeSegment(segment: string): string | undefined { try { const decoded = decodeURIComponent(segment); return decoded.length > 0 ? decoded : undefined; } catch { return undefined; } } /** * Reads a path into a route. * * A single top-level segment that is not a platform page names a module's page. The id is only * decoded here, never validated against a pattern: the shell compares it with the navigation the * server resolved, and an id that matches no entry is an unavailable module, not a malformed URL. * * Everything below `//` belongs to that module's own pages (`/signage/timelines`), which * need a contract that does not exist yet, so it is `not-found` for now rather than silently showing * the module's first page. Settings and Account pages are likewise `not-found` until they exist. One * trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the * canonical spelling. */ export function parseDashboardPath(pathname: string): DashboardRoute { if (pathname === "/") { return { kind: "overview" }; } const segments = pathname.split("/"); if (segments[0] !== "") { return { kind: "not-found" }; } const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1); const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]); if (first === "settings") { return parseSettingsPath(meaningful.slice(1)); } if (first === "account") { return parseAccountPath(meaningful.slice(1)); } if (first === undefined || platformPages.has(first)) { return { kind: "not-found" }; } if (meaningful.length === 1) { return { kind: "module", moduleId: first }; } return { kind: "not-found" }; } /** * Reads what follows `/settings`. One more segment is a platform section or, failing that, a * module's settings. `discover` is entered at Browse: `/settings/discover` reads as Browse, whose * canonical spelling is `/settings/discover/browse`, so the shell respells it in place and there is * one Browse address rather than two. Anything deeper is reserved for later Settings pages and is * `not-found` until they exist, rather than silently showing their parent. */ function parseSettingsPath(rest: readonly string[]): DashboardRoute { if (rest.length === 0) { return { kind: "settings" }; } const name = rest[0] === undefined ? undefined : decodeSegment(rest[0]); if (name === undefined) { return { kind: "not-found" }; } if (name === "discover") { if (rest.length === 1) { return { kind: "discover", view: "browse" }; } const view = rest[1] === undefined ? undefined : decodeSegment(rest[1]); return rest.length === 2 && view !== undefined && isDiscoverView(view) ? { kind: "discover", view } : { kind: "not-found" }; } if (rest.length !== 1) { return { kind: "not-found" }; } return isSettingsPageSection(name) ? { kind: "settings-section", section: name } : { kind: "module-settings", moduleId: name }; } /** Reads what follows `/account`: the account page itself, or one of its personal settings pages. * Anything else is `not-found` until it exists. */ function parseAccountPath(rest: readonly string[]): DashboardRoute { if (rest.length === 0) { return { kind: "account" }; } const name = rest[0] === undefined ? undefined : decodeSegment(rest[0]); return rest.length === 1 && name !== undefined && isAccountSection(name) ? { kind: "account-section", section: name } : { kind: "not-found" }; } /** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its * own path segment. */ export function dashboardPath(route: DashboardDestination): string { switch (route.kind) { case "overview": return "/"; case "module": return `/${encodeURIComponent(route.moduleId)}`; case "settings": return "/settings"; case "settings-section": return `/settings/${route.section}`; case "discover": return `/settings/discover/${route.view}`; case "module-settings": return `/settings/${encodeURIComponent(route.moduleId)}`; case "account": return "/account"; case "account-section": return `/account/${route.section}`; } }