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:
ekklesia
2026-10-15 08:21:50 -05:00
co-authored by Claude Opus 5.5
parent 7447381c8f
commit 4671622b9d
69 changed files with 4221 additions and 372 deletions
+1
View File
@@ -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";
+274 -207
View File
@@ -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,219 +158,267 @@ 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
/** 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(),
version: semanticVersionSchema,
compatibility: z
.object({
platform: semanticVersionRangeSchema,
moduleSdk: semanticVersionRangeSchema,
})
.strict(),
dependencies: dependenciesSchema,
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
permissions: z.array(permissionDeclarationSchema).default(() => []),
/** What this module needs an operator to configure, and how to validate it (ADR 0029). Inert
* data: declaring configuration never causes Core to read a database, contact a host, or run
* module code during discovery. A module that needs nothing configured omits the field. */
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
storage: moduleStorageSchema,
database: moduleDatabaseDeclarationSchema.optional(),
/** Compiled browser renderers this module offers to capabilities **other** modules provide
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
* never learns what a contribution renders. A module that contributes nothing omits the field. */
contributions: z.array(moduleContributionDeclarationSchema).default(() => []),
entrypoints: entrypointsSchema,
};
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"],
context,
"id",
);
reportDuplicates(
manifest.permissions.map((permission) => permission.id),
["permissions"],
context,
"id",
);
reportDuplicates(
manifest.storage.buckets.map((bucket) => bucket.id),
["storage", "buckets"],
context,
"id",
);
// One namespace for every configuration key a module owns, so an ordinary key and a secret key
// can never collide: a module cannot declare `apiToken` as both a defaultable string and a
// deployment-supplied secret and leave the platform to guess which one a stored row means.
reportDuplicates(
manifest.configuration.map((declaration) => declaration.key),
["configuration"],
context,
"key",
);
manifest.capabilities.forEach((capability, index) => {
if (!capability.id.startsWith(`${manifest.id}.`)) {
context.addIssue({
code: "custom",
message: `Capability identifiers owned by this module must begin with ${manifest.id}.`,
path: ["capabilities", index, "id"],
});
}
});
reportDuplicates(
manifest.contributions.map((contribution) => contribution.id),
["contributions"],
context,
"id",
);
const ownedCapabilityIds = new Set(manifest.capabilities.map((capability) => capability.id));
const contributionArtifacts = new Set<string>();
manifest.contributions.forEach((contribution, index) => {
// Namespacing is what makes a contribution id globally unique without a registry: module ids
// are already unique, so two modules can never produce the same contribution id.
if (!contribution.id.startsWith(`${manifest.id}.`)) {
context.addIssue({
code: "custom",
message: `Contribution identifiers owned by this module must begin with ${manifest.id}.`,
path: ["contributions", index, "id"],
});
}
// A contribution extends *another* module's capability. Targeting one this module already
// provides would be a module contributing to itself, which needs no platform mechanism at all
// and would make the compatibility check meaningless.
if (ownedCapabilityIds.has(contribution.capability)) {
context.addIssue({
code: "custom",
message: "A browser contribution must target a capability another module provides.",
path: ["contributions", index, "capability"],
});
}
if (
Object.entries(manifest.entrypoints).some(([entrypointName, configured]) =>
entrypointName === "display"
? (configured as { artifact?: string } | undefined)?.artifact === contribution.artifact
: configured === contribution.artifact,
)
) {
context.addIssue({
code: "custom",
message: "A contribution artifact must use a different path from every entrypoint.",
path: ["contributions", index, "artifact"],
});
}
if (contributionArtifacts.has(contribution.artifact)) {
context.addIssue({
code: "custom",
message: "Two contributions must not declare the same artifact path.",
path: ["contributions", index, "artifact"],
});
}
contributionArtifacts.add(contribution.artifact);
});
manifest.permissions.forEach((permission, index) => {
if (!permission.id.startsWith(`${manifest.id}.`)) {
context.addIssue({
code: "custom",
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
path: ["permissions", index, "id"],
});
}
});
const display = manifest.entrypoints.display;
if (display !== undefined) {
const displayPermission = manifest.permissions.find(
(permission) => permission.id === display.permission,
);
if (displayPermission === undefined) {
context.addIssue({
code: "custom",
message: "A display entrypoint must reference a permission declared by this module.",
path: ["entrypoints", "display", "permission"],
});
} else if (displayPermission.baseline === "public") {
context.addIssue({
code: "custom",
message: "A display entrypoint permission must require an authenticated principal.",
path: ["entrypoints", "display", "permission"],
});
}
if (
Object.entries(manifest.entrypoints).some(
([entrypointName, configured]) =>
entrypointName !== "display" && configured === display.artifact,
)
) {
context.addIssue({
code: "custom",
message: "The display UI must use a different path from every other configured entrypoint.",
path: ["entrypoints", "display", "artifact"],
});
}
}
for (const dependencyKind of ["required", "optional"] as const) {
if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) {
context.addIssue({
code: "custom",
message: "A module cannot depend on itself.",
path: ["dependencies", "modules", dependencyKind, manifest.id],
});
}
}
for (const dependencyId of Object.keys(manifest.dependencies.modules.required)) {
if (Object.hasOwn(manifest.dependencies.modules.optional, dependencyId)) {
context.addIssue({
code: "custom",
message: `${dependencyId} cannot be both a required and optional module dependency.`,
path: ["dependencies", "modules", "optional", dependencyId],
});
}
}
for (const dependencyKind of ["required", "optional"] as const) {
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
if (ownedCapabilityIds.has(capability)) {
context.addIssue({
code: "custom",
message: "A module does not need to depend on a capability it owns.",
path: ["dependencies", "capabilities", dependencyKind, capability],
});
}
}
}
for (const capability of Object.keys(manifest.dependencies.capabilities.required)) {
if (Object.hasOwn(manifest.dependencies.capabilities.optional, capability)) {
context.addIssue({
code: "custom",
message: `${capability} cannot be both a required and optional capability dependency.`,
path: ["dependencies", "capabilities", "optional", capability],
});
}
}
}
const moduleManifestV8Schema = z
.object({ manifestVersion: z.literal(8), ...manifestFields })
.strict()
.superRefine(refineManifest);
const moduleManifestV9Schema = z
.object({
manifestVersion: z.literal(MODULE_MANIFEST_VERSION),
id: moduleIdSchema,
name: z.string().trim().min(1).max(120),
description: z.string().trim().min(1).max(500).optional(),
version: semanticVersionSchema,
compatibility: z
.object({
platform: semanticVersionRangeSchema,
moduleSdk: semanticVersionRangeSchema,
})
.strict(),
dependencies: dependenciesSchema,
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
permissions: z.array(permissionDeclarationSchema).default(() => []),
/** What this module needs an operator to configure, and how to validate it (ADR 0029). Inert
* data: declaring configuration never causes Core to read a database, contact a host, or run
* module code during discovery. A module that needs nothing configured omits the field. */
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
storage: moduleStorageSchema,
database: moduleDatabaseDeclarationSchema.optional(),
/** Compiled browser renderers this module offers to capabilities **other** modules provide
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
* never learns what a contribution renders. A module that contributes nothing omits the field. */
contributions: z.array(moduleContributionDeclarationSchema).default(() => []),
entrypoints: entrypointsSchema,
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) => {
reportDuplicates(
manifest.capabilities.map((capability) => capability.id),
["capabilities"],
context,
"id",
);
reportDuplicates(
manifest.permissions.map((permission) => permission.id),
["permissions"],
context,
"id",
);
reportDuplicates(
manifest.storage.buckets.map((bucket) => bucket.id),
["storage", "buckets"],
context,
"id",
);
// One namespace for every configuration key a module owns, so an ordinary key and a secret key
// can never collide: a module cannot declare `apiToken` as both a defaultable string and a
// deployment-supplied secret and leave the platform to guess which one a stored row means.
reportDuplicates(
manifest.configuration.map((declaration) => declaration.key),
["configuration"],
context,
"key",
);
manifest.capabilities.forEach((capability, index) => {
if (!capability.id.startsWith(`${manifest.id}.`)) {
context.addIssue({
code: "custom",
message: `Capability identifiers owned by this module must begin with ${manifest.id}.`,
path: ["capabilities", index, "id"],
});
}
});
reportDuplicates(
manifest.contributions.map((contribution) => contribution.id),
["contributions"],
context,
"id",
);
const ownedCapabilityIds = new Set(manifest.capabilities.map((capability) => capability.id));
const contributionArtifacts = new Set<string>();
manifest.contributions.forEach((contribution, index) => {
// Namespacing is what makes a contribution id globally unique without a registry: module ids
// are already unique, so two modules can never produce the same contribution id.
if (!contribution.id.startsWith(`${manifest.id}.`)) {
context.addIssue({
code: "custom",
message: `Contribution identifiers owned by this module must begin with ${manifest.id}.`,
path: ["contributions", index, "id"],
});
}
// A contribution extends *another* module's capability. Targeting one this module already
// provides would be a module contributing to itself, which needs no platform mechanism at all
// and would make the compatibility check meaningless.
if (ownedCapabilityIds.has(contribution.capability)) {
context.addIssue({
code: "custom",
message: "A browser contribution must target a capability another module provides.",
path: ["contributions", index, "capability"],
});
}
if (
Object.entries(manifest.entrypoints).some(([entrypointName, configured]) =>
entrypointName === "display"
? (configured as { artifact?: string } | undefined)?.artifact === contribution.artifact
: configured === contribution.artifact,
)
) {
context.addIssue({
code: "custom",
message: "A contribution artifact must use a different path from every entrypoint.",
path: ["contributions", index, "artifact"],
});
}
if (contributionArtifacts.has(contribution.artifact)) {
context.addIssue({
code: "custom",
message: "Two contributions must not declare the same artifact path.",
path: ["contributions", index, "artifact"],
});
}
contributionArtifacts.add(contribution.artifact);
});
manifest.permissions.forEach((permission, index) => {
if (!permission.id.startsWith(`${manifest.id}.`)) {
context.addIssue({
code: "custom",
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
path: ["permissions", index, "id"],
});
}
});
const display = manifest.entrypoints.display;
if (display !== undefined) {
const displayPermission = manifest.permissions.find(
(permission) => permission.id === display.permission,
);
if (displayPermission === undefined) {
context.addIssue({
code: "custom",
message: "A display entrypoint must reference a permission declared by this module.",
path: ["entrypoints", "display", "permission"],
});
} else if (displayPermission.baseline === "public") {
context.addIssue({
code: "custom",
message: "A display entrypoint permission must require an authenticated principal.",
path: ["entrypoints", "display", "permission"],
});
}
if (
Object.entries(manifest.entrypoints).some(
([entrypointName, configured]) =>
entrypointName !== "display" && configured === display.artifact,
)
) {
context.addIssue({
code: "custom",
message:
"The display UI must use a different path from every other configured entrypoint.",
path: ["entrypoints", "display", "artifact"],
});
}
}
for (const dependencyKind of ["required", "optional"] as const) {
if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) {
context.addIssue({
code: "custom",
message: "A module cannot depend on itself.",
path: ["dependencies", "modules", dependencyKind, manifest.id],
});
}
}
for (const dependencyId of Object.keys(manifest.dependencies.modules.required)) {
if (Object.hasOwn(manifest.dependencies.modules.optional, dependencyId)) {
context.addIssue({
code: "custom",
message: `${dependencyId} cannot be both a required and optional module dependency.`,
path: ["dependencies", "modules", "optional", dependencyId],
});
}
}
for (const dependencyKind of ["required", "optional"] as const) {
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
if (ownedCapabilityIds.has(capability)) {
context.addIssue({
code: "custom",
message: "A module does not need to depend on a capability it owns.",
path: ["dependencies", "capabilities", dependencyKind, capability],
});
}
}
}
for (const capability of Object.keys(manifest.dependencies.capabilities.required)) {
if (Object.hasOwn(manifest.dependencies.capabilities.optional, capability)) {
context.addIssue({
code: "custom",
message: `${capability} cannot be both a required and optional capability dependency.`,
path: ["dependencies", "capabilities", "optional", capability],
});
}
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`). */
+242
View File
@@ -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([]),
});