Stage 1: module navigation contract — manifest v9, module routes, return-to-origin #110
@@ -532,8 +532,10 @@ reported `blocked · configuration_missing · bucket` — never imported, never
|
||||
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||||
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
||||
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
||||
manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a
|
||||
hard cutover; a version 7 source module or package must be updated and rebuilt.
|
||||
manifest was version 8 after ADR 0050 added the optional database ABI declaration, a hard cutover
|
||||
that requires a version 7 source module or package to be updated and rebuilt. Version 9 (ADR 0063)
|
||||
adds optional module navigation, and is the first version that keeps its predecessor accepted:
|
||||
released version 8 modules run unchanged.
|
||||
|
||||
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
||||
no key management, so a `secret` key's value is supplied by the deployment
|
||||
@@ -951,6 +953,15 @@ and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring
|
||||
Settings page. A person sees only what the server says they may administer, and no Settings link at all
|
||||
when that is nothing. The pages are placeholders until each section is built.
|
||||
|
||||
**Modules are first-class parts of the Dashboard**
|
||||
([ADR 0063](docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). A module
|
||||
declares its Lucide icon and its primary pages in manifest version 9, and the sidebar shows those
|
||||
pages beneath it while it is open. Every page has a real address: `/signage/timelines`, and routes
|
||||
beneath a page such as `/media/sermons/123`. A refresh, a bookmark, and Back and Forward all stay put.
|
||||
A module moves between its own pages, and opens its Settings page, through a narrow host navigation
|
||||
capability. Settings' Back returns to exactly the module page that opened it. Released manifest
|
||||
version 8 modules keep working unchanged, with the generic icon and a single page.
|
||||
|
||||
**Your account** is at the bottom of the sidebar, showing your name, email, and initials
|
||||
([ADR 0062](docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
|
||||
`/account` shows who you are signed in as. On `/account/appearance` you choose **System, Light, or
|
||||
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import { describeMountableModules, type HostModuleUiDescriptor } from "./module-ui-inventory.js";
|
||||
import {
|
||||
describeMountableModules,
|
||||
type HostModuleUiDescriptor,
|
||||
type MountableModuleDescription,
|
||||
} from "./module-ui-inventory.js";
|
||||
|
||||
export const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
|
||||
const DASHBOARD_GROUPS_PATH = `${DASHBOARD_NAVIGATION_PATH}/groups`;
|
||||
@@ -41,6 +45,20 @@ const DASHBOARD_PLACEMENTS_PATH = `${DASHBOARD_NAVIGATION_PATH}/placements`;
|
||||
export interface DashboardNavigationModuleView {
|
||||
readonly name: string;
|
||||
readonly module: HostModuleUiDescriptor;
|
||||
/**
|
||||
* What the module declares about its own pages (manifest v9, ADR 0063): the Lucide icon the
|
||||
* Dashboard draws it with (`null` for the generic one) and its primary pages, in declared order —
|
||||
* empty for a single-page module, which every manifest v8 module is. The module's identity, never
|
||||
* its placement: where it sits in the sidebar stays the administrator's (ADR 0027).
|
||||
*/
|
||||
readonly navigation: {
|
||||
readonly icon: string | null;
|
||||
readonly pages: readonly {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly path: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardNavigationGroupView {
|
||||
@@ -203,10 +221,18 @@ export function createDashboardRoutes(options: CreateDashboardRoutesOptions): Ho
|
||||
configuration,
|
||||
descriptors.map((entry) => ({ id: entry.descriptor.id, ...entry })),
|
||||
);
|
||||
const present = (entry: {
|
||||
readonly descriptor: HostModuleUiDescriptor;
|
||||
readonly name: string;
|
||||
}): DashboardNavigationModuleView => ({ module: entry.descriptor, name: entry.name });
|
||||
const present = (entry: MountableModuleDescription): DashboardNavigationModuleView => ({
|
||||
module: entry.descriptor,
|
||||
name: entry.name,
|
||||
navigation: {
|
||||
icon: entry.navigation.icon ?? null,
|
||||
pages: entry.navigation.pages.map((page) => ({
|
||||
id: page.id,
|
||||
label: page.label,
|
||||
path: page.path,
|
||||
})),
|
||||
},
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
groups: view.groups.map((group) => ({
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||
import type {
|
||||
EkklesiaPrincipal,
|
||||
ModuleNavigationDeclaration,
|
||||
PermissionDeclaration,
|
||||
} from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
||||
import type {
|
||||
ModuleUiPublicationRegistry,
|
||||
@@ -196,6 +200,10 @@ export interface MountableModuleDescription {
|
||||
* Dashboard owns (ADR 0027). Deliberately absent from `HostModuleUiDescriptor`, so the
|
||||
* host-neutral contract a standalone host consumes stays free of presentation concerns. */
|
||||
readonly name: string;
|
||||
/** The module's own declared icon and primary pages (manifest v9, ADR 0063); empty for one that
|
||||
* declares none. Presentation identity like `name`, and kept beside the descriptor for the same
|
||||
* reason. */
|
||||
readonly navigation: ModuleNavigationDeclaration;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,6 +224,7 @@ export async function describeMountableModules(
|
||||
registry.list().map(async (published) => ({
|
||||
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||
name: published.name,
|
||||
navigation: published.navigation,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,13 @@ import {
|
||||
DashboardHostConfigurationError,
|
||||
createDashboardHostRoutes,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import { DASHBOARD_PLATFORM_PAGES, SETTINGS_SECTIONS } from "@ekklesia/shell/dashboard-routes";
|
||||
import { MODULE_NAVIGATION_ICONS } from "@ekklesia/contracts";
|
||||
import {
|
||||
DASHBOARD_PLATFORM_PAGES,
|
||||
HOST_NAMESPACES,
|
||||
SETTINGS_SECTIONS,
|
||||
} from "@ekklesia/shell/dashboard-routes";
|
||||
import { DASHBOARD_MODULE_ICON_NAMES } from "@ekklesia/shell/module-icons";
|
||||
import {
|
||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||
MODULE_DISPLAY_PAGE_PREFIX,
|
||||
@@ -417,6 +423,14 @@ describe("the platform's reserved top-level names (ADR 0057)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("are exactly the Dashboard's platform pages plus the host namespaces it never treats as pages (ADR 0063)", () => {
|
||||
// With module routes nested beneath `/<module-id>/`, the shell must know every other top-level
|
||||
// name: `/api/v1/principal` must never read as the route `v1/principal` of a module called `api`.
|
||||
expect([...DASHBOARD_PLATFORM_PAGES, ...HOST_NAMESPACES].sort()).toEqual([
|
||||
...RESERVED_TOP_LEVEL_NAMES,
|
||||
]);
|
||||
});
|
||||
|
||||
it("include exactly the Settings sections the shell interprets, so /settings/<module-id> never collides (ADR 0061)", () => {
|
||||
expect([...SETTINGS_SECTIONS].sort()).toEqual([...RESERVED_SETTINGS_NAMES]);
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
@@ -630,3 +644,10 @@ describe("Dashboard host boundaries", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("module navigation icons (ADR 0063)", () => {
|
||||
it("are the same set in the manifest contract and in the Dashboard that draws them", () => {
|
||||
// The platform cannot accept an icon the Dashboard cannot draw, nor draw one it would refuse.
|
||||
expect([...DASHBOARD_MODULE_ICON_NAMES]).toEqual([...MODULE_NAVIGATION_ICONS]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,8 @@ interface ModuleFixture {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly withUi?: boolean;
|
||||
/** Present, the fixture is a manifest v9 module declaring this navigation (ADR 0063). */
|
||||
readonly navigation?: unknown;
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
@@ -78,8 +80,9 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 8,
|
||||
manifestVersion: fixture.navigation === undefined ? 8 : 9,
|
||||
name: fixture.name,
|
||||
...(fixture.navigation === undefined ? {} : { navigation: fixture.navigation }),
|
||||
version: "1.0.0",
|
||||
}),
|
||||
);
|
||||
@@ -170,6 +173,14 @@ function signedIn(): EkklesiaPrincipal {
|
||||
|
||||
interface NavigationEntry {
|
||||
readonly name: string;
|
||||
readonly navigation: {
|
||||
readonly icon: string | null;
|
||||
readonly pages: readonly {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly path: string;
|
||||
}[];
|
||||
};
|
||||
readonly module: {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
@@ -284,7 +295,68 @@ describe("Dashboard navigation — reading", () => {
|
||||
ui: { url: "/api/v1/module-ui/livestream/1.0.0" },
|
||||
version: "1.0.0",
|
||||
});
|
||||
expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name"]);
|
||||
expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name", "navigation"]);
|
||||
// A module that declares no navigation — this manifest v8 fixture — is drawn with the generic
|
||||
// icon and has its root as its only page (ADR 0063). Never a group or an order.
|
||||
expect(livestream?.navigation).toEqual({ icon: null, pages: [] });
|
||||
});
|
||||
|
||||
it("carries a manifest v9 module's own icon and pages beside its descriptor, in declared order", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [
|
||||
{
|
||||
id: "signage",
|
||||
name: "Signage",
|
||||
navigation: {
|
||||
icon: "monitor-play",
|
||||
pages: [
|
||||
{ id: "playback", label: "Playback", path: "playback" },
|
||||
{ id: "timelines", label: "Timelines", path: "timelines" },
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ id: "calendar", name: "Calendar" },
|
||||
],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const navigation = await navigationOf(app);
|
||||
const signage = navigation.ungrouped.find((entry) => entry.module.id === "signage");
|
||||
const calendar = navigation.ungrouped.find((entry) => entry.module.id === "calendar");
|
||||
|
||||
expect(signage?.navigation).toEqual({
|
||||
icon: "monitor-play",
|
||||
pages: [
|
||||
{ id: "playback", label: "Playback", path: "playback" },
|
||||
{ id: "timelines", label: "Timelines", path: "timelines" },
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
],
|
||||
});
|
||||
// The host-neutral descriptor is untouched by navigation: it lives beside it, never inside.
|
||||
expect(Object.keys(signage?.module ?? {}).sort()).toEqual(
|
||||
Object.keys(calendar?.module ?? {}).sort(),
|
||||
);
|
||||
// A manifest v8 module beside it keeps the generic icon and its single root page.
|
||||
expect(calendar?.navigation).toEqual({ icon: null, pages: [] });
|
||||
});
|
||||
|
||||
it("refuses at discovery a v9 module whose navigation is unsafe, so it never reaches the sidebar", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [
|
||||
{ id: "calendar", name: "Calendar" },
|
||||
{
|
||||
id: "escape",
|
||||
name: "Escape",
|
||||
navigation: { pages: [{ id: "out", label: "Out", path: "../settings" }] },
|
||||
},
|
||||
],
|
||||
principal: signedIn(),
|
||||
}).catch((error: unknown) => ({ app: undefined, error }));
|
||||
|
||||
// Discovery is atomic (ADR 0005): an invalid manifest fails the whole inventory rather than
|
||||
// quietly dropping one module, exactly as for any other manifest error.
|
||||
expect(app).toBeUndefined();
|
||||
});
|
||||
|
||||
it("agrees exactly with the generic inventory about what is mountable", async () => {
|
||||
|
||||
@@ -479,6 +479,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
"contributions",
|
||||
"display",
|
||||
"moduleId",
|
||||
"navigation",
|
||||
"permissions",
|
||||
"principal",
|
||||
"realtime",
|
||||
|
||||
@@ -953,6 +953,7 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId: "announcements",
|
||||
navigation: undefined,
|
||||
permissions: { has: () => false },
|
||||
principal: { kind: "anonymous" as const },
|
||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
isVersionCompatible,
|
||||
type ModuleNavigationDeclaration,
|
||||
moduleManifestNavigation,
|
||||
type PermissionDeclaration,
|
||||
type PermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -24,10 +26,18 @@ export interface PublishableModuleUi {
|
||||
*
|
||||
* Carried here so a host can render something a person recognizes instead of a module id. It is
|
||||
* presentation *identity*, not presentation *placement*: a module says what it is called, while
|
||||
* where it appears in a Dashboard is configuration an administrator owns. Nothing about grouping,
|
||||
* ordering, or navigation belongs on this record.
|
||||
* where it appears in a Dashboard is configuration an administrator owns. Nothing about grouping
|
||||
* or ordering belongs on this record.
|
||||
*/
|
||||
readonly name: string;
|
||||
/**
|
||||
* What the module declares about its own pages (manifest v9, ADR 0063): the Lucide icon it is
|
||||
* drawn with and its primary pages, in its declared order. Like `name`, this is the module's own
|
||||
* identity — what it is and which pages it has — never where it sits in the Dashboard, which stays
|
||||
* the administrator's (ADR 0027). Empty for a manifest v8 module and for one that declares none:
|
||||
* the generic icon and a single root page.
|
||||
*/
|
||||
readonly navigation: ModuleNavigationDeclaration;
|
||||
readonly artifactPath: string;
|
||||
/**
|
||||
* The module's own manifest-declared permissions, carried here so a publisher can compute the
|
||||
@@ -44,6 +54,18 @@ export interface PublishableModuleUi {
|
||||
readonly permissions: readonly PermissionDeclaration[];
|
||||
}
|
||||
|
||||
/** A fresh, deeply frozen copy, so a caller holding a record never reaches the manifest graph. */
|
||||
function frozenNavigation(navigation: ModuleNavigationDeclaration): ModuleNavigationDeclaration {
|
||||
return Object.freeze({
|
||||
...(navigation.icon === undefined ? {} : { icon: navigation.icon }),
|
||||
pages: Object.freeze(
|
||||
navigation.pages.map((page) =>
|
||||
Object.freeze({ id: page.id, label: page.label, path: page.path }),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One installed module whose dedicated full-viewport display artifact may be published.
|
||||
*
|
||||
@@ -233,6 +255,7 @@ export function createModuleUiPublicationRegistry(
|
||||
artifactPath,
|
||||
moduleId: module.manifest.id,
|
||||
name: module.manifest.name,
|
||||
navigation: frozenNavigation(moduleManifestNavigation(module.manifest)),
|
||||
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
|
||||
// this record can never reach the manifest object graph itself.
|
||||
permissions: Object.freeze([...module.manifest.permissions]),
|
||||
|
||||
@@ -219,20 +219,25 @@ describe("Dashboard grouping never becomes module structure", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the publication record free of any grouping, ordering, or navigation field", async () => {
|
||||
it("keeps the publication record free of any grouping or ordering field", async () => {
|
||||
const registry = createModuleUiPublicationRegistry(await discoverFixtures());
|
||||
|
||||
const published = registry.resolve("livestream", "1.0.0");
|
||||
|
||||
// The record a host mounts from carries identity, the validated artifact, declared permissions,
|
||||
// and the module's own name — never a group, an order, a parent, or a folder.
|
||||
// the module's own name, and its own declared navigation — never a group, an order, a parent,
|
||||
// or a folder. Navigation is what the module *is* (its icon and pages, ADR 0063); where it sits
|
||||
// stays the administrator's (ADR 0027).
|
||||
expect(Object.keys(published ?? {}).sort()).toEqual([
|
||||
"artifactPath",
|
||||
"moduleId",
|
||||
"name",
|
||||
"navigation",
|
||||
"permissions",
|
||||
"version",
|
||||
]);
|
||||
expect(Object.keys(published?.navigation ?? {}).sort()).toEqual(["pages"]);
|
||||
expect(published?.navigation.pages).toEqual([]);
|
||||
});
|
||||
|
||||
it("takes the display name from the module's own manifest, not from any folder", async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { moduleManifestNavigation } from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
discoverModules,
|
||||
@@ -1220,3 +1222,107 @@ describe("browser contribution artifact resolution", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("manifest v8 and v9 side by side (ADR 0063)", () => {
|
||||
const releasedSignageManifest = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"packages",
|
||||
"contracts",
|
||||
"tests",
|
||||
"fixtures",
|
||||
"released-v8-signage-0.1.5.module.json",
|
||||
);
|
||||
|
||||
it("discovers the released Signage 0.1.5 module exactly as published, with no navigation", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const manifest = JSON.parse(await readFile(releasedSignageManifest, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
await createModule(modulesDirectory, "signage", {
|
||||
extraFiles: {
|
||||
"dist/display/index.js": "export default {};\n",
|
||||
"dist/schema/index.js": "export {};\n",
|
||||
"dist/ui/index.js": "export default {};\n",
|
||||
"drizzle/meta/_journal.json": JSON.stringify({
|
||||
dialect: "postgresql",
|
||||
entries: [],
|
||||
version: "7",
|
||||
}),
|
||||
},
|
||||
manifest,
|
||||
packageJson: {
|
||||
ekklesia: { layer: "module" },
|
||||
name: "@zayin/signage",
|
||||
type: "module",
|
||||
version: manifest.version,
|
||||
},
|
||||
serverSource: 'export default { id: "signage", version: "0.1.5" };',
|
||||
});
|
||||
|
||||
const [signage] = await discoverModules({
|
||||
moduleSdkVersion: "0.2.5",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.2.5",
|
||||
});
|
||||
|
||||
expect(signage?.manifest.manifestVersion).toBe(8);
|
||||
expect(signage?.entrypoints.ui).toBeDefined();
|
||||
expect(moduleManifestNavigation(signage?.manifest ?? { manifestVersion: 8 })).toEqual({
|
||||
pages: [],
|
||||
});
|
||||
// Its settings, which Settings → Modules lists (ADR 0061), are read exactly as before.
|
||||
expect(signage?.manifest.configuration.map((declaration) => declaration.key)).toEqual(
|
||||
(manifest.configuration as { key: string }[]).map((declaration) => declaration.key),
|
||||
);
|
||||
});
|
||||
|
||||
it("discovers a manifest v9 module with its declared icon and pages", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "media", {
|
||||
extraFiles: { "dist/ui/index.js": "export default {};\n" },
|
||||
manifest: {
|
||||
...manifestFor("media"),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
manifestVersion: 9,
|
||||
navigation: {
|
||||
icon: "images",
|
||||
pages: [
|
||||
{ id: "sermons", label: "Sermons", path: "sermons" },
|
||||
{ id: "library", label: "Library", path: "library" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [media] = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
expect(media?.manifest.manifestVersion).toBe(9);
|
||||
const navigation = moduleManifestNavigation(media?.manifest ?? { manifestVersion: 9 });
|
||||
expect(navigation.icon).toBe("images");
|
||||
expect(navigation.pages.map((page) => page.path)).toEqual(["sermons", "library"]);
|
||||
// Deeply frozen like the rest of a discovered manifest.
|
||||
expect(Object.isFrozen(navigation.pages)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a manifest v9 module whose navigation escapes its own namespace", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "media", {
|
||||
extraFiles: { "dist/ui/index.js": "export default {};\n" },
|
||||
manifest: {
|
||||
...manifestFor("media"),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
manifestVersion: 9,
|
||||
navigation: { pages: [{ id: "out", label: "Out", path: "../settings" }] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(issueCodes(await discoveryError(modulesDirectory))).toEqual(["MODULE_MANIFEST_INVALID"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-21
|
||||
- Navigation member decided by: [0063](0063-module-navigation-contract-manifest-v9.md)
|
||||
- Extends: [0021](0021-modules-as-hostable-products.md)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-22
|
||||
- Module icon and primary pages superseded by: [0063](0063-module-navigation-contract-manifest-v9.md) — a module now declares its own icon and pages; groups and ordering stay the administrator's
|
||||
- Extends: [0021](0021-modules-as-hostable-products.md),
|
||||
[0026](0026-minimal-ekklesia-module-ui-host.md)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-07
|
||||
- Manifest version acceptance extended by: [0063](0063-module-navigation-contract-manifest-v9.md) — version 8 stays accepted beside version 9
|
||||
- Supersedes (partial): [0016](0016-module-database-access.md) — for modules that declare ABI v1,
|
||||
only its definition of the module database service as `Pick<NodePgDatabase, …>`, a facade over
|
||||
Core's Drizzle instance, and its rule that every module receives the same `services` object.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-14
|
||||
- Module-declared icons and pages decided by: [0063](0063-module-navigation-contract-manifest-v9.md)
|
||||
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[0027](0027-dashboard-navigation-and-flat-modules.md),
|
||||
[0039](0039-shared-design-tokens-and-elements.md),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-15
|
||||
- Return-target carrier decided by: [0063](0063-module-navigation-contract-manifest-v9.md)
|
||||
- Extends: [0034](0034-host-owned-browser-authentication.md),
|
||||
[0039](0039-shared-design-tokens-and-elements.md),
|
||||
[0040](0040-module-stylesheets-and-artifact-isolation.md),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-15
|
||||
- Module → Settings return link decided by: [0063](0063-module-navigation-contract-manifest-v9.md)
|
||||
- Extends: [0027](0027-dashboard-navigation-and-flat-modules.md),
|
||||
[0028](0028-module-operational-lifecycle.md),
|
||||
[0029](0029-module-configuration-and-secrets.md),
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# 0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-15
|
||||
- Extends: [0022](0022-module-ui-authoring-and-host-contract.md),
|
||||
[0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[0050](0050-module-database-abi-v1.md),
|
||||
[0057](0057-dashboard-urls-are-host-owned-routes.md),
|
||||
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
|
||||
[0059](0059-shared-interaction-primitives.md),
|
||||
[0061](0061-settings-hierarchy-and-module-settings-routes.md)
|
||||
- Supersedes, in part: [0027](0027-dashboard-navigation-and-flat-modules.md) (a module now declares
|
||||
its own icon and primary pages; grouping and ordering stay the administrator's)
|
||||
|
||||
## Context
|
||||
|
||||
DESIGN.md treats a module as a first-class application area:
|
||||
|
||||
- its primary pages belong in the global sidebar beneath it, with no second tab strip inside the
|
||||
module (§4.4, locked decision 11);
|
||||
- every meaningful page has a real route, so a refresh, a bookmark, Back and Forward work (§2.7,
|
||||
locked decision 12);
|
||||
- a module can deep-link to its centralized Settings page and come back to exactly the workflow
|
||||
that sent the person there (§2.7, §9.3, locked decision 13).
|
||||
|
||||
The platform had none of this:
|
||||
|
||||
- **Icons.** Every module was drawn with one generic icon. ADR 0058 left a module's own icon to
|
||||
"the module navigation contract (manifest v9)".
|
||||
- **Routes.** `/<module-id>` was a module's only route, and everything beneath it was not-found
|
||||
(ADR 0057).
|
||||
- **Navigation.** The module UI context had no navigation member. ADR 0022 deferred it because "it
|
||||
needs a design that does not exist", and modules may not bring a router (ADR 0026).
|
||||
- **Return target.** ADR 0059 built and validated `parseReturnTarget`, and ADR 0061 gave every
|
||||
module's settings `/settings/<module-id>`, but both deferred *how* a link carries a return
|
||||
target.
|
||||
|
||||
Released modules (Signage 0.1.5 among them) are packaged, signed, and installed as manifest version
|
||||
8, and every earlier manifest version was rejected outright by its successor.
|
||||
|
||||
## Decision
|
||||
|
||||
### Manifest v9: one optional `navigation` field
|
||||
|
||||
Manifest version 8's top-level object is strict, so a new field is a new version. Version 9 is
|
||||
version 8 plus one optional field:
|
||||
|
||||
```json
|
||||
"navigation": {
|
||||
"icon": "monitor-play",
|
||||
"pages": [
|
||||
{ "id": "playback", "label": "Playback", "path": "playback" },
|
||||
{ "id": "timelines", "label": "Timelines", "path": "timelines" },
|
||||
{ "id": "media", "label": "Media", "path": "media" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **`icon`** (optional) is a Lucide name from a curated set (below). Omitted, the module is drawn
|
||||
with the generic module icon.
|
||||
- **`pages`** (optional, at most 12) are the module's primary pages, in the order the sidebar shows
|
||||
them. Each page has:
|
||||
- **`id`**: stable and never displayed. Lowercase kebab-case, at most 64 characters, unique within
|
||||
the manifest. The navigation API reports it as the current page.
|
||||
- **`label`**: plain text, trimmed, 1–60 characters, with no control or line-separator characters.
|
||||
- **`path`**: relative to the module's root. One to four lowercase kebab-case segments joined by
|
||||
`/`, unique within the manifest. It may not lie beneath another page's path.
|
||||
- **What a path can never be:** a leading or trailing slash, an empty segment, `.` or `..`,
|
||||
percent-encoding, a query, a fragment, or a scheme. A declared path cannot escape `/<module-id>/`
|
||||
or name anything outside the module.
|
||||
- **Requires a UI.** `navigation` is only valid with `entrypoints.ui`.
|
||||
- **Validated before activation.** Validation lives in `@ekklesia/contracts`, so discovery,
|
||||
`pnpm modules:validate`, the packager, and package verification all refuse a bad declaration
|
||||
before the module can start.
|
||||
- **No page icons.** The collapsed sidebar shows the module item alone, so a page icon would never
|
||||
be seen.
|
||||
- **Nothing else is added.** No groups, order, Overview widgets, or settings declaration: a module
|
||||
already has settings exactly when it declares `configuration` (ADR 0029, ADR 0061).
|
||||
|
||||
`moduleManifestNavigation(manifest)` reads the declaration the same way for every version. A version
|
||||
8 manifest, and a version 9 manifest that omits the field, both have no icon and no pages.
|
||||
|
||||
### Version 8 stays accepted beside version 9
|
||||
|
||||
Until now every manifest version rejected its predecessor ("no dual acceptance": ADR 0006, ADR 0014,
|
||||
ADR 0021, ADR 0050). This decision is the first exception, deliberately:
|
||||
|
||||
- **Why.** Version 9 adds one optional field and changes the meaning of nothing a version 8 document
|
||||
says. Rejecting version 8 would stop every installed module at its next upgrade for no safety gain.
|
||||
- **One document, one version.** `manifestVersion` selects one strict schema (a discriminated union
|
||||
of 8 and 9). A document is only ever read as the version it declares. A version 8 document that
|
||||
names `navigation` is refused, never quietly read as version 9.
|
||||
- **Nothing is rewritten.** The packager still copies manifest bytes verbatim, and verification
|
||||
checks them against the package digest. Core never writes a manifest back.
|
||||
- **The template moves to version 9.** `MODULE_MANIFEST_VERSION` is now 9, the version a new module
|
||||
declares. `SUPPORTED_MODULE_MANIFEST_VERSIONS` is `[8, 9]`. Version 7 and earlier are still
|
||||
refused.
|
||||
|
||||
How long version 8 stays accepted is a later decision. It needs its own ADR.
|
||||
|
||||
### Icons: a curated Lucide set, named in the manifest, drawn by the host
|
||||
|
||||
A manifest names an icon. It never supplies one: no SVG, URL, component, or code.
|
||||
|
||||
- **The set.** `MODULE_NAVIGATION_ICONS` (`@ekklesia/contracts`) is about ninety Lucide names chosen
|
||||
for generic subjects (`calendar`, `monitor-play`, `users`, `book-open`, …). There is no entry for
|
||||
any particular module, and no module-to-icon table anywhere.
|
||||
- **Validated against the set.** A manifest's `icon` must be in it, so a typo is caught when a
|
||||
module is packaged or discovered, not drawn silently as the generic icon.
|
||||
- **Imported by name.** The Dashboard imports exactly these icons from `lucide-react`
|
||||
(`browser/module-icon.tsx`) and never bundles the library's thousands of others.
|
||||
- **One list, held together by tests.** A surface may not import `@ekklesia/contracts` (ADR 0024),
|
||||
so the shell keeps the names in a framework-free file, `@ekklesia/shell/module-icons`.
|
||||
- An `apps/api` test holds the shell's list equal to `MODULE_NAVIGATION_ICONS`, so the platform
|
||||
cannot accept an icon the Dashboard cannot draw.
|
||||
- A shell test proves every entry is the real Lucide component of that name.
|
||||
- **Growing the set is additive.** It needs a platform release, and a module that uses a new name
|
||||
declares the platform range that has it.
|
||||
- **Fallback.** A host that cannot draw a name draws the generic icon instead of failing. The
|
||||
Dashboard can draw all of them; another host might not.
|
||||
- **Decorative only.** Icons never carry meaning (ADR 0058). The module's name is always beside the
|
||||
icon or in its control's accessible name.
|
||||
|
||||
### Module routes
|
||||
|
||||
```text
|
||||
/<module-id> the module's landing view (its root)
|
||||
/<module-id>/<page-path> a declared primary page
|
||||
/<module-id>/<page-path>/… a route beneath a declared page: a detail view the module owns
|
||||
```
|
||||
|
||||
**The root stays the canonical landing view and is never itself a declared page.** A page may not
|
||||
use the root's path, so no page has two addresses, and `/<module-id>` never silently redirects.
|
||||
|
||||
- **Landing on a page.** A module that wants to land on, say, Playback renders Playback at its root
|
||||
and simply doesn't declare a Playback page.
|
||||
- **Beneath a declared page.** Any route under a page (`/signage/media/sermons/123`) mounts the
|
||||
module with that full path. Detail views therefore need no manifest or sidebar entry. Primary
|
||||
navigation and every valid internal route are deliberately different things.
|
||||
- **What a route can contain.** Segments are unreserved URL characters only (letters, digits, `-`,
|
||||
`.`, `_`, `~`), at most 8 segments and 512 characters. Nothing else is a route.
|
||||
- **Unknown paths.** A path that is neither the root nor at or beneath a declared page is the
|
||||
host's *Page not found*, and the module is not mounted. Nothing silently opens some other page.
|
||||
- **Version 8 modules.** A module that declares no pages, which includes every version 8 module, has
|
||||
its root and nothing beneath it, exactly as before.
|
||||
- **Disabled modules.** A module that is installed but not running gets *This page isn't available*
|
||||
at its root and at every path beneath it, and nothing mounts. A module that isn't installed is not
|
||||
served at all (ADR 0057).
|
||||
- **No remount.** The mounted module is keyed by identity, not path, so moving between its own pages
|
||||
never remounts it. Focus moves to the page on each move, as for any page change.
|
||||
|
||||
The API already serves the Dashboard page throughout `/<module-id>/*` (ADR 0057), so none of this
|
||||
needs a server or proxy change.
|
||||
|
||||
**The host's own namespaces are never pages.** `api`, `assets`, and `display` (`HOST_NAMESPACES`)
|
||||
are never Dashboard pages at any depth, so `/api/v1/principal` can never read as the route
|
||||
`v1/principal` of a module called `api`. Together with the Dashboard's platform pages they are
|
||||
exactly Core's `RESERVED_TOP_LEVEL_NAMES`, and an `apps/api` test holds the lists together.
|
||||
|
||||
### The sidebar
|
||||
|
||||
- **A version 9 module** is drawn with its own icon. While it is open (its root, a page, or beneath
|
||||
one), its pages appear beneath it, the same in-place expansion Settings uses (ADR 0061).
|
||||
- **The current item.** Exactly one item carries `aria-current="page"`: the open page, or the
|
||||
module itself on its landing view. A detail route marks its page.
|
||||
- **Collapsed sidebar.** It shows icons only, so the pages stay hidden. The module item is then the
|
||||
current one, named by its tooltip and accessible name.
|
||||
- **Phone drawer.** It has the identical hierarchy and closes once a page is chosen.
|
||||
- **A version 8 module** is one item with the generic icon, as before.
|
||||
- **A disabled module** is absent, as before.
|
||||
|
||||
The server sends each module's declared icon (`null` for none) and pages beside its name in
|
||||
`GET /api/v1/dashboard/navigation`, never inside the host-neutral descriptor (ADR 0026, ADR 0027).
|
||||
The shell parses both strictly.
|
||||
|
||||
### The module's navigation capability
|
||||
|
||||
`ModuleUiHostContext.navigation` (`@ekklesia/module-ui-sdk`) is a narrow host capability, not a
|
||||
router. It is `undefined` where a host has no addressable pages: the display host, and a standalone
|
||||
host without routes.
|
||||
|
||||
```ts
|
||||
interface ModuleUiNavigation {
|
||||
location(): { path: string; page: string | undefined }; // "media/sermons/123", "media"
|
||||
subscribe(listener: () => void): () => void;
|
||||
navigate(path: string, options?: { replace?: boolean }): void;
|
||||
link(path: string): { href: string; onClick(event): void };
|
||||
readonly settings: { open(): void; link(): ModuleUiLink } | undefined;
|
||||
}
|
||||
```
|
||||
|
||||
- **Relative paths only.** Every path is relative to the module's own root, and a leading `/` means
|
||||
the same as none. There is therefore no way to name another module or a platform page.
|
||||
- **Refused paths.** A path the host would not open throws `ModuleUiNavigationError`: malformed or
|
||||
escaping gives `MODULE_NAVIGATION_PATH_INVALID`, and not at or beneath a declared page gives
|
||||
`MODULE_NAVIGATION_PATH_UNKNOWN`. The error message never echoes the path.
|
||||
- **Real links.** `link()` returns a real `href`, so a link can be opened in a new tab, copied, or
|
||||
bookmarked. Its `onClick` opens the route in place on an ordinary click and leaves modified or
|
||||
middle clicks to the browser.
|
||||
- **Location updates.** `location()` returns a stable snapshot between moves. Every subscriber hears
|
||||
every move: the module's own navigation, the sidebar, Back and Forward.
|
||||
- **Settings.** `settings` is present only when the module has a Settings page this viewer may open
|
||||
(it declares configuration, it is running, and Settings lists it for them). It's a presentation
|
||||
hint: Settings enforces its own authority.
|
||||
- **What a module never gets.** No history object, no `window` access, and no way to drive the shell
|
||||
beyond its own routes. Modules still may not bring a router (ADR 0026).
|
||||
- **Version 8 modules too.** They receive navigation with only their root, so a version 8 module
|
||||
rebuilt against the new SDK can already link to its Settings page.
|
||||
|
||||
Adding a context member changes nothing about how an artifact is mounted, so the mount ABI stays at
|
||||
version 2. A module that uses `navigation` checks for it, exactly as it checks `display`.
|
||||
|
||||
### Return-to-origin: one carrier, the `return` query parameter
|
||||
|
||||
The place to come back to travels as **one query parameter on the page that was opened**:
|
||||
|
||||
```text
|
||||
/settings/signage?return=%2Fsignage%2Ftimelines
|
||||
```
|
||||
|
||||
- **It is a query, not history state,** so it survives a refresh and works in a link opened in a new
|
||||
tab. A canonical respelling of the address keeps it.
|
||||
- **The module never builds or checks it.** `navigation.settings` builds it from the module's
|
||||
current location.
|
||||
- **It is only ever read through `parseReturnTarget`** (ADR 0059): a canonical, same-origin
|
||||
Dashboard address with no query, fragment, traversal, or encoded separator. The host namespaces
|
||||
are never accepted.
|
||||
- **A module's address is also checked against what is running.** The module must be running here,
|
||||
and the route must be its root or at or beneath a declared page. Anything else is ignored, and
|
||||
Back goes to the page's own parent, Settings.
|
||||
- **Back follows it.** A Settings page's Back goes to exactly that address ("Back to Signage"), as
|
||||
a real link, never `history.back()`.
|
||||
|
||||
`HostHistory` gains `search()` so the shell can read the query. The shell reads exactly this one
|
||||
parameter from it.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Keep version 8 and add `navigation` to it.** Rejected: version 8's object is strict. Older
|
||||
platforms would refuse such a manifest with a misleading "invalid manifest". A version number
|
||||
says what a document is.
|
||||
- **Reject version 8 as earlier versions were.** Rejected: it would break every installed module
|
||||
for an optional, additive field.
|
||||
- **Any Lucide name, validated only by shape.** Rejected: a typo would silently draw the generic
|
||||
icon, and drawing any name means bundling or code-splitting the whole library. The API serves a
|
||||
closed set of files (ADR 0044), so it cannot serve a thousand chunks.
|
||||
- **Icons as SVG or URLs in the manifest.** Rejected: executable or remote presentation in an inert
|
||||
document, with an asset-trust story nobody needs.
|
||||
- **The root as a declared page, or a redirect from the root to the first page.** Rejected: either
|
||||
gives one page two addresses or surprises a bookmark.
|
||||
- **Every route in the manifest.** Rejected: record URLs (`/media/sermons/123`) cannot be listed.
|
||||
Primary navigation and valid routes are different things.
|
||||
- **A router in the module, or `window.history`.** Rejected: routing belongs to the host
|
||||
(ADR 0026), and two owners of one address bar disagree.
|
||||
- **Return target in history state, `sessionStorage`, or a path segment.** Rejected: history state
|
||||
and storage don't survive a new tab, and a path segment would collide with module routes. A query
|
||||
parameter is visible, refreshable, and validated on every read.
|
||||
- **Page icons.** Not added: the collapsed sidebar never shows pages.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Packages.** `@ekklesia/contracts` gains `module-navigation.ts`, the v8/v9 manifest union, and
|
||||
`moduleManifestNavigation`. `@ekklesia/module-ui-sdk` gains `navigation.ts` and
|
||||
`ModuleUiHostContext.navigation`. `@ekklesia/module-sdk` re-exports the navigation helpers and
|
||||
types. All changes are additive for module code. A host that builds a context itself must now say
|
||||
`navigation: undefined`.
|
||||
- **Core** publishes each module's navigation beside its name. The navigation response gains a
|
||||
`navigation` member per module.
|
||||
- **The shell** reads module routes at any depth, draws icons and pages, and supplies the
|
||||
capability. Its route type's `module` destination gains `path`.
|
||||
- **The template module** is version 9, with two pages, a detail route, and a Settings link.
|
||||
- **Signage** is unaffected until it chooses to move to version 9. Its released 0.1.5 manifest is a
|
||||
test fixture and runs in the deploy smoke.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Migrating Signage to version 9.
|
||||
- How long version 8 stays accepted.
|
||||
- Overview widgets.
|
||||
- A module reporting "not found" for its own unknown detail route back to the host.
|
||||
- Return targets for platform pages other than Settings.
|
||||
- Navigation groups and their editor (ADR 0027).
|
||||
- Growing the icon set.
|
||||
- Module-owned breadcrumbs.
|
||||
@@ -71,3 +71,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted
|
||||
- [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted
|
||||
- [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.md) — Accepted
|
||||
- [0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin](0063-module-navigation-contract-manifest-v9.md) — Accepted
|
||||
|
||||
@@ -614,7 +614,17 @@ Core one typed column per preference and served only to their owner (`GET /api/v
|
||||
`PATCH /api/v1/account/preferences`, `core.account.self.manage`), so they follow the person to every
|
||||
device. A small same-origin script in the page's `<head>` applies this browser's cached copy before
|
||||
first paint, and the account's answer replaces it on every load
|
||||
([ADR 0062](decisions/0062-account-area-and-synced-appearance-preferences.md)). `pnpm boundaries` opens
|
||||
([ADR 0062](decisions/0062-account-area-and-synced-appearance-preferences.md)). A module is a
|
||||
first-class application area. Manifest version 9 declares its Lucide icon, from a curated set the
|
||||
Dashboard imports by name, and its primary pages. The sidebar nests those pages beneath it while it is
|
||||
open. Routes go to the module's root, to a declared page, or to anything beneath a page; anything
|
||||
else under a module is not-found and mounts nothing. The host's `api`, `assets`, and `display`
|
||||
namespaces are never pages. The mounted module receives `context.navigation`, a narrow capability to
|
||||
read its location, move among its own routes, make real links, and open its Settings page. The
|
||||
Settings URL carries `?return=` with where the module was; it is validated against the running
|
||||
modules and their declared routes, and Back returns there. Manifest version 8 stays accepted beside
|
||||
version 9, read strictly as itself
|
||||
([ADR 0063](decisions/0063-module-navigation-contract-manifest-v9.md)). `pnpm boundaries` opens
|
||||
exactly two shared roles to modules **by name**, and pins both design packages beneath everything
|
||||
else. The Dashboard and display install the defaults at their document root; custom properties
|
||||
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
|
||||
|
||||
@@ -1165,6 +1165,13 @@ How this installation presents its mountable modules
|
||||
"api": { "baseUrl": "/api/v1/modules/livestream" },
|
||||
"realtime": { "baseUrl": "/api/v1/module-realtime/livestream" },
|
||||
"permissions": ["livestream.streams.read"]
|
||||
},
|
||||
"navigation": {
|
||||
"icon": "video",
|
||||
"pages": [
|
||||
{ "id": "streams", "label": "Streams", "path": "streams" },
|
||||
{ "id": "archive", "label": "Archive", "path": "archive" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1174,6 +1181,13 @@ How this installation presents its mountable modules
|
||||
}
|
||||
```
|
||||
|
||||
`navigation` is what the module itself declares in manifest version 9
|
||||
([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)): the Lucide
|
||||
icon the Dashboard draws it with, and its primary pages in declared order. It is
|
||||
`{ "icon": null, "pages": [] }` for a module that declares none, which every manifest version 8
|
||||
module is: the generic icon and a single root page. It sits beside the host-neutral descriptor,
|
||||
never inside it, and says nothing about where the module is placed.
|
||||
|
||||
### Navigation versus inventory
|
||||
|
||||
They are deliberately separate, and this route is layered **on top of** the inventory rather than
|
||||
|
||||
@@ -416,6 +416,29 @@ holding the channel permission may receive. If it intentionally includes a signe
|
||||
URL remains a bearer capability and is forwarded byte-for-byte; fixed transport errors never reflect
|
||||
payloads, URLs, permissions, runtime exceptions, paths, or stacks.
|
||||
|
||||
### A module's navigation reaches only its own pages, and return links only real Dashboard pages
|
||||
|
||||
Module navigation ([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md))
|
||||
is inert data plus a narrow host capability:
|
||||
|
||||
- **The manifest supplies no executable or remote presentation.** A module's `navigation` is a
|
||||
Lucide name from a curated set (never an SVG, a URL, or code) and page paths that are lowercase
|
||||
kebab-case segments beneath its own root. Traversal, schemes, queries, and percent-encoding are
|
||||
refused before the module can start.
|
||||
- **The module gets no history object, router, or `window`.** It asks to move to a path relative to
|
||||
its own root. The host refuses anything that is not its root, or at or beneath a page it declares,
|
||||
so no module can navigate the Dashboard into another module or a platform page.
|
||||
- **The host's own namespaces are never pages.** `/api/…`, `/assets/…`, and `/display/…` are never
|
||||
read as a module route, a page, or a return target.
|
||||
- **`?return=` is untrusted input.** It is only ever read through `parseReturnTarget`, which accepts
|
||||
a canonical same-origin Dashboard address and nothing else. A module address must also name a
|
||||
running module and one of its routes. Anything else is ignored, and Back is a real link to that
|
||||
validated address, never `history.back()`.
|
||||
|
||||
None of this is a security boundary between modules: module UI is trusted code in the host's page
|
||||
(see above). It keeps honest mistakes and hostile links from turning Dashboard navigation into an
|
||||
open redirect or an API link.
|
||||
|
||||
### Client-side permission checks are not a security boundary
|
||||
|
||||
A host may tell a module UI what permissions it believes the viewer holds
|
||||
|
||||
@@ -412,6 +412,33 @@ pre-paint appearance cache
|
||||
one write of each fixed key, one removal of the appearance key, and no storage API in any file that
|
||||
holds the reset token.
|
||||
|
||||
Module navigation
|
||||
([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)) is proven at
|
||||
each layer:
|
||||
|
||||
- `packages/contracts/tests/module-navigation.test.ts` covers manifest v9: icons, pages, order, every
|
||||
unsafe path, duplicates, nesting, and bounds. It also accepts the byte-exact released Signage 0.1.5
|
||||
manifest (`tests/fixtures/`) as version 8 with no navigation, and refuses `navigation` in a
|
||||
version 8 document.
|
||||
- `core/tests/module-discovery.test.ts` discovers that released v8 module and a v9 module side by
|
||||
side, and refuses an escaping page path.
|
||||
- `apps/api/tests/dashboard-routes.test.ts` serves a v9 module's icon and pages beside its
|
||||
descriptor, and `{ icon: null, pages: [] }` for v8.
|
||||
- `apps/api/tests/dashboard-host-routes.test.ts` holds the shell's icon names equal to the contract's
|
||||
set, and the host namespaces plus platform pages equal to Core's reserved top-level names.
|
||||
- `surfaces/shell/tests/module-navigation.test.tsx` mounts real module artifacts through the loader's
|
||||
Shadow DOM boundary. It covers:
|
||||
- roots, declared pages, detail routes, unknown pages, and disabled modules;
|
||||
- Back and Forward and refresh with no remount;
|
||||
- the sidebar's icons and nested pages, collapsed, and in the drawer;
|
||||
- the navigation capability, including refused paths;
|
||||
- module → Settings → Back to the exact page, and every unsafe return target falling back.
|
||||
- `templates/module/tests/module-ui.test.ts` renders the v9 template's pages from the host's
|
||||
location.
|
||||
- The deploy smoke's real Chromium drives the v9 template through its pages, a deep link, refresh,
|
||||
Back and Forward, the drawer, and module → Settings → Back. With
|
||||
`EKKLESIA_SMOKE_MODULE_RELEASE_DIR` it runs the released v8 Signage package as well.
|
||||
|
||||
The account area
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)) is
|
||||
proven at each layer:
|
||||
|
||||
@@ -463,7 +463,7 @@ without executing module code:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 8,
|
||||
"manifestVersion": 9,
|
||||
"id": "calendar",
|
||||
"name": "Calendar",
|
||||
"version": "0.1.0",
|
||||
@@ -522,6 +522,13 @@ without executing module code:
|
||||
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
"icon": "calendar",
|
||||
"pages": [
|
||||
{ "id": "events", "label": "Events", "path": "events" },
|
||||
{ "id": "rooms", "label": "Rooms", "path": "rooms" }
|
||||
]
|
||||
},
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
@@ -548,18 +555,24 @@ visible to whoever holds `core.modules.configuration.manage`
|
||||
([ADR 0061](../architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)).
|
||||
Module lifecycle — installing, turning on and off, updating — is the platform's Settings → Discover, not
|
||||
yours to build. Do not build a second settings system inside your module; link to your Settings page
|
||||
instead once the module navigation contract can carry a return link. A provided capability
|
||||
instead, through `context.navigation.settings` (see [Module navigation](#module-navigation)), which
|
||||
brings the person back to exactly the page they left. A provided capability
|
||||
has its own exact semantic version because its public contract can evolve independently from the
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
|
||||
`manifestVersion: 8` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 7 manifest, and any other version, fails validation outright rather than being silently
|
||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||
Already-built version 7 packages must be rebuilt. Version 7 added the optional `contributions` array
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
|
||||
contributes nothing changes only the version literal; every browser artifact remains independently
|
||||
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
`manifestVersion` (`packages/contracts/src/module-manifest.ts`) is required and selects exactly one
|
||||
strict schema: **9**, the current version and what a new module declares, or **8**, which stays
|
||||
accepted so released modules keep working unchanged
|
||||
([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Version 9 is
|
||||
version 8 plus the optional `navigation` field; a version 8 manifest that names `navigation` is
|
||||
refused, never read as version 9. To adopt navigation, change `8` to `9` and add the field — nothing
|
||||
else changes. Version 7 and every other version fail validation outright; there is no compatibility
|
||||
adapter or automatic migration, and already-built version 7 packages must be rebuilt. Version 7
|
||||
added the optional `contributions` array
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)), and every browser
|
||||
artifact remains independently optional
|
||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||
@@ -591,9 +604,9 @@ Lucide is Ekklesia's icon vocabulary
|
||||
UI needs icons, depend on `lucide-react` and import each icon by name, rather than drawing your own
|
||||
SVGs (module `.svg` presentation assets are rejected by `pnpm boundaries` anyway). Treat icons as
|
||||
decorative: give every control a text name, visible or visually hidden, and keep the icon
|
||||
`aria-hidden`. The Dashboard's navigation currently shows every module with one generic icon; a
|
||||
module will declare its own navigation icon through the module navigation contract (manifest v9), not
|
||||
yet.
|
||||
`aria-hidden`. The icon the **Dashboard's sidebar** draws for your module is different: you name it
|
||||
in your manifest (`navigation.icon`), and the Dashboard draws it — see
|
||||
[Module navigation](#module-navigation).
|
||||
|
||||
### Your module's display name
|
||||
|
||||
@@ -606,8 +619,9 @@ your package name or module id.
|
||||
Production" — are presentation configuration an administrator owns, stored by the platform and
|
||||
changed at any time without touching your module
|
||||
([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). There is no
|
||||
manifest field for a folder, a group, an order, an icon, or a category, and adding one would make a
|
||||
rendering preference into module structure.
|
||||
manifest field for a folder, a group, an order, or a category, and adding one would make a rendering
|
||||
preference into module structure. What you *do* declare is what your module is: its icon and its
|
||||
primary pages ([Module navigation](#module-navigation)).
|
||||
|
||||
That separation also means grouping never affects you. Two modules sharing a folder have no
|
||||
relationship: no dependency, no ordering, no shared anything. If your module genuinely needs another
|
||||
@@ -624,9 +638,9 @@ symlink resolution. Modules with no tables omit `entrypoints.schema`; modules wi
|
||||
history omit `entrypoints.migrations`; modules may omit `ui`, `display`, or both.
|
||||
|
||||
Unknown manifest properties are still rejected. A `dashboard`, `surface`, `client`, `web`, or
|
||||
`renderers` key fails validation — and the manifest
|
||||
deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard
|
||||
organization is presentation configuration owned by the Dashboard, never module architecture.
|
||||
`renderers` key fails validation — and the manifest deliberately has no group, folder, parent,
|
||||
menu, or ordering field. Dashboard organization is presentation configuration owned by the
|
||||
Dashboard, never module architecture. `navigation` declares only your module's own icon and pages.
|
||||
|
||||
Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets.
|
||||
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
|
||||
@@ -706,7 +720,7 @@ artifact is the role selection.
|
||||
|
||||
### What the host gives you
|
||||
|
||||
`mount` receives exactly six members, and nothing else is available:
|
||||
`mount` receives exactly eight members, and nothing else is available:
|
||||
|
||||
| Member | What it is |
|
||||
| --- | --- |
|
||||
@@ -716,10 +730,12 @@ artifact is the role selection.
|
||||
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
|
||||
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
|
||||
| `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) |
|
||||
| `display` | Your display page's URL, or `undefined` when you publish none |
|
||||
| `navigation` | Where your module is, and how it moves between its own pages and to its Settings page — see [Module navigation](#module-navigation). `undefined` on the display host |
|
||||
|
||||
Navigation, module configuration, organization context, and diagnostics reporting are **not** in the
|
||||
contract. They need designs that do not exist, and a speculative field frozen into a public contract
|
||||
is harder to remove than to add.
|
||||
Module configuration, organization context, and diagnostics reporting are **not** in the contract.
|
||||
They need designs that do not exist, and a speculative field frozen into a public contract is harder
|
||||
to remove than to add.
|
||||
|
||||
The host **theme** is deliberately not a context member either, and that is not a gap: design tokens
|
||||
reach your artifact as inherited CSS custom properties rather than as data (ADR 0039). You import
|
||||
@@ -727,6 +743,90 @@ reach your artifact as inherited CSS custom properties rather than as data (ADR
|
||||
root. Nothing is negotiated at run time, so there is no token map to keep in sync and no host that
|
||||
can forget to pass one.
|
||||
|
||||
### Module navigation
|
||||
|
||||
Your module is a first-class part of the Dashboard
|
||||
([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Declare its
|
||||
icon and primary pages in the manifest (version 9):
|
||||
|
||||
```json
|
||||
"navigation": {
|
||||
"icon": "monitor-play",
|
||||
"pages": [
|
||||
{ "id": "playback", "label": "Playback", "path": "playback" },
|
||||
{ "id": "timelines", "label": "Timelines", "path": "timelines" },
|
||||
{ "id": "media", "label": "Media", "path": "media" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**The icon.**
|
||||
|
||||
- `icon` is a Lucide name from `MODULE_NAVIGATION_ICONS` (`@ekklesia/contracts`, re-exported by
|
||||
`@ekklesia/module-sdk`), a curated set of generic subjects. A name outside it fails validation, so
|
||||
you'll know at packaging time.
|
||||
- Omit it and the Dashboard draws its generic module icon.
|
||||
- Never ship an SVG or a URL for it.
|
||||
|
||||
**The pages.**
|
||||
|
||||
- `pages` are your primary pages, at most 12, in the order the sidebar shows them beneath your
|
||||
module, while your module is open.
|
||||
- `id` is stable and never shown.
|
||||
- `label` is what a person reads (1–60 characters).
|
||||
- `path` is relative to your module's root: one to four lowercase kebab-case segments, such as
|
||||
`timelines` or `media/library`.
|
||||
- Paths must be unique, and one page may not sit beneath another's path.
|
||||
- Don't add a tab bar of your own for these. The sidebar is your primary navigation (DESIGN.md
|
||||
§4.4). Tabs inside one resource are still fine.
|
||||
|
||||
**Your routes.**
|
||||
|
||||
| Address | What it is |
|
||||
| --- | --- |
|
||||
| `/<module-id>` | Your landing view. Never a declared page, so no page has two addresses. To open on, say, Playback, render it at your root and don't declare it. |
|
||||
| `/<module-id>/<page-path>` | A declared page. |
|
||||
| `/<module-id>/<page-path>/…` | Anything beneath a declared page: your detail views (`media/sermons/123`), with no manifest entry. |
|
||||
| anything else under your id | The Dashboard's *Page not found*. Your UI isn't mounted for it. |
|
||||
|
||||
A refresh, a bookmark, and Back and Forward all land on the same route.
|
||||
|
||||
**Moving around: `context.navigation`.** Never touch `window.location` or `window.history`, and
|
||||
never build a Dashboard URL yourself:
|
||||
|
||||
```tsx
|
||||
const { navigation } = context; // undefined on the display host
|
||||
navigation?.location(); // { path: "media/sermons/123", page: "media" }
|
||||
navigation?.subscribe(() => rerender()); // every move: yours, the sidebar's, Back and Forward
|
||||
navigation?.navigate("timelines"); // or { replace: true } to respell the current entry
|
||||
const link = navigation?.link("media/sermons/123");
|
||||
<a href={link.href} onClick={link.onClick}>Sermon</a> // a real link; new tabs still work
|
||||
```
|
||||
|
||||
- **Paths are relative to your root**, and a leading `/` means the same thing.
|
||||
- **Refused paths throw.** A path that names anything outside your root, or a page you don't
|
||||
declare, throws `ModuleUiNavigationError` (`MODULE_NAVIGATION_PATH_INVALID` or
|
||||
`MODULE_NAVIGATION_PATH_UNKNOWN`). There is no way to navigate into another module.
|
||||
- **You stay mounted.** Your UI is not remounted as it moves between its own pages. Subscribe and
|
||||
re-render from `location()`, which returns the same object until something moves.
|
||||
|
||||
**Your Settings page.** If your module declares `configuration`, its settings live at
|
||||
`/settings/<module-id>` (ADR 0061). `navigation.settings` opens that page, or `undefined` means this
|
||||
viewer can't:
|
||||
|
||||
```tsx
|
||||
navigation?.settings?.open(); // or navigation.settings.link() for a real <a>
|
||||
```
|
||||
|
||||
The host carries where your module is as the place to come back to
|
||||
(`/settings/<module-id>?return=%2F<module-id>%2Ftimelines`). Settings' Back then returns to exactly
|
||||
that page, after refresh too. You never build or validate the return address: the host does,
|
||||
accepting only real pages of running modules on this Dashboard.
|
||||
|
||||
**Version 8 modules.** A module still on version 8 keeps working unchanged: the generic icon, its root
|
||||
as its only page, and settings detected from `configuration` as before. Rebuilt against the new SDK,
|
||||
it also receives `navigation`, with only its root, and can link to its Settings page.
|
||||
|
||||
### Never build your own API URL
|
||||
|
||||
Ask the host for a path relative to your own module's API root:
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from "./module-http.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
export * from "./module-manifest.js";
|
||||
export * from "./module-migration.js";
|
||||
export * from "./module-navigation.js";
|
||||
export * from "./module-realtime.js";
|
||||
export * from "./module-runtime.js";
|
||||
export * from "./module-schema-metadata.js";
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
type ModuleContributionDeclaration,
|
||||
moduleContributionDeclarationSchema,
|
||||
} from "./module-contribution.js";
|
||||
import {
|
||||
EMPTY_MODULE_NAVIGATION,
|
||||
type ModuleNavigationDeclaration,
|
||||
moduleNavigationDeclarationSchema,
|
||||
} from "./module-navigation.js";
|
||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||
|
||||
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
||||
@@ -130,9 +135,21 @@ function reportDuplicates(
|
||||
}
|
||||
|
||||
/**
|
||||
* The one manifest shape Core accepts. Version 7's strict top-level object rejects `database`, so
|
||||
* the static ABI declaration is an incompatible shape change and the canonical manifest moves to
|
||||
* `manifestVersion: 8` without silently reinterpreting version 7.
|
||||
* The manifest shapes Core accepts: **version 8 and version 9**, each read strictly as itself.
|
||||
*
|
||||
* Version 7's strict top-level object rejected `database`, so that ABI declaration moved the
|
||||
* manifest to version 8 (ADR 0050). Version 8's strict object likewise rejects `navigation`, so
|
||||
* module-declared Dashboard navigation — an icon and primary pages (ADR 0063) — is version 9.
|
||||
*
|
||||
* Unlike every earlier step, **version 8 stays accepted beside version 9** (ADR 0063). Released
|
||||
* modules were packaged, verified, and installed as version 8, and the navigation version adds one
|
||||
* optional field and changes the meaning of nothing a version 8 document says. Requiring every
|
||||
* installation's modules to be rebuilt just because Core gained navigation would break working
|
||||
* churches for no safety gain. The rule that made single-version acceptance matter still holds
|
||||
* exactly: `manifestVersion` selects one strict schema, so a document is only ever read as the one
|
||||
* version it declares — a version 8 document that names `navigation` is refused, never quietly
|
||||
* reinterpreted as version 9 — and nothing ever rewrites a released manifest, its package bytes, or
|
||||
* its digest.
|
||||
*
|
||||
* Configuration is declared **statically, in this inert document**, rather than registered by
|
||||
* executable module code, for one decisive reason: the platform validates a module's configuration
|
||||
@@ -141,17 +158,15 @@ function reportDuplicates(
|
||||
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
||||
* importing a single line of module code, exactly as they always have.
|
||||
*
|
||||
* Version 7, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
||||
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 7
|
||||
* by version 8 (ADR 0050). One manifest document can therefore never be read as two different
|
||||
* versions. A version 7 package must be rebuilt; omitting `database` selects the legacy facade.
|
||||
* Every other version fails validation: there is no conversion and no guessing. Version 7 and
|
||||
* earlier must be rebuilt; omitting `database` selects the legacy facade.
|
||||
*/
|
||||
export const MODULE_MANIFEST_VERSION = 8;
|
||||
export const SUPPORTED_MODULE_MANIFEST_VERSIONS = Object.freeze([8, 9] as const);
|
||||
|
||||
export const moduleManifestSchema = z
|
||||
.object({
|
||||
manifestVersion: z.literal(MODULE_MANIFEST_VERSION),
|
||||
/** The newest manifest version: what a new module declares, and what the template uses. */
|
||||
export const MODULE_MANIFEST_VERSION = 9;
|
||||
|
||||
const manifestFields = {
|
||||
id: moduleIdSchema,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(500).optional(),
|
||||
@@ -176,9 +191,15 @@ export const moduleManifestSchema = z
|
||||
* never learns what a contribution renders. A module that contributes nothing omits the field. */
|
||||
contributions: z.array(moduleContributionDeclarationSchema).default(() => []),
|
||||
entrypoints: entrypointsSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((manifest, context) => {
|
||||
};
|
||||
|
||||
const manifestFieldsObject = z.object(manifestFields);
|
||||
|
||||
/** The fields every supported version shares, after parsing. */
|
||||
type SharedManifestFields = z.output<typeof manifestFieldsObject>;
|
||||
|
||||
/** The cross-field rules every supported version obeys, identically. */
|
||||
function refineManifest(manifest: SharedManifestFields, context: z.RefinementCtx): void {
|
||||
reportDuplicates(
|
||||
manifest.capabilities.map((capability) => capability.id),
|
||||
["capabilities"],
|
||||
@@ -305,8 +326,7 @@ export const moduleManifestSchema = z
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"The display UI must use a different path from every other configured entrypoint.",
|
||||
message: "The display UI must use a different path from every other configured entrypoint.",
|
||||
path: ["entrypoints", "display", "artifact"],
|
||||
});
|
||||
}
|
||||
@@ -352,7 +372,52 @@ export const moduleManifestSchema = z
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const moduleManifestV8Schema = z
|
||||
.object({ manifestVersion: z.literal(8), ...manifestFields })
|
||||
.strict()
|
||||
.superRefine(refineManifest);
|
||||
|
||||
const moduleManifestV9Schema = z
|
||||
.object({
|
||||
manifestVersion: z.literal(9),
|
||||
...manifestFields,
|
||||
/** The module's Dashboard navigation (ADR 0063): its Lucide icon and primary pages. Omitted,
|
||||
* the module is drawn with the generic icon and has one page, its root — exactly as a version 8
|
||||
* module. Only meaningful for a module with a primary UI. */
|
||||
navigation: moduleNavigationDeclarationSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((manifest, context) => {
|
||||
refineManifest(manifest, context);
|
||||
if (manifest.navigation !== undefined && manifest.entrypoints.ui === undefined) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Navigation can only be declared by a module with a primary UI (entrypoints.ui).",
|
||||
path: ["navigation"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const moduleManifestSchema = z.discriminatedUnion("manifestVersion", [
|
||||
moduleManifestV8Schema,
|
||||
moduleManifestV9Schema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The navigation a manifest declares, normalized: version 8 declares none, so it gets no icon and
|
||||
* no pages, exactly like a version 9 manifest that omits the field. Read through this rather than
|
||||
* from the manifest, so no consumer branches on the version.
|
||||
*/
|
||||
export function moduleManifestNavigation(manifest: {
|
||||
readonly manifestVersion: number;
|
||||
readonly navigation?: ModuleNavigationDeclaration | undefined;
|
||||
}): ModuleNavigationDeclaration {
|
||||
return manifest.manifestVersion === 9 && manifest.navigation !== undefined
|
||||
? manifest.navigation
|
||||
: EMPTY_MODULE_NAVIGATION;
|
||||
}
|
||||
|
||||
export function parseModuleManifest(input: unknown): ModuleManifest {
|
||||
return moduleManifestSchema.parse(input);
|
||||
@@ -364,6 +429,8 @@ export function safeParseModuleManifest(input: unknown) {
|
||||
|
||||
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||
export type ModuleManifestV8 = z.output<typeof moduleManifestV8Schema>;
|
||||
export type ModuleManifestV9 = z.output<typeof moduleManifestV9Schema>;
|
||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||
/** A module manifest's `contributions` array entry — the same `ModuleContributionDeclaration` shape
|
||||
* Core's publication registry consumes (`packages/contracts/src/module-contribution.ts`). */
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* A module's Dashboard navigation (manifest v9, ADR 0063): the icon it is drawn with, and the
|
||||
* meaningful primary pages it contributes to the global sidebar beneath its own entry.
|
||||
*
|
||||
* Inert data, like every other manifest field. The host resolves the icon and draws the pages; a
|
||||
* manifest never supplies executable icon code, a URL, or a route outside its own namespace.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Lucide icons a module may declare (DESIGN.md locked decision 94, ADR 0058), by their stable
|
||||
* Lucide names.
|
||||
*
|
||||
* A curated set rather than "any Lucide name", for two reasons. The host imports exactly these by
|
||||
* name, so the Dashboard never bundles the whole library to honour one manifest. And a manifest is
|
||||
* validated against it, so a typo is caught when a module is packaged or discovered instead of
|
||||
* silently drawing the generic icon. Growing the set is an additive platform change: the shell's
|
||||
* tests hold its icon map equal to this list, so a name cannot be added here without the Dashboard
|
||||
* being able to draw it.
|
||||
*
|
||||
* Generic subjects only. There is no "signage" or "calendar" entry tied to a particular module: a
|
||||
* module chooses what represents it.
|
||||
*/
|
||||
export const MODULE_NAVIGATION_ICONS = 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 ModuleNavigationIcon = (typeof MODULE_NAVIGATION_ICONS)[number];
|
||||
|
||||
const navigationIcons: ReadonlySet<string> = new Set(MODULE_NAVIGATION_ICONS);
|
||||
|
||||
export function isModuleNavigationIcon(value: unknown): value is ModuleNavigationIcon {
|
||||
return typeof value === "string" && navigationIcons.has(value);
|
||||
}
|
||||
|
||||
/** The most primary pages one module may contribute. A sidebar entry with more than this is a
|
||||
* module that needs its own information architecture, not a longer list. */
|
||||
export const MODULE_NAVIGATION_PAGES_MAX = 12;
|
||||
export const MODULE_NAVIGATION_PAGE_LABEL_MAX_LENGTH = 60;
|
||||
export const MODULE_NAVIGATION_PAGE_PATH_MAX_SEGMENTS = 4;
|
||||
|
||||
const PAGE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||
const PATH_SEGMENT_PATTERN = /^[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;
|
||||
|
||||
/**
|
||||
* A page path, relative to the module's own root: one to four lowercase kebab-case segments joined
|
||||
* by `/` — `timelines`, `media/library`. There is no leading slash, no empty segment, no `.` or
|
||||
* `..`, no percent-encoding, no query, and no scheme, so a declared path can never escape
|
||||
* `/<module-id>/` or name anything but a page of this module. The module root itself is not a page
|
||||
* path: it is the module's landing view (ADR 0063).
|
||||
*/
|
||||
export function isModuleNavigationPagePath(value: unknown): value is string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 120) {
|
||||
return false;
|
||||
}
|
||||
const segments = value.split("/");
|
||||
return (
|
||||
segments.length <= MODULE_NAVIGATION_PAGE_PATH_MAX_SEGMENTS &&
|
||||
segments.every((segment) => PATH_SEGMENT_PATTERN.test(segment))
|
||||
);
|
||||
}
|
||||
|
||||
export const moduleNavigationPageSchema = z
|
||||
.object({
|
||||
/** Stable, never displayed: what the host and the module call this page. Renaming a label never
|
||||
* changes it, and the module's navigation API reports it as the current page. */
|
||||
id: z.string().max(64).regex(PAGE_ID_PATTERN),
|
||||
/** What a person reads in the sidebar. Plain text, trimmed and bounded. */
|
||||
label: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(MODULE_NAVIGATION_PAGE_LABEL_MAX_LENGTH)
|
||||
.refine((label) => !CONTROL_CHARACTER_PATTERN.test(label), {
|
||||
message: "A page label must not contain control characters.",
|
||||
}),
|
||||
path: z.string().refine(isModuleNavigationPagePath, {
|
||||
message:
|
||||
"A page path must be one to four lowercase kebab-case segments relative to the module root.",
|
||||
}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const moduleNavigationDeclarationSchema = z
|
||||
.object({
|
||||
/** The Lucide icon this module is drawn with. Omitted, the host draws its generic module icon. */
|
||||
icon: z
|
||||
.custom<ModuleNavigationIcon>((value) => isModuleNavigationIcon(value), {
|
||||
message: "The navigation icon must be one of the Lucide names in MODULE_NAVIGATION_ICONS.",
|
||||
})
|
||||
.optional(),
|
||||
/** Primary pages, in the order the sidebar shows them. */
|
||||
pages: z
|
||||
.array(moduleNavigationPageSchema)
|
||||
.max(MODULE_NAVIGATION_PAGES_MAX)
|
||||
.default(() => []),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((navigation, context) => {
|
||||
const ids = new Set<string>();
|
||||
const paths = new Set<string>();
|
||||
navigation.pages.forEach((page, index) => {
|
||||
if (ids.has(page.id)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${page.id} is declared more than once.`,
|
||||
path: ["pages", index, "id"],
|
||||
});
|
||||
}
|
||||
ids.add(page.id);
|
||||
if (paths.has(page.path)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Two pages must not declare the same path.",
|
||||
path: ["pages", index, "path"],
|
||||
});
|
||||
}
|
||||
paths.add(page.path);
|
||||
});
|
||||
// A page beneath another would make "which page is open" ambiguous for every path under both,
|
||||
// and the sidebar's list is flat. Deeper routes belong to a page, not to a second page.
|
||||
navigation.pages.forEach((page, index) => {
|
||||
if (
|
||||
navigation.pages.some((other) => other !== page && page.path.startsWith(`${other.path}/`))
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A page path must not lie beneath another declared page's path.",
|
||||
path: ["pages", index, "path"],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export type ModuleNavigationPage = Readonly<z.output<typeof moduleNavigationPageSchema>>;
|
||||
|
||||
/** A module's navigation as every consumer reads it, parsed or deeply frozen alike. */
|
||||
export interface ModuleNavigationDeclaration {
|
||||
readonly icon?: ModuleNavigationIcon | undefined;
|
||||
readonly pages: readonly ModuleNavigationPage[];
|
||||
}
|
||||
|
||||
/** What a module declared, as every consumer reads it: no icon and no pages for a module that
|
||||
* declares no navigation — every manifest-v8 module, and a v9 module that omits the field. */
|
||||
export const EMPTY_MODULE_NAVIGATION: ModuleNavigationDeclaration = Object.freeze({
|
||||
pages: Object.freeze([]),
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"manifestVersion": 8,
|
||||
"id": "signage",
|
||||
"name": "Signage",
|
||||
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
|
||||
"version": "0.1.5",
|
||||
"compatibility": {
|
||||
"platform": "^0.2.0",
|
||||
"moduleSdk": "^0.2.0"
|
||||
},
|
||||
"database": {
|
||||
"abi": 1
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "signage.content-providers",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "signage.slide-renderer",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "signage.playback",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "signage.displays",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"id": "signage.view",
|
||||
"baseline": "restricted",
|
||||
"description": "View Signage timelines, images, channels, provider health, and display health."
|
||||
},
|
||||
{
|
||||
"id": "signage.assets.manage",
|
||||
"baseline": "restricted",
|
||||
"description": "Upload, rename, expire, and delete Signage's own native images."
|
||||
},
|
||||
{
|
||||
"id": "signage.timelines.manage",
|
||||
"baseline": "restricted",
|
||||
"description": "Create, edit, reorder, duplicate, schedule, and delete saved timelines."
|
||||
},
|
||||
{
|
||||
"id": "signage.playback.control",
|
||||
"baseline": "restricted",
|
||||
"description": "Activate timelines and operate live playback transport for a channel group."
|
||||
},
|
||||
{
|
||||
"id": "signage.displays.manage",
|
||||
"baseline": "restricted",
|
||||
"description": "Create channel groups and audience channels, and assign or rename viewers."
|
||||
},
|
||||
{
|
||||
"id": "signage.display.view",
|
||||
"baseline": "restricted",
|
||||
"description": "Render the Signage full-viewport display and report its own health."
|
||||
}
|
||||
],
|
||||
"configuration": [
|
||||
{
|
||||
"key": "timezone",
|
||||
"type": "string",
|
||||
"default": "America/Chicago",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"description": "IANA time zone this installation's Signage schedules are written and displayed in."
|
||||
},
|
||||
{
|
||||
"key": "nativeImageDurationSeconds",
|
||||
"type": "integer",
|
||||
"default": 7,
|
||||
"minimum": 1,
|
||||
"maximum": 3600,
|
||||
"description": "How long a native Signage image is shown when no slot override applies."
|
||||
},
|
||||
{
|
||||
"key": "crossfadeMilliseconds",
|
||||
"type": "integer",
|
||||
"default": 1000,
|
||||
"minimum": 0,
|
||||
"maximum": 10000,
|
||||
"description": "How long a display fades an incoming slide over the outgoing one."
|
||||
},
|
||||
{
|
||||
"key": "defaultBackgroundMode",
|
||||
"type": "enum",
|
||||
"values": ["blurred", "solid", "black"],
|
||||
"default": "blurred",
|
||||
"description": "How a native image that is not 16:9 is backed when a slot says nothing."
|
||||
},
|
||||
{
|
||||
"key": "defaultBackgroundColor",
|
||||
"type": "string",
|
||||
"default": "#000000",
|
||||
"minLength": 7,
|
||||
"maxLength": 7,
|
||||
"description": "The solid background colour used when the background mode is solid."
|
||||
},
|
||||
{
|
||||
"key": "defaultBackgroundDarkened",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Whether a native image's background is darkened behind the foreground by default."
|
||||
},
|
||||
{
|
||||
"key": "defaultBlurPixels",
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"minimum": 0,
|
||||
"maximum": 120,
|
||||
"description": "Blur radius applied to a blurred native-image background."
|
||||
},
|
||||
{
|
||||
"key": "providerTimeoutMilliseconds",
|
||||
"type": "integer",
|
||||
"default": 2000,
|
||||
"minimum": 100,
|
||||
"maximum": 15000,
|
||||
"description": "How long Signage waits for one content-provider call before treating it as failed."
|
||||
},
|
||||
{
|
||||
"key": "playbackHistoryLimit",
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"minimum": 2,
|
||||
"maximum": 500,
|
||||
"description": "How many resolved selections per channel group are retained as playback history."
|
||||
},
|
||||
{
|
||||
"key": "viewerHeartbeatWriteSeconds",
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"minimum": 5,
|
||||
"maximum": 600,
|
||||
"description": "Shortest interval between two persisted heartbeat writes for one steady viewer."
|
||||
}
|
||||
],
|
||||
"storage": {
|
||||
"buckets": [
|
||||
{
|
||||
"id": "native-images",
|
||||
"description": "Archived original uploads and generated renditions for Signage's native images."
|
||||
}
|
||||
]
|
||||
},
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
"migrations": "./drizzle",
|
||||
"ui": "./dist/ui/index.js",
|
||||
"display": {
|
||||
"artifact": "./dist/display/index.js",
|
||||
"permission": "signage.display.view"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MODULE_DEPENDENCY_KINDS,
|
||||
MODULE_MANIFEST_VERSION,
|
||||
MODULE_RUNTIME_STATES,
|
||||
SUPPORTED_MODULE_MANIFEST_VERSIONS,
|
||||
MODULE_STORAGE_BLOCKED_REASON,
|
||||
packageRelativePathSchema,
|
||||
parseModuleManifest,
|
||||
@@ -256,15 +257,17 @@ describe("module manifest contract", () => {
|
||||
expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(8);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 9, "8", 8.5, null]) {
|
||||
it("accepts exactly versions 8 and 9, each as itself, so no document is read as two versions", () => {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(9);
|
||||
expect(SUPPORTED_MODULE_MANIFEST_VERSIONS).toEqual([8, 9]);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 10, "8", "9", 8.5, null]) {
|
||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||
}
|
||||
expect(
|
||||
safeParseModuleManifest({ ...validManifest(), manifestVersion: MODULE_MANIFEST_VERSION })
|
||||
.success,
|
||||
).toBe(true);
|
||||
for (const manifestVersion of SUPPORTED_MODULE_MANIFEST_VERSIONS) {
|
||||
const parsed = safeParseModuleManifest({ ...validManifest(), manifestVersion });
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.success && parsed.data.manifestVersion).toBe(manifestVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults contributions to an empty list for a module that contributes nothing", () => {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EMPTY_MODULE_NAVIGATION,
|
||||
isModuleNavigationIcon,
|
||||
isModuleNavigationPagePath,
|
||||
MODULE_NAVIGATION_ICONS,
|
||||
MODULE_NAVIGATION_PAGES_MAX,
|
||||
moduleManifestNavigation,
|
||||
parseModuleManifest,
|
||||
safeParseModuleManifest,
|
||||
} from "../src/index.js";
|
||||
|
||||
/**
|
||||
* Manifest v9's Dashboard navigation (ADR 0063), and the guarantee that released manifest v8
|
||||
* modules keep working unchanged.
|
||||
*/
|
||||
|
||||
const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||
|
||||
function v9Manifest(navigation?: unknown): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 9,
|
||||
id: "signage",
|
||||
name: "Signage",
|
||||
version: "0.2.0",
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
...(navigation === undefined ? {} : { navigation }),
|
||||
};
|
||||
}
|
||||
|
||||
const pages = [
|
||||
{ id: "playback", label: "Playback", path: "playback" },
|
||||
{ id: "timelines", label: "Timelines", path: "timelines" },
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
];
|
||||
|
||||
function issues(input: unknown): string[] {
|
||||
const parsed = safeParseModuleManifest(input);
|
||||
return parsed.success
|
||||
? []
|
||||
: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
|
||||
}
|
||||
|
||||
describe("manifest v9 navigation", () => {
|
||||
it("accepts a single-page v9 module with no navigation at all: the generic icon, one root page", () => {
|
||||
const manifest = parseModuleManifest(v9Manifest());
|
||||
expect(manifest.manifestVersion).toBe(9);
|
||||
expect(moduleManifestNavigation(manifest)).toEqual({ pages: [] });
|
||||
expect(moduleManifestNavigation(manifest).icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a multi-page module with a Lucide icon, keeping pages in declared order", () => {
|
||||
const manifest = parseModuleManifest(v9Manifest({ icon: "monitor-play", pages }));
|
||||
const navigation = moduleManifestNavigation(manifest);
|
||||
expect(navigation.icon).toBe("monitor-play");
|
||||
expect(navigation.pages.map((page) => page.id)).toEqual(["playback", "timelines", "media"]);
|
||||
// Reversed in the manifest, reversed in the result: order is the author's, never re-sorted.
|
||||
const reversed = moduleManifestNavigation(
|
||||
parseModuleManifest(v9Manifest({ pages: [...pages].reverse() })),
|
||||
);
|
||||
expect(reversed.pages.map((page) => page.id)).toEqual(["media", "timelines", "playback"]);
|
||||
});
|
||||
|
||||
it("accepts an icon on its own, and pages on their own", () => {
|
||||
expect(moduleManifestNavigation(parseModuleManifest(v9Manifest({ icon: "calendar" })))).toEqual(
|
||||
{
|
||||
icon: "calendar",
|
||||
pages: [],
|
||||
},
|
||||
);
|
||||
expect(
|
||||
moduleManifestNavigation(parseModuleManifest(v9Manifest({ pages }))).icon,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts multi-segment page paths", () => {
|
||||
expect(
|
||||
issues(v9Manifest({ pages: [{ id: "library", label: "Library", path: "media/library" }] })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown icon", { icon: "not-an-icon" }],
|
||||
["a PascalCase component name", { icon: "MonitorPlay" }],
|
||||
["an icon as markup", { icon: "<svg onload=alert(1)>" }],
|
||||
["an icon as a URL", { icon: "https://cdn.example.test/icon.svg" }],
|
||||
["an icon as an object", { icon: { svg: "<path/>" } }],
|
||||
])("refuses %s", (_label, navigation) => {
|
||||
expect(issues(v9Manifest(navigation))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty path", ""],
|
||||
["the root as a page", "/"],
|
||||
["a leading slash", "/timelines"],
|
||||
["a trailing slash", "timelines/"],
|
||||
["an empty segment", "media//library"],
|
||||
["traversal", "../calendar"],
|
||||
["an inner traversal", "media/../../calendar"],
|
||||
["a dot segment", "./timelines"],
|
||||
["an absolute URL", "https://evil.example.test/x"],
|
||||
["a protocol-relative URL", "//evil.example.test"],
|
||||
["a scheme", "javascript:alert(1)"],
|
||||
["a query", "timelines?x=1"],
|
||||
["a fragment", "timelines#top"],
|
||||
["percent-encoding", "media%2Flibrary"],
|
||||
["a backslash", "media\\library"],
|
||||
["uppercase", "Timelines"],
|
||||
["whitespace", "time lines"],
|
||||
["too many segments", "a/b/c/d/e"],
|
||||
])("refuses a page path with %s", (_label, pagePath) => {
|
||||
expect(isModuleNavigationPagePath(pagePath)).toBe(false);
|
||||
expect(
|
||||
issues(v9Manifest({ pages: [{ id: "page", label: "Page", path: pagePath }] })),
|
||||
).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an uppercase id", { id: "Playback", label: "Playback", path: "playback" }],
|
||||
["an id with a slash", { id: "media/library", label: "Library", path: "library" }],
|
||||
["an empty label", { id: "playback", label: " ", path: "playback" }],
|
||||
["an overlong label", { id: "playback", label: "x".repeat(61), path: "playback" }],
|
||||
[
|
||||
"a control character in a label",
|
||||
{ id: "playback", label: "Play\u0007back", path: "playback" },
|
||||
],
|
||||
["a line separator in a label", { id: "playback", label: "Play\u2028back", path: "playback" }],
|
||||
["an unknown page key", { id: "playback", label: "Playback", path: "playback", icon: "tv" }],
|
||||
["an executable field", { id: "playback", label: "Playback", path: "playback", onClick: "x" }],
|
||||
])("refuses a page with %s", (_label, page) => {
|
||||
expect(issues(v9Manifest({ pages: [page] }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses duplicate page ids and duplicate page paths, naming the duplicate", () => {
|
||||
expect(
|
||||
issues(
|
||||
v9Manifest({
|
||||
pages: [
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
{ id: "media", label: "More media", path: "more-media" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual(["navigation.pages.1.id: media is declared more than once."]);
|
||||
expect(
|
||||
issues(
|
||||
v9Manifest({
|
||||
pages: [
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
{ id: "library", label: "Library", path: "media" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual(["navigation.pages.1.path: Two pages must not declare the same path."]);
|
||||
});
|
||||
|
||||
it("refuses a page beneath another page, which would make the open page ambiguous", () => {
|
||||
expect(
|
||||
issues(
|
||||
v9Manifest({
|
||||
pages: [
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
{ id: "library", label: "Library", path: "media/library" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
"navigation.pages.1.path: A page path must not lie beneath another declared page's path.",
|
||||
]);
|
||||
// A shared prefix that is not a parent segment is unrelated.
|
||||
expect(
|
||||
issues(
|
||||
v9Manifest({
|
||||
pages: [
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
{ id: "media-library", label: "Library", path: "media-library" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("bounds the number of pages", () => {
|
||||
const many = Array.from({ length: MODULE_NAVIGATION_PAGES_MAX + 1 }, (_, index) => ({
|
||||
id: `page-${index}`,
|
||||
label: `Page ${index}`,
|
||||
path: `page-${index}`,
|
||||
}));
|
||||
expect(issues(v9Manifest({ pages: many }))).not.toEqual([]);
|
||||
expect(issues(v9Manifest({ pages: many.slice(0, MODULE_NAVIGATION_PAGES_MAX) }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses navigation for a module with no primary UI, and unknown navigation keys", () => {
|
||||
const noUi = {
|
||||
...v9Manifest({ icon: "calendar" }),
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
};
|
||||
expect(issues(noUi)).toEqual([
|
||||
"navigation: Navigation can only be declared by a module with a primary UI (entrypoints.ui).",
|
||||
]);
|
||||
expect(issues(v9Manifest({ groups: ["media"] }))).not.toEqual([]);
|
||||
expect(issues(v9Manifest({ widgets: [] }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("offers a curated, sorted, duplicate-free set of Lucide names, all in Lucide's kebab-case form", () => {
|
||||
expect([...MODULE_NAVIGATION_ICONS]).toEqual([...MODULE_NAVIGATION_ICONS].sort());
|
||||
expect(new Set(MODULE_NAVIGATION_ICONS).size).toBe(MODULE_NAVIGATION_ICONS.length);
|
||||
for (const icon of MODULE_NAVIGATION_ICONS) {
|
||||
expect(icon).toMatch(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u);
|
||||
expect(isModuleNavigationIcon(icon)).toBe(true);
|
||||
}
|
||||
expect(isModuleNavigationIcon("signage")).toBe(false);
|
||||
expect(Object.isFrozen(MODULE_NAVIGATION_ICONS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("released manifest v8 modules", () => {
|
||||
it("still accepts the released Signage 0.1.5 manifest exactly as published", async () => {
|
||||
const published = JSON.parse(
|
||||
await readFile(path.join(fixtures, "released-v8-signage-0.1.5.module.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const manifest = parseModuleManifest(published);
|
||||
|
||||
expect(manifest.manifestVersion).toBe(8);
|
||||
expect(manifest.id).toBe("signage");
|
||||
// The settings Signage declares are what Settings reads (ADR 0061), unchanged by v9.
|
||||
expect(manifest.configuration.length).toBeGreaterThan(0);
|
||||
// A v8 module declares no navigation: the generic icon and one root page.
|
||||
expect(moduleManifestNavigation(manifest)).toBe(EMPTY_MODULE_NAVIGATION);
|
||||
expect("navigation" in manifest).toBe(false);
|
||||
});
|
||||
|
||||
it("never reads navigation into a version 8 document: one that names it is refused, not upgraded", () => {
|
||||
const v8WithNavigation = { ...v9Manifest({ icon: "tv" }), manifestVersion: 8 };
|
||||
expect(safeParseModuleManifest(v8WithNavigation).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,11 @@ export {
|
||||
type ModuleConfigurationValue,
|
||||
type ModuleHttpMethod,
|
||||
type ModuleManifest,
|
||||
moduleManifestNavigation,
|
||||
MODULE_NAVIGATION_ICONS,
|
||||
type ModuleNavigationDeclaration,
|
||||
type ModuleNavigationIcon,
|
||||
type ModuleNavigationPage,
|
||||
type ModuleRealtimeChannel,
|
||||
type ModuleRealtimeChannelDefinitionInput,
|
||||
type ModuleRealtimeChannelRegistrar,
|
||||
|
||||
@@ -54,6 +54,7 @@ function hostContext(requested: string[], moduleId = "announcements"): ModuleUiH
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId,
|
||||
navigation: undefined,
|
||||
api: {
|
||||
async request(path) {
|
||||
requested.push(path);
|
||||
|
||||
@@ -35,6 +35,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId: "example",
|
||||
navigation: undefined,
|
||||
api: {
|
||||
async request(pathname) {
|
||||
requested.push(`/api/v1/modules/example/${pathname.replace(/^\//u, "")}`);
|
||||
@@ -53,6 +54,7 @@ function standaloneContext(requested: string[]): ModuleUiHostContext {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: { url: "https://example.test/display" },
|
||||
moduleId: "example",
|
||||
navigation: undefined,
|
||||
api: {
|
||||
async request(pathname) {
|
||||
requested.push(`https://example.test/api/${pathname.replace(/^\//u, "")}`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModuleUiContributions } from "./contribution.js";
|
||||
import type { ModuleUiNavigation } from "./navigation.js";
|
||||
import type { ModuleUiRealtimeClient } from "./realtime.js";
|
||||
|
||||
/**
|
||||
@@ -55,9 +56,10 @@ export type ModuleUiPrincipal =
|
||||
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
|
||||
*
|
||||
* This is deliberately the smallest set that proves the architecture rather than everything a real
|
||||
* screen will eventually want. Navigation, module configuration, organization/platform context, and
|
||||
* diagnostics reporting are **not** here: each needs a design that does not exist, and a speculative
|
||||
* field frozen into a public contract is harder to remove than to add. See ADR 0022.
|
||||
* screen will eventually want. Module configuration, organization/platform context, and diagnostics
|
||||
* reporting are **not** here: each needs a design that does not exist, and a speculative field
|
||||
* frozen into a public contract is harder to remove than to add. See ADR 0022. Navigation arrived
|
||||
* once its design did (ADR 0063).
|
||||
*
|
||||
* The host **theme** is deliberately absent for a different reason. Design tokens reach a module as
|
||||
* inherited CSS custom properties, not as data (ADR 0039): the host installs the values at its own
|
||||
@@ -102,6 +104,15 @@ export interface ModuleUiHostContext {
|
||||
* resolves `api.request`.
|
||||
*/
|
||||
readonly contributions: ModuleUiContributions;
|
||||
/**
|
||||
* Where this module is and how it moves between its own pages, and to its Settings page
|
||||
* (ADR 0063). See {@link ModuleUiNavigation}.
|
||||
*
|
||||
* `undefined` where the host has no addressable pages for it — the full-viewport display host,
|
||||
* which shows one screen, and any standalone host without routes. A module that offers
|
||||
* navigation checks for it, exactly as it checks `display`.
|
||||
*/
|
||||
readonly navigation: ModuleUiNavigation | undefined;
|
||||
}
|
||||
|
||||
export interface ModuleUiDisplayLocation {
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from "./define-module-ui.js";
|
||||
export * from "./host-context.js";
|
||||
export * from "./json.js";
|
||||
export * from "./module-ui.js";
|
||||
export * from "./navigation.js";
|
||||
export * from "./realtime.js";
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId,
|
||||
navigation: undefined,
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => canPublish },
|
||||
@@ -214,6 +215,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: { url: "https://announcements.example.org/display" },
|
||||
moduleId,
|
||||
navigation: undefined,
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => canPublish },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiNavigation,
|
||||
ModuleUiNavigationError,
|
||||
} from "../src/index.js";
|
||||
|
||||
/** The navigation capability's public surface (ADR 0063). Its behaviour is the host's: the
|
||||
* Dashboard's implementation is proven in `surfaces/shell/tests/module-navigation.test.tsx`. */
|
||||
describe("module UI navigation contract", () => {
|
||||
it("reports a refused path with a stable code and a fixed message that never echoes the path", () => {
|
||||
for (const code of [
|
||||
"MODULE_NAVIGATION_PATH_INVALID",
|
||||
"MODULE_NAVIGATION_PATH_UNKNOWN",
|
||||
] as const) {
|
||||
const error = new ModuleUiNavigationError(code);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe("ModuleUiNavigationError");
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.stack).toBe(`ModuleUiNavigationError: ${error.message}`);
|
||||
expect(error.message).not.toMatch(/[/:?#]/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("makes navigation a context member a host without pages leaves undefined", () => {
|
||||
// A type-level contract: a context may say it has no navigation, as the display host does, and
|
||||
// a module reads it through an optional check.
|
||||
const describe = (context: Pick<ModuleUiHostContext, "navigation">): string =>
|
||||
context.navigation?.location().path ?? "no navigation";
|
||||
const navigation: ModuleUiNavigation = {
|
||||
link: () => ({ href: "/m/x", onClick: () => undefined }),
|
||||
location: () => ({ page: "x", path: "x" }),
|
||||
navigate: () => undefined,
|
||||
settings: undefined,
|
||||
subscribe: () => () => undefined,
|
||||
};
|
||||
expect(describe({ navigation: undefined })).toBe("no navigation");
|
||||
expect(describe({ navigation })).toBe("x");
|
||||
});
|
||||
});
|
||||
@@ -395,6 +395,185 @@ check(
|
||||
"widening the window brings the sidebar back on the same page",
|
||||
);
|
||||
|
||||
// --- Module navigation (ADR 0063): pages in the sidebar, real routes, module → Settings → back ---
|
||||
if (openedModuleId) {
|
||||
const moduleId = openedModuleId;
|
||||
const declared =
|
||||
await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/dashboard/navigation`)}, {
|
||||
credentials: "include",
|
||||
}).then((response) => response.json()).then((navigation) => {
|
||||
const entries = [...navigation.ungrouped, ...navigation.groups.flatMap((group) => group.modules)];
|
||||
return entries.find((entry) => entry.module.id === ${JSON.stringify(moduleId)})?.navigation ?? null;
|
||||
})`);
|
||||
const inModule = (inner) =>
|
||||
`document.querySelector("[data-ekklesia-module-ui]")?.shadowRoot?.querySelector(${JSON.stringify(inner)})`;
|
||||
const moduleMounted = `${selector("module-container")}?.childElementCount > 0 && !${selector("module-error")}`;
|
||||
const isCurrent = (testId) => `${selector(testId)}?.getAttribute("aria-current") === "page"`;
|
||||
if (declared === null) {
|
||||
check(false, "the navigation view describes the opened module");
|
||||
} else if (declared.pages.length === 0) {
|
||||
// A manifest v8 module (or a v9 one with no pages): exactly as before.
|
||||
await navigate(`${dashboardOrigin}/${moduleId}`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${moduleMounted} && ${isCurrent(`select-${moduleId}`)} && !${selector(`module-subnavigation-${moduleId}`)} && ${selector(`select-${moduleId}`)}?.querySelector("svg")?.dataset.icon === ${JSON.stringify(declared.icon === null ? "module" : `module:${declared.icon}`)}`,
|
||||
"the single-page module with its icon and no sub-navigation",
|
||||
),
|
||||
`${moduleId} declares no pages: one sidebar item with its icon, mounted at its root`,
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/${moduleId}/definitely-not-a-page`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("host-not-found")} && !(${selector("module-container")}?.childElementCount > 0)`,
|
||||
"not-found beneath a single-page module",
|
||||
),
|
||||
`an address beneath ${moduleId}'s root is not found, and mounts nothing`,
|
||||
);
|
||||
} else {
|
||||
const [first, second] = declared.pages;
|
||||
const pagePath = (page) => `/${moduleId}/${page.path}`;
|
||||
await navigate(`${dashboardOrigin}/${moduleId}`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${moduleMounted} && ${isCurrent(`select-${moduleId}`)} && ${selector(`module-subnavigation-${moduleId}`)}?.querySelectorAll("a").length === ${declared.pages.length} && ${selector(`select-${moduleId}`)}?.querySelector("svg")?.dataset.icon === ${JSON.stringify(declared.icon === null ? "module" : `module:${declared.icon}`)}`,
|
||||
"the module's pages beneath it in the sidebar",
|
||||
),
|
||||
`${moduleId}'s declared icon and ${declared.pages.length} pages appear beneath it while it is open`,
|
||||
);
|
||||
await evaluate(
|
||||
`window.__ekklesiaModuleRoot = document.querySelector("[data-ekklesia-module-ui]")`,
|
||||
);
|
||||
await evaluate(`${selector(`select-${moduleId}-${first.id}`)}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(first))} && ${isCurrent(`select-${moduleId}-${first.id}`)} && document.querySelector("[data-ekklesia-module-ui]") === window.__ekklesiaModuleRoot`,
|
||||
"the first page, without remounting the module",
|
||||
),
|
||||
`choosing ${first.label} opens ${pagePath(first)} in place, marks it, and keeps the module mounted`,
|
||||
);
|
||||
if (second) {
|
||||
await navigate(`${dashboardOrigin}${pagePath(second)}`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(second))} && ${moduleMounted} && ${isCurrent(`select-${moduleId}-${second.id}`)}`,
|
||||
"a declared page from its deep link",
|
||||
),
|
||||
`a deep link to ${pagePath(second)} opens that page (what a refresh or bookmark does)`,
|
||||
);
|
||||
await evaluate("history.back()");
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(first))} && ${isCurrent(`select-${moduleId}-${first.id}`)} && ${moduleMounted}`,
|
||||
"Back to the previous page",
|
||||
),
|
||||
"Back returns to the previously open page",
|
||||
);
|
||||
await evaluate("history.forward()");
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(second))} && ${isCurrent(`select-${moduleId}-${second.id}`)}`,
|
||||
"Forward to the next page",
|
||||
),
|
||||
"Forward returns to it again",
|
||||
);
|
||||
}
|
||||
await navigate(`${dashboardOrigin}/${moduleId}/definitely-not-a-page`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("host-not-found")} && !(${selector("module-container")}?.childElementCount > 0)`,
|
||||
"not-found for an undeclared page",
|
||||
),
|
||||
`an undeclared page under ${moduleId} is not found, and mounts nothing`,
|
||||
);
|
||||
if (moduleId === "example") {
|
||||
// The template's own pages: a detail route beneath Records, and its Settings link.
|
||||
await navigate(`${dashboardOrigin}/example/records`);
|
||||
await waitFor(
|
||||
inModule('[data-testid^="example-record-link-"]'),
|
||||
"a record link inside the module",
|
||||
);
|
||||
await evaluate(`${inModule('[data-testid^="example-record-link-"]')}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname.startsWith("/example/records/") && ${isCurrent("select-example-records")} && ${inModule('[data-testid="example-record-value"]')}`,
|
||||
"the record's detail route",
|
||||
),
|
||||
"a detail route beneath a declared page opens inside the module, its page marked",
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/example`);
|
||||
await waitFor(
|
||||
inModule('[data-testid="example-open-settings"]'),
|
||||
"the module's Settings button",
|
||||
);
|
||||
await evaluate(`${inModule('[data-testid="example-open-settings"]')}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === "/settings/example" && location.search === "?return=%2Fexample" && ${selector("module-settings")}`,
|
||||
"the module's Settings page, carrying where it came from",
|
||||
),
|
||||
"the module opens its centralized Settings page through its navigation capability",
|
||||
);
|
||||
}
|
||||
// Settings → Back to exactly the module page that sent the person there, across a refresh.
|
||||
await navigate(
|
||||
`${dashboardOrigin}/settings/${moduleId}?return=${encodeURIComponent(pagePath(first))}`,
|
||||
);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("module-settings")}?.querySelector(".ek-page-header__back")?.getAttribute("href") === ${JSON.stringify(pagePath(first))}`,
|
||||
"Back to the originating module page",
|
||||
),
|
||||
"after a refresh, Settings' Back still names the exact module page it was opened from",
|
||||
);
|
||||
await evaluate(`${selector("module-settings")}.querySelector(".ek-page-header__back").click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(first))} && ${moduleMounted} && ${isCurrent(`select-${moduleId}-${first.id}`)}`,
|
||||
"the originating module page",
|
||||
),
|
||||
`Back from Settings returns to exactly ${pagePath(first)}`,
|
||||
);
|
||||
await navigate(
|
||||
`${dashboardOrigin}/settings/${moduleId}?return=${encodeURIComponent("https://evil.example.test/")}`,
|
||||
);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("module-settings")}?.querySelector(".ek-page-header__back")?.getAttribute("href") === "/settings"`,
|
||||
"an unsafe return target ignored",
|
||||
),
|
||||
"an off-site return target is ignored: Back goes to Settings",
|
||||
);
|
||||
// The phone drawer: the identical hierarchy, closing once a page is chosen.
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 2,
|
||||
height: 844,
|
||||
mobile: true,
|
||||
width: 390,
|
||||
});
|
||||
await navigate(`${dashboardOrigin}/${moduleId}`);
|
||||
await waitFor(selector("mobile-navigation-trigger"), "the phone navigation trigger");
|
||||
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
|
||||
await waitFor(
|
||||
`${selector("navigation-drawer")}?.querySelector('[data-testid="module-subnavigation-${moduleId}"]')`,
|
||||
"the module's pages in the drawer",
|
||||
);
|
||||
await evaluate(`${selector(`select-${moduleId}-${first.id}`)}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(first))} && !${selector("navigation-drawer")} && ${moduleMounted}`,
|
||||
"a page chosen from the drawer",
|
||||
),
|
||||
"the phone drawer shows the module's pages, and closes once one is chosen",
|
||||
);
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 1,
|
||||
height: 1080,
|
||||
mobile: false,
|
||||
width: 1920,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settings (ADR 0061): a deep link, a refresh, Discover, a module's settings, the drawer -------
|
||||
await navigate(`${dashboardOrigin}/settings`);
|
||||
check(
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"./display-assets": {
|
||||
"types": "./dist/display-assets.d.ts",
|
||||
"import": "./dist/display-assets.js"
|
||||
},
|
||||
"./module-icons": {
|
||||
"types": "./dist/module-icons.d.ts",
|
||||
"import": "./dist/module-icons.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
{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 (
|
||||
<Fragment key={`${moduleId}@${entry.descriptor.version}`}>
|
||||
<NavigationItem
|
||||
current={props.route.kind === "module" && props.route.moduleId === moduleId}
|
||||
destination={{ kind: "module", moduleId }}
|
||||
current={resolution !== undefined && (openPage === undefined || !showPages)}
|
||||
destination={moduleDestination(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}`}
|
||||
icon={<ModuleIcon icon={entry.icon} />}
|
||||
label={entry.name}
|
||||
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
|
||||
presentation={props.presentation}
|
||||
{...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" };
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACCOUNT_SECTIONS,
|
||||
DASHBOARD_PLATFORM_PAGES,
|
||||
dashboardPathReturningTo,
|
||||
HOST_NAMESPACES,
|
||||
parseModuleRoute,
|
||||
returnTargetFromSearch,
|
||||
dashboardPath,
|
||||
parseDashboardPath,
|
||||
DISCOVER_VIEWS,
|
||||
@@ -27,13 +31,25 @@ describe("parseDashboardPath", () => {
|
||||
});
|
||||
|
||||
it("names a module page by its id at the top level, tolerating one trailing slash", () => {
|
||||
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
|
||||
expect(parseDashboardPath("/signage")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "",
|
||||
});
|
||||
expect(parseDashboardPath("/signage/")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "",
|
||||
});
|
||||
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people", path: "" });
|
||||
});
|
||||
|
||||
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
|
||||
expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseDashboardPath("/sign%61ge")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("never reads a platform page as a module page", () => {
|
||||
@@ -65,7 +81,9 @@ describe("parseDashboardPath", () => {
|
||||
"/accounts",
|
||||
]) {
|
||||
expect(parseDashboardPath(pathname), pathname).toEqual(
|
||||
pathname === "/accounts" ? { kind: "module", moduleId: "accounts" } : { kind: "not-found" },
|
||||
pathname === "/accounts"
|
||||
? { kind: "module", moduleId: "accounts", path: "" }
|
||||
: { kind: "not-found" },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -76,10 +94,13 @@ describe("parseDashboardPath", () => {
|
||||
"//",
|
||||
"//signage",
|
||||
"/signage//",
|
||||
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
|
||||
"/signage/timelines",
|
||||
"/signage/timelines/abc",
|
||||
"/signage/a//b",
|
||||
"/%E0%A4%A",
|
||||
// The host's non-Dashboard namespaces are never pages, however deep (ADR 0063).
|
||||
"/api",
|
||||
"/api/v1/principal",
|
||||
"/assets/ekklesia-dashboard/app.js",
|
||||
"/display/signage",
|
||||
// Deeper Settings pages are reserved for later (ADR 0061).
|
||||
"/settings/displays/add",
|
||||
"/settings/signage/timelines",
|
||||
@@ -166,27 +187,112 @@ describe("Settings routes (ADR 0061)", () => {
|
||||
describe("dashboardPath", () => {
|
||||
it("spells each route canonically, with no implementation prefix", () => {
|
||||
expect(dashboardPath({ kind: "overview" })).toBe("/");
|
||||
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage");
|
||||
expect(dashboardPath({ kind: "module", moduleId: "signage", path: "" })).toBe("/signage");
|
||||
});
|
||||
|
||||
it("encodes a module id so it can never escape its own segment", () => {
|
||||
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
|
||||
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
|
||||
expect(dashboardPath({ kind: "module", moduleId: "a/../b", path: "" })).toBe("/a%2F..%2Fb");
|
||||
expect(
|
||||
parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b", path: "" })),
|
||||
).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "a/../b",
|
||||
path: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips through the parser", () => {
|
||||
for (const route of [
|
||||
{ kind: "overview" } as const,
|
||||
{ kind: "module", moduleId: "example" } as const,
|
||||
{ kind: "module", moduleId: "example", path: "" } as const,
|
||||
{ kind: "module", moduleId: "example", path: "records" } as const,
|
||||
{ kind: "module", moduleId: "media", path: "sermons/2026-10-11_service~1.mp4" } as const,
|
||||
]) {
|
||||
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("module routes (ADR 0063)", () => {
|
||||
it("reads everything beneath a module's root as that module's own route", () => {
|
||||
expect(parseDashboardPath("/signage/timelines")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "timelines",
|
||||
});
|
||||
expect(parseDashboardPath("/signage/timelines/")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "timelines",
|
||||
});
|
||||
expect(parseDashboardPath("/media/sermons/123")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "media",
|
||||
path: "sermons/123",
|
||||
});
|
||||
});
|
||||
|
||||
it("spells a module route canonically, its path beneath the encoded module id", () => {
|
||||
expect(dashboardPath({ kind: "module", moduleId: "signage", path: "timelines" })).toBe(
|
||||
"/signage/timelines",
|
||||
);
|
||||
expect(dashboardPath({ kind: "module", moduleId: "signage", path: "" })).toBe("/signage");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", ""],
|
||||
["/", ""],
|
||||
["timelines", "timelines"],
|
||||
["/timelines", "timelines"],
|
||||
["media/sermons/123", "media/sermons/123"],
|
||||
["a.b_c~d-e", "a.b_c~d-e"],
|
||||
])("accepts the module route %j as %j", (input, expected) => {
|
||||
expect(parseModuleRoute(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"../calendar",
|
||||
"media/../../settings",
|
||||
"./timelines",
|
||||
"media/.",
|
||||
"//evil.example.test",
|
||||
"https://evil.example.test",
|
||||
"javascript:alert(1)",
|
||||
"timelines?x=1",
|
||||
"timelines#top",
|
||||
"media%2F..%2Fapi",
|
||||
"media\\library",
|
||||
"media//library",
|
||||
"timelines/",
|
||||
"time lines",
|
||||
"a/b/c/d/e/f/g/h/i",
|
||||
`${"a".repeat(513)}`,
|
||||
])("refuses the module route %j", (input) => {
|
||||
expect(parseModuleRoute(input)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses anything that is not a string", () => {
|
||||
for (const value of [undefined, null, 1, ["timelines"], { path: "timelines" }]) {
|
||||
expect(parseModuleRoute(value)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("names exactly the host's non-Dashboard namespaces", () => {
|
||||
expect(HOST_NAMESPACES).toEqual(["api", "assets", "display"]);
|
||||
});
|
||||
|
||||
it("carries a return target on its own query parameter, readable only as a raw value", () => {
|
||||
const href = dashboardPathReturningTo(
|
||||
{ kind: "module-settings", moduleId: "signage" },
|
||||
{ kind: "module", moduleId: "signage", path: "timelines" },
|
||||
);
|
||||
expect(href).toBe("/settings/signage?return=%2Fsignage%2Ftimelines");
|
||||
expect(returnTargetFromSearch("?return=%2Fsignage%2Ftimelines")).toBe("/signage/timelines");
|
||||
expect(returnTargetFromSearch("")).toBeUndefined();
|
||||
expect(returnTargetFromSearch("?other=1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the route locator is published for the API that serves the page", () => {
|
||||
it("is a narrow package subpath, not the shell root barrel", async () => {
|
||||
const manifest = JSON.parse(
|
||||
|
||||
@@ -251,10 +251,13 @@ describe("display host — full-viewport boundary", () => {
|
||||
"contributions",
|
||||
"display",
|
||||
"moduleId",
|
||||
"navigation",
|
||||
"permissions",
|
||||
"principal",
|
||||
"realtime",
|
||||
]);
|
||||
// A display is one screen with no pages: the display host offers no navigation (ADR 0063).
|
||||
expect(seenContext?.navigation).toBeUndefined();
|
||||
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||
expect(isolatedText(query("display-module-container"))).toBe("permitted");
|
||||
expect(
|
||||
@@ -352,6 +355,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
version: "1.2.0",
|
||||
},
|
||||
name: "Announcements",
|
||||
navigation: { icon: null, pages: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ function entry(id: string, name: string, version = "0.1.0", permissions: readonl
|
||||
version,
|
||||
},
|
||||
name,
|
||||
navigation: { icon: null, pages: [] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("the browser host history", () => {
|
||||
const view = {
|
||||
addEventListener: vi.fn(),
|
||||
history: { pushState: vi.fn(), replaceState: vi.fn() },
|
||||
location: { pathname: "/" },
|
||||
location: { pathname: "/", search: "" },
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
const history = createBrowserHostHistory(view);
|
||||
@@ -87,7 +87,7 @@ describe("the browser host history", () => {
|
||||
const view = {
|
||||
addEventListener: vi.fn(),
|
||||
history: { pushState: vi.fn(), replaceState: vi.fn() },
|
||||
location: { pathname: "/" },
|
||||
location: { pathname: "/", search: "" },
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
const history = createBrowserHostHistory(view);
|
||||
@@ -100,6 +100,37 @@ describe("the browser host history", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the query, which carries only where to return to (ADR 0063)", () => {
|
||||
it("reads the browser's query, and pushes an address that carries one", () => {
|
||||
const view = {
|
||||
addEventListener: vi.fn(),
|
||||
history: { pushState: vi.fn(), replaceState: vi.fn() },
|
||||
location: { pathname: "/settings/signage", search: "?return=%2Fsignage" },
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
const history = createBrowserHostHistory(view);
|
||||
|
||||
expect(history.search()).toBe("?return=%2Fsignage");
|
||||
// The address already showing, query included, adds no entry; the same path with another query
|
||||
// is another address.
|
||||
history.push("/settings/signage?return=%2Fsignage");
|
||||
expect(view.history.pushState).not.toHaveBeenCalled();
|
||||
history.push("/settings/signage");
|
||||
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/settings/signage");
|
||||
});
|
||||
|
||||
it("keeps the query beside the path in memory, as the browser does", () => {
|
||||
const history = createMemoryHostHistory("/settings/signage?return=%2Fsignage%2Fmedia");
|
||||
|
||||
expect(history.pathname()).toBe("/settings/signage");
|
||||
expect(history.search()).toBe("?return=%2Fsignage%2Fmedia");
|
||||
history.push("/signage/media");
|
||||
expect(history.search()).toBe("");
|
||||
history.go(-1);
|
||||
expect(history.search()).toBe("?return=%2Fsignage%2Fmedia");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the in-memory host history", () => {
|
||||
it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => {
|
||||
const history = createMemoryHostHistory("/");
|
||||
|
||||
@@ -78,7 +78,13 @@ const navigationPayload = {
|
||||
{
|
||||
id: "media",
|
||||
label: "Media",
|
||||
modules: [{ module: inventoryPayload.modules[0], name: "Example Module" }],
|
||||
modules: [
|
||||
{
|
||||
module: inventoryPayload.modules[0],
|
||||
name: "Example Module",
|
||||
navigation: { icon: null, pages: [] },
|
||||
},
|
||||
],
|
||||
order: 10,
|
||||
},
|
||||
],
|
||||
@@ -95,6 +101,7 @@ const navigationPayload = {
|
||||
version: "2.0.0",
|
||||
},
|
||||
name: "Calendar",
|
||||
navigation: { icon: null, pages: [] },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -111,7 +118,10 @@ const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
};
|
||||
|
||||
function navigationWithModule(module: unknown): unknown {
|
||||
return { groups: [], ungrouped: [{ module, name: "Example Module" }] };
|
||||
return {
|
||||
groups: [],
|
||||
ungrouped: [{ module, name: "Example Module", navigation: { icon: null, pages: [] } }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("host protocol — API origin (ADR 0045)", () => {
|
||||
@@ -257,9 +267,11 @@ describe("host protocol — Dashboard navigation", () => {
|
||||
|
||||
expect(navigation.groups.map((group) => [group.id, group.label])).toEqual([["media", "Media"]]);
|
||||
expect(navigation.groups[0]?.modules).toEqual([
|
||||
{ descriptor: exampleDescriptor, name: "Example Module" },
|
||||
{ descriptor: exampleDescriptor, icon: undefined, name: "Example Module", pages: [] },
|
||||
]);
|
||||
expect(navigation.ungrouped).toEqual([
|
||||
{ descriptor: calendarDescriptor, icon: undefined, name: "Calendar", pages: [] },
|
||||
]);
|
||||
expect(navigation.ungrouped).toEqual([{ descriptor: calendarDescriptor, name: "Calendar" }]);
|
||||
});
|
||||
|
||||
it("rejects a malformed navigation document rather than rendering half of it", () => {
|
||||
@@ -281,6 +293,7 @@ describe("host protocol — Dashboard navigation", () => {
|
||||
ui: { url: "https://evil.example.test/x.js" },
|
||||
},
|
||||
name: "Example",
|
||||
navigation: { icon: null, pages: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -556,7 +569,7 @@ describe("module UI host context", () => {
|
||||
expect(context.display?.url).toBe("https://api.example.test/display/example");
|
||||
});
|
||||
|
||||
it("exposes exactly the seven contract members and the browser principal", () => {
|
||||
it("exposes exactly the eight contract members and the browser principal", () => {
|
||||
const { implementation } = recordingFetch();
|
||||
|
||||
const context = contextFor(implementation);
|
||||
@@ -566,11 +579,14 @@ describe("module UI host context", () => {
|
||||
"contributions",
|
||||
"display",
|
||||
"moduleId",
|
||||
"navigation",
|
||||
"permissions",
|
||||
"principal",
|
||||
"realtime",
|
||||
]);
|
||||
expect(context.moduleId).toBe("example");
|
||||
// Navigation comes only from a host with addressable pages (ADR 0063); this one supplies none.
|
||||
expect(context.navigation).toBeUndefined();
|
||||
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||
});
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ function entry(id: string, name: string, version = "0.1.0") {
|
||||
version,
|
||||
},
|
||||
name,
|
||||
navigation: { icon: null, pages: [] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,15 @@ describe("the shell builds no module knowledge into its bundle", () => {
|
||||
// mean the shell had an opinion about an arrangement it must only render.
|
||||
// Deliberately distinctive terms: a generic word like "media" appears inside React's own
|
||||
// bundled source and would make this assertion meaningless rather than strict.
|
||||
for (const term of ["planning", "sunday-production", "livestream", "calendar", "ungrouped-"]) {
|
||||
// "calendar" is no longer one of them: it is a generic Lucide icon a module may declare
|
||||
// (ADR 0063), so the bundle names it on purpose.
|
||||
for (const term of [
|
||||
"planning",
|
||||
"sunday-production",
|
||||
"livestream",
|
||||
"youth-ministry",
|
||||
"ungrouped-",
|
||||
]) {
|
||||
expect(bundle.toLowerCase(), term).not.toContain(term);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
import {
|
||||
MODULE_UI_ABI_VERSION,
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiNavigation,
|
||||
ModuleUiNavigationError,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import * as lucide from "lucide-react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HostApp } from "../src/browser/HostApp.js";
|
||||
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
|
||||
import { createMemoryHostViewport } from "../src/browser/host-viewport.js";
|
||||
import { MODULE_ICON_COMPONENTS } from "../src/browser/module-icon.js";
|
||||
import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js";
|
||||
import type { DashboardSettings } from "../src/host-protocol.js";
|
||||
import { DASHBOARD_MODULE_ICON_NAMES } from "../src/module-icons.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module navigation (manifest v9, ADR 0063): a module's own icon and primary pages in the global
|
||||
* sidebar, its real nested routes, the navigation capability a mounted module is handed through the
|
||||
* real mounting boundary, and module → Settings → back to exactly where it was — beside a
|
||||
* manifest v8 module that keeps behaving as it always has.
|
||||
*/
|
||||
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
function entry(
|
||||
id: string,
|
||||
name: string,
|
||||
navigation: { icon: string | null; pages: { id: string; label: string; path: string }[] } = {
|
||||
icon: null,
|
||||
pages: [],
|
||||
},
|
||||
) {
|
||||
return {
|
||||
module: {
|
||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||
contributions: [],
|
||||
display: null,
|
||||
id,
|
||||
permissions: [],
|
||||
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
||||
ui: { url: `/api/v1/module-ui/${id}/0.1.0` },
|
||||
version: "0.1.0",
|
||||
},
|
||||
name,
|
||||
navigation,
|
||||
};
|
||||
}
|
||||
|
||||
const SIGNAGE_PAGES = [
|
||||
{ id: "playback", label: "Playback", path: "playback" },
|
||||
{ id: "timelines", label: "Timelines", path: "timelines" },
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
];
|
||||
|
||||
/** A manifest v9 module with an icon and three pages, and a manifest v8 module with neither. */
|
||||
const NAVIGATION = {
|
||||
groups: [],
|
||||
ungrouped: [
|
||||
entry("signage", "Signage", { icon: "monitor-play", pages: SIGNAGE_PAGES }),
|
||||
entry("calendar", "Calendar"),
|
||||
],
|
||||
};
|
||||
|
||||
/** Signage declares configuration and the viewer may change it; Calendar has no settings; Archive
|
||||
* is installed but turned off, so it is in Settings but not in the navigation. */
|
||||
const SETTINGS: DashboardSettings = {
|
||||
modules: [
|
||||
{ available: false, id: "archive", name: "Archive" },
|
||||
{ available: true, id: "signage", name: "Signage" },
|
||||
],
|
||||
sections: [],
|
||||
};
|
||||
|
||||
interface Mounts {
|
||||
imported: string[];
|
||||
mounted: string[];
|
||||
unmounted: string[];
|
||||
contexts: Map<string, ModuleUiHostContext>;
|
||||
}
|
||||
|
||||
async function render(
|
||||
path: string,
|
||||
options: { readonly history?: MemoryHostHistory; readonly compact?: boolean } = {},
|
||||
) {
|
||||
const history = options.history ?? createMemoryHostHistory(path);
|
||||
const titles: string[] = [];
|
||||
const mounts: Mounts = { contexts: new Map(), imported: [], mounted: [], unmounted: [] };
|
||||
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/v1/principal")) {
|
||||
return Response.json({ kind: "authenticated", userId: "user-1" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/dashboard/navigation")) {
|
||||
return Response.json(NAVIGATION);
|
||||
}
|
||||
if (url.endsWith("/api/v1/dashboard/settings")) {
|
||||
return Response.json(SETTINGS);
|
||||
}
|
||||
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
||||
}) as unknown as typeof fetch;
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HostApp
|
||||
config={{ apiBaseUrl, fetch: fetchImplementation }}
|
||||
history={history}
|
||||
importModule={async (url: string) => {
|
||||
const moduleId = /module-ui\/([a-z-]+)\//u.exec(url)?.[1] ?? "unknown";
|
||||
mounts.imported.push(moduleId);
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id: moduleId,
|
||||
mount(element: Element, context: ModuleUiHostContext) {
|
||||
mounts.contexts.set(moduleId, context);
|
||||
const show = (): void => {
|
||||
element.textContent = `${moduleId}:${context.navigation?.location().path ?? "-"}`;
|
||||
};
|
||||
show();
|
||||
const unsubscribe = context.navigation?.subscribe(show);
|
||||
mounts.mounted.push(moduleId);
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
mounts.unmounted.push(moduleId);
|
||||
};
|
||||
},
|
||||
version: "0.1.0",
|
||||
},
|
||||
};
|
||||
}}
|
||||
setDocumentTitle={(title) => titles.push(title)}
|
||||
sidebarPreference={createMemorySidebarPreference()}
|
||||
viewport={createMemoryHostViewport(options.compact ?? false)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { history, mounts, titles };
|
||||
}
|
||||
|
||||
function query(testId: string): HTMLElement | null {
|
||||
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
|
||||
}
|
||||
|
||||
function required(testId: string): HTMLElement {
|
||||
const element = query(testId);
|
||||
if (!element) {
|
||||
throw new Error(`missing ${testId}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
async function click(element: Element, init: MouseEventInit = {}): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** What the mounted module rendered, read through its isolated (Shadow DOM) root. */
|
||||
function moduleText(): string {
|
||||
const host = container.querySelector("[data-ekklesia-module-ui]");
|
||||
return host?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? "";
|
||||
}
|
||||
|
||||
const current = () =>
|
||||
[...container.querySelectorAll('nav [aria-current="page"]')].map(
|
||||
(element) => (element as HTMLElement).dataset.testid,
|
||||
);
|
||||
|
||||
function navigationOf(mounts: Mounts, moduleId = "signage"): ModuleUiNavigation {
|
||||
const navigation = mounts.contexts.get(moduleId)?.navigation;
|
||||
if (!navigation) {
|
||||
throw new Error(`no navigation for ${moduleId}`);
|
||||
}
|
||||
return navigation;
|
||||
}
|
||||
|
||||
describe("a manifest v9 module's routes", () => {
|
||||
it("opens the module root as its landing view, with its pages beneath it in the sidebar", async () => {
|
||||
const { mounts, titles } = await render("/signage");
|
||||
|
||||
expect(mounts.mounted).toEqual(["signage"]);
|
||||
expect(moduleText()).toBe("signage:");
|
||||
expect(titles.at(-1)).toBe("Signage");
|
||||
expect(current()).toEqual(["select-signage"]);
|
||||
const pages = [...required("module-subnavigation-signage").querySelectorAll("a")];
|
||||
expect(pages.map((link) => link.textContent)).toEqual(["Playback", "Timelines", "Media"]);
|
||||
expect(pages.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/signage/playback",
|
||||
"/signage/timelines",
|
||||
"/signage/media",
|
||||
]);
|
||||
expect(required("module-subnavigation-signage").getAttribute("aria-label")).toBe("Signage");
|
||||
});
|
||||
|
||||
it("opens a declared page directly from its deep link, as a refresh or a bookmark does", async () => {
|
||||
const { mounts, titles } = await render("/signage/timelines");
|
||||
|
||||
expect(mounts.mounted).toEqual(["signage"]);
|
||||
expect(navigationOf(mounts).location()).toEqual({ page: "timelines", path: "timelines" });
|
||||
expect(moduleText()).toBe("signage:timelines");
|
||||
expect(current()).toEqual(["select-signage-timelines"]);
|
||||
expect(titles.at(-1)).toBe("Timelines · Signage");
|
||||
});
|
||||
|
||||
it("opens a route beneath a declared page — a detail view — marking its page", async () => {
|
||||
const { mounts } = await render("/signage/media/sermons/2026-10-11");
|
||||
|
||||
expect(navigationOf(mounts).location()).toEqual({
|
||||
page: "media",
|
||||
path: "media/sermons/2026-10-11",
|
||||
});
|
||||
expect(current()).toEqual(["select-signage-media"]);
|
||||
});
|
||||
|
||||
it("is not found at a path no declared page owns, and never mounts the module for it", async () => {
|
||||
for (const path of ["/signage/nope", "/signage/timeline", "/signage/Timelines"]) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
const { mounts, titles } = await render(path);
|
||||
|
||||
expect(query("host-not-found"), path).not.toBeNull();
|
||||
expect(mounts.imported, path).toEqual([]);
|
||||
expect(titles.at(-1), path).toBe("Page not found");
|
||||
expect(current(), path).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves between its own pages without remounting, and Back and Forward walk them", async () => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
|
||||
await click(required("select-signage-timelines"));
|
||||
expect(history.entries).toEqual(["/signage", "/signage/timelines"]);
|
||||
expect(moduleText()).toBe("signage:timelines");
|
||||
await click(required("select-signage-media"));
|
||||
|
||||
await act(async () => history.go(-1));
|
||||
expect(moduleText()).toBe("signage:timelines");
|
||||
expect(current()).toEqual(["select-signage-timelines"]);
|
||||
await act(async () => history.go(-1));
|
||||
expect(moduleText()).toBe("signage:");
|
||||
await act(async () => history.go(2));
|
||||
expect(moduleText()).toBe("signage:media");
|
||||
|
||||
// One import and one mount for the whole walk: the module was never torn down.
|
||||
expect(mounts.imported).toEqual(["signage"]);
|
||||
expect(mounts.mounted).toEqual(["signage"]);
|
||||
expect(mounts.unmounted).toEqual([]);
|
||||
});
|
||||
|
||||
it("moves focus to the page when the module moves to another of its pages", async () => {
|
||||
await render("/signage");
|
||||
const main = required("host-main-region");
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
|
||||
await click(required("select-signage-playback"));
|
||||
|
||||
expect(document.activeElement).toBe(main);
|
||||
});
|
||||
|
||||
it("keeps the exact page across a refresh", async () => {
|
||||
const history = createMemoryHostHistory("/signage");
|
||||
await render("/signage", { history });
|
||||
await click(required("select-signage-media"));
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
const { mounts } = await render("/signage/media");
|
||||
|
||||
expect(navigationOf(mounts).location().path).toBe("media");
|
||||
expect(current()).toEqual(["select-signage-media"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a released manifest v8 module", () => {
|
||||
it("is one page, its root, with the generic icon and no sub-navigation", async () => {
|
||||
const { mounts } = await render("/calendar");
|
||||
|
||||
expect(mounts.mounted).toEqual(["calendar"]);
|
||||
expect(moduleText()).toBe("calendar:");
|
||||
expect(current()).toEqual(["select-calendar"]);
|
||||
expect(query("module-subnavigation-calendar")).toBeNull();
|
||||
expect(required("select-calendar").querySelector("svg")?.dataset.icon).toBe("module");
|
||||
});
|
||||
|
||||
it("is not found beneath its root, exactly as before", async () => {
|
||||
const { mounts } = await render("/calendar/events/456");
|
||||
|
||||
expect(query("host-not-found")).not.toBeNull();
|
||||
expect(mounts.imported).toEqual([]);
|
||||
});
|
||||
|
||||
it("still receives navigation — its root, and no Settings link when it has no settings", async () => {
|
||||
const { mounts } = await render("/calendar");
|
||||
const navigation = navigationOf(mounts, "calendar");
|
||||
|
||||
expect(navigation.location()).toEqual({ page: undefined, path: "" });
|
||||
expect(navigation.settings).toBeUndefined();
|
||||
expect(() => navigation.navigate("events")).toThrowError(
|
||||
expect.objectContaining({ code: "MODULE_NAVIGATION_PATH_UNKNOWN" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modules that are not running", () => {
|
||||
it("says a turned-off module is unavailable at its root and beneath it, mounting nothing", async () => {
|
||||
for (const path of ["/archive", "/archive/timelines", "/archive/a/b"]) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
const { mounts } = await render(path);
|
||||
|
||||
expect(query("host-module-unavailable"), path).not.toBeNull();
|
||||
expect(mounts.imported, path).toEqual([]);
|
||||
// A module that is not running is absent from the navigation.
|
||||
expect(query("select-archive"), path).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sidebar", () => {
|
||||
it("draws a module's declared Lucide icon, and the generic icon for one that declares none", async () => {
|
||||
await render("/");
|
||||
|
||||
expect(required("select-signage").querySelector("svg")?.dataset.icon).toBe(
|
||||
"module:monitor-play",
|
||||
);
|
||||
expect(required("select-signage").querySelector("svg")?.getAttribute("aria-hidden")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(required("select-calendar").querySelector("svg")?.dataset.icon).toBe("module");
|
||||
});
|
||||
|
||||
it("shows a module's pages only while that module is open", async () => {
|
||||
await render("/");
|
||||
expect(query("module-subnavigation-signage")).toBeNull();
|
||||
|
||||
await click(required("select-signage"));
|
||||
expect(query("module-subnavigation-signage")).not.toBeNull();
|
||||
|
||||
await click(required("select-calendar"));
|
||||
expect(query("module-subnavigation-signage")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapsed, hides the pages and marks the module itself, named for assistive technology", async () => {
|
||||
await render("/signage/timelines");
|
||||
await click(container.querySelector('[aria-label="Collapse navigation"]') as Element);
|
||||
|
||||
expect(query("module-subnavigation-signage")).toBeNull();
|
||||
expect(current()).toEqual(["select-signage"]);
|
||||
const label = required("select-signage").querySelector("[data-nav-label]");
|
||||
expect(label?.textContent).toBe("Signage");
|
||||
expect(label?.className).toBe("ek-shell-visually-hidden");
|
||||
});
|
||||
|
||||
it("uses the identical hierarchy in the phone drawer, which closes once a page is chosen", async () => {
|
||||
const { history } = await render("/signage", { compact: true });
|
||||
|
||||
await click(required("mobile-navigation-trigger"));
|
||||
const drawer = required("navigation-drawer");
|
||||
const pages = [
|
||||
...drawer.querySelectorAll('[data-testid="module-subnavigation-signage"] a'),
|
||||
] as HTMLElement[];
|
||||
expect(pages.map((link) => link.textContent)).toEqual(["Playback", "Timelines", "Media"]);
|
||||
for (const link of pages) {
|
||||
expect(link.style.minHeight).toBe("var(--ekklesia-control-height-lg)");
|
||||
}
|
||||
|
||||
await click(pages[1] as HTMLElement);
|
||||
expect(history.pathname()).toBe("/signage/timelines");
|
||||
expect(query("navigation-drawer")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the module navigation capability", () => {
|
||||
it("navigates to the module's own routes, adding or replacing a history entry", async () => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
const navigation = navigationOf(mounts);
|
||||
|
||||
await act(async () => navigation.navigate("timelines"));
|
||||
expect(history.entries).toEqual(["/signage", "/signage/timelines"]);
|
||||
await act(async () => navigation.navigate("/media/sermons/9"));
|
||||
expect(history.pathname()).toBe("/signage/media/sermons/9");
|
||||
expect(navigation.location()).toEqual({ page: "media", path: "media/sermons/9" });
|
||||
await act(async () => navigation.navigate("media", { replace: true }));
|
||||
expect(history.entries).toEqual(["/signage", "/signage/timelines", "/signage/media"]);
|
||||
await act(async () => navigation.navigate(""));
|
||||
expect(navigation.location()).toEqual({ page: undefined, path: "" });
|
||||
expect(mounts.mounted).toEqual(["signage"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["../calendar", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["timelines/../../calendar", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["//evil.example.test", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["https://evil.example.test/", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["javascript:alert(1)", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["timelines?x=1", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["timelines#top", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["nope", "MODULE_NAVIGATION_PATH_UNKNOWN"],
|
||||
["timeline", "MODULE_NAVIGATION_PATH_UNKNOWN"],
|
||||
])("refuses %j — no other module, platform page, or undeclared page", async (path, code) => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
const navigation = navigationOf(mounts);
|
||||
|
||||
expect(() => navigation.navigate(path)).toThrowError(expect.objectContaining({ code }));
|
||||
expect(() => navigation.link(path)).toThrowError(ModuleUiNavigationError);
|
||||
expect(history.entries).toEqual(["/signage"]);
|
||||
});
|
||||
|
||||
it("names nothing it refused in its error", async () => {
|
||||
const { mounts } = await render("/signage");
|
||||
const failure = (() => {
|
||||
try {
|
||||
navigationOf(mounts).navigate("../calendar?token=secret");
|
||||
} catch (error) {
|
||||
return error as Error;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
expect(failure).toBeInstanceOf(ModuleUiNavigationError);
|
||||
expect(`${failure?.message}${failure?.stack}`).not.toMatch(/calendar|secret/u);
|
||||
});
|
||||
|
||||
it("makes real links: in place on an ordinary click, to the browser with a modifier", async () => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
const link = navigationOf(mounts).link("timelines");
|
||||
expect(link.href).toBe("/signage/timelines");
|
||||
|
||||
const ordinary = {
|
||||
altKey: false,
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
defaultPrevented: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
shiftKey: false,
|
||||
};
|
||||
await act(async () => link.onClick(ordinary));
|
||||
expect(ordinary.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(history.pathname()).toBe("/signage/timelines");
|
||||
|
||||
const newTab = { ...ordinary, metaKey: true, preventDefault: vi.fn() };
|
||||
await act(async () => navigationOf(mounts).link("media").onClick(newTab));
|
||||
expect(newTab.preventDefault).not.toHaveBeenCalled();
|
||||
expect(history.pathname()).toBe("/signage/timelines");
|
||||
});
|
||||
|
||||
it("reports moves made by the host — the sidebar, Back — to the mounted module", async () => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
const listener = vi.fn();
|
||||
navigationOf(mounts).subscribe(listener);
|
||||
|
||||
await click(required("select-signage-playback"));
|
||||
await act(async () => history.go(-1));
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module → Settings → back to exactly where it was", () => {
|
||||
it("opens the module's Settings page carrying its current page as the place to return to", async () => {
|
||||
const { history, mounts } = await render("/signage/timelines");
|
||||
const settings = navigationOf(mounts).settings;
|
||||
|
||||
expect(settings?.link().href).toBe("/settings/signage?return=%2Fsignage%2Ftimelines");
|
||||
await act(async () => settings?.open());
|
||||
|
||||
expect(history.entries).toEqual([
|
||||
"/signage/timelines",
|
||||
"/settings/signage?return=%2Fsignage%2Ftimelines",
|
||||
]);
|
||||
expect(query("module-settings")).not.toBeNull();
|
||||
const back =
|
||||
required("module-settings").querySelector<HTMLAnchorElement>(".ek-page-header__back");
|
||||
expect(back?.getAttribute("href")).toBe("/signage/timelines");
|
||||
expect(back?.textContent).toContain("Back to Signage");
|
||||
|
||||
await click(back as HTMLAnchorElement);
|
||||
expect(history.pathname()).toBe("/signage/timelines");
|
||||
expect(current()).toEqual(["select-signage-timelines"]);
|
||||
});
|
||||
|
||||
it("returns to where the module is now, even if it never read its location since moving", async () => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
const settings = navigationOf(mounts).settings;
|
||||
mounts.contexts.get("signage")?.navigation?.subscribe(() => undefined)();
|
||||
|
||||
await act(async () => history.push("/signage/media"));
|
||||
|
||||
expect(settings?.link().href).toBe("/settings/signage?return=%2Fsignage%2Fmedia");
|
||||
});
|
||||
|
||||
it("keeps the place to return to across a refresh of the Settings page", async () => {
|
||||
await render("/settings/signage?return=%2Fsignage%2Fmedia%2Fsermons%2F9");
|
||||
|
||||
const back = required("module-settings").querySelector(".ek-page-header__back");
|
||||
expect(back?.getAttribute("href")).toBe("/signage/media/sermons/9");
|
||||
expect(back?.textContent).toContain("Back to Signage");
|
||||
});
|
||||
|
||||
it("keeps it when the Settings address is respelled", async () => {
|
||||
const { history } = await render("/settings/signage/?return=%2Fsignage%2Fmedia");
|
||||
|
||||
expect(history.entries).toEqual(["/settings/signage?return=%2Fsignage%2Fmedia"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another origin", "https://evil.example.test/signage"],
|
||||
["a protocol-relative URL", "//evil.example.test"],
|
||||
["a script", "javascript:alert(1)"],
|
||||
["the API", "/api/v1/principal"],
|
||||
["a page the module does not declare", "/signage/nope"],
|
||||
["a module that is not running", "/archive"],
|
||||
["a module that does not exist", "/nowhere"],
|
||||
["traversal", "/signage/../api"],
|
||||
["a nested query", "/signage?return=https://evil.example.test"],
|
||||
])("ignores a return target naming %s, going back to Settings instead", async (_label, target) => {
|
||||
await render(`/settings/signage?return=${encodeURIComponent(target)}`);
|
||||
|
||||
const back = required("module-settings").querySelector(".ek-page-header__back");
|
||||
expect(back?.getAttribute("href")).toBe("/settings");
|
||||
expect(back?.textContent).toContain("Back to Settings");
|
||||
});
|
||||
|
||||
it("offers no Settings link to a module whose settings this viewer cannot open", async () => {
|
||||
const { mounts } = await render("/calendar");
|
||||
|
||||
expect(navigationOf(mounts, "calendar").settings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("module icons", () => {
|
||||
it("maps exactly the names the platform accepts, each to a real Lucide component", () => {
|
||||
expect(Object.keys(MODULE_ICON_COMPONENTS).sort()).toEqual(
|
||||
[...DASHBOARD_MODULE_ICON_NAMES].sort(),
|
||||
);
|
||||
const exports = lucide as unknown as Record<string, unknown>;
|
||||
for (const name of DASHBOARD_MODULE_ICON_NAMES) {
|
||||
const pascal = name
|
||||
.split("-")
|
||||
.map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`)
|
||||
.join("");
|
||||
expect(MODULE_ICON_COMPONENTS[name], name).toBe(exports[pascal]);
|
||||
}
|
||||
});
|
||||
|
||||
it("draws the generic icon for a name this Dashboard cannot draw", async () => {
|
||||
NAVIGATION.ungrouped.push(entry("press", "Press", { icon: "not-yet-an-icon", pages: [] }));
|
||||
try {
|
||||
await render("/");
|
||||
expect(required("select-press").querySelector("svg")?.dataset.icon).toBe("module");
|
||||
} finally {
|
||||
NAVIGATION.ungrouped.pop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,16 @@ import { parseReturnTarget } from "../src/return-target.js";
|
||||
describe("parseReturnTarget", () => {
|
||||
it("accepts a Dashboard page on this origin, as its canonical destination", () => {
|
||||
expect(parseReturnTarget("/")).toEqual({ kind: "overview" });
|
||||
expect(parseReturnTarget("/signage")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseReturnTarget("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseReturnTarget("/signage")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "",
|
||||
});
|
||||
expect(parseReturnTarget("/signage/")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "",
|
||||
});
|
||||
// Settings pages are Dashboard pages too (ADR 0061).
|
||||
expect(parseReturnTarget("/settings")).toEqual({ kind: "settings" });
|
||||
expect(parseReturnTarget("/settings/signage")).toEqual({
|
||||
@@ -59,13 +67,29 @@ describe("parseReturnTarget", () => {
|
||||
"/signage?x=1",
|
||||
"/signage#top",
|
||||
"/api/v1/principal",
|
||||
"/api/auth/sign-out",
|
||||
"/assets/ekklesia-dashboard/app.js",
|
||||
"/display/signage",
|
||||
"/settings/displays/add",
|
||||
"/signage/timelines",
|
||||
"/signage//",
|
||||
"/signage/a//b",
|
||||
]) {
|
||||
expect(parseReturnTarget(value), value).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a module's own deeper route, whose page the host then checks (ADR 0063)", () => {
|
||||
expect(parseReturnTarget("/signage/timelines")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "timelines",
|
||||
});
|
||||
expect(parseReturnTarget("/media/sermons/123")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "media",
|
||||
path: "sermons/123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboardBack", () => {
|
||||
@@ -84,7 +108,13 @@ describe("dashboardBack", () => {
|
||||
});
|
||||
|
||||
it("falls back to the page's own parent when the target is missing or unsafe", () => {
|
||||
for (const returnTarget of [undefined, "https://evil.example/", "//evil.example", "/nope/x"]) {
|
||||
for (const returnTarget of [
|
||||
undefined,
|
||||
"https://evil.example/",
|
||||
"//evil.example",
|
||||
"/api/v1/principal",
|
||||
"/signage//",
|
||||
]) {
|
||||
const history = createMemoryHostHistory("/calendar");
|
||||
const back = dashboardBack(history, {
|
||||
fallback: { kind: "overview" },
|
||||
|
||||
@@ -53,6 +53,7 @@ function entry(id: string, name: string) {
|
||||
version: "0.1.0",
|
||||
},
|
||||
name,
|
||||
navigation: { icon: null, pages: [] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ function entry(id: string, name: string, version = "0.1.0") {
|
||||
version,
|
||||
},
|
||||
name,
|
||||
navigation: { icon: null, pages: [] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/index.ts",
|
||||
"src/dashboard-assets.ts",
|
||||
"src/dashboard-routes.ts",
|
||||
"src/display-assets.ts",
|
||||
"src/host-protocol.ts",
|
||||
"src/module-context.ts"
|
||||
"src/index.ts",
|
||||
"src/module-context.ts",
|
||||
"src/module-icons.ts",
|
||||
"src/module-navigation.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -69,9 +69,13 @@ pnpm modules:pack --module templates/module --target any --out <directory> \
|
||||
--source-revision "$(git rev-parse HEAD)"
|
||||
```
|
||||
|
||||
The module owns its feature screens and display renderer. Do not add platform navigation, the
|
||||
containing shell, global theme, authentication screens, or top-level routing; those belong to
|
||||
surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
|
||||
The module owns its feature screens and display renderer. Its manifest (version 9) declares its
|
||||
sidebar icon and two primary pages, Records and About. Its UI renders whichever page
|
||||
`context.navigation.location()` names, links with `navigation.link()`, shows a record's detail at
|
||||
`records/<id>` beneath the Records page, and opens its Settings page with `navigation.settings`
|
||||
([ADR 0063](../../docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Do
|
||||
not add a tab bar for those pages, the containing shell, global theme, authentication screens, or
|
||||
top-level routing, and never touch `window.history`; those belong to surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
|
||||
hardcodes Ekklesia routes. The display permission is enforced by the host before it serves the page
|
||||
or descriptor; `context.permissions` remains a presentation-only hint.
|
||||
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 8,
|
||||
"manifestVersion": 9,
|
||||
"id": "example",
|
||||
"name": "Example Module",
|
||||
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
|
||||
@@ -48,6 +48,21 @@
|
||||
"description": "Renders this module's record summary inside a host module's own screen."
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
"icon": "clipboard-list",
|
||||
"pages": [
|
||||
{
|
||||
"id": "records",
|
||||
"label": "Records",
|
||||
"path": "records"
|
||||
},
|
||||
{
|
||||
"id": "about",
|
||||
"label": "About",
|
||||
"path": "about"
|
||||
}
|
||||
]
|
||||
},
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
|
||||
@@ -1,24 +1,64 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { EmptyState, LoadingState, Panel } from "@ekklesia/ui";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import type {
|
||||
ModuleUiHostContext,
|
||||
ModuleUiLocation,
|
||||
ModuleUiNavigation,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import { Button, EmptyState, LoadingState, PageHeader, Panel } from "@ekklesia/ui";
|
||||
import { type ReactElement, useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* The template's example screen. It is deliberately tiny, and deliberately host-neutral: it reads
|
||||
* everything it needs from the host context and hardcodes no URL, no origin, and no global.
|
||||
* The template's example screens. Deliberately small, and deliberately host-neutral: everything
|
||||
* comes from the host context, and no URL, origin, route, or global is hardcoded.
|
||||
*
|
||||
* The module declares two primary pages in its manifest (`navigation.pages`, manifest v9,
|
||||
* ADR 0063): **Records** and **About**. The Dashboard draws them in its sidebar beneath this
|
||||
* module, so the module renders no tab bar of its own — it renders whichever page the host says is
|
||||
* open (`context.navigation.location()`), and moves with `navigation.link()` and
|
||||
* `navigation.navigate()` instead of touching `window.history`. A record's own page,
|
||||
* `records/<id>`, lies beneath the Records page: a detail route, which needs no manifest entry.
|
||||
*
|
||||
* Presentation comes from the platform's shared elements and this module's own stylesheet
|
||||
* (`./styles.css`, declared through `defineModuleUi({ styles })`), so it matches every other
|
||||
* Ekklesia screen without copying a single colour value (ADR 0039, ADR 0040).
|
||||
*/
|
||||
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
|
||||
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
|
||||
|
||||
// Presentation-only gating. The server decides what actually happens; this only decides what the
|
||||
// viewer is offered. See docs/development/security.md.
|
||||
const canRead = context.permissions.has("example.records.read");
|
||||
interface ExampleRecord {
|
||||
readonly id: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
const NO_LOCATION: ModuleUiLocation = Object.freeze({ page: undefined, path: "" });
|
||||
|
||||
/** Where the host says this module is. A host without addressable pages — a standalone host —
|
||||
* supplies no navigation, and the module shows its landing view. */
|
||||
function useModuleLocation(navigation: ModuleUiNavigation | undefined): ModuleUiLocation {
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => navigation?.subscribe(listener) ?? (() => undefined),
|
||||
[navigation],
|
||||
);
|
||||
const read = useCallback(() => navigation?.location() ?? NO_LOCATION, [navigation]);
|
||||
return useSyncExternalStore(subscribe, read, read);
|
||||
}
|
||||
|
||||
/** A link to one of this module's own pages: a real `<a>`, opened in place by the host. */
|
||||
function ModuleLink(props: {
|
||||
readonly navigation: ModuleUiNavigation;
|
||||
readonly to: string;
|
||||
readonly children: string;
|
||||
readonly testId: string;
|
||||
}): ReactElement {
|
||||
const link = props.navigation.link(props.to);
|
||||
return (
|
||||
<a className="example-link" data-testid={props.testId} href={link.href} onClick={link.onClick}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function useRecords(context: ModuleUiHostContext, enabled: boolean) {
|
||||
const [records, setRecords] = useState<readonly ExampleRecord[] | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!canRead) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -28,20 +68,40 @@ export function ExampleApp({ context }: { readonly context: ModuleUiHostContext
|
||||
void context.api
|
||||
.request("records")
|
||||
.then((response) => (response.ok ? response.json() : []))
|
||||
.then((records: unknown) => {
|
||||
.then((body: unknown) => {
|
||||
if (!cancelled) {
|
||||
setRecordCount(Array.isArray(records) ? records.length : 0);
|
||||
setRecords(
|
||||
Array.isArray(body)
|
||||
? body.filter(
|
||||
(record): record is ExampleRecord =>
|
||||
typeof record === "object" &&
|
||||
record !== null &&
|
||||
typeof (record as ExampleRecord).id === "string" &&
|
||||
typeof (record as ExampleRecord).value === "string",
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setRecordCount(0);
|
||||
setRecords([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canRead, context.api]);
|
||||
}, [context.api, enabled]);
|
||||
return records;
|
||||
}
|
||||
|
||||
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
|
||||
// Presentation-only gating. The server decides what actually happens; this only decides what the
|
||||
// viewer is offered. See docs/development/security.md.
|
||||
const canRead = context.permissions.has("example.records.read");
|
||||
const { navigation } = context;
|
||||
const location = useModuleLocation(navigation);
|
||||
const records = useRecords(context, canRead);
|
||||
|
||||
if (!canRead) {
|
||||
return (
|
||||
@@ -52,15 +112,106 @@ export function ExampleApp({ context }: { readonly context: ModuleUiHostContext
|
||||
);
|
||||
}
|
||||
|
||||
if (navigation !== undefined && location.page === "about") {
|
||||
return (
|
||||
<Panel description="Everything this module knows about." title="Example module UI">
|
||||
{recordCount === undefined ? (
|
||||
<div className="example-page" data-testid="example-about">
|
||||
<PageHeader description="How this example module is put together." title="About" />
|
||||
<Panel>
|
||||
<p className="example-text">
|
||||
This module declares two pages, Records and About, in its manifest. The Dashboard shows
|
||||
them in its own navigation, so the module never draws a tab bar of its own.
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (navigation !== undefined && location.page === "records") {
|
||||
const recordId = location.path.startsWith("records/")
|
||||
? location.path.slice("records/".length)
|
||||
: undefined;
|
||||
if (recordId !== undefined) {
|
||||
const record = records?.find((candidate) => candidate.id === recordId);
|
||||
return (
|
||||
<div className="example-page" data-testid="example-record">
|
||||
<PageHeader
|
||||
back={{
|
||||
href: navigation.link("records").href,
|
||||
label: "Back to Records",
|
||||
onBack: () => navigation.navigate("records"),
|
||||
}}
|
||||
title="Record"
|
||||
/>
|
||||
{records === undefined ? (
|
||||
<LoadingState label="Loading record…" />
|
||||
) : record === undefined ? (
|
||||
<EmptyState description="It may have been removed." title="This record isn't here" />
|
||||
) : (
|
||||
<Panel>
|
||||
<p className="example-text" data-testid="example-record-value">
|
||||
{record.value}
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="example-page" data-testid="example-records">
|
||||
<PageHeader description="Everything this module knows about." title="Records" />
|
||||
{records === undefined ? (
|
||||
<LoadingState label="Loading records…" />
|
||||
) : records.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Records appear here once there are any."
|
||||
title="No records yet"
|
||||
/>
|
||||
) : (
|
||||
<Panel>
|
||||
<ul className="example-list">
|
||||
{records.map((record) => (
|
||||
<li key={record.id}>
|
||||
<ModuleLink
|
||||
navigation={navigation}
|
||||
testId={`example-record-link-${record.id}`}
|
||||
to={`records/${encodeURIComponent(record.id)}`}
|
||||
>
|
||||
{record.value}
|
||||
</ModuleLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = navigation?.settings;
|
||||
return (
|
||||
<Panel
|
||||
actions={
|
||||
settings === undefined ? undefined : (
|
||||
<Button data-testid="example-open-settings" onClick={() => settings.open()}>
|
||||
Settings
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description="Everything this module knows about."
|
||||
title="Example module UI"
|
||||
>
|
||||
{records === undefined ? (
|
||||
<LoadingState label="Loading records…" />
|
||||
) : (
|
||||
<div className="example-summary">
|
||||
<div className="example-summary" data-testid="example-home">
|
||||
<p className="example-summary__count" data-testid="record-count">
|
||||
{`${recordCount} record(s)`}
|
||||
{`${records.length} record(s)`}
|
||||
</p>
|
||||
{navigation === undefined ? undefined : (
|
||||
<ModuleLink navigation={navigation} testId="example-open-records" to="records">
|
||||
View records
|
||||
</ModuleLink>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
@@ -29,3 +29,30 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.example-page {
|
||||
display: grid;
|
||||
gap: var(--ekklesia-spacing-lg);
|
||||
}
|
||||
|
||||
.example-text {
|
||||
color: var(--ekklesia-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.example-list {
|
||||
display: grid;
|
||||
gap: var(--ekklesia-spacing-xs);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.example-link {
|
||||
color: var(--ekklesia-accent-strong);
|
||||
}
|
||||
|
||||
.example-link:focus-visible {
|
||||
outline: var(--ekklesia-state-focus-visible-width) solid var(--ekklesia-accent-focus);
|
||||
outline-offset: var(--ekklesia-state-focus-visible-offset);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { parseModuleManifest } from "@ekklesia/module-sdk";
|
||||
import { moduleManifestNavigation, parseModuleManifest } from "@ekklesia/module-sdk";
|
||||
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||
import {
|
||||
type ModuleUiApiClient,
|
||||
@@ -58,6 +58,7 @@ function recordingHost(options: {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId: "example",
|
||||
navigation: undefined,
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => options.canRead },
|
||||
@@ -65,6 +66,56 @@ function recordingHost(options: {
|
||||
};
|
||||
}
|
||||
|
||||
/** A host navigation capability the test drives: the pages this module declares, a location the
|
||||
* host can move, and a record of every place the module asked to go. */
|
||||
function fakeNavigation(initial: string) {
|
||||
const pages = ["records", "about"];
|
||||
let path = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
const pageOf = (route: string) =>
|
||||
pages.find((page) => route === page || route.startsWith(`${page}/`));
|
||||
let snapshot = { page: pageOf(path), path };
|
||||
const visited: string[] = [];
|
||||
const goTo = (next: string): void => {
|
||||
path = next;
|
||||
snapshot = { page: pageOf(path), path };
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
const navigation = {
|
||||
goTo,
|
||||
link: (to: string) => ({
|
||||
href: `/example/${to}`,
|
||||
onClick: (event: { preventDefault(): void }) => {
|
||||
event.preventDefault();
|
||||
visited.push(to);
|
||||
goTo(to);
|
||||
},
|
||||
}),
|
||||
location: () => snapshot,
|
||||
navigate: (to: string) => {
|
||||
visited.push(to);
|
||||
goTo(to);
|
||||
},
|
||||
settings: {
|
||||
link: () => ({ href: "/settings/example", onClick: () => undefined }),
|
||||
open: () => {
|
||||
navigation.settingsOpened += 1;
|
||||
},
|
||||
},
|
||||
settingsOpened: 0,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
visited,
|
||||
};
|
||||
return navigation;
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
// Let React commit and the awaited fetch microtasks settle.
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
@@ -187,6 +238,71 @@ describe("module UI build pipeline", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("declares two primary pages and an icon in its manifest v9 navigation (ADR 0063)", async () => {
|
||||
const manifest = parseModuleManifest(
|
||||
JSON.parse(await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8")),
|
||||
);
|
||||
expect(manifest.manifestVersion).toBe(9);
|
||||
expect(moduleManifestNavigation(manifest)).toEqual({
|
||||
icon: "clipboard-list",
|
||||
pages: [
|
||||
{ id: "records", label: "Records", path: "records" },
|
||||
{ id: "about", label: "About", path: "about" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the page its host says is open, and moves only through the host's navigation", async () => {
|
||||
const namespace = (await import(artifactHref)) as unknown;
|
||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const navigation = fakeNavigation("");
|
||||
const host = {
|
||||
...recordingHost({
|
||||
canRead: true,
|
||||
records: [{ id: "a1", value: "Hello, one" }],
|
||||
requested: [],
|
||||
resolve: () => undefined,
|
||||
}),
|
||||
navigation,
|
||||
};
|
||||
|
||||
const unmount = await definition.mount(container, host);
|
||||
await flush();
|
||||
// The landing view links to a page by its host-made address, and offers Settings.
|
||||
const toRecords = container.querySelector<HTMLAnchorElement>(
|
||||
"[data-testid='example-open-records']",
|
||||
);
|
||||
expect(toRecords?.getAttribute("href")).toBe("/example/records");
|
||||
container.querySelector<HTMLButtonElement>("[data-testid='example-open-settings']")?.click();
|
||||
expect(navigation.settingsOpened).toBe(1);
|
||||
|
||||
toRecords?.click();
|
||||
await flush();
|
||||
expect(navigation.visited).toEqual(["records"]);
|
||||
expect(container.querySelector("[data-testid='example-records']")).not.toBeNull();
|
||||
const toRecord = container.querySelector<HTMLAnchorElement>(
|
||||
"[data-testid='example-record-link-a1']",
|
||||
);
|
||||
expect(toRecord?.getAttribute("href")).toBe("/example/records/a1");
|
||||
|
||||
toRecord?.click();
|
||||
await flush();
|
||||
expect(container.querySelector("[data-testid='example-record-value']")?.textContent).toBe(
|
||||
"Hello, one",
|
||||
);
|
||||
|
||||
navigation.goTo("about");
|
||||
await flush();
|
||||
expect(container.querySelector("[data-testid='example-about']")).not.toBeNull();
|
||||
// The module never touched the browser's own history.
|
||||
expect(window.location.pathname).toBe("/");
|
||||
|
||||
await (unmount as () => Promise<void>)();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("mounts the display artifact through the same ABI, then unmounts it", async () => {
|
||||
const namespace = (await import(displayArtifactHref)) as unknown;
|
||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||
|
||||
Reference in New Issue
Block a user