sbuilder-mcp 0.76.4 → 0.77.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,21 @@ 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.77.0] - 2026-10-08
10
+
11
+ ### Added
12
+ - `sb_catalog_search` ranks exact type and name matches first, then whole words, then element keys and vocabulary values, so searches such as pagination, sort or load_more find the right element.
13
+ - `sb_traits_for` reports `base_only`, `preconditions`, `events`, `binding_events` and `bindable` for an element, and marks controls that write base-only keys with `responsive:false`.
14
+ - `sb_add` and `sb_set` return a `checks` list (`unknown_key`, `unknown_value`, `form_rule` and others) in place of `value`.
15
+ - `sb_review` reports `invalid_action`, `action_missing_target`, `no_data_context` and `unread_value` findings, and returns the `custom_code_native` maintenance hint separately under `advice`.
16
+ - `sb_page_open` with `form_id` edits a form's document fields, including fields, defaults and dependent-field rules, using a rule vocabulary generated from the platform.
17
+ - `sb_undo` undoes and redoes page operations, and refuses when the undo would erase live edits.
18
+
19
+ ### Changed
20
+ - `sb_bind` refuses a field that cannot be bound, and `sb_event` refuses an action with no target (url, popup id or `targetId`) unless `force` is set.
21
+ - The `dry_run` of the eight writing tools runs the same validation step as a real save and returns `would_refuse`, without consuming warnings, and a `sb_template_use` dry run no longer changes the open page.
22
+ - `scroll_to` projects an `href` of `#id` and checks `targetId`, waiting for the platform to release the matching change.
23
+
9
24
  ## [0.76.4] - 2026-10-08
10
25
 
11
26
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,21 @@ 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.77.0] - 2026-10-08
10
+
11
+ ### Added
12
+ - `sb_catalog_search` xếp các kết quả trùng type và tên lên đầu, rồi đến từ nguyên vẹn, rồi key và giá trị từ vựng của element, nên tìm pagination, sort hay load_more ra đúng element.
13
+ - `sb_traits_for` trả thêm `base_only`, `preconditions`, `events`, `binding_events` và `bindable` của element, và đánh dấu `responsive:false` trên control ghi key chỉ-base.
14
+ - `sb_add` và `sb_set` trả danh sách `checks` (`unknown_key`, `unknown_value`, `form_rule` và các loại khác) thay cho `value`.
15
+ - `sb_review` báo thêm các finding `invalid_action`, `action_missing_target`, `no_data_context` và `unread_value`, đồng thời trả gợi ý bảo trì `custom_code_native` riêng trong `advice`.
16
+ - `sb_page_open` với `form_id` sửa các field document của form, gồm trường, giá trị mặc định và rule field phụ thuộc, dùng từ vựng rule sinh từ nền tảng.
17
+ - `sb_undo` hoàn tác và làm lại các thao tác trên trang, và từ chối khi việc hoàn tác sẽ xoá các sửa đổi live.
18
+
19
+ ### Changed
20
+ - `sb_bind` từ chối field không bind được, và `sb_event` từ chối action thiếu đích (url, id popup hoặc `targetId`) trừ khi đặt `force`.
21
+ - `dry_run` của tám tool ghi chạy đúng bước validate của lần lưu thật và trả `would_refuse`, không tiêu cảnh báo, và `sb_template_use` ở chế độ dry run không còn đổi trang đang mở.
22
+ - `scroll_to` chiếu `href` dạng `#id` và kiểm tra `targetId`, chờ nền tảng phát hành thay đổi tương ứng.
23
+
9
24
  ## [0.76.4] - 2026-10-08
10
25
 
11
26
  ### Added
package/README.md CHANGED
@@ -84,7 +84,7 @@ in this client: one would put the very key the platform exists to hold back into
84
84
  | `sb_site_list` | List the sites this account can operate |
85
85
  | `sb_api_find` | Find API operations by intent — one line per match — then read one operation's call sheet by id: real parameter schemas, the credential it needs, and the body's fields read off the handler that decodes them, each carrying the trap its own doc comment records |
86
86
  | `sb_api_call` | Execute an operation, or any route by method+path; dry run by default. Field selection and local result pagination |
87
- | `sb_page_open` | Open a page for editing and return its outline |
87
+ | `sb_page_open` | Open a page for editing and return its outline — or, with `form_id`, a form's field document (fields, defaults, dependent-field rules) |
88
88
  | `sb_outline` | The open page as a compressed tree — never a raw document dump |
89
89
  | `sb_node_read` | One node in full, with a warning if it is a shared global |
90
90
  | `sb_catalog_search` | Find an element by what it should do, using the platform's own AI hints |
@@ -112,7 +112,7 @@ in this client: one would put the very key the platform exists to hold back into
112
112
  | `sb_import_site` | Read a WHOLE site from one URL — its sitemap, or the links on that page — and give each page found its own draft page here, built from this site's tokens; the entry page's own colours and type scale also patch into this SITE'S theme, so it stops being purely a read |
113
113
  | `sb_theme` | Read or patch the site's palette and type scale — the layer every style preset resolves from, so one token repaints every page; `locale` sets the site's language |
114
114
  | `sb_store` | Run a store flow that must happen in a fixed order — `checkout` (the four writes that make a working one), `checkout_sync` (the store's current payment and delivery methods into every existing order form, checkout page republished), `form` (any of the platform's 17 templates with its own field document), `chrome` (one shared header or footer on a real site menu — desktop menu, mobile drawer, cart and account icons), `menu` (a menu node bound to the site's menu, its links resolved), `overlay_attach` (a pop-up or quick view on the open page), `cart` (the site's cart drawer, when it has none; `relocalize` puts an existing one in the site's language) and `app` (a built-in app plus the pages it needs), `global_attach` / `global_detach` (put an EXISTING shared section on the open page, or take it off) |
115
- | `sb_undo` | Put back what a PUT replaced. The SECOND answer for a page, not the only one: the platform has versions, history and restore (`sb_api_find` "page versions"), which outlive this process — reach for those first and use this for every other shaped PUT |
115
+ | `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_api_find` "page versions") outlive this process |
116
116
 
117
117
  Twenty-eight tools, **574 API operations** (197 of the 255 writes carrying a body shape read
118
118
  off the handler), 122 elements, 79 binding sources. `sb_api_find`
@@ -124,7 +124,7 @@ Every result is compact JSON, every directive is said once per process, and ever
124
124
  carries MCP annotations — a client that honours them stops asking a person to confirm a
125
125
  read.
126
126
 
127
- Full reference: [`docs/tools.md`](./docs/tools.md).
127
+ Full reference: [`docs/tools.md`](./docs/tools.md). What the builder supports natively, and where it stops: [`docs/capabilities.md`](./docs/capabilities.md).
128
128
 
129
129
  ## How it stays in sync
130
130
 
package/README.vi.md CHANGED
@@ -81,7 +81,7 @@ nhét ngược lại vào mọi bản cài.
81
81
  | `sb_site_list` | Liệt kê site tài khoản vận hành được |
82
82
  | `sb_api_find` | Tìm operation theo ý định — mỗi kết quả một dòng — rồi đọc call sheet của một operation theo id: schema tham số thật, credential cần dùng, và các trường body đọc thẳng từ handler decode chúng, mỗi trường mang theo cái bẫy doc comment của chính nó ghi lại |
83
83
  | `sb_api_call` | Chạy operation, hoặc bất kỳ route nào bằng method+path; mặc định chạy khô. Chọn field và phân trang kết quả phía MCP |
84
- | `sb_page_open` | Mở một trang để sửa và trả về outline |
84
+ | `sb_page_open` | Mở một trang để sửa và trả về outline — hoặc, với `form_id`, tài liệu trường của biểu mẫu (trường, giá trị mặc định, quy tắc trường phụ thuộc) |
85
85
  | `sb_outline` | Trang đang mở dạng cây nén — không bao giờ dump tài liệu thô |
86
86
  | `sb_node_read` | Một node đầy đủ, kèm cảnh báo nếu nó là global dùng chung |
87
87
  | `sb_catalog_search` | Tìm element theo việc nó cần làm, dùng chính AI hints của nền tảng |
@@ -109,7 +109,7 @@ nhét ngược lại vào mọi bản cài.
109
109
  | `sb_import_site` | Đọc CẢ website từ một URL — sitemap của nó, hoặc các link trên trang đó — và tạo cho mỗi trang tìm được một trang nháp riêng ở đây, dựng bằng token của site này; màu và thang chữ của trang gốc cũng được vá vào THEME của site này, nên đây không còn thuần là đọc |
110
110
  | `sb_theme` | Đọc hoặc vá bảng màu và thang chữ của site — tầng mà mọi style preset phân giải từ đó, nên một token thay áo cho mọi trang; `locale` đặt ngôn ngữ của site |
111
111
  | `sb_store` | Chạy một luồng cửa hàng bắt buộc đúng thứ tự — `checkout` (bốn lệnh ghi tạo nên trang thanh toán), `checkout_sync` (đưa phương thức thanh toán và giao hàng hiện tại vào mọi form đặt hàng đã có, publish lại trang thanh toán), `form` (một trong 17 template của nền tảng kèm field document của nó), `chrome` (một header hoặc footer dùng chung trên menu thật của site — menu desktop, drawer mobile, icon giỏ hàng và tài khoản), `menu` (một node menu bind vào menu của site, link đã phân giải), `overlay_attach` (một pop-up hay quick view trên trang đang mở), `cart` (ngăn giỏ hàng của site, khi chưa có; `relocalize` đưa ngăn giỏ đã có về ngôn ngữ của site), `app` (một app dựng sẵn kèm những trang nó cần), và `global_attach` / `global_detach` (đặt một section dùng chung ĐÃ CÓ lên trang đang mở, hoặc gỡ ra) |
112
- | `sb_undo` | Trả lại thứ mà một lệnh PUT đã ghi đè. Với TRANG thì đây là đường về thứ hai chứ không phải duy nhất: nền tảng có version, history và restore (`sb_api_find` "page versions"), và chúng sống lâu hơn tiến trình này — hãy dùng chúng trước, còn tool này cho mọi PUT có hình dạng khác |
112
+ | `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_api_find` "page versions") sống lâu hơn process này |
113
113
 
114
114
  Hai mươi tám tool, **574 operation API** (197 trong 255 lệnh ghi có hình dạng body đọc thẳng
115
115
  từ handler), 122 element, 79 nguồn binding. `sb_api_find` là
@@ -120,7 +120,7 @@ thứ nền tảng làm được vẫn với tới — và operation mới thêm
120
120
  Mọi kết quả đều là JSON nén, mọi directive chỉ nói một lần mỗi process, và mọi tool đều mang
121
121
  MCP annotation — client nào tôn trọng chúng sẽ thôi hỏi người dùng xác nhận một lần đọc.
122
122
 
123
- Tra cứu đầy đủ: [`docs/tools.vi.md`](./docs/tools.vi.md).
123
+ Tra cứu đầy đủ: [`docs/tools.vi.md`](./docs/tools.vi.md). Builder hỗ trợ native những gì, và dừng ở đâu: [`docs/capabilities.vi.md`](./docs/capabilities.vi.md).
124
124
 
125
125
  ## Cách nó không bị lệch
126
126
 
@@ -1,4 +1,6 @@
1
- import { ELEMENTS, TRAIT_WRITES } from './elements.generated.js';
1
+ import { ELEMENTS, TRAIT_WRITES, WRITE_PRECONDITIONS } from './elements.generated.js';
2
+ import { isBaseOnlyConfig } from '../domains/site/baseonly.js';
3
+ import { bindableFields } from '../domains/site/writecheck.js';
2
4
  import { animationValues, animationVocabulary, elementVocabularies, sharedVocabularies, vocabulariesForWrites, } from '../domains/site/vocabulary.js';
3
5
  import { neverTranslatedOn, translatableSpecials } from '../domains/site/translate.js';
4
6
  /** Eight to choose from; the hints for the chosen one come with sb_traits_for. */
@@ -48,6 +50,81 @@ export function catalogBrowse() {
48
50
  export function largestCategorySize() {
49
51
  return Math.max(...Object.values(catalogBrowse()).map((g) => g.length));
50
52
  }
53
+ /** Lowercase, diacritics off, camelCase and snake_case split — the unit a word match compares. */
54
+ function words(s) {
55
+ return s
56
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
57
+ .normalize('NFD')
58
+ .replace(/[\u0300-\u036f]/g, '')
59
+ .toLowerCase()
60
+ .split(/[^a-z0-9]+/)
61
+ .filter(Boolean);
62
+ }
63
+ /** Two spellings of one name compare equal: "Product list", "product-list", "PRÓDUCT LÍST". */
64
+ const slug = (s) => words(s).join('-');
65
+ /**
66
+ * CAPABILITY is the element's own generated config/specials keys and the
67
+ * vocabulary VALUES they take — so "pagination", "load_more" and "sort" find
68
+ * the element that does them from the shared metadata, with no alias table to
69
+ * drift. The weakest word signal, because a key name is not a purpose.
70
+ */
71
+ let index = null;
72
+ function searchIndex() {
73
+ return (index ??= Object.values(ELEMENTS).map((el) => {
74
+ const cap = [
75
+ ...Object.keys(el.defaults?.config ?? {}),
76
+ ...Object.keys(el.defaults?.specials ?? {}),
77
+ ...Object.values(elementVocabularies(el.type)).flatMap((v) => [v.writeKey, ...v.values]),
78
+ ];
79
+ const hints = [...el.semantics, ...el.useWhen];
80
+ return {
81
+ el,
82
+ ix: {
83
+ type: el.type,
84
+ label: slug(el.label),
85
+ typeWords: new Set(words(el.type)),
86
+ labelWords: new Set(words(el.label)),
87
+ hintWords: new Set(words(hints.join(' '))),
88
+ descWords: new Set(words(`${el.category} ${el.description}`)),
89
+ capWords: new Set(words(cap.join(' '))),
90
+ hay: words([el.type, el.label, el.category, el.description, ...hints].join(' ')).join(' '),
91
+ },
92
+ };
93
+ }));
94
+ }
95
+ /** A whole word, or its plural — "image" finds "images" without a stemmer. */
96
+ const has = (set, t) => set.has(t) || set.has(`${t}s`);
97
+ /**
98
+ * Tiered, so a stronger signal is never outvoted by a pile of weaker ones:
99
+ * exact type > exact label > the type's own words > the label's > semantics and
100
+ * useWhen > description > capability > bare substring. Measured before this:
101
+ * "list" returned address-book first and not `list` at all, because a flat
102
+ * substring count over prose ranks whatever mentions a word most.
103
+ */
104
+ function score(ix, q, terms) {
105
+ let s = 0;
106
+ if (ix.type === q)
107
+ s += 1e6;
108
+ if (ix.label === q)
109
+ s += 1e5;
110
+ if (ix.type.startsWith(q))
111
+ s += 1e4;
112
+ for (const t of terms) {
113
+ if (has(ix.typeWords, t))
114
+ s += 1000;
115
+ if (has(ix.labelWords, t))
116
+ s += 300;
117
+ if (has(ix.hintWords, t))
118
+ s += 30;
119
+ if (has(ix.descWords, t))
120
+ s += 10;
121
+ if (has(ix.capWords, t))
122
+ s += 3;
123
+ if (ix.hay.includes(t))
124
+ s += 1;
125
+ }
126
+ return s;
127
+ }
51
128
  /**
52
129
  * Four fields to CHOOSE by.
53
130
  *
@@ -57,14 +134,10 @@ export function largestCategorySize() {
57
134
  * `traitsFor` now carries. `detail: true` restores the hints per match.
58
135
  */
59
136
  export function catalogMatches(query, opts = {}) {
60
- const terms = query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
61
- return Object.values(ELEMENTS)
62
- .map((el) => {
63
- const hay = [el.type, el.label, el.category, el.description, ...el.semantics, ...el.useWhen]
64
- .join(' ')
65
- .toLowerCase();
66
- return { el, score: terms.filter((t) => hay.includes(t)).length };
67
- })
137
+ const terms = words(query);
138
+ const q = terms.join('-');
139
+ return searchIndex()
140
+ .map(({ el, ix }) => ({ el, score: score(ix, q, terms) }))
68
141
  .filter((s) => s.score > 0)
69
142
  .sort((a, b) => b.score - a.score || a.el.type.localeCompare(b.el.type))
70
143
  .slice(0, opts.limit ?? DEFAULT_CATALOG_LIMIT)
@@ -83,7 +156,7 @@ export function catalogMatches(query, opts = {}) {
83
156
  /**
84
157
  * What one inspector control writes.
85
158
  *
86
- * 118 of the 435 controls declare it in the platform's trait registry. The rest
159
+ * 165 of the 577 controls declare it in the platform's trait registry. The rest
87
160
  * live inside a Vue widget's prop closure, which is not machine-readable — so
88
161
  * they come back named but undescribed, with the honest reason. Saying nothing
89
162
  * would read as "this control writes nothing".
@@ -125,10 +198,23 @@ export function traitsFor(type, control) {
125
198
  }
126
199
  return { type, control, ...describeControl(control) };
127
200
  }
201
+ // Read from base only whatever breakpoint is asked for — sb_set routes them
202
+ // there, so a "tablet-only" value of one of these is the desktop value too.
203
+ const baseOnly = [
204
+ ...new Set([
205
+ ...Object.keys(el.defaults?.config ?? {}),
206
+ ...el.controls.flatMap((c) => (TRAIT_WRITES[c]?.writes ?? []).filter((w) => w.target === 'config').map((w) => w.writeKey)),
207
+ ]),
208
+ ].filter((k) => isBaseOnlyConfig(type, k));
128
209
  const declared = {};
129
- for (const c of el.controls)
130
- if (TRAIT_WRITES[c])
131
- declared[c] = describeControl(c);
210
+ for (const c of el.controls) {
211
+ if (!TRAIT_WRITES[c])
212
+ continue;
213
+ const base = TRAIT_WRITES[c].writes.some((w) => w.target === 'config' && isBaseOnlyConfig(type, w.writeKey));
214
+ declared[c] = { ...describeControl(c), ...(base ? { responsive: false } : {}) };
215
+ }
216
+ const preconditions = WRITE_PRECONDITIONS.filter((p) => p.types.includes(type)).map(({ types: _, ...p }) => p);
217
+ const bindable = bindableFields(type);
132
218
  return {
133
219
  type: el.type,
134
220
  hints: { useWhen: el.useWhen, avoidWhen: el.avoidWhen, contentTips: el.contentTips },
@@ -137,6 +223,15 @@ export function traitsFor(type, control) {
137
223
  groups: t.groups.map((g) => ({ group: g.label, controls: g.controls })),
138
224
  })),
139
225
  declared,
226
+ ...(baseOnly.length ? { base_only: baseOnly } : {}),
227
+ // Legal apart, meaningless together — the platform's own declaration.
228
+ ...(preconditions.length ? { preconditions } : {}),
229
+ // The click actions per trigger; binding_events replaces them once the node
230
+ // carries a purchase binding (add_to_cart is the BINDING, never an event).
231
+ ...(el.events ? { events: el.events } : {}),
232
+ ...(el.bindingEvents ? { binding_events: el.bindingEvents } : {}),
233
+ // The specials a record binding may target on this element.
234
+ ...(bindable.length ? { bindable } : {}),
140
235
  // The keys this element actually seeds. For `config` and `specials` —
141
236
  // which, unlike `style`, are NOT open — this is the machine-readable
142
237
  // answer to "what does this element store", and often the only one.
@@ -0,0 +1,29 @@
1
+ // GENERATED by scripts/gen-catalog.ts — do not edit by hand.
2
+ // Source: <WB_REPO>/schema/src/elements/formRules.ts (RULE_ACTIONS, RuleJoin) and
3
+ // schema/src/elements/fieldCondition.ts (CONDITION_OPS), asserted against
4
+ // server/internal/forms/{rules,condition}.go.
5
+ /**
6
+ * The vocabulary of `specials.formRules` on a form root. A rule using a word
7
+ * outside these lists is DROPPED by every reader (editor, page, server) with no
8
+ * error, so the field it governs simply never changes.
9
+ */
10
+ export const FORM_RULE_VOCAB = {
11
+ "joins": [
12
+ "and",
13
+ "or"
14
+ ],
15
+ "ops": [
16
+ "is",
17
+ "isNot",
18
+ "filled",
19
+ "empty",
20
+ "contains",
21
+ "notContains"
22
+ ],
23
+ "actions": [
24
+ "hidden",
25
+ "shown",
26
+ "optional",
27
+ "required"
28
+ ]
29
+ };
@@ -94,6 +94,21 @@ export const FIX = {
94
94
  no_h1: 'Make the page\'s main title an h1: sb_set id "<id>", namespace specials, keys ' +
95
95
  '{ "htmlTag": "h1" } — ONE per page, on the heading the page is about (the first one ' +
96
96
  'is named here; pick a different one if it is not the title). Every heading ships as h2.',
97
+ invalid_action: 'Re-issue it through sb_event (id "<id>", trigger "<key>"), which refuses an action the ' +
98
+ 'element does not offer and lists the ones it does — or sb_event action "none" to drop it. ' +
99
+ 'A purchase is a BINDING (sb_bind action "add_to_cart"), never a click action.',
100
+ action_missing_target: 'Give the action its target: sb_event id "<id>", action "<key>", payload ' +
101
+ '{ "url": "/path" } for go_to_url / open_page (open_page needs the resolved url — no ' +
102
+ 'renderer resolves a page id), { "id": "<popup id>" } for popup.',
103
+ no_data_context: 'Give it a record: put it inside a list-dataset / dataset-block, pin one with sb_bind ' +
104
+ '(target id), or move it to a page whose type supplies "<key>" (product, category, course, ' +
105
+ 'post, blog). Off those it renders its placeholder for ever.',
106
+ unread_value: 'Write a value the renderer reads: sb_traits_for "<type>" lists them; sb_set id "<id>" ' +
107
+ 'with one, at the same breakpoint it is stored at.',
108
+ // ADVICE, never a blocker: the snippet works. The cost is that nobody can edit
109
+ // it in the inspector, it ignores the theme, and it is invisible to sb_review.
110
+ custom_code_native: 'Consider rebuilding it natively: sb_catalog_search "<key>" → sb_traits_for → sb_add, ' +
111
+ 'then sb_remove id "<id>". Keep custom-code for a script or embed no element covers.',
97
112
  stuck_no_host: 'Pin something: sb_set id "<id>" (or the section it lives in), namespace style, keys ' +
98
113
  '{ "position": "sticky" } — sb_set seeds the offset and the layer order with it. Until ' +
99
114
  'then the platform compiles no rule for this state at all, so the override is stored, ' +
@@ -1,3 +1,4 @@
1
+ import { ELEMENTS } from '../../catalog/elements.generated.js';
1
2
  /**
2
3
  * THE BRIDGE BETWEEN A CLICK ACTION AND THE THING A RENDERER ACTUALLY READS.
3
4
  *
@@ -49,8 +50,14 @@ export const PRODUCT_ACTION_BINDING_ID = 'bind-product-action';
49
50
  * a checkout hop is never a plain link, and the platform's own comment says so.
50
51
  */
51
52
  export const NAVIGATION_ACTIONS = ['go_to_url', 'open_page', 'go_to_checkout'];
52
- /** The two that a renderer expects to meet as an `<a href>`. */
53
- const PROJECTABLE = new Set(['go_to_url', 'open_page']);
53
+ /**
54
+ * The actions a SOLE click projects onto `specials.href` — the platform's
55
+ * `projectsHref` (`schema/src/actions/engine.ts`). `scroll_to` degrades to a
56
+ * fragment link (`#<targetId>`), because publish stamps every node's id as
57
+ * its DOM id; until the catalog carries it, `sb_event` refuses it as an
58
+ * action no element offers, so this branch is inert rather than a guess.
59
+ */
60
+ const PROJECTABLE = new Set(['go_to_url', 'open_page', 'scroll_to']);
54
61
  /**
55
62
  * The destination an event projects onto `specials.href`, or undefined.
56
63
  *
@@ -62,6 +69,10 @@ const PROJECTABLE = new Set(['go_to_url', 'open_page']);
62
69
  export function eventHref(ev) {
63
70
  if (!ev || !PROJECTABLE.has(ev.action ?? ''))
64
71
  return undefined;
72
+ if (ev.action === 'scroll_to') {
73
+ const id = (ev.payload ?? {}).targetId;
74
+ return typeof id === 'string' && id !== '' ? `#${id}` : undefined;
75
+ }
65
76
  const url = (ev.payload ?? {}).url;
66
77
  return typeof url === 'string' && url !== '' ? url : undefined;
67
78
  }
@@ -133,7 +144,9 @@ export function hrefPatches(id, events, purchaseBound, current) {
133
144
  */
134
145
  export function deadNavigation(node) {
135
146
  const sole = soleNavigationClick(node.events, hasPurchaseBinding(node.bindings));
136
- if (!sole)
147
+ // A scroll still runs with no href — publish emits its ScrollControl call
148
+ // beside the fragment link — so only the no-JS fallback is missing, not the click.
149
+ if (!sole || sole.action === 'scroll_to')
137
150
  return null;
138
151
  const url = eventHref(sole);
139
152
  if (url === undefined)
@@ -141,3 +154,64 @@ export function deadNavigation(node) {
141
154
  const href = (node.specials ?? {}).href;
142
155
  return typeof href === 'string' && href !== '' ? null : { url };
143
156
  }
157
+ /**
158
+ * The payload key each action cannot work without, read off
159
+ * `schema/src/actions/actions/{goToUrl,openPage,openPopup}.ts` and the Go that
160
+ * consumes them (`navhref.ts`'s `eventHref`, `popupTargetProps` in
161
+ * `render/nodes/helpers.go`). `open_page` asks for `url` rather than the `id`
162
+ * the platform validates, because the url is what renders.
163
+ */
164
+ export const PAYLOAD_NEEDS = {
165
+ go_to_url: {
166
+ key: 'url',
167
+ why: 'publishes with no href and goes nowhere',
168
+ shape: '{ "url": "/path or https://…", "openInNewTab": false }',
169
+ },
170
+ open_page: {
171
+ key: 'url',
172
+ why: 'publishes with no href — neither renderer can resolve a page id, only a url',
173
+ shape: '{ "linkType": "page", "id": "<page id>", "label": "<page name>", "url": "/<page path>" }',
174
+ },
175
+ popup: {
176
+ key: 'id',
177
+ why: 'publishes a PopupControl call with no target, which opens nothing',
178
+ shape: '{ "id": "<the pop-up\'s overlay id, from sb_store popup>", "label": "<name>" }',
179
+ },
180
+ scroll_to: {
181
+ key: 'targetId',
182
+ why: 'publishes a ScrollControl call with no target, which scrolls nowhere',
183
+ shape: '{ "targetId": "<the section or block node id>", "label": "<name>" }',
184
+ },
185
+ };
186
+ /**
187
+ * The click-action allow-list that is LIVE for this node — the one table
188
+ * `sb_event` refuses by and `sb_review` reports by, so the two cannot drift.
189
+ *
190
+ * `activeEvents` in the platform (`return action ? binding_events : events`):
191
+ * a purchase-bound control swaps to `meta.bindingEvents`.
192
+ *
193
+ * PLUS ONE CONTEXT RULE THE META CANNOT STATE: `close_popup` inside a pop-up.
194
+ * The action declares the click trigger and is wired for ANY node
195
+ * (`islandCallForAction`, render/nodes/helpers.go — EventAttrs never consults
196
+ * the meta), and it resolves its panel by proximity (`closest('.wb-popup')`).
197
+ * No element meta lists it, yet the platform's own pop-up seed
198
+ * (`editor/src/features/overlays/seed.ts`) ships a button carrying it, and the
199
+ * editor keeps it selectable through `withCurrent`. So inside a `popup` it is
200
+ * live; outside one it has no panel to close.
201
+ */
202
+ export function liveEventTable(nodes, id) {
203
+ const node = nodes[id];
204
+ const meta = ELEMENTS[node?.data?.type ?? ''];
205
+ if (!meta?.events)
206
+ return undefined;
207
+ const table = (hasPurchaseBinding(node?.bindings) && meta.bindingEvents) || meta.events;
208
+ if (!table.click || table.click.includes('close_popup'))
209
+ return table;
210
+ const seen = new Set();
211
+ for (let p = node?.data?.parent; p && !seen.has(p); p = nodes[p]?.data?.parent) {
212
+ seen.add(p);
213
+ if (nodes[p]?.data?.type === 'popup')
214
+ return { ...table, click: [...table.click, 'close_popup'] };
215
+ }
216
+ return table;
217
+ }
@@ -17,8 +17,7 @@
17
17
  * that fires on a correctly built site is one the reader learns to ignore.
18
18
  */
19
19
  import { cartGalleryThumbs, cartRelocalize, cartSeedLocale } from './cartlang.js';
20
- /** The purchase binding an Add-to-cart / Buy-now button carries. */
21
- const PRODUCT_ACTION_BINDING_ID = 'bind-product-action';
20
+ import { PRODUCT_ACTION_BINDING_ID } from './navhref.js';
22
21
  /** Element types that only make sense in a store. */
23
22
  const COMMERCE_TYPES = [
24
23
  'cart-order',
@@ -4,7 +4,8 @@ import { bandOf } from './traps.js';
4
4
  import { HOVER_STATE, hoverHome } from './hover.js';
5
5
  import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
6
6
  import { fill } from './findings.js';
7
- import { deadNavigation } from './navhref.js';
7
+ import { deadNavigation, liveEventTable, PAYLOAD_NEEDS } from './navhref.js';
8
+ import { unknownValueNote, unknownWriteNote, vocabularyFor, vocabularyForWrite } from './vocabulary.js';
8
9
  /**
9
10
  * A defect somebody looking at the page would see.
10
11
  *
@@ -34,7 +35,8 @@ export const REVIEW_NOTICE = 'FIX THESE. Each one is a defect a visitor will see
34
35
  'finding names, then review again until the list is empty. Do not report the page as done ' +
35
36
  'while findings stand; if you believe one is a false positive, say which and why. ' +
36
37
  'A finding marked overlay:true is in the cart drawer or a pop-up — fix it the same way ' +
37
- '(sb_set lands there), but it is SITE-WIDE, so fix it once rather than once per page.';
38
+ '(sb_set lands there), but it is SITE-WIDE, so fix it once rather than once per page. ' +
39
+ '`advice` lists what works but is hard to edit — never a blocker.';
38
40
  /** The specials keys an element seeds that hold its visible content. */
39
41
  function contentKeys(type) {
40
42
  const seeded = ELEMENTS[type]?.defaults.specials ?? {};
@@ -126,6 +128,72 @@ const LINK_SPECIALS = new Set(['boundHref', 'boundHrefLabel', 'boundProductURL']
126
128
  function drawsItsOwnContent(type) {
127
129
  return (BOUND_SPECIALS[type] ?? []).some((k) => !LINK_SPECIALS.has(k));
128
130
  }
131
+ /** Binding-source prefixes that name a RECORD (not `site.*`, which every page has). */
132
+ const RECORD_ENTITIES = new Set(['product', 'category', 'course', 'article', 'blogCategory']);
133
+ /**
134
+ * Which records a page TYPE supplies outside any repeater — read off the
135
+ * platform's own seeds (STORE_PAGE_SEEDS bind exactly these at top level).
136
+ */
137
+ const PAGE_ENTITIES = {
138
+ product: ['product'],
139
+ category: ['category'],
140
+ course: ['course'],
141
+ post: ['article'],
142
+ blog: ['blogCategory', 'category'],
143
+ };
144
+ /** Does this node or an ancestor PIN a record (`bindings[0].target.id`)? */
145
+ function pinnedAbove(d, id) {
146
+ for (let x = id; x && d.nodes[x]; x = d.nodes[x].data.parent) {
147
+ const b0 = d.nodes[x].bindings?.[0];
148
+ if (b0?.target?.id)
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+ /**
154
+ * The native element a custom-code snippet re-implements, or null.
155
+ *
156
+ * Judged on the MARKUP left once every <script>, <style> and comment is
157
+ * stripped — so an analytics tag, a chat widget's mount div, or HTML built
158
+ * inside a script is never flagged — and only when that markup outweighs the
159
+ * script (mostly markup). An iframe counts only for the three hosts a native
160
+ * embed exists for; any other iframe is a third-party embed and stays.
161
+ * ponytail: tag-presence heuristic, not a parser — tighten if it cries wolf.
162
+ */
163
+ function nativeFor(code) {
164
+ const scripts = code.match(/<script\b[\s\S]*?(<\/script>|$)/gi) ?? [];
165
+ const markup = code
166
+ .replace(/<script\b[\s\S]*?(<\/script>|$)/gi, '')
167
+ .replace(/<style\b[\s\S]*?(<\/style>|$)/gi, '')
168
+ .replace(/<!--[\s\S]*?-->/g, '');
169
+ if (markup.trim().length < scripts.join('').length)
170
+ return null;
171
+ // A form POSTING TO ANOTHER HOST (a Mailchimp / Klaviyo embed) is a
172
+ // third-party integration, not hand-built markup: a native form submits to
173
+ // this platform only, so there is nothing native to swap it for.
174
+ if (/<form\b[^>]*\baction\s*=\s*["']?(https?:)?\/\//i.test(markup))
175
+ return null;
176
+ const iframe = /<iframe\b[^>]*\bsrc\s*=\s*["']?([^"'\s>]+)/i.exec(markup)?.[1] ?? '';
177
+ if (/youtube(-nocookie)?\.com|youtu\.be/i.test(iframe))
178
+ return 'youtube';
179
+ if (/vimeo\.com/i.test(iframe))
180
+ return 'vimeo';
181
+ if (/google\.[a-z.]+\/maps|maps\.google\./i.test(iframe))
182
+ return 'google-map';
183
+ if (/<form\b/i.test(markup))
184
+ return 'form';
185
+ if (/<(nav|ul|ol)\b/i.test(markup) && (markup.match(/<a\b/gi) ?? []).length >= 2)
186
+ return 'menu';
187
+ if (/<h[1-6]\b/i.test(markup))
188
+ return 'heading';
189
+ if (/<video\b/i.test(markup))
190
+ return 'video';
191
+ if (/<img\b/i.test(markup))
192
+ return 'image';
193
+ if (/<button\b/i.test(markup))
194
+ return 'button';
195
+ return null;
196
+ }
129
197
  /**
130
198
  * Everything wrong with this page that a person would notice.
131
199
  *
@@ -322,6 +390,113 @@ opts = {}) {
322
390
  ...(inOverlay.has(id) ? { overlay: true } : {}),
323
391
  });
324
392
  }
393
+ // AN EVENT THE ELEMENT CANNOT FIRE, OR ONE WITH NOWHERE TO GO. `sb_event`
394
+ // refuses both at write time; a document gets here by other roads (an
395
+ // import, a template, a hand-built sb_api_call). Same table setEvent reads
396
+ // (`liveEventTable` in tools/live.ts): bindingEvents once a purchase binds.
397
+ const events = n.events ?? [];
398
+ if (events.length) {
399
+ const table = liveEventTable(d.nodes, id);
400
+ for (const ev of events) {
401
+ const trigger = String(ev?.name ?? '');
402
+ const action = String(ev?.action ?? '');
403
+ if (!table?.[trigger]?.includes(action)) {
404
+ out.push({
405
+ code: 'invalid_action',
406
+ nodeId: id,
407
+ type,
408
+ problem: `"${action}" on ${trigger || '(no trigger)'} is not an action a ${type} offers` +
409
+ (table?.[trigger] ? ` there (${table[trigger].join(', ')})` : '') +
410
+ ' — it is stored and never fires.',
411
+ key: trigger,
412
+ fix: fill('invalid_action', { id, key: trigger }),
413
+ });
414
+ continue;
415
+ }
416
+ // The same table `sb_event` refuses by — one list, so the two cannot drift.
417
+ const need = PAYLOAD_NEEDS[action]?.key;
418
+ const v = (ev.payload ?? {})[need ?? ''];
419
+ if (need && (typeof v !== 'string' || v.trim() === '')) {
420
+ out.push({
421
+ code: 'action_missing_target',
422
+ nodeId: id,
423
+ type,
424
+ problem: `Its ${trigger} "${action}" carries no payload.${need}, so the click does nothing.`,
425
+ key: action,
426
+ fix: fill('action_missing_target', { id, key: action }),
427
+ });
428
+ }
429
+ }
430
+ }
431
+ // A RECORD-BOUND ELEMENT WITH NO RECORD. Inside a repeater the row supplies
432
+ // one; on an entity page (product, category, post…) the page does; a pin
433
+ // (`bindings[0].target.id`, render/html.go pinnedEntityID) does for its
434
+ // subtree. Anywhere else the binding resolves to nothing and the element
435
+ // renders its seeded placeholder for ever — saved, published, no warning.
436
+ // Overlays are skipped: one drawer serves every page type.
437
+ if (opts.pageType !== undefined && !inRepeater.get(id) && !inOverlay.has(id)) {
438
+ const entities = PAGE_ENTITIES[opts.pageType] ?? [];
439
+ const missing = (n.bindings ?? [])
440
+ .map((b) => String(b?.source ?? '').split('.')[0])
441
+ .find((e) => RECORD_ENTITIES.has(e) && !entities.includes(e));
442
+ if (missing && !pinnedAbove(d, id)) {
443
+ out.push({
444
+ code: 'no_data_context',
445
+ nodeId: id,
446
+ type,
447
+ problem: `Bound to a ${missing} record, but it sits in no repeater and a "${opts.pageType}" ` +
448
+ 'page supplies none — it renders its placeholder on the published page.',
449
+ key: missing,
450
+ fix: fill('no_data_context', { id, key: missing }),
451
+ });
452
+ }
453
+ }
454
+ // A STORED VALUE NO RENDERER READS. The same vocabulary `sb_set` warns from
455
+ // (`unknownWriteNote` / `unknownValueNote`), asked of what the document
456
+ // already holds — base and every breakpoint — because an import or a write
457
+ // made before the warning existed is still publishing the fallback.
458
+ {
459
+ const layered = n;
460
+ const layers = [
461
+ ['config', layered.config],
462
+ ['specials', n.specials],
463
+ ...Object.values(layered.responsive ?? {}).flatMap((r) => [['config', r?.config], ['specials', r?.specials]]),
464
+ ];
465
+ const said = new Set();
466
+ for (const [ns, slot] of layers) {
467
+ for (const [k, v] of Object.entries(slot ?? {})) {
468
+ const unread = unknownWriteNote(type, ns, k, v) ?? (ns === 'config' ? unknownValueNote(k, v) : null);
469
+ if (!unread || said.has(`${ns}.${k}=${String(v)}`))
470
+ continue;
471
+ said.add(`${ns}.${k}=${String(v)}`);
472
+ const legal = (vocabularyForWrite(type, ns, k) ?? (ns === 'config' ? vocabularyFor(k) : null))?.values ?? [];
473
+ out.push({
474
+ code: 'unread_value',
475
+ nodeId: id,
476
+ type,
477
+ problem: `${ns}.${k} = ${JSON.stringify(v)} is not a value the renderer reads; it publishes ` +
478
+ `as its fallback with no error. Legal: ${legal.map((x) => x || '""').join(', ')}.`,
479
+ key: `${ns}.${k}`,
480
+ fix: fill('unread_value', { id, type }),
481
+ });
482
+ }
483
+ }
484
+ }
485
+ if (type === 'custom-code') {
486
+ const native = nativeFor(String((n.specials ?? {}).code ?? ''));
487
+ if (native) {
488
+ out.push({
489
+ code: 'custom_code_native',
490
+ nodeId: id,
491
+ type,
492
+ severity: 'maintenance',
493
+ problem: `Hand-written markup a native ${native} covers. It works, but no inspector can edit ` +
494
+ 'it, it ignores the theme, and sb_review cannot see inside it.',
495
+ key: native,
496
+ fix: fill('custom_code_native', { id, key: native }),
497
+ });
498
+ }
499
+ }
325
500
  // PINNING FAILS WITH NOTHING ON SCREEN AND NOTHING IN THE LOG — both halves
326
501
  // of it — which is exactly what a review is for. `sb_set` refuses the second
327
502
  // case at write time, but a document reaches here by other roads: an import,