sbuilder-mcp 0.76.3 → 0.77.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.
@@ -4,7 +4,8 @@ import { bandOf } from './traps.js';
4
4
  import { HOVER_STATE, hoverHome } from './hover.js';
5
5
  import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
6
6
  import { fill } from './findings.js';
7
- import { deadNavigation } from './navhref.js';
7
+ import { deadNavigation, liveEventTable, PAYLOAD_NEEDS } from './navhref.js';
8
+ import { unknownValueNote, unknownWriteNote, vocabularyFor, vocabularyForWrite } from './vocabulary.js';
8
9
  /**
9
10
  * A defect somebody looking at the page would see.
10
11
  *
@@ -34,7 +35,8 @@ export const REVIEW_NOTICE = 'FIX THESE. Each one is a defect a visitor will see
34
35
  'finding names, then review again until the list is empty. Do not report the page as done ' +
35
36
  'while findings stand; if you believe one is a false positive, say which and why. ' +
36
37
  'A finding marked overlay:true is in the cart drawer or a pop-up — fix it the same way ' +
37
- '(sb_set lands there), but it is SITE-WIDE, so fix it once rather than once per page.';
38
+ '(sb_set lands there), but it is SITE-WIDE, so fix it once rather than once per page. ' +
39
+ '`advice` lists what works but is hard to edit — never a blocker.';
38
40
  /** The specials keys an element seeds that hold its visible content. */
39
41
  function contentKeys(type) {
40
42
  const seeded = ELEMENTS[type]?.defaults.specials ?? {};
@@ -126,6 +128,72 @@ const LINK_SPECIALS = new Set(['boundHref', 'boundHrefLabel', 'boundProductURL']
126
128
  function drawsItsOwnContent(type) {
127
129
  return (BOUND_SPECIALS[type] ?? []).some((k) => !LINK_SPECIALS.has(k));
128
130
  }
131
+ /** Binding-source prefixes that name a RECORD (not `site.*`, which every page has). */
132
+ const RECORD_ENTITIES = new Set(['product', 'category', 'course', 'article', 'blogCategory']);
133
+ /**
134
+ * Which records a page TYPE supplies outside any repeater — read off the
135
+ * platform's own seeds (STORE_PAGE_SEEDS bind exactly these at top level).
136
+ */
137
+ const PAGE_ENTITIES = {
138
+ product: ['product'],
139
+ category: ['category'],
140
+ course: ['course'],
141
+ post: ['article'],
142
+ blog: ['blogCategory', 'category'],
143
+ };
144
+ /** Does this node or an ancestor PIN a record (`bindings[0].target.id`)? */
145
+ function pinnedAbove(d, id) {
146
+ for (let x = id; x && d.nodes[x]; x = d.nodes[x].data.parent) {
147
+ const b0 = d.nodes[x].bindings?.[0];
148
+ if (b0?.target?.id)
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+ /**
154
+ * The native element a custom-code snippet re-implements, or null.
155
+ *
156
+ * Judged on the MARKUP left once every <script>, <style> and comment is
157
+ * stripped — so an analytics tag, a chat widget's mount div, or HTML built
158
+ * inside a script is never flagged — and only when that markup outweighs the
159
+ * script (mostly markup). An iframe counts only for the three hosts a native
160
+ * embed exists for; any other iframe is a third-party embed and stays.
161
+ * ponytail: tag-presence heuristic, not a parser — tighten if it cries wolf.
162
+ */
163
+ function nativeFor(code) {
164
+ const scripts = code.match(/<script\b[\s\S]*?(<\/script>|$)/gi) ?? [];
165
+ const markup = code
166
+ .replace(/<script\b[\s\S]*?(<\/script>|$)/gi, '')
167
+ .replace(/<style\b[\s\S]*?(<\/style>|$)/gi, '')
168
+ .replace(/<!--[\s\S]*?-->/g, '');
169
+ if (markup.trim().length < scripts.join('').length)
170
+ return null;
171
+ // A form POSTING TO ANOTHER HOST (a Mailchimp / Klaviyo embed) is a
172
+ // third-party integration, not hand-built markup: a native form submits to
173
+ // this platform only, so there is nothing native to swap it for.
174
+ if (/<form\b[^>]*\baction\s*=\s*["']?(https?:)?\/\//i.test(markup))
175
+ return null;
176
+ const iframe = /<iframe\b[^>]*\bsrc\s*=\s*["']?([^"'\s>]+)/i.exec(markup)?.[1] ?? '';
177
+ if (/youtube(-nocookie)?\.com|youtu\.be/i.test(iframe))
178
+ return 'youtube';
179
+ if (/vimeo\.com/i.test(iframe))
180
+ return 'vimeo';
181
+ if (/google\.[a-z.]+\/maps|maps\.google\./i.test(iframe))
182
+ return 'google-map';
183
+ if (/<form\b/i.test(markup))
184
+ return 'form';
185
+ if (/<(nav|ul|ol)\b/i.test(markup) && (markup.match(/<a\b/gi) ?? []).length >= 2)
186
+ return 'menu';
187
+ if (/<h[1-6]\b/i.test(markup))
188
+ return 'heading';
189
+ if (/<video\b/i.test(markup))
190
+ return 'video';
191
+ if (/<img\b/i.test(markup))
192
+ return 'image';
193
+ if (/<button\b/i.test(markup))
194
+ return 'button';
195
+ return null;
196
+ }
129
197
  /**
130
198
  * Everything wrong with this page that a person would notice.
131
199
  *
@@ -322,6 +390,113 @@ opts = {}) {
322
390
  ...(inOverlay.has(id) ? { overlay: true } : {}),
323
391
  });
324
392
  }
393
+ // AN EVENT THE ELEMENT CANNOT FIRE, OR ONE WITH NOWHERE TO GO. `sb_event`
394
+ // refuses both at write time; a document gets here by other roads (an
395
+ // import, a template, a hand-built sb_api_call). Same table setEvent reads
396
+ // (`liveEventTable` in tools/live.ts): bindingEvents once a purchase binds.
397
+ const events = n.events ?? [];
398
+ if (events.length) {
399
+ const table = liveEventTable(d.nodes, id);
400
+ for (const ev of events) {
401
+ const trigger = String(ev?.name ?? '');
402
+ const action = String(ev?.action ?? '');
403
+ if (!table?.[trigger]?.includes(action)) {
404
+ out.push({
405
+ code: 'invalid_action',
406
+ nodeId: id,
407
+ type,
408
+ problem: `"${action}" on ${trigger || '(no trigger)'} is not an action a ${type} offers` +
409
+ (table?.[trigger] ? ` there (${table[trigger].join(', ')})` : '') +
410
+ ' — it is stored and never fires.',
411
+ key: trigger,
412
+ fix: fill('invalid_action', { id, key: trigger }),
413
+ });
414
+ continue;
415
+ }
416
+ // The same table `sb_event` refuses by — one list, so the two cannot drift.
417
+ const need = PAYLOAD_NEEDS[action]?.key;
418
+ const v = (ev.payload ?? {})[need ?? ''];
419
+ if (need && (typeof v !== 'string' || v.trim() === '')) {
420
+ out.push({
421
+ code: 'action_missing_target',
422
+ nodeId: id,
423
+ type,
424
+ problem: `Its ${trigger} "${action}" carries no payload.${need}, so the click does nothing.`,
425
+ key: action,
426
+ fix: fill('action_missing_target', { id, key: action }),
427
+ });
428
+ }
429
+ }
430
+ }
431
+ // A RECORD-BOUND ELEMENT WITH NO RECORD. Inside a repeater the row supplies
432
+ // one; on an entity page (product, category, post…) the page does; a pin
433
+ // (`bindings[0].target.id`, render/html.go pinnedEntityID) does for its
434
+ // subtree. Anywhere else the binding resolves to nothing and the element
435
+ // renders its seeded placeholder for ever — saved, published, no warning.
436
+ // Overlays are skipped: one drawer serves every page type.
437
+ if (opts.pageType !== undefined && !inRepeater.get(id) && !inOverlay.has(id)) {
438
+ const entities = PAGE_ENTITIES[opts.pageType] ?? [];
439
+ const missing = (n.bindings ?? [])
440
+ .map((b) => String(b?.source ?? '').split('.')[0])
441
+ .find((e) => RECORD_ENTITIES.has(e) && !entities.includes(e));
442
+ if (missing && !pinnedAbove(d, id)) {
443
+ out.push({
444
+ code: 'no_data_context',
445
+ nodeId: id,
446
+ type,
447
+ problem: `Bound to a ${missing} record, but it sits in no repeater and a "${opts.pageType}" ` +
448
+ 'page supplies none — it renders its placeholder on the published page.',
449
+ key: missing,
450
+ fix: fill('no_data_context', { id, key: missing }),
451
+ });
452
+ }
453
+ }
454
+ // A STORED VALUE NO RENDERER READS. The same vocabulary `sb_set` warns from
455
+ // (`unknownWriteNote` / `unknownValueNote`), asked of what the document
456
+ // already holds — base and every breakpoint — because an import or a write
457
+ // made before the warning existed is still publishing the fallback.
458
+ {
459
+ const layered = n;
460
+ const layers = [
461
+ ['config', layered.config],
462
+ ['specials', n.specials],
463
+ ...Object.values(layered.responsive ?? {}).flatMap((r) => [['config', r?.config], ['specials', r?.specials]]),
464
+ ];
465
+ const said = new Set();
466
+ for (const [ns, slot] of layers) {
467
+ for (const [k, v] of Object.entries(slot ?? {})) {
468
+ const unread = unknownWriteNote(type, ns, k, v) ?? (ns === 'config' ? unknownValueNote(k, v) : null);
469
+ if (!unread || said.has(`${ns}.${k}=${String(v)}`))
470
+ continue;
471
+ said.add(`${ns}.${k}=${String(v)}`);
472
+ const legal = (vocabularyForWrite(type, ns, k) ?? (ns === 'config' ? vocabularyFor(k) : null))?.values ?? [];
473
+ out.push({
474
+ code: 'unread_value',
475
+ nodeId: id,
476
+ type,
477
+ problem: `${ns}.${k} = ${JSON.stringify(v)} is not a value the renderer reads; it publishes ` +
478
+ `as its fallback with no error. Legal: ${legal.map((x) => x || '""').join(', ')}.`,
479
+ key: `${ns}.${k}`,
480
+ fix: fill('unread_value', { id, type }),
481
+ });
482
+ }
483
+ }
484
+ }
485
+ if (type === 'custom-code') {
486
+ const native = nativeFor(String((n.specials ?? {}).code ?? ''));
487
+ if (native) {
488
+ out.push({
489
+ code: 'custom_code_native',
490
+ nodeId: id,
491
+ type,
492
+ severity: 'maintenance',
493
+ problem: `Hand-written markup a native ${native} covers. It works, but no inspector can edit ` +
494
+ 'it, it ignores the theme, and sb_review cannot see inside it.',
495
+ key: native,
496
+ fix: fill('custom_code_native', { id, key: native }),
497
+ });
498
+ }
499
+ }
325
500
  // PINNING FAILS WITH NOTHING ON SCREEN AND NOTHING IN THE LOG — both halves
326
501
  // of it — which is exactly what a review is for. `sb_set` refuses the second
327
502
  // case at write time, but a document reaches here by other roads: an import,
@@ -0,0 +1,262 @@
1
+ import { BASE_ONLY_CONFIG, BOUND_SPECIALS, CONFIG_VALUES, ELEMENT_VALUES, ELEMENTS, HOVER_HOMES, SATELLITE_RULES, TRAIT_WRITES, WRITE_PRECONDITIONS, } from '../../catalog/elements.generated.js';
2
+ import { FIELD_SKIN_BY_NODE } from '../../catalog/fieldskin.generated.js';
3
+ import { FORM_RULE_VOCAB } from '../../catalog/formrules.generated.js';
4
+ import { animationNote, deadKeyNote, preconditionNotes, unknownValueNote, unknownWriteNote, unsupportedSettingNote, } from './vocabulary.js';
5
+ /**
6
+ * Keys any node may carry, read by shared helpers rather than one element:
7
+ * visibility and reveal (`display` trait), the entrance animation, the pinned
8
+ * state's trigger, the style preset, the parent-hover host, custom class/CSS,
9
+ * the projected link, and the two STORED references (the composed stamps are
10
+ * refused by their own guards, so they are not listed).
11
+ */
12
+ const UNIVERSAL = {
13
+ config: ['hidden', 'revealOnHover', 'animation', 'stuckAfter'],
14
+ specials: ['stylePreset', 'hoverHostDepth', 'className', 'customCss', 'href', 'target', 'globalRef', 'appBlockRef', 'appBlockValues'],
15
+ };
16
+ /**
17
+ * Inspector rows that are a WIDGET rather than a trait, so `TRAIT_WRITES` does
18
+ * not describe what they write — the key sits in the widget's props
19
+ * (`editor/src/trait/widgets.ts`: `specialKey: 'icon'` on `button_icon`) or in
20
+ * the component it mounts (`text_global_style` → `StylePresetRow`, which
21
+ * stores the picked site text style as `config.textGlobalStyle`, the key
22
+ * `schema/src/theme.ts` names for every text-bearing element). Keyed by the
23
+ * GENERATED control name, so a key is known only where the element offers the row.
24
+ *
25
+ * ponytail: hand-read, and only the rows the platform's own seeds exercise
26
+ * (test/seed-sweep.test.ts). Upgrade: have codegen read every widget's
27
+ * `specialKey`/`configKey` into the catalog — blocked on a catalog regenerate.
28
+ */
29
+ const WIDGET_WRITES = {
30
+ button_icon: ['specials', 'icon'],
31
+ breadcrumb_icon: ['specials', 'icon'],
32
+ divider_icon: ['specials', 'iconName'],
33
+ text_global_style: ['config', 'textGlobalStyle'],
34
+ };
35
+ const known = new Map();
36
+ function keysOf(type) {
37
+ const el = ELEMENTS[type];
38
+ if (!el)
39
+ return null;
40
+ const hit = known.get(type);
41
+ if (hit)
42
+ return hit;
43
+ const out = { config: new Set(UNIVERSAL.config), specials: new Set(UNIVERSAL.specials) };
44
+ const add = (ns, keys) => {
45
+ if (ns !== 'config' && ns !== 'specials')
46
+ return;
47
+ for (const k of keys ?? [])
48
+ out[ns].add(k);
49
+ };
50
+ const d = el.defaults;
51
+ add('config', Object.keys(d.config ?? {}));
52
+ add('specials', Object.keys(d.specials ?? {}));
53
+ for (const layer of [d.responsive, d.states]) {
54
+ for (const slot of Object.values(layer ?? {})) {
55
+ for (const ns of ['config', 'specials'])
56
+ add(ns, Object.keys((slot?.[ns] ?? {})));
57
+ }
58
+ }
59
+ for (const c of el.controls)
60
+ for (const w of TRAIT_WRITES[c]?.writes ?? [])
61
+ add(w.target, [w.writeKey]);
62
+ for (const c of el.controls)
63
+ if (WIDGET_WRITES[c])
64
+ add(WIDGET_WRITES[c][0], [WIDGET_WRITES[c][1]]);
65
+ // A control the trait registry does not describe is often an inspector row
66
+ // named after the key it writes (`{ key: 'quickviewId', visible: false }` on
67
+ // list-dataset), so its NAME counts as a key in either namespace.
68
+ add('config', el.controls);
69
+ add('specials', el.controls);
70
+ for (const v of [...Object.values(ELEMENT_VALUES[type] ?? {}), ...Object.values(ELEMENT_VALUES['*'] ?? {})]) {
71
+ add(v.target, [v.writeKey]);
72
+ }
73
+ add('config', Object.keys(CONFIG_VALUES));
74
+ add('config', BASE_ONLY_CONFIG);
75
+ add('config', (SATELLITE_RULES[type] ?? []).map((r) => r.configKey));
76
+ add('config', FIELD_SKIN_BY_NODE[type]);
77
+ if (HOVER_HOMES[type]?.home === 'legacy')
78
+ add('config', ['stateHover']);
79
+ add('specials', BOUND_SPECIALS[type]);
80
+ // The binding fields the element is BORN with name specials keys too
81
+ // (`boundProductId` is one no html.go reads, and it is still correct).
82
+ for (const b of bindingFields(type))
83
+ add('specials', [b]);
84
+ for (const p of WRITE_PRECONDITIONS) {
85
+ if (p.types.includes(type))
86
+ add('specials', [p.key, ...p.requires.map((r) => r.key)]);
87
+ }
88
+ known.set(type, out);
89
+ return out;
90
+ }
91
+ /** Every `specials.<key>` this element's generated bindings write, key only. */
92
+ export function bindingFields(type) {
93
+ const el = ELEMENTS[type];
94
+ const rows = [...(el?.defaults.bindings ?? []), ...Object.values(el?.bindingsFor ?? {}).flat()];
95
+ return [...new Set(rows.map((b) => b.field ?? '').filter((f) => f.startsWith('specials.')).map((f) => f.slice(9)))];
96
+ }
97
+ /**
98
+ * The `specials.<key>` fields `sb_bind` accepts on this element, and the list
99
+ * `sb_traits_for` reports as `bindable` — one function so the two cannot drift.
100
+ * What the renderer paints (`BOUND_SPECIALS`) plus the element's own generated
101
+ * binding fields (`boundProductId` is read by the runtime, not html.go). Empty
102
+ * when the catalog knows no bound special for the type: `sb_bind` then does not
103
+ * restrict the field.
104
+ */
105
+ export function bindableFields(type) {
106
+ const painted = BOUND_SPECIALS[type] ?? [];
107
+ return painted.length ? [...new Set([...painted, ...bindingFields(type)])] : [];
108
+ }
109
+ /**
110
+ * Check one write. `specials` is the node's specials AS THEY WILL BE after the
111
+ * write, for the precondition question (a pair is legal or not together).
112
+ */
113
+ export function writeCheck(type, namespace, keys, opts = {}) {
114
+ const out = [];
115
+ const push = (once, code, key, problem, fix) => out.push({ once, note: { code, ...(opts.id ? { id: opts.id } : {}), key: `${namespace}.${key}`, problem, fix } });
116
+ if (namespace !== 'config' && namespace !== 'specials')
117
+ return out;
118
+ const ks = keysOf(type);
119
+ for (const [k, v] of Object.entries(keys)) {
120
+ const dead = deadKeyNote(namespace, k);
121
+ if (dead) {
122
+ push(`dead-key:${namespace}.${k}`, 'dead_key', k, dead, 'Drop the key — no value for it does anything.');
123
+ continue;
124
+ }
125
+ if (ks && !ks[namespace].has(k)) {
126
+ const other = namespace === 'config' ? 'specials' : 'config';
127
+ push(`unknown-key:${type}.${namespace}.${k}`, 'unknown_key', k, `${namespace}.${k} is not a key ${type} is known to read — not in its seeded defaults, its ` +
128
+ 'declared controls or any generated vocabulary. It is stored as written; if nothing reads ' +
129
+ 'it, the write changes nothing on the page.', ks[other].has(k)
130
+ ? `${type} reads ${other}.${k} — write it in namespace "${other}".`
131
+ : `Check the spelling against sb_traits_for "${type}"; a CSS property belongs in namespace ` +
132
+ '"style". Ignore this if the key is newer than this catalog.');
133
+ continue;
134
+ }
135
+ const vn = type ? unknownWriteNote(type, namespace, k, v) : null;
136
+ if (vn) {
137
+ push(`value:${type}.${namespace}.${k}=${JSON.stringify(v)}`, 'unknown_value', k, vn, 'Write one of the listed values.');
138
+ }
139
+ if (namespace === 'config') {
140
+ const cn = k === 'animation' ? animationNote(v) : unknownValueNote(k, v);
141
+ if (cn) {
142
+ push(`config-value:${k}=${JSON.stringify(v)}`, k === 'animation' ? 'animation' : 'unknown_value', k, cn, k === 'animation'
143
+ ? 'Write an object: { active: true, type: "fade_in", … } — sb_traits_for lists the vocabulary.'
144
+ : 'Write one of the listed values.');
145
+ }
146
+ }
147
+ if (namespace === 'specials') {
148
+ const un = unsupportedSettingNote(type, k, v);
149
+ if (un)
150
+ push(`unsupported:${type}.${k}=${JSON.stringify(v)}`, 'unsupported_setting', k, un, `Unset specials.${k}; ${type} never reads it.`);
151
+ }
152
+ }
153
+ if (namespace === 'specials' && type === 'form' && 'formRules' in keys && opts.doc) {
154
+ for (const r of formRuleChecks(opts.doc, keys.formRules)) {
155
+ push(`form-rule:${r.problem}`, 'form_rule', 'formRules', r.problem, r.fix);
156
+ }
157
+ }
158
+ if (namespace === 'specials') {
159
+ for (const n of preconditionNotes(type, opts.specials ?? keys)) {
160
+ const key = n.slice('specials.'.length, n.indexOf(' ='));
161
+ push(`precondition:${type}:${n.slice(0, 60)}`, 'precondition', key, n, 'Set the neighbouring keys named above, or unset this one.');
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+ /**
167
+ * `specials.formRules` on a form root, judged against the open form document.
168
+ *
169
+ * EVERY READER DROPS A BAD RULE IN SILENCE — `readFormRules` (schema) and
170
+ * `ReadFormRules` (server/internal/forms/rules.go) skip an entry with no id or
171
+ * an unknown join/op/action, one at a time — so the save succeeds and the field
172
+ * it governs never changes. A rule is keyed by each field's POSTED NAME,
173
+ * `specials.name` trimmed, falling back to the node id only when there is none
174
+ * (the editor's `ruleNameOf`, DeriveSchema's rule) — so a named field's node id
175
+ * is NOT a key. Warnings only: the platform stores the document either way.
176
+ */
177
+ export function formRuleChecks(doc, raw) {
178
+ const out = [];
179
+ const add = (problem, fix) => out.push({ problem, fix });
180
+ let list = raw;
181
+ if (typeof raw === 'string') {
182
+ if (!raw.trim())
183
+ return out;
184
+ try {
185
+ list = JSON.parse(raw);
186
+ }
187
+ catch {
188
+ add('specials.formRules is not valid JSON, so the platform reads NO rules at all.', 'Write JSON.stringify(rules) of an array of rules.');
189
+ return out;
190
+ }
191
+ }
192
+ if (!Array.isArray(list)) {
193
+ add('specials.formRules must be a JSON array of rules; anything else is read as no rules.', 'Wrap the rule in [ … ].');
194
+ return out;
195
+ }
196
+ // ponytail: names from every node, not only answering fields — a rule naming
197
+ // a title is missed rather than a real field being flagged.
198
+ const names = new Set(Object.values(doc.nodes)
199
+ .filter((n) => n.id !== doc.root_node_id)
200
+ .map((n) => (typeof n.specials?.name === 'string' && n.specials.name.trim()) || n.id));
201
+ const v = FORM_RULE_VOCAB;
202
+ const dropped = 'and every reader drops the whole rule';
203
+ list.forEach((r, i) => {
204
+ const at = `rule ${i + 1}${r && typeof r.id === 'string' && r.id ? ` (${r.id})` : ''}`;
205
+ if (!r || typeof r !== 'object')
206
+ return add(`${at} is not an object, ${dropped}.`, 'Remove it.');
207
+ if (typeof r.id !== 'string' || !r.id.trim())
208
+ add(`${at} has no id, ${dropped}.`, 'Give it a unique string id, e.g. "rule_1".');
209
+ if (!v.joins.includes(r.join))
210
+ add(`${at} has join ${JSON.stringify(r.join)}, ${dropped}.`, `Use one of: ${v.joins.join(', ')}.`);
211
+ const conds = Array.isArray(r.conditions) ? r.conditions : null;
212
+ const targets = Array.isArray(r.targets) ? r.targets : null;
213
+ if (!conds || !targets)
214
+ return add(`${at} needs conditions[] and targets[] arrays, ${dropped}.`, 'Add both arrays.');
215
+ if (conds.length === 0)
216
+ add(`${at} has no conditions, so it never fires.`, 'Add at least one { field, op, value }.');
217
+ const fieldNote = (f, role) => {
218
+ if (typeof f !== 'string' || !names.has(f)) {
219
+ add(`${at} ${role} ${JSON.stringify(f)}, which names no field in this form — a rule is keyed by a field's specials.name (its node id only when it has none).`, `Use one of: ${[...names].join(', ')}.`);
220
+ }
221
+ };
222
+ for (const c of conds) {
223
+ if (!v.ops.includes(c?.op))
224
+ add(`${at} has op ${JSON.stringify(c?.op)}, ${dropped}.`, `Use one of: ${v.ops.join(', ')}.`);
225
+ if (typeof c?.value !== 'string')
226
+ add(`${at} has a non-string condition value, ${dropped}.`, 'Write value as a string ("" for filled/empty).');
227
+ fieldNote(c?.field, 'conditions on');
228
+ }
229
+ const sources = new Set(conds.map((c) => c?.field));
230
+ for (const t of targets) {
231
+ if (!v.actions.includes(t?.action))
232
+ add(`${at} has action ${JSON.stringify(t?.action)}, ${dropped}.`, `Use one of: ${v.actions.join(', ')}.`);
233
+ fieldNote(t?.field, 'targets');
234
+ if (sources.has(t?.field)) {
235
+ add(`${at} targets ${JSON.stringify(t?.field)}, which it also conditions on — hiding a field blanks its answer, so the rule flips itself.`, 'Target a different field.');
236
+ }
237
+ }
238
+ });
239
+ return out;
240
+ }
241
+ /** The same, for every node of a nested `sb_add` spec, as each will be born. */
242
+ export function specCheck(spec) {
243
+ const out = [];
244
+ const walk = (s) => {
245
+ const born = ELEMENTS[s.type]?.defaults.specials ?? {};
246
+ if (s.config)
247
+ out.push(...writeCheck(s.type, 'config', s.config));
248
+ if (s.specials)
249
+ out.push(...writeCheck(s.type, 'specials', s.specials, { specials: { ...born, ...s.specials } }));
250
+ for (const c of s.children ?? [])
251
+ walk(c);
252
+ };
253
+ walk(spec);
254
+ return out;
255
+ }
256
+ /** The notes `say` has not said yet, deduped within the call too. */
257
+ export function sayChecks(checked, say) {
258
+ const seen = new Set();
259
+ return checked
260
+ .filter((c) => !seen.has(c.once) && (seen.add(c.once), say(c.once, c.note.problem) !== undefined))
261
+ .map((c) => c.note);
262
+ }
@@ -15,6 +15,14 @@ export class Notices {
15
15
  this.said.add(key);
16
16
  return body;
17
17
  }
18
+ /**
19
+ * What `once` WOULD return, without spending it. A dry run uses this: it
20
+ * must show the same warnings as the real call, and the real call must
21
+ * still have them to show.
22
+ */
23
+ peek(key, body) {
24
+ return this.said.has(key) ? undefined : body;
25
+ }
18
26
  reset() {
19
27
  this.said.clear();
20
28
  }
package/dist/server.js CHANGED
@@ -22,13 +22,13 @@ import { registerThemeTools } from './tools/theme.js';
22
22
  */
23
23
  const INSTRUCTIONS = `Store Builder. Call sb_connect first.
24
24
 
25
- API: sb_api_find query → one line per operation (${SWAGGER_SOURCE.operations} reachable); sb_api_find id → its call sheet; sb_api_call runs it. /api/v1 paths take the API key, every other path the session; the platform refuses each on the other's surface. If the sheet says the body is undescribed, read the matching GET and send back a modified copy.
25
+ API: sb_api_find query → one line per operation (${SWAGGER_SOURCE.operations} reachable); sb_api_find id → its call sheet; sb_api_call runs it. /api/v1 paths take the API key, every other path the session; each is refused on the other's surface. Undescribed body: send back a modified copy of the matching GET.
26
26
 
27
- Design: sb_page_open → sb_catalog_search (${ELEMENT_SOURCE.count} elements) → sb_traits_for the one you chose → sb_add with a NESTED spec (one call per section) → sb_set → sb_look → sb_review.
27
+ Design, native first: sb_page_open → sb_catalog_search (${ELEMENT_SOURCE.count} elements) → sb_traits_for → sb_add one NESTED native spec per section → sb_bind/sb_event → sb_look + sb_review. custom-code only for a gap you can name that no element covers.
28
28
  - Writes and API calls default to dry_run:true; pass dry_run:false to act.
29
- - sb_set writes per breakpoint by default; base is the cascade's fallback layer, fine for values that should not vary.
30
- - Outline flags: global = shared master, an edit lands on every page; overlay = not this page; app = an app block, not editable inside.
31
- - sb_live_join shows edits live in an open editor (needs SB_EMAIL/SB_PASSWORD). sb_bind puts real store data in the page.`;
29
+ - sb_set writes per breakpoint by default; base is the cascade's fallback layer, for values that should not vary.
30
+ - Outline flags: global = shared master, an edit lands on every page; overlay = not this page; app = app block, not editable inside.
31
+ - sb_live_join shows edits live in an open editor (needs SB_EMAIL/SB_PASSWORD).`;
32
32
  /**
33
33
  * The published version, read from package.json at runtime so serverInfo never
34
34
  * drifts from what npm shipped. package.json sits one level above both dist/ and
@@ -73,6 +73,6 @@ export function createServer(ctx = buildContext()) {
73
73
  registerStoreTools(server, ctx, pageSession);
74
74
  registerThemeTools(server, ctx);
75
75
  registerImportTools(server, ctx, pageSession);
76
- registerUndoTools(server, ctx);
76
+ registerUndoTools(server, ctx, pageSession);
77
77
  return server;
78
78
  }
@@ -519,7 +519,7 @@ export function registerImportTools(server, ctx, session) {
519
519
  // per page would give the first page element defaults and every later page
520
520
  // the defaults of the blank page before it — rule 0 failing on every page
521
521
  // at once. The open page if there is one, the site's home page otherwise.
522
- let tokenDoc = session.peek();
522
+ let tokenDoc = session.page() ? session.peek() : null;
523
523
  if (!tokenDoc && home && typeof home.id === 'string') {
524
524
  try {
525
525
  tokenDoc = PageDoc.from((await loadSource(ctx, siteId, home.id)).document);
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import { z } from 'zod';
3
3
  import { text, images } from '../mcp/response.js';
4
4
  import { BINDING_SOURCES, ELEMENTS } from '../catalog/elements.generated.js';
5
+ import { bindableFields } from '../domains/site/writecheck.js';
5
6
  import { previewUrl } from '../vision/preview.js';
6
7
  import { uploadMedia } from '../transport/media.js';
7
8
  import { request } from '../transport/http.js';
@@ -26,7 +27,7 @@ import { RealtimeSocket } from '../transport/socket.js';
26
27
  import { LiveSession } from '../live/session.js';
27
28
  import { refuseAppBlockInterior } from '../domains/site/builder.js';
28
29
  import { soft } from '../domains/site/guard.js';
29
- import { hrefPatches, hasPurchaseBinding, PRODUCT_ACTION_BINDING_ID, } from '../domains/site/navhref.js';
30
+ import { hrefPatches, hasPurchaseBinding, liveEventTable, PAYLOAD_NEEDS, PRODUCT_ACTION_BINDING_ID, } from '../domains/site/navhref.js';
30
31
  import { offscreenNodes, offscreenRootOf } from '../domains/site/offscreen.js';
31
32
  import { siteToken } from './credentialpick.js';
32
33
  import { searchStock, SearchUnavailable, NO_SEARCH_NEXT } from '../transport/stock.js';
@@ -70,6 +71,24 @@ export function bindNode(doc, id, source, field, action, guard) {
70
71
  throw new Error(`sbuilder: a binding field must be "specials.<key>", not "${field}". The renderer ignores ` +
71
72
  'every other namespace, so the binding would be stored and never applied.');
72
73
  }
74
+ // A FIELD THE ELEMENT NEVER PAINTS. `BOUND_SPECIALS` is read off each
75
+ // element's html.go, so a `specials.boundImage` binding on a text-dataset is
76
+ // stored, saved, published and rendered as placeholder text forever. Soft:
77
+ // the list is the catalog's copy of the renderer. The element's own generated
78
+ // binding fields count too — `boundProductId` is read by the runtime, not
79
+ // html.go, and every default product binding carries it.
80
+ const type = node.data?.type ?? '';
81
+ const key = field.slice(dot + 1);
82
+ const allowed = bindableFields(type);
83
+ if (action === undefined && allowed.length) {
84
+ if (!allowed.includes(key)) {
85
+ soft(guard, () => {
86
+ throw new Error(`sbuilder: a ${type} renders a binding only into ${allowed.map((k) => `specials.${k}`).join(', ')} ` +
87
+ `— "${field}" would be stored and never painted. Allowed fields: ${allowed.join(', ')}. ` +
88
+ 'Bind one of those, or swap to an element that shows this value (sb_catalog_search).');
89
+ });
90
+ }
91
+ }
73
92
  // A PURCHASE BINDING, which is what makes a button add to the cart.
74
93
  //
75
94
  // It is not an ordinary binding and cannot be written as one: the renderer
@@ -109,23 +128,6 @@ export function bindNode(doc, id, source, field, action, guard) {
109
128
  },
110
129
  ];
111
130
  }
112
- /**
113
- * The click-action allow-list that is LIVE for this node.
114
- *
115
- * `activeEvents` in the platform, whose whole rule is one line in
116
- * `ActionTrait.vue`: `return action ? def.binding_events : def.events`. A
117
- * purchase control is a different kind of control — an unbound button navigates,
118
- * a bound one hands off to the cart or the checkout — and the two sets are
119
- * mutually exclusive, because "add this product, then go to an arbitrary URL" is
120
- * not a thing the cart runtime can express.
121
- */
122
- function liveEventTable(type, node) {
123
- const meta = ELEMENTS[type];
124
- if (!meta?.events)
125
- return undefined;
126
- const bound = (node.bindings ?? []).some((b) => b?.id === PRODUCT_ACTION_BINDING_ID);
127
- return (bound && meta.bindingEvents) || meta.events;
128
- }
129
131
  /**
130
132
  * Put a click action on a node, or take one off.
131
133
  *
@@ -172,7 +174,7 @@ export function setEvent(doc, id, trigger, action, payload, guard) {
172
174
  ...project(next),
173
175
  ];
174
176
  }
175
- const table = liveEventTable(node.data.type, node);
177
+ const table = liveEventTable(doc.doc.nodes, id);
176
178
  if (!table) {
177
179
  throw new Error(`sbuilder: a ${node.data.type} declares no click actions, so an event on it would be ` +
178
180
  'stored and never fired. Elements that do: ' +
@@ -192,6 +194,21 @@ export function setEvent(doc, id, trigger, action, payload, guard) {
192
194
  : '') +
193
195
  `Allowed: ${allowed.join(', ')}.`);
194
196
  }
197
+ // A NAVIGATION WITH NOWHERE TO GO. The renderer turns a sole go_to_url /
198
+ // open_page click into `<a href>` from `payload.url` ALONE (navhref.ts) — an
199
+ // `open_page` with only a page id projects nothing, since neither renderer
200
+ // can resolve one — and a `popup` with no `payload.id` publishes a call with
201
+ // no target that opens nothing. All three store, save and publish clean.
202
+ const need = PAYLOAD_NEEDS[action];
203
+ if (need) {
204
+ const v = (payload ?? {})[need.key];
205
+ if (typeof v !== 'string' || v === '') {
206
+ soft(guard, () => {
207
+ throw new Error(`sbuilder: "${action}" needs payload.${need.key} — without it the click ${need.why}. ` +
208
+ `Expected payload: ${need.shape}.`);
209
+ });
210
+ }
211
+ }
195
212
  const value = { id: `ev_${action}`, name: trigger, action, payload: payload ?? {} };
196
213
  if (at >= 0) {
197
214
  const next = events.map((e, i) => (i === at ? value : e));
@@ -258,7 +275,7 @@ export function joinRoom(ctx, session, siteId) {
258
275
  const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(siteId)}`, tokenFn);
259
276
  const live = new LiveSession(socket, {
260
277
  onRemote: (patches) => session.applyRemote(patches),
261
- onDesync: (reason) => session.markStale(reason),
278
+ onDesync: (reason) => session.roomDesync(reason),
262
279
  onSource: (pageId, rev, peerId) => session.sourceSaved(pageId, rev, peerId),
263
280
  onMaster: (kind, id, op, rev) => session.masterSaved(kind, id, op, rev),
264
281
  });
@@ -366,7 +383,7 @@ export function registerLiveTools(server, ctx, session) {
366
383
  // and judging its geometry produced thirteen false off-canvas findings
367
384
  // per page on a site whose pages were correct.
368
385
  const skip = offscreenNodes(doc);
369
- const visual = node_id ? [] : measure(shots, skip);
386
+ const visual = node_id ? [] : measure(shots, skip, doc);
370
387
  const layout = compactFindings(visual);
371
388
  const layoutNotice = visual.length > 0 ? ctx.notices.once('measure', MEASURE_NOTICE) : undefined;
372
389
  // The legend rides with the first look only; the shape does not change after.
@@ -661,8 +678,10 @@ export function registerLiveTools(server, ctx, session) {
661
678
  const guard = { force, forced: [] };
662
679
  const patches = setEvent(d, id, trigger ?? 'click', action, payload, guard);
663
680
  const forced = guard.forced.length ? { forced: guard.forced } : {};
664
- if (dry_run !== false)
665
- return text({ dry_run: true, patches, ...forced });
681
+ if (dry_run !== false) {
682
+ const refuse = session.wouldRefuse(patches);
683
+ return text({ dry_run: true, patches, ...(refuse ? { would_refuse: refuse } : {}), ...forced });
684
+ }
666
685
  await session.applyAndSave(patches);
667
686
  return text({ node: id, trigger: trigger ?? 'click', action, rev: d.rev, ...forced });
668
687
  });
@@ -692,8 +711,10 @@ export function registerLiveTools(server, ctx, session) {
692
711
  const guard = { force, forced: [] };
693
712
  const patches = bindNode(d, id, source, field, action, guard);
694
713
  const forced = guard.forced.length ? { forced: guard.forced } : {};
695
- if (dry_run !== false)
696
- return text({ dry_run: true, patches, ...forced });
714
+ if (dry_run !== false) {
715
+ const refuse = session.wouldRefuse(patches);
716
+ return text({ dry_run: true, patches, ...(refuse ? { would_refuse: refuse } : {}), ...forced });
717
+ }
697
718
  await session.applyAndSave(patches);
698
719
  return text({ bound: id, source, field, ...(action ? { action } : {}), rev: d.rev, ...forced });
699
720
  });