sbuilder-mcp 0.91.0 → 0.93.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,31 @@ 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.93.0] - 2026-10-26
10
+
11
+ ### Added
12
+ - `sb_bind` accepts `record.agg.<key>` and `record.money.agg.<key>`, and a text-dataset tile with `recordField: "agg.<key>"` derives its binding the way the platform does.
13
+ - `sb_review` reports `record_aggregate_invalid` for `recordAggregates` that are malformed, which would publish an empty list, or that use `whenNone: "hide"` without an empty state.
14
+ - `sb_review` reports `estimate_invalid` for a form-estimate that does not have exactly one record term, or whose term or rule points at a node of the wrong type.
15
+ - The element catalog gains `form-estimate` and the base-only `recordAggregates` key.
16
+
17
+ ### Changed
18
+ - `sb_review` accepts `agg.<key>` and `param.<name>` axes in `record_filter_invalid` when the target list defines them, rejects a param with more than one value, and accepts sort values of `agg.<key>_asc|desc`.
19
+ - `sb_review` reports `record_field_unset` for a tile bound to `agg.<key>` that its list does not define.
20
+ - The API catalog is synced with web_builder v1.0.173 and now covers 124 elements and 95 binding sources.
21
+
22
+ ## [0.92.0] - 2026-10-26
23
+
24
+ ### Added
25
+ - `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.
26
+ - `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.
27
+ - 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_…`.
28
+
29
+ ### Changed
30
+ - `sb_review` no longer reports `no_data_context` or `record_relation_orphan` for nodes outside a list on a record page.
31
+ - `sb_set` no longer writes `optionSource`, `recordLabelField`, `recordParentField` or `recordParentNode` per breakpoint, because they are now base-only config keys.
32
+ - The API catalog is synced with web_builder v1.0.172 and gains the `record` page type, served at `/records/<id>`.
33
+
9
34
  ## [0.91.0] - 2026-10-23
10
35
 
11
36
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,31 @@ 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.93.0] - 2026-10-26
10
+
11
+ ### Added
12
+ - `sb_bind` nhận `record.agg.<key>` và `record.money.agg.<key>`, và tile text-dataset có `recordField: "agg.<key>"` suy ra binding giống nền tảng.
13
+ - `sb_review` báo `record_aggregate_invalid` cho `recordAggregates` sai định dạng (làm danh sách publish rỗng) hoặc dùng `whenNone: "hide"` mà thiếu trạng thái rỗng.
14
+ - `sb_review` báo `estimate_invalid` khi form-estimate không có đúng một term record, hoặc term/rule trỏ vào node sai loại.
15
+ - Catalog element thêm `form-estimate` và khoá base-only `recordAggregates`.
16
+
17
+ ### Changed
18
+ - `sb_review` chấp nhận axis `agg.<key>` và `param.<name>` trong `record_filter_invalid` khi danh sách đích có định nghĩa, từ chối param có nhiều hơn một giá trị, và chấp nhận giá trị sort `agg.<key>_asc|desc`.
19
+ - `sb_review` báo `record_field_unset` cho tile bind `agg.<key>` mà danh sách của nó không định nghĩa.
20
+ - Catalog API được đồng bộ với web_builder v1.0.173 và nay gồm 124 element và 95 nguồn binding.
21
+
22
+ ## [0.92.0] - 2026-10-26
23
+
24
+ ### Added
25
+ - `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.
26
+ - `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.
27
+ - 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_…`.
28
+
29
+ ### Changed
30
+ - `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.
31
+ - `sb_set` không còn ghi `optionSource`, `recordLabelField`, `recordParentField` hay `recordParentNode` theo breakpoint, vì chúng nay là khoá config base-only.
32
+ - 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>`.
33
+
9
34
  ## [0.91.0] - 2026-10-23
10
35
 
11
36
  ### Added
package/README.md CHANGED
@@ -119,7 +119,7 @@ in this client: one would put the very key the platform exists to hold back into
119
119
  | `sb_undo` | Undo / redo this session's writes — page and form edits and `sb_api_call` PUTs; a page is refused if someone else saved it since. The platform's versions and history (`sb_page_version`) outlive this process |
120
120
 
121
121
  Twenty-eight tools, **919 API operations** (344 of the 429 writes carrying a body shape read
122
- off the handler), 123 elements, 94 binding sources. `sb_api_find`
122
+ off the handler), 124 elements, 95 binding sources. `sb_api_find`
123
123
  is an index rather than a tool per endpoint, so the tool list stays short while everything
124
124
  the platform can do stays reachable — and operations added to the platform arrive with the
125
125
  next `npm run codegen`.
package/README.vi.md CHANGED
@@ -116,7 +116,7 @@ nhét ngược lại vào mọi bản cài.
116
116
  | `sb_undo` | Hoàn tác / làm lại các thao tác ghi trong phiên — chỉnh sửa trang, form và PUT qua `sb_api_call`; trang bị từ chối nếu người khác đã lưu sau đó. Version và history của nền tảng (`sb_page_version`) sống lâu hơn process này |
117
117
 
118
118
  Hai mươi tám tool, **919 operation API** (344 trong 429 lệnh ghi có hình dạng body đọc thẳng
119
- từ handler), 123 element, 94 nguồn binding. `sb_api_find` là
119
+ từ handler), 124 element, 95 nguồn binding. `sb_api_find` là
120
120
  một chỉ mục chứ không phải mỗi endpoint một tool, nên danh sách tool vẫn ngắn trong khi mọi
121
121
  thứ nền tảng làm được vẫn với tới — và operation mới thêm bên nền tảng sẽ tự có sau lần
122
122
  `npm run codegen` kế tiếp.
@@ -376,6 +376,7 @@ export const APP_SCAFFOLDS = {
376
376
  "recordFilterField": "",
377
377
  "recordFilterValue": "",
378
378
  "recordRelationField": "",
379
+ "recordAggregates": [],
379
380
  "itemsPerRow": 4,
380
381
  "collectionType": "all_products",
381
382
  "collectionId": "all_products",
@@ -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": 5992,
3
+ "identifiers": 110190,
4
+ "seededKeys": 460,
5
5
  "dead": 0
6
6
  };
7
7
  /**
@@ -1,5 +1,5 @@
1
1
  export const ELEMENT_SOURCE = {
2
- "count": 123,
2
+ "count": 124,
3
3
  "docSchemaVersion": 2
4
4
  };
5
5
  export const ELEMENTS = {
@@ -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%"
@@ -9837,6 +9846,154 @@ export const ELEMENTS = {
9837
9846
  "account"
9838
9847
  ]
9839
9848
  },
9849
+ "form-estimate": {
9850
+ "type": "form-estimate",
9851
+ "label": "Estimate",
9852
+ "category": "form",
9853
+ "isContainer": false,
9854
+ "isRootOnly": false,
9855
+ "locked": false,
9856
+ "hideInLayer": false,
9857
+ "childAllows": [],
9858
+ "defaults": {
9859
+ "specials": {
9860
+ "name": "",
9861
+ "label": "Tạm tính",
9862
+ "placeholder": "Chọn phòng và ngày để xem giá dự kiến.",
9863
+ "description": "Giá dự kiến, chưa phải giá giữ chỗ.",
9864
+ "estimateTerms": [],
9865
+ "estimateRules": []
9866
+ },
9867
+ "style": {
9868
+ "width": "100%",
9869
+ "height": "fit-content",
9870
+ "fontSize": "14px",
9871
+ "lineHeight": "150%",
9872
+ "color": "#171717"
9873
+ }
9874
+ },
9875
+ "inspector": [
9876
+ {
9877
+ "tab": "general",
9878
+ "groups": [
9879
+ {
9880
+ "key": "content",
9881
+ "label": "Content",
9882
+ "controls": [
9883
+ "form_estimate_content"
9884
+ ]
9885
+ },
9886
+ {
9887
+ "key": "estimate",
9888
+ "label": "Estimate",
9889
+ "controls": [
9890
+ "form_estimate_terms"
9891
+ ]
9892
+ },
9893
+ {
9894
+ "key": "estimate_rules",
9895
+ "label": "Stay rules",
9896
+ "controls": [
9897
+ "form_estimate_rules"
9898
+ ]
9899
+ },
9900
+ {
9901
+ "key": "typography",
9902
+ "label": "Typography",
9903
+ "controls": [
9904
+ "text_color",
9905
+ "font_family",
9906
+ "font_size",
9907
+ "text_align",
9908
+ "line_height"
9909
+ ]
9910
+ },
9911
+ {
9912
+ "key": "size",
9913
+ "label": "Size",
9914
+ "controls": [
9915
+ "width_select",
9916
+ "height_select",
9917
+ "size_bounds"
9918
+ ]
9919
+ },
9920
+ {
9921
+ "key": "id_field",
9922
+ "label": "ID field",
9923
+ "controls": [
9924
+ "field_name"
9925
+ ]
9926
+ }
9927
+ ]
9928
+ },
9929
+ {
9930
+ "tab": "advanced",
9931
+ "groups": [
9932
+ {
9933
+ "key": "spacing",
9934
+ "label": "Spacing",
9935
+ "controls": [
9936
+ "padding_margin"
9937
+ ]
9938
+ },
9939
+ {
9940
+ "key": "display",
9941
+ "label": "Display",
9942
+ "controls": [
9943
+ "display"
9944
+ ]
9945
+ },
9946
+ {
9947
+ "key": "class_css",
9948
+ "label": "Class",
9949
+ "controls": [
9950
+ "class_css"
9951
+ ]
9952
+ }
9953
+ ]
9954
+ }
9955
+ ],
9956
+ "controls": [
9957
+ "form_estimate_content",
9958
+ "form_estimate_terms",
9959
+ "form_estimate_rules",
9960
+ "text_color",
9961
+ "font_family",
9962
+ "font_size",
9963
+ "text_align",
9964
+ "line_height",
9965
+ "width_select",
9966
+ "height_select",
9967
+ "size_bounds",
9968
+ "field_name",
9969
+ "padding_margin",
9970
+ "display",
9971
+ "class_css"
9972
+ ],
9973
+ "description": "A live booking summary inside a form: the price of the record chosen in a data-record select, multiplied by the nights between two date fields and by a number field (\"3.330.000 ₫ × 2 đêm × 2 phòng = 13.320.000 ₫\"), plus the stay rules that go with it — check-in not in the past, check-out after check-in, a maximum number of nights, and guests within the chosen room's capacity. The server recomputes the total from the published record on submit and stores it as an estimate.",
9974
+ "useWhen": [
9975
+ "A booking or quote request whose price is a record (a room type, a tour, a rental) times nights and/or a quantity.",
9976
+ "The visitor should see what their choice costs before sending, and the stay dates need checking."
9977
+ ],
9978
+ "avoidWhen": [
9979
+ "The form sells cart products — the order form and its bill already price those.",
9980
+ "The price is not stored on a data record; there is nothing authoritative for the server to recompute from."
9981
+ ],
9982
+ "contentTips": [
9983
+ "Point the price at a number column holding WHOLE currency units (1850000, not cents).",
9984
+ "Set the guests and rooms fields to whole numbers with a range, and add a capacity rule so 3 guests cannot book 1 room for 2.",
9985
+ "Keep the note: an estimate is not a held price."
9986
+ ],
9987
+ "semantics": [
9988
+ "form",
9989
+ "booking",
9990
+ "estimate",
9991
+ "total",
9992
+ "price",
9993
+ "summary",
9994
+ "nights"
9995
+ ]
9996
+ },
9840
9997
  "form-segment": {
9841
9998
  "type": "form-segment",
9842
9999
  "label": "Segment",
@@ -42909,6 +43066,7 @@ export const ELEMENTS = {
42909
43066
  "recordFilterField": "",
42910
43067
  "recordFilterValue": "",
42911
43068
  "recordRelationField": "",
43069
+ "recordAggregates": [],
42912
43070
  "itemsPerRow": 4,
42913
43071
  "collectionType": "all_products",
42914
43072
  "collectionId": "all_products",
@@ -44016,6 +44174,7 @@ export const ELEMENTS = {
44016
44174
  "recordFilterField",
44017
44175
  "recordFilterValue",
44018
44176
  "recordRelationField",
44177
+ "recordAggregates",
44019
44178
  "itemsPerRow",
44020
44179
  "listNavIcon",
44021
44180
  "listNavPosition",
@@ -44058,6 +44217,7 @@ export const ELEMENTS = {
44058
44217
  "recordFilterField",
44059
44218
  "recordFilterValue",
44060
44219
  "recordRelationField",
44220
+ "recordAggregates",
44061
44221
  "itemsPerRow",
44062
44222
  "listNavIcon",
44063
44223
  "listNavPosition",
@@ -44108,6 +44268,7 @@ export const ELEMENTS = {
44108
44268
  "recordFilterField",
44109
44269
  "recordFilterValue",
44110
44270
  "recordRelationField",
44271
+ "recordAggregates",
44111
44272
  "itemsPerRow",
44112
44273
  "listNavIcon",
44113
44274
  "listNavPosition",
@@ -44160,6 +44321,7 @@ export const ELEMENTS = {
44160
44321
  "recordFilterField",
44161
44322
  "recordFilterValue",
44162
44323
  "recordRelationField",
44324
+ "recordAggregates",
44163
44325
  "itemsPerRow",
44164
44326
  "listNavIcon",
44165
44327
  "listNavPosition",
@@ -44202,6 +44364,7 @@ export const ELEMENTS = {
44202
44364
  "recordFilterField",
44203
44365
  "recordFilterValue",
44204
44366
  "recordRelationField",
44367
+ "recordAggregates",
44205
44368
  "itemsPerRow",
44206
44369
  "listNavIcon",
44207
44370
  "listNavPosition",
@@ -44390,6 +44553,7 @@ export const ELEMENTS = {
44390
44553
  "recordFilterField",
44391
44554
  "recordFilterValue",
44392
44555
  "recordRelationField",
44556
+ "recordAggregates",
44393
44557
  "itemsPerRow",
44394
44558
  "listNavIcon",
44395
44559
  "listNavPosition",
@@ -44502,6 +44666,7 @@ export const ELEMENTS = {
44502
44666
  "Collection lists: config.collectionListType \"page_collections\" renders THE SUB-COLLECTIONS OF THE COLLECTION THE PAGE IS (the one /collections/{slug} named), so a single collection template draws every collection's browse grid — off a collection template it shows every collection instead; \"all_collections\" renders every collection; \"custom_collections\" renders specials.collectionIds, in that order",
44503
44667
  "config.quantity caps how many records render and, under a loadingMode, is the PAGE SIZE; it is BASE-only — one value for every breakpoint, never per-breakpoint. A Data list (datasetSource \"record\") pages at most 50 records per page. config.itemsPerRow is the row size (grid columns / slide page size) and style.gap is the space between cards — both are per-breakpoint",
44504
44668
  "A Data list (datasetSource \"record\") nested inside another Data list's row can show ONLY the child records pointing at that row: set config.recordRelationField to the field id (fld_<16hex>) of a \"reference\" field in the nested list's own collection (e.g. RoomType.hotel inside a Hotels list). BASE-only. Such a list is never paged (loadingMode is ignored), its card may also bind the enclosing row's fields, and outside a record-list row it shows its empty state, never all records",
44669
+ "A Data list can carry config.recordAggregates (BASE-only, ≤4): [{key, recordCollectionId (child collection), recordRelationField (child reference field → this collection), op \"min\"|\"max\"|\"count\", recordField (child number/integer field, \"\" for count), where: [{recordField, op:\"gte\", param, per?}], whenNone \"hide\"|\"show\"}]. Each row then binds record.agg.<key> / record.money.agg.<key> (e.g. a hotel's cheapest room that fits: min nightly price where capacity × rooms ≥ guests). Visitors send params as f.record.param.<name>=<int>, filter as f.record.agg.<key>=..3000000, sort as s.<node>=agg.<key>_asc. Not availability",
44505
44670
  "config.layout \"grid\" for a multi-row grid, \"slide\" for row-by-row carousel behaviour",
44506
44671
  "Navigation and pagination only matter when layout is \"slide\". The arrows exist while config.listNavIcon names an icon (clearing it turns them off); the dots exist while paginationItemWidth and paginationItemHeight are both above 0",
44507
44672
  "config.loadingMode is how a GRID list offers the REST of its feed once config.quantity rows are on screen: \"none\" (the default — those rows and nothing more), \"pagination\" (a numbered pager, each page its own URL), \"load_more\" (a button that appends the next page in place) or \"scroll_more\" (the same append, triggered by reaching the end). Set it whenever the listing is a real catalogue rather than a fixed shelf of featured rows",
@@ -45571,6 +45736,7 @@ export const BINDING_SOURCES = [
45571
45736
  "site.supportEmail",
45572
45737
  "site.supportPhone",
45573
45738
  "site.tiktok",
45739
+ "site.timezone",
45574
45740
  "site.youtube"
45575
45741
  ];
45576
45742
  export const BOUND_SPECIALS = {
@@ -45762,6 +45928,67 @@ export const RECORD_BINDING_PROBES = [
45762
45928
  }
45763
45929
  ]
45764
45930
  },
45931
+ {
45932
+ "type": "text-dataset",
45933
+ "config": {
45934
+ "datasetSource": "record",
45935
+ "recordField": "agg.startPrice",
45936
+ "recordFieldType": "number"
45937
+ },
45938
+ "bindings": [
45939
+ {
45940
+ "id": "bind-text",
45941
+ "source": "record.agg.startPrice",
45942
+ "field": "specials.boundText",
45943
+ "target": {
45944
+ "type": "record",
45945
+ "id": "",
45946
+ "kind": "title"
45947
+ }
45948
+ }
45949
+ ]
45950
+ },
45951
+ {
45952
+ "type": "text-dataset",
45953
+ "config": {
45954
+ "datasetSource": "record",
45955
+ "recordField": "agg.startPrice",
45956
+ "recordFieldType": "number",
45957
+ "recordFormat": "money"
45958
+ },
45959
+ "bindings": [
45960
+ {
45961
+ "id": "bind-text",
45962
+ "source": "record.money.agg.startPrice",
45963
+ "field": "specials.boundText",
45964
+ "target": {
45965
+ "type": "record",
45966
+ "id": "",
45967
+ "kind": "title"
45968
+ }
45969
+ }
45970
+ ]
45971
+ },
45972
+ {
45973
+ "type": "text-dataset",
45974
+ "config": {
45975
+ "datasetSource": "record",
45976
+ "recordField": "agg.startPrice",
45977
+ "recordFieldType": "richtext"
45978
+ },
45979
+ "bindings": [
45980
+ {
45981
+ "id": "bind-text",
45982
+ "source": "",
45983
+ "field": "specials.boundHtml",
45984
+ "target": {
45985
+ "type": "record",
45986
+ "id": "",
45987
+ "kind": "title"
45988
+ }
45989
+ }
45990
+ ]
45991
+ },
45765
45992
  {
45766
45993
  "type": "text-dataset",
45767
45994
  "config": {
@@ -49348,6 +49575,7 @@ export const ELEMENT_VALUES = {
49348
49575
  "category",
49349
49576
  "manual",
49350
49577
  "product",
49578
+ "record",
49351
49579
  "tag"
49352
49580
  ],
49353
49581
  "readBy": "OPTION_SOURCES (schema/src/elements/formOptionSource.ts)"
@@ -49363,6 +49591,7 @@ export const ELEMENT_VALUES = {
49363
49591
  "category",
49364
49592
  "manual",
49365
49593
  "product",
49594
+ "record",
49366
49595
  "tag"
49367
49596
  ],
49368
49597
  "readBy": "OPTION_SOURCES (schema/src/elements/formOptionSource.ts)"
@@ -49389,6 +49618,7 @@ export const ELEMENT_VALUES = {
49389
49618
  "category",
49390
49619
  "manual",
49391
49620
  "product",
49621
+ "record",
49392
49622
  "tag"
49393
49623
  ],
49394
49624
  "readBy": "OPTION_SOURCES (schema/src/elements/formOptionSource.ts)"
@@ -50186,11 +50416,16 @@ export const BASE_ONLY_CONFIG = [
50186
50416
  "backgroundSceneUrl",
50187
50417
  "backgroundSceneModelUrl",
50188
50418
  "rowLimit",
50419
+ "optionSource",
50420
+ "recordAggregates",
50189
50421
  "recordCollectionId",
50190
50422
  "recordField",
50191
50423
  "recordFieldType",
50192
50424
  "recordFilterField",
50193
50425
  "recordFilterValue",
50426
+ "recordLabelField",
50427
+ "recordParentField",
50428
+ "recordParentNode",
50194
50429
  "recordRelationField",
50195
50430
  "recordSortDesc",
50196
50431
  "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": 3467
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": "e909f77a5b8c33c635657cc906e45b99f6d253d8",
5
+ "committedAt": "2026-10-26T17:18:35+07:00",
6
6
  "dirty": false
7
7
  };
@@ -678,6 +678,7 @@ export const PAGE_LAYOUT_SEEDS = {
678
678
  "recordFilterField": "",
679
679
  "recordFilterValue": "",
680
680
  "recordRelationField": "",
681
+ "recordAggregates": [],
681
682
  "itemsPerRow": 4,
682
683
  "collectionType": "all_products",
683
684
  "collectionId": "all_products",
@@ -1624,6 +1625,11 @@ export const PAGE_TYPES = [
1624
1625
  "ownSlug": false,
1625
1626
  "routePattern": "/brands/{slug}"
1626
1627
  },
1628
+ {
1629
+ "type": "record",
1630
+ "group": "store",
1631
+ "ownSlug": false
1632
+ },
1627
1633
  {
1628
1634
  "type": "search",
1629
1635
  "group": "main",
@@ -2962,6 +2968,7 @@ export const STORE_PAGE_SEEDS = {
2962
2968
  "recordFilterField": "",
2963
2969
  "recordFilterValue": "",
2964
2970
  "recordRelationField": "",
2971
+ "recordAggregates": [],
2965
2972
  "itemsPerRow": 4,
2966
2973
  "collectionType": "all_products",
2967
2974
  "collectionId": "all_products",
@@ -3683,6 +3690,7 @@ export const STORE_PAGE_SEEDS = {
3683
3690
  "recordFilterField": "",
3684
3691
  "recordFilterValue": "",
3685
3692
  "recordRelationField": "",
3693
+ "recordAggregates": [],
3686
3694
  "itemsPerRow": 4,
3687
3695
  "collectionType": "all_products",
3688
3696
  "collectionId": "all_products",
@@ -4413,6 +4421,7 @@ export const STORE_PAGE_SEEDS = {
4413
4421
  "recordFilterField": "",
4414
4422
  "recordFilterValue": "",
4415
4423
  "recordRelationField": "",
4424
+ "recordAggregates": [],
4416
4425
  "itemsPerRow": 4,
4417
4426
  "collectionType": "all_products",
4418
4427
  "collectionId": "all_products",
@@ -1,9 +1,9 @@
1
1
  // GENERATED by scripts/gen-catalog.ts — do not edit by hand.
2
2
  // Source: <WB_REPO>/schema/src/elements/translatableFields.ts
3
3
  export const TRANSLATION_SOURCE = {
4
- "elements": 67,
5
- "pairs": 187,
6
- "neverKeys": 180,
4
+ "elements": 68,
5
+ "pairs": 190,
6
+ "neverKeys": 182,
7
7
  "entityTypes": 12
8
8
  };
9
9
  /** Every entity type a translation record can name, including "node". */
@@ -274,6 +274,11 @@ export const TRANSLATABLE_SPECIALS = {
274
274
  "text",
275
275
  "linkText"
276
276
  ],
277
+ "form-estimate": [
278
+ "label",
279
+ "placeholder",
280
+ "description"
281
+ ],
277
282
  "form-step-button": [
278
283
  "text"
279
284
  ],
@@ -407,6 +412,7 @@ export const NEVER_TRANSLATED = [
407
412
  "displayType",
408
413
  "end",
409
414
  "endsAt",
415
+ "estimateRules",
410
416
  "field",
411
417
  "fileFormat",
412
418
  "fileLimit",
@@ -522,6 +528,7 @@ export const NEVER_TRANSLATED = [
522
528
  "showPercent",
523
529
  "showPhone",
524
530
  "showPlaceholder",
531
+ "siteTimezone",
525
532
  "source",
526
533
  "sourceCode",
527
534
  "sourceFlashSale",
@@ -1129,6 +1136,17 @@ export const TRANSLATABLE_FIELDS = {
1129
1136
  "key": "linkText"
1130
1137
  }
1131
1138
  ],
1139
+ "form-estimate": [
1140
+ {
1141
+ "key": "label"
1142
+ },
1143
+ {
1144
+ "key": "placeholder"
1145
+ },
1146
+ {
1147
+ "key": "description"
1148
+ }
1149
+ ],
1132
1150
  "form-step-button": [
1133
1151
  {
1134
1152
  "key": "text"
@@ -120,6 +120,17 @@ 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.',
127
+ record_aggregate_invalid: 'Fix config.recordAggregates on "<id>" (base): each { key, recordCollectionId (child), recordRelationField ' +
128
+ '(its reference to this list\'s collection), op min|max|count, recordField (child number/integer; "" for count), ' +
129
+ 'where: [{ recordField, op: "gte", param, per? }], whenNone hide|show } — and keep the list\'s list-empty.',
130
+ estimate_invalid: 'Point its operands at THIS form\'s nodes: sb_set id "<id>", namespace specials, base, keys { "estimateTerms": ' +
131
+ '[{kind:"record", node:<record form-select>, field:<fld_ price>}, {kind:"nights", start:<date>, end:<date>, unit:"đêm"}, ' +
132
+ '{kind:"number", node:<form-number>, unit:"phòng"}], "estimateRules": [{kind:"dateRange", start, end, notPast:true}, ' +
133
+ '{kind:"maxNights", start, end, max:30}, {kind:"capacity", node, field:<fld_ capacity>, per:<rooms>, count:<guests>}] }.',
123
134
  record_filter_invalid: 'Aim it at ONE collection\'s record list(s) and a field: sb_set id "<id>", namespace specials, keys ' +
124
135
  '{ "filterSource": "record", "filterAxis": "fld_…", "filterTargets": ["<list id>"], "filterBehavior": "filter" } — ' +
125
136
  'axis types text/select/reference/url/date/boolean/integer/number. A sort select takes filterValues ' +
@@ -12,9 +12,64 @@ 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}|(?:money\.)?agg\.[a-z][a-zA-Z0-9_]{0,31})$/;
19
+ /** An aggregate key — recordAggregates.ts RECORD_AGG_KEY. A tile names it as `agg.<key>`. */
20
+ export const RECORD_AGG_KEY = /^[a-z][a-zA-Z0-9_]{0,31}$/;
21
+ const RECORD_PARAM_NAME = /^[a-z][a-z0-9_]{0,23}$/;
22
+ const COLLECTION_ID = /^col_[0-9a-f]{16}$/;
23
+ export const recordAggKeyOf = (axis) => {
24
+ const key = axis.startsWith('agg.') ? axis.slice(4) : '';
25
+ return RECORD_AGG_KEY.test(key) ? key : '';
26
+ };
27
+ /**
28
+ * `config.recordAggregates` read the way the platform does
29
+ * (recordAggregates.ts readRecordAggregates): [] for none, null when MALFORMED —
30
+ * the server then refuses the list and it publishes EMPTY.
31
+ */
32
+ export function readRecordAggregates(raw) {
33
+ if (raw === undefined || raw === null)
34
+ return [];
35
+ if (!Array.isArray(raw) || raw.length > 4)
36
+ return null;
37
+ const s = (v) => (typeof v === 'string' ? v : '');
38
+ const seen = new Set();
39
+ const out = [];
40
+ for (const a of raw) {
41
+ if (!a || typeof a !== 'object')
42
+ return null;
43
+ const op = s(a.op);
44
+ const field = s(a.recordField);
45
+ const whenNone = a.whenNone == null || a.whenNone === '' ? 'hide' : s(a.whenNone);
46
+ if (!RECORD_AGG_KEY.test(s(a.key)) ||
47
+ seen.has(s(a.key)) ||
48
+ !['min', 'max', 'count'].includes(op) ||
49
+ !COLLECTION_ID.test(s(a.recordCollectionId)) ||
50
+ !RECORD_FIELD_ID.test(s(a.recordRelationField)) ||
51
+ (op === 'count' ? field !== '' : !RECORD_FIELD_ID.test(field)) ||
52
+ !['hide', 'show'].includes(whenNone)) {
53
+ return null;
54
+ }
55
+ const where = a.where ?? [];
56
+ if (!Array.isArray(where) || where.length > 4)
57
+ return null;
58
+ const conds = [];
59
+ for (const c of where) {
60
+ const per = s(c?.per);
61
+ if (!c || !RECORD_FIELD_ID.test(s(c.recordField)) || c.op !== 'gte' || !RECORD_PARAM_NAME.test(s(c.param)) || (per !== '' && !RECORD_PARAM_NAME.test(per))) {
62
+ return null;
63
+ }
64
+ conds.push({ param: s(c.param), ...(per ? { per } : {}) });
65
+ }
66
+ seen.add(s(a.key));
67
+ out.push({ key: s(a.key), op, where: conds, whenNone });
68
+ }
69
+ return out;
70
+ }
71
+ /** The visitor params a list's aggregates read (`param.<name>` axes it accepts). */
72
+ export const recordAggregateParams = (aggs) => new Set(aggs.flatMap((a) => a.where.flatMap((c) => (c.per ? [c.param, c.per] : [c.param]))));
18
73
  /** The field types `record.money.<fid>` formats — recordContext.ts RECORD_MONEY_FIELD_TYPES. */
19
74
  export const RECORD_MONEY_TYPES = new Set(['number', 'integer']);
20
75
  /** The only record source `specials.boundHtml` admits — schema/src/rawSinkBindings.ts:102. */
@@ -34,8 +89,9 @@ export function recordTileBindings(type, config, rows) {
34
89
  if (!RECORD_TILES.has(type))
35
90
  return rows;
36
91
  const fid = config.recordField;
37
- const ok = typeof fid === 'string' && RECORD_FIELD_ID.test(fid);
38
92
  const html = type === 'text-dataset' && config.recordFieldType === 'richtext';
93
+ // An aggregate (`agg.<key>`) is a number on the row — datasetBindings.ts recordSource.
94
+ const ok = typeof fid === 'string' && (RECORD_FIELD_ID.test(fid) || (!html && recordAggKeyOf(fid) !== ''));
39
95
  // datasetBindings.ts isRecordMoney: a stale recordFormat on any other type is ignored.
40
96
  const money = type === 'text-dataset' && config.recordFormat === 'money' && RECORD_MONEY_TYPES.has(String(config.recordFieldType));
41
97
  return rows.map((row) => {
@@ -85,9 +141,12 @@ export function recordScope(nodeAt, id) {
85
141
  */
86
142
  export function recordFieldProblem(source, scope) {
87
143
  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.`;
144
+ 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
145
  }
90
- if (source === 'record.id')
146
+ if (source === 'record.id' || source === 'record.url')
147
+ return undefined;
148
+ // An aggregate is checked against the list's recordAggregates (sb_review), not a field.
149
+ if (/^record\.(?:money\.)?agg\./.test(source))
91
150
  return undefined;
92
151
  const html = source.startsWith('record.html.');
93
152
  const money = source.startsWith('record.money.');
@@ -7,7 +7,7 @@ import { SEED_COPY_VI, langOf } from './seedcopy.js';
7
7
  import { HOVER_STATE, hoverHome } from './hover.js';
8
8
  import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
9
9
  import { fill } from './findings.js';
10
- import { isRecordSource, RECORD_FIELD_ID, RECORD_HTML_SOURCE, RECORD_SOURCE, RECORD_TILES } from './record.js';
10
+ import { isRecordSource, readRecordAggregates, recordAggKeyOf, recordAggregateParams, RECORD_FIELD_ID, RECORD_HTML_SOURCE, RECORD_SOURCE, RECORD_TILES, } from './record.js';
11
11
  import { formFieldFindings } from './formdoc.js';
12
12
  import { deadNavigation, liveEventTable, PAYLOAD_NEEDS } from './navhref.js';
13
13
  import { unknownValueNote, unknownWriteNote, vocabularyFor, vocabularyForWrite } from './vocabulary.js';
@@ -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,94 @@ 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
+ }
708
+ // AGGREGATES OVER A RELATION (web_builder #162, contract §7.5). A malformed
709
+ // definition makes the server refuse the WHOLE list — it publishes empty; a
710
+ // "hide" aggregate drops every row nothing fits, so the list needs its empty state.
711
+ if (type === 'list-dataset' && cfg.datasetSource === 'record' && cfg.recordAggregates !== undefined) {
712
+ const aggs = readRecordAggregates(cfg.recordAggregates);
713
+ const empty = d.nodes[String(cfg.emptyStateId ?? '')]?.data.type === 'list-empty';
714
+ if (aggs === null || (aggs.some((a) => a.whenNone === 'hide') && !empty)) {
715
+ out.push({
716
+ code: 'record_aggregate_invalid',
717
+ nodeId: id,
718
+ type,
719
+ problem: aggs === null
720
+ ? 'config.recordAggregates is malformed (a bad key, op, id, condition or more than 4) — the server refuses the list and it publishes EMPTY.'
721
+ : 'An aggregate with whenNone "hide" drops every row nothing fits, and this list has no empty state — a visitor whose search fits nothing sees a blank band.',
722
+ fix: fill('record_aggregate_invalid', { id }),
723
+ });
724
+ }
725
+ }
726
+ // BOOKING ESTIMATE (web_builder #161, contract §7.4): its operands are NODE IDS of
727
+ // this form document. A wrong one leaves the line on its placeholder and the
728
+ // server stores no total — or skips a stay rule — with no error anywhere.
729
+ if (type === 'form-estimate') {
730
+ const sp = (n.specials ?? {});
731
+ const terms = Array.isArray(sp.estimateTerms) ? sp.estimateTerms : [];
732
+ const rules = Array.isArray(sp.estimateRules) ? sp.estimateRules : [];
733
+ const why = [];
734
+ const t = (x) => d.nodes[String(x ?? '')]?.data.type;
735
+ const cfgSrc = (x) => String((d.nodes[String(x ?? '')]?.config?.optionSource ?? '')).trim();
736
+ const isRecordSelect = (x) => t(x) === 'form-select' && cfgSrc(x) === 'record';
737
+ const isDate = (x) => t(x) === 'form-date' || t(x) === 'form-calendar';
738
+ if (terms.filter((x) => x.kind === 'record').length !== 1)
739
+ why.push('it needs exactly ONE "record" term (the chosen room\'s price)');
740
+ for (const x of terms) {
741
+ if (x.kind === 'record' && (!isRecordSelect(x.node) || !RECORD_FIELD_ID.test(String(x.field ?? ''))))
742
+ why.push(`record term: node "${String(x.node ?? '')}" must be a record-sourced form-select and field a fld_ id`);
743
+ if (x.kind === 'nights' && (!isDate(x.start) || !isDate(x.end)))
744
+ why.push('nights term: start and end must be date fields of this form');
745
+ if (x.kind === 'number' && t(x.node) !== 'form-number')
746
+ why.push(`number term: "${String(x.node ?? '')}" is not a form-number`);
747
+ }
748
+ for (const r of rules) {
749
+ if ((r.kind === 'dateRange' || r.kind === 'maxNights') && (!isDate(r.start) || !isDate(r.end)))
750
+ why.push(`${String(r.kind)} rule: start and end must be date fields of this form`);
751
+ if (r.kind === 'capacity' && (!isRecordSelect(r.node) || !RECORD_FIELD_ID.test(String(r.field ?? '')) || t(r.count) !== 'form-number' || (r.per && t(r.per) !== 'form-number')))
752
+ why.push('capacity rule: node must be a record select, field a fld_ id, count (and per) form-number fields');
753
+ }
754
+ if (why.length) {
755
+ out.push({
756
+ code: 'estimate_invalid',
757
+ nodeId: id,
758
+ type,
759
+ problem: `A booking estimate that shows no total or skips a stay rule: ${[...new Set(why)].join('; ')}.`,
760
+ fix: fill('estimate_invalid', { id }),
761
+ });
762
+ }
763
+ }
673
764
  // RECORD FILTERS (web_builder PR #157, contract §7.1): a filter/sort control aimed at a
674
765
  // record list. Every miss below renders a control that empties the list or narrows nothing.
675
766
  {
@@ -681,11 +772,23 @@ opts = {}) {
681
772
  });
682
773
  const why = [];
683
774
  if (sp.filterSource === 'record') {
684
- if (!RECORD_FIELD_ID.test(String(sp.filterAxis ?? '')))
685
- why.push(`filterAxis "${String(sp.filterAxis ?? '')}" is not a field ID`);
775
+ const cfgOf = (t) => d.nodes[t].config ?? {};
776
+ // web_builder #162: an axis may also be an aggregate (`agg.<key>`, a budget) or a
777
+ // visitor param (`param.<name>`) — both must exist on EVERY targeted list.
778
+ const axis = String(sp.filterAxis ?? '');
779
+ const aggsOf = (t) => readRecordAggregates(cfgOf(t).recordAggregates) ?? [];
780
+ if (axis.startsWith('agg.') || axis.startsWith('param.')) {
781
+ const key = axis.startsWith('agg.') ? recordAggKeyOf(axis) : axis.slice(6);
782
+ const has = (t) => axis.startsWith('agg.') ? aggsOf(t).some((a) => a.key === key) : recordAggregateParams(aggsOf(t)).has(key);
783
+ if (!key || !recordTargets.length || !recordTargets.every(has))
784
+ why.push(`filterAxis "${axis}" names no aggregate or param of its target list's config.recordAggregates`);
785
+ if (axis.startsWith('param.') && sp.filterArity !== undefined && sp.filterArity !== 'single')
786
+ why.push(`a param axis holds ONE value — filterArity "${String(sp.filterArity)}" lets a visitor tick two and empties the list`);
787
+ }
788
+ else if (!RECORD_FIELD_ID.test(axis))
789
+ why.push(`filterAxis "${axis}" is not a field ID`);
686
790
  if (targets.length && recordTargets.length !== targets.length)
687
791
  why.push('a filterTargets entry is not a record list');
688
- const cfgOf = (t) => d.nodes[t].config ?? {};
689
792
  if (recordTargets.some((t) => String(cfgOf(t).recordRelationField ?? '')))
690
793
  why.push('it targets a relation list');
691
794
  if (new Set(recordTargets.map((t) => String(cfgOf(t).recordCollectionId ?? ''))).size > 1)
@@ -696,9 +799,9 @@ opts = {}) {
696
799
  if (sp.filterSource === 'sort' && recordTargets.length) {
697
800
  const bad = (Array.isArray(sp.filterValues) ? sp.filterValues : [])
698
801
  .map((v) => String(v?.value ?? ''))
699
- .filter((v) => !/^fld_[0-9a-f]{16}_(?:asc|desc)$/.test(v));
802
+ .filter((v) => !/^(?:fld_[0-9a-f]{16}|agg\.[a-z][a-zA-Z0-9_]{0,31})_(?:asc|desc)$/.test(v));
700
803
  if (bad.length)
701
- why.push(`sort values ${bad.slice(0, 3).map((v) => `"${v}"`).join(', ')} are not "<fieldId>_asc|desc" — a record list ignores product sort keys`);
804
+ why.push(`sort values ${bad.slice(0, 3).map((v) => `"${v}"`).join(', ')} are not "<fieldId>_asc|desc" or "agg.<key>_asc|desc" — a record list ignores product sort keys`);
702
805
  }
703
806
  if (why.length) {
704
807
  out.push({
@@ -712,7 +815,25 @@ opts = {}) {
712
815
  }
713
816
  if (RECORD_TILES.has(type) && cfg.datasetSource === 'record') {
714
817
  const fid = cfg.recordField;
715
- if (typeof fid !== 'string' || !RECORD_FIELD_ID.test(fid)) {
818
+ const aggKey = typeof fid === 'string' ? recordAggKeyOf(fid) : '';
819
+ let aggOk = false;
820
+ if (aggKey) {
821
+ let up = n.data.parent;
822
+ while (up && d.nodes[up] && d.nodes[up].data.type !== 'list-dataset')
823
+ up = d.nodes[up].data.parent;
824
+ const lc = up ? d.nodes[up].config : undefined;
825
+ aggOk = (readRecordAggregates(lc?.recordAggregates) ?? []).some((a) => a.key === aggKey);
826
+ }
827
+ if (aggKey && !aggOk) {
828
+ out.push({
829
+ code: 'record_field_unset',
830
+ nodeId: id,
831
+ type,
832
+ problem: `config.recordField "${String(fid)}" names an aggregate its record list does not define in config.recordAggregates — it renders its placeholder on every row.`,
833
+ fix: fill('record_field_unset', { id }),
834
+ });
835
+ }
836
+ else if (!aggKey && (typeof fid !== 'string' || !RECORD_FIELD_ID.test(fid))) {
716
837
  out.push({
717
838
  code: 'record_field_unset',
718
839
  nodeId: id,
@@ -730,7 +851,8 @@ opts = {}) {
730
851
  while (list && d.nodes[list] && d.nodes[list].data.type !== 'list-dataset')
731
852
  list = d.nodes[list].data.parent;
732
853
  const lcfg = list && d.nodes[list] ? d.nodes[list].config : undefined;
733
- if (lcfg?.datasetSource !== 'record') {
854
+ // On a record detail page the PAGE supplies the record outside any list.
855
+ if (lcfg?.datasetSource !== 'record' && !(opts.pageType === 'record' && !list)) {
734
856
  out.push({
735
857
  code: 'no_data_context',
736
858
  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.93.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",