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.
- package/CHANGELOG.md +23 -0
- package/CHANGELOG.vi.md +23 -0
- package/README.md +3 -3
- package/README.vi.md +3 -3
- package/dist/catalog/element-search.js +108 -13
- package/dist/catalog/formrules.generated.js +29 -0
- package/dist/domains/site/findings.js +20 -0
- package/dist/domains/site/importmap.js +53 -0
- package/dist/domains/site/navhref.js +77 -3
- package/dist/domains/site/readiness.js +1 -2
- package/dist/domains/site/review.js +177 -2
- package/dist/domains/site/writecheck.js +262 -0
- package/dist/mcp/notices.js +8 -0
- package/dist/server.js +6 -6
- package/dist/tools/importpage.js +1 -1
- package/dist/tools/live.js +46 -25
- package/dist/tools/overlay.js +2 -2
- package/dist/tools/page.js +330 -132
- package/dist/tools/undo.js +70 -15
- package/dist/transport/http.js +21 -0
- package/dist/transport/pages.js +1 -1
- package/dist/vision/measure.js +86 -4
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/mcp/notices.js
CHANGED
|
@@ -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;
|
|
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
|
|
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,
|
|
30
|
-
- Outline flags: global = shared master, an edit lands on every page; overlay = not this page; app =
|
|
31
|
-
- sb_live_join shows edits live in an open editor (needs SB_EMAIL/SB_PASSWORD)
|
|
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
|
}
|
package/dist/tools/importpage.js
CHANGED
|
@@ -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);
|
package/dist/tools/live.js
CHANGED
|
@@ -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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|