dodil/erp-ui

public/r/contract.json

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