/** * What a page is made of, and how a user's changes are folded into it. * * A page registers its own composition — the nodes it ships with, in order — * and this module holds the table. The engine reads the table; it never learns * a page name, and there is no switch here that would have to grow a case per * surface. Registering is how a page opts in, exactly as `registerNodeType` is * how a component does. * * The merge is the other half: * * built-in composition what the application ships * ⊕ saved user patch the person's own changes, persisted * ⊕ preview patch what they are trying, not yet saved * = the tree that renders * * Both patches are **lists of operations**, replayed onto a freshly computed * base. That is what makes a saved layout survive a release: a stored tree * would be a photograph of the page on the day it was saved, and every * improvement afterwards would be invisible to whoever had customised it. * * Skill sections are deliberately **not** merged here. A skill's `ui:` block is * still rendered by `SkillSurface`, exactly as it is today, and a surface is * simply one of the nodes a page composes. That keeps the existing Board-skill * behaviour byte-for-byte unchanged while still putting it in the tree, where * it can be hidden and reordered like anything else. */ import { makeNode } from './node'; import { applyPatch } from './patch'; import { nodeRegistry } from './registry'; /** @type {Map} */ const compositions = new Map(); /** * A container node's placement, as its own registration declared it. * * Read off `props` rather than from a field the engine knows about, so the * composition layer needs no concept of what a placement is — only that a node * may name one and that skill sections are grouped by the same name. */ const placementOf = (node) => String(node?.props?.placement || ''); /** * The composition with each slot's skill sections hung underneath it. * * Done here, before any patch is replayed, so a person's saved operations act * on the same tree they were made against — including the skill sections. A * patch that hides a Board card keeps working; a patch naming a card whose * skill has since been switched off is skipped, like any other stale operation. */ function attachSkillNodes(base, byPlacement) { if (!byPlacement) return base; return base.map((node) => { const placement = placementOf(node); const children = placement ? byPlacement[placement] : null; if (!children?.length) return node; return { ...node, children }; }); } /** * Declare the nodes a page ships with. * * Called once, at module scope, beside the page it describes — so a page and * its composition move together and neither can be deployed without the other. * Re-registering replaces, which is what a hot module reload needs; a duplicate * is not an error the way a duplicate *type* is, because the second call is the * same page saying the same thing again. */ export function registerPageComposition(page, nodes) { const key = String(page ?? '').trim(); if (!key) throw new Error('registerPageComposition: a composition needs a page.'); compositions.set(key, (nodes || []).map((node) => makeNode({ origin: 'builtin', ...node }))); return compositions.get(key); } /** The nodes a page ships with, or an empty list for a page that has not opted in. */ export const compositionFor = (page) => compositions.get(String(page ?? '').trim()) || []; /** Whether this page composes through the node system yet. */ export const hasComposition = (page) => compositions.has(String(page ?? '').trim()); /** Every page that has registered. Used by tests and, later, by the editor. */ export const composedPages = () => [...compositions.keys()]; /** Forget everything. Tests only. */ export const resetCompositions = () => compositions.clear(); /** * The tree to render for a page. * * `skipped` carries the operations that no longer apply — a saved change naming * a node a release has since removed. They are reported rather than thrown: * that is not the user's mistake, and it must not cost them the rest of their * layout. */ export function composePage(page, { patch = null, preview = null, registry = nodeRegistry, role = null, /** * The sections this page's definitions contribute, grouped by placement. * * Passed in rather than read here, because resolving them needs the account's * custom skills and disabled list — React state, which this module must stay * free of to remain a pure function two callers can trust equally. */ skillNodes = null, } = {}) { const base = attachSkillNodes(compositionFor(page), skillNodes); const context = { registry, role }; const skipped = []; let tree = base; /* Saved first, then preview. Order matters: a preview is composed against what the person has already saved, so what they see while deciding is what they will get if they keep it. */ for (const layer of [patch, preview]) { if (!layer?.ops?.length) continue; const result = applyPatch(tree, layer, context); tree = result.tree; skipped.push(...result.skipped); } return { tree, skipped }; }