sbuilder-mcp 0.2.0 → 0.2.2

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.
package/dist/core/tree.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { SATELLITE_RULES } from '../catalog/elements.generated.js';
1
2
  /** The specials stamp a composed site overlay carries. */
2
3
  export const SPEC_OVERLAY_ID = 'overlayId';
3
4
  /** The specials stamps a composed global section carries. */
@@ -37,7 +38,26 @@ export function isOverlay(doc, id) {
37
38
  export function pageChildren(doc) {
38
39
  return childrenOf(doc, doc.root_node_id).filter((id) => !isOverlay(doc, id));
39
40
  }
40
- /** Depth-first walk. Cycle-safe: a malformed document must not hang a save. */
41
+ /**
42
+ * Depth-first walk, children AND satellites. Cycle-safe: a malformed document
43
+ * must not hang a save.
44
+ *
45
+ * A satellite is a real node referenced from `config[configKey]` rather than
46
+ * from `data.nodes`, so a walk that follows child lists alone cannot see it. The
47
+ * platform states the requirement in as many words
48
+ * (`server/render/generated/schema_gen.go:245`): "Anything that asks 'what is
49
+ * inside this node?' (subtree collection, copy, delete) must consult this table
50
+ * as well, exactly as the editor's `subtreeIds` does."
51
+ *
52
+ * Satellite-aware is the DEFAULT, and the name stays short, for the same reason
53
+ * `pageChildren` has the short name and `childrenOf` the explicit one: a caller
54
+ * who writes the obvious thing must not be silently wrong. Copying an accordion
55
+ * with the child-only walk gave the copy a pointer to the ORIGINAL's skin.
56
+ *
57
+ * A `configKey` naming a node that is not in the document is skipped rather than
58
+ * reported here — a walk is not a validator, and a trimmed subtree is a real
59
+ * shape the editor handles the same way (`stores/node.ts:408-411`).
60
+ */
41
61
  export function walk(doc, id, visit) {
42
62
  const seen = new Set();
43
63
  const go = (cur) => {
@@ -50,6 +70,11 @@ export function walk(doc, id, visit) {
50
70
  visit(n);
51
71
  for (const k of n.data.nodes)
52
72
  go(k);
73
+ for (const rule of SATELLITE_RULES[n.data.type] ?? []) {
74
+ const sat = n.config?.[rule.configKey];
75
+ if (typeof sat === 'string' && sat && doc.nodes[sat])
76
+ go(sat);
77
+ }
53
78
  };
54
79
  go(id);
55
80
  }
@@ -1,6 +1,7 @@
1
- import { isOverlay, subtreeIds, ancestors, appBlockRoot } from '../../core/tree.js';
2
- import { ELEMENTS } from '../../catalog/elements.generated.js';
3
- import { createNode } from './node.js';
1
+ import { isOverlay, subtreeIds, ancestors, appBlockRoot, SPEC_GLOBAL_ID, SPEC_GLOBAL_KIND, SPEC_GLOBAL_REF, } from '../../core/tree.js';
2
+ import { ELEMENTS, ELEMENT_SEEDS, SATELLITE_RULES } from '../../catalog/elements.generated.js';
3
+ import { createNode, mintSatellites } from './node.js';
4
+ import { refuseSecondTemplate } from './traps.js';
4
5
  import { genId } from './ids.js';
5
6
  /** Append sentinel: splice clamps a too-large index, and `isSyncablePatch`
6
7
  * deliberately allows one — an append is a legitimate thing to describe. */
@@ -78,6 +79,7 @@ export function addSubtree(doc, parentId, spec, index) {
78
79
  refuseAppBlockParent(doc, parentId, 'adding');
79
80
  requireContainer(parent.data.type, parentId);
80
81
  requireAllowed(parent.data.type, spec.type);
82
+ refuseSecondTemplate(doc.doc, parentId, 'Adding');
81
83
  const patches = [];
82
84
  const ids = [];
83
85
  const build = (s, parentNodeId) => {
@@ -89,9 +91,22 @@ export function addSubtree(doc, parentId, spec, index) {
89
91
  config: s.config,
90
92
  specials: s.specials,
91
93
  });
94
+ // Before the owner is handed to a patch: minting rewrites its `config`.
95
+ const sats = mintSatellites(n);
92
96
  patches.push({ op: 'set', path: ['nodes', n.id], value: n });
93
97
  ids.push(n.id);
94
- for (const child of s.children ?? []) {
98
+ for (const sat of sats) {
99
+ patches.push({ op: 'set', path: ['nodes', sat.id], value: sat });
100
+ ids.push(sat.id);
101
+ }
102
+ // SEEDED CONTENT, when the caller brought none of its own.
103
+ //
104
+ // `ELEMENT_SEEDS` is the content an element "is not USABLE without": a
105
+ // dropdown with no trigger and no panel is a bare relative box, and a select
106
+ // renders INTO those two nodes and draws an empty box without them. A caller
107
+ // who passed children has expressed an intent and is never overridden.
108
+ const children = s.children?.length ? s.children : (ELEMENT_SEEDS[s.type] ?? []);
109
+ for (const child of children) {
95
110
  requireContainer(s.type, n.id);
96
111
  requireAllowed(s.type, child.type);
97
112
  const childId = build(child, n.id);
@@ -114,6 +129,14 @@ export function addSubtree(doc, parentId, spec, index) {
114
129
  * whole subtree, and every page carrying it goes blank. That is not a
115
130
  * hypothetical — it took four pages down before this check existed.
116
131
  */
132
+ /**
133
+ * The composition stamps a DUPLICATE must not inherit.
134
+ *
135
+ * `globalRev` is the fence the editor writes against a shared master
136
+ * (`features/globalsections/api.ts:65`); a local copy has no master and so no
137
+ * revision to be stale against.
138
+ */
139
+ const COPY_STRIPPED_SPECIALS = [SPEC_GLOBAL_ID, SPEC_GLOBAL_REF, SPEC_GLOBAL_KIND, 'globalRev'];
117
140
  const COMPOSED_STAMPS = {
118
141
  globalId: 'globalRef',
119
142
  appBlockId: 'appBlockRef',
@@ -272,6 +295,11 @@ export function duplicateNode(doc, id) {
272
295
  if (!parentId || !doc.has(parentId)) {
273
296
  throw new Error(`sbuilder: ${id} has no parent to be duplicated beside`);
274
297
  }
298
+ // A copy lands BESIDE the original, so duplicating a repeater's template makes
299
+ // the second child the renderer will never draw. sb_add and sb_move already
300
+ // refuse that; duplicate is the likeliest way to reach for it, since
301
+ // "duplicate the card" is the move a designer makes constantly.
302
+ refuseSecondTemplate(doc.doc, parentId, 'Duplicating into');
275
303
  const patches = [];
276
304
  const ids = [];
277
305
  // One pass, parent-first, so a child's `parent` always names an id already
@@ -281,12 +309,44 @@ export function duplicateNode(doc, id) {
281
309
  const clone = JSON.parse(JSON.stringify(src));
282
310
  clone.id = genId(src.data.type);
283
311
  clone.data = { ...clone.data, parent: newParent, nodes: [] };
312
+ // A COPY IS NOT THE SHARED MASTER.
313
+ //
314
+ // The stamps came over verbatim, so duplicating a global header produced two
315
+ // ROOT children carrying one globalId — ErrDuplicateGlobal
316
+ // (server/internal/page/decompose.go:293), refused on a LATER save, by which
317
+ // time the agent has kept editing and reads it as a transport error. Strip
318
+ // rather than refuse: unlike an overlay or an app block, whose copies the
319
+ // SERVER would destroy, a stripped global copy is a perfectly valid
320
+ // document, and it is what a designer means by duplicating a header to make
321
+ // a variant.
322
+ for (const stamp of COPY_STRIPPED_SPECIALS)
323
+ delete clone.specials?.[stamp];
284
324
  patches.push({ op: 'set', path: ['nodes', clone.id], value: clone });
285
325
  ids.push(clone.id);
286
326
  for (const kid of src.data.nodes) {
287
327
  const kidId = copy(kid, clone.id);
288
328
  patches.push({ op: 'insert', path: ['nodes', clone.id, 'data', 'nodes'], index: APPEND, value: kidId });
289
329
  }
330
+ // SATELLITES, deep-copied with the owner's pointer rewritten — the editor's
331
+ // copyNode does exactly this (`editor/src/stores/node.ts:373,403`). Without
332
+ // it the copy pointed at the ORIGINAL's skin: editing one changed both, and
333
+ // removing the original deleted the skin out from under the copy.
334
+ //
335
+ // Written as an explicit patch rather than by mutating `clone` after it has
336
+ // been handed to one, so the emitted patch list says what it does.
337
+ for (const rule of SATELLITE_RULES[src.data.type] ?? []) {
338
+ const satId = src.config?.[rule.configKey];
339
+ const path = ['nodes', clone.id, 'config', rule.configKey];
340
+ if (typeof satId === 'string' && satId && doc.has(satId)) {
341
+ patches.push({ op: 'set', path, value: copy(satId, clone.id) });
342
+ }
343
+ else if (satId !== undefined) {
344
+ // A satellite id that resolves to nothing is a trimmed subtree, which the
345
+ // editor handles the same way (`stores/node.ts:408-411`). Carrying the
346
+ // pointer would aim the copy at a node it does not own.
347
+ patches.push({ op: 'unset', path });
348
+ }
349
+ }
290
350
  return clone.id;
291
351
  };
292
352
  const rootId = copy(id, parentId);
@@ -317,6 +377,10 @@ export function moveNode(doc, id, newParentId, index) {
317
377
  }
318
378
  requireContainer(newParent.data.type, newParentId);
319
379
  requireAllowed(newParent.data.type, n.data.type);
380
+ // Not for a REORDER: a node already in this parent is not a second template,
381
+ // and refusing it would block the one move that is always safe.
382
+ if (n.data.parent !== newParentId)
383
+ refuseSecondTemplate(doc.doc, newParentId, 'Moving');
320
384
  const patches = [];
321
385
  const oldParentId = n.data.parent;
322
386
  if (oldParentId && doc.has(oldParentId)) {
@@ -25,6 +25,13 @@ export const FIX = {
25
25
  'sb_remove id "<id>". Setting "<key>" on this one would show the same value in every row.',
26
26
  unbound_dataset_element: 'Bind it: sb_bind id "<id>", field "specials.<key>", and the source that names the record ' +
27
27
  'field you want (sb_bind refuses an unknown source and lists every valid one).',
28
+ unlinked_form: 'Point it at a real form: sb_set id "<id>", namespace specials, keys ' +
29
+ '{ "formId": "<a form id from sb_api_find \'list forms\'>" }.',
30
+ dead_menu_link: 'Write the entries the renderer actually reads: sb_set id "<id>", namespace specials, keys ' +
31
+ '{ "menuItems": [{ "id": "mi-1", "label": "Shop", "href": "/shop" }] }. Setting menuId alone ' +
32
+ 'publishes an empty nav — the Go renderer never reads it.',
33
+ extra_repeater_child: 'Keep one template: sb_remove the extra children of "<id>", or sb_move them out. Design the ' +
34
+ 'single remaining child — it is what every record is drawn with.',
28
35
  dead_binding_source: `Rebind with sb_bind using one of: ${BINDING_SOURCES.join(', ')}.`,
29
36
  // "<key>" here is documentation, not a placeholder: this template is never
30
37
  // filled with a key, so the reader sees the form a field must take.
@@ -58,3 +65,54 @@ export function compactFindings(items) {
58
65
  });
59
66
  return { findings, fixes };
60
67
  }
68
+ /**
69
+ * What a COMPOSE WARNING means for the document now in hand.
70
+ *
71
+ * These ride on `GET .../source` beside the page. The client typed the field and
72
+ * read it nowhere, which mattered most for the one that is destructive:
73
+ * `globalMissing` means the server could not find the master and DELETED the
74
+ * reference node from the tree it handed back (`compose.go:118,127`), so the
75
+ * page opens with the section already gone and the next save stores that loss
76
+ * permanently — with no error at any step.
77
+ */
78
+ export const COMPOSE_WARNINGS = {
79
+ globalMissing: 'The shared section is GONE from the tree you just opened — the server could not find its ' +
80
+ 'master and removed the reference. Saving from here makes that permanent. Re-add the section, ' +
81
+ 'or restore the master, before you save.',
82
+ globalStale: 'A shared section was edited elsewhere while this copy was held; the master write was refused.',
83
+ overlayStale: 'The overlay master was edited elsewhere while this copy was held; that write was refused. ' +
84
+ 'The page edits in the same request were kept.',
85
+ appBlockMissing: 'An app block on this page no longer resolves; its subtree composed as nothing.',
86
+ appBlockEdited: 'An edit inside an app block was reduced back to its reference on save, and is stored nowhere.',
87
+ formMissing: 'A form placement names a form that no longer exists, so it publishes as an EMPTY BOX rather ' +
88
+ 'than an error.',
89
+ };
90
+ /**
91
+ * Turn the server's warnings into something a reader can act on.
92
+ *
93
+ * The two id fields are separate on the wire on purpose — they name rows in
94
+ * different tables, and one field holding "an id of whichever kind the code
95
+ * implies" is the shape that makes a client resolve it against the wrong store.
96
+ * An unknown code is passed through rather than dropped: a warning this build
97
+ * has never heard of is still the platform telling the caller something.
98
+ */
99
+ export function composeWarnings(raw) {
100
+ if (!raw?.length)
101
+ return [];
102
+ const out = [];
103
+ for (const w of raw) {
104
+ if (!w || typeof w !== 'object')
105
+ continue;
106
+ const { code, globalId, overlayId, name } = w;
107
+ if (typeof code !== 'string' || !code)
108
+ continue;
109
+ const id = typeof overlayId === 'string' && overlayId ? overlayId : globalId;
110
+ out.push({
111
+ code,
112
+ ...(typeof id === 'string' && id ? { id } : {}),
113
+ ...(typeof name === 'string' && name ? { name } : {}),
114
+ effect: COMPOSE_WARNINGS[code] ?? 'The platform reported this about the page it composed.',
115
+ });
116
+ }
117
+ return out;
118
+ }
@@ -1,4 +1,4 @@
1
- import { ELEMENTS } from '../../catalog/elements.generated.js';
1
+ import { ELEMENTS, SATELLITE_RULES } from '../../catalog/elements.generated.js';
2
2
  import { genId } from './ids.js';
3
3
  /** Structured clone via JSON — the defaults are plain data, and this is what
4
4
  * stops two nodes of the same type sharing one nested object. */
@@ -57,3 +57,92 @@ export function createNode(type, opts = {}) {
57
57
  bindings: copy(d.bindings ?? []),
58
58
  };
59
59
  }
60
+ /**
61
+ * Mint the SATELLITES a freshly created node owns, and point it at them.
62
+ *
63
+ * A satellite is a real node in the document's node map referenced from
64
+ * `config[configKey]` instead of `data.nodes` — a style-holder like the tab's
65
+ * shared button skin. The editor's node store mints them the moment an element
66
+ * is added (`editor/src/element/seeds.ts:5-7`, `stores/node.ts` addDetachedNode)
67
+ * and this server did not, so an accordion it created carried no
68
+ * `accordionItemId` at all. A missing satellite "simply resolves to nothing at
69
+ * assemble time and the renderer takes its degrade path"
70
+ * (`server/render/scope/capture.go:98-105`) — for a repeater that means ghost
71
+ * cards shown to a shopper, which the platform's own meta calls a lie.
72
+ *
73
+ * MUTATES `owner.config`, so call it before the owner is handed to a patch, and
74
+ * only on a node this process just created.
75
+ *
76
+ * An `optional` rule is skipped deliberately: `list-loading` is the one opt-in
77
+ * satellite in the platform, because a list with no loading design shows a
78
+ * silhouette of its own cards, which is the better answer for almost every site.
79
+ * A `configKey` the caller already filled is left alone — an explicit id beats a
80
+ * minted one.
81
+ */
82
+ export function mintSatellites(owner, seen = new Set()) {
83
+ // A type is expanded once per call chain. No element owns itself today, and a
84
+ // future one that did would otherwise mint until the stack ran out.
85
+ if (seen.has(owner.data.type))
86
+ return [];
87
+ seen.add(owner.data.type);
88
+ const out = [];
89
+ for (const rule of SATELLITE_RULES[owner.data.type] ?? []) {
90
+ if (rule.optional)
91
+ continue;
92
+ const existing = owner.config[rule.configKey];
93
+ if (typeof existing === 'string' && existing)
94
+ continue;
95
+ // A satellite is usually one bare skin node, but the three list-empty
96
+ // owners are born as a SUBTREE — the editor calls addDetachedTree for them,
97
+ // and a bare list-empty is blank space where it shows a glyph, a headline
98
+ // and a line of body.
99
+ const seed = seedFor(rule, owner);
100
+ const born = seed
101
+ ? buildFromSeed(seed, owner.id, seen)
102
+ : [createNode(rule.type, { parent: owner.id })];
103
+ owner.config[rule.configKey] = born[0].id;
104
+ out.push(...born, ...(seed ? [] : mintSatellites(born[0], seen)));
105
+ }
106
+ return out;
107
+ }
108
+ /**
109
+ * Which seed subtree this satellite is born as, if any.
110
+ *
111
+ * A repeater's empty state says "No products yet" or "No posts yet" depending on
112
+ * what it lists, so the table is keyed by the owner's `config.datasetSource`.
113
+ * An unknown source falls back to `product`, exactly as the editor's `copyFor`
114
+ * does — a list whose source this build does not know still gets a designed
115
+ * empty state rather than a blank one.
116
+ */
117
+ function seedFor(rule, owner) {
118
+ if (rule.seed)
119
+ return rule.seed;
120
+ if (!rule.seedBySource)
121
+ return undefined;
122
+ const src = owner.config.datasetSource;
123
+ const key = typeof src === 'string' ? src : 'product';
124
+ return rule.seedBySource[key] ?? rule.seedBySource.product;
125
+ }
126
+ /**
127
+ * Mint a whole seeded subtree, ROOT FIRST.
128
+ *
129
+ * Root-first matters to every caller: the owner points `config[configKey]` at
130
+ * `[0]`, and `addSubtree` links a child by the same index. Each node is created
131
+ * through `createNode`, so it still gets its element's own defaults and
132
+ * bindings; the seed carries only what the seed decided.
133
+ */
134
+ export function buildFromSeed(seed, parent, seen) {
135
+ const n = createNode(seed.type, {
136
+ parent,
137
+ style: seed.style,
138
+ config: seed.config,
139
+ specials: seed.specials,
140
+ });
141
+ const out = [n, ...mintSatellites(n, seen ? new Set(seen) : undefined)];
142
+ for (const child of seed.children ?? []) {
143
+ const kids = buildFromSeed(child, n.id, seen);
144
+ n.data.nodes.push(kids[0].id);
145
+ out.push(...kids);
146
+ }
147
+ return out;
148
+ }
@@ -1,5 +1,5 @@
1
1
  import { childrenOf, isOverlay, pageChildren, appBlockRoot } from '../../core/tree.js';
2
- import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS } from '../../catalog/elements.generated.js';
2
+ import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY } from '../../catalog/elements.generated.js';
3
3
  import { fill } from './findings.js';
4
4
  /**
5
5
  * Shipped with every non-empty finding list.
@@ -175,6 +175,72 @@ export function reviewDesign(doc) {
175
175
  });
176
176
  }
177
177
  }
178
+ // A FORM NOBODY LINKED publishes as an empty box, and the platform stays
179
+ // deliberately quiet about it: form.go:221 skips an empty formId with the
180
+ // comment that reporting it "would cry wolf on every page mid-edit". True
181
+ // for a human mid-drag, wrong for an agent that has finished — and since the
182
+ // element seeds `formId: ""` and forms compose on the RENDER path only, the
183
+ // canvas looks identical either way. An unlinked form is the DEFAULT.
184
+ if (type === 'form') {
185
+ const formId = (n.specials ?? {}).formId;
186
+ if (typeof formId !== 'string' || formId.trim() === '') {
187
+ out.push({
188
+ code: 'unlinked_form',
189
+ nodeId: id,
190
+ type,
191
+ problem: 'This form names no form, so it composes nothing and publishes as an empty box — ' +
192
+ 'and the platform reports no warning for it.',
193
+ key: 'formId',
194
+ fix: fill('unlinked_form', { id, key: 'formId' }),
195
+ });
196
+ }
197
+ }
198
+ // A MENU ENTRY WITH NO HREF is a link that goes nowhere. The Go renderer
199
+ // reads specials.menuItems and never menuId, so picking a menu by id
200
+ // publishes an empty nav, and the element's own seed ships one entry
201
+ // ("Home") whose href is "". Resolution from a menu id to real addresses is
202
+ // client-side (editor/src/features/menus/snapshot.ts), so nothing fills it
203
+ // in on the way to publish.
204
+ if (type === 'menu') {
205
+ const items = (n.specials ?? {}).menuItems;
206
+ const rows = Array.isArray(items) ? items : [];
207
+ const dead = rows.filter((r) => {
208
+ const row = (r ?? {});
209
+ const href = row.href;
210
+ const panel = row.panelId;
211
+ return ((typeof href !== 'string' || href.trim() === '') &&
212
+ (typeof panel !== 'string' || panel.trim() === ''));
213
+ });
214
+ if (rows.length === 0 || dead.length > 0) {
215
+ out.push({
216
+ code: 'dead_menu_link',
217
+ nodeId: id,
218
+ type,
219
+ problem: rows.length === 0
220
+ ? 'This menu has no entries, so it publishes as an empty nav. The renderer reads ' +
221
+ 'specials.menuItems and never menuId.'
222
+ : `${dead.length} of ${rows.length} entries have no href, so those links go ` +
223
+ 'nowhere. The renderer reads specials.menuItems and never menuId.',
224
+ key: 'menuItems',
225
+ fix: fill('dead_menu_link', { id, key: 'menuItems' }),
226
+ });
227
+ }
228
+ }
229
+ // A REPEATER RENDERS ITS FIRST CHILD AND DROPS THE REST. `templateID`
230
+ // returns Data.Nodes[0], and list-dataset is the only renderer that does —
231
+ // which is why the list is generated rather than derived from the obvious
232
+ // "is a dataset container" predicate, since dataset-block renders all of
233
+ // its children.
234
+ if (FIRST_CHILD_ONLY.includes(type) && n.data.nodes.length > 1) {
235
+ out.push({
236
+ code: 'extra_repeater_child',
237
+ nodeId: id,
238
+ type,
239
+ problem: `"${type}" clones only its FIRST child per record, so the other ` +
240
+ `${n.data.nodes.length - 1} never appear on the published page.`,
241
+ fix: fill('extra_repeater_child', { id }),
242
+ });
243
+ }
178
244
  // A dataset element with NO binding at all is the same silence from the other
179
245
  // direction: its renderer is waiting for a bound special that nothing writes,
180
246
  // so it falls back to whatever the document authored — once, for every record.
@@ -1,3 +1,4 @@
1
+ import { FIRST_CHILD_ONLY } from '../../catalog/elements.generated.js';
1
2
  import { pageChildren, isOverlay, SPEC_GLOBAL_ID, SPEC_GLOBAL_KIND, } from '../../core/tree.js';
2
3
  /**
3
4
  * Which band a direct child of ROOT belongs to.
@@ -95,4 +96,27 @@ export const RESPONSIVE_NOTICE = 'Written per breakpoint, which is the default b
95
96
  'legitimate too — it is the cascade\'s fallback layer, below every breakpoint slot, and ' +
96
97
  'where an element\'s own defaults live. Use base for a value that genuinely should not ' +
97
98
  'vary; use a breakpoint for anything a narrower screen should change.';
99
+ /**
100
+ * Refuse a child a repeater would never render.
101
+ *
102
+ * `list-dataset` clones `Data.Nodes[0]` per record and ignores every sibling
103
+ * after it (`server/render/nodes/list-dataset/html.go:60`). A second child is a
104
+ * perfectly valid document: it stores, it publishes, and it simply never appears
105
+ * — so the agent designs a card nobody will ever see and nothing says why.
106
+ *
107
+ * The list is GENERATED from the renderers. `dataset-block` is a dataset
108
+ * container too and renders all of its children, so the obvious
109
+ * `isContainer && category === 'dataset'` predicate would have restricted the
110
+ * wrong element.
111
+ */
112
+ export function refuseSecondTemplate(doc, parentId, verb) {
113
+ const parent = doc.nodes[parentId];
114
+ if (!parent || !FIRST_CHILD_ONLY.includes(parent.data.type))
115
+ return;
116
+ if (parent.data.nodes.length === 0)
117
+ return;
118
+ throw new Error(`sbuilder: "${parent.data.type}" (${parentId}) renders only its FIRST child, once per ` +
119
+ `record — Data.Nodes[0] is the template. ${verb} a second one stores fine and never ` +
120
+ 'appears on the published page. Design the existing template, or sb_remove it first.');
121
+ }
98
122
  export { isOverlay };
@@ -1,4 +1,4 @@
1
- import { subtreeIds } from '../../core/tree.js';
1
+ import { subtreeIds, childrenOf, SPEC_GLOBAL_ID, SPEC_OVERLAY_ID, } from '../../core/tree.js';
2
2
  import { checkBandOrder } from './traps.js';
3
3
  /**
4
4
  * Everything that would make the platform refuse this document on save.
@@ -16,6 +16,36 @@ export function validateForSave(doc) {
16
16
  const band = checkBandOrder(d);
17
17
  if (band)
18
18
  problems.push(band);
19
+ // COMPOSITION STAMPS: only a DIRECT child of ROOT may carry one, and never the
20
+ // same id twice.
21
+ //
22
+ // The platform refuses both — ErrGlobalNested / ErrDuplicateGlobal
23
+ // (server/internal/page/decompose.go:256,293) and their overlay twins
24
+ // (overlay.go:46-55). It refuses them LATE, though: the agent keeps editing
25
+ // and one autosave later gets a bare `duplicate_global`, which reads like a
26
+ // transport error rather than something it did. The overlay pair is worse
27
+ // still — it has no mapped error code and falls through to writeErr's default.
28
+ const rootKids = new Set(childrenOf(d, d.root_node_id));
29
+ for (const stamp of [SPEC_GLOBAL_ID, SPEC_OVERLAY_ID]) {
30
+ const seen = new Map();
31
+ for (const [id, n] of Object.entries(d.nodes)) {
32
+ const value = n.specials?.[stamp];
33
+ if (typeof value !== 'string' || !value)
34
+ continue;
35
+ if (!rootKids.has(id)) {
36
+ problems.push(`Node ${id} carries ${stamp} "${value}" but is not a direct child of ROOT. ` +
37
+ 'The platform refuses the save; move it to ROOT or remove the stamp.');
38
+ continue;
39
+ }
40
+ const first = seen.get(value);
41
+ if (first) {
42
+ problems.push(`Nodes ${first} and ${id} both carry ${stamp} "${value}". One page may reference ` +
43
+ 'it only once — remove one, or drop the stamp to make it a plain local section.');
44
+ continue;
45
+ }
46
+ seen.set(value, id);
47
+ }
48
+ }
19
49
  // Dangling child ids: a parent naming a node that is not in the map. The
20
50
  // renderer walks children by id, so this is a hole in the rendered page.
21
51
  for (const [id, n] of Object.entries(d.nodes)) {
package/dist/smoke.js CHANGED
@@ -98,6 +98,35 @@ export async function runSmoke() {
98
98
  doc.apply(add2(doc, 'rt', { type: 'flex-section', children: [{ type: 'text' }] }).patches);
99
99
  const codes = reviewDesign(doc).map((f) => f.code);
100
100
  check('an unfilled placeholder IS reported', codes.includes('placeholder_content'));
101
+ // A REPEATER ARRIVES WITH ITS EMPTY STATE.
102
+ //
103
+ // On its OWN document, because a repeater with no card template is a genuinely
104
+ // empty container and would answer the review check below with a finding it is
105
+ // right to make.
106
+ //
107
+ // The satellite hangs off `config.emptyStateId`, not the child list, so every
108
+ // walk that follows children only reports this document as correct while the
109
+ // renderer takes its degrade path and ships ghost cards to a shopper.
110
+ const satDoc = PageDoc.from({
111
+ schema_version: 2,
112
+ root_node_id: 'rt',
113
+ nodes: {
114
+ rt: {
115
+ id: 'rt',
116
+ data: { type: 'root', parent: null, nodes: [], isCanvas: true, hidden: false, custom: {} },
117
+ style: {}, config: {}, specials: {}, responsive: {}, events: [], bindings: [],
118
+ },
119
+ },
120
+ });
121
+ const satSection = addSubtree(satDoc, 'rt', { type: 'flex-section' });
122
+ satDoc.apply(satSection.patches);
123
+ const listed = addSubtree(satDoc, satSection.ids[0], { type: 'list-dataset' });
124
+ satDoc.apply(listed.patches);
125
+ const list = satDoc.node(listed.ids[0]);
126
+ const emptyId = list.config.emptyStateId;
127
+ check('a repeater is minted with its empty state', typeof emptyId === 'string' && satDoc.has(emptyId) && satDoc.node(emptyId).data.type === 'list-empty');
128
+ check('the empty state carries its design, not a blank box', satDoc.node(String(emptyId)).data.nodes.length === 3);
129
+ check('a document carrying satellites still stores', validateForSave(satDoc).length === 0);
101
130
  console.error('ALL GOOD');
102
131
  }
103
132
  runSmoke().catch((err) => {
@@ -60,30 +60,45 @@ export function bindNode(doc, id, source, field) {
60
60
  ];
61
61
  }
62
62
  /**
63
- * The live socket takes a session JWT only.
63
+ * The credential that opens the live-edit room.
64
64
  *
65
- * `server/internal/server/realtime.go:38` refuses API keys, and a rejected
66
- * socket auth still fires `onopen` — so without this check an API-key-only
67
- * agent would "join", publish every edit into the void, and never learn why
68
- * nobody saw them. Every other tool works with the key; this one says so.
65
+ * AN AGENT KEY NOW WORKS. `server/internal/server/realtime.go:44` gives a `wbk_`
66
+ * bearer "the same door as a session", decided from the token's own shape, and
67
+ * gates it on member.read through the key's DELEGATED principal — so the key
68
+ * sees the room only if the member who minted it may, and only on the site the
69
+ * key belongs to. This function used to refuse a key-only context outright,
70
+ * which was true before agent keys landed and now turns away a working setup.
71
+ *
72
+ * The peer that appears on the canvas is then the KEY, not a person: the
73
+ * platform returns `key.ID` and `key.Name` rather than the minter's name,
74
+ * deliberately — an avatar borrowing a human's name would tell the room a person
75
+ * is editing when a machine is. So the merchant sees the label they chose for
76
+ * the key moving around the page.
77
+ *
78
+ * Still a GETTER, read per attempt: a session access token lives ~15 minutes and
79
+ * rotates, and a captured string replays an expired token forever on every
80
+ * reconnect — silently, because a rejected socket auth still fires `onopen`.
69
81
  */
70
- export function requireSessionForLive(ctx) {
71
- if (!ctx.session.loggedIn()) {
72
- throw new Error('sbuilder: the live-edit room takes a session token only — an API key cannot join. Set ' +
73
- 'SB_EMAIL and SB_PASSWORD and call sb_connect, then sb_live_join. Every other tool ' +
74
- 'works with the key alone.');
82
+ export function liveTokenFor(ctx) {
83
+ if (!ctx.session.loggedIn() && !ctx.apiKey) {
84
+ throw new Error('sbuilder: the live-edit room needs a credential. Set SB_TOKEN, or SB_EMAIL and ' +
85
+ 'SB_PASSWORD, and call sb_connect before sb_live_join.');
75
86
  }
76
- return () => ctx.session.token();
87
+ // Prefer the key, for the reason `tokenFor` prefers it everywhere: a key is
88
+ // narrower — one site, its own scopes, revocable on its own — while a session
89
+ // carries the whole account.
90
+ return () => (ctx.apiKey ? ctx.apiKey : ctx.session.token());
77
91
  }
78
92
  export function registerLiveTools(server, ctx, session) {
79
93
  server.registerTool('sb_live_join', {
80
94
  description: "Join the site's live-edit room as a visible peer: every write then appears in any open " +
81
- 'editor as it happens. Always yields, so it is safe beside a human. Needs ' +
82
- 'SB_EMAIL / SB_PASSWORD; the socket refuses API keys.',
95
+ 'editor as it happens, with the agent shown by the API key\'s own name rather than a ' +
96
+ "person's. Always yields, so it is safe beside a human. Works with SB_TOKEN or with " +
97
+ 'SB_EMAIL / SB_PASSWORD.',
83
98
  inputSchema: { site_id: z.string() },
84
99
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
85
100
  }, async ({ site_id }) => {
86
- const tokenFn = requireSessionForLive(ctx);
101
+ const tokenFn = liveTokenFor(ctx);
87
102
  const wsBase = ctx.base.replace(/^http/, 'ws').replace(/\/$/, '');
88
103
  const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(site_id)}`, tokenFn);
89
104
  const live = new LiveSession(socket, {