sbuilder-mcp 0.76.4 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import { z } from 'zod';
3
3
  import { text, images } from '../mcp/response.js';
4
4
  import { BINDING_SOURCES, ELEMENTS } from '../catalog/elements.generated.js';
5
+ import { bindableFields } from '../domains/site/writecheck.js';
5
6
  import { previewUrl } from '../vision/preview.js';
6
7
  import { uploadMedia } from '../transport/media.js';
7
8
  import { request } from '../transport/http.js';
@@ -26,7 +27,7 @@ import { RealtimeSocket } from '../transport/socket.js';
26
27
  import { LiveSession } from '../live/session.js';
27
28
  import { refuseAppBlockInterior } from '../domains/site/builder.js';
28
29
  import { soft } from '../domains/site/guard.js';
29
- import { hrefPatches, hasPurchaseBinding, PRODUCT_ACTION_BINDING_ID, } from '../domains/site/navhref.js';
30
+ import { hrefPatches, hasPurchaseBinding, liveEventTable, PAYLOAD_NEEDS, PRODUCT_ACTION_BINDING_ID, } from '../domains/site/navhref.js';
30
31
  import { offscreenNodes, offscreenRootOf } from '../domains/site/offscreen.js';
31
32
  import { siteToken } from './credentialpick.js';
32
33
  import { searchStock, SearchUnavailable, NO_SEARCH_NEXT } from '../transport/stock.js';
@@ -70,6 +71,24 @@ export function bindNode(doc, id, source, field, action, guard) {
70
71
  throw new Error(`sbuilder: a binding field must be "specials.<key>", not "${field}". The renderer ignores ` +
71
72
  'every other namespace, so the binding would be stored and never applied.');
72
73
  }
74
+ // A FIELD THE ELEMENT NEVER PAINTS. `BOUND_SPECIALS` is read off each
75
+ // element's html.go, so a `specials.boundImage` binding on a text-dataset is
76
+ // stored, saved, published and rendered as placeholder text forever. Soft:
77
+ // the list is the catalog's copy of the renderer. The element's own generated
78
+ // binding fields count too — `boundProductId` is read by the runtime, not
79
+ // html.go, and every default product binding carries it.
80
+ const type = node.data?.type ?? '';
81
+ const key = field.slice(dot + 1);
82
+ const allowed = bindableFields(type);
83
+ if (action === undefined && allowed.length) {
84
+ if (!allowed.includes(key)) {
85
+ soft(guard, () => {
86
+ throw new Error(`sbuilder: a ${type} renders a binding only into ${allowed.map((k) => `specials.${k}`).join(', ')} ` +
87
+ `— "${field}" would be stored and never painted. Allowed fields: ${allowed.join(', ')}. ` +
88
+ 'Bind one of those, or swap to an element that shows this value (sb_catalog_search).');
89
+ });
90
+ }
91
+ }
73
92
  // A PURCHASE BINDING, which is what makes a button add to the cart.
74
93
  //
75
94
  // It is not an ordinary binding and cannot be written as one: the renderer
@@ -109,23 +128,6 @@ export function bindNode(doc, id, source, field, action, guard) {
109
128
  },
110
129
  ];
111
130
  }
112
- /**
113
- * The click-action allow-list that is LIVE for this node.
114
- *
115
- * `activeEvents` in the platform, whose whole rule is one line in
116
- * `ActionTrait.vue`: `return action ? def.binding_events : def.events`. A
117
- * purchase control is a different kind of control — an unbound button navigates,
118
- * a bound one hands off to the cart or the checkout — and the two sets are
119
- * mutually exclusive, because "add this product, then go to an arbitrary URL" is
120
- * not a thing the cart runtime can express.
121
- */
122
- function liveEventTable(type, node) {
123
- const meta = ELEMENTS[type];
124
- if (!meta?.events)
125
- return undefined;
126
- const bound = (node.bindings ?? []).some((b) => b?.id === PRODUCT_ACTION_BINDING_ID);
127
- return (bound && meta.bindingEvents) || meta.events;
128
- }
129
131
  /**
130
132
  * Put a click action on a node, or take one off.
131
133
  *
@@ -172,7 +174,7 @@ export function setEvent(doc, id, trigger, action, payload, guard) {
172
174
  ...project(next),
173
175
  ];
174
176
  }
175
- const table = liveEventTable(node.data.type, node);
177
+ const table = liveEventTable(doc.doc.nodes, id);
176
178
  if (!table) {
177
179
  throw new Error(`sbuilder: a ${node.data.type} declares no click actions, so an event on it would be ` +
178
180
  'stored and never fired. Elements that do: ' +
@@ -192,6 +194,21 @@ export function setEvent(doc, id, trigger, action, payload, guard) {
192
194
  : '') +
193
195
  `Allowed: ${allowed.join(', ')}.`);
194
196
  }
197
+ // A NAVIGATION WITH NOWHERE TO GO. The renderer turns a sole go_to_url /
198
+ // open_page click into `<a href>` from `payload.url` ALONE (navhref.ts) — an
199
+ // `open_page` with only a page id projects nothing, since neither renderer
200
+ // can resolve one — and a `popup` with no `payload.id` publishes a call with
201
+ // no target that opens nothing. All three store, save and publish clean.
202
+ const need = PAYLOAD_NEEDS[action];
203
+ if (need) {
204
+ const v = (payload ?? {})[need.key];
205
+ if (typeof v !== 'string' || v === '') {
206
+ soft(guard, () => {
207
+ throw new Error(`sbuilder: "${action}" needs payload.${need.key} — without it the click ${need.why}. ` +
208
+ `Expected payload: ${need.shape}.`);
209
+ });
210
+ }
211
+ }
195
212
  const value = { id: `ev_${action}`, name: trigger, action, payload: payload ?? {} };
196
213
  if (at >= 0) {
197
214
  const next = events.map((e, i) => (i === at ? value : e));
@@ -258,7 +275,7 @@ export function joinRoom(ctx, session, siteId) {
258
275
  const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(siteId)}`, tokenFn);
259
276
  const live = new LiveSession(socket, {
260
277
  onRemote: (patches) => session.applyRemote(patches),
261
- onDesync: (reason) => session.markStale(reason),
278
+ onDesync: (reason) => session.roomDesync(reason),
262
279
  onSource: (pageId, rev, peerId) => session.sourceSaved(pageId, rev, peerId),
263
280
  onMaster: (kind, id, op, rev) => session.masterSaved(kind, id, op, rev),
264
281
  });
@@ -661,8 +678,10 @@ export function registerLiveTools(server, ctx, session) {
661
678
  const guard = { force, forced: [] };
662
679
  const patches = setEvent(d, id, trigger ?? 'click', action, payload, guard);
663
680
  const forced = guard.forced.length ? { forced: guard.forced } : {};
664
- if (dry_run !== false)
665
- return text({ dry_run: true, patches, ...forced });
681
+ if (dry_run !== false) {
682
+ const refuse = session.wouldRefuse(patches);
683
+ return text({ dry_run: true, patches, ...(refuse ? { would_refuse: refuse } : {}), ...forced });
684
+ }
666
685
  await session.applyAndSave(patches);
667
686
  return text({ node: id, trigger: trigger ?? 'click', action, rev: d.rev, ...forced });
668
687
  });
@@ -692,8 +711,10 @@ export function registerLiveTools(server, ctx, session) {
692
711
  const guard = { force, forced: [] };
693
712
  const patches = bindNode(d, id, source, field, action, guard);
694
713
  const forced = guard.forced.length ? { forced: guard.forced } : {};
695
- if (dry_run !== false)
696
- return text({ dry_run: true, patches, ...forced });
714
+ if (dry_run !== false) {
715
+ const refuse = session.wouldRefuse(patches);
716
+ return text({ dry_run: true, patches, ...(refuse ? { would_refuse: refuse } : {}), ...forced });
717
+ }
697
718
  await session.applyAndSave(patches);
698
719
  return text({ bound: id, source, field, ...(action ? { action } : {}), rev: d.rev, ...forced });
699
720
  });
@@ -323,7 +323,7 @@ export async function ensureCartDrawer(ctx, session, siteId, dryRun) {
323
323
  note: 'Nothing was sent. Re-call with dry_run:false to create it; it shows on every page.',
324
324
  };
325
325
  }
326
- const open = session.peek() ? session.location() : null;
326
+ const open = session.page();
327
327
  const onPage = open?.siteId === siteId;
328
328
  if (onPage)
329
329
  await session.applyAndSave([]);
@@ -365,7 +365,7 @@ export async function relocalizeCartDrawer(ctx, session, siteId, dryRun) {
365
365
  const raw = settings.settings?.locale;
366
366
  // No locale means no language to move the WORDS to; the thumbnail needs none.
367
367
  const language = typeof raw === 'string' && raw ? cartSeedLocale(raw) : null;
368
- const open = session.peek() ? session.location() : null;
368
+ const open = session.page();
369
369
  const composed = open?.siteId === siteId
370
370
  ? Object.values(session.current().doc.nodes).find((n) => n.specials?.overlayId === cart.id)
371
371
  : undefined;