sbuilder-mcp 0.62.0 → 0.63.1

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,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.63.1] - 2026-09-24
10
+
11
+ ### Fixed
12
+ - Live-edit frames now carry the open page's id from the moment a room is joined and whenever a new page is opened afterward, so the editor's own per-page filter no longer drops every ops, cursor, and select frame this server sends.
13
+ - A second `sb_live_join`, or opening a page on a different site while already in a room, now leaves the previous room instead of leaving its socket connected and reconnecting for the rest of the process.
14
+ - Opening a page on a site other than the one the current live room belongs to now leaves that room first, so a failed join can no longer strand the session in the wrong site's room.
15
+ - The server process now exits when the MCP client closes stdin, instead of being kept alive indefinitely by the live-room socket and its reconnect timer.
16
+
17
+ ## [0.63.0] - 2026-09-21
18
+
19
+ ### Added
20
+ - `sb_store` gained `global_attach` and `global_detach` actions to put an existing shared header or footer onto the open page, or take one off, without hand-writing the composed stamp.
21
+ - `sb_page_state` is a new tool that reports whether the editor's draft, the published page, and this session's own copy of a page agree, and lists the real recovery points (autosave checkpoints and labelled versions) when they do not.
22
+ - `sb_publish` now reports the published revision's `id`, `publishedAt`, and the draft version it was compiled from, and takes a `verify` argument that fetches the live storefront and confirms the origin is actually serving that revision.
23
+
24
+ ### Fixed
25
+ - `sb_event` now writes the `<a href>` a renderer actually reads alongside a navigation click, since `node.events` is never read for a sole `go_to_url`/`open_page` click; every navigation authored before this fix rendered, saved, and published a control that did nothing when a shopper clicked it.
26
+ - `sb_review` no longer throws on a page whose document names a child node it does not hold; it reports the defect as `missing_node` instead.
27
+ - `sb_review` reports `dead_nav` for a navigation click that reached the document without its href projection.
28
+ - `sb_look`'s overlap check and its `node_id` framing now recognize an overlay (such as the cart drawer) by how it actually renders off-screen, not only by its composition stamp, removing dozens of false off-canvas findings per page and letting a drawer authored straight into a page document be framed at all.
29
+ - API errors thrown by a tool now carry the platform's own error `code` and HTTP status in the message, so a failure like a band-order refusal can be matched against its documented code instead of only its prose.
30
+ - `sb_store`'s `chrome` action and `sb_page_create` now re-read and re-store the page immediately after attaching a global section, so the platform's usage count and referencing-pages list reflect the attachment right away instead of only after a later save.
31
+
9
32
  ## [0.62.0] - 2026-09-20
10
33
 
11
34
  ### Added
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.63.1] - 2026-09-24
10
+
11
+ ### Fixed
12
+ - Các frame live-edit giờ mang theo id của trang đang mở ngay từ lúc vào phòng và mỗi khi một trang mới được mở sau đó, để bộ lọc theo từng trang của chính editor không còn loại bỏ mọi frame ops, cursor và select mà server này gửi đi.
13
+ - Gọi `sb_live_join` lần thứ hai, hoặc mở một trang thuộc site khác trong khi đang ở trong một phòng, giờ sẽ rời phòng cũ thay vì để socket của nó tiếp tục kết nối và tự reconnect trong suốt phần đời còn lại của tiến trình.
14
+ - Mở một trang thuộc site khác với site của phòng live hiện tại giờ sẽ rời phòng đó trước, để một lần join thất bại không còn có thể khiến session bị mắc kẹt trong phòng của site sai.
15
+ - Tiến trình server giờ thoát khi client MCP đóng stdin, thay vì bị giữ sống vô thời hạn bởi socket của phòng live và timer reconnect của nó.
16
+
17
+ ## [0.63.0] - 2026-09-21
18
+
19
+ ### Added
20
+ - `sb_store` có thêm hai action `global_attach` và `global_detach` để đặt một header hoặc footer dùng chung đã tồn tại lên trang đang mở, hoặc gỡ nó ra, mà không cần tự tay viết stamp đã compose.
21
+ - `sb_page_state` là một tool mới, báo cáo xem bản draft của editor, trang đã publish, và bản sao của session này có khớp nhau hay không, đồng thời liệt kê các điểm khôi phục thật sự (checkpoint autosave và version có nhãn) khi chúng không khớp.
22
+ - `sb_publish` giờ báo cáo `id` của bản ghi đã publish, `publishedAt`, và version draft mà nó được biên dịch từ đó, đồng thời nhận tham số `verify` để lấy trang storefront trực tiếp và xác nhận origin có đang phục vụ đúng bản đó không.
23
+
24
+ ### Fixed
25
+ - `sb_event` giờ ghi `<a href>` mà renderer thực sự đọc cùng lúc với một click điều hướng, vì `node.events` không bao giờ được đọc cho một click `go_to_url`/`open_page` đơn lẻ; mọi điều hướng được tạo trước bản sửa này render, lưu và publish thành một control không làm gì khi khách bấm vào.
26
+ - `sb_review` không còn ném lỗi khi tài liệu của trang trỏ tới một node con mà nó không có; giờ báo cáo lỗi này dưới dạng `missing_node`.
27
+ - `sb_review` báo cáo `dead_nav` cho một click điều hướng đến được tài liệu mà không có phép chiếu href của nó.
28
+ - Kiểm tra chồng lấp của `sb_look` và việc khung hình theo `node_id` giờ nhận diện một overlay (như cart drawer) dựa trên cách nó thực sự render ngoài màn hình, chứ không chỉ dựa vào stamp compose, loại bỏ hàng chục phát hiện off-canvas giả trên mỗi trang và cho phép khung hình một drawer được tạo thẳng vào tài liệu trang.
29
+ - Lỗi API do một tool ném ra giờ mang theo `code` lỗi và mã trạng thái HTTP của nền tảng trong message, để một lỗi như bị từ chối do sai thứ tự band có thể được đối chiếu với mã đã tài liệu hóa thay vì chỉ dựa vào câu chữ.
30
+ - Action `chrome` của `sb_store` và `sb_page_create` giờ đọc lại và lưu lại trang ngay sau khi gắn một global section, để số lượt sử dụng và danh sách trang tham chiếu của nền tảng phản ánh việc gắn kết ngay lập tức thay vì chỉ sau một lần lưu sau đó.
31
+
9
32
  ## [0.62.0] - 2026-09-20
10
33
 
11
34
  ### Added
package/README.md CHANGED
@@ -98,7 +98,8 @@ in this client: one would put the very key the platform exists to hold back into
98
98
  | `sb_template_use` | Instantiate a template into a page |
99
99
  | `sb_page_list` | Every page on the site |
100
100
  | `sb_page_create` | Create a page — a store type arrives with the editor's own starting document; `type` is the route for checkout, product, category, post, course |
101
- | `sb_publish` | Compile the draft into the live page (cascades to shared globals) |
101
+ | `sb_publish` | Compile the draft into the live page (cascades to shared globals), report which revision went live, and with `verify` check the origin is serving it |
102
+ | `sb_page_state` | Which of a page's three copies is which — the DRAFT the editor canvas shows, the PUBLISHED row the storefront serves, and this session's — plus whether the editor will render the canvas BLANK, and where the recovery points are |
102
103
  | `sb_review` | Every defect a visitor would see, each with its fix, plus the five gaps between this store and a paid order |
103
104
  | `sb_media_list` | The site's media library |
104
105
  | `sb_media_upload` | Add an image and get its URL — a local path, a URL the platform fetches, or a SEARCH for real photographs you read and pick from, one or several at a time |
@@ -109,7 +110,7 @@ in this client: one would put the very key the platform exists to hold back into
109
110
  | `sb_import` | Read a page from any public URL and add its structure and content to the open page as real elements, styled with THIS page's own tokens — a translation, not a clone |
110
111
  | `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 |
111
112
  | `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 |
112
- | `sb_store` | Run a store flow that must happen in a fixed order — `checkout` (the four writes that make a working one), `form` (any of the platform's 17 templates with its own field document), `chrome` (one shared header or footer), `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) and `app` (a built-in app plus the pages it needs) |
113
+ | `sb_store` | Run a store flow that must happen in a fixed order — `checkout` (the four writes that make a working one), `form` (any of the platform's 17 templates with its own field document), `chrome` (one shared header or footer), `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) 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) |
113
114
  | `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 |
114
115
 
115
116
  Twenty-eight tools, **560 API operations** (193 of the 251 writes carrying a body shape read
package/README.vi.md CHANGED
@@ -95,7 +95,8 @@ nhét ngược lại vào mọi bản cài.
95
95
  | `sb_template_use` | Thả một template vào trang |
96
96
  | `sb_page_list` | Mọi trang của site |
97
97
  | `sb_page_create` | Tạo một trang — trang cửa hàng sinh ra đã có sẵn tài liệu như trong editor; `type` là đường đi cho checkout, product, category, post, course |
98
- | `sb_publish` | Biên dịch bản nháp thành trang live (lan sang global dùng chung) |
98
+ | `sb_publish` | Biên dịch bản nháp thành trang live (lan sang global dùng chung), báo bản nào đã lên live, và với `verify` kiểm origin đã phục vụ đúng bản đó chưa |
99
+ | `sb_page_state` | Ba bản sao của một trang, bản nào là bản nào — bản NHÁP canvas editor hiển thị, dòng PUBLISHED storefront phục vụ, và bản phiên này giữ — kèm dự báo editor có render canvas TRẮNG không, và các điểm phục hồi nằm ở đâu |
99
100
  | `sb_review` | Mọi khiếm khuyết người xem sẽ thấy, kèm lệnh sửa từng cái, và năm khoảng trống chắn giữa cửa hàng với một đơn đã thanh toán |
100
101
  | `sb_media_list` | Thư viện ảnh của site |
101
102
  | `sb_media_upload` | Thêm ảnh và lấy URL — file trên máy, một URL để nền tảng tự tải, hoặc TÌM ảnh chụp thật để đọc mô tả rồi chọn, một hoặc nhiều tấm một lượt |
@@ -106,7 +107,7 @@ nhét ngược lại vào mọi bản cài.
106
107
  | `sb_import` | Đọc một trang từ URL công khai bất kỳ và thêm cấu trúc + nội dung của nó vào trang đang mở dưới dạng element thật, mang token của CHÍNH trang này — là dịch lại, không phải sao chép |
107
108
  | `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 |
108
109
  | `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 |
109
- | `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), `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), `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ở) và `app` (một app dựng sẵn kèm những trang nó cần) |
110
+ | `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), `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), `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ở) `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) |
110
111
  | `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 |
111
112
 
112
113
  Hai mươi tám tool, **560 operation API** (193 trong 251 lệnh ghi có hình dạng body đọc thẳng
@@ -46,6 +46,20 @@ export const FIX = {
46
46
  '"menu-item" per link, and delete the buttons with sb_remove. Add a "menu-drawer" (with a ' +
47
47
  '"hamburger-menu" trigger) for the phone, and "menu-dropdown" where a link has a sub-level. ' +
48
48
  'sb_traits_for "menu" has the controls.',
49
+ // The child is NAMED, because "a child is missing" without which one sends
50
+ // the reader back to diff two documents by hand.
51
+ missing_node: 'Take the dead reference out: sb_remove id "<child>" clears it when the node is merely ' +
52
+ 'orphaned. If the content is wanted, sb_add it under "<id>" instead. Until one or the ' +
53
+ 'other happens the platform refuses every save of this page, and a refused save is ' +
54
+ 'reported against whatever command came next rather than this one.',
55
+ // THE FIX IS NOT "SET href INSTEAD OF THE EVENT" — it is to re-issue the event
56
+ // through the tool that now projects both. Telling a caller to write the href
57
+ // by hand would leave the two able to disagree again on the next edit, which
58
+ // is the whole defect.
59
+ dead_nav: 'Re-issue the click through sb_event, which now writes specials.href alongside it: ' +
60
+ 'sb_event id "<id>", action "go_to_url", payload { "url": "<url>" }. A sole navigation ' +
61
+ 'click renders as an <a href> and from nothing else — the renderer emits no on:click ' +
62
+ 'for one — so the event without the href is a control that publishes and does nothing.',
49
63
  dead_menu_link: 'Write the entries the renderer actually reads: sb_set id "<id>", namespace specials, keys ' +
50
64
  '{ "menuItems": [{ "id": "mi-1", "label": "Shop", "href": "/shop" }] }. Setting menuId alone ' +
51
65
  'publishes an empty nav — the Go renderer never reads it.',
@@ -0,0 +1,91 @@
1
+ /**
2
+ * DID THE LIVE PAGE ACTUALLY START SERVING WHAT WAS JUST PUBLISHED?
3
+ *
4
+ * A publish that answers 200 proves the platform STORED a new published row.
5
+ * It does not prove a visitor is being served it, and the two come apart for a
6
+ * measured, ordinary reason: the storefront answers
7
+ *
8
+ * cache-control: public, max-age=60
9
+ *
10
+ * so a browser — and anything in front of it — may go on showing the previous
11
+ * page for up to a minute. A caller who publishes, reloads, sees the old page
12
+ * and concludes the publish failed then "fixes" something that was never
13
+ * broken; a caller who publishes, does not reload, and reports the page live is
14
+ * making a claim nothing checked.
15
+ *
16
+ * So this fetches the page and asks a question the HTML can answer: does the
17
+ * markup a visitor is being served contain the node ids this publish put in it?
18
+ * Every element the renderer draws carries `id="<node id>"`, so the top-level
19
+ * band ids are a fingerprint of the document without needing to diff bytes —
20
+ * and the diff would be the wrong tool anyway, since the served page is
21
+ * assembled with a head, a runtime bundle and stylesheet links the published
22
+ * `html` field does not carry.
23
+ *
24
+ * CACHE-BUSTED, because the question is about the ORIGIN and not about what
25
+ * some intermediary happens to be holding: an unmatched query parameter makes
26
+ * a distinct cache key, and `Cache-Control: no-cache` asks the chain to
27
+ * revalidate. What comes back is therefore what the origin serves NOW, and the
28
+ * `max_age` reported beside it is how long somebody else's copy may differ.
29
+ */
30
+ /** The node ids that are a page's top-level bands — its cheapest fingerprint. */
31
+ export function bandIds(document) {
32
+ const d = document;
33
+ const root = d?.root_node_id;
34
+ if (!root || !d?.nodes)
35
+ return [];
36
+ return (d.nodes[root]?.data?.nodes ?? []).filter((id) => typeof id === 'string');
37
+ }
38
+ /** `max-age=<n>` out of a Cache-Control header, when it says one. */
39
+ export function maxAgeOf(header) {
40
+ const m = /max-age=(\d+)/i.exec(header ?? '');
41
+ return m ? Number(m[1]) : undefined;
42
+ }
43
+ export async function proveLive(url, ids, fetchImpl = fetch) {
44
+ // A DISTINCT CACHE KEY. `_sb` rather than a bare `t`: the storefront's own
45
+ // preview route reads `t`, and colliding with a parameter the platform
46
+ // already means something by is how a cache-buster becomes a bug report.
47
+ const bust = new URL(url);
48
+ bust.searchParams.set('_sb', String(Date.now()));
49
+ let res;
50
+ try {
51
+ res = await fetchImpl(bust.toString(), { headers: { 'Cache-Control': 'no-cache' } });
52
+ }
53
+ catch (e) {
54
+ return {
55
+ url,
56
+ status: 0,
57
+ serving: false,
58
+ checked: ids.length,
59
+ note: `The live page could not be reached (${e.message}). The publish itself ` +
60
+ 'succeeded — this check did not run, which is not the same as the page being wrong.',
61
+ };
62
+ }
63
+ const html = await res.text();
64
+ const missing = ids.filter((id) => !html.includes(`id="${id}"`));
65
+ const maxAge = maxAgeOf(res.headers.get('cache-control'));
66
+ const etag = res.headers.get('etag') ?? undefined;
67
+ return {
68
+ url,
69
+ status: res.status,
70
+ serving: res.ok && missing.length === 0,
71
+ checked: ids.length,
72
+ ...(missing.length ? { missing } : {}),
73
+ ...(maxAge !== undefined ? { max_age: maxAge } : {}),
74
+ ...(etag ? { etag } : {}),
75
+ ...(res.ok && missing.length === 0 && maxAge
76
+ ? {
77
+ note: `The origin is serving this revision. Another viewer's browser may hold the ` +
78
+ `previous page for up to ${maxAge}s (cache-control: max-age=${maxAge}) — that is ` +
79
+ 'the platform\'s own caching, not a failed publish, and a hard reload ends it.',
80
+ }
81
+ : {}),
82
+ ...(res.ok && missing.length > 0
83
+ ? {
84
+ note: 'The page answered, and the markup does not carry every band this publish put in ' +
85
+ 'it. Either something in front of the origin is still serving the previous copy, ' +
86
+ 'or the page being served is built from a different document — re-run this check ' +
87
+ 'once before treating it as the second.',
88
+ }
89
+ : {}),
90
+ };
91
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * THE BRIDGE BETWEEN A CLICK ACTION AND THE THING A RENDERER ACTUALLY READS.
3
+ *
4
+ * `node.events` IS NEVER READ BY A RENDERER. The platform says so in as many
5
+ * words (`editor/src/stores/node.ts`, on `projectHref`) and the Go side proves
6
+ * it: `nodes.EventAttrs` SKIPS a sole navigation click outright —
7
+ *
8
+ * if ev.Name == "click" && clicks == 1 && purchaseItem == "" &&
9
+ * (ev.Action == "go_to_url" || ev.Action == "open_page") {
10
+ * continue // the <a href> form; see above
11
+ * }
12
+ *
13
+ * — on the stated assumption that the node's `specials.href` has already made
14
+ * it an `<a href>`, because "a call beside an anchor would navigate twice".
15
+ * The editor holds up that assumption by writing the event AND its href in one
16
+ * undo step (`projectHref`). THIS SERVER DID NOT, for as long as `sb_event`
17
+ * has existed.
18
+ *
19
+ * So `sb_event action:"go_to_url"` produced a node with no `href` and no
20
+ * `on:click`: a dead control that renders perfectly, saves, publishes, and does
21
+ * nothing when a shopper clicks it. Silent at every step — `sb_review` reads
22
+ * the tree and the tree is correct, `sb_look` photographs the page and the page
23
+ * looks right.
24
+ *
25
+ * MEASURED ON A LIVE STOREFRONT, which is how it was found rather than an
26
+ * argument for how it could happen. Three images on one home page carried
27
+ * `click: go_to_url {"url":"/bo-suu-tap"}` with no href, and the published
28
+ * markup for each was a bare `<img>` — no anchor, no `on:click` — beside a
29
+ * button that carried the href and rendered `<a href="/bo-suu-tap">`.
30
+ *
31
+ * Pure, and in its own module, because two callers need it for opposite
32
+ * reasons: `setEvent` must WRITE the projection, and `sb_review` must REPORT a
33
+ * document that reached here by another road (an import, a hand-built
34
+ * `sb_api_call`, a page authored before this fix).
35
+ */
36
+ /**
37
+ * The RESERVED id of the purchase binding
38
+ * (`schema/src/elements/datasetBindings.ts:852`).
39
+ *
40
+ * It lives here rather than beside `sb_bind` because both sides of this
41
+ * projection need it and a copy in each is how the two drift: the renderer's
42
+ * `purchaseItem` is what decides whether a navigation may be an anchor at all.
43
+ */
44
+ export const PRODUCT_ACTION_BINDING_ID = 'bind-product-action';
45
+ /**
46
+ * Actions that leave the page (`schema/src/actions/engine.ts`).
47
+ *
48
+ * `go_to_checkout` is one of them and is deliberately NOT projectable below —
49
+ * a checkout hop is never a plain link, and the platform's own comment says so.
50
+ */
51
+ 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']);
54
+ /**
55
+ * The destination an event projects onto `specials.href`, or undefined.
56
+ *
57
+ * `open_page` reads a `url` the editor's page picker RESOLVED AND CACHED at
58
+ * pick time — neither renderer can resolve a page id — so an `open_page`
59
+ * payload carrying only an id projects nothing, exactly as the editor's own
60
+ * `eventHref` does.
61
+ */
62
+ export function eventHref(ev) {
63
+ if (!ev || !PROJECTABLE.has(ev.action ?? ''))
64
+ return undefined;
65
+ const url = (ev.payload ?? {}).url;
66
+ return typeof url === 'string' && url !== '' ? url : undefined;
67
+ }
68
+ /** The `target` an event projects. Only `go_to_url` offers the choice. */
69
+ export function eventTarget(ev) {
70
+ if (!ev || ev.action !== 'go_to_url')
71
+ return undefined;
72
+ return (ev.payload ?? {}).openInNewTab === true ? '_blank' : undefined;
73
+ }
74
+ /** Does this node carry the reserved purchase binding? */
75
+ export function hasPurchaseBinding(bindings) {
76
+ return (bindings ?? []).some((b) => b?.id === PRODUCT_ACTION_BINDING_ID);
77
+ }
78
+ /**
79
+ * The click event this node's `<a href>` projection comes from, or null.
80
+ *
81
+ * EXACTLY the editor's rule, and each clause earns its place:
82
+ * - exactly ONE click event — the moment anything else joins the list the
83
+ * renderer emits every one of them as a `Nav#go` call in the chain, and an
84
+ * href beside that chain would navigate twice;
85
+ * - a PROJECTABLE navigation action, so `go_to_checkout` keeps its runtime
86
+ * call;
87
+ * - no purchase binding — a bound button's navigation always rides chained
88
+ * after `AddToCart#add`, never as a link.
89
+ */
90
+ export function soleNavigationClick(events, purchaseBound) {
91
+ if (purchaseBound)
92
+ return null;
93
+ const clicks = (events ?? []).filter((e) => e?.name === 'click');
94
+ if (clicks.length !== 1)
95
+ return null;
96
+ return PROJECTABLE.has(clicks[0].action ?? '') ? clicks[0] : null;
97
+ }
98
+ /**
99
+ * The patches that put `specials.href`/`specials.target` back in step with a
100
+ * node's click list.
101
+ *
102
+ * `undefined` becomes an `unset`, and a key that is already absent produces NO
103
+ * patch at all — both mirroring the editor's `PatchRecorder.set`, because these
104
+ * go on the wire to peers running that code and an empty write would churn a
105
+ * revision for nothing.
106
+ */
107
+ export function hrefPatches(id, events, purchaseBound, current) {
108
+ const sole = soleNavigationClick(events, purchaseBound);
109
+ const want = {
110
+ href: eventHref(sole),
111
+ target: eventTarget(sole),
112
+ };
113
+ const out = [];
114
+ for (const [key, value] of Object.entries(want)) {
115
+ const had = (current ?? {})[key];
116
+ if (value === undefined) {
117
+ if (had !== undefined)
118
+ out.push({ op: 'unset', path: ['nodes', id, 'specials', key] });
119
+ continue;
120
+ }
121
+ if (had !== value)
122
+ out.push({ op: 'set', path: ['nodes', id, 'specials', key], value });
123
+ }
124
+ return out;
125
+ }
126
+ /**
127
+ * Would this node's navigation reach the shopper?
128
+ *
129
+ * The question `sb_review` asks, and the one no screenshot answers: a dead
130
+ * navigation control is the RIGHT PIXELS with nothing behind them. Returns the
131
+ * destination the author meant when the answer is no, so the finding can name
132
+ * the page the click was supposed to reach.
133
+ */
134
+ export function deadNavigation(node) {
135
+ const sole = soleNavigationClick(node.events, hasPurchaseBinding(node.bindings));
136
+ if (!sole)
137
+ return null;
138
+ const url = eventHref(sole);
139
+ if (url === undefined)
140
+ return null;
141
+ const href = (node.specials ?? {}).href;
142
+ return typeof href === 'string' && href !== '' ? null : { url };
143
+ }
@@ -0,0 +1,103 @@
1
+ import { childrenOf, isOverlay, subtreeIds } from '../../core/tree.js';
2
+ /**
3
+ * PARKED OFF-SCREEN UNTIL A SHOPPER OPENS IT — a fact about the ELEMENT'S
4
+ * RENDERER, not about how the node reached the page.
5
+ *
6
+ * `measure` already skipped overlays, and the skip was built from `isOverlay`,
7
+ * which asks a COMPOSITION question: does this node carry `specials.overlayId`
8
+ * and sit directly under ROOT. That is the right question for trap 1 — an
9
+ * overlay is composed onto ROOT on read and stripped on write, so a write must
10
+ * refuse its root — and it is the WRONG question here, because being off-screen
11
+ * is something the element's own CSS does:
12
+ *
13
+ * .wb-cart-drawer { visibility: hidden; transform: translateX(105%) }
14
+ * .wb-cart-drawer.is-open{ visibility: visible; transform: none }
15
+ *
16
+ * A `cart-drawer` authored straight into a page document — no `overlayId`, just
17
+ * a node somebody added — parks itself exactly the same way. MEASURED on a live
18
+ * storefront: every page carried an unstamped `cart-drawer` as a ROOT child, so
19
+ * the skip set came out EMPTY and `sb_look` reported THIRTEEN off-canvas
20
+ * findings per page, at every width, on pages that were completely correct.
21
+ * "A list that is two dozen false positives long is a list nobody reads" is the
22
+ * comment on the skip this repairs.
23
+ *
24
+ * The same blind spot has a second symptom, which is why this is one module and
25
+ * not a patch in `measure`: `sb_look` opens an overlay before framing a node
26
+ * inside it, and it decides what to open with `overlayRoot` — the same
27
+ * stamp-based test. Framing the quantity stepper inside an unstamped drawer
28
+ * therefore did not open it, the clip landed outside the image, and Playwright
29
+ * answered "Clipped area is either empty or outside the resulting image",
30
+ * naming neither the overlay nor the reason.
31
+ *
32
+ * THIS IS THE `Box.position` LESSON AGAIN, one field along: a comment described
33
+ * behaviour the code did not have, because the code asked a question adjacent
34
+ * to the one the comment was about.
35
+ *
36
+ * HAND-KEPT, and the replacement is nameable. The honest source is the Go CSS —
37
+ * an element whose `render/nodes/<type>/css.go` hides itself until `.is-open` —
38
+ * which is mechanically readable and is what a codegen reader should take. It
39
+ * is four types today, and the element metas carry nothing that separates them:
40
+ * all of `cart-drawer`, `popup`, `menu-drawer`, `menu-panel` and `hamburger-menu`
41
+ * report `category: "basic"`, so there is nothing in the catalog to derive this
42
+ * from. Until that reader exists, a new overlay element the platform ships is a
43
+ * silent gap here — the same standing debt `INERT_ON_ADD` carries, recorded
44
+ * rather than hidden.
45
+ */
46
+ export const OFFSCREEN_UNTIL_OPEN = new Set([
47
+ // Both measured directly in the Go: these two translate themselves out of the
48
+ // viewport and back on `.is-open`.
49
+ 'cart-drawer',
50
+ 'hamburger-menu',
51
+ // These two are hidden rather than translated, so they do not usually measure
52
+ // as off-canvas — but they are the same KIND of thing, they are what
53
+ // `sb_look` must open before framing a node inside one, and leaving them out
54
+ // would make this set mean two different things depending on which caller
55
+ // read it.
56
+ 'popup',
57
+ 'menu-panel',
58
+ 'menu-drawer',
59
+ ]);
60
+ /** Is this node an overlay for RENDER purposes — composed, or one by type? */
61
+ export function isOffscreenOverlay(doc, id) {
62
+ if (isOverlay(doc, id))
63
+ return true;
64
+ return OFFSCREEN_UNTIL_OPEN.has(doc.nodes[id]?.data.type ?? '');
65
+ }
66
+ /**
67
+ * Every node inside an overlay on this page, for a caller that must not judge
68
+ * their geometry.
69
+ *
70
+ * ROOT's children only, matching where an overlay may legally sit — and a
71
+ * by-type overlay nested deeper is deliberately not swept up, because a
72
+ * `popup` inside a section is a document the platform refuses on save and
73
+ * hiding it here would hide that.
74
+ */
75
+ export function offscreenNodes(doc) {
76
+ const out = new Set();
77
+ for (const id of childrenOf(doc, doc.root_node_id)) {
78
+ if (!isOffscreenOverlay(doc, id))
79
+ continue;
80
+ for (const n of subtreeIds(doc, id))
81
+ out.add(n);
82
+ }
83
+ return out;
84
+ }
85
+ /**
86
+ * The overlay this node sits in, for the RENDER question "what must be opened
87
+ * before I can photograph this".
88
+ *
89
+ * Deliberately NOT `overlayRoot` from core/tree, and deliberately not a change
90
+ * to it: that one answers the COMPOSITION question every write guard asks —
91
+ * "is this node inside a subtree the save will strip" — and widening it to
92
+ * element types would make `refuseOverlay` start refusing writes to a plain
93
+ * authored drawer, which is a node this page genuinely owns and may edit.
94
+ * Two questions, two functions.
95
+ */
96
+ export function offscreenRootOf(doc, id) {
97
+ const roots = childrenOf(doc, doc.root_node_id).filter((k) => isOffscreenOverlay(doc, k));
98
+ for (const root of roots) {
99
+ if (root === id || subtreeIds(doc, root).includes(id))
100
+ return root;
101
+ }
102
+ return null;
103
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * WHICH OF THIS PAGE'S THREE COPIES IS WHICH.
3
+ *
4
+ * A page is not one document. It is three, read by three different consumers,
5
+ * and every tool here answered for one of them while a caller was asking about
6
+ * another:
7
+ *
8
+ * - the DRAFT — `GET /pages/{id}/source`. What the EDITOR CANVAS shows.
9
+ * - the PUBLISHED row — compiled at publish time. What the STOREFRONT serves.
10
+ * - this SESSION's copy — read once into memory and edited since.
11
+ *
12
+ * "The render has data but the canvas is blank" is the shape this exists for,
13
+ * and it is not a cache: it is the draft having been emptied while the
14
+ * published row kept the good copy. This repo has the measured incident — a
15
+ * product template of 24 nodes read back bare, stored bare; the published copy
16
+ * untouched, so all 19 product pages went on rendering; invisible until a
17
+ * person opened the editor. `PageSession.save` guards the session that CAUSES
18
+ * that, and nothing reported a draft that was already in that state.
19
+ *
20
+ * THE CANVAS GATE IS SIMULATED RATHER THAN DESCRIBED, because the editor's
21
+ * rule is three lines and nothing else can answer it
22
+ * (`editor/src/stores/node.ts`, `hydrate`):
23
+ *
24
+ * const nodes = doc?.nodes ?? {};
25
+ * const rootId = doc?.root_node_id ?? '';
26
+ * if (!rootId || !nodes[rootId]) { this.seedRoot(opts?.pageId); return; }
27
+ *
28
+ * A document failing it is SILENTLY REPLACED by an empty ROOT — blank canvas,
29
+ * no error anywhere — and the editor's next save stores that blank. The Go
30
+ * renderer has no such gate, so a document can fail this and still publish
31
+ * markup, which is exactly how the two copies come apart.
32
+ *
33
+ * READ AGAINST THE RAW DOCUMENT, never against a `PageDoc`. `PageDoc.from`
34
+ * ADOPTS a `rootId`/`rootNodeId` alias and repairs it in memory, which is the
35
+ * right thing for a tool that is about to edit the page and the wrong thing
36
+ * for a report about what the editor will do: the editor reads the stored
37
+ * bytes, and the stored bytes still carry the alias.
38
+ */
39
+ export function canvasVerdict(document) {
40
+ const d = (document ?? {});
41
+ const nodes = (d.nodes ?? {});
42
+ const count = Object.keys(nodes).length;
43
+ const root = typeof d.root_node_id === 'string' ? d.root_node_id : '';
44
+ if (root && nodes[root])
45
+ return { blank: false, nodes: count };
46
+ // THE ALIAS IS WORTH NAMING SEPARATELY, because the repair is one save and
47
+ // the reader otherwise has no idea why a document with plenty of nodes is
48
+ // about to vanish. `rootId` is the key an app block and a section template
49
+ // use for the same idea, and the editor's own completion-page seed shipped
50
+ // it (`element/completionPage.ts:91`, fixed upstream in 8e40bbab) — so pages
51
+ // created from that seed still carry it today.
52
+ const alias = ['rootId', 'rootNodeId'].find((k) => typeof d[k] === 'string' && nodes[d[k]]);
53
+ if (alias) {
54
+ return {
55
+ blank: true,
56
+ nodes: count,
57
+ why: `The document names its root under "${alias}" instead of root_node_id, so the editor ` +
58
+ `discards all ${count} nodes and shows an empty canvas, and the Go renderer publishes ` +
59
+ 'an empty <body> with a 200.',
60
+ fix: 'sb_page_open adopts the alias and the next save writes the canonical key: open the ' +
61
+ 'page, make any edit (or none — sb_page_open reports blank_page_repair), save, publish.',
62
+ };
63
+ }
64
+ if (count === 0) {
65
+ return {
66
+ blank: true,
67
+ nodes: 0,
68
+ why: 'The document holds no nodes at all. For a page just created this is normal.',
69
+ fix: 'Build it — sb_template_use for a designed band, or sb_add from ROOT.',
70
+ };
71
+ }
72
+ return {
73
+ blank: true,
74
+ nodes: count,
75
+ why: `root_node_id is ${JSON.stringify(d.root_node_id ?? null)}, which names none of the ` +
76
+ `${count} nodes present. The editor discards the whole document and shows an empty ` +
77
+ 'canvas; its next save would store that blank over these nodes.',
78
+ fix: 'Do NOT open and save this page in the editor until it is repaired — that save is what ' +
79
+ 'makes the loss permanent. Recover the draft from a page version or from the published ' +
80
+ 'copy (sb_api_find "page versions").',
81
+ };
82
+ }
83
+ export function driftOf(draftUpdatedAt, publishedAt) {
84
+ if (!publishedAt) {
85
+ return {
86
+ state: 'never_published',
87
+ note: 'This page has no published copy, so nothing is served for it — the URL 404s.',
88
+ };
89
+ }
90
+ if (!draftUpdatedAt) {
91
+ return { state: 'in_step', note: 'The draft reports no timestamp to compare.' };
92
+ }
93
+ const draft = Date.parse(draftUpdatedAt);
94
+ const live = Date.parse(publishedAt);
95
+ if (!Number.isFinite(draft) || !Number.isFinite(live)) {
96
+ return { state: 'in_step', note: 'One of the timestamps could not be read.' };
97
+ }
98
+ // A SECOND of slack. Publish writes its own row after reading the draft, so
99
+ // the two are legitimately microseconds apart on an untouched page and
100
+ // reporting that as "unpublished changes" would cry wolf on every check.
101
+ if (draft > live + 1000) {
102
+ return {
103
+ state: 'draft_ahead',
104
+ note: 'The draft has changes the live page does not. The editor canvas and the storefront ' +
105
+ 'will disagree until this page is published.',
106
+ };
107
+ }
108
+ if (live > draft + 1000) {
109
+ return {
110
+ state: 'published_ahead',
111
+ note: 'The live page is NEWER than the draft. That happens on a cascaded publish — a shared ' +
112
+ 'header edited elsewhere republishes every page carrying it — and is not a defect.',
113
+ };
114
+ }
115
+ return { state: 'in_step', note: 'The draft and the live page are the same revision.' };
116
+ }