sbuilder-mcp 0.15.0 → 0.16.1

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 CHANGED
@@ -6,6 +6,22 @@ All notable changes to this project are documented in this file.
6
6
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.16.1] - 2026-09-10
10
+
11
+ ### Fixed
12
+ - sb_add, sb_set, sb_move, sb_remove, sb_duplicate, sb_event and sb_bind now judge a write against a throwaway copy of the page before applying it, so a save the platform refuses is never applied to the draft or broadcast to a live session; previously the refused node stayed in the document and every later command was validated against a tree the caller never asked for, repeating the same complaint about an id it had never typed. A write is still refused only for problems it introduces, not for damage the page already had when the session opened it.
13
+ - sb_import_page now builds its whole run of sections on a copy and commits them in one save, so a refusal partway through no longer leaves a page half imported with no way to tell which sections landed.
14
+
15
+ ## [0.16.0] - 2026-09-10
16
+
17
+ ### Added
18
+ - sb_traits_for now returns a `translatable` block naming which of an element's specials a translation may safely rewrite, and which of its own specials must never be translated, since translating a non-content special (a lucide icon `name`, a `src` URL, a `filterSource` registry id) does not degrade the page, it breaks the render.
19
+ - sb_api_find and sb_api_call now attach a `translation_fields` call sheet to every `/translations` operation, listing the translatable columns for each entity type and pointing to sb_traits_for for the per-element `node` vocabulary, since every translations route was already reachable with no way to know which fields were safe to send.
20
+ - sb_set now warns once per node type when a field-skin config key (`payCardBg`, `choice*`, `slot*`, `file*`, and the rest of the 55-key vocabulary) is written on a form node whose renderer does not read it, naming the field node that actually renders it, since such a write is stored, saved and published but rendered nowhere.
21
+
22
+ ### Internal
23
+ - The generated catalog gained a translations table (`translations.generated.ts`, 141 translatable specials across 58 elements, 156 keys classified as never-translate, 12 entity types with their columns) and a field-skin table (`fieldskin.generated.ts`), both cross-checked at codegen time against the platform's own registries rather than hand-kept.
24
+
9
25
  ## [0.15.0] - 2026-09-10
10
26
 
11
27
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,22 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
6
6
  Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.16.1] - 2026-09-10
10
+
11
+ ### Fixed
12
+ - sb_add, sb_set, sb_move, sb_remove, sb_duplicate, sb_event và sb_bind giờ đánh giá một lần ghi trên một bản sao tạm của trang trước khi áp dụng thật, để một lần save bị nền tảng từ chối không bao giờ được áp vào draft hay phát ra cho một phiên live đang xem; trước đây node bị từ chối vẫn ở lại trong document và mọi lệnh sau đó đều bị kiểm tra dựa trên một tree mà người gọi chưa từng yêu cầu, lặp lại cùng một lời phàn nàn về một id mà họ chưa từng gõ. Một lần ghi vẫn chỉ bị từ chối vì vấn đề do chính nó gây ra, không phải vì hư hỏng trang đã có sẵn từ trước khi phiên làm việc mở nó.
13
+ - sb_import_page giờ dựng toàn bộ các section trong một lượt import trên một bản sao và commit chúng trong một lần save duy nhất, để một lần từ chối giữa chừng không còn để lại một trang import dở dang mà không cách nào biết những section nào đã vào được.
14
+
15
+ ## [0.16.0] - 2026-09-10
16
+
17
+ ### Added
18
+ - sb_traits_for giờ trả về một khối `translatable` nêu tên những special nào của element có thể an toàn để một bản dịch ghi đè, và những special riêng của nó không bao giờ được dịch, vì dịch một special không phải nội dung (một `name` icon lucide, một URL `src`, một id registry `filterSource`) không làm trang xấu đi, mà làm hỏng hẳn phần render.
19
+ - sb_api_find và sb_api_call giờ đính kèm một call sheet `translation_fields` vào mọi operation `/translations`, liệt kê các cột có thể dịch cho từng loại entity và trỏ tới sb_traits_for để lấy vocabulary riêng theo từng element cho entity `node`, vì trước đây mọi route translations đều đã gọi được mà không có cách nào biết field nào an toàn để gửi.
20
+ - sb_set giờ cảnh báo một lần cho mỗi loại node khi một config key thuộc field-skin (`payCardBg`, `choice*`, `slot*`, `file*`, và phần còn lại trong vocabulary 55 key) được ghi trên một form node mà renderer của nó không đọc key đó, nêu tên field node thực sự render nó, vì việc ghi này vẫn được lưu, save và publish nhưng không được render ở đâu cả.
21
+
22
+ ### Internal
23
+ - Catalog được sinh ra giờ có thêm bảng translations (`translations.generated.ts`, 141 special có thể dịch trên 58 element, 156 key được phân loại không bao giờ dịch, 12 loại entity cùng các cột của chúng) và bảng field-skin (`fieldskin.generated.ts`), cả hai đều được đối chiếu chéo lúc codegen với chính các registry của nền tảng thay vì giữ thủ công.
24
+
9
25
  ## [0.15.0] - 2026-09-10
10
26
 
11
27
  ### Added
@@ -1,5 +1,6 @@
1
1
  import { ELEMENTS, TRAIT_WRITES } from './elements.generated.js';
2
2
  import { vocabulariesForWrites } from '../domains/site/vocabulary.js';
3
+ import { neverTranslatedOn, translatableSpecials } from '../domains/site/translate.js';
3
4
  /** Eight to choose from; the hints for the chosen one come with sb_traits_for. */
4
5
  export const DEFAULT_CATALOG_LIMIT = 8;
5
6
  /**
@@ -95,6 +96,24 @@ export function traitsFor(type, control) {
95
96
  // which, unlike `style`, are NOT open — this is the machine-readable
96
97
  // answer to "what does this element store", and often the only one.
97
98
  defaults: el.defaults,
99
+ // WHICH OF THIS ELEMENT'S STRINGS A TRANSLATION MAY REWRITE. Translating one
100
+ // of the others does not degrade the page, it BREAKS the render — `name` is
101
+ // a lucide icon id, `src` a URL, `filterSource` a registry id the renderer
102
+ // switches on. An empty list is the complete answer for an element whose
103
+ // only string is one of those, so it is reported rather than omitted.
104
+ ...(() => {
105
+ const may = translatableSpecials(el.type);
106
+ const own = Object.keys(el.defaults?.specials ?? {});
107
+ const never = neverTranslatedOn(el.type, own);
108
+ if (!may.length && !never.length)
109
+ return {};
110
+ return {
111
+ translatable: {
112
+ specials: may,
113
+ ...(never.length ? { never_translate: never } : {}),
114
+ },
115
+ };
116
+ })(),
98
117
  // WHAT THOSE KEYS ARE ALLOWED TO HOLD, for the few where guessing wrong is
99
118
  // silent. Attached to the ELEMENT rather than to a control, because the keys
100
119
  // that most need it are exactly the UNDECLARED ones: `collectionType` is in
@@ -0,0 +1,162 @@
1
+ // GENERATED by scripts/gen-catalog.ts — do not edit by hand.
2
+ // Source: <WB_REPO>/schema/src/elements/fieldSkin.ts (the vocabulary) and each
3
+ // server/render/nodes/form*/css.go (which node emits which group).
4
+ export const FIELD_SKIN_SOURCE = {
5
+ "nodes": 11,
6
+ "keys": 55
7
+ };
8
+ /**
9
+ * The field-skin config keys each form node's renderer actually reads.
10
+ *
11
+ * A knob written on a node that is not in its list is stored, saved, published
12
+ * and rendered NOWHERE — the documented case being payCard* on the FORM, which
13
+ * emits only the input vocabulary. Composed from the TS groups and asserted
14
+ * against the Go's own key literals at codegen.
15
+ */
16
+ export const FIELD_SKIN_BY_NODE = {
17
+ "form": [
18
+ "fieldStackGap",
19
+ "fieldReqColor",
20
+ "fieldHintOpacity",
21
+ "fieldBg",
22
+ "fieldBorderColor",
23
+ "fieldBorderWidth",
24
+ "fieldRadius",
25
+ "fieldPadY",
26
+ "fieldPadX"
27
+ ],
28
+ "form-calendar": [
29
+ "fieldStackGap",
30
+ "fieldReqColor",
31
+ "fieldHintOpacity",
32
+ "fieldBg",
33
+ "fieldBorderColor",
34
+ "fieldBorderWidth",
35
+ "fieldRadius",
36
+ "fieldPadY",
37
+ "fieldPadX"
38
+ ],
39
+ "form-checkbox": [
40
+ "fieldStackGap",
41
+ "fieldReqColor",
42
+ "fieldHintOpacity",
43
+ "choiceRowGap",
44
+ "choiceColGap",
45
+ "choiceGap",
46
+ "choiceSize",
47
+ "choiceBorderWidth",
48
+ "choiceBorderColor",
49
+ "choiceBg",
50
+ "choiceAccent",
51
+ "choiceRadius",
52
+ "choiceMarkWidth"
53
+ ],
54
+ "form-date": [
55
+ "fieldStackGap",
56
+ "fieldReqColor",
57
+ "fieldHintOpacity",
58
+ "fieldBg",
59
+ "fieldBorderColor",
60
+ "fieldBorderWidth",
61
+ "fieldRadius",
62
+ "fieldPadY",
63
+ "fieldPadX"
64
+ ],
65
+ "form-file": [
66
+ "fieldStackGap",
67
+ "fieldReqColor",
68
+ "fieldHintOpacity",
69
+ "fileGap",
70
+ "filePlaceholderWeight"
71
+ ],
72
+ "form-number": [
73
+ "fieldStackGap",
74
+ "fieldReqColor",
75
+ "fieldHintOpacity",
76
+ "fieldBg",
77
+ "fieldBorderColor",
78
+ "fieldBorderWidth",
79
+ "fieldRadius",
80
+ "fieldPadY",
81
+ "fieldPadX"
82
+ ],
83
+ "form-payment": [
84
+ "fieldStackGap",
85
+ "fieldReqColor",
86
+ "fieldHintOpacity",
87
+ "payCardBg",
88
+ "payCardBorderColor",
89
+ "payCardBorderWidth",
90
+ "payCardRadius",
91
+ "payCardPadY",
92
+ "payCardPadX",
93
+ "payCardGap",
94
+ "payCardRowGap",
95
+ "payCardAccent",
96
+ "payCardSelectedBg",
97
+ "payCardHoverColor",
98
+ "payCardHoverBg",
99
+ "payCardText",
100
+ "payCardNameWeight",
101
+ "payCardDescOpacity",
102
+ "payCardBodyGap",
103
+ "payMarkerSize",
104
+ "payMarkerDot"
105
+ ],
106
+ "form-radio": [
107
+ "fieldStackGap",
108
+ "fieldReqColor",
109
+ "fieldHintOpacity",
110
+ "choiceRowGap",
111
+ "choiceColGap",
112
+ "choiceGap",
113
+ "choiceSize",
114
+ "choiceBorderWidth",
115
+ "choiceBorderColor",
116
+ "choiceBg",
117
+ "choiceAccent",
118
+ "choiceDot"
119
+ ],
120
+ "form-select": [
121
+ "fieldStackGap",
122
+ "fieldReqColor",
123
+ "fieldHintOpacity",
124
+ "fieldBg",
125
+ "fieldBorderColor",
126
+ "fieldBorderWidth",
127
+ "fieldRadius",
128
+ "fieldPadY",
129
+ "fieldPadX"
130
+ ],
131
+ "form-text": [
132
+ "fieldStackGap",
133
+ "fieldReqColor",
134
+ "fieldHintOpacity",
135
+ "fieldBg",
136
+ "fieldBorderColor",
137
+ "fieldBorderWidth",
138
+ "fieldRadius",
139
+ "fieldPadY",
140
+ "fieldPadX"
141
+ ],
142
+ "form-timeslot": [
143
+ "fieldStackGap",
144
+ "fieldReqColor",
145
+ "fieldHintOpacity",
146
+ "slotBg",
147
+ "slotBorderColor",
148
+ "slotBorderWidth",
149
+ "slotRadius",
150
+ "slotPadY",
151
+ "slotPadX",
152
+ "slotGap",
153
+ "slotMinWidth",
154
+ "slotMinHeight",
155
+ "slotAccent",
156
+ "slotHoverColor",
157
+ "slotSelectedWeight",
158
+ "slotMarkerSize",
159
+ "slotMarkerDot",
160
+ "slotMarkerGap"
161
+ ]
162
+ };
@@ -1,5 +1,6 @@
1
1
  import { API_OPERATIONS, API_DEFINITIONS } from './api.generated.js';
2
2
  import { REQUEST_SHAPES } from './shapes.generated.js';
3
+ import { translationCallSheet } from '../domains/site/translate.js';
3
4
  /**
4
5
  * Term-hit scoring, weighted by field, and deliberately NOT fuzzy.
5
6
  *
@@ -82,6 +83,14 @@ export function describeOperation(op) {
82
83
  // The two warnings below are dropped when a shape is present: they exist
83
84
  // because the shape was unknown, and keeping them once it is known is prose
84
85
  // the model has to read past on its way to the answer.
86
+ // A TRANSLATIONS OPERATION IS REACHABLE AND WAS UNSAFE. Every route here is in
87
+ // the catalog, so an agent could call them all and had no way to know which
88
+ // fields are content — and translating the wrong special BREAKS the render
89
+ // rather than degrading it. Attached to the call sheet because that is where
90
+ // the agent already is when it decides what to send.
91
+ if (/\/translations(\/|$)/.test(op.path)) {
92
+ out.translation_fields = translationCallSheet();
93
+ }
85
94
  const shape = REQUEST_SHAPES[op.id];
86
95
  if (shape) {
87
96
  out.body_shape = shape;
@@ -4,7 +4,7 @@ export const SHAPE_SOURCE = {
4
4
  "fromHandlers": 166,
5
5
  "fromSwaggerOnly": 0,
6
6
  "withReadOnly": 26,
7
- "structsRead": 1637
7
+ "structsRead": 1641
8
8
  };
9
9
  export const REQUEST_SHAPES = {
10
10
  "post:/api/sites/{siteId}/api-keys": {
@@ -0,0 +1,656 @@
1
+ // GENERATED by scripts/gen-catalog.ts — do not edit by hand.
2
+ // Source: <WB_REPO>/schema/src/elements/translatableFields.ts
3
+ export const TRANSLATION_SOURCE = {
4
+ "elements": 58,
5
+ "pairs": 141,
6
+ "neverKeys": 156,
7
+ "entityTypes": 12
8
+ };
9
+ /** Every entity type a translation record can name, including "node". */
10
+ export const TRANSLATION_ENTITY_TYPES = [
11
+ "product",
12
+ "category",
13
+ "article",
14
+ "blogCategory",
15
+ "course",
16
+ "review",
17
+ "courseSection",
18
+ "courseLesson",
19
+ "node",
20
+ "page",
21
+ "uiString",
22
+ "mailString",
23
+ "siteLabel"
24
+ ];
25
+ /**
26
+ * The specials a translation MAY rewrite, per element type.
27
+ *
28
+ * An element absent here has none. That is not an oversight: an icon's only
29
+ * string is a lucide id.
30
+ */
31
+ export const TRANSLATABLE_SPECIALS = {
32
+ "popup": [
33
+ "closeLabel"
34
+ ],
35
+ "select": [
36
+ "placeholder",
37
+ "filterValues"
38
+ ],
39
+ "filter-checkbox": [
40
+ "filterValues"
41
+ ],
42
+ "filter-radio": [
43
+ "filterValues"
44
+ ],
45
+ "filter-color": [
46
+ "filterValues"
47
+ ],
48
+ "filter-tag": [
49
+ "filterValues"
50
+ ],
51
+ "account-info": [
52
+ "promptText",
53
+ "loginLabel"
54
+ ],
55
+ "cart-count": [
56
+ "placeholder"
57
+ ],
58
+ "cart-total": [
59
+ "label",
60
+ "placeholder"
61
+ ],
62
+ "order-history": [
63
+ "title",
64
+ "emptyText",
65
+ "signedOutText",
66
+ "lookupNumberPlaceholder",
67
+ "lookupContactPlaceholder",
68
+ "lookupButtonText"
69
+ ],
70
+ "order-receipt": [
71
+ "title",
72
+ "numberLabel",
73
+ "placedLabel",
74
+ "statusLabel",
75
+ "itemsLabel",
76
+ "totalLabel",
77
+ "dueLabel",
78
+ "unavailableText"
79
+ ],
80
+ "course-outline": [
81
+ "emptyText"
82
+ ],
83
+ "my-courses": [
84
+ "emptyText",
85
+ "signedOutText",
86
+ "continueText"
87
+ ],
88
+ "course-player": [
89
+ "completeLabel",
90
+ "completedLabel",
91
+ "certificateText",
92
+ "lockedText",
93
+ "lockedLabel",
94
+ "emptyText"
95
+ ],
96
+ "wishlist-list": [
97
+ "title",
98
+ "emptyText"
99
+ ],
100
+ "address-book": [
101
+ "title",
102
+ "emptyText",
103
+ "signedOutText",
104
+ "addLabel"
105
+ ],
106
+ "points-card": [
107
+ "title",
108
+ "signedOutText"
109
+ ],
110
+ "member-field": [
111
+ "prefix",
112
+ "suffix",
113
+ "guestText"
114
+ ],
115
+ "accordion-content": [
116
+ "label"
117
+ ],
118
+ "button": [
119
+ "text",
120
+ "textHtml"
121
+ ],
122
+ "cart-order": [
123
+ "emptyText"
124
+ ],
125
+ "payment-status": [
126
+ "paidTitle",
127
+ "paidText",
128
+ "pendingTitle",
129
+ "pendingText",
130
+ "failedTitle",
131
+ "failedText",
132
+ "retryLabel",
133
+ "referenceLabel"
134
+ ],
135
+ "collection-media": [
136
+ "alt"
137
+ ],
138
+ "divider": [
139
+ "label"
140
+ ],
141
+ "heading": [
142
+ "text",
143
+ "textHtml"
144
+ ],
145
+ "form-text": [
146
+ "label",
147
+ "placeholder",
148
+ "description"
149
+ ],
150
+ "form-number": [
151
+ "label",
152
+ "placeholder",
153
+ "description"
154
+ ],
155
+ "form-select": [
156
+ "label",
157
+ "options",
158
+ "placeholder",
159
+ "description"
160
+ ],
161
+ "form-address": [
162
+ "label",
163
+ "description",
164
+ "provinceLabel",
165
+ "wardLabel",
166
+ "detailLabel",
167
+ "provincePlaceholder",
168
+ "wardPlaceholder",
169
+ "detailPlaceholder",
170
+ "placeholder"
171
+ ],
172
+ "form-radio": [
173
+ "label",
174
+ "options",
175
+ "description"
176
+ ],
177
+ "form-payment": [
178
+ "label",
179
+ "description",
180
+ "methods"
181
+ ],
182
+ "form-timeslot": [
183
+ "label",
184
+ "description"
185
+ ],
186
+ "form-checkbox": [
187
+ "label",
188
+ "options",
189
+ "description"
190
+ ],
191
+ "form-date": [
192
+ "label",
193
+ "description",
194
+ "dayPlaceholder",
195
+ "monthPlaceholder",
196
+ "yearPlaceholder",
197
+ "dayLabel",
198
+ "monthLabel",
199
+ "yearLabel",
200
+ "timeLabel"
201
+ ],
202
+ "form-calendar": [
203
+ "label",
204
+ "placeholder",
205
+ "description"
206
+ ],
207
+ "form-file": [
208
+ "label",
209
+ "placeholder",
210
+ "hintText",
211
+ "description",
212
+ "buttonLabel"
213
+ ],
214
+ "form-paragraph": [
215
+ "text"
216
+ ],
217
+ "form-step-button": [
218
+ "text"
219
+ ],
220
+ "form-submit": [
221
+ "text",
222
+ "submittingText"
223
+ ],
224
+ "form-title": [
225
+ "text"
226
+ ],
227
+ "image": [
228
+ "alt"
229
+ ],
230
+ "image-comparison": [
231
+ "beforeAlt",
232
+ "afterAlt"
233
+ ],
234
+ "list-dataset": [
235
+ "loadMoreLabel"
236
+ ],
237
+ "list-item": [
238
+ "text"
239
+ ],
240
+ "product-image-feature": [
241
+ "alt"
242
+ ],
243
+ "search-input": [
244
+ "searchPlaceholder",
245
+ "searchClearLabel",
246
+ "searchToggleLabel"
247
+ ],
248
+ "tab-content": [
249
+ "label"
250
+ ],
251
+ "text": [
252
+ "text",
253
+ "textHtml"
254
+ ],
255
+ "text-marquee": [
256
+ "text"
257
+ ],
258
+ "text-marquee-item": [
259
+ "text"
260
+ ],
261
+ "breadcrumb": [
262
+ "crumbs",
263
+ "homeLabel"
264
+ ],
265
+ "menu": [
266
+ "menuItems"
267
+ ],
268
+ "chat-widget": [
269
+ "title"
270
+ ],
271
+ "qr-code": [
272
+ "alt",
273
+ "value"
274
+ ],
275
+ "bundle-items": [
276
+ "heading",
277
+ "quantitySeparator"
278
+ ],
279
+ "search-keywords": [
280
+ "keywordItems"
281
+ ],
282
+ "locale-switcher": [
283
+ "panelTitle"
284
+ ],
285
+ "theme-switcher": [
286
+ "lightLabel",
287
+ "darkLabel"
288
+ ]
289
+ };
290
+ /**
291
+ * Specials keys that must NEVER be translated, across every element.
292
+ *
293
+ * Translating one of these does not degrade the page — it BREAKS the render.
294
+ * Kept as the platform's whole classification rather than an allow-list,
295
+ * because a positive-only list goes stale silently as elements ship new strings.
296
+ */
297
+ export const NEVER_TRANSLATED = [
298
+ "acceptedDates",
299
+ "addressGroup",
300
+ "addressLink",
301
+ "afterSrc",
302
+ "allowMultiple",
303
+ "allowedCodes",
304
+ "arrow",
305
+ "audience",
306
+ "authKind",
307
+ "autoComplete",
308
+ "availableDays",
309
+ "beforeSrc",
310
+ "bookMaxDays",
311
+ "bookMinDays",
312
+ "bookSlots",
313
+ "boundBundleItems",
314
+ "boundCompareCents",
315
+ "boundHref",
316
+ "boundHrefLabel",
317
+ "boundMoneyOverride",
318
+ "boundPriceCents",
319
+ "boundProductURL",
320
+ "boundValue",
321
+ "breakEnd",
322
+ "breakStart",
323
+ "button",
324
+ "capturesProduct",
325
+ "className",
326
+ "closeIcon",
327
+ "code",
328
+ "collectionIds",
329
+ "contentType",
330
+ "copyFrom",
331
+ "country",
332
+ "courseId",
333
+ "customCss",
334
+ "customName",
335
+ "ddFilterSource",
336
+ "ddFilterStash",
337
+ "ddSortStash",
338
+ "defaultMode",
339
+ "defaultVariant",
340
+ "dir",
341
+ "displayType",
342
+ "end",
343
+ "field",
344
+ "fileFormat",
345
+ "fileLimit",
346
+ "filterArity",
347
+ "filterAxis",
348
+ "filterBehavior",
349
+ "filterColors",
350
+ "filterExcluded",
351
+ "filterMatch",
352
+ "filterRanges",
353
+ "filterSource",
354
+ "filterTarget",
355
+ "filterTargets",
356
+ "filterValueMode",
357
+ "filterable",
358
+ "formId",
359
+ "formRules",
360
+ "format",
361
+ "hideWhenEmpty",
362
+ "hoverHostDepth",
363
+ "href",
364
+ "htmlTag",
365
+ "icon",
366
+ "iconEnabled",
367
+ "iconName",
368
+ "inputType",
369
+ "keywordAction",
370
+ "kind",
371
+ "labelMode",
372
+ "learnHref",
373
+ "limitChars",
374
+ "lockedHref",
375
+ "loginHref",
376
+ "logoShape",
377
+ "lookupEnabled",
378
+ "mapTo",
379
+ "mapType",
380
+ "maxCents",
381
+ "maxChars",
382
+ "maxLabelText",
383
+ "maxValue",
384
+ "menuId",
385
+ "minCents",
386
+ "minChars",
387
+ "minLabelText",
388
+ "minValue",
389
+ "moneyPayload",
390
+ "moreButtonEnabled",
391
+ "multiline",
392
+ "name",
393
+ "openOnHover",
394
+ "orderSource",
395
+ "orientation",
396
+ "overlayId",
397
+ "part",
398
+ "parts",
399
+ "pattern",
400
+ "patternCountries",
401
+ "patternPreset",
402
+ "payload",
403
+ "picker",
404
+ "poster",
405
+ "prefillValue",
406
+ "presetCode",
407
+ "provinceField",
408
+ "ratingValue",
409
+ "required",
410
+ "reviewForm",
411
+ "sameDefault",
412
+ "searchBehavior",
413
+ "searchClearSwap",
414
+ "searchDebounceMs",
415
+ "searchDisplay",
416
+ "searchIconPosition",
417
+ "searchMinChars",
418
+ "searchOnEnter",
419
+ "searchPages",
420
+ "searchScope",
421
+ "searchShowClear",
422
+ "searchTargets",
423
+ "searchable",
424
+ "segmentId",
425
+ "selectMode",
426
+ "sentRedirect",
427
+ "sentText",
428
+ "setRange",
429
+ "showCount",
430
+ "showCurrent",
431
+ "showDetail",
432
+ "showFlag",
433
+ "showHeading",
434
+ "showIcon",
435
+ "showLabel",
436
+ "showLogos",
437
+ "showMarker",
438
+ "showOutline",
439
+ "showPhone",
440
+ "showPlaceholder",
441
+ "source",
442
+ "specificDate",
443
+ "src",
444
+ "start",
445
+ "step",
446
+ "stepMode",
447
+ "stylePreset",
448
+ "symbolChar",
449
+ "target",
450
+ "trackUrl",
451
+ "variant",
452
+ "videoId",
453
+ "videoSrc"
454
+ ];
455
+ /** The columns a translation may rewrite on each entity, SEO fields included. */
456
+ export const TRANSLATABLE_ENTITY_FIELDS = {
457
+ "product": [
458
+ {
459
+ "key": "title"
460
+ },
461
+ {
462
+ "key": "description",
463
+ "html": true
464
+ },
465
+ {
466
+ "key": "vendor"
467
+ },
468
+ {
469
+ "key": "attributes",
470
+ "list": "attributes"
471
+ },
472
+ {
473
+ "key": "seoTitle"
474
+ },
475
+ {
476
+ "key": "seoDescription"
477
+ },
478
+ {
479
+ "key": "seoKeywords"
480
+ },
481
+ {
482
+ "key": "ogTitle"
483
+ },
484
+ {
485
+ "key": "ogDesc"
486
+ }
487
+ ],
488
+ "category": [
489
+ {
490
+ "key": "title"
491
+ },
492
+ {
493
+ "key": "description",
494
+ "html": true
495
+ },
496
+ {
497
+ "key": "seoTitle"
498
+ },
499
+ {
500
+ "key": "seoDescription"
501
+ },
502
+ {
503
+ "key": "seoKeywords"
504
+ },
505
+ {
506
+ "key": "ogTitle"
507
+ },
508
+ {
509
+ "key": "ogDesc"
510
+ }
511
+ ],
512
+ "article": [
513
+ {
514
+ "key": "title"
515
+ },
516
+ {
517
+ "key": "summary"
518
+ },
519
+ {
520
+ "key": "content",
521
+ "html": true,
522
+ "multiline": true
523
+ },
524
+ {
525
+ "key": "tags"
526
+ },
527
+ {
528
+ "key": "seoTitle"
529
+ },
530
+ {
531
+ "key": "seoDescription"
532
+ },
533
+ {
534
+ "key": "seoKeywords"
535
+ },
536
+ {
537
+ "key": "ogTitle"
538
+ },
539
+ {
540
+ "key": "ogDesc"
541
+ }
542
+ ],
543
+ "blogCategory": [
544
+ {
545
+ "key": "title"
546
+ },
547
+ {
548
+ "key": "description"
549
+ },
550
+ {
551
+ "key": "seoTitle"
552
+ },
553
+ {
554
+ "key": "seoDescription"
555
+ },
556
+ {
557
+ "key": "seoKeywords"
558
+ },
559
+ {
560
+ "key": "ogTitle"
561
+ },
562
+ {
563
+ "key": "ogDesc"
564
+ }
565
+ ],
566
+ "course": [
567
+ {
568
+ "key": "title"
569
+ },
570
+ {
571
+ "key": "summary"
572
+ },
573
+ {
574
+ "key": "description",
575
+ "html": true,
576
+ "multiline": true
577
+ },
578
+ {
579
+ "key": "level"
580
+ },
581
+ {
582
+ "key": "instructorTitle"
583
+ },
584
+ {
585
+ "key": "instructorBio",
586
+ "html": true,
587
+ "multiline": true
588
+ },
589
+ {
590
+ "key": "tags"
591
+ },
592
+ {
593
+ "key": "seoTitle"
594
+ },
595
+ {
596
+ "key": "seoDescription"
597
+ },
598
+ {
599
+ "key": "seoKeywords"
600
+ },
601
+ {
602
+ "key": "ogTitle"
603
+ },
604
+ {
605
+ "key": "ogDesc"
606
+ }
607
+ ],
608
+ "review": [
609
+ {
610
+ "key": "reply",
611
+ "html": true
612
+ }
613
+ ],
614
+ "courseSection": [
615
+ {
616
+ "key": "title"
617
+ }
618
+ ],
619
+ "courseLesson": [
620
+ {
621
+ "key": "title"
622
+ }
623
+ ],
624
+ "page": [
625
+ {
626
+ "key": "title"
627
+ },
628
+ {
629
+ "key": "description"
630
+ },
631
+ {
632
+ "key": "keywords"
633
+ },
634
+ {
635
+ "key": "ogTitle"
636
+ },
637
+ {
638
+ "key": "ogDesc"
639
+ }
640
+ ],
641
+ "uiString": [
642
+ {
643
+ "key": "text"
644
+ }
645
+ ],
646
+ "mailString": [
647
+ {
648
+ "key": "text"
649
+ }
650
+ ],
651
+ "siteLabel": [
652
+ {
653
+ "key": "text"
654
+ }
655
+ ]
656
+ };
@@ -130,6 +130,29 @@ export class PageDoc {
130
130
  applyPatches(this.doc, patches);
131
131
  this.revision += 1;
132
132
  }
133
+ /**
134
+ * What this document WOULD be with these patches applied — a throwaway copy,
135
+ * so the real one is untouched.
136
+ *
137
+ * It exists so a write can be judged before it is made. Applying first and
138
+ * validating after leaves a refused edit sitting in the draft: the platform
139
+ * declines the save, the node stays, and every later command is then judged
140
+ * against a tree the caller never asked for and cannot see. Driving the real
141
+ * server, that arrived as three `sb_add` calls in a row all answering with the
142
+ * same complaint about a node id the caller had never typed.
143
+ *
144
+ * A JSON round-trip rather than anything cleverer. A page is hundreds of KB,
145
+ * which is a millisecond, and it is paid only on a write that is actually
146
+ * being committed — never on a dry run, never on a read.
147
+ */
148
+ preview(patches) {
149
+ const copy = new PageDoc(JSON.parse(JSON.stringify(this.doc)), this.revision);
150
+ // Carried, not re-derived: a validator that saw a different root key on the
151
+ // copy than on the original would be judging a different page.
152
+ copy.adoptedRootKey = this.adoptedRootKey;
153
+ copy.apply(patches);
154
+ return copy;
155
+ }
133
156
  /**
134
157
  * A compressed tree — id, type, name, child count, and the flags that change
135
158
  * what a caller may safely do with a node.
@@ -0,0 +1,59 @@
1
+ import { FIELD_SKIN_BY_NODE } from '../../catalog/fieldskin.generated.js';
2
+ /**
3
+ * A FORM'S FIELDS ARE STYLED BY CONFIG KEYS, AND THE WRONG LEVEL IS SILENT.
4
+ *
5
+ * `fieldSkin.ts` / `fieldskin.go` hold the vocabulary, and each form node's
6
+ * `css.go` emits only the group it names. A knob written on a node that does not
7
+ * read it is stored, saved, published, and rendered NOWHERE — the same shape as
8
+ * a binding outside the `specials` namespace.
9
+ *
10
+ * CLAUDE.md carried the rule as PROSE ("form/css.go emits only FieldKnobs, so
11
+ * payCard* written on the form is stored and rendered nowhere"), which is a
12
+ * hand-kept summary of a 55-key table across 11 nodes. It is generated now: the
13
+ * groups from `fieldSkin.ts` (pure data), the node mapping from each `css.go`'s
14
+ * `fieldskin.<Group>` identifier, and the vocabulary cross-checked against the
15
+ * Go's own key literals at codegen.
16
+ *
17
+ * THE ANSWER IS PER NODE, not per key: `payCardBg` is real and rendered on
18
+ * `form-payment`, and dead on `form`. So a caller is told which node DOES read
19
+ * it, which is the only thing that turns the warning into a fix.
20
+ */
21
+ /** Every field-skin key, across every form node. */
22
+ const ALL_KEYS = new Set(Object.values(FIELD_SKIN_BY_NODE).flat());
23
+ /** Does this node's renderer read this skin key? */
24
+ export function readsSkinKey(nodeType, key) {
25
+ return (FIELD_SKIN_BY_NODE[nodeType] ?? []).includes(key);
26
+ }
27
+ /** The node types whose renderer DOES read a key — the fix half of the warning. */
28
+ export function nodesReading(key) {
29
+ return Object.entries(FIELD_SKIN_BY_NODE)
30
+ .filter(([, keys]) => keys.includes(key))
31
+ .map(([type]) => type)
32
+ .sort();
33
+ }
34
+ /**
35
+ * The warning for field-skin keys written on a node that renders none of them.
36
+ *
37
+ * Only fires for a key that IS a skin knob somewhere — an unknown config key is
38
+ * not this module's business, and claiming it would put a false positive on
39
+ * every ordinary write. A node that reads no skin keys at all (an ordinary
40
+ * heading) is equally not the target: the mistake this catches is a skin key on
41
+ * the WRONG form node, which is the one the platform documents.
42
+ */
43
+ export function skinLevelNote(nodeType, keys) {
44
+ const misplaced = keys.filter((k) => ALL_KEYS.has(k) && !readsSkinKey(nodeType, k));
45
+ if (!misplaced.length)
46
+ return null;
47
+ const detail = misplaced
48
+ .map((k) => {
49
+ const readers = nodesReading(k);
50
+ return `${k} (read by ${readers.join(', ') || 'no node'})`;
51
+ })
52
+ .join('; ');
53
+ return (`"${nodeType}" renders none of these field-skin keys, so they would be stored, saved, ` +
54
+ `published and read by nothing: ${detail}. A skin knob is emitted only by the node whose ` +
55
+ 'css.go names its group — the FORM dresses every field it holds with the input vocabulary, ' +
56
+ 'and a payment card, choice group, timeslot or file field carries its own. Write them on ' +
57
+ 'the field node, which lives in the FORM DOCUMENT ' +
58
+ '(PUT /api/sites/{siteId}/forms/{id}/document), not on the page.');
59
+ }
@@ -0,0 +1,72 @@
1
+ import { NEVER_TRANSLATED, TRANSLATABLE_ENTITY_FIELDS, TRANSLATABLE_SPECIALS, TRANSLATION_ENTITY_TYPES, } from '../../catalog/translations.generated.js';
2
+ /**
3
+ * WHICH CONTENT A TRANSLATION MAY REWRITE — AND WHICH BREAKS THE PAGE.
4
+ *
5
+ * A multi-language store was REACHABLE and UNSAFE. Every translations route is
6
+ * in the catalog — read, write, auto-fill, the review queue — so an agent could
7
+ * call them all and had no way to know which fields are content.
8
+ *
9
+ * The platform's registry says why that matters: the element registry declares
10
+ * 117 `(element, special)` pairs across 66 keys, INTERLEAVED in one object —
11
+ *
12
+ * text · label · alt · emptyText · searchPlaceholder ← content
13
+ * htmlTag · videoId · filterSource · contentType · src · name ← NOT
14
+ *
15
+ * — and translating one of the second group "does not degrade the page, it
16
+ * breaks the render": `name` is a lucide icon id, `src` is a URL,
17
+ * `filterSource` is a registry id the Go predicate switches on. An agent
18
+ * walking a page document and translating every string it finds hits all three,
19
+ * and the page it hands back renders wrong with nothing reporting why.
20
+ *
21
+ * A NEGATIVE ANSWER HERE IS INFORMATION, not an absence. `icon` has no
22
+ * translatable specials at all, and that is the correct, complete answer.
23
+ *
24
+ * NO NEW TOOL: this rides inside `sb_traits_for`'s result and the call sheet
25
+ * `sb_api_find` already prints for a translations operation.
26
+ */
27
+ /** The specials a translation may rewrite on this element. Empty is an answer. */
28
+ export function translatableSpecials(elementType) {
29
+ return TRANSLATABLE_SPECIALS[elementType] ?? [];
30
+ }
31
+ /** Would translating this key break a render, whatever element it is on? */
32
+ export function isNeverTranslated(key) {
33
+ return NEVER_TRANSLATED.includes(key);
34
+ }
35
+ /**
36
+ * The specials this element HAS that must never be translated.
37
+ *
38
+ * Named per element rather than handed over as the whole 156-key list, because
39
+ * the answer a caller needs is about the node in front of it — and a list that
40
+ * long, on every element, is the kind of weight that makes a result unreadable.
41
+ */
42
+ export function neverTranslatedOn(elementType, ownKeys) {
43
+ const ok = new Set(translatableSpecials(elementType));
44
+ return ownKeys.filter((k) => !ok.has(k) && isNeverTranslated(k));
45
+ }
46
+ /** The columns a translation may rewrite on an entity, or null for an unknown one. */
47
+ export function entityFields(entityType) {
48
+ return TRANSLATABLE_ENTITY_FIELDS[entityType] ?? null;
49
+ }
50
+ export function entityTypes() {
51
+ return TRANSLATION_ENTITY_TYPES;
52
+ }
53
+ /**
54
+ * The vocabulary a translations operation needs, for its call sheet.
55
+ *
56
+ * The ENTITY FIELDS are the body of it; `node` is called out separately because
57
+ * it is the one entity type with no column list — a node translation is keyed
58
+ * by (node id, special), so its vocabulary is per element and lives in
59
+ * `sb_traits_for`. Without that sentence the empty answer for `node` reads as
60
+ * "nothing on a node is translatable", which is the opposite of true.
61
+ */
62
+ export function translationCallSheet() {
63
+ return {
64
+ entity_types: TRANSLATION_ENTITY_TYPES,
65
+ entity_fields: TRANSLATABLE_ENTITY_FIELDS,
66
+ node_note: 'entityType "node" has no column list because a node translation is keyed by (node id, ' +
67
+ 'specials key). Ask sb_traits_for <element> for `translatable` — it names the specials ' +
68
+ 'that element allows. Translating any OTHER special BREAKS the render rather than ' +
69
+ 'degrading it: `name` is a lucide icon id, `src` a URL, `filterSource` a registry id the ' +
70
+ 'renderer switches on. Never walk a document translating every string you find.',
71
+ };
72
+ }
@@ -121,13 +121,24 @@ export function registerImportTools(server, ctx, session) {
121
121
  `Skipped: ${JSON.stringify(shot.skipped)}. A page that builds itself with scripts ` +
122
122
  'after load, or one behind a login, reads as empty here.');
123
123
  }
124
+ // STAGED ON A COPY, committed once.
125
+ //
126
+ // Each section's patch set is computed from the tree the previous one
127
+ // left, so they have to be applied in order — but applying them to the
128
+ // REAL document means a refusal at the end leaves the page half imported,
129
+ // with no way for the caller to tell which half. Building on a throwaway
130
+ // and committing the whole run through `applyAndSave` keeps the import
131
+ // all-or-nothing, and keeps it to one save and one live frame.
124
132
  const added = [];
133
+ const all = [];
134
+ const staged = doc.preview([]);
125
135
  for (const spec of specs) {
126
- const { patches, ids } = addSubtree(doc, doc.doc.root_node_id, spec);
127
- session.applyAndPublish(patches);
136
+ const { patches, ids } = addSubtree(staged, staged.doc.root_node_id, spec);
137
+ staged.apply(patches);
138
+ all.push(...patches);
128
139
  added.push(ids[0]);
129
140
  }
130
- await session.save();
141
+ await session.applyAndSave(all);
131
142
  return text({
132
143
  read: shot.url,
133
144
  added_sections: added,
@@ -452,8 +452,7 @@ export function registerLiveTools(server, ctx, session) {
452
452
  const patches = setEvent(d, id, trigger ?? 'click', action, payload);
453
453
  if (dry_run !== false)
454
454
  return text({ dry_run: true, patches });
455
- session.applyAndPublish(patches);
456
- await session.save();
455
+ await session.applyAndSave(patches);
457
456
  return text({ node: id, trigger: trigger ?? 'click', action, rev: d.rev });
458
457
  });
459
458
  server.registerTool('sb_bind', {
@@ -481,8 +480,7 @@ export function registerLiveTools(server, ctx, session) {
481
480
  const patches = bindNode(d, id, source, field, action);
482
481
  if (dry_run !== false)
483
482
  return text({ dry_run: true, patches });
484
- session.applyAndPublish(patches);
485
- await session.save();
483
+ await session.applyAndSave(patches);
486
484
  return text({ bound: id, source, field, ...(action ? { action } : {}), rev: d.rev });
487
485
  });
488
486
  }
@@ -9,6 +9,7 @@ import { detachNote, presetIdOf, presetLayer } from '../domains/site/theme.js';
9
9
  import { inertHintsFor } from '../domains/site/inert.js';
10
10
  import { hasSeed, seedDocument, seedSummary, seededTypes } from '../domains/site/storepage.js';
11
11
  import { unknownValueNote } from '../domains/site/vocabulary.js';
12
+ import { skinLevelNote } from '../domains/site/fieldskin.js';
12
13
  import { siteTheme } from '../domains/site/theme-fetch.js';
13
14
  import { request, redact } from '../transport/http.js';
14
15
  import { siteToken } from './credentialpick.js';
@@ -100,6 +101,36 @@ export class PageSession {
100
101
  this.live.cursor(box.x + box.w / 2, box.y + box.h / 2);
101
102
  }
102
103
  }
104
+ /**
105
+ * The only sanctioned way to write: judge, then apply, publish and save.
106
+ *
107
+ * SPLITTING THESE WAS THE BUG. Every tool used to call `applyAndPublish` and
108
+ * then `save()`, and a save the platform would refuse threw with the patches
109
+ * already in the draft — and already broadcast to anyone watching the page
110
+ * live. The refused node then sat there, so the NEXT command was validated
111
+ * against a tree the caller had never asked for and got the same complaint
112
+ * about an id they had never typed. Three `sb_add` calls in a row, three
113
+ * identical refusals, and three copies of the element quietly in the page.
114
+ *
115
+ * The check runs on a COPY (`PageDoc.preview`), so a write that cannot be
116
+ * stored is never applied at all — no rollback to get wrong, and no phantom
117
+ * frame for a peer in the room to have to un-see.
118
+ *
119
+ * IT REFUSES ONLY WHAT THIS WRITE INTRODUCES. A page that arrived broken —
120
+ * damage stored before this session opened it — must not become a page nobody
121
+ * can edit, because the edit that repairs it is also a write. Pre-existing
122
+ * problems are left to `save()`, which names them.
123
+ */
124
+ async applyAndSave(patches) {
125
+ const d = this.current();
126
+ const before = new Set(validateForSave(d));
127
+ const introduced = validateForSave(d.preview(patches)).filter((p) => !before.has(p));
128
+ if (introduced.length > 0) {
129
+ throw new Error(`sbuilder: refusing to save — ${introduced.join(' ')}`);
130
+ }
131
+ this.applyAndPublish(patches);
132
+ await this.save();
133
+ }
103
134
  applyRemote(patches) {
104
135
  this.doc?.apply(patches);
105
136
  }
@@ -286,8 +317,7 @@ export function registerPageTools(server, ctx) {
286
317
  ...(inert ? { inert } : {}),
287
318
  });
288
319
  }
289
- session.applyAndPublish(patches);
290
- await session.save();
320
+ await session.applyAndSave(patches);
291
321
  return text({ added: ids, rev: d.rev, ...(inert ? { inert } : {}) });
292
322
  });
293
323
  server.registerTool('sb_set', {
@@ -441,6 +471,28 @@ export function registerPageTools(server, ctx) {
441
471
  }
442
472
  }
443
473
  const valueNote = valueNotes.join(' ');
474
+ // A FIELD-SKIN KNOB ON THE WRONG NODE renders nowhere. The FORM dresses
475
+ // every field it holds with the input vocabulary; a payment card, choice
476
+ // group, timeslot or file field carries its own, and a knob written on a
477
+ // node whose css.go does not name that group is stored and read by
478
+ // nothing. Once per node TYPE, because the answer is about the type.
479
+ const skinNotes = [];
480
+ const skinSeen = new Set();
481
+ for (const e of batch) {
482
+ if (e.namespace !== 'config')
483
+ continue;
484
+ const type = d.doc.nodes[e.id]?.data.type ?? '';
485
+ if (!type || skinSeen.has(type))
486
+ continue;
487
+ const n = skinLevelNote(type, Object.keys(e.keys));
488
+ if (!n)
489
+ continue;
490
+ skinSeen.add(type);
491
+ const once = ctx.notices.once(`field-skin:${type}`, n);
492
+ if (once)
493
+ skinNotes.push(once);
494
+ }
495
+ const skinNote = skinNotes.join(' ');
444
496
  // THE STICKY WARNING IS COMPUTED AGAINST THE DOCUMENT AS IT WILL BE, so
445
497
  // the dry run and the real run say the same thing. A caller who is told
446
498
  // only after committing has already shipped a header that does not move.
@@ -475,13 +527,13 @@ export function registerPageTools(server, ctx) {
475
527
  ...(baseNote ? { base_only: baseNote } : {}),
476
528
  ...(presetNote ? { preset: presetNote } : {}),
477
529
  ...(valueNote ? { value: valueNote } : {}),
530
+ ...(skinNote ? { field_skin: skinNote } : {}),
478
531
  ...(Object.keys(hostNotes).length ? { hover_host: hostNotes } : {}),
479
532
  ...(note ? { note } : {}),
480
533
  });
481
534
  }
482
535
  const warnings = stuck();
483
- session.applyAndPublish(patches);
484
- await session.save();
536
+ await session.applyAndSave(patches);
485
537
  for (const t of touched) {
486
538
  const w = globalWarning(d.doc, t.id);
487
539
  if (w)
@@ -497,6 +549,7 @@ export function registerPageTools(server, ctx) {
497
549
  ...(baseNote ? { base_only: baseNote } : {}),
498
550
  ...(presetNote ? { preset: presetNote } : {}),
499
551
  ...(valueNote ? { value: valueNote } : {}),
552
+ ...(skinNote ? { field_skin: skinNote } : {}),
500
553
  ...(hostNotes[batch[0].id] ? { hover_host: hostNotes[batch[0].id] } : {}),
501
554
  });
502
555
  }
@@ -508,6 +561,7 @@ export function registerPageTools(server, ctx) {
508
561
  ...(baseNote ? { base_only: baseNote } : {}),
509
562
  ...(presetNote ? { preset: presetNote } : {}),
510
563
  ...(valueNote ? { value: valueNote } : {}),
564
+ ...(skinNote ? { field_skin: skinNote } : {}),
511
565
  ...(Object.keys(hostNotes).length ? { hover_host: hostNotes } : {}),
512
566
  });
513
567
  });
@@ -525,8 +579,7 @@ export function registerPageTools(server, ctx) {
525
579
  const patches = moveNode(d, id, parent_id, index);
526
580
  if (dry_run !== false)
527
581
  return text({ dry_run: true, patches });
528
- session.applyAndPublish(patches);
529
- await session.save();
582
+ await session.applyAndSave(patches);
530
583
  return text({ moved: id, rev: d.rev });
531
584
  });
532
585
  server.registerTool('sb_remove', {
@@ -538,8 +591,7 @@ export function registerPageTools(server, ctx) {
538
591
  const patches = removeNode(d, id);
539
592
  if (dry_run !== false)
540
593
  return text({ dry_run: true, removing: patches.length });
541
- session.applyAndPublish(patches);
542
- await session.save();
594
+ await session.applyAndSave(patches);
543
595
  return text({ removed: id, rev: d.rev });
544
596
  });
545
597
  server.registerTool('sb_review', {
@@ -582,8 +634,7 @@ export function registerPageTools(server, ctx) {
582
634
  const { patches, ids } = duplicateNode(d, id);
583
635
  if (dry_run !== false)
584
636
  return text({ dry_run: true, would_copy: ids.length });
585
- session.applyAndPublish(patches);
586
- await session.save();
637
+ await session.applyAndSave(patches);
587
638
  return text({ duplicated: id, into: ids[0], nodes: ids.length, rev: d.rev });
588
639
  });
589
640
  server.registerTool('sb_templates', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
5
5
  "mcpName": "io.github.vuluu2k/sbuilder-mcp",
6
6
  "type": "module",