/**
* The module UI contract — what every brick's UI exports, whichever way the product is assembled
* (one bundle, federated at runtime, or each brick serving its own). One contract is what lets the
* assembly mode be a setting rather than three systems.
*
* screens routes the brick owns, with the permission needed to see them
* offers named extension points on its screens, where other bricks may add panels
* extensions panels it adds to OTHER bricks' extension points
*
* A brick never imports another brick's UI. It links by URL and plugs into extension points — the UI's
* version of a seam.
*/
import type { ComponentType } from "react";
export interface Screen {
/** Path inside the brick, e.g. "/partners/:bpId". The shell mounts it under "/<brick>". */
path: string;
title: string;
/** In the navigation when set — the section it sits in. Detail screens usually have none. */
nav?: { section: string; label?: string; order?: number };
/** Hidden from callers without it. Omitted = any signed-in user. */
permission?: string;
component: ComponentType<Record<string, never>>;
}
export interface ExtensionPoint {
/** "<brick>.<screen>.<place>", e.g. "bp.partner.panels" */
id: string;
/** What the host passes to every panel at this point, e.g. ["bpId"]. */
context: string[];
description: string;
}
export interface Extension {
/** The extension point it fills. */
point: string;
title: string;
permission?: string;
order?: number;
component: ComponentType<{ context: Record<string, string> }>;
}
export interface ModuleUI {
/** The brick's name — the same as its backend's, e.g. "bp". */
name: string;
version: string;
screens: Screen[];
offers?: ExtensionPoint[];
extensions?: Extension[];
}
/** Everything wrong with a set of module UIs, before anything is shown. Empty = they fit together. */
export function checkModules(modules: ModuleUI[]): string[] {
const problems: string[] = [];
const names = modules.map((m) => m.name);
names.filter((n, i) => names.indexOf(n) !== i).forEach((n) => problems.push(`module ${n} is listed twice`));
const points = new Map<string, string>();
for (const m of modules) {
const paths = m.screens.map((s) => s.path);
paths.filter((p, i) => paths.indexOf(p) !== i).forEach((p) => problems.push(`${m.name}: two screens at ${p}`));
for (const s of m.screens) {
if (!s.path.startsWith("/")) problems.push(`${m.name}: screen path ${s.path} must start with /`);
}
for (const p of m.offers ?? []) {
if (!p.id.startsWith(`${m.name}.`)) problems.push(`${m.name}: extension point ${p.id} must start with "${m.name}."`);
if (points.has(p.id)) problems.push(`extension point ${p.id} is offered by both ${points.get(p.id)} and ${m.name}`);
points.set(p.id, m.name);
}
}
for (const m of modules) {
for (const e of m.extensions ?? []) {
if (!points.has(e.point)) problems.push(`${m.name}: "${e.title}" plugs into ${e.point}, which no module offers`);
}
}
return problems;
}
/** The panels for one extension point, in order, that this caller may see. */
export function extensionsFor(modules: ModuleUI[], point: string, can: (permission?: string) => boolean): Extension[] {
return modules
.flatMap((m) => m.extensions ?? [])
.filter((e) => e.point === point && can(e.permission))
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title));
}