Add the module navigation contract: manifest v9, module routes, return-to-origin
Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard. - Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe label, kebab-case path relative to the module root; no traversal, schemes, queries, duplicates, or nesting). Validated before activation. - Version 8 stays accepted beside version 9, each read strictly as itself (discriminated union); no released manifest is rewritten. The template moves to v9; the released Signage 0.1.5 manifest is a test fixture. - Real module routes: /<id> is the landing view, /<id>/<page> a declared page, anything beneath a page a module-owned detail route; anything else is not-found and mounts nothing. api/assets/display are never pages. - Sidebar: declared icons, pages nested under an open module, one aria-current, collapsed and drawer behaviour; v8 modules unchanged. - module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe, navigate, link, settings), refusing anything outside the module's routes; undefined on the display host. Mount ABI unchanged. - Return-to-origin: one `?return=` carrier on Settings URLs, validated by parseReturnTarget and against running modules' declared routes; Settings Back returns to exactly the originating module page, across refreshes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7447381c8f
commit
4671622b9d
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user