sbuilder-mcp 0.76.4 → 0.78.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,9 @@ 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 { formFieldFindings } from './formdoc.js';
8
+ import { deadNavigation, liveEventTable, PAYLOAD_NEEDS } from './navhref.js';
9
+ import { unknownValueNote, unknownWriteNote, vocabularyFor, vocabularyForWrite } from './vocabulary.js';
8
10
  /**
9
11
  * A defect somebody looking at the page would see.
10
12
  *
@@ -34,7 +36,8 @@ export const REVIEW_NOTICE = 'FIX THESE. Each one is a defect a visitor will see
34
36
  'finding names, then review again until the list is empty. Do not report the page as done ' +
35
37
  'while findings stand; if you believe one is a false positive, say which and why. ' +
36
38
  '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.';
39
+ '(sb_set lands there), but it is SITE-WIDE, so fix it once rather than once per page. ' +
40
+ '`advice` lists what works but is hard to edit — never a blocker.';
38
41
  /** The specials keys an element seeds that hold its visible content. */
39
42
  function contentKeys(type) {
40
43
  const seeded = ELEMENTS[type]?.defaults.specials ?? {};
@@ -126,6 +129,72 @@ const LINK_SPECIALS = new Set(['boundHref', 'boundHrefLabel', 'boundProductURL']
126
129
  function drawsItsOwnContent(type) {
127
130
  return (BOUND_SPECIALS[type] ?? []).some((k) => !LINK_SPECIALS.has(k));
128
131
  }
132
+ /** Binding-source prefixes that name a RECORD (not `site.*`, which every page has). */
133
+ const RECORD_ENTITIES = new Set(['product', 'category', 'course', 'article', 'blogCategory']);
134
+ /**
135
+ * Which records a page TYPE supplies outside any repeater — read off the
136
+ * platform's own seeds (STORE_PAGE_SEEDS bind exactly these at top level).
137
+ */
138
+ const PAGE_ENTITIES = {
139
+ product: ['product'],
140
+ category: ['category'],
141
+ course: ['course'],
142
+ post: ['article'],
143
+ blog: ['blogCategory', 'category'],
144
+ };
145
+ /** Does this node or an ancestor PIN a record (`bindings[0].target.id`)? */
146
+ function pinnedAbove(d, id) {
147
+ for (let x = id; x && d.nodes[x]; x = d.nodes[x].data.parent) {
148
+ const b0 = d.nodes[x].bindings?.[0];
149
+ if (b0?.target?.id)
150
+ return true;
151
+ }
152
+ return false;
153
+ }
154
+ /**
155
+ * The native element a custom-code snippet re-implements, or null.
156
+ *
157
+ * Judged on the MARKUP left once every <script>, <style> and comment is
158
+ * stripped — so an analytics tag, a chat widget's mount div, or HTML built
159
+ * inside a script is never flagged — and only when that markup outweighs the
160
+ * script (mostly markup). An iframe counts only for the three hosts a native
161
+ * embed exists for; any other iframe is a third-party embed and stays.
162
+ * ponytail: tag-presence heuristic, not a parser — tighten if it cries wolf.
163
+ */
164
+ function nativeFor(code) {
165
+ const scripts = code.match(/<script\b[\s\S]*?(<\/script>|$)/gi) ?? [];
166
+ const markup = code
167
+ .replace(/<script\b[\s\S]*?(<\/script>|$)/gi, '')
168
+ .replace(/<style\b[\s\S]*?(<\/style>|$)/gi, '')
169
+ .replace(/<!--[\s\S]*?-->/g, '');
170
+ if (markup.trim().length < scripts.join('').length)
171
+ return null;
172
+ // A form POSTING TO ANOTHER HOST (a Mailchimp / Klaviyo embed) is a
173
+ // third-party integration, not hand-built markup: a native form submits to
174
+ // this platform only, so there is nothing native to swap it for.
175
+ if (/<form\b[^>]*\baction\s*=\s*["']?(https?:)?\/\//i.test(markup))
176
+ return null;
177
+ const iframe = /<iframe\b[^>]*\bsrc\s*=\s*["']?([^"'\s>]+)/i.exec(markup)?.[1] ?? '';
178
+ if (/youtube(-nocookie)?\.com|youtu\.be/i.test(iframe))
179
+ return 'youtube';
180
+ if (/vimeo\.com/i.test(iframe))
181
+ return 'vimeo';
182
+ if (/google\.[a-z.]+\/maps|maps\.google\./i.test(iframe))
183
+ return 'google-map';
184
+ if (/<form\b/i.test(markup))
185
+ return 'form';
186
+ if (/<(nav|ul|ol)\b/i.test(markup) && (markup.match(/<a\b/gi) ?? []).length >= 2)
187
+ return 'menu';
188
+ if (/<h[1-6]\b/i.test(markup))
189
+ return 'heading';
190
+ if (/<video\b/i.test(markup))
191
+ return 'video';
192
+ if (/<img\b/i.test(markup))
193
+ return 'image';
194
+ if (/<button\b/i.test(markup))
195
+ return 'button';
196
+ return null;
197
+ }
129
198
  /**
130
199
  * Everything wrong with this page that a person would notice.
131
200
  *
@@ -322,6 +391,113 @@ opts = {}) {
322
391
  ...(inOverlay.has(id) ? { overlay: true } : {}),
323
392
  });
324
393
  }
394
+ // AN EVENT THE ELEMENT CANNOT FIRE, OR ONE WITH NOWHERE TO GO. `sb_event`
395
+ // refuses both at write time; a document gets here by other roads (an
396
+ // import, a template, a hand-built sb_api_call). Same table setEvent reads
397
+ // (`liveEventTable` in tools/live.ts): bindingEvents once a purchase binds.
398
+ const events = n.events ?? [];
399
+ if (events.length) {
400
+ const table = liveEventTable(d.nodes, id);
401
+ for (const ev of events) {
402
+ const trigger = String(ev?.name ?? '');
403
+ const action = String(ev?.action ?? '');
404
+ if (!table?.[trigger]?.includes(action)) {
405
+ out.push({
406
+ code: 'invalid_action',
407
+ nodeId: id,
408
+ type,
409
+ problem: `"${action}" on ${trigger || '(no trigger)'} is not an action a ${type} offers` +
410
+ (table?.[trigger] ? ` there (${table[trigger].join(', ')})` : '') +
411
+ ' — it is stored and never fires.',
412
+ key: trigger,
413
+ fix: fill('invalid_action', { id, key: trigger }),
414
+ });
415
+ continue;
416
+ }
417
+ // The same table `sb_event` refuses by — one list, so the two cannot drift.
418
+ const need = PAYLOAD_NEEDS[action]?.key;
419
+ const v = (ev.payload ?? {})[need ?? ''];
420
+ if (need && (typeof v !== 'string' || v.trim() === '')) {
421
+ out.push({
422
+ code: 'action_missing_target',
423
+ nodeId: id,
424
+ type,
425
+ problem: `Its ${trigger} "${action}" carries no payload.${need}, so the click does nothing.`,
426
+ key: action,
427
+ fix: fill('action_missing_target', { id, key: action }),
428
+ });
429
+ }
430
+ }
431
+ }
432
+ // A RECORD-BOUND ELEMENT WITH NO RECORD. Inside a repeater the row supplies
433
+ // one; on an entity page (product, category, post…) the page does; a pin
434
+ // (`bindings[0].target.id`, render/html.go pinnedEntityID) does for its
435
+ // subtree. Anywhere else the binding resolves to nothing and the element
436
+ // renders its seeded placeholder for ever — saved, published, no warning.
437
+ // Overlays are skipped: one drawer serves every page type.
438
+ if (opts.pageType !== undefined && !inRepeater.get(id) && !inOverlay.has(id)) {
439
+ const entities = PAGE_ENTITIES[opts.pageType] ?? [];
440
+ const missing = (n.bindings ?? [])
441
+ .map((b) => String(b?.source ?? '').split('.')[0])
442
+ .find((e) => RECORD_ENTITIES.has(e) && !entities.includes(e));
443
+ if (missing && !pinnedAbove(d, id)) {
444
+ out.push({
445
+ code: 'no_data_context',
446
+ nodeId: id,
447
+ type,
448
+ problem: `Bound to a ${missing} record, but it sits in no repeater and a "${opts.pageType}" ` +
449
+ 'page supplies none — it renders its placeholder on the published page.',
450
+ key: missing,
451
+ fix: fill('no_data_context', { id, key: missing }),
452
+ });
453
+ }
454
+ }
455
+ // A STORED VALUE NO RENDERER READS. The same vocabulary `sb_set` warns from
456
+ // (`unknownWriteNote` / `unknownValueNote`), asked of what the document
457
+ // already holds — base and every breakpoint — because an import or a write
458
+ // made before the warning existed is still publishing the fallback.
459
+ {
460
+ const layered = n;
461
+ const layers = [
462
+ ['config', layered.config],
463
+ ['specials', n.specials],
464
+ ...Object.values(layered.responsive ?? {}).flatMap((r) => [['config', r?.config], ['specials', r?.specials]]),
465
+ ];
466
+ const said = new Set();
467
+ for (const [ns, slot] of layers) {
468
+ for (const [k, v] of Object.entries(slot ?? {})) {
469
+ const unread = unknownWriteNote(type, ns, k, v) ?? (ns === 'config' ? unknownValueNote(k, v) : null);
470
+ if (!unread || said.has(`${ns}.${k}=${String(v)}`))
471
+ continue;
472
+ said.add(`${ns}.${k}=${String(v)}`);
473
+ const legal = (vocabularyForWrite(type, ns, k) ?? (ns === 'config' ? vocabularyFor(k) : null))?.values ?? [];
474
+ out.push({
475
+ code: 'unread_value',
476
+ nodeId: id,
477
+ type,
478
+ problem: `${ns}.${k} = ${JSON.stringify(v)} is not a value the renderer reads; it publishes ` +
479
+ `as its fallback with no error. Legal: ${legal.map((x) => x || '""').join(', ')}.`,
480
+ key: `${ns}.${k}`,
481
+ fix: fill('unread_value', { id, type }),
482
+ });
483
+ }
484
+ }
485
+ }
486
+ if (type === 'custom-code') {
487
+ const native = nativeFor(String((n.specials ?? {}).code ?? ''));
488
+ if (native) {
489
+ out.push({
490
+ code: 'custom_code_native',
491
+ nodeId: id,
492
+ type,
493
+ severity: 'maintenance',
494
+ problem: `Hand-written markup a native ${native} covers. It works, but no inspector can edit ` +
495
+ 'it, it ignores the theme, and sb_review cannot see inside it.',
496
+ key: native,
497
+ fix: fill('custom_code_native', { id, key: native }),
498
+ });
499
+ }
500
+ }
325
501
  // PINNING FAILS WITH NOTHING ON SCREEN AND NOTHING IN THE LOG — both halves
326
502
  // of it — which is exactly what a review is for. `sb_set` refuses the second
327
503
  // case at write time, but a document reaches here by other roads: an import,
@@ -364,10 +540,11 @@ opts = {}) {
364
540
  // read 0 because the cart it was summing has just been emptied. Nothing
365
541
  // errors, and the shopper cannot tell a completed order from a failed one.
366
542
  //
367
- // Checked on the FORM NODE rather than on the form record, because the
368
- // record's own `afterSubmit: "redirect"` is stored by the API and carried
369
- // nowhere (`forms/pagesource.go`) — reading it would report a page as fine
370
- // on the strength of a setting the platform ignores.
543
+ // The FORM NODE's event, or the RECORD's own `afterSubmit: "redirect"` —
544
+ // carried since web_builder fcfde38ac (`forms/pagesource.go` stamps
545
+ // `sentRedirect`; the runtime follows it after the `form:success` chain).
546
+ // A `pageId` target that is unpublished falls back to the message; that is
547
+ // the page's own defect (readiness reports unpublished pages), not this one.
371
548
  if (type === 'form') {
372
549
  const events = n.events ?? [];
373
550
  const navigates = events.some((e) => e.name === 'form:success' &&
@@ -385,7 +562,8 @@ opts = {}) {
385
562
  const sellsFromCart = walkOrder.some((x) => !inOverlay.has(x) && d.nodes[x]?.data.type === 'cart-total');
386
563
  const formType = opts.formTypes?.[String(n.specials?.formId ?? '')];
387
564
  const isOrder = formType ? formType === 'order' : sellsFromCart;
388
- if (!navigates && isOrder && !inOverlay.has(id)) {
565
+ const redirects = opts.formRedirects?.has(String(n.specials?.formId ?? '')) ?? false;
566
+ if (!navigates && !redirects && isOrder && !inOverlay.has(id)) {
389
567
  out.push({
390
568
  code: 'order_goes_nowhere',
391
569
  nodeId: id,
@@ -749,5 +927,8 @@ opts = {}) {
749
927
  });
750
928
  }
751
929
  }
930
+ // A FORM DOCUMENT's fields that store fine and can never be answered.
931
+ for (const f of formFieldFindings(d, opts.formDocType))
932
+ out.push({ ...f, fix: fill(f.code, { id: f.nodeId }) });
752
933
  return out;
753
934
  }
@@ -1,5 +1,6 @@
1
1
  import { subtreeIds, childrenOf, SPEC_GLOBAL_ID, SPEC_OVERLAY_ID, } from '../../core/tree.js';
2
2
  import { checkBandOrder } from './traps.js';
3
+ import { formClashes } from './formdoc.js';
3
4
  /**
4
5
  * Everything that would make the platform refuse this document on save.
5
6
  *
@@ -16,6 +17,8 @@ export function validateForSave(doc) {
16
17
  const band = checkBandOrder(d);
17
18
  if (band)
18
19
  problems.push(band);
20
+ // A FORM document's field-uniqueness rule (DeriveSchema → 409 on save). Empty for a page.
21
+ problems.push(...formClashes(d));
19
22
  // COMPOSITION STAMPS: only a DIRECT child of ROOT may carry one, and never the
20
23
  // same id twice.
21
24
  //
@@ -0,0 +1,309 @@
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 { fill } from './findings.js';
5
+ import { collectsValue, formFieldFindings, formFields, nameFromLabel } from './formdoc.js';
6
+ import { animationNote, deadKeyNote, preconditionNotes, unknownValueNote, unknownWriteNote, unsupportedSettingNote, } from './vocabulary.js';
7
+ /**
8
+ * Keys any node may carry, read by shared helpers rather than one element:
9
+ * visibility and reveal (`display` trait), the entrance animation, the pinned
10
+ * state's trigger, the style preset, the parent-hover host, custom class/CSS,
11
+ * the projected link, and the two STORED references (the composed stamps are
12
+ * refused by their own guards, so they are not listed).
13
+ */
14
+ const UNIVERSAL = {
15
+ config: ['hidden', 'revealOnHover', 'animation', 'stuckAfter'],
16
+ specials: ['stylePreset', 'hoverHostDepth', 'className', 'customCss', 'href', 'target', 'globalRef', 'appBlockRef', 'appBlockValues'],
17
+ };
18
+ /**
19
+ * Inspector rows that are a WIDGET rather than a trait, so `TRAIT_WRITES` does
20
+ * not describe what they write — the key sits in the widget's props
21
+ * (`editor/src/trait/widgets.ts`: `specialKey: 'icon'` on `button_icon`) or in
22
+ * the component it mounts (`text_global_style` → `StylePresetRow`, which
23
+ * stores the picked site text style as `config.textGlobalStyle`, the key
24
+ * `schema/src/theme.ts` names for every text-bearing element). Keyed by the
25
+ * GENERATED control name, so a key is known only where the element offers the row.
26
+ *
27
+ * ponytail: hand-read, and only the rows the platform's own seeds exercise
28
+ * (test/seed-sweep.test.ts). Upgrade: have codegen read every widget's
29
+ * `specialKey`/`configKey` into the catalog — blocked on a catalog regenerate.
30
+ */
31
+ const WIDGET_WRITES = {
32
+ button_icon: ['specials', 'icon'],
33
+ breadcrumb_icon: ['specials', 'icon'],
34
+ divider_icon: ['specials', 'iconName'],
35
+ text_global_style: ['config', 'textGlobalStyle'],
36
+ };
37
+ const known = new Map();
38
+ function keysOf(type) {
39
+ const el = ELEMENTS[type];
40
+ if (!el)
41
+ return null;
42
+ const hit = known.get(type);
43
+ if (hit)
44
+ return hit;
45
+ const out = { config: new Set(UNIVERSAL.config), specials: new Set(UNIVERSAL.specials) };
46
+ const add = (ns, keys) => {
47
+ if (ns !== 'config' && ns !== 'specials')
48
+ return;
49
+ for (const k of keys ?? [])
50
+ out[ns].add(k);
51
+ };
52
+ const d = el.defaults;
53
+ add('config', Object.keys(d.config ?? {}));
54
+ add('specials', Object.keys(d.specials ?? {}));
55
+ for (const layer of [d.responsive, d.states]) {
56
+ for (const slot of Object.values(layer ?? {})) {
57
+ for (const ns of ['config', 'specials'])
58
+ add(ns, Object.keys((slot?.[ns] ?? {})));
59
+ }
60
+ }
61
+ for (const c of el.controls)
62
+ for (const w of TRAIT_WRITES[c]?.writes ?? [])
63
+ add(w.target, [w.writeKey]);
64
+ for (const c of el.controls)
65
+ if (WIDGET_WRITES[c])
66
+ add(WIDGET_WRITES[c][0], [WIDGET_WRITES[c][1]]);
67
+ // A control the trait registry does not describe is often an inspector row
68
+ // named after the key it writes (`{ key: 'quickviewId', visible: false }` on
69
+ // list-dataset), so its NAME counts as a key in either namespace.
70
+ add('config', el.controls);
71
+ add('specials', el.controls);
72
+ for (const v of [...Object.values(ELEMENT_VALUES[type] ?? {}), ...Object.values(ELEMENT_VALUES['*'] ?? {})]) {
73
+ add(v.target, [v.writeKey]);
74
+ }
75
+ add('config', Object.keys(CONFIG_VALUES));
76
+ add('config', BASE_ONLY_CONFIG);
77
+ add('config', (SATELLITE_RULES[type] ?? []).map((r) => r.configKey));
78
+ add('config', FIELD_SKIN_BY_NODE[type]);
79
+ if (HOVER_HOMES[type]?.home === 'legacy')
80
+ add('config', ['stateHover']);
81
+ add('specials', BOUND_SPECIALS[type]);
82
+ // The binding fields the element is BORN with name specials keys too
83
+ // (`boundProductId` is one no html.go reads, and it is still correct).
84
+ for (const b of bindingFields(type))
85
+ add('specials', [b]);
86
+ for (const p of WRITE_PRECONDITIONS) {
87
+ if (p.types.includes(type))
88
+ add('specials', [p.key, ...p.requires.map((r) => r.key)]);
89
+ }
90
+ known.set(type, out);
91
+ return out;
92
+ }
93
+ /** Every `specials.<key>` this element's generated bindings write, key only. */
94
+ export function bindingFields(type) {
95
+ const el = ELEMENTS[type];
96
+ const rows = [...(el?.defaults.bindings ?? []), ...Object.values(el?.bindingsFor ?? {}).flat()];
97
+ return [...new Set(rows.map((b) => b.field ?? '').filter((f) => f.startsWith('specials.')).map((f) => f.slice(9)))];
98
+ }
99
+ /**
100
+ * The `specials.<key>` fields `sb_bind` accepts on this element, and the list
101
+ * `sb_traits_for` reports as `bindable` — one function so the two cannot drift.
102
+ * What the renderer paints (`BOUND_SPECIALS`) plus the element's own generated
103
+ * binding fields (`boundProductId` is read by the runtime, not html.go). Empty
104
+ * when the catalog knows no bound special for the type: `sb_bind` then does not
105
+ * restrict the field.
106
+ */
107
+ export function bindableFields(type) {
108
+ const painted = BOUND_SPECIALS[type] ?? [];
109
+ return painted.length ? [...new Set([...painted, ...bindingFields(type)])] : [];
110
+ }
111
+ /**
112
+ * Check one write. `specials` is the node's specials AS THEY WILL BE after the
113
+ * write, for the precondition question (a pair is legal or not together).
114
+ */
115
+ export function writeCheck(type, namespace, keys, opts = {}) {
116
+ const out = [];
117
+ const push = (once, code, key, problem, fix) => out.push({ once, note: { code, ...(opts.id ? { id: opts.id } : {}), key: `${namespace}.${key}`, problem, fix } });
118
+ if (namespace !== 'config' && namespace !== 'specials')
119
+ return out;
120
+ const ks = keysOf(type);
121
+ for (const [k, v] of Object.entries(keys)) {
122
+ const dead = deadKeyNote(namespace, k);
123
+ if (dead) {
124
+ push(`dead-key:${namespace}.${k}`, 'dead_key', k, dead, 'Drop the key — no value for it does anything.');
125
+ continue;
126
+ }
127
+ if (ks && !ks[namespace].has(k)) {
128
+ const other = namespace === 'config' ? 'specials' : 'config';
129
+ 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 ` +
130
+ 'declared controls or any generated vocabulary. It is stored as written; if nothing reads ' +
131
+ 'it, the write changes nothing on the page.', ks[other].has(k)
132
+ ? `${type} reads ${other}.${k} — write it in namespace "${other}".`
133
+ : `Check the spelling against sb_traits_for "${type}"; a CSS property belongs in namespace ` +
134
+ '"style". Ignore this if the key is newer than this catalog.');
135
+ continue;
136
+ }
137
+ const vn = type ? unknownWriteNote(type, namespace, k, v) : null;
138
+ if (vn) {
139
+ push(`value:${type}.${namespace}.${k}=${JSON.stringify(v)}`, 'unknown_value', k, vn, 'Write one of the listed values.');
140
+ }
141
+ if (namespace === 'config') {
142
+ const cn = k === 'animation' ? animationNote(v) : unknownValueNote(k, v);
143
+ if (cn) {
144
+ push(`config-value:${k}=${JSON.stringify(v)}`, k === 'animation' ? 'animation' : 'unknown_value', k, cn, k === 'animation'
145
+ ? 'Write an object: { active: true, type: "fade_in", … } — sb_traits_for lists the vocabulary.'
146
+ : 'Write one of the listed values.');
147
+ }
148
+ }
149
+ if (namespace === 'specials') {
150
+ const un = unsupportedSettingNote(type, k, v);
151
+ if (un)
152
+ push(`unsupported:${type}.${k}=${JSON.stringify(v)}`, 'unsupported_setting', k, un, `Unset specials.${k}; ${type} never reads it.`);
153
+ }
154
+ }
155
+ if (namespace === 'specials' && type === 'form' && 'formRules' in keys && opts.doc) {
156
+ for (const r of formRuleChecks(opts.doc, keys.formRules)) {
157
+ push(`form-rule:${r.problem}`, 'form_rule', 'formRules', r.problem, r.fix);
158
+ }
159
+ }
160
+ if (namespace === 'specials') {
161
+ for (const n of preconditionNotes(type, opts.specials ?? keys)) {
162
+ const key = n.slice('specials.'.length, n.indexOf(' ='));
163
+ push(`precondition:${type}:${n.slice(0, 60)}`, 'precondition', key, n, 'Set the neighbouring keys named above, or unset this one.');
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+ /**
169
+ * `specials.formRules` on a form root, judged against the open form document.
170
+ *
171
+ * EVERY READER DROPS A BAD RULE IN SILENCE — `readFormRules` (schema) and
172
+ * `ReadFormRules` (server/internal/forms/rules.go) skip an entry with no id or
173
+ * an unknown join/op/action, one at a time — so the save succeeds and the field
174
+ * it governs never changes. A rule is keyed by each field's POSTED NAME,
175
+ * `specials.name` trimmed, falling back to the node id only when there is none
176
+ * (the editor's `ruleNameOf`, DeriveSchema's rule) — so a named field's node id
177
+ * is NOT a key. Warnings only: the platform stores the document either way.
178
+ */
179
+ export function formRuleChecks(doc, raw) {
180
+ const out = [];
181
+ const add = (problem, fix) => out.push({ problem, fix });
182
+ let list = raw;
183
+ if (typeof raw === 'string') {
184
+ if (!raw.trim())
185
+ return out;
186
+ try {
187
+ list = JSON.parse(raw);
188
+ }
189
+ catch {
190
+ add('specials.formRules is not valid JSON, so the platform reads NO rules at all.', 'Write JSON.stringify(rules) of an array of rules.');
191
+ return out;
192
+ }
193
+ }
194
+ if (!Array.isArray(list)) {
195
+ add('specials.formRules must be a JSON array of rules; anything else is read as no rules.', 'Wrap the rule in [ … ].');
196
+ return out;
197
+ }
198
+ // ponytail: names from every node, not only answering fields — a rule naming
199
+ // a title is missed rather than a real field being flagged.
200
+ const names = new Set(Object.values(doc.nodes)
201
+ .filter((n) => n.id !== doc.root_node_id)
202
+ .map((n) => (typeof n.specials?.name === 'string' && n.specials.name.trim()) || n.id));
203
+ const v = FORM_RULE_VOCAB;
204
+ const dropped = 'and every reader drops the whole rule';
205
+ list.forEach((r, i) => {
206
+ const at = `rule ${i + 1}${r && typeof r.id === 'string' && r.id ? ` (${r.id})` : ''}`;
207
+ if (!r || typeof r !== 'object')
208
+ return add(`${at} is not an object, ${dropped}.`, 'Remove it.');
209
+ if (typeof r.id !== 'string' || !r.id.trim())
210
+ add(`${at} has no id, ${dropped}.`, 'Give it a unique string id, e.g. "rule_1".');
211
+ if (!v.joins.includes(r.join))
212
+ add(`${at} has join ${JSON.stringify(r.join)}, ${dropped}.`, `Use one of: ${v.joins.join(', ')}.`);
213
+ const conds = Array.isArray(r.conditions) ? r.conditions : null;
214
+ const targets = Array.isArray(r.targets) ? r.targets : null;
215
+ if (!conds || !targets)
216
+ return add(`${at} needs conditions[] and targets[] arrays, ${dropped}.`, 'Add both arrays.');
217
+ if (conds.length === 0)
218
+ add(`${at} has no conditions, so it never fires.`, 'Add at least one { field, op, value }.');
219
+ const fieldNote = (f, role) => {
220
+ if (typeof f !== 'string' || !names.has(f)) {
221
+ 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(', ')}.`);
222
+ }
223
+ };
224
+ for (const c of conds) {
225
+ if (!v.ops.includes(c?.op))
226
+ add(`${at} has op ${JSON.stringify(c?.op)}, ${dropped}.`, `Use one of: ${v.ops.join(', ')}.`);
227
+ if (typeof c?.value !== 'string')
228
+ add(`${at} has a non-string condition value, ${dropped}.`, 'Write value as a string ("" for filled/empty).');
229
+ fieldNote(c?.field, 'conditions on');
230
+ }
231
+ const sources = new Set(conds.map((c) => c?.field));
232
+ for (const t of targets) {
233
+ if (!v.actions.includes(t?.action))
234
+ add(`${at} has action ${JSON.stringify(t?.action)}, ${dropped}.`, `Use one of: ${v.actions.join(', ')}.`);
235
+ fieldNote(t?.field, 'targets');
236
+ if (sources.has(t?.field)) {
237
+ 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.');
238
+ }
239
+ }
240
+ });
241
+ return out;
242
+ }
243
+ /**
244
+ * A write into an open FORM document, judged on the document it LEAVES: a field
245
+ * it makes unanswerable (no options, a dead time slot, a third date on a
246
+ * booking form), and a field it ADDS with no `specials.name`.
247
+ *
248
+ * The unnamed one is a note, not a defect: the editor leaves a new field's name
249
+ * empty too (FieldNameRow.vue — "the node id is the honest resting value"), and
250
+ * the platform posts the answer under the node id. But that id is what the
251
+ * submissions table, the notification mail and every form rule then show, so
252
+ * an agent that can name the field should. Only what this write introduced is
253
+ * said; a page answers nothing.
254
+ */
255
+ export function formDocChecks(before, after, formType) {
256
+ const out = [];
257
+ const had = new Set(formFieldFindings(before, formType).map((f) => `${f.code}:${f.nodeId}`));
258
+ for (const f of formFieldFindings(after, formType)) {
259
+ if (had.has(`${f.code}:${f.nodeId}`))
260
+ continue;
261
+ out.push({
262
+ once: `form-field:${f.code}:${f.nodeId}`,
263
+ note: { code: 'form_field', id: f.nodeId, key: `specials.${f.key}`, problem: f.problem, fix: fill(f.code, { id: f.nodeId }) },
264
+ });
265
+ }
266
+ for (const n of formFields(after)) {
267
+ if (before.nodes[n.id] || !collectsValue(n.data.type))
268
+ continue;
269
+ const name = n.specials?.name;
270
+ if (typeof name === 'string' && name.trim())
271
+ continue;
272
+ const label = typeof n.specials?.label === 'string' ? nameFromLabel(n.specials.label) : '';
273
+ const suggest = label && label !== 'label' ? label : 'a_short_key';
274
+ out.push({
275
+ once: `form-field:unnamed:${n.id}`,
276
+ note: {
277
+ code: 'form_field',
278
+ id: n.id,
279
+ key: 'specials.name',
280
+ problem: `${n.data.type} ${n.id} has no specials.name, so its answer posts under the node id "${n.id}" — ` +
281
+ 'which is what the submissions table, the notification mail and every form rule then show.',
282
+ fix: `sb_set id "${n.id}", namespace specials, keys { "name": "${suggest}" } — unique within the form.`,
283
+ },
284
+ });
285
+ }
286
+ return out;
287
+ }
288
+ /** The same, for every node of a nested `sb_add` spec, as each will be born. */
289
+ export function specCheck(spec) {
290
+ const out = [];
291
+ const walk = (s) => {
292
+ const born = ELEMENTS[s.type]?.defaults.specials ?? {};
293
+ if (s.config)
294
+ out.push(...writeCheck(s.type, 'config', s.config));
295
+ if (s.specials)
296
+ out.push(...writeCheck(s.type, 'specials', s.specials, { specials: { ...born, ...s.specials } }));
297
+ for (const c of s.children ?? [])
298
+ walk(c);
299
+ };
300
+ walk(spec);
301
+ return out;
302
+ }
303
+ /** The notes `say` has not said yet, deduped within the call too. */
304
+ export function sayChecks(checked, say) {
305
+ const seen = new Set();
306
+ return checked
307
+ .filter((c) => !seen.has(c.once) && (seen.add(c.once), say(c.once, c.note.problem) !== undefined))
308
+ .map((c) => c.note);
309
+ }
@@ -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);