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