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
@@ -3,7 +3,6 @@ import type {
|
||||
CSSProperties,
|
||||
FocusEventHandler,
|
||||
KeyboardEventHandler,
|
||||
MouseEvent,
|
||||
MouseEventHandler,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
@@ -57,7 +56,14 @@ const currentStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
|
||||
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
|
||||
export function isBrowserHandledClick(event: {
|
||||
readonly defaultPrevented: boolean;
|
||||
readonly button: number;
|
||||
readonly metaKey: boolean;
|
||||
readonly ctrlKey: boolean;
|
||||
readonly shiftKey: boolean;
|
||||
readonly altKey: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
|
||||
@@ -20,7 +20,14 @@ import {
|
||||
signInWithEmail,
|
||||
signOutHost,
|
||||
} from "../auth-protocol.js";
|
||||
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
|
||||
import {
|
||||
type DashboardRoute,
|
||||
dashboardPath,
|
||||
parseDashboardPath,
|
||||
returnTargetFromSearch,
|
||||
} from "../dashboard-routes.js";
|
||||
import { resolveModuleRoute } from "../module-navigation.js";
|
||||
import { parseReturnTarget } from "../return-target.js";
|
||||
import {
|
||||
bootstrapHost,
|
||||
type DashboardNavigationEntry,
|
||||
@@ -42,6 +49,7 @@ import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js
|
||||
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
|
||||
import { LoginForm } from "./LoginForm.js";
|
||||
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
||||
import { createModuleUiNavigation } from "./module-ui-navigation.js";
|
||||
import { ResetPasswordForm } from "./ResetPasswordForm.js";
|
||||
import { ShellLayout } from "./ShellLayout.js";
|
||||
import { resolveSettingsPage, type SettingsPage, settingsPageTitle } from "./settings-catalog.js";
|
||||
@@ -51,6 +59,7 @@ import {
|
||||
SettingsHomePage,
|
||||
SettingsLoadFailedPage,
|
||||
SettingsSectionPage,
|
||||
type SettingsReturn,
|
||||
SettingsUnavailablePage,
|
||||
} from "./SettingsPages.js";
|
||||
import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js";
|
||||
@@ -194,19 +203,20 @@ function SettingsPageView(props: {
|
||||
readonly page: SettingsPage;
|
||||
readonly history: HostHistory;
|
||||
readonly onRetry: () => void;
|
||||
readonly returnTo: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const { page, history } = props;
|
||||
const { page, history, returnTo } = props;
|
||||
switch (page.kind) {
|
||||
case "home":
|
||||
return <SettingsHomePage history={history} settings={page.settings} />;
|
||||
case "section":
|
||||
return <SettingsSectionPage history={history} section={page.section} />;
|
||||
return <SettingsSectionPage history={history} returnTo={returnTo} section={page.section} />;
|
||||
case "discover":
|
||||
return <DiscoverPage history={history} view={page.view} />;
|
||||
return <DiscoverPage history={history} returnTo={returnTo} view={page.view} />;
|
||||
case "module":
|
||||
return <ModuleSettingsPage history={history} module={page.module} />;
|
||||
return <ModuleSettingsPage history={history} module={page.module} returnTo={returnTo} />;
|
||||
case "unavailable":
|
||||
return <SettingsUnavailablePage history={history} reason={page.reason} />;
|
||||
return <SettingsUnavailablePage history={history} reason={page.reason} returnTo={returnTo} />;
|
||||
case "load-failed":
|
||||
return <SettingsLoadFailedPage onRetry={props.onRetry} />;
|
||||
case "not-found":
|
||||
@@ -220,6 +230,74 @@ function routeKey(route: DashboardRoute): string {
|
||||
return route.kind === "not-found" ? "not-found" : dashboardPath(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `?return=` value the Settings pages may use (ADR 0063), or `undefined`. `parseReturnTarget`
|
||||
* accepts only a canonical same-origin Dashboard address; a module's address is additionally checked
|
||||
* against the modules actually running here and the routes each declares, so Back never points at a
|
||||
* module that is not running or at a page it does not have.
|
||||
*/
|
||||
function usableReturnTarget(
|
||||
raw: string | undefined,
|
||||
entries: readonly DashboardNavigationEntry[],
|
||||
): string | undefined {
|
||||
const destination = parseReturnTarget(raw);
|
||||
if (destination === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (destination.kind !== "module") {
|
||||
return raw;
|
||||
}
|
||||
const entry = entries.find((candidate) => candidate.descriptor.id === destination.moduleId);
|
||||
return entry !== undefined && resolveModuleRoute(entry.pages, destination.path) !== undefined
|
||||
? raw
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One mounted module UI and the navigation capability it is handed (ADR 0063). The capability is
|
||||
* created once per mount and reads the address bar live, so it is the same object for the module's
|
||||
* whole life however often it moves between its pages, and the frame never remounts because of it.
|
||||
*/
|
||||
function MountedModule(props: {
|
||||
readonly bootstrap: HostBootstrap;
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly entry: DashboardNavigationEntry;
|
||||
readonly history: HostHistory;
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
readonly onAuthorizationResponse: (status: 401 | 403) => void;
|
||||
}): ReactElement {
|
||||
const { bootstrap, entry, history } = props;
|
||||
const settingsRef = useRef(bootstrap.settings);
|
||||
settingsRef.current = bootstrap.settings;
|
||||
const moduleId = entry.descriptor.id;
|
||||
const navigation = useMemo(
|
||||
() =>
|
||||
createModuleUiNavigation({
|
||||
hasSettings: () => {
|
||||
const settings = settingsRef.current;
|
||||
return (
|
||||
settings !== "unavailable" &&
|
||||
settings.modules.some((module) => module.id === moduleId && module.available)
|
||||
);
|
||||
},
|
||||
history,
|
||||
moduleId,
|
||||
pages: entry.pages,
|
||||
}),
|
||||
[entry.pages, history, moduleId],
|
||||
);
|
||||
return (
|
||||
<ModuleUiFrame
|
||||
config={props.config}
|
||||
descriptor={entry.descriptor}
|
||||
{...(props.importModule ? { importModule: props.importModule } : {})}
|
||||
navigation={navigation}
|
||||
onAuthorizationResponse={props.onAuthorizationResponse}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostMessage(props: {
|
||||
readonly children: string;
|
||||
readonly testId: string;
|
||||
@@ -299,8 +377,11 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
|
||||
const appearance = props.appearance ?? fallbackAppearance;
|
||||
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
|
||||
const readPathname = useCallback(() => history.pathname(), [history]);
|
||||
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
|
||||
// The whole address, so a change of query alone (a new place to return to) re-renders too.
|
||||
const readAddress = useCallback(() => `${history.pathname()}${history.search()}`, [history]);
|
||||
const address = useSyncExternalStore(subscribe, readAddress, readAddress);
|
||||
const pathname = address.split("?")[0] ?? "/";
|
||||
const search = address.slice(pathname.length);
|
||||
const route = useMemo(() => parseDashboardPath(pathname), [pathname]);
|
||||
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
|
||||
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
|
||||
@@ -364,9 +445,11 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
// history entry, so what is bookmarked or shared next is the one canonical address.
|
||||
useEffect(() => {
|
||||
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
|
||||
history.replace(dashboardPath(route));
|
||||
// The query is kept: it is where to return to (ADR 0063), and respelling the path must not
|
||||
// lose it.
|
||||
history.replace(`${dashboardPath(route)}${search}`);
|
||||
}
|
||||
}, [history, pathname, route]);
|
||||
}, [history, pathname, route, search]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -522,6 +605,12 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
state.status === "ready" && route.kind === "module"
|
||||
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
|
||||
: undefined;
|
||||
// Which of the open module's own routes this is (ADR 0063): its root, a declared page, or a route
|
||||
// beneath one. Anything else is the host's not-found, and the module is not mounted for it.
|
||||
const openModuleRoute =
|
||||
openEntry !== undefined && route.kind === "module"
|
||||
? resolveModuleRoute(openEntry.pages, route.path)
|
||||
: undefined;
|
||||
const settingsPage =
|
||||
state.status === "ready" &&
|
||||
(route.kind === "settings" ||
|
||||
@@ -542,7 +631,13 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
: route.kind === "overview"
|
||||
? "Overview"
|
||||
: route.kind === "module"
|
||||
? (openEntry?.name ?? "This page isn’t available")
|
||||
? openEntry === undefined
|
||||
? "This page isn’t available"
|
||||
: openModuleRoute === undefined
|
||||
? "Page not found"
|
||||
: openModuleRoute.page === undefined
|
||||
? openEntry.name
|
||||
: `${openModuleRoute.page.label} · ${openEntry.name}`
|
||||
: route.kind === "account"
|
||||
? "Account"
|
||||
: route.kind === "account-section"
|
||||
@@ -675,7 +770,16 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
viewport={viewport}
|
||||
>
|
||||
{settingsPage !== undefined ? (
|
||||
<SettingsPageView history={history} onRetry={retryBootstrap} page={settingsPage} />
|
||||
<SettingsPageView
|
||||
history={history}
|
||||
onRetry={retryBootstrap}
|
||||
page={settingsPage}
|
||||
returnTo={{
|
||||
moduleName: (moduleId) =>
|
||||
allEntries(bootstrap).find((entry) => entry.descriptor.id === moduleId)?.name,
|
||||
target: usableReturnTarget(returnTargetFromSearch(search), allEntries(bootstrap)),
|
||||
}}
|
||||
/>
|
||||
) : route.kind === "account" || route.kind === "account-section" ? (
|
||||
bootstrap.account === "unavailable" ? (
|
||||
<AccountLoadFailedPage onRetry={retryBootstrap} />
|
||||
@@ -698,17 +802,22 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
) : route.kind === "overview" ? (
|
||||
<OverviewPage />
|
||||
) : route.kind === "module" ? (
|
||||
openEntry ? (
|
||||
<ModuleUiFrame
|
||||
openEntry === undefined ? (
|
||||
// Installed but not running, or not known at all: never mounted, whatever the path.
|
||||
<ModuleUnavailablePage history={history} />
|
||||
) : openModuleRoute === undefined ? (
|
||||
<NotFoundPage history={history} />
|
||||
) : (
|
||||
<MountedModule
|
||||
bootstrap={bootstrap}
|
||||
config={config}
|
||||
descriptor={openEntry.descriptor}
|
||||
entry={openEntry}
|
||||
history={history}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
// Keyed by module identity, not path: moving between its own pages never remounts it.
|
||||
key={moduleKey(openEntry)}
|
||||
onAuthorizationResponse={onAuthorizationResponse}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
) : (
|
||||
<ModuleUnavailablePage history={history} />
|
||||
)
|
||||
) : (
|
||||
<NotFoundPage history={history} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import type { ModuleUiNavigation, ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js";
|
||||
import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js";
|
||||
@@ -11,6 +11,9 @@ export interface ModuleUiFrameProps {
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly descriptor: HostModuleUiDescriptor;
|
||||
readonly principal: ModuleUiPrincipal;
|
||||
/** The module's navigation capability (ADR 0063). Must be stable for the module's life: a new one
|
||||
* remounts the module. */
|
||||
readonly navigation?: ModuleUiNavigation;
|
||||
/** Host-internal session signal. It never enters the module-facing context. */
|
||||
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
|
||||
/** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove
|
||||
@@ -52,7 +55,8 @@ export interface ModuleUiFrameProps {
|
||||
export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<FrameStatus>("loading");
|
||||
const { config, descriptor, importModule, onAuthorizationResponse, principal } = props;
|
||||
const { config, descriptor, importModule, navigation, onAuthorizationResponse, principal } =
|
||||
props;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -81,6 +85,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
descriptor,
|
||||
principal,
|
||||
...(importModule ? { importModule } : {}),
|
||||
...(navigation ? { navigation } : {}),
|
||||
...(onAuthorizationResponse
|
||||
? { onAuthorizationResponse: reportAuthorizationResponse }
|
||||
: {}),
|
||||
@@ -114,7 +119,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
// already in progress, and a module that fails to clean up must not take the shell with it.
|
||||
void mounted?.unmount().catch(() => undefined);
|
||||
};
|
||||
}, [config, descriptor, importModule, onAuthorizationResponse, principal]);
|
||||
}, [config, descriptor, importModule, navigation, onAuthorizationResponse, principal]);
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -28,13 +28,31 @@ import { ShellIcon, type ShellIconName } from "./shell-icons.js";
|
||||
|
||||
const BACK_TO_SETTINGS: DashboardDestination = { kind: "settings" };
|
||||
|
||||
/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings; once the
|
||||
* module navigation contract carries a return target, a validated one takes precedence. */
|
||||
function settingsBack(history: HostHistory, returnTarget?: unknown) {
|
||||
/**
|
||||
* Where a Settings page was opened from (ADR 0063): the raw `?return=` value, judged by
|
||||
* `parseReturnTarget` inside `dashboardBack`, and the names of the modules a person might be going
|
||||
* back to, so Back can say "Back to Signage".
|
||||
*/
|
||||
export interface SettingsReturn {
|
||||
readonly target: unknown;
|
||||
readonly moduleName: (moduleId: string) => string | undefined;
|
||||
}
|
||||
|
||||
/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings — or, when a
|
||||
* module opened this page, to exactly the page it was opened from, if that is a real Dashboard page.
|
||||
* Never the browser's own `history.back()`. */
|
||||
function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
|
||||
return dashboardBack(history, {
|
||||
fallback: BACK_TO_SETTINGS,
|
||||
label: (destination) => (destination.kind === "settings" ? "Back to Settings" : "Back"),
|
||||
returnTarget,
|
||||
label: (destination) => {
|
||||
if (destination.kind === "settings") {
|
||||
return "Back to Settings";
|
||||
}
|
||||
const name =
|
||||
destination.kind === "module" ? returnTo?.moduleName(destination.moduleId) : undefined;
|
||||
return name === undefined ? "Back" : `Back to ${name}`;
|
||||
},
|
||||
returnTarget: returnTo?.target,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,6 +165,7 @@ const SECTION_NOT_YET: Readonly<Record<SettingsPageSection, string>> = Object.fr
|
||||
export function DiscoverPage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly view: DiscoverView;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const copy = DISCOVER_VIEW_COPY[props.view];
|
||||
return (
|
||||
@@ -155,7 +174,7 @@ export function DiscoverPage(props: {
|
||||
style={{ display: "grid", gap: designToken("spacing.xl") }}
|
||||
>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={copy.description}
|
||||
eyebrow="Discover"
|
||||
title={copy.title}
|
||||
@@ -169,6 +188,7 @@ export function DiscoverPage(props: {
|
||||
export function SettingsSectionPage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly section: SettingsPageSection;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const copy = SETTINGS_SECTION_COPY[props.section];
|
||||
return (
|
||||
@@ -177,7 +197,7 @@ export function SettingsSectionPage(props: {
|
||||
style={{ display: "grid", gap: designToken("spacing.xl") }}
|
||||
>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={copy.description}
|
||||
title={copy.label}
|
||||
/>
|
||||
@@ -186,15 +206,17 @@ export function SettingsSectionPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/** `/settings/<module-id>`: a running module's settings, not built yet. */
|
||||
/** `/settings/<module-id>`: a running module's settings, not built yet. A module opens it through its
|
||||
* navigation capability, which carries where it was so Back returns exactly there (ADR 0063). */
|
||||
export function ModuleSettingsPage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly module: DashboardSettingsModule;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div data-testid="module-settings" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={`How ${props.module.name} works for your church.`}
|
||||
eyebrow="Settings"
|
||||
title={props.module.name}
|
||||
@@ -217,12 +239,13 @@ export type SettingsUnavailableReason = "no-access" | "module-disabled";
|
||||
export function SettingsUnavailablePage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly reason: SettingsUnavailableReason;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const disabled = props.reason === "module-disabled";
|
||||
return (
|
||||
<div data-testid={disabled ? "settings-module-unavailable" : "settings-no-access"}>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={
|
||||
disabled
|
||||
? "The module these settings belong to is turned off. Its settings come back when it is turned on again."
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { CSSProperties, ReactElement } from "react";
|
||||
import { type CSSProperties, Fragment, type ReactElement } from "react";
|
||||
import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js";
|
||||
import type {
|
||||
DashboardNavigation,
|
||||
DashboardNavigationEntry,
|
||||
DashboardSettings,
|
||||
} from "../host-protocol.js";
|
||||
import { moduleDestination, resolveModuleRoute } from "../module-navigation.js";
|
||||
import { DashboardLink } from "./DashboardLink.js";
|
||||
import { ModuleIcon } from "./module-icon.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { useNavTooltip } from "./NavTooltip.js";
|
||||
import { DISCOVER_VIEWS } from "../dashboard-routes.js";
|
||||
@@ -56,7 +58,8 @@ export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement
|
||||
function NavigationItem(props: {
|
||||
readonly destination: DashboardDestination;
|
||||
readonly label: string;
|
||||
readonly icon: ShellIconName;
|
||||
/** A shell icon by name, a module's own icon, or none — a module's pages are text beneath it. */
|
||||
readonly icon?: ShellIconName | ReactElement;
|
||||
readonly current: boolean;
|
||||
readonly history: HostHistory;
|
||||
readonly presentation: NavigationPresentation;
|
||||
@@ -77,7 +80,11 @@ function NavigationItem(props: {
|
||||
to={props.destination}
|
||||
{...triggerProps}
|
||||
>
|
||||
<ShellIcon name={props.icon} />
|
||||
{props.icon === undefined ? undefined : typeof props.icon === "string" ? (
|
||||
<ShellIcon name={props.icon} />
|
||||
) : (
|
||||
props.icon
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
props.presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
|
||||
@@ -92,6 +99,17 @@ function NavigationItem(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The modules in one navigation group, each with its own icon (manifest v9, ADR 0063).
|
||||
*
|
||||
* While a module is open, its declared primary pages appear beneath it, in its declared order
|
||||
* (DESIGN.md §4.4) — the same in-place expansion Settings uses (ADR 0061), so the rest of the
|
||||
* navigation stays where it was and one item carries `aria-current`: the open page, or the module
|
||||
* itself on its landing view. A route beneath a page (a detail view) marks that page. Collapsed,
|
||||
* the sidebar shows icons only, so the pages stay hidden and the module item is the current one; its
|
||||
* tooltip and accessible name are the module's name. A module that declares no pages — every
|
||||
* manifest v8 module — is one item, exactly as before.
|
||||
*/
|
||||
function ModuleItems(props: {
|
||||
readonly entries: readonly DashboardNavigationEntry[];
|
||||
readonly route: DashboardRoute;
|
||||
@@ -100,6 +118,8 @@ function ModuleItems(props: {
|
||||
readonly onNavigate?: () => void;
|
||||
readonly labelledBy?: string;
|
||||
}): ReactElement {
|
||||
const { route, presentation } = props;
|
||||
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
|
||||
return (
|
||||
<ul
|
||||
aria-labelledby={props.labelledBy}
|
||||
@@ -108,20 +128,49 @@ function ModuleItems(props: {
|
||||
>
|
||||
{props.entries.map((entry) => {
|
||||
const moduleId = entry.descriptor.id;
|
||||
const resolution =
|
||||
route.kind === "module" && route.moduleId === moduleId
|
||||
? resolveModuleRoute(entry.pages, route.path)
|
||||
: undefined;
|
||||
const showPages =
|
||||
resolution !== undefined && entry.pages.length > 0 && !presentation.collapsed;
|
||||
const openPage = resolution?.page;
|
||||
return (
|
||||
<NavigationItem
|
||||
current={props.route.kind === "module" && props.route.moduleId === moduleId}
|
||||
destination={{ kind: "module", moduleId }}
|
||||
history={props.history}
|
||||
// Every module gets the one generic icon until modules can declare their own
|
||||
// (ADR 0058); there is no table of known modules.
|
||||
icon="module"
|
||||
key={`${moduleId}@${entry.descriptor.version}`}
|
||||
label={entry.name}
|
||||
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
|
||||
presentation={props.presentation}
|
||||
testId={`select-${moduleId}`}
|
||||
/>
|
||||
<Fragment key={`${moduleId}@${entry.descriptor.version}`}>
|
||||
<NavigationItem
|
||||
current={resolution !== undefined && (openPage === undefined || !showPages)}
|
||||
destination={moduleDestination(moduleId)}
|
||||
history={props.history}
|
||||
icon={<ModuleIcon icon={entry.icon} />}
|
||||
label={entry.name}
|
||||
{...onNavigate}
|
||||
presentation={presentation}
|
||||
testId={`select-${moduleId}`}
|
||||
/>
|
||||
{showPages ? (
|
||||
<li>
|
||||
<ul
|
||||
aria-label={entry.name}
|
||||
className="ek-shell-nav__group ek-shell-nav__nested"
|
||||
data-testid={`module-subnavigation-${moduleId}`}
|
||||
style={{ listStyle: "none", margin: 0, padding: 0 }}
|
||||
>
|
||||
{entry.pages.map((page) => (
|
||||
<NavigationItem
|
||||
current={openPage?.id === page.id}
|
||||
destination={moduleDestination(moduleId, page.path)}
|
||||
history={props.history}
|
||||
key={page.id}
|
||||
label={page.label}
|
||||
{...onNavigate}
|
||||
presentation={presentation}
|
||||
testId={`select-${moduleId}-${page.id}`}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : undefined}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
export interface HostHistory {
|
||||
/** The current path, without query or fragment. */
|
||||
pathname(): string;
|
||||
/** Opens a new page, adding a history entry. Opening the page already showing adds none. */
|
||||
/** The current query, `""` or starting with `?`. The shell reads exactly one thing from it: the
|
||||
* place to return to (`?return=…`, ADR 0063). */
|
||||
search(): string;
|
||||
/** Opens a new page, adding a history entry. `path` may carry a query. Opening the address already
|
||||
* showing adds none. */
|
||||
push(path: string): void;
|
||||
/** Corrects the current entry in place, adding none — for a non-canonical spelling of the same
|
||||
* page. */
|
||||
@@ -28,7 +32,7 @@ export interface HostHistory {
|
||||
|
||||
/** The parts of a `Window` the browser history needs, passed in by the entry point. */
|
||||
export interface HostHistoryWindow {
|
||||
readonly location: Pick<Location, "pathname">;
|
||||
readonly location: Pick<Location, "pathname" | "search">;
|
||||
readonly history: Pick<History, "pushState" | "replaceState">;
|
||||
addEventListener(type: "popstate", listener: () => void): void;
|
||||
removeEventListener(type: "popstate", listener: () => void): void;
|
||||
@@ -47,8 +51,9 @@ export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory {
|
||||
};
|
||||
return {
|
||||
pathname: () => view.location.pathname,
|
||||
search: () => view.location.search,
|
||||
push(path) {
|
||||
if (path === view.location.pathname) {
|
||||
if (path === `${view.location.pathname}${view.location.search}`) {
|
||||
return;
|
||||
}
|
||||
view.history.pushState(null, "", path);
|
||||
@@ -98,7 +103,12 @@ export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory {
|
||||
get index() {
|
||||
return index;
|
||||
},
|
||||
pathname: () => entries[index] ?? "/",
|
||||
pathname: () => (entries[index] ?? "/").split("?")[0] ?? "/",
|
||||
search: () => {
|
||||
const entry = entries[index] ?? "/";
|
||||
const query = entry.indexOf("?");
|
||||
return query === -1 ? "" : entry.slice(query);
|
||||
},
|
||||
push(path) {
|
||||
if (path === entries[index]) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
Activity,
|
||||
Archive,
|
||||
Baby,
|
||||
Bell,
|
||||
BookOpen,
|
||||
BookOpenText,
|
||||
Bookmark,
|
||||
Briefcase,
|
||||
Building,
|
||||
Bus,
|
||||
Calendar,
|
||||
CalendarCheck,
|
||||
CalendarDays,
|
||||
Camera,
|
||||
Car,
|
||||
ChartColumn,
|
||||
ChartPie,
|
||||
Church,
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
Coffee,
|
||||
Contact,
|
||||
CreditCard,
|
||||
Database,
|
||||
DoorOpen,
|
||||
FileText,
|
||||
Files,
|
||||
Film,
|
||||
Flag,
|
||||
Folder,
|
||||
Gift,
|
||||
Globe,
|
||||
GraduationCap,
|
||||
HandCoins,
|
||||
HandHeart,
|
||||
Handshake,
|
||||
Headphones,
|
||||
Heart,
|
||||
HeartHandshake,
|
||||
House,
|
||||
Image,
|
||||
Images,
|
||||
Inbox,
|
||||
KeyRound,
|
||||
Landmark,
|
||||
LayoutGrid,
|
||||
Library,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
Mail,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Megaphone,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Monitor,
|
||||
MonitorPlay,
|
||||
Music,
|
||||
Newspaper,
|
||||
Notebook,
|
||||
Package,
|
||||
Palette,
|
||||
PartyPopper,
|
||||
Phone,
|
||||
Presentation,
|
||||
Printer,
|
||||
Projector,
|
||||
Radio,
|
||||
Receipt,
|
||||
School,
|
||||
Send,
|
||||
Shield,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
Star,
|
||||
Sun,
|
||||
TabletSmartphone,
|
||||
Tag,
|
||||
Tent,
|
||||
Ticket,
|
||||
Trophy,
|
||||
Truck,
|
||||
Tv,
|
||||
UserCheck,
|
||||
UserRound,
|
||||
Users,
|
||||
UsersRound,
|
||||
Utensils,
|
||||
Video,
|
||||
Wallet,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ReactElement } from "react";
|
||||
import { type DashboardModuleIconName, isDashboardModuleIconName } from "../module-icons.js";
|
||||
import { ShellIcon } from "./shell-icons.js";
|
||||
|
||||
/**
|
||||
* The Lucide components for the icons a module may declare (manifest v9, ADR 0063), by name.
|
||||
*
|
||||
* Each icon is imported by name, so the Dashboard bundles exactly this curated set and never the
|
||||
* whole library. Typed against `DASHBOARD_MODULE_ICON_NAMES`, so the map can neither miss a name
|
||||
* the platform accepts nor carry one it does not; the shell's tests prove each entry is a real Lucide
|
||||
* component.
|
||||
*/
|
||||
export const MODULE_ICON_COMPONENTS: Readonly<Record<DashboardModuleIconName, LucideIcon>> =
|
||||
Object.freeze({
|
||||
activity: Activity,
|
||||
archive: Archive,
|
||||
baby: Baby,
|
||||
bell: Bell,
|
||||
"book-open": BookOpen,
|
||||
"book-open-text": BookOpenText,
|
||||
bookmark: Bookmark,
|
||||
briefcase: Briefcase,
|
||||
building: Building,
|
||||
bus: Bus,
|
||||
calendar: Calendar,
|
||||
"calendar-check": CalendarCheck,
|
||||
"calendar-days": CalendarDays,
|
||||
camera: Camera,
|
||||
car: Car,
|
||||
"chart-column": ChartColumn,
|
||||
"chart-pie": ChartPie,
|
||||
church: Church,
|
||||
"clipboard-check": ClipboardCheck,
|
||||
"clipboard-list": ClipboardList,
|
||||
clock: Clock,
|
||||
coffee: Coffee,
|
||||
contact: Contact,
|
||||
"credit-card": CreditCard,
|
||||
database: Database,
|
||||
"door-open": DoorOpen,
|
||||
"file-text": FileText,
|
||||
files: Files,
|
||||
film: Film,
|
||||
flag: Flag,
|
||||
folder: Folder,
|
||||
gift: Gift,
|
||||
globe: Globe,
|
||||
"graduation-cap": GraduationCap,
|
||||
"hand-coins": HandCoins,
|
||||
"hand-heart": HandHeart,
|
||||
handshake: Handshake,
|
||||
headphones: Headphones,
|
||||
heart: Heart,
|
||||
"heart-handshake": HeartHandshake,
|
||||
house: House,
|
||||
image: Image,
|
||||
images: Images,
|
||||
inbox: Inbox,
|
||||
"key-round": KeyRound,
|
||||
landmark: Landmark,
|
||||
"layout-grid": LayoutGrid,
|
||||
library: Library,
|
||||
"list-checks": ListChecks,
|
||||
"list-todo": ListTodo,
|
||||
mail: Mail,
|
||||
map: MapIcon,
|
||||
"map-pin": MapPin,
|
||||
megaphone: Megaphone,
|
||||
"message-circle": MessageCircle,
|
||||
"message-square": MessageSquare,
|
||||
mic: Mic,
|
||||
monitor: Monitor,
|
||||
"monitor-play": MonitorPlay,
|
||||
music: Music,
|
||||
newspaper: Newspaper,
|
||||
notebook: Notebook,
|
||||
package: Package,
|
||||
palette: Palette,
|
||||
"party-popper": PartyPopper,
|
||||
phone: Phone,
|
||||
presentation: Presentation,
|
||||
printer: Printer,
|
||||
projector: Projector,
|
||||
radio: Radio,
|
||||
receipt: Receipt,
|
||||
school: School,
|
||||
send: Send,
|
||||
shield: Shield,
|
||||
"shopping-bag": ShoppingBag,
|
||||
sparkles: Sparkles,
|
||||
star: Star,
|
||||
sun: Sun,
|
||||
"tablet-smartphone": TabletSmartphone,
|
||||
tag: Tag,
|
||||
tent: Tent,
|
||||
ticket: Ticket,
|
||||
trophy: Trophy,
|
||||
truck: Truck,
|
||||
tv: Tv,
|
||||
"user-check": UserCheck,
|
||||
"user-round": UserRound,
|
||||
users: Users,
|
||||
"users-round": UsersRound,
|
||||
utensils: Utensils,
|
||||
video: Video,
|
||||
wallet: Wallet,
|
||||
wrench: Wrench,
|
||||
});
|
||||
|
||||
/**
|
||||
* A module's icon: the Lucide icon it declared, or the generic module icon when it declared none or
|
||||
* one this Dashboard cannot draw. Decorative, like every navigation icon (ADR 0058): the module's
|
||||
* name is always beside it or in its control's accessible name.
|
||||
*/
|
||||
export function ModuleIcon(props: {
|
||||
readonly icon: string | undefined;
|
||||
readonly size?: number;
|
||||
}): ReactElement {
|
||||
if (!isDashboardModuleIconName(props.icon)) {
|
||||
return <ShellIcon name="module" {...(props.size === undefined ? {} : { size: props.size })} />;
|
||||
}
|
||||
const Icon = MODULE_ICON_COMPONENTS[props.icon];
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
data-icon={`module:${props.icon}`}
|
||||
focusable="false"
|
||||
size={props.size ?? 20}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
type ModuleUiLink,
|
||||
type ModuleUiLinkClickEvent,
|
||||
type ModuleUiLocation,
|
||||
type ModuleUiNavigation,
|
||||
ModuleUiNavigationError,
|
||||
type ModuleUiSettingsLocation,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import {
|
||||
type DashboardDestination,
|
||||
dashboardPath,
|
||||
dashboardPathReturningTo,
|
||||
parseDashboardPath,
|
||||
} from "../dashboard-routes.js";
|
||||
import {
|
||||
type DashboardModulePage,
|
||||
moduleDestination,
|
||||
moduleRouteFor,
|
||||
resolveModuleRoute,
|
||||
} from "../module-navigation.js";
|
||||
import { isBrowserHandledClick } from "./DashboardLink.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
|
||||
export interface CreateModuleUiNavigationOptions {
|
||||
readonly moduleId: string;
|
||||
/** The module's declared primary pages; empty for a single-page module. */
|
||||
readonly pages: readonly DashboardModulePage[];
|
||||
readonly history: HostHistory;
|
||||
/** Whether this module has a Settings page this viewer may open (ADR 0061) — read each time it is
|
||||
* asked, so a Settings view that finishes loading after the module mounted is still honoured. */
|
||||
readonly hasSettings: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Dashboard's implementation of a module's navigation capability (ADR 0063).
|
||||
*
|
||||
* A narrow handle over the shell's own history, scoped to one module. It can only ever produce
|
||||
* addresses beneath `/<module-id>/` — a declared page or a route beneath one — or this module's
|
||||
* Settings page; it hands the module no history object, no router, and no way to name another
|
||||
* module or platform page. The module stays mounted as it moves between its pages: the shell keys
|
||||
* the mounted frame by module identity, not by path, and this reports each move to subscribers.
|
||||
*/
|
||||
export function createModuleUiNavigation(
|
||||
options: CreateModuleUiNavigationOptions,
|
||||
): ModuleUiNavigation {
|
||||
const { history, moduleId, pages } = options;
|
||||
|
||||
const currentPath = (): string | undefined => {
|
||||
const route = parseDashboardPath(history.pathname());
|
||||
return route.kind === "module" && route.moduleId === moduleId ? route.path : undefined;
|
||||
};
|
||||
|
||||
const locate = (path: string): ModuleUiLocation =>
|
||||
Object.freeze({ page: resolveModuleRoute(pages, path)?.page?.id, path });
|
||||
|
||||
// One frozen snapshot per distinct location, so `location()` is stable between moves — what a
|
||||
// React external-store read needs.
|
||||
let snapshot = locate(currentPath() ?? "");
|
||||
const refresh = (): boolean => {
|
||||
const path = currentPath();
|
||||
if (path === undefined || path === snapshot.path) {
|
||||
return false;
|
||||
}
|
||||
snapshot = locate(path);
|
||||
return true;
|
||||
};
|
||||
|
||||
// One subscription to the address bar, shared by every listener the module registers, and the
|
||||
// path they were last told about — so every listener hears every move exactly once, however
|
||||
// often the module reads `location()` in between.
|
||||
const listeners = new Set<() => void>();
|
||||
let stopWatching: (() => void) | undefined;
|
||||
let notifiedPath = snapshot.path;
|
||||
const notify = (): void => {
|
||||
refresh();
|
||||
if (snapshot.path === notifiedPath) {
|
||||
return;
|
||||
}
|
||||
notifiedPath = snapshot.path;
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const routeFor = (path: unknown): string => {
|
||||
const route = moduleRouteFor(pages, path);
|
||||
if (route === "invalid") {
|
||||
throw new ModuleUiNavigationError("MODULE_NAVIGATION_PATH_INVALID");
|
||||
}
|
||||
if (route === "unknown") {
|
||||
throw new ModuleUiNavigationError("MODULE_NAVIGATION_PATH_UNKNOWN");
|
||||
}
|
||||
return dashboardPath(moduleDestination(moduleId, route.path));
|
||||
};
|
||||
|
||||
const linkTo = (href: string): ModuleUiLink =>
|
||||
Object.freeze({
|
||||
href,
|
||||
onClick(event: ModuleUiLinkClickEvent) {
|
||||
if (isBrowserHandledClick(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
history.push(href);
|
||||
},
|
||||
});
|
||||
|
||||
const settingsDestination: DashboardDestination = { kind: "module-settings", moduleId };
|
||||
const settingsHref = (): string => {
|
||||
// Read the address bar now: a module with no listener of its own may have moved since.
|
||||
refresh();
|
||||
return dashboardPathReturningTo(
|
||||
settingsDestination,
|
||||
moduleDestination(moduleId, snapshot.path),
|
||||
);
|
||||
};
|
||||
const settings: ModuleUiSettingsLocation = Object.freeze({
|
||||
link: () => linkTo(settingsHref()),
|
||||
open: () => history.push(settingsHref()),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
link: (path: string) => linkTo(routeFor(path)),
|
||||
location: () => {
|
||||
refresh();
|
||||
return snapshot;
|
||||
},
|
||||
navigate(path: string, navigateOptions?: { readonly replace?: boolean }) {
|
||||
const href = routeFor(path);
|
||||
if (navigateOptions?.replace === true) {
|
||||
history.replace(href);
|
||||
} else {
|
||||
history.push(href);
|
||||
}
|
||||
},
|
||||
get settings() {
|
||||
return options.hasSettings() ? settings : undefined;
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) {
|
||||
stopWatching = history.subscribe(notify);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
stopWatching?.();
|
||||
stopWatching = undefined;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
* /settings/<module-id> a module's settings (and later its own deeper settings pages)
|
||||
* /account your account
|
||||
* /account/<section> a personal settings page: appearance
|
||||
* /<module-id>[/…] a module's page, and later its own sub-pages
|
||||
* /<module-id> a module's landing view
|
||||
* /<module-id>/<path> one of its declared pages, or a route beneath one (ADR 0063)
|
||||
* ```
|
||||
*
|
||||
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
|
||||
@@ -31,6 +32,15 @@
|
||||
*/
|
||||
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
|
||||
|
||||
/**
|
||||
* The host's top-level namespaces that are **not** Dashboard pages: the API, served assets, and the
|
||||
* display host. No address beneath them is ever a Dashboard page or module route, so nothing — a
|
||||
* typed URL, a link, or a return target (ADR 0063) — can make the Dashboard treat `/api/…` as a page
|
||||
* to open or link to. Together with {@link DASHBOARD_PLATFORM_PAGES} these are exactly Core's
|
||||
* reserved top-level names (`RESERVED_TOP_LEVEL_NAMES`); `apps/api` tests hold the lists together.
|
||||
*/
|
||||
export const HOST_NAMESPACES = Object.freeze(["api", "assets", "display"] 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-id>`. Module lifecycle — finding,
|
||||
@@ -72,7 +82,10 @@ 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 }
|
||||
/** `path` is relative to the module's root: `""` for the landing view, `"timelines"`,
|
||||
* `"media/sermons/123"`. Whether the module has such a page is the module's declaration's to say,
|
||||
* so the shell resolves it against the navigation, not here (ADR 0063). */
|
||||
| { readonly kind: "module"; readonly moduleId: string; readonly path: string }
|
||||
| { readonly kind: "settings" }
|
||||
| { readonly kind: "settings-section"; readonly section: SettingsPageSection }
|
||||
| { readonly kind: "discover"; readonly view: DiscoverView }
|
||||
@@ -85,6 +98,7 @@ export type DashboardRoute =
|
||||
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
|
||||
|
||||
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
|
||||
const hostNamespaces: ReadonlySet<string> = new Set(HOST_NAMESPACES);
|
||||
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
|
||||
|
||||
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
|
||||
@@ -102,6 +116,40 @@ function isAccountSection(value: string): value is AccountSection {
|
||||
return accountSections.has(value);
|
||||
}
|
||||
|
||||
/** The most segments, and characters, a module route may have beneath its root. Generous for detail
|
||||
* routes (`media/sermons/123`), bounded so a URL cannot carry anything else. */
|
||||
export const MODULE_ROUTE_MAX_SEGMENTS = 8;
|
||||
export const MODULE_ROUTE_MAX_LENGTH = 512;
|
||||
const MODULE_ROUTE_SEGMENT_PATTERN = /^[A-Za-z0-9._~-]+$/u;
|
||||
|
||||
/**
|
||||
* Reads a module-relative route (ADR 0063): `""` for the root, otherwise segments of unreserved URL
|
||||
* characters joined by `/`, none of them `.` or `..`. One leading `/` is the root's own spelling and
|
||||
* is dropped; nothing else is normalized. Anything else — a scheme, a query, a fragment, an empty
|
||||
* segment, percent-encoding, a backslash, traversal — is `undefined`, so a route can only ever name a
|
||||
* place inside its own module.
|
||||
*/
|
||||
export function parseModuleRoute(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const path = value.startsWith("/") ? value.slice(1) : value;
|
||||
if (path === "") {
|
||||
return "";
|
||||
}
|
||||
if (path.length > MODULE_ROUTE_MAX_LENGTH) {
|
||||
return undefined;
|
||||
}
|
||||
const segments = path.split("/");
|
||||
return segments.length <= MODULE_ROUTE_MAX_SEGMENTS &&
|
||||
segments.every(
|
||||
(segment) =>
|
||||
segment !== "." && segment !== ".." && MODULE_ROUTE_SEGMENT_PATTERN.test(segment),
|
||||
)
|
||||
? path
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function decodeSegment(segment: string): string | undefined {
|
||||
try {
|
||||
const decoded = decodeURIComponent(segment);
|
||||
@@ -118,9 +166,10 @@ function decodeSegment(segment: string): string | undefined {
|
||||
* 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 `/<module-id>/` 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
|
||||
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`, ADR
|
||||
* 0063): read here as a module-relative route, and resolved by the shell against the pages the module
|
||||
* declares, so a route no page owns is `not-found` there rather than silently showing the module's
|
||||
* first page. Settings and Account pages beyond those that exist are `not-found` too. One
|
||||
* trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the
|
||||
* canonical spelling.
|
||||
*/
|
||||
@@ -140,13 +189,17 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
|
||||
if (first === "account") {
|
||||
return parseAccountPath(meaningful.slice(1));
|
||||
}
|
||||
if (first === undefined || platformPages.has(first)) {
|
||||
if (first === undefined || platformPages.has(first) || hostNamespaces.has(first)) {
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
if (meaningful.length === 1) {
|
||||
return { kind: "module", moduleId: first };
|
||||
const rest = meaningful.slice(1);
|
||||
// `/signage//` is not `/signage/`: only one trailing slash is tolerated, and an empty segment
|
||||
// anywhere beneath a module is not a route.
|
||||
if (rest.some((segment) => segment === "")) {
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
return { kind: "not-found" };
|
||||
const path = parseModuleRoute(rest.join("/"));
|
||||
return path === undefined ? { kind: "not-found" } : { kind: "module", moduleId: first, path };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,7 +253,9 @@ export function dashboardPath(route: DashboardDestination): string {
|
||||
case "overview":
|
||||
return "/";
|
||||
case "module":
|
||||
return `/${encodeURIComponent(route.moduleId)}`;
|
||||
return route.path === ""
|
||||
? `/${encodeURIComponent(route.moduleId)}`
|
||||
: `/${encodeURIComponent(route.moduleId)}/${route.path}`;
|
||||
case "settings":
|
||||
return "/settings";
|
||||
case "settings-section":
|
||||
@@ -215,3 +270,30 @@ export function dashboardPath(route: DashboardDestination): string {
|
||||
return `/account/${route.section}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one carrier for "where to come back to" (ADR 0063): a `return` query parameter on the page
|
||||
* that was opened, holding the Dashboard address of the page that opened it —
|
||||
* `/settings/signage?return=%2Fsignage%2Ftimelines`. A query rather than history state, so it
|
||||
* survives a refresh and a link opened in a new tab; it is only ever read through
|
||||
* `parseReturnTarget`, so nothing but a real same-origin Dashboard page is ever used from it.
|
||||
*/
|
||||
export const RETURN_TARGET_PARAMETER = "return";
|
||||
|
||||
/** `destination`'s address, carrying `returnTo` as the place to come back to. */
|
||||
export function dashboardPathReturningTo(
|
||||
destination: DashboardDestination,
|
||||
returnTo: DashboardDestination,
|
||||
): string {
|
||||
const query = new URLSearchParams({ [RETURN_TARGET_PARAMETER]: dashboardPath(returnTo) });
|
||||
return `${dashboardPath(destination)}?${query.toString()}`;
|
||||
}
|
||||
|
||||
/** The raw, unvalidated return target in a query string, for `parseReturnTarget` to judge. */
|
||||
export function returnTargetFromSearch(search: string): string | undefined {
|
||||
try {
|
||||
return new URLSearchParams(search).get(RETURN_TARGET_PARAMETER) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
} from "@ekklesia/design-tokens";
|
||||
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js";
|
||||
import {
|
||||
type DashboardModulePage,
|
||||
isDashboardModulePage,
|
||||
isModuleIconName,
|
||||
} from "./module-navigation.js";
|
||||
|
||||
/**
|
||||
* How this shell reaches the Ekklesia API.
|
||||
@@ -640,6 +645,11 @@ export function moduleDisplayDescriptorPath(moduleId: string): string {
|
||||
export interface DashboardNavigationEntry {
|
||||
readonly name: string;
|
||||
readonly descriptor: HostModuleUiDescriptor;
|
||||
/** The Lucide name the module declared (manifest v9, ADR 0063), or `undefined` for the generic
|
||||
* module icon. */
|
||||
readonly icon: string | undefined;
|
||||
/** Its primary pages, in its declared order; empty for a single-page module. */
|
||||
readonly pages: readonly DashboardModulePage[];
|
||||
}
|
||||
|
||||
export interface DashboardNavigationGroup {
|
||||
@@ -662,12 +672,48 @@ export interface DashboardNavigation {
|
||||
readonly ungrouped: readonly DashboardNavigationEntry[];
|
||||
}
|
||||
|
||||
function parseNavigationEntry(entry: unknown): DashboardNavigationEntry {
|
||||
const name = nestedString(entry, "name");
|
||||
if (name === undefined || !isRecord(entry)) {
|
||||
/** Strict: the module's declared icon (`null` for none) and pages, each in the manifest's grammar,
|
||||
* with no repeated page id or path. */
|
||||
function parseModuleNavigation(value: unknown): {
|
||||
readonly icon: string | undefined;
|
||||
readonly pages: readonly DashboardModulePage[];
|
||||
} {
|
||||
if (
|
||||
!hasExactKeys(value, ["icon", "pages"]) ||
|
||||
(value.icon !== null && !isModuleIconName(value.icon)) ||
|
||||
!Array.isArray(value.pages) ||
|
||||
value.pages.length > 12 ||
|
||||
!value.pages.every(isDashboardModulePage)
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze({ descriptor: parseDescriptor(entry.module), name });
|
||||
const pages = value.pages as readonly DashboardModulePage[];
|
||||
if (
|
||||
new Set(pages.map((page) => page.id)).size !== pages.length ||
|
||||
new Set(pages.map((page) => page.path)).size !== pages.length
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze({
|
||||
icon: value.icon === null ? undefined : (value.icon as string),
|
||||
pages: Object.freeze(
|
||||
pages.map((page) => Object.freeze({ id: page.id, label: page.label, path: page.path })),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function parseNavigationEntry(entry: unknown): DashboardNavigationEntry {
|
||||
const name = nestedString(entry, "name");
|
||||
if (name === undefined || !hasExactKeys(entry, ["module", "name", "navigation"])) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
const navigation = parseModuleNavigation(entry.navigation);
|
||||
return Object.freeze({
|
||||
descriptor: parseDescriptor(entry.module),
|
||||
icon: navigation.icon,
|
||||
name,
|
||||
pages: navigation.pages,
|
||||
});
|
||||
}
|
||||
|
||||
/** Strict, total validation, exactly like the inventory parser: a malformed group or entry fails
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ModuleUiApiClient,
|
||||
type ModuleUiContributions,
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiNavigation,
|
||||
type ModuleUiPrincipal,
|
||||
type ModuleUiRealtimeClient,
|
||||
type ModuleUiRealtimeError,
|
||||
@@ -272,6 +273,9 @@ export interface CreateModuleUiContextOptions {
|
||||
/** Test-only seam, forwarded to the generic contribution loader so a test can drive a contributed
|
||||
* renderer without a real network. Production composition leaves it unset. */
|
||||
readonly importModule?: (url: string) => Promise<unknown>;
|
||||
/** The module's navigation capability (ADR 0063), from a host whose pages are addressable — the
|
||||
* Dashboard. Omitted, the context carries `navigation: undefined`. */
|
||||
readonly navigation?: ModuleUiNavigation;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,6 +360,9 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
|
||||
: Object.freeze({ url: resolveApiUrl(options.config, options.descriptor.displayPageUrl) }),
|
||||
moduleId: options.descriptor.id,
|
||||
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
||||
// Only a host with addressable module pages supplies navigation (ADR 0063); the display host
|
||||
// shows one screen and supplies none.
|
||||
navigation: options.navigation,
|
||||
principal: options.principal,
|
||||
realtime: createModuleRealtimeClient(options.config, options.descriptor),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The Lucide icons the Dashboard can draw for a module (manifest v9, ADR 0063), by Lucide name.
|
||||
*
|
||||
* The same curated set `@ekklesia/contracts` validates manifests against (`MODULE_NAVIGATION_ICONS`).
|
||||
* A surface may not import that package — its type graph is the server's (ADR 0024) — so the list is
|
||||
* repeated here, and `apps/api` tests hold the two equal: the platform cannot accept an icon the
|
||||
* Dashboard cannot draw. The browser's map from these names to Lucide components is
|
||||
* `browser/module-icon.tsx`, which imports exactly these icons and no others.
|
||||
*
|
||||
* Framework-free and exported on its own subpath, so the server's parity test reads the list without
|
||||
* importing React.
|
||||
*/
|
||||
export const DASHBOARD_MODULE_ICON_NAMES = Object.freeze([
|
||||
"activity",
|
||||
"archive",
|
||||
"baby",
|
||||
"bell",
|
||||
"book-open",
|
||||
"book-open-text",
|
||||
"bookmark",
|
||||
"briefcase",
|
||||
"building",
|
||||
"bus",
|
||||
"calendar",
|
||||
"calendar-check",
|
||||
"calendar-days",
|
||||
"camera",
|
||||
"car",
|
||||
"chart-column",
|
||||
"chart-pie",
|
||||
"church",
|
||||
"clipboard-check",
|
||||
"clipboard-list",
|
||||
"clock",
|
||||
"coffee",
|
||||
"contact",
|
||||
"credit-card",
|
||||
"database",
|
||||
"door-open",
|
||||
"file-text",
|
||||
"files",
|
||||
"film",
|
||||
"flag",
|
||||
"folder",
|
||||
"gift",
|
||||
"globe",
|
||||
"graduation-cap",
|
||||
"hand-coins",
|
||||
"hand-heart",
|
||||
"handshake",
|
||||
"headphones",
|
||||
"heart",
|
||||
"heart-handshake",
|
||||
"house",
|
||||
"image",
|
||||
"images",
|
||||
"inbox",
|
||||
"key-round",
|
||||
"landmark",
|
||||
"layout-grid",
|
||||
"library",
|
||||
"list-checks",
|
||||
"list-todo",
|
||||
"mail",
|
||||
"map",
|
||||
"map-pin",
|
||||
"megaphone",
|
||||
"message-circle",
|
||||
"message-square",
|
||||
"mic",
|
||||
"monitor",
|
||||
"monitor-play",
|
||||
"music",
|
||||
"newspaper",
|
||||
"notebook",
|
||||
"package",
|
||||
"palette",
|
||||
"party-popper",
|
||||
"phone",
|
||||
"presentation",
|
||||
"printer",
|
||||
"projector",
|
||||
"radio",
|
||||
"receipt",
|
||||
"school",
|
||||
"send",
|
||||
"shield",
|
||||
"shopping-bag",
|
||||
"sparkles",
|
||||
"star",
|
||||
"sun",
|
||||
"tablet-smartphone",
|
||||
"tag",
|
||||
"tent",
|
||||
"ticket",
|
||||
"trophy",
|
||||
"truck",
|
||||
"tv",
|
||||
"user-check",
|
||||
"user-round",
|
||||
"users",
|
||||
"users-round",
|
||||
"utensils",
|
||||
"video",
|
||||
"wallet",
|
||||
"wrench",
|
||||
] as const);
|
||||
|
||||
export type DashboardModuleIconName = (typeof DASHBOARD_MODULE_ICON_NAMES)[number];
|
||||
|
||||
const iconNames: ReadonlySet<string> = new Set(DASHBOARD_MODULE_ICON_NAMES);
|
||||
|
||||
export function isDashboardModuleIconName(value: unknown): value is DashboardModuleIconName {
|
||||
return typeof value === "string" && iconNames.has(value);
|
||||
}
|
||||
@@ -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