sbuilder-mcp 0.2.2 → 0.3.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 +22 -0
- package/CHANGELOG.vi.md +22 -0
- package/README.md +11 -4
- package/README.vi.md +11 -4
- package/dist/catalog/api.generated.js +1350 -127
- package/dist/catalog/elements.generated.js +345 -2
- package/dist/core/tree.js +9 -0
- package/dist/domains/site/builder.js +5 -9
- package/dist/domains/site/document.js +34 -2
- package/dist/domains/site/node.js +50 -7
- package/dist/domains/site/readiness.js +41 -0
- package/dist/domains/site/review.js +23 -2
- package/dist/domains/site/traps.js +38 -1
- package/dist/install/index.js +40 -0
- package/dist/server.js +7 -1
- package/dist/tools/api.js +15 -1
- package/dist/tools/context.js +18 -1
- package/dist/tools/live.js +169 -15
- package/dist/tools/page.js +29 -17
- package/dist/tools/session.js +35 -10
- package/dist/transport/media.js +26 -5
- package/dist/vision/measure.js +19 -3
- package/dist/vision/shoot.js +39 -0
- package/package.json +1 -1
|
@@ -38,6 +38,21 @@ const BOUND_TYPES = Object.keys(BOUND_SPECIALS).sort();
|
|
|
38
38
|
function canShowARecord(type) {
|
|
39
39
|
return (BOUND_SPECIALS[type] ?? []).length > 0;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Bound specials that carry NAVIGATION rather than what a visitor reads.
|
|
43
|
+
*
|
|
44
|
+
* The distinction decides whether an empty container is a defect. A
|
|
45
|
+
* `dataset-block` binds only these — it is a card that still needs the fields
|
|
46
|
+
* put inside it, so an empty one really is an empty card. A `media-dataset`
|
|
47
|
+
* binds `boundImage` / `boundImages` and DRAWS the record itself, children or
|
|
48
|
+
* not: a bound one with no children publishes a product photo, and calling that
|
|
49
|
+
* "an empty band" is the false positive that teaches a reader to skip the list.
|
|
50
|
+
*/
|
|
51
|
+
const LINK_SPECIALS = new Set(['boundHref', 'boundHrefLabel', 'boundProductURL']);
|
|
52
|
+
/** Whether this element's own renderer paints the record, so it needs no children. */
|
|
53
|
+
function drawsItsOwnContent(type) {
|
|
54
|
+
return (BOUND_SPECIALS[type] ?? []).some((k) => !LINK_SPECIALS.has(k));
|
|
55
|
+
}
|
|
41
56
|
/**
|
|
42
57
|
* Everything wrong with this page that a person would notice.
|
|
43
58
|
*
|
|
@@ -99,9 +114,16 @@ export function reviewDesign(doc) {
|
|
|
99
114
|
});
|
|
100
115
|
continue;
|
|
101
116
|
}
|
|
117
|
+
const bindings = n.bindings ?? [];
|
|
102
118
|
// A container with nothing in it is a band of empty space. The commonest way
|
|
103
119
|
// to ship one is to add the section and then get distracted.
|
|
104
|
-
|
|
120
|
+
//
|
|
121
|
+
// Unless the element paints the record ITSELF: a bound `media-dataset` with
|
|
122
|
+
// no children publishes the product's photo and its thumbnail strip, which
|
|
123
|
+
// this rule reported as an empty band on a page that rendered correctly.
|
|
124
|
+
if (meta.isContainer &&
|
|
125
|
+
childrenOf(d, id).length === 0 &&
|
|
126
|
+
!(bindings.length > 0 && drawsItsOwnContent(type))) {
|
|
105
127
|
out.push({
|
|
106
128
|
code: 'empty_container',
|
|
107
129
|
nodeId: id,
|
|
@@ -110,7 +132,6 @@ export function reviewDesign(doc) {
|
|
|
110
132
|
fix: fill('empty_container', { id }),
|
|
111
133
|
});
|
|
112
134
|
}
|
|
113
|
-
const bindings = n.bindings ?? [];
|
|
114
135
|
const repeater = inRepeater.get(id);
|
|
115
136
|
const boundFields = new Set(bindings.map((b) => b.field));
|
|
116
137
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FIRST_CHILD_ONLY } from '../../catalog/elements.generated.js';
|
|
2
|
-
import { pageChildren, isOverlay, SPEC_GLOBAL_ID, SPEC_GLOBAL_KIND, } from '../../core/tree.js';
|
|
2
|
+
import { pageChildren, isOverlay, SPEC_GLOBAL_ID, SPEC_GLOBAL_KIND, SPEC_GLOBAL_REV, SPEC_OVERLAY_ID, SPEC_OVERLAY_REV, } from '../../core/tree.js';
|
|
3
3
|
/**
|
|
4
4
|
* Which band a direct child of ROOT belongs to.
|
|
5
5
|
*
|
|
@@ -120,3 +120,40 @@ export function refuseSecondTemplate(doc, parentId, verb) {
|
|
|
120
120
|
'appears on the published page. Design the existing template, or sb_remove it first.');
|
|
121
121
|
}
|
|
122
122
|
export { isOverlay };
|
|
123
|
+
/**
|
|
124
|
+
* Re-stamp the composed masters with the revisions the save just reported.
|
|
125
|
+
*
|
|
126
|
+
* THE FENCE MOVES ON EVERY SAVE. Compose stamps `specials.globalRev` /
|
|
127
|
+
* `specials.overlayRev` onto the node it materialises; the save sends that back
|
|
128
|
+
* as `expectRev`; the platform refuses a stale one — and refuses it with a
|
|
129
|
+
* WARNING and a 200, not an error. So the first edit to a shared header or to
|
|
130
|
+
* the cart drawer lands, the fence advances on the server, and every edit after
|
|
131
|
+
* it in the same session is dropped while the tool reports success.
|
|
132
|
+
*
|
|
133
|
+
* Measured: two `sb_remove` calls in one session against the cart drawer. The
|
|
134
|
+
* first removed its subtree; the second answered `{"removed": …}` and changed
|
|
135
|
+
* nothing, and the drawer kept rendering the node in the browser.
|
|
136
|
+
*
|
|
137
|
+
* A master the report does not mention is left alone — it was not part of this
|
|
138
|
+
* save, and inventing a revision for it is how a fence stops being one.
|
|
139
|
+
*/
|
|
140
|
+
export function restampPatches(doc, report) {
|
|
141
|
+
const wanted = new Map();
|
|
142
|
+
for (const g of report.globals ?? [])
|
|
143
|
+
wanted.set(g.id, { key: SPEC_GLOBAL_REV, rev: g.rev });
|
|
144
|
+
for (const o of report.overlays ?? [])
|
|
145
|
+
wanted.set(o.id, { key: SPEC_OVERLAY_REV, rev: o.rev });
|
|
146
|
+
if (wanted.size === 0)
|
|
147
|
+
return [];
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const [id, n] of Object.entries(doc.nodes)) {
|
|
150
|
+
const masterId = n.specials?.[SPEC_GLOBAL_ID] ?? n.specials?.[SPEC_OVERLAY_ID];
|
|
151
|
+
if (typeof masterId !== 'string')
|
|
152
|
+
continue;
|
|
153
|
+
const next = wanted.get(masterId);
|
|
154
|
+
if (!next || n.specials?.[next.key] === next.rev)
|
|
155
|
+
continue;
|
|
156
|
+
out.push({ op: 'set', path: ['nodes', id, 'specials', next.key], value: next.rev });
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
package/dist/install/index.js
CHANGED
|
@@ -2,12 +2,34 @@ import { targets, detected } from './paths.js';
|
|
|
2
2
|
import { mergeInto } from './write.js';
|
|
3
3
|
/** The name the server appears under in every client. */
|
|
4
4
|
export const SERVER_NAME = 'sbuilder';
|
|
5
|
+
/**
|
|
6
|
+
* Every flag the CLI understands.
|
|
7
|
+
*
|
|
8
|
+
* NAMED, so an unknown one can be refused. `--site` and `--site-name` were typed
|
|
9
|
+
* at a real install, read by nothing, and reported as success — the caller then
|
|
10
|
+
* spent the session wondering why the site was not selected. A flag that is
|
|
11
|
+
* silently dropped is worse than one that does not exist.
|
|
12
|
+
*/
|
|
13
|
+
const FLAGS = [
|
|
14
|
+
'--token',
|
|
15
|
+
'--api',
|
|
16
|
+
'--site',
|
|
17
|
+
'--email',
|
|
18
|
+
'--password',
|
|
19
|
+
'--client',
|
|
20
|
+
'--dry-run',
|
|
21
|
+
];
|
|
5
22
|
export function buildEntry(opts, pkg = 'sbuilder-mcp') {
|
|
6
23
|
const env = {};
|
|
7
24
|
if (opts.api)
|
|
8
25
|
env.SB_API = opts.api;
|
|
9
26
|
if (opts.token)
|
|
10
27
|
env.SB_TOKEN = opts.token;
|
|
28
|
+
// The site the agent works on. A key belongs to exactly one, so writing it
|
|
29
|
+
// here spares every tool call an id the install already knew — and spares the
|
|
30
|
+
// model the guess it otherwise makes from a page list.
|
|
31
|
+
if (opts.site)
|
|
32
|
+
env.SB_SITE = opts.site;
|
|
11
33
|
// Only when a key is absent: a key opens everything the agent does day to day,
|
|
12
34
|
// and writing an account password into six config files to buy the handful of
|
|
13
35
|
// account-level calls it adds is a bad trade the installer should not make for
|
|
@@ -66,9 +88,27 @@ export function runInstallCli(argv) {
|
|
|
66
88
|
const i = argv.indexOf(flag);
|
|
67
89
|
return i >= 0 ? argv[i + 1] : undefined;
|
|
68
90
|
};
|
|
91
|
+
// REFUSE what we cannot act on. Anything that looks like a flag and is not one
|
|
92
|
+
// is a typo or a flag from another version, and either way the caller believes
|
|
93
|
+
// it took effect.
|
|
94
|
+
const taken = new Set();
|
|
95
|
+
for (const f of FLAGS) {
|
|
96
|
+
const i = argv.indexOf(f);
|
|
97
|
+
if (i >= 0) {
|
|
98
|
+
taken.add(i);
|
|
99
|
+
if (f !== '--dry-run')
|
|
100
|
+
taken.add(i + 1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const unknown = argv.filter((a, i) => a.startsWith('--') && !taken.has(i));
|
|
104
|
+
if (unknown.length) {
|
|
105
|
+
console.error(`sbuilder: unknown option(s) ${unknown.join(', ')}. Known: ${FLAGS.join(', ')}.`);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
69
108
|
const opts = {
|
|
70
109
|
token: get('--token') ?? process.env.SB_TOKEN,
|
|
71
110
|
api: get('--api') ?? process.env.SB_API,
|
|
111
|
+
site: get('--site') ?? process.env.SB_SITE,
|
|
72
112
|
email: get('--email') ?? process.env.SB_EMAIL,
|
|
73
113
|
password: get('--password') ?? process.env.SB_PASSWORD,
|
|
74
114
|
clients: get('--client')?.split(','),
|
package/dist/server.js
CHANGED
|
@@ -41,7 +41,13 @@ export function pkgVersion() {
|
|
|
41
41
|
}
|
|
42
42
|
export function buildContext() {
|
|
43
43
|
const base = process.env.SB_API ?? 'http://localhost:8080';
|
|
44
|
-
return {
|
|
44
|
+
return {
|
|
45
|
+
base,
|
|
46
|
+
session: new Session(base),
|
|
47
|
+
apiKey: process.env.SB_TOKEN,
|
|
48
|
+
siteId: process.env.SB_SITE,
|
|
49
|
+
notices: new Notices(),
|
|
50
|
+
};
|
|
45
51
|
}
|
|
46
52
|
export function createServer(ctx = buildContext()) {
|
|
47
53
|
const server = new McpServer({ name: 'sbuilder', version: pkgVersion(), title: 'Store Builder' }, { instructions: INSTRUCTIONS });
|
package/dist/tools/api.js
CHANGED
|
@@ -173,7 +173,14 @@ export async function callOperation(ctx, args) {
|
|
|
173
173
|
const name = m[1];
|
|
174
174
|
const value = args.path_params?.[name];
|
|
175
175
|
if (value === undefined) {
|
|
176
|
-
|
|
176
|
+
// NAME THE ARGUMENT, not just the parameter. The call sheet lists these
|
|
177
|
+
// under `params` while the call takes them in `path_params`, and a caller
|
|
178
|
+
// who reads the sheet and passes `params` is told only that the param is
|
|
179
|
+
// missing — which is exactly the value they just supplied. Two words of
|
|
180
|
+
// "in path_params" is the difference between one round trip and a loop.
|
|
181
|
+
throw new Error(`sbuilder: operation ${op.id} needs path param "${name}" — pass it in path_params, ` +
|
|
182
|
+
`e.g. path_params: { "${name}": "…" }. The call sheet lists it under "params"; ` +
|
|
183
|
+
'query values go in `query`.');
|
|
177
184
|
}
|
|
178
185
|
path = path.replace(`{${name}}`, encodeURIComponent(value));
|
|
179
186
|
}
|
|
@@ -206,6 +213,13 @@ export async function callOperation(ctx, args) {
|
|
|
206
213
|
body: args.body,
|
|
207
214
|
fetchImpl: ctx.fetchImpl,
|
|
208
215
|
});
|
|
216
|
+
// A 204 HAS NO BODY, and `null` is not an answer a caller can read: a DELETE
|
|
217
|
+
// that worked and a DELETE that returned nothing looked identical, so sixteen
|
|
218
|
+
// page deletes in a row reported `null` sixteen times and the only way to know
|
|
219
|
+
// they had happened was to list the pages again. Say what the operation did.
|
|
220
|
+
if (raw === null || raw === undefined) {
|
|
221
|
+
return { ok: true, method: op.method, path, note: 'The platform answered with no content.' };
|
|
222
|
+
}
|
|
209
223
|
return shapeResponse(raw, { pick: args.pick, max_items: args.max_items });
|
|
210
224
|
}
|
|
211
225
|
export function registerApiTools(server, ctx) {
|
package/dist/tools/context.js
CHANGED
|
@@ -1 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The site a call is about: the one it names, or the one the install did.
|
|
3
|
+
*
|
|
4
|
+
* An API key belongs to exactly ONE site, so on a key-only install the id is a
|
|
5
|
+
* constant the environment already holds — and requiring it on every call made
|
|
6
|
+
* the model carry a 32-character string through a whole session, which it can
|
|
7
|
+
* only get by listing pages and reading one back. `SB_SITE` makes it optional
|
|
8
|
+
* without making it implicit: an explicit argument always wins, so a
|
|
9
|
+
* two-site session still works by naming each one.
|
|
10
|
+
*/
|
|
11
|
+
export function siteFor(ctx, given) {
|
|
12
|
+
const id = given ?? ctx.siteId;
|
|
13
|
+
if (!id) {
|
|
14
|
+
throw new Error('sbuilder: no site. Pass site_id, or set SB_SITE to the site this install works on ' +
|
|
15
|
+
'(sbuilder-mcp install --site site_…).');
|
|
16
|
+
}
|
|
17
|
+
return id;
|
|
18
|
+
}
|
package/dist/tools/live.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { text, images } from '../mcp/response.js';
|
|
4
|
-
import { BINDING_SOURCES } from '../catalog/elements.generated.js';
|
|
4
|
+
import { BINDING_SOURCES, ELEMENTS } from '../catalog/elements.generated.js';
|
|
5
5
|
import { previewUrl } from '../vision/preview.js';
|
|
6
6
|
import { uploadMedia } from '../transport/media.js';
|
|
7
7
|
import { request } from '../transport/http.js';
|
|
@@ -24,8 +24,24 @@ const DATASET_TYPES = new Set([
|
|
|
24
24
|
import { RealtimeSocket } from '../transport/socket.js';
|
|
25
25
|
import { LiveSession } from '../live/session.js';
|
|
26
26
|
import { refuseAppBlockInterior } from '../domains/site/builder.js';
|
|
27
|
+
import { childrenOf, isOverlay, subtreeIds } from '../core/tree.js';
|
|
27
28
|
import { siteToken } from './credentialpick.js';
|
|
29
|
+
import { siteFor } from './context.js';
|
|
28
30
|
import { projectList, MEDIA_FIELDS } from './project.js';
|
|
31
|
+
/**
|
|
32
|
+
* The reserved binding id a PURCHASE control carries, and the vocabulary its
|
|
33
|
+
* target speaks — `schema/src/elements/datasetBindings.ts:845-852`.
|
|
34
|
+
*
|
|
35
|
+
* The id is reserved so authoring and the editor's own healing never collide,
|
|
36
|
+
* and `buy_now` is stored as builderx's `dynamic_checkout`: the picker's word
|
|
37
|
+
* and the document's word are deliberately different, and hand-mapping either
|
|
38
|
+
* one is how the two drift.
|
|
39
|
+
*/
|
|
40
|
+
const PRODUCT_ACTION_BINDING_ID = 'bind-product-action';
|
|
41
|
+
const PURCHASE_TARGETS = {
|
|
42
|
+
add_to_cart: 'add_to_cart',
|
|
43
|
+
buy_now: 'dynamic_checkout',
|
|
44
|
+
};
|
|
29
45
|
/**
|
|
30
46
|
* Bind a node's content to real store data.
|
|
31
47
|
*
|
|
@@ -38,7 +54,7 @@ import { projectList, MEDIA_FIELDS } from './project.js';
|
|
|
38
54
|
* reads the namespace off the field and `continue`s on anything else — so a
|
|
39
55
|
* `style.color` binding is stored, saved, published, and ignored forever.
|
|
40
56
|
*/
|
|
41
|
-
export function bindNode(doc, id, source, field) {
|
|
57
|
+
export function bindNode(doc, id, source, field, action) {
|
|
42
58
|
const node = doc.node(id);
|
|
43
59
|
refuseAppBlockInterior(doc, id, 'binding');
|
|
44
60
|
if (!BINDING_SOURCES.includes(source)) {
|
|
@@ -50,6 +66,36 @@ export function bindNode(doc, id, source, field) {
|
|
|
50
66
|
throw new Error(`sbuilder: a binding field must be "specials.<key>", not "${field}". The renderer ignores ` +
|
|
51
67
|
'every other namespace, so the binding would be stored and never applied.');
|
|
52
68
|
}
|
|
69
|
+
// A PURCHASE BINDING, which is what makes a button add to the cart.
|
|
70
|
+
//
|
|
71
|
+
// It is not an ordinary binding and cannot be written as one: the renderer
|
|
72
|
+
// reads `target.action` (`server/render/nodes/helpers.go:1166`) and nothing
|
|
73
|
+
// else, `sb_set` writes only style/config/specials, and this tool's plain path
|
|
74
|
+
// writes no target at all — so before this branch a store built entirely
|
|
75
|
+
// through these tools had no way to author an Add-to-cart button, while
|
|
76
|
+
// `sb_review` reported the gap and named no fix that worked. The one control
|
|
77
|
+
// a shop cannot do without was the one the tools could not make.
|
|
78
|
+
if (action !== undefined) {
|
|
79
|
+
const mapped = PURCHASE_TARGETS[action];
|
|
80
|
+
if (!mapped) {
|
|
81
|
+
throw new Error(`sbuilder: "${action}" is not a purchase action. Use "add_to_cart" or "buy_now" — ` +
|
|
82
|
+
'those are the two the renderer draws a purchase control for.');
|
|
83
|
+
}
|
|
84
|
+
const value = {
|
|
85
|
+
id: PRODUCT_ACTION_BINDING_ID,
|
|
86
|
+
source,
|
|
87
|
+
field,
|
|
88
|
+
target: { type: 'product', id: '', action: mapped },
|
|
89
|
+
};
|
|
90
|
+
// RESERVED ID, so a second call re-points the control instead of leaving two
|
|
91
|
+
// purchase bindings on one button for the runtime to choose between.
|
|
92
|
+
const at = node.bindings.findIndex((b) => b?.id === PRODUCT_ACTION_BINDING_ID);
|
|
93
|
+
if (at >= 0)
|
|
94
|
+
return [{ op: 'set', path: ['nodes', id, 'bindings', String(at)], value }];
|
|
95
|
+
return [
|
|
96
|
+
{ op: 'insert', path: ['nodes', id, 'bindings'], index: node.bindings.length, value },
|
|
97
|
+
];
|
|
98
|
+
}
|
|
53
99
|
return [
|
|
54
100
|
{
|
|
55
101
|
op: 'insert',
|
|
@@ -59,6 +105,77 @@ export function bindNode(doc, id, source, field) {
|
|
|
59
105
|
},
|
|
60
106
|
];
|
|
61
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The click-action allow-list that is LIVE for this node.
|
|
110
|
+
*
|
|
111
|
+
* `activeEvents` in the platform, whose whole rule is one line in
|
|
112
|
+
* `ActionTrait.vue`: `return action ? def.binding_events : def.events`. A
|
|
113
|
+
* purchase control is a different kind of control — an unbound button navigates,
|
|
114
|
+
* a bound one hands off to the cart or the checkout — and the two sets are
|
|
115
|
+
* mutually exclusive, because "add this product, then go to an arbitrary URL" is
|
|
116
|
+
* not a thing the cart runtime can express.
|
|
117
|
+
*/
|
|
118
|
+
function liveEventTable(type, node) {
|
|
119
|
+
const meta = ELEMENTS[type];
|
|
120
|
+
if (!meta?.events)
|
|
121
|
+
return undefined;
|
|
122
|
+
const bound = (node.bindings ?? []).some((b) => b?.id === PRODUCT_ACTION_BINDING_ID);
|
|
123
|
+
return (bound && meta.bindingEvents) || meta.events;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Put a click action on a node, or take one off.
|
|
127
|
+
*
|
|
128
|
+
* THE ONE THING NO TOOL COULD DO. `NodeSpec` carries no `events`, `sb_set`
|
|
129
|
+
* writes only style/config/specials, and `createNode` always minted `events: []`
|
|
130
|
+
* — so `open_cart` could not be authored, and a site built from scratch had no
|
|
131
|
+
* way to open its own cart drawer. `sb_review` reported that gap
|
|
132
|
+
* (`cartTrigger`) and named a fix nothing could apply, which is the same shape
|
|
133
|
+
* the purchase binding had before `sb_bind` grew `action`.
|
|
134
|
+
*
|
|
135
|
+
* A purchase is NOT here. `add_to_cart` and `buy_now` are absent from every
|
|
136
|
+
* element's allow-list, and the button meta says why in as many words: neither
|
|
137
|
+
* is a click action. The intent is the BINDING — `sb_bind` with `action` — and
|
|
138
|
+
* the event is what happens alongside it.
|
|
139
|
+
*
|
|
140
|
+
* ONE ACTION PER TRIGGER, replaced in place. The platform stores a list, but a
|
|
141
|
+
* second `click` on one node is two answers to one question, and picking between
|
|
142
|
+
* them at runtime is the platform's business rather than an authoring choice.
|
|
143
|
+
*/
|
|
144
|
+
export function setEvent(doc, id, trigger, action, payload) {
|
|
145
|
+
const node = doc.node(id);
|
|
146
|
+
refuseAppBlockInterior(doc, id, 'setting an event on');
|
|
147
|
+
const events = node.events ?? [];
|
|
148
|
+
const at = events.findIndex((e) => e?.name === trigger);
|
|
149
|
+
if (action === 'none') {
|
|
150
|
+
if (at < 0)
|
|
151
|
+
return [];
|
|
152
|
+
return [{ op: 'remove', path: ['nodes', id, 'events'], index: at }];
|
|
153
|
+
}
|
|
154
|
+
const table = liveEventTable(node.data.type, node);
|
|
155
|
+
if (!table) {
|
|
156
|
+
throw new Error(`sbuilder: a ${node.data.type} declares no click actions, so an event on it would be ` +
|
|
157
|
+
'stored and never fired. Elements that do: ' +
|
|
158
|
+
Object.keys(ELEMENTS).filter((t) => ELEMENTS[t]?.events).join(', ') + '.');
|
|
159
|
+
}
|
|
160
|
+
const allowed = table[trigger];
|
|
161
|
+
if (!allowed) {
|
|
162
|
+
throw new Error(`sbuilder: a ${node.data.type} offers no "${trigger}" trigger. It offers: ` +
|
|
163
|
+
`${Object.keys(table).join(', ')}.`);
|
|
164
|
+
}
|
|
165
|
+
if (!allowed.includes(action)) {
|
|
166
|
+
const purchase = action === 'add_to_cart' || action === 'buy_now';
|
|
167
|
+
throw new Error(`sbuilder: "${action}" is not an action a ${node.data.type} offers on ${trigger}. ` +
|
|
168
|
+
(purchase
|
|
169
|
+
? 'A purchase is a BINDING, not a click action — use sb_bind with action:"' +
|
|
170
|
+
action + '". '
|
|
171
|
+
: '') +
|
|
172
|
+
`Allowed: ${allowed.join(', ')}.`);
|
|
173
|
+
}
|
|
174
|
+
const value = { id: `ev_${action}`, name: trigger, action, payload: payload ?? {} };
|
|
175
|
+
if (at >= 0)
|
|
176
|
+
return [{ op: 'set', path: ['nodes', id, 'events', String(at)], value }];
|
|
177
|
+
return [{ op: 'insert', path: ['nodes', id, 'events'], index: events.length, value }];
|
|
178
|
+
}
|
|
62
179
|
/**
|
|
63
180
|
* The credential that opens the live-edit room.
|
|
64
181
|
*
|
|
@@ -95,9 +212,10 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
95
212
|
'editor as it happens, with the agent shown by the API key\'s own name rather than a ' +
|
|
96
213
|
"person's. Always yields, so it is safe beside a human. Works with SB_TOKEN or with " +
|
|
97
214
|
'SB_EMAIL / SB_PASSWORD.',
|
|
98
|
-
inputSchema: { site_id: z.string() },
|
|
215
|
+
inputSchema: { site_id: z.string().optional() },
|
|
99
216
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
100
|
-
}, async ({ site_id }) => {
|
|
217
|
+
}, async ({ site_id: given }) => {
|
|
218
|
+
const site_id = siteFor(ctx, given);
|
|
101
219
|
const tokenFn = liveTokenFor(ctx);
|
|
102
220
|
const wsBase = ctx.base.replace(/^http/, 'ws').replace(/\/$/, '');
|
|
103
221
|
const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(site_id)}`, tokenFn);
|
|
@@ -166,7 +284,16 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
166
284
|
const review = reviewField(ctx, session.current());
|
|
167
285
|
// Measured on the render, not read off the document — a card that spills
|
|
168
286
|
// at 390px is invisible to every check that only reads the tree.
|
|
169
|
-
|
|
287
|
+
// The overlay subtree, read off the OPEN DOCUMENT — the boxes come from
|
|
288
|
+
// the render and carry no idea which node is a drawer.
|
|
289
|
+
const doc = session.current().doc;
|
|
290
|
+
const skip = new Set();
|
|
291
|
+
for (const id of childrenOf(doc, doc.root_node_id)) {
|
|
292
|
+
if (isOverlay(doc, id))
|
|
293
|
+
for (const n of subtreeIds(doc, id))
|
|
294
|
+
skip.add(n);
|
|
295
|
+
}
|
|
296
|
+
const visual = node_id ? [] : measure(shots, skip);
|
|
170
297
|
const layout = compactFindings(visual);
|
|
171
298
|
const layoutNotice = visual.length > 0 ? ctx.notices.once('measure', MEASURE_NOTICE) : undefined;
|
|
172
299
|
// The legend rides with the first look only; the shape does not change after.
|
|
@@ -204,17 +331,17 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
204
331
|
description: "The site's media library. Reuse an image before adding another; search by name, filter " +
|
|
205
332
|
'by type, page with limit/offset.',
|
|
206
333
|
inputSchema: {
|
|
207
|
-
site_id: z.string(),
|
|
334
|
+
site_id: z.string().optional(),
|
|
208
335
|
search: z.string().optional(),
|
|
209
336
|
media_type: z.string().optional().describe('e.g. "image"'),
|
|
210
337
|
limit: z.number().int().min(1).max(200).optional(),
|
|
211
338
|
offset: z.number().int().min(0).optional(),
|
|
212
339
|
},
|
|
213
340
|
annotations: { readOnlyHint: true },
|
|
214
|
-
}, async ({ site_id, search, media_type, limit, offset }) => text(projectList(await request({
|
|
341
|
+
}, async ({ site_id: given, search, media_type, limit, offset }) => text(projectList(await request({
|
|
215
342
|
base: ctx.base,
|
|
216
343
|
method: 'GET',
|
|
217
|
-
path: `/api/sites/${encodeURIComponent(
|
|
344
|
+
path: `/api/sites/${encodeURIComponent(siteFor(ctx, given))}/media`,
|
|
218
345
|
token: siteToken(ctx),
|
|
219
346
|
query: { search, mediaType: media_type, limit, offset },
|
|
220
347
|
fetchImpl: ctx.fetchImpl,
|
|
@@ -224,7 +351,7 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
224
351
|
'local file path or a URL to fetch. This is the ONLY way to add an image: the upload ' +
|
|
225
352
|
'is multipart, which sb_api_call cannot send.',
|
|
226
353
|
inputSchema: {
|
|
227
|
-
site_id: z.string(),
|
|
354
|
+
site_id: z.string().optional(),
|
|
228
355
|
path: z.string().optional().describe('A file on this machine'),
|
|
229
356
|
url: z.string().optional().describe('Fetched, then uploaded'),
|
|
230
357
|
name: z.string().optional(),
|
|
@@ -232,7 +359,8 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
232
359
|
dry_run: z.boolean().optional(),
|
|
233
360
|
},
|
|
234
361
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
235
|
-
}, async ({ site_id, path, url, name, folder_id, dry_run }) => {
|
|
362
|
+
}, async ({ site_id: given, path, url, name, folder_id, dry_run }) => {
|
|
363
|
+
const site_id = siteFor(ctx, given);
|
|
236
364
|
if (!path && !url)
|
|
237
365
|
throw new Error('sbuilder: give sb_media_upload either a path or a url');
|
|
238
366
|
if (dry_run !== false) {
|
|
@@ -251,9 +379,31 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
251
379
|
: 'Uploaded, but the server returned no url — read it back with sb_media_list.',
|
|
252
380
|
});
|
|
253
381
|
});
|
|
382
|
+
server.registerTool('sb_event', {
|
|
383
|
+
description: 'Give a node a click action — open the cart, go to a page, open a pop-up. A purchase ' +
|
|
384
|
+
'is not one: use sb_bind action.',
|
|
385
|
+
inputSchema: {
|
|
386
|
+
id: z.string(),
|
|
387
|
+
action: z
|
|
388
|
+
.string()
|
|
389
|
+
.describe('An action this element allows, or "none" to clear. A wrong one is refused with the list'),
|
|
390
|
+
trigger: z.string().optional().describe('Default "click"'),
|
|
391
|
+
payload: z.record(z.unknown()).optional(),
|
|
392
|
+
dry_run: z.boolean().optional(),
|
|
393
|
+
},
|
|
394
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
395
|
+
}, async ({ id, action, trigger, payload, dry_run }) => {
|
|
396
|
+
const d = session.current();
|
|
397
|
+
const patches = setEvent(d, id, trigger ?? 'click', action, payload);
|
|
398
|
+
if (dry_run !== false)
|
|
399
|
+
return text({ dry_run: true, patches });
|
|
400
|
+
session.applyAndPublish(patches);
|
|
401
|
+
await session.save();
|
|
402
|
+
return text({ node: id, trigger: trigger ?? 'click', action, rev: d.rev });
|
|
403
|
+
});
|
|
254
404
|
server.registerTool('sb_bind', {
|
|
255
|
-
description:
|
|
256
|
-
'
|
|
405
|
+
description: 'Bind a node to real store data so the page shows actual products, not placeholder ' +
|
|
406
|
+
'text. action makes a button a purchase control.',
|
|
257
407
|
inputSchema: {
|
|
258
408
|
id: z.string(),
|
|
259
409
|
source: z
|
|
@@ -264,16 +414,20 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
264
414
|
// unknown one costs one round trip and the schema stays small.
|
|
265
415
|
`e.g. ${BINDING_SOURCES.slice(0, 4).join(', ')}; ${BINDING_SOURCES.length} in all, and a wrong one is refused with the list`),
|
|
266
416
|
field: z.string().describe('Where the value lands, always "specials.<key>"'),
|
|
417
|
+
action: z
|
|
418
|
+
.enum(['add_to_cart', 'buy_now'])
|
|
419
|
+
.optional()
|
|
420
|
+
.describe('Pass product.id + specials.boundProductId'),
|
|
267
421
|
dry_run: z.boolean().optional(),
|
|
268
422
|
},
|
|
269
423
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
270
|
-
}, async ({ id, source, field, dry_run }) => {
|
|
424
|
+
}, async ({ id, source, field, action, dry_run }) => {
|
|
271
425
|
const d = session.current();
|
|
272
|
-
const patches = bindNode(d, id, source, field);
|
|
426
|
+
const patches = bindNode(d, id, source, field, action);
|
|
273
427
|
if (dry_run !== false)
|
|
274
428
|
return text({ dry_run: true, patches });
|
|
275
429
|
session.applyAndPublish(patches);
|
|
276
430
|
await session.save();
|
|
277
|
-
return text({ bound: id, source, field, rev: d.rev });
|
|
431
|
+
return text({ bound: id, source, field, ...(action ? { action } : {}), rev: d.rev });
|
|
278
432
|
});
|
|
279
433
|
}
|
package/dist/tools/page.js
CHANGED
|
@@ -11,8 +11,9 @@ import { reviewDesign, REVIEW_NOTICE } from '../domains/site/review.js';
|
|
|
11
11
|
import { compactFindings } from '../domains/site/findings.js';
|
|
12
12
|
import { readinessGaps, READINESS_NOTICE } from '../domains/site/readiness.js';
|
|
13
13
|
import { gatherReadiness } from '../domains/site/readiness-fetch.js';
|
|
14
|
-
import { globalWarning, RESPONSIVE_NOTICE } from '../domains/site/traps.js';
|
|
14
|
+
import { globalWarning, restampPatches, RESPONSIVE_NOTICE } from '../domains/site/traps.js';
|
|
15
15
|
import { catalogMatches, traitsFor } from '../catalog/element-search.js';
|
|
16
|
+
import { siteFor } from './context.js';
|
|
16
17
|
import { projectList, PAGE_FIELDS, TEMPLATE_FIELDS } from './project.js';
|
|
17
18
|
/**
|
|
18
19
|
* Findings, in the shape every surface returns them.
|
|
@@ -131,7 +132,15 @@ export class PageSession {
|
|
|
131
132
|
if (problems.length > 0) {
|
|
132
133
|
throw new Error(`sbuilder: refusing to save — ${problems.join(' ')}`);
|
|
133
134
|
}
|
|
134
|
-
await saveSource(this.ctx, this.siteId, this.pageId, d.doc);
|
|
135
|
+
const saved = await saveSource(this.ctx, this.siteId, this.pageId, d.doc);
|
|
136
|
+
// RE-STAMP THE FENCE, or lose every edit after this one.
|
|
137
|
+
//
|
|
138
|
+
// The save reports each shared master's new revision precisely so the client
|
|
139
|
+
// can carry it into the next save; the platform refuses a stale `expectRev`
|
|
140
|
+
// with a warning and a 200. Applied locally rather than published: these are
|
|
141
|
+
// the server's own numbers coming back, not an edit anybody made, and a peer
|
|
142
|
+
// in the room gets them from its own save.
|
|
143
|
+
d.apply(restampPatches(d.doc, { globals: saved.globals, overlays: saved.overlays }));
|
|
135
144
|
}
|
|
136
145
|
}
|
|
137
146
|
const specSchema = z.lazy(() => z.object({
|
|
@@ -147,10 +156,10 @@ export function registerPageTools(server, ctx) {
|
|
|
147
156
|
server.registerTool('sb_page_open', {
|
|
148
157
|
description: 'Open a page for editing and return its outline. Call before any sb_add / sb_set / ' +
|
|
149
158
|
'sb_move / sb_remove. Find page ids with sb_api_find "list pages".',
|
|
150
|
-
inputSchema: { site_id: z.string(), page_id: z.string() },
|
|
159
|
+
inputSchema: { site_id: z.string().optional(), page_id: z.string() },
|
|
151
160
|
annotations: { readOnlyHint: true },
|
|
152
|
-
}, async ({ site_id, page_id }) => {
|
|
153
|
-
const outline = await session.open(
|
|
161
|
+
}, async ({ site_id: given, page_id }) => {
|
|
162
|
+
const outline = await session.open(siteFor(ctx, given), page_id);
|
|
154
163
|
const doc = session.current();
|
|
155
164
|
// A page whose stored document named its root under the app-block key
|
|
156
165
|
// renders as an empty <body> and says nothing about why. Nobody else can
|
|
@@ -356,12 +365,12 @@ export function registerPageTools(server, ctx) {
|
|
|
356
365
|
server.registerTool('sb_templates', {
|
|
357
366
|
description: "The store's saved section templates — designed sections a person starts from rather " +
|
|
358
367
|
'than assembling one. Use sb_template_use to drop one into the open page.',
|
|
359
|
-
inputSchema: { site_id: z.string() },
|
|
368
|
+
inputSchema: { site_id: z.string().optional() },
|
|
360
369
|
annotations: { readOnlyHint: true },
|
|
361
|
-
}, async ({ site_id }) => text(projectList(await request({
|
|
370
|
+
}, async ({ site_id: given }) => text(projectList(await request({
|
|
362
371
|
base: ctx.base,
|
|
363
372
|
method: 'GET',
|
|
364
|
-
path: `/api/sites/${encodeURIComponent(
|
|
373
|
+
path: `/api/sites/${encodeURIComponent(siteFor(ctx, given))}/section-templates`,
|
|
365
374
|
token: siteToken(ctx),
|
|
366
375
|
fetchImpl: ctx.fetchImpl,
|
|
367
376
|
}), 'sectionTemplates', TEMPLATE_FIELDS)));
|
|
@@ -369,13 +378,14 @@ export function registerPageTools(server, ctx) {
|
|
|
369
378
|
description: 'Instantiate a saved section template into a page. The server does the copy, so the ' +
|
|
370
379
|
'section arrives exactly as it was designed — then re-open the page to see it.',
|
|
371
380
|
inputSchema: {
|
|
372
|
-
site_id: z.string(),
|
|
381
|
+
site_id: z.string().optional(),
|
|
373
382
|
template_id: z.string(),
|
|
374
383
|
page_id: z.string(),
|
|
375
384
|
dry_run: z.boolean().optional(),
|
|
376
385
|
},
|
|
377
386
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
378
|
-
}, async ({ site_id, template_id, page_id, dry_run }) => {
|
|
387
|
+
}, async ({ site_id: given, template_id, page_id, dry_run }) => {
|
|
388
|
+
const site_id = siteFor(ctx, given);
|
|
379
389
|
const path = `/api/sites/${encodeURIComponent(site_id)}/section-templates/${encodeURIComponent(template_id)}/instantiate`;
|
|
380
390
|
if (dry_run !== false) {
|
|
381
391
|
return text({ dry_run: true, would_post: path, body: { pageId: page_id } });
|
|
@@ -397,12 +407,12 @@ export function registerPageTools(server, ctx) {
|
|
|
397
407
|
});
|
|
398
408
|
server.registerTool('sb_page_list', {
|
|
399
409
|
description: "Every page on the site, with its slug and whether it is live.",
|
|
400
|
-
inputSchema: { site_id: z.string() },
|
|
410
|
+
inputSchema: { site_id: z.string().optional() },
|
|
401
411
|
annotations: { readOnlyHint: true },
|
|
402
|
-
}, async ({ site_id }) => text(projectList(await request({
|
|
412
|
+
}, async ({ site_id: given }) => text(projectList(await request({
|
|
403
413
|
base: ctx.base,
|
|
404
414
|
method: 'GET',
|
|
405
|
-
path: `/api/sites/${encodeURIComponent(
|
|
415
|
+
path: `/api/sites/${encodeURIComponent(siteFor(ctx, given))}/pages`,
|
|
406
416
|
token: siteToken(ctx),
|
|
407
417
|
fetchImpl: ctx.fetchImpl,
|
|
408
418
|
}), 'pages', PAGE_FIELDS)));
|
|
@@ -411,7 +421,7 @@ export function registerPageTools(server, ctx) {
|
|
|
411
421
|
'checkout, product, category, post and course: /checkout and /products/{slug} need a ' +
|
|
412
422
|
'PUBLISHED page of that type or they 404.',
|
|
413
423
|
inputSchema: {
|
|
414
|
-
site_id: z.string(),
|
|
424
|
+
site_id: z.string().optional(),
|
|
415
425
|
name: z.string(),
|
|
416
426
|
type: z.string().optional().describe('page (default), checkout, product, category, post, course'),
|
|
417
427
|
slug: z.string().optional(),
|
|
@@ -420,7 +430,8 @@ export function registerPageTools(server, ctx) {
|
|
|
420
430
|
dry_run: z.boolean().optional(),
|
|
421
431
|
},
|
|
422
432
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
423
|
-
}, async ({ site_id, name, type, slug, is_homepage, settings, dry_run }) => {
|
|
433
|
+
}, async ({ site_id: given, name, type, slug, is_homepage, settings, dry_run }) => {
|
|
434
|
+
const site_id = siteFor(ctx, given);
|
|
424
435
|
const path = `/api/sites/${encodeURIComponent(site_id)}/pages`;
|
|
425
436
|
// TYPE IS THE ROUTE for several kinds of page: /checkout and
|
|
426
437
|
// /products/{slug} resolve to the site's PUBLISHED page of that type and
|
|
@@ -470,9 +481,10 @@ export function registerPageTools(server, ctx) {
|
|
|
470
481
|
description: 'Compile the draft into the live page. PUBLISH CASCADES: a page sharing a global ' +
|
|
471
482
|
'section with others republishes them too, because a header edited once must not go ' +
|
|
472
483
|
'live on one page and stay stale on the rest.',
|
|
473
|
-
inputSchema: { site_id: z.string(), page_id: z.string(), dry_run: z.boolean().optional() },
|
|
484
|
+
inputSchema: { site_id: z.string().optional(), page_id: z.string(), dry_run: z.boolean().optional() },
|
|
474
485
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
475
|
-
}, async ({ site_id, page_id, dry_run }) => {
|
|
486
|
+
}, async ({ site_id: given, page_id, dry_run }) => {
|
|
487
|
+
const site_id = siteFor(ctx, given);
|
|
476
488
|
// PUBLISH IS A SITE-LEVEL CALL that NAMES pages, not a page-level route.
|
|
477
489
|
// This used to POST /pages/{id}/publish, which the platform answers 404 —
|
|
478
490
|
// it mounts "publish" as its own resource beside "pages"
|