sbuilder-mcp 0.91.0 → 0.92.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,18 @@ 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.92.0] - 2026-10-26
10
+
11
+ ### Added
12
+ - `sb_bind` accepts `record.url` and, on a page of type `record`, reads the collection linked through page-links so `record.*` bindings work outside any list, refusing `record.url` while the collection has no detail page.
13
+ - `sb_review` reports `record_option_invalid` for form-select, radio and checkbox controls with `optionSource: "record"` that lack a collection, use a label that is not an ID field, or have a parent that is not a record-sourced form-select.
14
+ - Form controls accept `optionSource: "record"` to take their options from a collection, and form-select supports `specials.urlParam` prefill such as `?hotel=rec_…&room=rec_…`.
15
+
16
+ ### Changed
17
+ - `sb_review` no longer reports `no_data_context` or `record_relation_orphan` for nodes outside a list on a record page.
18
+ - `sb_set` no longer writes `optionSource`, `recordLabelField`, `recordParentField` or `recordParentNode` per breakpoint, because they are now base-only config keys.
19
+ - The API catalog is synced with web_builder v1.0.172 and gains the `record` page type, served at `/records/<id>`.
20
+
9
21
  ## [0.91.0] - 2026-10-23
10
22
 
11
23
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,18 @@ 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.92.0] - 2026-10-26
10
+
11
+ ### Added
12
+ - `sb_bind` nhận `record.url` và, trên trang loại `record`, đọc collection gắn qua page-links để bind `record.*` ngoài mọi danh sách, đồng thời từ chối `record.url` khi collection chưa có trang chi tiết.
13
+ - `sb_review` báo `record_option_invalid` cho control form-select, radio và checkbox có `optionSource: "record"` thiếu collection, dùng nhãn không phải ID field, hoặc có cha không phải form-select lấy từ record.
14
+ - Các control form nhận `optionSource: "record"` để lấy option từ collection, và form-select hỗ trợ điền sẵn qua `specials.urlParam` như `?hotel=rec_…&room=rec_…`.
15
+
16
+ ### Changed
17
+ - `sb_review` không còn báo `no_data_context` hay `record_relation_orphan` cho node nằm ngoài danh sách trên trang record.
18
+ - `sb_set` không còn ghi `optionSource`, `recordLabelField`, `recordParentField` hay `recordParentNode` theo breakpoint, vì chúng nay là khoá config base-only.
19
+ - Catalog API được đồng bộ với web_builder v1.0.172 và thêm loại trang `record`, phục vụ tại `/records/<id>`.
20
+
9
21
  ## [0.91.0] - 2026-10-23
10
22
 
11
23
  ### Added
@@ -361,7 +361,11 @@ export const CHECKOUT_FORM_DOCUMENT = {
361
361
  "width": "100%"
362
362
  },
363
363
  "config": {
364
- "optionSource": "manual"
364
+ "optionSource": "manual",
365
+ "recordCollectionId": "",
366
+ "recordLabelField": "",
367
+ "recordParentField": "",
368
+ "recordParentNode": ""
365
369
  },
366
370
  "specials": {
367
371
  "name": "shipping_method",
@@ -372,7 +376,8 @@ export const CHECKOUT_FORM_DOCUMENT = {
372
376
  "mapTo": "order.shippingMethod",
373
377
  "options": [],
374
378
  "defaultValue": "",
375
- "placeholder": "Chọn phương thức vận chuyển"
379
+ "placeholder": "Chọn phương thức vận chuyển",
380
+ "urlParam": ""
376
381
  },
377
382
  "responsive": {},
378
383
  "events": [],
@@ -466,7 +471,9 @@ export const CHECKOUT_FORM_DOCUMENT = {
466
471
  },
467
472
  "config": {
468
473
  "columns": 1,
469
- "optionSource": "manual"
474
+ "optionSource": "manual",
475
+ "recordCollectionId": "",
476
+ "recordLabelField": ""
470
477
  },
471
478
  "specials": {
472
479
  "name": "xuat_hoa_don_vat",
@@ -2271,7 +2278,11 @@ export const FORM_TEMPLATES = {
2271
2278
  "width": "100%"
2272
2279
  },
2273
2280
  "config": {
2274
- "optionSource": "manual"
2281
+ "optionSource": "manual",
2282
+ "recordCollectionId": "",
2283
+ "recordLabelField": "",
2284
+ "recordParentField": "",
2285
+ "recordParentNode": ""
2275
2286
  },
2276
2287
  "specials": {
2277
2288
  "name": "shipping_method",
@@ -2282,7 +2293,8 @@ export const FORM_TEMPLATES = {
2282
2293
  "mapTo": "order.shippingMethod",
2283
2294
  "options": [],
2284
2295
  "defaultValue": "",
2285
- "placeholder": "Chọn phương thức vận chuyển"
2296
+ "placeholder": "Chọn phương thức vận chuyển",
2297
+ "urlParam": ""
2286
2298
  },
2287
2299
  "responsive": {},
2288
2300
  "events": [],
@@ -2376,7 +2388,9 @@ export const FORM_TEMPLATES = {
2376
2388
  },
2377
2389
  "config": {
2378
2390
  "columns": 1,
2379
- "optionSource": "manual"
2391
+ "optionSource": "manual",
2392
+ "recordCollectionId": "",
2393
+ "recordLabelField": ""
2380
2394
  },
2381
2395
  "specials": {
2382
2396
  "name": "xuat_hoa_don_vat",
@@ -4199,7 +4213,11 @@ export const FORM_TEMPLATES = {
4199
4213
  "width": "100%"
4200
4214
  },
4201
4215
  "config": {
4202
- "optionSource": "manual"
4216
+ "optionSource": "manual",
4217
+ "recordCollectionId": "",
4218
+ "recordLabelField": "",
4219
+ "recordParentField": "",
4220
+ "recordParentNode": ""
4203
4221
  },
4204
4222
  "specials": {
4205
4223
  "name": "loai_phong",
@@ -4214,7 +4232,8 @@ export const FORM_TEMPLATES = {
4214
4232
  "Phòng gia đình"
4215
4233
  ],
4216
4234
  "defaultValue": "",
4217
- "placeholder": "Chọn loại phòng"
4235
+ "placeholder": "Chọn loại phòng",
4236
+ "urlParam": ""
4218
4237
  },
4219
4238
  "responsive": {},
4220
4239
  "events": [],
@@ -4422,7 +4441,9 @@ export const FORM_TEMPLATES = {
4422
4441
  },
4423
4442
  "config": {
4424
4443
  "columns": 1,
4425
- "optionSource": "manual"
4444
+ "optionSource": "manual",
4445
+ "recordCollectionId": "",
4446
+ "recordLabelField": ""
4426
4447
  },
4427
4448
  "specials": {
4428
4449
  "name": "muc_do_hai_long",
@@ -1,7 +1,7 @@
1
1
  export const DEAD_KEY_SOURCE = {
2
- "files": 5944,
3
- "identifiers": 109564,
4
- "seededKeys": 454,
2
+ "files": 5966,
3
+ "identifiers": 109792,
4
+ "seededKeys": 457,
5
5
  "dead": 0
6
6
  };
7
7
  /**
@@ -8304,13 +8304,18 @@ export const ELEMENTS = {
8304
8304
  "Tùy chọn 3"
8305
8305
  ],
8306
8306
  "defaultValue": "",
8307
- "placeholder": "Chọn một mục"
8307
+ "placeholder": "Chọn một mục",
8308
+ "urlParam": ""
8308
8309
  },
8309
8310
  "style": {
8310
8311
  "width": "100%"
8311
8312
  },
8312
8313
  "config": {
8313
- "optionSource": "manual"
8314
+ "optionSource": "manual",
8315
+ "recordCollectionId": "",
8316
+ "recordLabelField": "",
8317
+ "recordParentField": "",
8318
+ "recordParentNode": ""
8314
8319
  }
8315
8320
  },
8316
8321
  "inspector": [
@@ -8476,7 +8481,9 @@ export const ELEMENTS = {
8476
8481
  },
8477
8482
  "config": {
8478
8483
  "columns": 1,
8479
- "optionSource": "manual"
8484
+ "optionSource": "manual",
8485
+ "recordCollectionId": "",
8486
+ "recordLabelField": ""
8480
8487
  },
8481
8488
  "style": {
8482
8489
  "width": "100%"
@@ -8967,7 +8974,9 @@ export const ELEMENTS = {
8967
8974
  },
8968
8975
  "config": {
8969
8976
  "columns": 1,
8970
- "optionSource": "manual"
8977
+ "optionSource": "manual",
8978
+ "recordCollectionId": "",
8979
+ "recordLabelField": ""
8971
8980
  },
8972
8981
  "style": {
8973
8982
  "width": "100%"
@@ -49348,6 +49357,7 @@ export const ELEMENT_VALUES = {
49348
49357
  "category",
49349
49358
  "manual",
49350
49359
  "product",
49360
+ "record",
49351
49361
  "tag"
49352
49362
  ],
49353
49363
  "readBy": "OPTION_SOURCES (schema/src/elements/formOptionSource.ts)"
@@ -49363,6 +49373,7 @@ export const ELEMENT_VALUES = {
49363
49373
  "category",
49364
49374
  "manual",
49365
49375
  "product",
49376
+ "record",
49366
49377
  "tag"
49367
49378
  ],
49368
49379
  "readBy": "OPTION_SOURCES (schema/src/elements/formOptionSource.ts)"
@@ -49389,6 +49400,7 @@ export const ELEMENT_VALUES = {
49389
49400
  "category",
49390
49401
  "manual",
49391
49402
  "product",
49403
+ "record",
49392
49404
  "tag"
49393
49405
  ],
49394
49406
  "readBy": "OPTION_SOURCES (schema/src/elements/formOptionSource.ts)"
@@ -50186,11 +50198,15 @@ export const BASE_ONLY_CONFIG = [
50186
50198
  "backgroundSceneUrl",
50187
50199
  "backgroundSceneModelUrl",
50188
50200
  "rowLimit",
50201
+ "optionSource",
50189
50202
  "recordCollectionId",
50190
50203
  "recordField",
50191
50204
  "recordFieldType",
50192
50205
  "recordFilterField",
50193
50206
  "recordFilterValue",
50207
+ "recordLabelField",
50208
+ "recordParentField",
50209
+ "recordParentNode",
50194
50210
  "recordRelationField",
50195
50211
  "recordSortDesc",
50196
50212
  "recordSortField"
@@ -4,7 +4,7 @@ export const SHAPE_SOURCE = {
4
4
  "fromHandlers": 334,
5
5
  "fromSwaggerOnly": 10,
6
6
  "withReadOnly": 29,
7
- "structsRead": 3457
7
+ "structsRead": 3459
8
8
  };
9
9
  export const REQUEST_SHAPES = {
10
10
  "post:/api/sites/{id}/ai-credits/purchase": {
@@ -1,7 +1,7 @@
1
1
  // GENERATED by scripts/gen-catalog.ts — do not edit by hand.
2
2
  // The platform commit every other generated file here describes.
3
3
  export const PLATFORM_SOURCE = {
4
- "commit": "4a6a6b151a18713d4f6c3235f063b870d314a968",
5
- "committedAt": "2026-10-23T14:44:15+07:00",
4
+ "commit": "05168e6e00f55abbddc7895a29a15f030ca060fa",
5
+ "committedAt": "2026-10-26T09:25:34+07:00",
6
6
  "dirty": false
7
7
  };
@@ -1624,6 +1624,11 @@ export const PAGE_TYPES = [
1624
1624
  "ownSlug": false,
1625
1625
  "routePattern": "/brands/{slug}"
1626
1626
  },
1627
+ {
1628
+ "type": "record",
1629
+ "group": "store",
1630
+ "ownSlug": false
1631
+ },
1627
1632
  {
1628
1633
  "type": "search",
1629
1634
  "group": "main",
@@ -120,6 +120,10 @@ export const FIX = {
120
120
  'datasetSource is "record" — one card template, rows edited as data. Logic the platform has ' +
121
121
  'no native control for yet (visitor filters, computed totals) may stay in code, reading ' +
122
122
  'GET /_wb/collections/{slug}/records instead of its own copy.',
123
+ record_option_invalid: 'Point it at a collection and its label column: sb_set id "<id>", namespace config, keys ' +
124
+ '{ "optionSource": "record", "recordCollectionId": "col_…", "recordLabelField": "fld_…" } (base). A dependent ' +
125
+ 'select adds "recordParentNode": "<parent form-select id>" and "recordParentField": "<reference fld_… of this ' +
126
+ 'collection pointing at the parent\'s>". Values are record ids; prefill with specials.urlParam.',
123
127
  record_filter_invalid: 'Aim it at ONE collection\'s record list(s) and a field: sb_set id "<id>", namespace specials, keys ' +
124
128
  '{ "filterSource": "record", "filterAxis": "fld_…", "filterTargets": ["<list id>"], "filterBehavior": "filter" } — ' +
125
129
  'axis types text/select/reference/url/date/boolean/integer/number. A sort select takes filterValues ' +
@@ -12,9 +12,10 @@ export const RECORD_FIELD_ID = /^fld_[0-9a-f]{16}$/;
12
12
  /**
13
13
  * A `record.*` source a context can supply: `record.<fid>`, `record.html.<fid>`,
14
14
  * `record.money.<fid>` (a number/integer field formatted in the site's currency),
15
- * or `record.id` — the row's own record id (recordContext.ts).
15
+ * `record.id` — the row's own record id — or `record.url` — `/records/<id>`, emitted
16
+ * only when the row's collection has a published record detail page (recordContext.ts).
16
17
  */
17
- export const RECORD_SOURCE = /^record\.(?:id|(?:html\.|money\.)?fld_[0-9a-f]{16})$/;
18
+ export const RECORD_SOURCE = /^record\.(?:id|url|(?:html\.|money\.)?fld_[0-9a-f]{16})$/;
18
19
  /** The field types `record.money.<fid>` formats — recordContext.ts RECORD_MONEY_FIELD_TYPES. */
19
20
  export const RECORD_MONEY_TYPES = new Set(['number', 'integer']);
20
21
  /** The only record source `specials.boundHtml` admits — schema/src/rawSinkBindings.ts:102. */
@@ -85,9 +86,9 @@ export function recordScope(nodeAt, id) {
85
86
  */
86
87
  export function recordFieldProblem(source, scope) {
87
88
  if (!scope.length) {
88
- return `"${source}" sits in no record-list row — only a list-dataset with datasetSource "record" (or a relation list inside one) supplies a record, so it would render its placeholder.`;
89
+ return `"${source}" sits in no record-list row and this is no record detail page — only a list-dataset with datasetSource "record" (or a relation list inside one), or a page of type "record" attached to a collection, supplies a record, so it would render its placeholder.`;
89
90
  }
90
- if (source === 'record.id')
91
+ if (source === 'record.id' || source === 'record.url')
91
92
  return undefined;
92
93
  const html = source.startsWith('record.html.');
93
94
  const money = source.startsWith('record.money.');
@@ -636,6 +636,9 @@ opts = {}) {
636
636
  const parentCfg = up && d.nodes[up] ? d.nodes[up].config : undefined;
637
637
  const nested = parentCfg?.datasetSource === 'record';
638
638
  const rel = String(cfg.recordRelationField ?? '');
639
+ // A record detail page (web_builder #160) is the parent of a relation list
640
+ // placed on it outside any list.
641
+ const pageParent = opts.pageType === 'record' && !up;
639
642
  if (nested && !rel) {
640
643
  out.push({
641
644
  code: 'record_relation_unset',
@@ -647,7 +650,7 @@ opts = {}) {
647
650
  fix: fill('record_relation_unset', { id }),
648
651
  });
649
652
  }
650
- else if (rel && (!nested || !RECORD_FIELD_ID.test(rel))) {
653
+ else if (rel && ((!nested && !pageParent) || !RECORD_FIELD_ID.test(rel))) {
651
654
  out.push({
652
655
  code: 'record_relation_orphan',
653
656
  nodeId: id,
@@ -670,6 +673,38 @@ opts = {}) {
670
673
  });
671
674
  }
672
675
  }
676
+ // FORM OPTIONS FROM A COLLECTION (web_builder #159, contract §7.2). Every miss renders
677
+ // a choice field with only its placeholder (no authored fallback), or a room list that
678
+ // never narrows — and the server refuses any other answer as not_an_option.
679
+ if (['form-select', 'form-radio', 'form-checkbox'].includes(type) && String(cfg.optionSource ?? '').trim() === 'record') {
680
+ const why = [];
681
+ if (!String(cfg.recordCollectionId ?? '').trim())
682
+ why.push('config.recordCollectionId names no collection');
683
+ if (!RECORD_FIELD_ID.test(String(cfg.recordLabelField ?? '')))
684
+ why.push('config.recordLabelField is not a field ID');
685
+ const pNode = String(cfg.recordParentNode ?? '');
686
+ const pField = String(cfg.recordParentField ?? '');
687
+ if (pNode || pField) {
688
+ const pc = d.nodes[pNode]?.config;
689
+ if (type !== 'form-select')
690
+ why.push('only a form-select narrows by a parent field');
691
+ else if (!pNode || !pField)
692
+ why.push('recordParentNode and recordParentField go together');
693
+ else if (d.nodes[pNode]?.data.type !== 'form-select' || String(pc?.optionSource ?? '').trim() !== 'record')
694
+ why.push(`recordParentNode "${pNode}" is not a record-sourced form-select`);
695
+ else if (!RECORD_FIELD_ID.test(pField))
696
+ why.push(`recordParentField "${pField}" is not a field ID`);
697
+ }
698
+ if (why.length) {
699
+ out.push({
700
+ code: 'record_option_invalid',
701
+ nodeId: id,
702
+ type,
703
+ problem: `A choice field listing records that shows no options or never narrows: ${why.join('; ')}.`,
704
+ fix: fill('record_option_invalid', { id }),
705
+ });
706
+ }
707
+ }
673
708
  // RECORD FILTERS (web_builder PR #157, contract §7.1): a filter/sort control aimed at a
674
709
  // record list. Every miss below renders a control that empties the list or narrows nothing.
675
710
  {
@@ -730,7 +765,8 @@ opts = {}) {
730
765
  while (list && d.nodes[list] && d.nodes[list].data.type !== 'list-dataset')
731
766
  list = d.nodes[list].data.parent;
732
767
  const lcfg = list && d.nodes[list] ? d.nodes[list].config : undefined;
733
- if (lcfg?.datasetSource !== 'record') {
768
+ // On a record detail page the PAGE supplies the record outside any list.
769
+ if (lcfg?.datasetSource !== 'record' && !(opts.pageType === 'record' && !list)) {
734
770
  out.push({
735
771
  code: 'no_data_context',
736
772
  nodeId: id,
@@ -745,21 +745,23 @@ export function registerLiveTools(server, ctx, session) {
745
745
  // `force` gets past it, recorded under `forced`.
746
746
  if (RECORD_SOURCE.test(source)) {
747
747
  const scope = recordScope((x) => (d.has(x) ? d.node(x) : undefined), id);
748
- // Resolved only when there is something to read: an empty scope is refused below
749
- // without a fetch, and must not fail on a missing SB_SITE first.
750
- const site = scope.length ? siteFor(ctx, session.page()?.siteId) : '';
748
+ const open = session.page();
749
+ const site = siteFor(ctx, open?.siteId);
750
+ const get = async (path) => request({ base: ctx.base, method: 'GET', path: `/api/sites/${encodeURIComponent(site)}${path}`, token: siteToken(ctx), fetchImpl: ctx.fetchImpl });
751
751
  let withFields;
752
752
  try {
753
+ // A RECORD DETAIL PAGE (web_builder #160) supplies its record outside any list:
754
+ // the collections attached to it are the scope.
755
+ if (!scope.length && open) {
756
+ const links = (await get(`/page-links?pageId=${encodeURIComponent(open.pageId)}&linkType=recordCollection`));
757
+ for (const l of links.links ?? [])
758
+ if (l.linkId)
759
+ scope.push(l.linkId);
760
+ }
753
761
  withFields = await Promise.all(scope.map(async (cid) => {
754
762
  if (!cid)
755
763
  return { collectionId: '(none set)', fields: [] };
756
- const got = (await request({
757
- base: ctx.base,
758
- method: 'GET',
759
- path: `/api/sites/${encodeURIComponent(site)}/collections/${encodeURIComponent(cid)}/fields`,
760
- token: siteToken(ctx),
761
- fetchImpl: ctx.fetchImpl,
762
- }));
764
+ const got = (await get(`/collections/${encodeURIComponent(cid)}/fields`));
763
765
  return { collectionId: cid, fields: got.fields ?? [] };
764
766
  }));
765
767
  }
@@ -776,6 +778,26 @@ export function registerLiveTools(server, ctx, session) {
776
778
  if (why)
777
779
  throw new Error(`sbuilder: ${why}`);
778
780
  });
781
+ // record.url resolves only for a collection with an attached record page.
782
+ const cid = scope[0];
783
+ if (source === 'record.url' && cid) {
784
+ let attached = true;
785
+ try {
786
+ await get(`/page-links/recordCollection/${encodeURIComponent(cid)}`);
787
+ }
788
+ catch (e) {
789
+ if (e.status !== 404)
790
+ throw e;
791
+ attached = false;
792
+ }
793
+ soft(guard, () => {
794
+ if (!attached) {
795
+ throw new Error(`sbuilder: collection ${cid} has no record detail page, so record.url is never emitted and the link ` +
796
+ 'stays a plain button. Create a page of type "record", attach it with PUT ' +
797
+ `/api/sites/{siteId}/page-links/recordCollection/${cid} {pageId}, and publish it.`);
798
+ }
799
+ });
800
+ }
779
801
  }
780
802
  }
781
803
  const forced = guard.forced.length ? { forced: guard.forced } : {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.91.0",
3
+ "version": "0.92.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",