sbuilder-mcp 0.76.3 → 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 +23 -0
- package/CHANGELOG.vi.md +23 -0
- package/README.md +3 -3
- package/README.vi.md +3 -3
- package/dist/catalog/element-search.js +108 -13
- package/dist/catalog/formrules.generated.js +29 -0
- package/dist/domains/site/findings.js +20 -0
- package/dist/domains/site/importmap.js +53 -0
- package/dist/domains/site/navhref.js +77 -3
- package/dist/domains/site/readiness.js +1 -2
- package/dist/domains/site/review.js +177 -2
- package/dist/domains/site/writecheck.js +262 -0
- package/dist/mcp/notices.js +8 -0
- package/dist/server.js +6 -6
- package/dist/tools/importpage.js +1 -1
- package/dist/tools/live.js +46 -25
- package/dist/tools/overlay.js +2 -2
- package/dist/tools/page.js +330 -132
- package/dist/tools/undo.js +70 -15
- package/dist/transport/http.js +21 -0
- package/dist/transport/pages.js +1 -1
- package/dist/vision/measure.js +86 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,29 @@ 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
|
+
|
|
24
|
+
## [0.76.4] - 2026-10-08
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- `sb_look` reports two new layout findings, `title_bar_stranded` and `title_bar_offcenter`, when a hand-built heading-plus-action row leaves the button short of the right edge or off the heading's centre line, and skips rows that declare a centred layout.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- `sb_import` and `sb_import_site` now map a heading beside a single action such as "Xem tất cả" to a space-between row with centred alignment, so the button sits on the right edge, shares the heading's centre line, and stacks left-aligned on mobile.
|
|
31
|
+
|
|
9
32
|
## [0.76.3] - 2026-09-28
|
|
10
33
|
|
|
11
34
|
### Fixed
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,29 @@ 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
|
+
|
|
24
|
+
## [0.76.4] - 2026-10-08
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- `sb_look` báo thêm hai finding bố cục `title_bar_stranded` và `title_bar_offcenter` khi một hàng tiêu đề kèm nút được dựng tay để nút lệch khỏi mép phải hoặc lệch tâm dọc so với tiêu đề, và bỏ qua các hàng khai báo bố cục căn giữa.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- `sb_import` và `sb_import_site` giờ ánh xạ một tiêu đề đi kèm một nút duy nhất như "Xem tất cả" thành hàng space-between căn giữa theo chiều dọc, nên nút nằm sát mép phải, cùng tâm dọc với tiêu đề và xếp dọc căn trái trên mobile.
|
|
31
|
+
|
|
9
32
|
## [0.76.3] - 2026-09-28
|
|
10
33
|
|
|
11
34
|
### Fixed
|
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` |
|
|
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` |
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
+
};
|
|
@@ -71,6 +71,11 @@ export const FIX = {
|
|
|
71
71
|
dead_binding_field: 'Rebind with sb_bind and a field of the form "specials.<key>".',
|
|
72
72
|
off_canvas: 'Give it a width that can shrink — sb_set id "<id>", namespace style, keys { "maxWidth": "100%" } at this breakpoint.',
|
|
73
73
|
text_too_small: 'Raise it for this breakpoint: sb_set id "<id>", namespace style, keys { "fontSize": "16px" }.',
|
|
74
|
+
title_bar_stranded: 'Put the action on the right edge: sb_set id "<id>", namespace style, keys ' +
|
|
75
|
+
'{ "justifyContent": "space-between", "alignItems": "center" }, and sb_set id "<cell>", ' +
|
|
76
|
+
'namespace style, keys { "flex": "0 0 auto", "width": "auto" }. On mobile give "<id>" ' +
|
|
77
|
+
'{ "flexDirection": "column", "alignItems": "flex-start" }.',
|
|
78
|
+
title_bar_offcenter: 'Centre the pair on one line: sb_set id "<id>", namespace style, keys { "alignItems": "center" } at this breakpoint.',
|
|
74
79
|
overlap: 'Check the two for a fixed height or a negative margin at this breakpoint; sb_look with node_id on each shows which one is out of place.',
|
|
75
80
|
// Both sticky findings name a node OTHER than the one at fault, because in
|
|
76
81
|
// both cases the node carrying the defect is not the node to edit.
|
|
@@ -89,6 +94,21 @@ export const FIX = {
|
|
|
89
94
|
no_h1: 'Make the page\'s main title an h1: sb_set id "<id>", namespace specials, keys ' +
|
|
90
95
|
'{ "htmlTag": "h1" } — ONE per page, on the heading the page is about (the first one ' +
|
|
91
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.',
|
|
92
112
|
stuck_no_host: 'Pin something: sb_set id "<id>" (or the section it lives in), namespace style, keys ' +
|
|
93
113
|
'{ "position": "sticky" } — sb_set seeds the offset and the layer order with it. Until ' +
|
|
94
114
|
'then the platform compiles no rule for this state at all, so the override is stored, ' +
|
|
@@ -184,6 +184,22 @@ function textScale(slug, fallback) {
|
|
|
184
184
|
}
|
|
185
185
|
return { style, config: { textGlobalStyle: slug } };
|
|
186
186
|
}
|
|
187
|
+
const holds = (c, kind) => c.kind === kind || (c.children ?? []).some((k) => holds(k, kind));
|
|
188
|
+
/** A button, or a packed run of nothing but buttons. */
|
|
189
|
+
const isAction = (c) => c.kind === 'button' || (c.kind === 'group' && !!c.children?.length && c.children.every((k) => k.kind === 'button'));
|
|
190
|
+
/**
|
|
191
|
+
* A row of a heading block and ONE action — a shelf's title and its "see all".
|
|
192
|
+
* `align: 'center'` is a caller asking for a centred band, and keeps the
|
|
193
|
+
* equal-share answer it asked for.
|
|
194
|
+
*/
|
|
195
|
+
export function isTitleBar(c) {
|
|
196
|
+
const kids = c.children ?? [];
|
|
197
|
+
if (c.kind !== 'group' || c.direction === 'column' || c.wrap || c.pack || c.align === 'center')
|
|
198
|
+
return false;
|
|
199
|
+
if (kids.length !== 2)
|
|
200
|
+
return false;
|
|
201
|
+
return holds(kids[0], 'heading') && !holds(kids[0], 'button') && isAction(kids[1]);
|
|
202
|
+
}
|
|
187
203
|
function one(c, t) {
|
|
188
204
|
const n = shape(c, t);
|
|
189
205
|
const x = c.extra;
|
|
@@ -510,6 +526,43 @@ function shape(c, t) {
|
|
|
510
526
|
})),
|
|
511
527
|
};
|
|
512
528
|
}
|
|
529
|
+
// A TITLE BAR IS NOT TWO EQUAL COLUMNS. A heading with one action beside
|
|
530
|
+
// it ("Xem tất cả") went down the equal-share path below: each side got
|
|
531
|
+
// half the row, the action sat at the LEFT of its half — the middle of
|
|
532
|
+
// the band — and `flex-start` hung it from the heading's top edge. The
|
|
533
|
+
// shape wanted is the opposite on both axes: the heading takes what it
|
|
534
|
+
// needs, the action sits on the right edge, and both share a centre line.
|
|
535
|
+
// A phone stacks them and keeps them flush left.
|
|
536
|
+
if (kids.length === 2 && isTitleBar(c)) {
|
|
537
|
+
return {
|
|
538
|
+
type: 'flex-block',
|
|
539
|
+
style: {
|
|
540
|
+
width: '100%',
|
|
541
|
+
display: 'flex',
|
|
542
|
+
flexDirection: 'row',
|
|
543
|
+
flexWrap: 'nowrap',
|
|
544
|
+
justifyContent: 'space-between',
|
|
545
|
+
alignItems: 'center',
|
|
546
|
+
gap: '24px',
|
|
547
|
+
},
|
|
548
|
+
responsive: { mobile: { style: { flexDirection: 'column', alignItems: 'flex-start', gap: '12px' } } },
|
|
549
|
+
// `width: auto` on both, for the reason the packed row records:
|
|
550
|
+
// `flex-block` seeds `width: 100%`, which would hand the heading the
|
|
551
|
+
// whole line and push the action onto nothing.
|
|
552
|
+
children: [
|
|
553
|
+
{
|
|
554
|
+
type: 'flex-block',
|
|
555
|
+
style: { flex: '1 1 auto', width: 'auto', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '8px' },
|
|
556
|
+
children: [kids[0]],
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
type: 'flex-block',
|
|
560
|
+
style: { flex: '0 0 auto', width: 'auto', display: 'flex', flexDirection: 'column' },
|
|
561
|
+
children: [kids[1]],
|
|
562
|
+
},
|
|
563
|
+
],
|
|
564
|
+
};
|
|
565
|
+
}
|
|
513
566
|
if (c.wrap) {
|
|
514
567
|
return {
|
|
515
568
|
type: 'flex-block',
|
|
@@ -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
|
-
/**
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|