sbuilder-mcp 0.92.0 → 0.94.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.94.0] - 2026-10-27
10
+
11
+ ### Added
12
+ - `sb_bind` accepts `record.ref.<a>.<b>` and `record.money.ref.<a>.<b>` to bind a field of the record a reference names, reading the referenced collection's fields to check the target, and a text-dataset tile with `recordField: "ref.<a>.<b>"` derives its binding the way the platform does.
13
+ - `sb_review` reports `href_params_invalid`, `href_params_unresolved` and `href_params_stranded` for link parameters (`hrefParams`) that are malformed, have no record to read from, or sit on a button without exactly one click action.
14
+ - `sb_review` reports `record_favorite_invalid` for a favorites heart or tab that is wired wrongly, and `show_record_favorites` requires a `targetId`.
15
+ - Buttons gain the `toggle_record_favorite` and `show_record_favorites` actions, and the element catalog gains the `tags` field type.
16
+
17
+ ### Changed
18
+ - `hrefParams` is treated as an untranslated key, and `filterMatch: "all"` is valid on record sources.
19
+ - The API catalog is synced with web_builder v1.0.174.
20
+
21
+ ## [0.93.0] - 2026-10-26
22
+
23
+ ### Added
24
+ - `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.
25
+ - `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.
26
+ - `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.
27
+ - The element catalog gains `form-estimate` and the base-only `recordAggregates` key.
28
+
29
+ ### Changed
30
+ - `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`.
31
+ - `sb_review` reports `record_field_unset` for a tile bound to `agg.<key>` that its list does not define.
32
+ - The API catalog is synced with web_builder v1.0.173 and now covers 124 elements and 95 binding sources.
33
+
9
34
  ## [0.92.0] - 2026-10-26
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.94.0] - 2026-10-27
10
+
11
+ ### Added
12
+ - `sb_bind` nhận `record.ref.<a>.<b>` và `record.money.ref.<a>.<b>` để bind field của record mà một reference trỏ tới, đọc field của collection được tham chiếu để kiểm tra đích, và tile text-dataset có `recordField: "ref.<a>.<b>"` suy ra binding giống nền tảng.
13
+ - `sb_review` báo `href_params_invalid`, `href_params_unresolved` và `href_params_stranded` cho tham số liên kết (`hrefParams`) sai định dạng, không có record để đọc, hoặc nằm trên button không có đúng một hành động click.
14
+ - `sb_review` báo `record_favorite_invalid` cho nút tim hoặc tab yêu thích nối sai, và `show_record_favorites` yêu cầu `targetId`.
15
+ - Button thêm hai hành động `toggle_record_favorite` và `show_record_favorites`, và catalog element thêm loại field `tags`.
16
+
17
+ ### Changed
18
+ - `hrefParams` được coi là khoá không dịch, và `filterMatch: "all"` hợp lệ trên nguồn record.
19
+ - Catalog API được đồng bộ với web_builder v1.0.174.
20
+
21
+ ## [0.93.0] - 2026-10-26
22
+
23
+ ### Added
24
+ - `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.
25
+ - `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.
26
+ - `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.
27
+ - Catalog element thêm `form-estimate` và khoá base-only `recordAggregates`.
28
+
29
+ ### Changed
30
+ - `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`.
31
+ - `sb_review` báo `record_field_unset` cho tile bind `agg.<key>` mà danh sách của nó không định nghĩa.
32
+ - Catalog API được đồng bộ với web_builder v1.0.173 và nay gồm 124 element và 95 nguồn binding.
33
+
9
34
  ## [0.92.0] - 2026-10-26
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.
@@ -29687,7 +29687,8 @@ export const API_DEFINITIONS = {
29687
29687
  "select",
29688
29688
  "integer",
29689
29689
  "reference",
29690
- "gallery"
29690
+ "gallery",
29691
+ "tags"
29691
29692
  ],
29692
29693
  "x-enum-varnames": [
29693
29694
  "TypeText",
@@ -29700,7 +29701,8 @@ export const API_DEFINITIONS = {
29700
29701
  "TypeSelect",
29701
29702
  "TypeInteger",
29702
29703
  "TypeReference",
29703
- "TypeGallery"
29704
+ "TypeGallery",
29705
+ "TypeTags"
29704
29706
  ]
29705
29707
  },
29706
29708
  "github_com_webbuilder_server_internal_collections.UpsertItem": {
@@ -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",
@@ -1,7 +1,7 @@
1
1
  export const DEAD_KEY_SOURCE = {
2
- "files": 5966,
3
- "identifiers": 109792,
4
- "seededKeys": 457,
2
+ "files": 6012,
3
+ "identifiers": 110435,
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 = {
@@ -833,7 +833,9 @@ export const ELEMENTS = {
833
833
  "logout_customer",
834
834
  "toggle_wishlist",
835
835
  "quick_view",
836
- "close_quick_view"
836
+ "close_quick_view",
837
+ "toggle_record_favorite",
838
+ "show_record_favorites"
837
839
  ]
838
840
  },
839
841
  "bindingEvents": {
@@ -9846,6 +9848,154 @@ export const ELEMENTS = {
9846
9848
  "account"
9847
9849
  ]
9848
9850
  },
9851
+ "form-estimate": {
9852
+ "type": "form-estimate",
9853
+ "label": "Estimate",
9854
+ "category": "form",
9855
+ "isContainer": false,
9856
+ "isRootOnly": false,
9857
+ "locked": false,
9858
+ "hideInLayer": false,
9859
+ "childAllows": [],
9860
+ "defaults": {
9861
+ "specials": {
9862
+ "name": "",
9863
+ "label": "Tạm tính",
9864
+ "placeholder": "Chọn phòng và ngày để xem giá dự kiến.",
9865
+ "description": "Giá dự kiến, chưa phải giá giữ chỗ.",
9866
+ "estimateTerms": [],
9867
+ "estimateRules": []
9868
+ },
9869
+ "style": {
9870
+ "width": "100%",
9871
+ "height": "fit-content",
9872
+ "fontSize": "14px",
9873
+ "lineHeight": "150%",
9874
+ "color": "#171717"
9875
+ }
9876
+ },
9877
+ "inspector": [
9878
+ {
9879
+ "tab": "general",
9880
+ "groups": [
9881
+ {
9882
+ "key": "content",
9883
+ "label": "Content",
9884
+ "controls": [
9885
+ "form_estimate_content"
9886
+ ]
9887
+ },
9888
+ {
9889
+ "key": "estimate",
9890
+ "label": "Estimate",
9891
+ "controls": [
9892
+ "form_estimate_terms"
9893
+ ]
9894
+ },
9895
+ {
9896
+ "key": "estimate_rules",
9897
+ "label": "Stay rules",
9898
+ "controls": [
9899
+ "form_estimate_rules"
9900
+ ]
9901
+ },
9902
+ {
9903
+ "key": "typography",
9904
+ "label": "Typography",
9905
+ "controls": [
9906
+ "text_color",
9907
+ "font_family",
9908
+ "font_size",
9909
+ "text_align",
9910
+ "line_height"
9911
+ ]
9912
+ },
9913
+ {
9914
+ "key": "size",
9915
+ "label": "Size",
9916
+ "controls": [
9917
+ "width_select",
9918
+ "height_select",
9919
+ "size_bounds"
9920
+ ]
9921
+ },
9922
+ {
9923
+ "key": "id_field",
9924
+ "label": "ID field",
9925
+ "controls": [
9926
+ "field_name"
9927
+ ]
9928
+ }
9929
+ ]
9930
+ },
9931
+ {
9932
+ "tab": "advanced",
9933
+ "groups": [
9934
+ {
9935
+ "key": "spacing",
9936
+ "label": "Spacing",
9937
+ "controls": [
9938
+ "padding_margin"
9939
+ ]
9940
+ },
9941
+ {
9942
+ "key": "display",
9943
+ "label": "Display",
9944
+ "controls": [
9945
+ "display"
9946
+ ]
9947
+ },
9948
+ {
9949
+ "key": "class_css",
9950
+ "label": "Class",
9951
+ "controls": [
9952
+ "class_css"
9953
+ ]
9954
+ }
9955
+ ]
9956
+ }
9957
+ ],
9958
+ "controls": [
9959
+ "form_estimate_content",
9960
+ "form_estimate_terms",
9961
+ "form_estimate_rules",
9962
+ "text_color",
9963
+ "font_family",
9964
+ "font_size",
9965
+ "text_align",
9966
+ "line_height",
9967
+ "width_select",
9968
+ "height_select",
9969
+ "size_bounds",
9970
+ "field_name",
9971
+ "padding_margin",
9972
+ "display",
9973
+ "class_css"
9974
+ ],
9975
+ "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.",
9976
+ "useWhen": [
9977
+ "A booking or quote request whose price is a record (a room type, a tour, a rental) times nights and/or a quantity.",
9978
+ "The visitor should see what their choice costs before sending, and the stay dates need checking."
9979
+ ],
9980
+ "avoidWhen": [
9981
+ "The form sells cart products — the order form and its bill already price those.",
9982
+ "The price is not stored on a data record; there is nothing authoritative for the server to recompute from."
9983
+ ],
9984
+ "contentTips": [
9985
+ "Point the price at a number column holding WHOLE currency units (1850000, not cents).",
9986
+ "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.",
9987
+ "Keep the note: an estimate is not a held price."
9988
+ ],
9989
+ "semantics": [
9990
+ "form",
9991
+ "booking",
9992
+ "estimate",
9993
+ "total",
9994
+ "price",
9995
+ "summary",
9996
+ "nights"
9997
+ ]
9998
+ },
9849
9999
  "form-segment": {
9850
10000
  "type": "form-segment",
9851
10001
  "label": "Segment",
@@ -42918,6 +43068,7 @@ export const ELEMENTS = {
42918
43068
  "recordFilterField": "",
42919
43069
  "recordFilterValue": "",
42920
43070
  "recordRelationField": "",
43071
+ "recordAggregates": [],
42921
43072
  "itemsPerRow": 4,
42922
43073
  "collectionType": "all_products",
42923
43074
  "collectionId": "all_products",
@@ -44025,6 +44176,7 @@ export const ELEMENTS = {
44025
44176
  "recordFilterField",
44026
44177
  "recordFilterValue",
44027
44178
  "recordRelationField",
44179
+ "recordAggregates",
44028
44180
  "itemsPerRow",
44029
44181
  "listNavIcon",
44030
44182
  "listNavPosition",
@@ -44067,6 +44219,7 @@ export const ELEMENTS = {
44067
44219
  "recordFilterField",
44068
44220
  "recordFilterValue",
44069
44221
  "recordRelationField",
44222
+ "recordAggregates",
44070
44223
  "itemsPerRow",
44071
44224
  "listNavIcon",
44072
44225
  "listNavPosition",
@@ -44117,6 +44270,7 @@ export const ELEMENTS = {
44117
44270
  "recordFilterField",
44118
44271
  "recordFilterValue",
44119
44272
  "recordRelationField",
44273
+ "recordAggregates",
44120
44274
  "itemsPerRow",
44121
44275
  "listNavIcon",
44122
44276
  "listNavPosition",
@@ -44169,6 +44323,7 @@ export const ELEMENTS = {
44169
44323
  "recordFilterField",
44170
44324
  "recordFilterValue",
44171
44325
  "recordRelationField",
44326
+ "recordAggregates",
44172
44327
  "itemsPerRow",
44173
44328
  "listNavIcon",
44174
44329
  "listNavPosition",
@@ -44211,6 +44366,7 @@ export const ELEMENTS = {
44211
44366
  "recordFilterField",
44212
44367
  "recordFilterValue",
44213
44368
  "recordRelationField",
44369
+ "recordAggregates",
44214
44370
  "itemsPerRow",
44215
44371
  "listNavIcon",
44216
44372
  "listNavPosition",
@@ -44399,6 +44555,7 @@ export const ELEMENTS = {
44399
44555
  "recordFilterField",
44400
44556
  "recordFilterValue",
44401
44557
  "recordRelationField",
44558
+ "recordAggregates",
44402
44559
  "itemsPerRow",
44403
44560
  "listNavIcon",
44404
44561
  "listNavPosition",
@@ -44511,6 +44668,7 @@ export const ELEMENTS = {
44511
44668
  "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",
44512
44669
  "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",
44513
44670
  "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",
44671
+ "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",
44514
44672
  "config.layout \"grid\" for a multi-row grid, \"slide\" for row-by-row carousel behaviour",
44515
44673
  "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",
44516
44674
  "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",
@@ -45580,6 +45738,7 @@ export const BINDING_SOURCES = [
45580
45738
  "site.supportEmail",
45581
45739
  "site.supportPhone",
45582
45740
  "site.tiktok",
45741
+ "site.timezone",
45583
45742
  "site.youtube"
45584
45743
  ];
45585
45744
  export const BOUND_SPECIALS = {
@@ -45771,6 +45930,67 @@ export const RECORD_BINDING_PROBES = [
45771
45930
  }
45772
45931
  ]
45773
45932
  },
45933
+ {
45934
+ "type": "text-dataset",
45935
+ "config": {
45936
+ "datasetSource": "record",
45937
+ "recordField": "agg.startPrice",
45938
+ "recordFieldType": "number"
45939
+ },
45940
+ "bindings": [
45941
+ {
45942
+ "id": "bind-text",
45943
+ "source": "record.agg.startPrice",
45944
+ "field": "specials.boundText",
45945
+ "target": {
45946
+ "type": "record",
45947
+ "id": "",
45948
+ "kind": "title"
45949
+ }
45950
+ }
45951
+ ]
45952
+ },
45953
+ {
45954
+ "type": "text-dataset",
45955
+ "config": {
45956
+ "datasetSource": "record",
45957
+ "recordField": "agg.startPrice",
45958
+ "recordFieldType": "number",
45959
+ "recordFormat": "money"
45960
+ },
45961
+ "bindings": [
45962
+ {
45963
+ "id": "bind-text",
45964
+ "source": "record.money.agg.startPrice",
45965
+ "field": "specials.boundText",
45966
+ "target": {
45967
+ "type": "record",
45968
+ "id": "",
45969
+ "kind": "title"
45970
+ }
45971
+ }
45972
+ ]
45973
+ },
45974
+ {
45975
+ "type": "text-dataset",
45976
+ "config": {
45977
+ "datasetSource": "record",
45978
+ "recordField": "agg.startPrice",
45979
+ "recordFieldType": "richtext"
45980
+ },
45981
+ "bindings": [
45982
+ {
45983
+ "id": "bind-text",
45984
+ "source": "",
45985
+ "field": "specials.boundHtml",
45986
+ "target": {
45987
+ "type": "record",
45988
+ "id": "",
45989
+ "kind": "title"
45990
+ }
45991
+ }
45992
+ ]
45993
+ },
45774
45994
  {
45775
45995
  "type": "text-dataset",
45776
45996
  "config": {
@@ -45832,6 +46052,67 @@ export const RECORD_BINDING_PROBES = [
45832
46052
  }
45833
46053
  ]
45834
46054
  },
46055
+ {
46056
+ "type": "text-dataset",
46057
+ "config": {
46058
+ "datasetSource": "record",
46059
+ "recordField": "ref.fld_0123456789abcdef.fld_0123456789abcdef",
46060
+ "recordFieldType": "text"
46061
+ },
46062
+ "bindings": [
46063
+ {
46064
+ "id": "bind-text",
46065
+ "source": "record.ref.fld_0123456789abcdef.fld_0123456789abcdef",
46066
+ "field": "specials.boundText",
46067
+ "target": {
46068
+ "type": "record",
46069
+ "id": "",
46070
+ "kind": "title"
46071
+ }
46072
+ }
46073
+ ]
46074
+ },
46075
+ {
46076
+ "type": "text-dataset",
46077
+ "config": {
46078
+ "datasetSource": "record",
46079
+ "recordField": "ref.fld_0123456789abcdef.fld_0123456789abcdef",
46080
+ "recordFieldType": "integer",
46081
+ "recordFormat": "money"
46082
+ },
46083
+ "bindings": [
46084
+ {
46085
+ "id": "bind-text",
46086
+ "source": "record.money.ref.fld_0123456789abcdef.fld_0123456789abcdef",
46087
+ "field": "specials.boundText",
46088
+ "target": {
46089
+ "type": "record",
46090
+ "id": "",
46091
+ "kind": "title"
46092
+ }
46093
+ }
46094
+ ]
46095
+ },
46096
+ {
46097
+ "type": "text-dataset",
46098
+ "config": {
46099
+ "datasetSource": "record",
46100
+ "recordField": "ref.fld_0123456789abcdef.fld_0123456789abcdef",
46101
+ "recordFieldType": "richtext"
46102
+ },
46103
+ "bindings": [
46104
+ {
46105
+ "id": "bind-text",
46106
+ "source": "",
46107
+ "field": "specials.boundHtml",
46108
+ "target": {
46109
+ "type": "record",
46110
+ "id": "",
46111
+ "kind": "title"
46112
+ }
46113
+ }
46114
+ ]
46115
+ },
45835
46116
  {
45836
46117
  "type": "collection-media",
45837
46118
  "config": {
@@ -48946,7 +49227,8 @@ export const WRITE_PRECONDITIONS = [
48946
49227
  "tag",
48947
49228
  "blog_category",
48948
49229
  "blog_tag",
48949
- "course_tag"
49230
+ "course_tag",
49231
+ "record"
48950
49232
  ]
48951
49233
  },
48952
49234
  {
@@ -48959,7 +49241,7 @@ export const WRITE_PRECONDITIONS = [
48959
49241
  ]
48960
49242
  }
48961
49243
  ],
48962
- "otherwise": "the filter combines ticked values with OR, as it always did. Both predicates IGNORE the mode outside these cases rather than honouring it — a product holds ONE brand, one price and one stock state, so \"match all\" on two ticked values could only return an empty shelf, and a stale URL degrades to the ordinary OR instead of to a blank page."
49244
+ "otherwise": "the filter combines ticked values with OR, as it always did. Both predicates IGNORE the mode outside these cases rather than honouring it — a product holds ONE brand, one price and one stock state, so \"match all\" on two ticked values could only return an empty shelf, and a stale URL degrades to the ordinary OR instead of to a blank page. On a `record` source only a `tags` field honours it."
48963
49245
  }
48964
49246
  ];
48965
49247
  export const HOVER_HOMES = {
@@ -50199,6 +50481,7 @@ export const BASE_ONLY_CONFIG = [
50199
50481
  "backgroundSceneModelUrl",
50200
50482
  "rowLimit",
50201
50483
  "optionSource",
50484
+ "recordAggregates",
50202
50485
  "recordCollectionId",
50203
50486
  "recordField",
50204
50487
  "recordFieldType",
@@ -372,6 +372,11 @@ export function describeOperation(op) {
372
372
  'product priceCents). reference = "rec_…" of a record in the field\'s refCollectionId, ' +
373
373
  'same site; gallery = 1..30 image URLs; date = YYYY-MM-DD. A field\'s type and ' +
374
374
  'refCollectionId cannot change after creation.',
375
+ tags: 'tags = a multi-select: options REQUIRED (1..100, unique, no comma); a value is a JSON array of ' +
376
+ 'options or one comma-separated string, stored in options order; CSV cell ", "-joined. A page ' +
377
+ 'prints it joined. Filter: filterSource "record", filterAxis = its fld_…, filterValues = its options; ' +
378
+ 'f.record.<fid> matches ANY, fm.record.<fid>=all (specials.filterMatch "all") ALL — only a tags axis ' +
379
+ 'honours all. type is immutable: migrate a text column to a NEW tags field and upsert.',
375
380
  revision: 'PUT a record with the rev you read: a different stored rev is 409 stale_record and ' +
376
381
  'nothing is written — re-read and reapply, never retry blind. PUT always needs published.',
377
382
  import: 'records/upsert is the idempotent import: EVERY item needs an externalId (1..120, no ' +
@@ -4,7 +4,7 @@ export const SHAPE_SOURCE = {
4
4
  "fromHandlers": 334,
5
5
  "fromSwaggerOnly": 10,
6
6
  "withReadOnly": 29,
7
- "structsRead": 3459
7
+ "structsRead": 3467
8
8
  };
9
9
  export const REQUEST_SHAPES = {
10
10
  "post:/api/sites/{id}/ai-credits/purchase": {
@@ -1533,7 +1533,7 @@ export const REQUEST_SHAPES = {
1533
1533
  },
1534
1534
  {
1535
1535
  "name": "type",
1536
- "type": "\"text\" | \"richtext\" | \"number\" | \"boolean\" | \"image\" | \"url\" | \"date\" | \"select\" | \"integer\" | \"reference\" | \"gallery\""
1536
+ "type": "\"text\" | \"richtext\" | \"number\" | \"boolean\" | \"image\" | \"url\" | \"date\" | \"select\" | \"integer\" | \"reference\" | \"gallery\" | \"tags\""
1537
1537
  },
1538
1538
  {
1539
1539
  "name": "required",
@@ -1580,7 +1580,7 @@ export const REQUEST_SHAPES = {
1580
1580
  },
1581
1581
  {
1582
1582
  "name": "type",
1583
- "type": "\"text\" | \"richtext\" | \"number\" | \"boolean\" | \"image\" | \"url\" | \"date\" | \"select\" | \"integer\" | \"reference\" | \"gallery\""
1583
+ "type": "\"text\" | \"richtext\" | \"number\" | \"boolean\" | \"image\" | \"url\" | \"date\" | \"select\" | \"integer\" | \"reference\" | \"gallery\" | \"tags\""
1584
1584
  },
1585
1585
  {
1586
1586
  "name": "required",
@@ -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": "05168e6e00f55abbddc7895a29a15f030ca060fa",
5
- "committedAt": "2026-10-26T09:25:34+07:00",
4
+ "commit": "db78bb5204d20a8f3dd9abf6d6a42fbfb7e58daf",
5
+ "committedAt": "2026-10-26T21:17:55+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",
@@ -2967,6 +2968,7 @@ export const STORE_PAGE_SEEDS = {
2967
2968
  "recordFilterField": "",
2968
2969
  "recordFilterValue": "",
2969
2970
  "recordRelationField": "",
2971
+ "recordAggregates": [],
2970
2972
  "itemsPerRow": 4,
2971
2973
  "collectionType": "all_products",
2972
2974
  "collectionId": "all_products",
@@ -3688,6 +3690,7 @@ export const STORE_PAGE_SEEDS = {
3688
3690
  "recordFilterField": "",
3689
3691
  "recordFilterValue": "",
3690
3692
  "recordRelationField": "",
3693
+ "recordAggregates": [],
3691
3694
  "itemsPerRow": 4,
3692
3695
  "collectionType": "all_products",
3693
3696
  "collectionId": "all_products",
@@ -4418,6 +4421,7 @@ export const STORE_PAGE_SEEDS = {
4418
4421
  "recordFilterField": "",
4419
4422
  "recordFilterValue": "",
4420
4423
  "recordRelationField": "",
4424
+ "recordAggregates": [],
4421
4425
  "itemsPerRow": 4,
4422
4426
  "collectionType": "all_products",
4423
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": 183,
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",
@@ -434,6 +440,7 @@ export const NEVER_TRANSLATED = [
434
440
  "hideWhenEmpty",
435
441
  "hoverHostDepth",
436
442
  "href",
443
+ "hrefParams",
437
444
  "htmlTag",
438
445
  "icon",
439
446
  "iconEnabled",
@@ -522,6 +529,7 @@ export const NEVER_TRANSLATED = [
522
529
  "showPercent",
523
530
  "showPhone",
524
531
  "showPlaceholder",
532
+ "siteTimezone",
525
533
  "source",
526
534
  "sourceCode",
527
535
  "sourceFlashSale",
@@ -1129,6 +1137,17 @@ export const TRANSLATABLE_FIELDS = {
1129
1137
  "key": "linkText"
1130
1138
  }
1131
1139
  ],
1140
+ "form-estimate": [
1141
+ {
1142
+ "key": "label"
1143
+ },
1144
+ {
1145
+ "key": "placeholder"
1146
+ },
1147
+ {
1148
+ "key": "description"
1149
+ }
1150
+ ],
1132
1151
  "form-step-button": [
1133
1152
  {
1134
1153
  "key": "text"
@@ -124,6 +124,13 @@ export const FIX = {
124
124
  '{ "optionSource": "record", "recordCollectionId": "col_…", "recordLabelField": "fld_…" } (base). A dependent ' +
125
125
  'select adds "recordParentNode": "<parent form-select id>" and "recordParentField": "<reference fld_… of this ' +
126
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>}] }.',
127
134
  record_filter_invalid: 'Aim it at ONE collection\'s record list(s) and a field: sb_set id "<id>", namespace specials, keys ' +
128
135
  '{ "filterSource": "record", "filterAxis": "fld_…", "filterTargets": ["<list id>"], "filterBehavior": "filter" } — ' +
129
136
  'axis types text/select/reference/url/date/boolean/integer/number. A sort select takes filterValues ' +
@@ -135,6 +142,17 @@ export const FIX = {
135
142
  'namespace config, keys { "recordRelationField": "" }.',
136
143
  record_relation_no_empty: 'Give it an empty state: sb_add a "list-empty" into id "<id>" (it becomes config.emptyStateId), ' +
137
144
  'e.g. a short "Chưa có dữ liệu" line.',
145
+ href_params_invalid: 'Rewrite them: sb_set id "<id>", namespace specials, keys { "href": "/<same-site path>", "hrefParams": ' +
146
+ '[{ "name": "hotel", "source": "record.fld_…" }, { "name": "room", "source": "record.id" }] } — name ' +
147
+ '^[a-z][a-z0-9_]{0,23}$, unique, not lang/s/t/lesson; source record.id | record.url | record.fld_<16 hex>; ≤ 8. ' +
148
+ 'Pass ids, never names, emails or phones — a query is visible in the address bar and logs.',
149
+ href_params_unresolved: 'Move it inside a record list\'s card (sb_move), use it on a record detail page, or drop them: sb_set id "<id>", ' +
150
+ 'namespace specials, keys { "hrefParams": [] }.',
151
+ href_params_stranded: 'Give it ONE click action — open_page or go_to_url (sb_event) — and no purchase binding, or drop them: ' +
152
+ 'sb_set id "<id>", namespace specials, keys { "hrefParams": [] }.',
153
+ record_favorite_invalid: 'A heart is a button inside a record list\'s card (or on a record page) with click toggle_record_favorite {}; a tab ' +
154
+ 'is a button with show_record_favorites { "targetId": "<record list id, not a relation list>", "view": ' +
155
+ '"favorites" | "all", "showCount": true } (sb_event id "<id>"). Give the list a list-empty.',
138
156
  record_field_unset: 'Name the field by its ID: sb_set id "<id>", namespace config, keys { "recordField": "fld_…", ' +
139
157
  '"recordFieldType": "<the field\'s type>" } — ids and types are on GET /api/sites/{siteId}/collections/{id}/fields. ' +
140
158
  'A text-dataset shows text/richtext/number/integer/boolean/date/select/url/reference (a number/integer ' +
@@ -177,6 +177,11 @@ export const PAYLOAD_NEEDS = {
177
177
  why: 'publishes a PopupControl call with no target, which opens nothing',
178
178
  shape: '{ "id": "<the pop-up\'s overlay id, from sb_store popup>", "label": "<name>" }',
179
179
  },
180
+ show_record_favorites: {
181
+ key: 'targetId',
182
+ why: 'publishes a favorites tab aimed at no list, which narrows nothing',
183
+ shape: '{ "targetId": "<record list-dataset id>", "view": "favorites" | "all", "showCount": true }',
184
+ },
180
185
  scroll_to: {
181
186
  key: 'targetId',
182
187
  why: 'publishes a ScrollControl call with no target, which scrolls nowhere',
@@ -13,9 +13,117 @@ export const RECORD_FIELD_ID = /^fld_[0-9a-f]{16}$/;
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
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
+ * only when the row's collection has a published record detail page (recordContext.ts),
17
+ * or `record.ref.<refFid>.<fid>` / `record.money.ref.…` — a field of the record a
18
+ * `reference` field names, one hop, never rich (contract §7.8).
17
19
  */
18
- export const RECORD_SOURCE = /^record\.(?:id|url|(?:html\.|money\.)?fld_[0-9a-f]{16})$/;
20
+ export const RECORD_SOURCE = /^record\.(?:id|url|(?:html\.|money\.)?fld_[0-9a-f]{16}|(?:money\.)?agg\.[a-z][a-zA-Z0-9_]{0,31}|(?:money\.)?ref\.fld_[0-9a-f]{16}\.fld_[0-9a-f]{16})$/;
21
+ /** recordContext.ts parseRecordRefAxis — `ref.<referenceFid>.<targetFid>`, else null. */
22
+ export function parseRecordRefAxis(axis) {
23
+ const m = /^ref\.(fld_[0-9a-f]{16})\.(fld_[0-9a-f]{16})$/.exec(axis);
24
+ return m ? { ref: m[1], field: m[2] } : null;
25
+ }
26
+ /** recordContext.ts RECORD_REF_TARGET_TYPES — what a `ref.<a>.<b>` tile may show. */
27
+ export const RECORD_REF_TARGET_TYPES = new Set(['text', 'select', 'url', 'date', 'number', 'integer', 'boolean', 'tags']);
28
+ /**
29
+ * RECORD LINK PARAMETERS (contract §7.6, schema/src/elements/hrefParams.ts).
30
+ * `specials.hrefParams: [{name, source}]` appends row values to a same-site
31
+ * `specials.href`; every malformed entry is DROPPED at render, never repaired.
32
+ */
33
+ export const HREF_PARAM_NAME = /^[a-z][a-z0-9_]{0,23}$/;
34
+ export const HREF_PARAM_SOURCE = /^record\.(?:id|url|fld_[0-9a-f]{16})$/;
35
+ export const HREF_PARAM_RESERVED = new Set(['lang', 's', 't', 'lesson']);
36
+ export const MAX_HREF_PARAMS = 8;
37
+ /** hrefParams.ts isSameSitePath — the only hrefs params are appended to. */
38
+ export function isSameSitePath(p) {
39
+ if (!p.startsWith('/') || p[1] === '/' || p[1] === '\\')
40
+ return false;
41
+ return ![...p].some((c) => c.charCodeAt(0) <= 0x20 || c.charCodeAt(0) === 0x7f || c === '\\');
42
+ }
43
+ /** Why entries of `hrefParams` would be dropped at render (hrefWithParams), in the renderer's own order. */
44
+ export function hrefParamProblems(raw) {
45
+ if (raw === undefined || (Array.isArray(raw) && !raw.length))
46
+ return [];
47
+ if (!Array.isArray(raw))
48
+ return ['specials.hrefParams is not an array of { name, source }'];
49
+ const out = [];
50
+ if (raw.length > MAX_HREF_PARAMS)
51
+ out.push(`only the first ${MAX_HREF_PARAMS} entries are read — ${raw.length - MAX_HREF_PARAMS} more are ignored`);
52
+ const seen = new Set();
53
+ for (const item of raw.slice(0, MAX_HREF_PARAMS)) {
54
+ const { name, source } = (item ?? {});
55
+ if (typeof name !== 'string') {
56
+ out.push('an entry has no string name');
57
+ continue;
58
+ }
59
+ if (seen.has(name)) {
60
+ out.push(`"${name}" is named twice — only the first entry is read`);
61
+ continue;
62
+ }
63
+ seen.add(name);
64
+ if (!HREF_PARAM_NAME.test(name))
65
+ out.push(`name "${name}" is not ^[a-z][a-z0-9_]{0,23}$`);
66
+ else if (HREF_PARAM_RESERVED.has(name))
67
+ out.push(`name "${name}" is reserved (lang, s, t, lesson are read by the platform)`);
68
+ else if (typeof source !== 'string' || !HREF_PARAM_SOURCE.test(source))
69
+ out.push(`"${name}"'s source "${String(source)}" is not record.id, record.url or record.fld_<16 hex>`);
70
+ }
71
+ return out;
72
+ }
73
+ /** An aggregate key — recordAggregates.ts RECORD_AGG_KEY. A tile names it as `agg.<key>`. */
74
+ export const RECORD_AGG_KEY = /^[a-z][a-zA-Z0-9_]{0,31}$/;
75
+ const RECORD_PARAM_NAME = /^[a-z][a-z0-9_]{0,23}$/;
76
+ const COLLECTION_ID = /^col_[0-9a-f]{16}$/;
77
+ export const recordAggKeyOf = (axis) => {
78
+ const key = axis.startsWith('agg.') ? axis.slice(4) : '';
79
+ return RECORD_AGG_KEY.test(key) ? key : '';
80
+ };
81
+ /**
82
+ * `config.recordAggregates` read the way the platform does
83
+ * (recordAggregates.ts readRecordAggregates): [] for none, null when MALFORMED —
84
+ * the server then refuses the list and it publishes EMPTY.
85
+ */
86
+ export function readRecordAggregates(raw) {
87
+ if (raw === undefined || raw === null)
88
+ return [];
89
+ if (!Array.isArray(raw) || raw.length > 4)
90
+ return null;
91
+ const s = (v) => (typeof v === 'string' ? v : '');
92
+ const seen = new Set();
93
+ const out = [];
94
+ for (const a of raw) {
95
+ if (!a || typeof a !== 'object')
96
+ return null;
97
+ const op = s(a.op);
98
+ const field = s(a.recordField);
99
+ const whenNone = a.whenNone == null || a.whenNone === '' ? 'hide' : s(a.whenNone);
100
+ if (!RECORD_AGG_KEY.test(s(a.key)) ||
101
+ seen.has(s(a.key)) ||
102
+ !['min', 'max', 'count'].includes(op) ||
103
+ !COLLECTION_ID.test(s(a.recordCollectionId)) ||
104
+ !RECORD_FIELD_ID.test(s(a.recordRelationField)) ||
105
+ (op === 'count' ? field !== '' : !RECORD_FIELD_ID.test(field)) ||
106
+ !['hide', 'show'].includes(whenNone)) {
107
+ return null;
108
+ }
109
+ const where = a.where ?? [];
110
+ if (!Array.isArray(where) || where.length > 4)
111
+ return null;
112
+ const conds = [];
113
+ for (const c of where) {
114
+ const per = s(c?.per);
115
+ 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))) {
116
+ return null;
117
+ }
118
+ conds.push({ param: s(c.param), ...(per ? { per } : {}) });
119
+ }
120
+ seen.add(s(a.key));
121
+ out.push({ key: s(a.key), op, where: conds, whenNone });
122
+ }
123
+ return out;
124
+ }
125
+ /** The visitor params a list's aggregates read (`param.<name>` axes it accepts). */
126
+ export const recordAggregateParams = (aggs) => new Set(aggs.flatMap((a) => a.where.flatMap((c) => (c.per ? [c.param, c.per] : [c.param]))));
19
127
  /** The field types `record.money.<fid>` formats — recordContext.ts RECORD_MONEY_FIELD_TYPES. */
20
128
  export const RECORD_MONEY_TYPES = new Set(['number', 'integer']);
21
129
  /** The only record source `specials.boundHtml` admits — schema/src/rawSinkBindings.ts:102. */
@@ -35,8 +143,11 @@ export function recordTileBindings(type, config, rows) {
35
143
  if (!RECORD_TILES.has(type))
36
144
  return rows;
37
145
  const fid = config.recordField;
38
- const ok = typeof fid === 'string' && RECORD_FIELD_ID.test(fid);
39
146
  const html = type === 'text-dataset' && config.recordFieldType === 'richtext';
147
+ // An aggregate (`agg.<key>`) is a number on the row — datasetBindings.ts recordSource.
148
+ // A referenced record's field (`ref.<a>.<b>`) — one hop, never rich.
149
+ const ok = typeof fid === 'string' &&
150
+ (RECORD_FIELD_ID.test(fid) || (!html && (recordAggKeyOf(fid) !== '' || parseRecordRefAxis(fid) !== null)));
40
151
  // datasetBindings.ts isRecordMoney: a stale recordFormat on any other type is ignored.
41
152
  const money = type === 'text-dataset' && config.recordFormat === 'money' && RECORD_MONEY_TYPES.has(String(config.recordFieldType));
42
153
  return rows.map((row) => {
@@ -84,15 +195,35 @@ export function recordScope(nodeAt, id) {
84
195
  * stringification is recordContext.ts: gallery emits nothing yet, richtext only
85
196
  * under `record.html.`, every other type only under `record.`.
86
197
  */
87
- export function recordFieldProblem(source, scope) {
198
+ export function recordFieldProblem(source, scope,
199
+ /** The fields of each collection a scope `reference` field targets, by collection id (§7.8). */
200
+ refFields = {}) {
88
201
  if (!scope.length) {
89
202
  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.`;
90
203
  }
91
204
  if (source === 'record.id' || source === 'record.url')
92
205
  return undefined;
206
+ // An aggregate is checked against the list's recordAggregates (sb_review), not a field.
207
+ if (/^record\.(?:money\.)?agg\./.test(source))
208
+ return undefined;
93
209
  const html = source.startsWith('record.html.');
94
210
  const money = source.startsWith('record.money.');
95
211
  const fid = source.slice(html ? 12 : money ? 13 : 7);
212
+ const ref = parseRecordRefAxis(fid);
213
+ if (ref) {
214
+ const r = scope.flatMap((s) => s.fields).find((x) => x.id === ref.ref);
215
+ if (!r || r.type !== 'reference')
216
+ return `${ref.ref} is not a reference field of the collection(s) this row reads.`;
217
+ const target = r.refCollectionId ?? '';
218
+ const t = (refFields[target] ?? []).find((x) => x.id === ref.field);
219
+ if (!t)
220
+ return `${ref.field} is not a field of ${target || 'the referenced collection'} (what ${r.key} points at).`;
221
+ if (!RECORD_REF_TARGET_TYPES.has(t.type))
222
+ return `${ref.field} (${t.key}) is ${t.type} — a referenced field shows only ${[...RECORD_REF_TARGET_TYPES].join(', ')}.`;
223
+ if (money && !RECORD_MONEY_TYPES.has(t.type))
224
+ return `${ref.field} (${t.key}) is ${t.type} — record.money. formats only a number or integer field.`;
225
+ return undefined;
226
+ }
96
227
  const f = scope.flatMap((s) => s.fields).find((x) => x.id === fid);
97
228
  if (!f) {
98
229
  const avail = scope
@@ -7,9 +7,9 @@ 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 { hrefParamProblems, isRecordSource, isSameSitePath, parseRecordRefAxis, readRecordAggregates, recordAggKeyOf, recordAggregateParams, RECORD_FIELD_ID, RECORD_HTML_SOURCE, RECORD_SOURCE, RECORD_TILES, recordScope, } from './record.js';
11
11
  import { formFieldFindings } from './formdoc.js';
12
- import { deadNavigation, liveEventTable, PAYLOAD_NEEDS } from './navhref.js';
12
+ import { deadNavigation, hasPurchaseBinding, liveEventTable, PAYLOAD_NEEDS, soleNavigationClick } from './navhref.js';
13
13
  import { unknownValueNote, unknownWriteNote, vocabularyFor, vocabularyForWrite } from './vocabulary.js';
14
14
  import { iconNote } from './icons.js';
15
15
  /**
@@ -705,6 +705,62 @@ opts = {}) {
705
705
  });
706
706
  }
707
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
+ }
708
764
  // RECORD FILTERS (web_builder PR #157, contract §7.1): a filter/sort control aimed at a
709
765
  // record list. Every miss below renders a control that empties the list or narrows nothing.
710
766
  {
@@ -716,11 +772,23 @@ opts = {}) {
716
772
  });
717
773
  const why = [];
718
774
  if (sp.filterSource === 'record') {
719
- if (!RECORD_FIELD_ID.test(String(sp.filterAxis ?? '')))
720
- 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`);
721
790
  if (targets.length && recordTargets.length !== targets.length)
722
791
  why.push('a filterTargets entry is not a record list');
723
- const cfgOf = (t) => d.nodes[t].config ?? {};
724
792
  if (recordTargets.some((t) => String(cfgOf(t).recordRelationField ?? '')))
725
793
  why.push('it targets a relation list');
726
794
  if (new Set(recordTargets.map((t) => String(cfgOf(t).recordCollectionId ?? ''))).size > 1)
@@ -731,9 +799,9 @@ opts = {}) {
731
799
  if (sp.filterSource === 'sort' && recordTargets.length) {
732
800
  const bad = (Array.isArray(sp.filterValues) ? sp.filterValues : [])
733
801
  .map((v) => String(v?.value ?? ''))
734
- .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));
735
803
  if (bad.length)
736
- 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`);
737
805
  }
738
806
  if (why.length) {
739
807
  out.push({
@@ -745,9 +813,86 @@ opts = {}) {
745
813
  });
746
814
  }
747
815
  }
816
+ // RECORD LINK PARAMETERS and VISITOR FAVORITES (web_builder v1.0.174, contract §7.6/§7.7).
817
+ // Both read the record of the row they sit in — or the page's, on a record page.
818
+ {
819
+ const sp = (n.specials ?? {});
820
+ const nodeAt = (x) => d.nodes[x];
821
+ let list = n.data.parent;
822
+ while (list && d.nodes[list] && d.nodes[list].data.type !== 'list-dataset')
823
+ list = d.nodes[list].data.parent;
824
+ const hasRecord = recordScope(nodeAt, id).length > 0 || (opts.pageType === 'record' && !list);
825
+ const evs = n.events ?? [];
826
+ const binds = n.bindings ?? [];
827
+ const params = sp.hrefParams;
828
+ if (Array.isArray(params) ? params.length > 0 : params !== undefined) {
829
+ const why = hrefParamProblems(params);
830
+ // A bound path (record.url) is the platform's own same-site address.
831
+ const boundHref = binds.some((b) => b?.field === 'specials.href');
832
+ if (!boundHref && !isSameSitePath(String(sp.href ?? '')))
833
+ why.push(`specials.href "${String(sp.href ?? '')}" is not a same-site path ("/…"), so no param is ever added`);
834
+ if (why.length)
835
+ out.push({ code: 'href_params_invalid', nodeId: id, type, problem: `Link parameters the renderer drops: ${why.join('; ')}.`, fix: fill('href_params_invalid', { id }) });
836
+ if (!hasRecord)
837
+ out.push({
838
+ code: 'href_params_unresolved',
839
+ nodeId: id,
840
+ type,
841
+ problem: 'specials.hrefParams on a node in no record-list row and not on a record page — no record supplies a value, so the link carries no params.',
842
+ fix: fill('href_params_unresolved', { id }),
843
+ });
844
+ const purchase = hasPurchaseBinding(binds);
845
+ if (purchase || (evs.some((e) => e?.name === 'click') && !soleNavigationClick(evs, false)))
846
+ out.push({
847
+ code: 'href_params_stranded',
848
+ nodeId: id,
849
+ type,
850
+ problem: `specials.hrefParams are not applied: ${purchase ? 'the node is purchase-bound' : 'its click is not ONE Open page / Go to URL action'}, so the href never renders as the link.`,
851
+ fix: fill('href_params_stranded', { id }),
852
+ });
853
+ }
854
+ for (const ev of evs) {
855
+ if (ev?.name !== 'click')
856
+ continue;
857
+ const why = [];
858
+ if (ev.action === 'toggle_record_favorite' && !hasRecord)
859
+ why.push('the heart sits in no record-list row and not on a record page, so it publishes nothing');
860
+ if (ev.action === 'show_record_favorites') {
861
+ const p = ev.payload ?? {};
862
+ const t = String(p.targetId ?? '');
863
+ const tc = d.nodes[t]?.config;
864
+ if (t && (d.nodes[t]?.data.type !== 'list-dataset' || tc?.datasetSource !== 'record'))
865
+ why.push(`targetId "${t}" is not a record list on this page`);
866
+ else if (t && String(tc?.recordRelationField ?? ''))
867
+ why.push(`targetId "${t}" is a relation list, which ignores favorites`);
868
+ if (p.view !== 'favorites' && p.view !== 'all')
869
+ why.push(`view "${String(p.view)}" is neither "favorites" nor "all"`);
870
+ }
871
+ if (why.length)
872
+ out.push({ code: 'record_favorite_invalid', nodeId: id, type, problem: `Its "${String(ev.action)}" does nothing: ${why.join('; ')}.`, key: String(ev.action), fix: fill('record_favorite_invalid', { id }) });
873
+ }
874
+ }
748
875
  if (RECORD_TILES.has(type) && cfg.datasetSource === 'record') {
749
876
  const fid = cfg.recordField;
750
- if (typeof fid !== 'string' || !RECORD_FIELD_ID.test(fid)) {
877
+ const aggKey = typeof fid === 'string' ? recordAggKeyOf(fid) : '';
878
+ let aggOk = false;
879
+ if (aggKey) {
880
+ let up = n.data.parent;
881
+ while (up && d.nodes[up] && d.nodes[up].data.type !== 'list-dataset')
882
+ up = d.nodes[up].data.parent;
883
+ const lc = up ? d.nodes[up].config : undefined;
884
+ aggOk = (readRecordAggregates(lc?.recordAggregates) ?? []).some((a) => a.key === aggKey);
885
+ }
886
+ if (aggKey && !aggOk) {
887
+ out.push({
888
+ code: 'record_field_unset',
889
+ nodeId: id,
890
+ type,
891
+ problem: `config.recordField "${String(fid)}" names an aggregate its record list does not define in config.recordAggregates — it renders its placeholder on every row.`,
892
+ fix: fill('record_field_unset', { id }),
893
+ });
894
+ }
895
+ else if (!aggKey && (typeof fid !== 'string' || (!RECORD_FIELD_ID.test(fid) && !parseRecordRefAxis(fid)))) {
751
896
  out.push({
752
897
  code: 'record_field_unset',
753
898
  nodeId: id,
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { text, images } from '../mcp/response.js';
4
4
  import { BINDING_SOURCES, ELEMENTS } from '../catalog/elements.generated.js';
5
5
  import { bindableFields } from '../domains/site/writecheck.js';
6
- import { isRecordSource, RECORD_HTML_SOURCE, RECORD_SOURCE, recordFieldProblem, recordScope, } from '../domains/site/record.js';
6
+ import { isRecordSource, parseRecordRefAxis, RECORD_HTML_SOURCE, RECORD_SOURCE, recordFieldProblem, recordScope, } from '../domains/site/record.js';
7
7
  import { previewUrl } from '../vision/preview.js';
8
8
  import { uploadMedia } from '../transport/media.js';
9
9
  import { request } from '../transport/http.js';
@@ -69,7 +69,8 @@ export function bindNode(doc, id, source, field, action, guard) {
69
69
  if (!RECORD_SOURCE.test(source)) {
70
70
  throw new Error(`sbuilder: "${source}" names no record field. A record source is "record.fld_<16 hex>" ` +
71
71
  '(or "record.html.fld_<16 hex>" for a richtext field) — the field ID from ' +
72
- 'GET …/collections/{id}/fields, never its key — or "record.id", the row\'s own record id; ' +
72
+ 'GET …/collections/{id}/fields, never its key — or "record.id", the row\'s own record id, or ' +
73
+ '"record.ref.<reference fld_>.<target fld_>" (and its record.money.ref. form), a field of the record a reference names; ' +
73
74
  'anything else binds nothing.');
74
75
  }
75
76
  // record.html.<fid> is HTML: in a text sink its tags print as text.
@@ -771,10 +772,27 @@ export function registerLiveTools(server, ctx, session) {
771
772
  throw new Error(`sbuilder: could not read the fields to check "${source}" against: ${msg}`);
772
773
  });
773
774
  }
775
+ // A REFERENCED record's field (contract §7.8): its target is a field of the
776
+ // reference's refCollectionId, so that collection's fields are read too.
777
+ const refFields = {};
778
+ const ref = parseRecordRefAxis(source.replace(/^record\.(?:money\.)?/, ''));
779
+ const target = ref && withFields?.flatMap((s) => s.fields).find((f) => f.id === ref.ref)?.refCollectionId;
780
+ if (target) {
781
+ try {
782
+ const got = (await get(`/collections/${encodeURIComponent(target)}/fields`));
783
+ refFields[target] = got.fields ?? [];
784
+ }
785
+ catch (e) {
786
+ const msg = e instanceof Error ? e.message : String(e);
787
+ soft(guard, () => {
788
+ throw new Error(`sbuilder: could not read ${target}'s fields to check "${source}" against: ${msg}`);
789
+ });
790
+ }
791
+ }
774
792
  if (withFields) {
775
793
  const fields = withFields;
776
794
  soft(guard, () => {
777
- const why = recordFieldProblem(source, fields);
795
+ const why = recordFieldProblem(source, fields, refFields);
778
796
  if (why)
779
797
  throw new Error(`sbuilder: ${why}`);
780
798
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.92.0",
3
+ "version": "0.94.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",