sbuilder-mcp 0.93.0 → 0.95.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 +18 -0
- package/CHANGELOG.vi.md +18 -0
- package/dist/catalog/api.generated.js +4 -2
- package/dist/catalog/deadkeys.generated.js +2 -2
- package/dist/catalog/elements.generated.js +67 -3
- package/dist/catalog/search.js +5 -0
- package/dist/catalog/shapes.generated.js +2 -2
- package/dist/catalog/source.generated.js +2 -2
- package/dist/catalog/translations.generated.js +2 -1
- package/dist/domains/site/findings.js +11 -0
- package/dist/domains/site/navhref.js +5 -0
- package/dist/domains/site/record.js +80 -4
- package/dist/domains/site/review.js +62 -3
- package/dist/tools/live.js +21 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ 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.95.0] - 2026-10-27
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- `sb_review` accepts a record-list filter key of the form `f.record.fld_<16 hex>[~<node>]` as an `hrefParams` name, so a card in one record list can link to another list narrowed to its record, and reserved names apply only to plain names.
|
|
13
|
+
- The API catalog is synced with web_builder v1.0.175.
|
|
14
|
+
|
|
15
|
+
## [0.94.0] - 2026-10-27
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `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.
|
|
19
|
+
- `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.
|
|
20
|
+
- `sb_review` reports `record_favorite_invalid` for a favorites heart or tab that is wired wrongly, and `show_record_favorites` requires a `targetId`.
|
|
21
|
+
- Buttons gain the `toggle_record_favorite` and `show_record_favorites` actions, and the element catalog gains the `tags` field type.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- `hrefParams` is treated as an untranslated key, and `filterMatch: "all"` is valid on record sources.
|
|
25
|
+
- The API catalog is synced with web_builder v1.0.174.
|
|
26
|
+
|
|
9
27
|
## [0.93.0] - 2026-10-26
|
|
10
28
|
|
|
11
29
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,24 @@ 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.95.0] - 2026-10-27
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- `sb_review` chấp nhận khoá lọc danh sách record dạng `f.record.fld_<16 hex>[~<node>]` làm tên `hrefParams`, để thẻ trong một danh sách record liên kết tới danh sách khác đã lọc theo record của nó, và tên dành riêng chỉ áp dụng cho tên thường.
|
|
13
|
+
- Catalog API được đồng bộ với web_builder v1.0.175.
|
|
14
|
+
|
|
15
|
+
## [0.94.0] - 2026-10-27
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `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.
|
|
19
|
+
- `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.
|
|
20
|
+
- `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`.
|
|
21
|
+
- Button thêm hai hành động `toggle_record_favorite` và `show_record_favorites`, và catalog element thêm loại field `tags`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- `hrefParams` được coi là khoá không dịch, và `filterMatch: "all"` hợp lệ trên nguồn record.
|
|
25
|
+
- Catalog API được đồng bộ với web_builder v1.0.174.
|
|
26
|
+
|
|
9
27
|
## [0.93.0] - 2026-10-26
|
|
10
28
|
|
|
11
29
|
### Added
|
|
@@ -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": {
|
|
@@ -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": {
|
|
@@ -46050,6 +46052,67 @@ export const RECORD_BINDING_PROBES = [
|
|
|
46050
46052
|
}
|
|
46051
46053
|
]
|
|
46052
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
|
+
},
|
|
46053
46116
|
{
|
|
46054
46117
|
"type": "collection-media",
|
|
46055
46118
|
"config": {
|
|
@@ -49164,7 +49227,8 @@ export const WRITE_PRECONDITIONS = [
|
|
|
49164
49227
|
"tag",
|
|
49165
49228
|
"blog_category",
|
|
49166
49229
|
"blog_tag",
|
|
49167
|
-
"course_tag"
|
|
49230
|
+
"course_tag",
|
|
49231
|
+
"record"
|
|
49168
49232
|
]
|
|
49169
49233
|
},
|
|
49170
49234
|
{
|
|
@@ -49177,7 +49241,7 @@ export const WRITE_PRECONDITIONS = [
|
|
|
49177
49241
|
]
|
|
49178
49242
|
}
|
|
49179
49243
|
],
|
|
49180
|
-
"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."
|
|
49181
49245
|
}
|
|
49182
49246
|
];
|
|
49183
49247
|
export const HOVER_HOMES = {
|
package/dist/catalog/search.js
CHANGED
|
@@ -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 ' +
|
|
@@ -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": "
|
|
5
|
-
"committedAt": "2026-10-
|
|
4
|
+
"commit": "41847d6540bdc1151bc3b780001a11e1713db217",
|
|
5
|
+
"committedAt": "2026-10-27T09:30:41+07:00",
|
|
6
6
|
"dirty": false
|
|
7
7
|
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
export const TRANSLATION_SOURCE = {
|
|
4
4
|
"elements": 68,
|
|
5
5
|
"pairs": 190,
|
|
6
|
-
"neverKeys":
|
|
6
|
+
"neverKeys": 183,
|
|
7
7
|
"entityTypes": 12
|
|
8
8
|
};
|
|
9
9
|
/** Every entity type a translation record can name, including "node". */
|
|
@@ -440,6 +440,7 @@ export const NEVER_TRANSLATED = [
|
|
|
440
440
|
"hideWhenEmpty",
|
|
441
441
|
"hoverHostDepth",
|
|
442
442
|
"href",
|
|
443
|
+
"hrefParams",
|
|
443
444
|
"htmlTag",
|
|
444
445
|
"icon",
|
|
445
446
|
"iconEnabled",
|
|
@@ -142,6 +142,17 @@ export const FIX = {
|
|
|
142
142
|
'namespace config, keys { "recordRelationField": "" }.',
|
|
143
143
|
record_relation_no_empty: 'Give it an empty state: sb_add a "list-empty" into id "<id>" (it becomes config.emptyStateId), ' +
|
|
144
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.',
|
|
145
156
|
record_field_unset: 'Name the field by its ID: sb_set id "<id>", namespace config, keys { "recordField": "fld_…", ' +
|
|
146
157
|
'"recordFieldType": "<the field\'s type>" } — ids and types are on GET /api/sites/{siteId}/collections/{id}/fields. ' +
|
|
147
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,66 @@ 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}|(?:money\.)?agg\.[a-z][a-zA-Z0-9_]{0,31})$/;
|
|
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
|
+
/** A record-list filter key as a name (v1.0.175): `/?f.record.<fld>~<list node>=<record.id>` narrows that list. */
|
|
35
|
+
export const HREF_PARAM_FILTER_KEY = /^f\.record\.fld_[0-9a-f]{16}(?:~[A-Za-z0-9_-]{1,64})?$/;
|
|
36
|
+
export const HREF_PARAM_SOURCE = /^record\.(?:id|url|fld_[0-9a-f]{16})$/;
|
|
37
|
+
export const HREF_PARAM_RESERVED = new Set(['lang', 's', 't', 'lesson']);
|
|
38
|
+
export const MAX_HREF_PARAMS = 8;
|
|
39
|
+
/** hrefParams.ts isSameSitePath — the only hrefs params are appended to. */
|
|
40
|
+
export function isSameSitePath(p) {
|
|
41
|
+
if (!p.startsWith('/') || p[1] === '/' || p[1] === '\\')
|
|
42
|
+
return false;
|
|
43
|
+
return ![...p].some((c) => c.charCodeAt(0) <= 0x20 || c.charCodeAt(0) === 0x7f || c === '\\');
|
|
44
|
+
}
|
|
45
|
+
/** Why entries of `hrefParams` would be dropped at render (hrefWithParams), in the renderer's own order. */
|
|
46
|
+
export function hrefParamProblems(raw) {
|
|
47
|
+
if (raw === undefined || (Array.isArray(raw) && !raw.length))
|
|
48
|
+
return [];
|
|
49
|
+
if (!Array.isArray(raw))
|
|
50
|
+
return ['specials.hrefParams is not an array of { name, source }'];
|
|
51
|
+
const out = [];
|
|
52
|
+
if (raw.length > MAX_HREF_PARAMS)
|
|
53
|
+
out.push(`only the first ${MAX_HREF_PARAMS} entries are read — ${raw.length - MAX_HREF_PARAMS} more are ignored`);
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const item of raw.slice(0, MAX_HREF_PARAMS)) {
|
|
56
|
+
const { name, source } = (item ?? {});
|
|
57
|
+
if (typeof name !== 'string') {
|
|
58
|
+
out.push('an entry has no string name');
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (seen.has(name)) {
|
|
62
|
+
out.push(`"${name}" is named twice — only the first entry is read`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
seen.add(name);
|
|
66
|
+
const filterKey = HREF_PARAM_FILTER_KEY.test(name);
|
|
67
|
+
if (!filterKey && !HREF_PARAM_NAME.test(name))
|
|
68
|
+
out.push(`name "${name}" is neither ^[a-z][a-z0-9_]{0,23}$ nor a filter key f.record.fld_<16 hex>[~<node>]`);
|
|
69
|
+
else if (!filterKey && HREF_PARAM_RESERVED.has(name))
|
|
70
|
+
out.push(`name "${name}" is reserved (lang, s, t, lesson are read by the platform)`);
|
|
71
|
+
else if (typeof source !== 'string' || !HREF_PARAM_SOURCE.test(source))
|
|
72
|
+
out.push(`"${name}"'s source "${String(source)}" is not record.id, record.url or record.fld_<16 hex>`);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
19
76
|
/** An aggregate key — recordAggregates.ts RECORD_AGG_KEY. A tile names it as `agg.<key>`. */
|
|
20
77
|
export const RECORD_AGG_KEY = /^[a-z][a-zA-Z0-9_]{0,31}$/;
|
|
21
78
|
const RECORD_PARAM_NAME = /^[a-z][a-z0-9_]{0,23}$/;
|
|
@@ -91,7 +148,9 @@ export function recordTileBindings(type, config, rows) {
|
|
|
91
148
|
const fid = config.recordField;
|
|
92
149
|
const html = type === 'text-dataset' && config.recordFieldType === 'richtext';
|
|
93
150
|
// An aggregate (`agg.<key>`) is a number on the row — datasetBindings.ts recordSource.
|
|
94
|
-
|
|
151
|
+
// A referenced record's field (`ref.<a>.<b>`) — one hop, never rich.
|
|
152
|
+
const ok = typeof fid === 'string' &&
|
|
153
|
+
(RECORD_FIELD_ID.test(fid) || (!html && (recordAggKeyOf(fid) !== '' || parseRecordRefAxis(fid) !== null)));
|
|
95
154
|
// datasetBindings.ts isRecordMoney: a stale recordFormat on any other type is ignored.
|
|
96
155
|
const money = type === 'text-dataset' && config.recordFormat === 'money' && RECORD_MONEY_TYPES.has(String(config.recordFieldType));
|
|
97
156
|
return rows.map((row) => {
|
|
@@ -139,7 +198,9 @@ export function recordScope(nodeAt, id) {
|
|
|
139
198
|
* stringification is recordContext.ts: gallery emits nothing yet, richtext only
|
|
140
199
|
* under `record.html.`, every other type only under `record.`.
|
|
141
200
|
*/
|
|
142
|
-
export function recordFieldProblem(source, scope
|
|
201
|
+
export function recordFieldProblem(source, scope,
|
|
202
|
+
/** The fields of each collection a scope `reference` field targets, by collection id (§7.8). */
|
|
203
|
+
refFields = {}) {
|
|
143
204
|
if (!scope.length) {
|
|
144
205
|
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.`;
|
|
145
206
|
}
|
|
@@ -151,6 +212,21 @@ export function recordFieldProblem(source, scope) {
|
|
|
151
212
|
const html = source.startsWith('record.html.');
|
|
152
213
|
const money = source.startsWith('record.money.');
|
|
153
214
|
const fid = source.slice(html ? 12 : money ? 13 : 7);
|
|
215
|
+
const ref = parseRecordRefAxis(fid);
|
|
216
|
+
if (ref) {
|
|
217
|
+
const r = scope.flatMap((s) => s.fields).find((x) => x.id === ref.ref);
|
|
218
|
+
if (!r || r.type !== 'reference')
|
|
219
|
+
return `${ref.ref} is not a reference field of the collection(s) this row reads.`;
|
|
220
|
+
const target = r.refCollectionId ?? '';
|
|
221
|
+
const t = (refFields[target] ?? []).find((x) => x.id === ref.field);
|
|
222
|
+
if (!t)
|
|
223
|
+
return `${ref.field} is not a field of ${target || 'the referenced collection'} (what ${r.key} points at).`;
|
|
224
|
+
if (!RECORD_REF_TARGET_TYPES.has(t.type))
|
|
225
|
+
return `${ref.field} (${t.key}) is ${t.type} — a referenced field shows only ${[...RECORD_REF_TARGET_TYPES].join(', ')}.`;
|
|
226
|
+
if (money && !RECORD_MONEY_TYPES.has(t.type))
|
|
227
|
+
return `${ref.field} (${t.key}) is ${t.type} — record.money. formats only a number or integer field.`;
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
154
230
|
const f = scope.flatMap((s) => s.fields).find((x) => x.id === fid);
|
|
155
231
|
if (!f) {
|
|
156
232
|
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, readRecordAggregates, recordAggKeyOf, recordAggregateParams, 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
|
/**
|
|
@@ -813,6 +813,65 @@ opts = {}) {
|
|
|
813
813
|
});
|
|
814
814
|
}
|
|
815
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
|
+
}
|
|
816
875
|
if (RECORD_TILES.has(type) && cfg.datasetSource === 'record') {
|
|
817
876
|
const fid = cfg.recordField;
|
|
818
877
|
const aggKey = typeof fid === 'string' ? recordAggKeyOf(fid) : '';
|
|
@@ -833,7 +892,7 @@ opts = {}) {
|
|
|
833
892
|
fix: fill('record_field_unset', { id }),
|
|
834
893
|
});
|
|
835
894
|
}
|
|
836
|
-
else if (!aggKey && (typeof fid !== 'string' || !RECORD_FIELD_ID.test(fid))) {
|
|
895
|
+
else if (!aggKey && (typeof fid !== 'string' || (!RECORD_FIELD_ID.test(fid) && !parseRecordRefAxis(fid)))) {
|
|
837
896
|
out.push({
|
|
838
897
|
code: 'record_field_unset',
|
|
839
898
|
nodeId: id,
|
package/dist/tools/live.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.95.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",
|