sbuilder-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +142 -0
  4. package/README.vi.md +137 -0
  5. package/dist/catalog/api.generated.js +11938 -0
  6. package/dist/catalog/element-types.js +1 -0
  7. package/dist/catalog/elements.generated.js +14761 -0
  8. package/dist/catalog/search.js +87 -0
  9. package/dist/catalog/types.js +1 -0
  10. package/dist/core/patch.js +110 -0
  11. package/dist/core/tree.js +69 -0
  12. package/dist/domains/site/builder.js +224 -0
  13. package/dist/domains/site/document.js +112 -0
  14. package/dist/domains/site/ids.js +27 -0
  15. package/dist/domains/site/node.js +43 -0
  16. package/dist/domains/site/review.js +141 -0
  17. package/dist/domains/site/traps.js +98 -0
  18. package/dist/domains/site/validate.js +49 -0
  19. package/dist/index.js +20 -0
  20. package/dist/install/index.js +108 -0
  21. package/dist/install/paths.js +83 -0
  22. package/dist/install/write.js +97 -0
  23. package/dist/live/session.js +164 -0
  24. package/dist/mcp/response.js +40 -0
  25. package/dist/server.js +59 -0
  26. package/dist/smoke.js +106 -0
  27. package/dist/tools/api.js +96 -0
  28. package/dist/tools/context.js +1 -0
  29. package/dist/tools/credentialpick.js +11 -0
  30. package/dist/tools/live.js +104 -0
  31. package/dist/tools/page.js +383 -0
  32. package/dist/tools/session.js +72 -0
  33. package/dist/transport/auth.js +61 -0
  34. package/dist/transport/credential.js +7 -0
  35. package/dist/transport/http.js +77 -0
  36. package/dist/transport/pages.js +51 -0
  37. package/dist/transport/socket.js +85 -0
  38. package/dist/vision/preview.js +30 -0
  39. package/dist/vision/shoot.js +103 -0
  40. package/package.json +66 -0
@@ -0,0 +1,87 @@
1
+ import { API_OPERATIONS, API_DEFINITIONS } from './api.generated.js';
2
+ /**
3
+ * Term-hit scoring, weighted by field, and deliberately NOT fuzzy.
4
+ *
5
+ * The caller is a language model that can re-query with better words, so an
6
+ * empty list is a cheap, recoverable answer. A fuzzy ranker's failure mode is
7
+ * the expensive one: it returns a confident wrong operation, and the model then
8
+ * calls it. Ties break by id so the same query always returns the same order —
9
+ * a ranker whose output shuffles between calls is one nobody can debug.
10
+ */
11
+ const WEIGHT = { tag: 5, path: 3, summary: 2 };
12
+ export function searchOperations(query, opts = {}) {
13
+ const limit = opts.limit ?? 12;
14
+ const terms = query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
15
+ let pool = API_OPERATIONS;
16
+ if (opts.tag)
17
+ pool = pool.filter((o) => o.tags.includes(opts.tag));
18
+ if (terms.length === 0)
19
+ return pool.slice(0, limit);
20
+ const scored = [];
21
+ for (const op of pool) {
22
+ const tags = op.tags.join(' ').toLowerCase();
23
+ const path = op.path.toLowerCase();
24
+ const summary = op.summary.toLowerCase();
25
+ let score = 0;
26
+ for (const t of terms) {
27
+ if (tags.includes(t))
28
+ score += WEIGHT.tag;
29
+ if (path.includes(t))
30
+ score += WEIGHT.path;
31
+ if (summary.includes(t))
32
+ score += WEIGHT.summary;
33
+ }
34
+ if (score > 0)
35
+ scored.push({ op, score });
36
+ }
37
+ scored.sort((a, b) => b.score - a.score || a.op.id.localeCompare(b.op.id));
38
+ return scored.slice(0, limit).map((s) => s.op);
39
+ }
40
+ /**
41
+ * The full call sheet for one operation — including, deliberately, what is NOT
42
+ * known about it.
43
+ *
44
+ * The source document under-describes bodies in TWO different ways, and telling
45
+ * them apart matters because the right recovery differs:
46
+ *
47
+ * - 58 of the 140 body-carrying operations declare a body with no `$ref`, so
48
+ * the shape is unknown but its EXISTENCE is certain.
49
+ * - 60 of the 137 write operations declare no body parameter at all. Some
50
+ * genuinely take none (`POST /api/orgs/{id}/leave` is an action, not a
51
+ * payload). Others are simply un-annotated: `PUT /pages/{id}/source` carries
52
+ * a whole page document and the document says nothing about it, and
53
+ * `POST /_wb/account/login` obviously takes credentials.
54
+ *
55
+ * Saying "no body" for the second group would be the silent failure — the model
56
+ * would send an empty PUT and wipe a page. So the two get different words.
57
+ */
58
+ export function describeOperation(op) {
59
+ const out = {
60
+ id: op.id,
61
+ method: op.method,
62
+ path: op.path,
63
+ summary: op.summary,
64
+ tags: op.tags,
65
+ credential: op.credential,
66
+ params: op.params.filter((p) => p.in !== 'body'),
67
+ };
68
+ const hasBody = op.params.some((p) => p.in === 'body');
69
+ const isWrite = op.method === 'POST' || op.method === 'PUT' || op.method === 'PATCH';
70
+ if (hasBody && op.bodyDescribed && op.bodyRef) {
71
+ out.body_schema = API_DEFINITIONS[op.bodyRef];
72
+ }
73
+ else if (hasBody) {
74
+ out.body_warning =
75
+ 'This operation takes a body, but the OpenAPI document does not describe its shape ' +
76
+ '(no $ref). Do NOT guess one: read an existing item with the matching GET first and ' +
77
+ 'send back a modified copy.';
78
+ }
79
+ else if (isWrite) {
80
+ out.body_note =
81
+ 'The document declares NO request body for this write operation. That may be true ' +
82
+ '(some endpoints are pure actions, e.g. POST /api/orgs/{id}/leave), or the annotation ' +
83
+ 'may simply be missing — PUT /pages/{id}/source takes a whole page document and is ' +
84
+ 'documented exactly like this. Confirm with the matching GET before sending an empty body.';
85
+ }
86
+ return out;
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ /** Segments that must never appear in a path we apply or send. */
2
+ const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
3
+ /**
4
+ * Is this path part of the shared DOCUMENT?
5
+ *
6
+ * An allowlist, not a denylist, and three rules rather than one — each closing a
7
+ * real door:
8
+ *
9
+ * - `path.length < 2` also rejects the bare `['nodes']`. A write there does not
10
+ * touch one node, it REPLACES THE ENTIRE MAP. No legitimate edit produces it
11
+ * (every real one is `nodes.<id>.…`, at least three segments), so refusing it
12
+ * costs nothing and closes a one-frame whole-document takeover.
13
+ * - The path must start at `nodes`. Everything else in a store is per-viewer
14
+ * state or local bookkeeping — syncing a selection would yank every peer's
15
+ * cursor about.
16
+ * - Segments are compared AFTER stringifying. A segment shaped `['__proto__']`
17
+ * stringifies to `'__proto__'` — a one-element array becomes its single
18
+ * element — so a `typeof seg === 'string'` guard lets it through and it lands
19
+ * on Object.prototype anyway.
20
+ */
21
+ export function isSyncablePath(path) {
22
+ if (!Array.isArray(path) || path.length < 2 || path[0] !== 'nodes')
23
+ return false;
24
+ return path.every((seg) => !FORBIDDEN.has(`${seg}`));
25
+ }
26
+ /**
27
+ * Is this whole patch admissible — path and, for a splice, its index?
28
+ *
29
+ * The index rule exists because a NEGATIVE index splices from the END: `remove`
30
+ * at -1 deletes the last child of an array the sender never named, and `insert`
31
+ * at -1 lands one slot in from the end of somebody else's parent. A fractional
32
+ * index is the same class — splice truncates it, so the write addresses a
33
+ * different element than the one described.
34
+ *
35
+ * An index PAST the end is deliberately allowed: splice clamps it, an append is
36
+ * a legitimate thing to describe, and a remove past the end removes nothing.
37
+ */
38
+ export function isSyncablePatch(p) {
39
+ if (!isSyncablePath(p.path))
40
+ return false;
41
+ if (p.op !== 'insert' && p.op !== 'remove')
42
+ return true;
43
+ return Number.isInteger(p.index) && p.index >= 0;
44
+ }
45
+ /**
46
+ * Keep only the patches that belong on the wire.
47
+ *
48
+ * FILTERS rather than throws, unlike applyPatches below, and the asymmetry is
49
+ * deliberate: a peer must never be able to halt this process by sending one bad
50
+ * frame, while a patch WE built that fails admission is a bug in our own builder
51
+ * and must be loud.
52
+ */
53
+ export function syncable(patches) {
54
+ return patches.filter(isSyncablePatch);
55
+ }
56
+ function parentOf(state, path) {
57
+ let cur = state;
58
+ for (let i = 0; i < path.length - 1; i++) {
59
+ const k = `${path[i]}`;
60
+ if (FORBIDDEN.has(k))
61
+ throw new Error(`patch: inadmissible segment "${k}"`);
62
+ const next = cur[k];
63
+ if (next === undefined || next === null || typeof next !== 'object') {
64
+ cur[k] = {};
65
+ }
66
+ cur = cur[k];
67
+ }
68
+ const key = `${path[path.length - 1]}`;
69
+ if (FORBIDDEN.has(key))
70
+ throw new Error(`patch: inadmissible segment "${key}"`);
71
+ return { holder: cur, key };
72
+ }
73
+ /**
74
+ * Apply patches left to right, in place.
75
+ *
76
+ * An inadmissible patch THROWS rather than being skipped. Skipping is right for
77
+ * the wire (see `syncable`), but locally a silent skip would leave the document
78
+ * half-edited with nothing anywhere to say so — which is the exact failure shape
79
+ * this whole repo exists to rule out.
80
+ */
81
+ export function applyPatches(state, patches) {
82
+ for (const p of patches) {
83
+ if (!isSyncablePatch(p)) {
84
+ throw new Error(`patch: inadmissible patch ${JSON.stringify(p)}`);
85
+ }
86
+ const { holder, key } = parentOf(state, p.path);
87
+ switch (p.op) {
88
+ case 'set':
89
+ holder[key] = p.value;
90
+ break;
91
+ case 'unset':
92
+ delete holder[key];
93
+ break;
94
+ case 'insert': {
95
+ const arr = holder[key];
96
+ if (!Array.isArray(arr))
97
+ break;
98
+ arr.splice(p.index, 0, p.value);
99
+ break;
100
+ }
101
+ case 'remove': {
102
+ const arr = holder[key];
103
+ if (!Array.isArray(arr))
104
+ break;
105
+ arr.splice(p.index, 1);
106
+ break;
107
+ }
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,69 @@
1
+ /** The specials stamp a composed site overlay carries. */
2
+ export const SPEC_OVERLAY_ID = 'overlayId';
3
+ /** The specials stamps a composed global section carries. */
4
+ export const SPEC_GLOBAL_ID = 'globalId';
5
+ export const SPEC_GLOBAL_KIND = 'globalKind';
6
+ export const SPEC_GLOBAL_REF = 'globalRef';
7
+ export function childrenOf(doc, id) {
8
+ return doc.nodes[id]?.data.nodes ?? [];
9
+ }
10
+ /**
11
+ * Is this node a composed SITE OVERLAY — the cart drawer, a pop-up?
12
+ *
13
+ * Two conditions, and the second is load-bearing: the node must carry the
14
+ * `overlayId` stamp AND be a DIRECT child of ROOT. The platform enforces exactly
15
+ * that on write, and it is what keeps overlays out of repeater rows, out of
16
+ * global sections, and out of each other — a stamp found deeper in the tree
17
+ * would otherwise be stored inside whatever contains it.
18
+ */
19
+ export function isOverlay(doc, id) {
20
+ const n = doc.nodes[id];
21
+ if (!n || n.specials?.[SPEC_OVERLAY_ID] === undefined)
22
+ return false;
23
+ return childrenOf(doc, doc.root_node_id).includes(id);
24
+ }
25
+ /**
26
+ * ROOT's children WITHOUT the overlays — the walk every ROOT-level rule must use.
27
+ *
28
+ * A separate function from `childrenOf` on purpose. An overlay is composed onto
29
+ * ROOT on read and stripped on write, so it is not part of the page document at
30
+ * all: the band rule, drag clamping and the save check all have to skip it. A
31
+ * caller who writes the obvious `childrenOf(doc, doc.root_node_id)` gets that
32
+ * wrong SILENTLY, so the correct walk is the one with the shorter name.
33
+ */
34
+ export function pageChildren(doc) {
35
+ return childrenOf(doc, doc.root_node_id).filter((id) => !isOverlay(doc, id));
36
+ }
37
+ /** Depth-first walk. Cycle-safe: a malformed document must not hang a save. */
38
+ export function walk(doc, id, visit) {
39
+ const seen = new Set();
40
+ const go = (cur) => {
41
+ if (seen.has(cur))
42
+ return;
43
+ seen.add(cur);
44
+ const n = doc.nodes[cur];
45
+ if (!n)
46
+ return;
47
+ visit(n);
48
+ for (const k of n.data.nodes)
49
+ go(k);
50
+ };
51
+ go(id);
52
+ }
53
+ export function subtreeIds(doc, id) {
54
+ const out = [];
55
+ walk(doc, id, (n) => out.push(n.id));
56
+ return out;
57
+ }
58
+ /** Ids from this node's parent up to ROOT. Cycle-safe for the same reason. */
59
+ export function ancestors(doc, id) {
60
+ const out = [];
61
+ const seen = new Set([id]);
62
+ let cur = doc.nodes[id]?.data.parent ?? null;
63
+ while (cur && !seen.has(cur)) {
64
+ seen.add(cur);
65
+ out.push(cur);
66
+ cur = doc.nodes[cur]?.data.parent ?? null;
67
+ }
68
+ return out;
69
+ }
@@ -0,0 +1,224 @@
1
+ import { isOverlay, subtreeIds, ancestors } from '../../core/tree.js';
2
+ import { ELEMENTS } from '../../catalog/elements.generated.js';
3
+ import { createNode } from './node.js';
4
+ import { genId } from './ids.js';
5
+ /** Append sentinel: splice clamps a too-large index, and `isSyncablePatch`
6
+ * deliberately allows one — an append is a legitimate thing to describe. */
7
+ const APPEND = Number.MAX_SAFE_INTEGER;
8
+ function requireContainer(parentType, parentId) {
9
+ if (!ELEMENTS[parentType]?.isContainer) {
10
+ throw new Error(`sbuilder: ${parentId} is a ${parentType}, which is not a container — it cannot hold children.`);
11
+ }
12
+ }
13
+ function requireAllowed(parentType, childType) {
14
+ const child = ELEMENTS[childType];
15
+ if (!child) {
16
+ throw new Error(`sbuilder: unknown element "${childType}". Use sb_catalog_search to find a real one.`);
17
+ }
18
+ if (child.isRootOnly && parentType !== 'root') {
19
+ throw new Error(`sbuilder: ${childType} is root-only — it may only be a direct child of ROOT, not of a ${parentType}.`);
20
+ }
21
+ const allows = ELEMENTS[parentType]?.childAllows ?? [];
22
+ if (allows.length > 0 && !allows.includes(childType)) {
23
+ throw new Error(`sbuilder: a ${parentType} accepts only [${allows.join(', ')}], not ${childType}.`);
24
+ }
25
+ }
26
+ function refuseOverlay(doc, id, verb) {
27
+ if (!isOverlay(doc.doc, id))
28
+ return;
29
+ throw new Error(`sbuilder: ${id} is a SITE OVERLAY (the cart drawer or a pop-up). It is composed onto ROOT ` +
30
+ `on read and stripped out on write, so it is not part of this page document — ${verb} it ` +
31
+ 'here would do nothing on save. Use the overlays API instead.');
32
+ }
33
+ /**
34
+ * Add a whole subtree under `parentId`, as ONE batch of patches.
35
+ *
36
+ * Nested rather than one-node-per-call because a hero section is a section, a
37
+ * heading, a paragraph and a button — four round trips to describe one idea, and
38
+ * four separate op batches for anyone watching the room. The spec is a tree; the
39
+ * patches come out flat in CREATION ORDER, so a peer applying them left to right
40
+ * never sees a child referenced before the node exists.
41
+ */
42
+ export function addSubtree(doc, parentId, spec, index) {
43
+ const parent = doc.node(parentId);
44
+ requireContainer(parent.data.type, parentId);
45
+ requireAllowed(parent.data.type, spec.type);
46
+ const patches = [];
47
+ const ids = [];
48
+ const build = (s, parentNodeId) => {
49
+ const n = createNode(s.type, {
50
+ name: s.name,
51
+ parent: parentNodeId,
52
+ style: s.style,
53
+ config: s.config,
54
+ specials: s.specials,
55
+ });
56
+ patches.push({ op: 'set', path: ['nodes', n.id], value: n });
57
+ ids.push(n.id);
58
+ for (const child of s.children ?? []) {
59
+ requireContainer(s.type, n.id);
60
+ requireAllowed(s.type, child.type);
61
+ const childId = build(child, n.id);
62
+ patches.push({ op: 'insert', path: ['nodes', n.id, 'data', 'nodes'], index: APPEND, value: childId });
63
+ }
64
+ return n.id;
65
+ };
66
+ const rootId = build(spec, parentId);
67
+ const at = index ?? parent.data.nodes.length;
68
+ patches.push({ op: 'insert', path: ['nodes', parentId, 'data', 'nodes'], index: at, value: rootId });
69
+ return { patches, ids };
70
+ }
71
+ /**
72
+ * Write keys into a namespace.
73
+ *
74
+ * PER BREAKPOINT BY DEFAULT, and that default is the whole point. The platform's
75
+ * rule is that any key which CAN be responsive MUST be: a visual quantity
76
+ * written at base renders correctly on the canvas and then VANISHES on publish,
77
+ * because the published cascade has no base layer under it. An agent writing
78
+ * styles would otherwise ship that bug on every element it touches.
79
+ *
80
+ * `specials` needs no flag and takes no breakpoint: it is content and identity,
81
+ * base-only by definition.
82
+ */
83
+ export function setKeys(doc, id, keys, opts) {
84
+ doc.node(id); // throws naming the id if it is not there
85
+ const { namespace } = opts;
86
+ if (namespace === 'specials') {
87
+ return Object.entries(keys).map(([k, v]) => ({
88
+ op: 'set',
89
+ path: ['nodes', id, 'specials', k],
90
+ value: v,
91
+ }));
92
+ }
93
+ if (opts.base) {
94
+ // Base IS legitimate, and this used to throw for anything that was not an
95
+ // identity key — a refusal built on a misread of the platform's responsive
96
+ // mandate.
97
+ //
98
+ // That mandate is about ELEMENT IMPLEMENTATION: an element whose Go renderer
99
+ // reads `n.Config[...]` directly (nodes.ConfigInt in html.go, an SVG width=
100
+ // attribute) bypasses the cascade, so a per-breakpoint value the author sets
101
+ // renders on the canvas and never reaches publish. It is not a rule about
102
+ // documents.
103
+ //
104
+ // The cascade proves it: style/cascade.go's MergeNamespace resolves a key
105
+ // "current slot, then wider slots, then BASE, then narrower slots" — base is
106
+ // the fallback layer, and it is exactly where every element's own
107
+ // meta.defaults.style is seeded. Refusing to write there refused a namespace
108
+ // the platform itself fills on every node it creates.
109
+ //
110
+ // Per-breakpoint remains the DEFAULT, because a design should respond. Base
111
+ // is for a value that genuinely should not vary.
112
+ return Object.entries(keys).map(([k, v]) => ({
113
+ op: 'set',
114
+ path: ['nodes', id, namespace, k],
115
+ value: v,
116
+ }));
117
+ }
118
+ const bp = opts.breakpoint ?? 'desktop';
119
+ if (opts.state) {
120
+ return Object.entries(keys).map(([k, v]) => ({
121
+ op: 'set',
122
+ path: ['nodes', id, 'states', opts.state, bp, namespace, k],
123
+ value: v,
124
+ }));
125
+ }
126
+ return Object.entries(keys).map(([k, v]) => ({
127
+ op: 'set',
128
+ path: ['nodes', id, 'responsive', bp, namespace, k],
129
+ value: v,
130
+ }));
131
+ }
132
+ /**
133
+ * Copy a node and everything under it, under fresh ids, beside the original.
134
+ *
135
+ * The move a designer makes constantly — build one card, duplicate it twice —
136
+ * and without it an agent rebuilds the subtree by hand and gets it subtly
137
+ * different. Ids are re-minted rather than reused: two nodes sharing an id is a
138
+ * document the renderer draws once and the editor cannot select.
139
+ *
140
+ * Refuses ROOT (there is nothing to put a second one beside) and an overlay
141
+ * (not part of this document at all).
142
+ */
143
+ export function duplicateNode(doc, id) {
144
+ const n = doc.node(id);
145
+ if (id === doc.doc.root_node_id)
146
+ throw new Error('sbuilder: cannot duplicate ROOT');
147
+ refuseOverlay(doc, id, 'duplicating');
148
+ const parentId = n.data.parent;
149
+ if (!parentId || !doc.has(parentId)) {
150
+ throw new Error(`sbuilder: ${id} has no parent to be duplicated beside`);
151
+ }
152
+ const patches = [];
153
+ const ids = [];
154
+ // One pass, parent-first, so a child's `parent` always names an id already
155
+ // emitted — the same ordering rule addSubtree follows.
156
+ const copy = (srcId, newParent) => {
157
+ const src = doc.node(srcId);
158
+ const clone = JSON.parse(JSON.stringify(src));
159
+ clone.id = genId(src.data.type);
160
+ clone.data = { ...clone.data, parent: newParent, nodes: [] };
161
+ patches.push({ op: 'set', path: ['nodes', clone.id], value: clone });
162
+ ids.push(clone.id);
163
+ for (const kid of src.data.nodes) {
164
+ const kidId = copy(kid, clone.id);
165
+ patches.push({ op: 'insert', path: ['nodes', clone.id, 'data', 'nodes'], index: APPEND, value: kidId });
166
+ }
167
+ return clone.id;
168
+ };
169
+ const rootId = copy(id, parentId);
170
+ const at = doc.node(parentId).data.nodes.indexOf(id);
171
+ patches.push({
172
+ op: 'insert',
173
+ path: ['nodes', parentId, 'data', 'nodes'],
174
+ index: at < 0 ? APPEND : at + 1,
175
+ value: rootId,
176
+ });
177
+ return { patches, ids };
178
+ }
179
+ export function moveNode(doc, id, newParentId, index) {
180
+ const n = doc.node(id);
181
+ refuseOverlay(doc, id, 'moving');
182
+ const newParent = doc.node(newParentId);
183
+ // The STRUCTURAL check runs first, before the type rules, and the order is not
184
+ // arbitrary: a node moved inside its own subtree detaches that subtree from the
185
+ // document with nothing to report it — the nodes still sit in the map,
186
+ // reachable from nobody, and the page silently loses a section. Running the
187
+ // type rules first would report "flex-section is root-only" for a caller whose
188
+ // actual mistake was moving a node into itself, which sends them to fix the
189
+ // wrong thing.
190
+ if (newParentId === id || subtreeIds(doc.doc, id).includes(newParentId) || ancestors(doc.doc, newParentId).includes(id)) {
191
+ throw new Error(`sbuilder: cannot move ${id} into its own descendant ${newParentId}`);
192
+ }
193
+ requireContainer(newParent.data.type, newParentId);
194
+ requireAllowed(newParent.data.type, n.data.type);
195
+ const patches = [];
196
+ const oldParentId = n.data.parent;
197
+ if (oldParentId && doc.has(oldParentId)) {
198
+ const at = doc.node(oldParentId).data.nodes.indexOf(id);
199
+ if (at >= 0)
200
+ patches.push({ op: 'remove', path: ['nodes', oldParentId, 'data', 'nodes'], index: at });
201
+ }
202
+ patches.push({ op: 'insert', path: ['nodes', newParentId, 'data', 'nodes'], index, value: id });
203
+ patches.push({ op: 'set', path: ['nodes', id, 'data', 'parent'], value: newParentId });
204
+ return patches;
205
+ }
206
+ export function removeNode(doc, id) {
207
+ const n = doc.node(id);
208
+ if (id === doc.doc.root_node_id)
209
+ throw new Error('sbuilder: cannot remove ROOT');
210
+ refuseOverlay(doc, id, 'removing');
211
+ const patches = [];
212
+ const parentId = n.data.parent;
213
+ if (parentId && doc.has(parentId)) {
214
+ const at = doc.node(parentId).data.nodes.indexOf(id);
215
+ if (at >= 0)
216
+ patches.push({ op: 'remove', path: ['nodes', parentId, 'data', 'nodes'], index: at });
217
+ }
218
+ // Unset the whole subtree — a node left behind is an orphan the save check
219
+ // would reject, and the agent would never guess why.
220
+ for (const sub of subtreeIds(doc.doc, id)) {
221
+ patches.push({ op: 'unset', path: ['nodes', sub] });
222
+ }
223
+ return patches;
224
+ }
@@ -0,0 +1,112 @@
1
+ import { applyPatches } from '../../core/patch.js';
2
+ import { childrenOf, isOverlay } from '../../core/tree.js';
3
+ import { bandOf, isGlobal } from './traps.js';
4
+ /**
5
+ * One page's document, in memory.
6
+ *
7
+ * Owns a DEEP COPY of what it was handed. The caller's object is very often a
8
+ * parsed HTTP response that something else still holds a reference to, and an
9
+ * in-place mutation there is the kind of bug that only ever shows up as "the
10
+ * second save wrote the first save's tree".
11
+ */
12
+ export class PageDoc {
13
+ doc;
14
+ revision;
15
+ constructor(doc, revision = 0) {
16
+ this.doc = doc;
17
+ this.revision = revision;
18
+ }
19
+ static from(raw) {
20
+ if (!raw || typeof raw !== 'object' || !raw.nodes) {
21
+ throw new Error('sbuilder: not a page document — expected { schema_version, root_node_id, nodes }');
22
+ }
23
+ const d = JSON.parse(JSON.stringify(raw));
24
+ // A BRAND-NEW page comes back as {"schema_version":1,"root_node_id":"","nodes":{}}
25
+ // — the server's `emptyDocument`, because it treats the document as opaque
26
+ // JSONB and will not synthesize node shapes. Seed the same ROOT the editor's
27
+ // own `seedRoot` does, id and all.
28
+ //
29
+ // This used to throw, on the reasoning that healing here would race the
30
+ // editor's hydrate path. Running it against a live server showed the cost:
31
+ // an agent that had just CREATED a page could not open it, which is the
32
+ // first thing anyone connecting an agent does. And there is no race to lose
33
+ // — an empty document has nothing to disagree with, and whichever side seeds
34
+ // first is the tree the other then loads.
35
+ //
36
+ // The genuinely broken case is still refused below: a document that HAS
37
+ // nodes but whose root_node_id names none of them is damage, not emptiness,
38
+ // and inventing a root there would strand every existing node as an orphan.
39
+ if (!d.root_node_id && Object.keys(d.nodes).length === 0) {
40
+ d.root_node_id = 'ROOT';
41
+ d.nodes.ROOT = {
42
+ id: 'ROOT',
43
+ data: { type: 'root', parent: null, nodes: [] },
44
+ specials: {},
45
+ // The remaining namespaces the platform's own makeRoot writes. Spread
46
+ // through an index signature because NodeLike models only what the tree
47
+ // walk reads — the document carries more, and a node missing them is a
48
+ // node the renderer cannot draw.
49
+ ...{ style: {}, config: {}, responsive: {}, events: [], bindings: [] },
50
+ };
51
+ if (!d.schema_version)
52
+ d.schema_version = 2;
53
+ }
54
+ if (!d.root_node_id || !d.nodes[d.root_node_id]) {
55
+ throw new Error(`sbuilder: document is damaged — root_node_id=${JSON.stringify(d.root_node_id)} names no ` +
56
+ `node, but ${Object.keys(d.nodes).length} nodes are present. Open the page in the ` +
57
+ 'editor once; its hydrate path repairs this.');
58
+ }
59
+ return new PageDoc(d);
60
+ }
61
+ get rev() {
62
+ return this.revision;
63
+ }
64
+ has(id) {
65
+ return this.doc.nodes[id] !== undefined;
66
+ }
67
+ node(id) {
68
+ const n = this.doc.nodes[id];
69
+ if (!n)
70
+ throw new Error(`sbuilder: no node "${id}" in this page`);
71
+ return n;
72
+ }
73
+ apply(patches) {
74
+ applyPatches(this.doc, patches);
75
+ this.revision += 1;
76
+ }
77
+ /**
78
+ * A compressed tree — id, type, name, child count, and the flags that change
79
+ * what a caller may safely do with a node.
80
+ *
81
+ * Never the document itself. A real page is hundreds of KB of JSON, and a tool
82
+ * that returns it burns the context the agent needs for the actual design
83
+ * work. Depth 1 (the default) is ROOT's children; deeper levels nest in `kids`.
84
+ *
85
+ * Overlays ARE listed, unlike in `pageChildren`: the agent needs to know the
86
+ * cart drawer is there. `pageChildren` is for the RULES; this is for the
87
+ * reader, and the `overlay: true` flag is how the two stay distinguishable.
88
+ */
89
+ outline(opts = {}) {
90
+ const depth = opts.depth ?? 1;
91
+ const line = (id, level) => {
92
+ const n = this.node(id);
93
+ const kidIds = childrenOf(this.doc, id);
94
+ const out = { id, type: n.data.type, children: kidIds.length };
95
+ if (n.data.name)
96
+ out.name = n.data.name;
97
+ if (level === 0) {
98
+ if (isOverlay(this.doc, id))
99
+ out.overlay = true;
100
+ else
101
+ out.band = bandOf(this.doc, id);
102
+ }
103
+ if (isGlobal(this.doc, id))
104
+ out.global = true;
105
+ if (level + 1 < depth && kidIds.length > 0) {
106
+ out.kids = kidIds.map((k) => line(k, level + 1));
107
+ }
108
+ return out;
109
+ };
110
+ return childrenOf(this.doc, this.doc.root_node_id).map((id) => line(id, 0));
111
+ }
112
+ }
@@ -0,0 +1,27 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ /**
3
+ * Id prefixes, mirrored from schema/src/node.ts.
4
+ *
5
+ * Purely cosmetic — an id only has to be unique — but a document this server
6
+ * builds should be indistinguishable from one a human built, and the prefix is
7
+ * the first thing anyone reading a document sees. The tab family is listed
8
+ * explicitly because the generic two-letter fallback collapses all three to 'ta'.
9
+ */
10
+ const PREFIXES = {
11
+ root: 'rt',
12
+ 'flex-section': 'fs',
13
+ 'flex-block': 'fb',
14
+ heading: 'he',
15
+ text: 'tx',
16
+ button: 'bt',
17
+ image: 'im',
18
+ icon: 'ic',
19
+ spacer: 'sp',
20
+ tab: 'tb',
21
+ 'tab-content': 'tc',
22
+ 'tab-item': 'ti',
23
+ };
24
+ export function genId(type) {
25
+ const prefix = PREFIXES[type] ?? (type.replace(/[^a-z]/g, '').slice(0, 2) || 'nd');
26
+ return `${prefix}_${randomBytes(4).toString('hex')}`;
27
+ }
@@ -0,0 +1,43 @@
1
+ import { ELEMENTS } from '../../catalog/elements.generated.js';
2
+ import { genId } from './ids.js';
3
+ /** Structured clone via JSON — the defaults are plain data, and this is what
4
+ * stops two nodes of the same type sharing one nested object. */
5
+ function copy(v) {
6
+ return JSON.parse(JSON.stringify(v));
7
+ }
8
+ /**
9
+ * Mint a node seeded from its element's catalog defaults.
10
+ *
11
+ * `states` is spread in ONLY when the element declares defaults for it. A node
12
+ * that declares none must not carry the key at all: the platform's Go side marks
13
+ * it `omitempty`, so an empty object here would change the stored bytes of every
14
+ * document this server touches — and the render contract is byte-identical.
15
+ */
16
+ export function createNode(type, opts = {}) {
17
+ const meta = ELEMENTS[type];
18
+ if (!meta) {
19
+ throw new Error(`sbuilder: unknown element "${type}". Use sb_catalog_search to find a real one — ` +
20
+ 'guessing a type produces a node nothing can render.');
21
+ }
22
+ const d = meta.defaults;
23
+ const states = d.states;
24
+ return {
25
+ id: genId(type),
26
+ data: {
27
+ type,
28
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
29
+ parent: opts.parent ?? null,
30
+ nodes: [],
31
+ isCanvas: meta.isContainer,
32
+ hidden: false,
33
+ custom: {},
34
+ },
35
+ style: { ...copy(d.style ?? {}), ...(opts.style ?? {}) },
36
+ config: { ...copy(d.config ?? {}), ...(opts.config ?? {}) },
37
+ specials: { ...copy(d.specials ?? {}), ...(opts.specials ?? {}) },
38
+ responsive: copy(d.responsive ?? {}),
39
+ ...(states ? { states: copy(states) } : {}),
40
+ events: [],
41
+ bindings: [],
42
+ };
43
+ }