sbuilder-mcp 0.15.0 → 0.16.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 CHANGED
@@ -6,6 +6,16 @@ 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.0] - 2026-09-10
10
+
11
+ ### Added
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+
16
+ ### Internal
17
+ - 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.
18
+
9
19
  ## [0.15.0] - 2026-09-10
10
20
 
11
21
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,16 @@ 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.0] - 2026-09-10
10
+
11
+ ### Added
12
+ - 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.
13
+ - 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.
14
+ - 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ả.
15
+
16
+ ### Internal
17
+ - 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.
18
+
9
19
  ## [0.15.0] - 2026-09-10
10
20
 
11
21
  ### 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
+ };
@@ -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
+ }
@@ -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';
@@ -441,6 +442,28 @@ export function registerPageTools(server, ctx) {
441
442
  }
442
443
  }
443
444
  const valueNote = valueNotes.join(' ');
445
+ // A FIELD-SKIN KNOB ON THE WRONG NODE renders nowhere. The FORM dresses
446
+ // every field it holds with the input vocabulary; a payment card, choice
447
+ // group, timeslot or file field carries its own, and a knob written on a
448
+ // node whose css.go does not name that group is stored and read by
449
+ // nothing. Once per node TYPE, because the answer is about the type.
450
+ const skinNotes = [];
451
+ const skinSeen = new Set();
452
+ for (const e of batch) {
453
+ if (e.namespace !== 'config')
454
+ continue;
455
+ const type = d.doc.nodes[e.id]?.data.type ?? '';
456
+ if (!type || skinSeen.has(type))
457
+ continue;
458
+ const n = skinLevelNote(type, Object.keys(e.keys));
459
+ if (!n)
460
+ continue;
461
+ skinSeen.add(type);
462
+ const once = ctx.notices.once(`field-skin:${type}`, n);
463
+ if (once)
464
+ skinNotes.push(once);
465
+ }
466
+ const skinNote = skinNotes.join(' ');
444
467
  // THE STICKY WARNING IS COMPUTED AGAINST THE DOCUMENT AS IT WILL BE, so
445
468
  // the dry run and the real run say the same thing. A caller who is told
446
469
  // only after committing has already shipped a header that does not move.
@@ -475,6 +498,7 @@ export function registerPageTools(server, ctx) {
475
498
  ...(baseNote ? { base_only: baseNote } : {}),
476
499
  ...(presetNote ? { preset: presetNote } : {}),
477
500
  ...(valueNote ? { value: valueNote } : {}),
501
+ ...(skinNote ? { field_skin: skinNote } : {}),
478
502
  ...(Object.keys(hostNotes).length ? { hover_host: hostNotes } : {}),
479
503
  ...(note ? { note } : {}),
480
504
  });
@@ -497,6 +521,7 @@ export function registerPageTools(server, ctx) {
497
521
  ...(baseNote ? { base_only: baseNote } : {}),
498
522
  ...(presetNote ? { preset: presetNote } : {}),
499
523
  ...(valueNote ? { value: valueNote } : {}),
524
+ ...(skinNote ? { field_skin: skinNote } : {}),
500
525
  ...(hostNotes[batch[0].id] ? { hover_host: hostNotes[batch[0].id] } : {}),
501
526
  });
502
527
  }
@@ -508,6 +533,7 @@ export function registerPageTools(server, ctx) {
508
533
  ...(baseNote ? { base_only: baseNote } : {}),
509
534
  ...(presetNote ? { preset: presetNote } : {}),
510
535
  ...(valueNote ? { value: valueNote } : {}),
536
+ ...(skinNote ? { field_skin: skinNote } : {}),
511
537
  ...(Object.keys(hostNotes).length ? { hover_host: hostNotes } : {}),
512
538
  });
513
539
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
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",