sbuilder-mcp 0.7.2 → 0.9.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 +29 -0
- package/CHANGELOG.vi.md +29 -0
- package/dist/catalog/elements.generated.js +4 -1
- package/dist/domains/site/builder.js +21 -0
- package/dist/domains/site/findings.js +10 -0
- package/dist/domains/site/importmap.js +70 -0
- package/dist/domains/site/node.js +22 -1
- package/dist/domains/site/review.js +39 -0
- package/dist/domains/site/sticky.js +208 -0
- package/dist/tools/importpage.js +28 -7
- package/dist/tools/page.js +40 -4
- package/dist/transport/media.js +47 -1
- package/dist/vision/capture.js +178 -49
- package/dist/vision/shoot.js +19 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,35 @@ 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.9.0] - 2026-09-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_set's `state` argument now accepts `"stuck"`, the look a pinned (`position: sticky` or `fixed`) element wears once the platform's runtime island marks it stuck, refusing the write when neither the node nor any ancestor can pin since the renderer would compile no rule for it and the override would be stored, published and never painted.
|
|
13
|
+
- sb_set now seeds `top`/`zIndex` alongside a `position: sticky` write, matching what the platform's own editor seeds, since a pinned header with no z-index is painted over by later content the moment it scrolls past.
|
|
14
|
+
- sb_set warns (in both dry_run and the real write) when a sticky node sits inside an ancestor whose overflow clips it, since sticky resolves against the nearest scrolling ancestor and a clipping one silently defeats the pin.
|
|
15
|
+
- sb_review reports `stuck_no_host` for a `stuck` state override with no pinned self-or-ancestor, and `sticky_blocked` for a sticky node whose ancestor's overflow defeats it, so a document that reached either state through an import, a template, or a later edit is caught even when sb_set's own write-time checks were bypassed.
|
|
16
|
+
- sb_import now carries a source section's `sticky`/`fixed` positioning onto the imported node, including the same offset and layer-order seeds sb_set writes, since a section pinned to stay in view is a layout decision distinct from one that scrolls away.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- sb_media_upload from a `url` no longer fails for every image and video type; the upload was sent with no declared content type, which the platform treats as neither an image nor a video, so it refused a plain PNG with a message that named the wrong cause. The source's own Content-Type header is used when it identifies the file, and the file extension otherwise.
|
|
20
|
+
- sb_import no longer drops a link's text when the link wraps markup (such as `<a><span>Docs</span></a>`) whose contents produce nothing importable; it now keeps the link's own words instead of importing nothing for it.
|
|
21
|
+
|
|
22
|
+
### Internal
|
|
23
|
+
- The generated element catalog is refreshed against a current platform checkout: `dataset-block` and `list-dataset` now carry a content tip warning that a dataset node inside a repeater must bind to its own `config.datasetSource`, not the element's default.
|
|
24
|
+
|
|
25
|
+
## [0.8.0] - 2026-09-08
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- sb_add's `spec` argument now takes `responsive` on any node, seeding per-breakpoint style and config overrides at creation time instead of requiring a follow-up sb_set for every responsive value; it merges over the element's own seeded responsive defaults per namespace, so seeding a mobile style does not drop the element's own mobile config.
|
|
29
|
+
- sb_import takes max_nodes (default 300), a single bound on the whole import; it replaces the old per-section cap, which silently became the real limit on a page whose body has one top-level child, and the amount skipped is now reported on a real run too, not only a dry one.
|
|
30
|
+
- sb_import now preserves the source's layout: a container that actually lays its children out with flex or grid becomes a real row that stacks at mobile instead of every section flattening into one vertical column.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- sb_import now captures text from any element that holds it, not only `<p>` and `<blockquote>`, since most of the web does not use paragraph tags; a table-layout page or a utility-CSS page previously imported with none of its text at all.
|
|
34
|
+
- sb_import's empty-result fallback now retries against the page's `<main>` whenever the sections it found produced no content, instead of only when it found no candidate sections in the first place.
|
|
35
|
+
- sb_import now keeps an unpainted content link instead of dropping it, capturing it as a flat link-styled button rather than only capturing links that already look like a call to action; a painted button still requires a filled background or a border with actual width, so a Tailwind page's zero-width reset borders are no longer mistaken for one.
|
|
36
|
+
- sb_import no longer imports the source page's own page-level header and footer, since the target page already carries its own as shared globals; a `<header>` nested inside a section (a hero) is still kept.
|
|
37
|
+
|
|
9
38
|
## [0.7.2] - 2026-09-08
|
|
10
39
|
|
|
11
40
|
### Fixed
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,35 @@ 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.9.0] - 2026-09-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Tham số `state` của sb_set giờ nhận `"stuck"`, trạng thái mà một phần tử được ghim (`position: sticky` hoặc `fixed`) mang khi runtime island của nền tảng đánh dấu nó là stuck; sb_set sẽ từ chối ghi khi không có node nào — bản thân node hay bất kỳ tổ tiên nào — có thể ghim được, vì khi đó renderer sẽ không biên dịch rule nào cả và override sẽ bị lưu, publish rồi không bao giờ được vẽ ra.
|
|
13
|
+
- sb_set giờ tự gieo `top`/`zIndex` khi ghi `position: sticky`, khớp với những gì editor của chính nền tảng gieo sẵn, vì một header được ghim mà không có z-index sẽ bị nội dung phía sau vẽ đè lên ngay khi cuộn qua.
|
|
14
|
+
- sb_set giờ cảnh báo (cả ở dry_run lẫn lần ghi thật) khi một node sticky nằm trong một tổ tiên có overflow cắt nó, vì sticky phân giải theo tổ tiên có thể cuộn gần nhất, và một tổ tiên cắt nội dung sẽ âm thầm vô hiệu hóa việc ghim.
|
|
15
|
+
- sb_review giờ báo cáo `stuck_no_host` cho một override trạng thái `stuck` mà không có node ghim nào ở chính nó hay tổ tiên, và `sticky_blocked` cho một node sticky bị tổ tiên có overflow vô hiệu hóa, để một document đi đến một trong hai trạng thái này qua import, template, hoặc một chỉnh sửa sau đó vẫn bị phát hiện dù đã bỏ qua được các kiểm tra lúc ghi của sb_set.
|
|
16
|
+
- sb_import giờ mang theo thuộc tính ghim `sticky`/`fixed` của một section nguồn sang node được import, kèm theo đúng các giá trị gieo về offset và thứ tự layer mà sb_set ghi, vì một section được ghim để luôn hiển thị là một quyết định bố cục khác với một section cuộn trôi đi.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- sb_media_upload từ `url` không còn thất bại với mọi loại ảnh và video; trước đây lần upload được gửi đi mà không khai báo content type, khiến nền tảng coi đó không phải ảnh cũng không phải video, nên nó từ chối cả một file PNG bình thường với thông báo nêu sai nguyên nhân. Giờ đây content-type khai báo của nguồn được dùng khi nó xác định đúng loại file, và phần mở rộng của tên file được dùng khi không xác định được.
|
|
20
|
+
- sb_import không còn làm mất chữ của một liên kết khi liên kết đó bọc markup (như `<a><span>Docs</span></a>`) mà nội dung bên trong không import được gì; giờ nó giữ lại chính chữ của liên kết thay vì import ra không có gì cho nó.
|
|
21
|
+
|
|
22
|
+
### Internal
|
|
23
|
+
- Catalog element được tạo lại dựa trên một bản checkout hiện tại của nền tảng: `dataset-block` và `list-dataset` giờ mang một content tip cảnh báo rằng một node dataset bên trong repeater phải bind theo đúng `config.datasetSource` của chính nó, chứ không phải theo mặc định của element.
|
|
24
|
+
|
|
25
|
+
## [0.8.0] - 2026-09-08
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- Tham số `spec` của sb_add giờ nhận `responsive` trên bất kỳ node nào, cho phép gieo các override style và config theo từng breakpoint ngay lúc tạo node, thay vì phải gọi thêm sb_set cho mỗi giá trị responsive; nó merge đè lên các giá trị responsive mặc định mà chính element đã gieo sẵn theo từng namespace, nên việc gieo một style cho mobile sẽ không làm mất config mobile riêng của element.
|
|
29
|
+
- sb_import nhận max_nodes (mặc định 300), một giới hạn duy nhất cho toàn bộ lần import; nó thay thế giới hạn theo từng section cũ, vốn âm thầm trở thành giới hạn thực tế trên một trang mà `<body>` chỉ có một phần tử con cấp cao nhất, và số lượng bị bỏ qua giờ được báo cáo cả ở lần chạy thật, không chỉ ở dry run.
|
|
30
|
+
- sb_import giờ giữ lại cách bố trí của trang nguồn: một container thực sự sắp xếp các phần tử con bằng flex hoặc grid sẽ trở thành một row thật, tự động xếp chồng ở mobile, thay vì mọi section đều bị làm phẳng thành một cột dọc.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- sb_import giờ lấy text từ bất kỳ phần tử nào chứa nó, không chỉ `<p>` và `<blockquote>`, vì phần lớn nội dung trên web không dùng thẻ paragraph; một trang dùng layout bảng hoặc trang dùng utility-CSS trước đây bị import mà không lấy được chữ nào.
|
|
34
|
+
- Cơ chế fallback khi không có kết quả của sb_import giờ thử lại với `<main>` của trang bất cứ khi nào các section tìm được không tạo ra nội dung nào, thay vì chỉ thử lại khi không tìm được section ứng viên nào ngay từ đầu.
|
|
35
|
+
- sb_import giờ giữ lại một liên kết nội dung không được "tô" thay vì bỏ nó đi, lấy nó thành một button kiểu link phẳng thay vì chỉ lấy những liên kết đã trông giống một call-to-action; một button được "tô" vẫn cần có màu nền hoặc viền có độ dày thật, nên các viền reset độ dày bằng 0 của một trang dùng Tailwind không còn bị nhầm là một call-to-action nữa.
|
|
36
|
+
- sb_import không còn import header và footer cấp trang của trang nguồn, vì trang đích đã có sẵn header/footer riêng dưới dạng global dùng chung; một `<header>` nằm lồng bên trong một section (dạng hero) vẫn được giữ lại.
|
|
37
|
+
|
|
9
38
|
## [0.7.2] - 2026-09-08
|
|
10
39
|
|
|
11
40
|
### Fixed
|
|
@@ -30090,7 +30090,9 @@ export const ELEMENTS = {
|
|
|
30090
30090
|
"Place it on a product or collection page so its children inherit that page binding automatically; on any OTHER page, pin a standalone product via the Product trait group (and optionally a default variant) so the children preview that product",
|
|
30091
30091
|
"Fill it with dataset children (media-dataset, text-dataset, pricing-dataset, product-variants, quantity, buttons); style each field on its own node",
|
|
30092
30092
|
"Use direction + gap for the internal flex layout and content-width to cap and center the card",
|
|
30093
|
-
"Prefer a horizontal split (media beside a content column) for buy boxes and a vertical stack for compact cards"
|
|
30093
|
+
"Prefer a horizontal split (media beside a content column) for buy boxes and a vertical stack for compact cards",
|
|
30094
|
+
"config.datasetSource names the ENTITY this card holds — \"product\" (the default when the key is absent), \"category\", \"article\", \"blogCategory\", \"course\" or \"review\". Inside a list-dataset it MUST equal the repeater's own datasetSource: it is what makes the card bind that entity's fields AND what gives the card its link, so a collection card left on the default binds product.url, resolves to nothing in a collection row, and publishes with no link on it at all",
|
|
30095
|
+
"Set the same datasetSource on every dataset child of the card (text-dataset, collection-media) — each child reads its own key, not the card's"
|
|
30094
30096
|
],
|
|
30095
30097
|
"semantics": [
|
|
30096
30098
|
"dataset",
|
|
@@ -31465,6 +31467,7 @@ export const ELEMENTS = {
|
|
|
31465
31467
|
],
|
|
31466
31468
|
"contentTips": [
|
|
31467
31469
|
"Always add exactly one dataset-block child as the repeated item template; every row clones that exact structure and styling",
|
|
31470
|
+
"The dataset-block template — and every dataset node inside it — must carry the SAME config.datasetSource as this repeater. Each node reads its OWN key, so a collection list whose card omits it leaves that card on the product default: it binds product.url, no product context reaches a collection row, and the card publishes with no link, so clicking it does nothing",
|
|
31468
31471
|
"config.datasetSource picks the ENTITY: \"product\" for a product list, \"category\" for a collection list. It is what derives the node's binding target (product::product_list / category::collection_list)",
|
|
31469
31472
|
"Product lists: config.collectionType \"all_products\" renders the whole catalog, \"collection\" renders the one named by config.collectionId, \"related\" renders the PAGE PRODUCT's neighbours — the products sharing one of its collections, minus itself",
|
|
31470
31473
|
"Use \"related\" for a cross-sell shelf on a product page (\"Sản phẩm liên quan\" / \"You may also like\"). It reads no collectionId — its axis is whatever collections the page product is filed under — and it renders nothing on a page that names no product, so pair it with an emptyStateId if the section must never be blank",
|
|
@@ -2,6 +2,7 @@ import { isOverlay, subtreeIds, ancestors, appBlockRoot, SPEC_GLOBAL_ID, SPEC_GL
|
|
|
2
2
|
import { ELEMENTS, ELEMENT_SEEDS, SATELLITE_RULES } from '../../catalog/elements.generated.js';
|
|
3
3
|
import { bindingsForConfig, createNode, mintSatellites } from './node.js';
|
|
4
4
|
import { refuseSecondTemplate } from './traps.js';
|
|
5
|
+
import { STUCK_STATE, refuseStuckConfig, requireStuckHost, stickySeeds } from './sticky.js';
|
|
5
6
|
import { genId } from './ids.js';
|
|
6
7
|
/** Append sentinel: splice clamps a too-large index, and `isSyncablePatch`
|
|
7
8
|
* deliberately allows one — an append is a legitimate thing to describe. */
|
|
@@ -90,6 +91,7 @@ export function addSubtree(doc, parentId, spec, index) {
|
|
|
90
91
|
style: s.style,
|
|
91
92
|
config: s.config,
|
|
92
93
|
specials: s.specials,
|
|
94
|
+
responsive: s.responsive,
|
|
93
95
|
});
|
|
94
96
|
// Before the owner is handed to a patch: minting rewrites its `config`.
|
|
95
97
|
const sats = mintSatellites(n);
|
|
@@ -203,6 +205,16 @@ export function setKeys(doc, id, keys, opts) {
|
|
|
203
205
|
// documents that exact call — `sb_set pr_option style base:true state:"active"`
|
|
204
206
|
// — as the way to style a selected option.
|
|
205
207
|
if (opts.state) {
|
|
208
|
+
// THE STUCK STATE IS THE ONE STATE WITH A PRECONDITION. Every other state
|
|
209
|
+
// is a pseudo-class the browser resolves on the node itself; `stuck` is a
|
|
210
|
+
// class a runtime island toggles on the PINNED element, and the renderer
|
|
211
|
+
// emits no rule at all when there is nothing pinned to hang it off. See
|
|
212
|
+
// sticky.ts — this is the whole reason that module exists.
|
|
213
|
+
if (opts.state === STUCK_STATE) {
|
|
214
|
+
requireStuckHost(doc.doc, id);
|
|
215
|
+
if (namespace === 'config')
|
|
216
|
+
refuseStuckConfig(keys);
|
|
217
|
+
}
|
|
206
218
|
// Base state and per-breakpoint state are DIFFERENT PLACES in the document,
|
|
207
219
|
// and the old path (`states[state][bp][ns]`) was neither of them: it buried
|
|
208
220
|
// a breakpoint inside the base-state cluster, where nothing reads it.
|
|
@@ -218,6 +230,15 @@ export function setKeys(doc, id, keys, opts) {
|
|
|
218
230
|
...(rebind ? [rebind] : []),
|
|
219
231
|
];
|
|
220
232
|
}
|
|
233
|
+
// WRITING `position: sticky` IS THREE WRITES, and the editor makes all three.
|
|
234
|
+
// An agent that made only the first shipped a header the page paints over —
|
|
235
|
+
// measured in Chromium by the platform, not inferred. Seeded only where the
|
|
236
|
+
// caller and the node are both silent, so an explicit answer always wins.
|
|
237
|
+
if (namespace === 'style') {
|
|
238
|
+
const seeds = stickySeeds(doc.node(id), keys, opts.base ? undefined : (opts.breakpoint ?? 'desktop'));
|
|
239
|
+
if (Object.keys(seeds).length)
|
|
240
|
+
keys = { ...seeds, ...keys };
|
|
241
|
+
}
|
|
221
242
|
if (opts.base) {
|
|
222
243
|
// Base IS legitimate, and this used to throw for anything that was not an
|
|
223
244
|
// identity key — a refusal built on a misread of the platform's responsive
|
|
@@ -54,6 +54,16 @@ export const FIX = {
|
|
|
54
54
|
off_canvas: 'Give it a width that can shrink — sb_set id "<id>", namespace style, keys { "maxWidth": "100%" } at this breakpoint.',
|
|
55
55
|
text_too_small: 'Raise it for this breakpoint: sb_set id "<id>", namespace style, keys { "fontSize": "16px" }.',
|
|
56
56
|
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.',
|
|
57
|
+
// Both sticky findings name a node OTHER than the one at fault, because in
|
|
58
|
+
// both cases the node carrying the defect is not the node to edit.
|
|
59
|
+
sticky_blocked: 'Clear the clip on the ancestor: sb_set id "<key>", namespace style, keys ' +
|
|
60
|
+
'{ "overflowX": "visible", "overflowY": "visible" } — or move the pinned node outside it. ' +
|
|
61
|
+
'Sticky resolves against its nearest SCROLLING ancestor, so a clipping one becomes that ' +
|
|
62
|
+
'ancestor and the node pins inside a box that never scrolls.',
|
|
63
|
+
stuck_no_host: 'Pin something: sb_set id "<id>" (or the section it lives in), namespace style, keys ' +
|
|
64
|
+
'{ "position": "sticky" } — sb_set seeds the offset and the layer order with it. Until ' +
|
|
65
|
+
'then the platform compiles no rule for this state at all, so the override is stored, ' +
|
|
66
|
+
'published and never painted. To drop it instead, remove the stuck slot.',
|
|
57
67
|
};
|
|
58
68
|
/** The template for `code`, with every `<name>` in `vars` substituted. */
|
|
59
69
|
export function fill(code, vars) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { walk } from '../../core/tree.js';
|
|
2
|
+
import { stickySeeds } from './sticky.js';
|
|
2
3
|
/** Style keys read off a node, ignoring anything unset. */
|
|
3
4
|
function styleOf(n) {
|
|
4
5
|
return (n?.style ?? {});
|
|
@@ -125,6 +126,26 @@ function one(c, t) {
|
|
|
125
126
|
const text = c.text?.trim();
|
|
126
127
|
if (!text)
|
|
127
128
|
return null;
|
|
129
|
+
// TWO KINDS OF LINK, and giving them one look was wrong in both
|
|
130
|
+
// directions. Painting every link produced 38 pink pills out of a
|
|
131
|
+
// documentation sidebar; dropping the unpainted ones lost a whole page of
|
|
132
|
+
// story titles. A call to action takes the target's FILL; a link takes its
|
|
133
|
+
// accent as INK and nothing else, which is the platform's own idiom for a
|
|
134
|
+
// link (a button carrying href, styled flat).
|
|
135
|
+
if (c.variant === 'link') {
|
|
136
|
+
return {
|
|
137
|
+
type: 'button',
|
|
138
|
+
specials: { text, ...(c.href ? { href: c.href } : {}) },
|
|
139
|
+
style: {
|
|
140
|
+
width: 'fit-content',
|
|
141
|
+
backgroundColor: 'transparent',
|
|
142
|
+
border: 'none',
|
|
143
|
+
padding: '0',
|
|
144
|
+
fontWeight: '500',
|
|
145
|
+
...(t.buttonBg ? { color: t.buttonBg } : {}),
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
128
149
|
return {
|
|
129
150
|
type: 'button',
|
|
130
151
|
specials: { text, ...(c.href ? { href: c.href } : {}) },
|
|
@@ -157,6 +178,40 @@ function one(c, t) {
|
|
|
157
178
|
})),
|
|
158
179
|
};
|
|
159
180
|
}
|
|
181
|
+
case 'group': {
|
|
182
|
+
const kids = (c.children ?? []).map((k) => one(k, t)).filter((n) => n !== null);
|
|
183
|
+
if (kids.length === 0)
|
|
184
|
+
return null;
|
|
185
|
+
if (kids.length === 1)
|
|
186
|
+
return kids[0];
|
|
187
|
+
// A ROW OF TWO OR MORE COLUMNS NEEDS AN EXPLICIT STACK BREAKPOINT, and
|
|
188
|
+
// nothing catches it for you: the columns SHRINK to fit, so no box
|
|
189
|
+
// overflows and `measure` stays silent while a photo becomes a sliver and
|
|
190
|
+
// a label truncates mid-word. Rule 3 of the design skill, and an import is
|
|
191
|
+
// the one place a row arrives without anybody having thought about 390.
|
|
192
|
+
//
|
|
193
|
+
// The wide answer is said out loud at base too, because the cascade
|
|
194
|
+
// resolves narrower slots LAST but does consult them: a `column` written
|
|
195
|
+
// only at mobile would otherwise reach desktop whenever base declares
|
|
196
|
+
// nothing.
|
|
197
|
+
return {
|
|
198
|
+
type: 'flex-block',
|
|
199
|
+
style: {
|
|
200
|
+
width: '100%',
|
|
201
|
+
display: 'flex',
|
|
202
|
+
flexDirection: 'row',
|
|
203
|
+
flexWrap: c.wrap ? 'wrap' : 'nowrap',
|
|
204
|
+
alignItems: 'flex-start',
|
|
205
|
+
gap: '24px',
|
|
206
|
+
},
|
|
207
|
+
responsive: { mobile: { style: { flexDirection: 'column', gap: '16px' } } },
|
|
208
|
+
children: kids.map((k) => ({
|
|
209
|
+
type: 'flex-block',
|
|
210
|
+
style: { flex: '1 1 280px', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '12px' },
|
|
211
|
+
children: [k],
|
|
212
|
+
})),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
160
215
|
case 'section': {
|
|
161
216
|
const kids = (c.children ?? []).map((k) => one(k, t)).filter((n) => n !== null);
|
|
162
217
|
if (kids.length === 0)
|
|
@@ -165,6 +220,20 @@ function one(c, t) {
|
|
|
165
220
|
// inner block is what carries the page's own measure — an imported band
|
|
166
221
|
// that runs the full window width on a page whose sections are 1200 reads
|
|
167
222
|
// as a different site even when every colour matches.
|
|
223
|
+
// A PINNED SECTION ARRIVES WITH ALL THREE KEYS, never just `position`.
|
|
224
|
+
// The offset and the layer order are not decoration: measured in Chromium
|
|
225
|
+
// by the platform, a pinned section with no z-index is painted OVER by any
|
|
226
|
+
// `position: relative` element in a later section the moment it scrolls
|
|
227
|
+
// past, which reads as the import having produced a broken band rather
|
|
228
|
+
// than as a stacking question nobody answered. `stickySeeds` is the one
|
|
229
|
+
// place that says what they are, so this cannot drift from `sb_set`.
|
|
230
|
+
//
|
|
231
|
+
// `fixed` is carried as the source declared it, seeds and all — a fixed
|
|
232
|
+
// bar with no offset would sit wherever the flow left it, which on an
|
|
233
|
+
// imported page is not where the source had it.
|
|
234
|
+
const pin = c.pinned
|
|
235
|
+
? { position: c.pinned, ...stickySeeds(undefined, { position: 'sticky' }) }
|
|
236
|
+
: {};
|
|
168
237
|
return {
|
|
169
238
|
type: 'flex-section',
|
|
170
239
|
style: {
|
|
@@ -173,6 +242,7 @@ function one(c, t) {
|
|
|
173
242
|
flexDirection: 'column',
|
|
174
243
|
alignItems: 'center',
|
|
175
244
|
...(t.sectionPadding ? { padding: t.sectionPadding } : { padding: '64px 24px' }),
|
|
245
|
+
...pin,
|
|
176
246
|
},
|
|
177
247
|
children: [
|
|
178
248
|
{
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
import { ELEMENTS, SATELLITE_RULES } from '../../catalog/elements.generated.js';
|
|
2
2
|
import { genId } from './ids.js';
|
|
3
|
+
/**
|
|
4
|
+
* The element's own per-breakpoint defaults with the caller's laid over them.
|
|
5
|
+
*
|
|
6
|
+
* Per NAMESPACE, not per slot: an element that seeds `mobile.config.iconSize`
|
|
7
|
+
* and a caller who asks for `mobile.style.flexDirection` must end with both.
|
|
8
|
+
* Replacing the slot would drop the one nobody mentioned, which is the quiet
|
|
9
|
+
* kind of loss this repo keeps finding.
|
|
10
|
+
*/
|
|
11
|
+
function mergeResponsive(base, over) {
|
|
12
|
+
if (!over)
|
|
13
|
+
return base;
|
|
14
|
+
const out = { ...base };
|
|
15
|
+
for (const [bp, slot] of Object.entries(over)) {
|
|
16
|
+
out[bp] = {
|
|
17
|
+
...(out[bp] ?? {}),
|
|
18
|
+
...(slot.style ? { style: { ...(out[bp]?.style ?? {}), ...slot.style } } : {}),
|
|
19
|
+
...(slot.config ? { config: { ...(out[bp]?.config ?? {}), ...slot.config } } : {}),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
3
24
|
/** Structured clone via JSON — the defaults are plain data, and this is what
|
|
4
25
|
* stops two nodes of the same type sharing one nested object. */
|
|
5
26
|
function copy(v) {
|
|
@@ -73,7 +94,7 @@ export function createNode(type, opts = {}) {
|
|
|
73
94
|
style: { ...copy(d.style ?? {}), ...(opts.style ?? {}) },
|
|
74
95
|
config,
|
|
75
96
|
specials: { ...copy(d.specials ?? {}), ...(opts.specials ?? {}) },
|
|
76
|
-
responsive: copy(d.responsive ?? {}),
|
|
97
|
+
responsive: mergeResponsive(copy(d.responsive ?? {}), opts.responsive),
|
|
77
98
|
...(states ? { states: copy(states) } : {}),
|
|
78
99
|
events: [],
|
|
79
100
|
// SEED THE ELEMENT'S OWN BINDINGS.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { childrenOf, childrenWithSatellites, isOverlay, pageChildren, appBlockRoot, SPEC_GLOBAL_REF, SPEC_APP_BLOCK_REF } from '../../core/tree.js';
|
|
2
|
+
import { STUCK_STATE, stickyBlockedBy, stuckHostOf, isPinnedNode } from './sticky.js';
|
|
2
3
|
import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
|
|
3
4
|
import { fill } from './findings.js';
|
|
4
5
|
/**
|
|
@@ -188,6 +189,44 @@ export function reviewDesign(doc) {
|
|
|
188
189
|
});
|
|
189
190
|
continue;
|
|
190
191
|
}
|
|
192
|
+
// PINNING FAILS WITH NOTHING ON SCREEN AND NOTHING IN THE LOG — both halves
|
|
193
|
+
// of it — which is exactly what a review is for. `sb_set` refuses the second
|
|
194
|
+
// case at write time, but a document reaches here by other roads: an import,
|
|
195
|
+
// a template, or a later edit that un-pinned the host and left the overrides
|
|
196
|
+
// behind.
|
|
197
|
+
const styled = n;
|
|
198
|
+
if (isPinnedNode(n)) {
|
|
199
|
+
const blocker = stickyBlockedBy(d, id);
|
|
200
|
+
// Sticky only: a `fixed` element is positioned against the viewport and an
|
|
201
|
+
// overflow ancestor cannot take that away from it.
|
|
202
|
+
const sticky = String(styled.style?.position) === 'sticky' ||
|
|
203
|
+
Object.values(styled.responsive ?? {}).some((r) => String(r?.style?.position) === 'sticky');
|
|
204
|
+
if (blocker && sticky) {
|
|
205
|
+
out.push({
|
|
206
|
+
code: 'sticky_blocked',
|
|
207
|
+
nodeId: id,
|
|
208
|
+
type,
|
|
209
|
+
problem: `Pinned, but ancestor "${blocker}" clips its overflow — so that ancestor becomes ` +
|
|
210
|
+
'the scroll container this node pins inside, and it never scrolls. The node does ' +
|
|
211
|
+
'not move, and nothing reports it.',
|
|
212
|
+
key: blocker,
|
|
213
|
+
fix: fill('sticky_blocked', { id, key: blocker }),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const hasStuck = styled.states?.[STUCK_STATE] !== undefined ||
|
|
218
|
+
Object.values(styled.responsive ?? {}).some((r) => r?.states?.[STUCK_STATE] !== undefined);
|
|
219
|
+
if (hasStuck && !stuckHostOf(d, id)) {
|
|
220
|
+
out.push({
|
|
221
|
+
code: 'stuck_no_host',
|
|
222
|
+
nodeId: id,
|
|
223
|
+
type,
|
|
224
|
+
problem: 'Carries a "stuck" override, but neither it nor any ancestor can pin — so the ' +
|
|
225
|
+
'renderer emits no rule for it. The styling is stored, saved and published, and ' +
|
|
226
|
+
'never painted.',
|
|
227
|
+
fix: fill('stuck_no_host', { id }),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
191
230
|
const bindings = n.bindings ?? [];
|
|
192
231
|
// A container with nothing in it is a band of empty space. The commonest way
|
|
193
232
|
// to ship one is to add the section and then get distracted.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { ancestors } from '../../core/tree.js';
|
|
2
|
+
/**
|
|
3
|
+
* PINNING, AND THE STATE A PINNED ELEMENT WEARS ONCE IT IS STUCK.
|
|
4
|
+
*
|
|
5
|
+
* The platform gained this in September 2026 (`schema/src/stickyState.ts`, Go
|
|
6
|
+
* mirror `server/render/style/sticky.go`), and every part of it fails SILENTLY
|
|
7
|
+
* when authored wrong — which is why it needs a module here rather than a note.
|
|
8
|
+
*
|
|
9
|
+
* `position: sticky` changes nothing observable when it engages: CSS has no
|
|
10
|
+
* `:stuck` pseudo-class. So the platform toggles ONE class (`wb-stuck`) on the
|
|
11
|
+
* pinned element from a runtime island, and compiles every rule an author wrote
|
|
12
|
+
* for the pinned look against it — the node itself as `#self.wb-stuck`, a
|
|
13
|
+
* DESCENDANT as `#host.wb-stuck #self`, which is what lets a pinned header
|
|
14
|
+
* shrink its logo without the logo knowing what pinned it.
|
|
15
|
+
*
|
|
16
|
+
* Three things about it are load-bearing here, and each is a write that
|
|
17
|
+
* disappears without one:
|
|
18
|
+
*
|
|
19
|
+
* 1. THE STATE NEEDS A HOST. `render/css.go` emits the stuck rules inside
|
|
20
|
+
* `if stuckHost := stuckHostFor(...); stuckHost != ""`. A `stuck` override
|
|
21
|
+
* on a node with no pinned self-or-ancestor compiles to NOTHING — stored,
|
|
22
|
+
* saved, published and ignored forever, the same shape as a binding in a
|
|
23
|
+
* namespace `applyBindings` does not honour.
|
|
24
|
+
* 2. THE SEEDS ARE NOT COSMETIC. The editor writes `top: 0px` and
|
|
25
|
+
* `zIndex: 10` the moment an author picks "Stick on scroll", and the
|
|
26
|
+
* platform's own commit records why the second one exists: measured in
|
|
27
|
+
* Chromium, a pinned header with no z-index is painted OVER by any
|
|
28
|
+
* `position: relative` element in a later section the moment it scrolls
|
|
29
|
+
* past. An agent writing `position: sticky` through `sb_set` reaches none
|
|
30
|
+
* of that, so its first sticky header half-works in the way that reads as
|
|
31
|
+
* the feature being broken.
|
|
32
|
+
* 3. A CLIPPING ANCESTOR DEFEATS IT ENTIRELY. Sticky resolves against its
|
|
33
|
+
* nearest SCROLLING ancestor, so an ancestor with `overflow: hidden|auto|
|
|
34
|
+
* scroll|clip|overlay` becomes that ancestor and the node pins inside a box
|
|
35
|
+
* that never scrolls. Nothing on screen and nothing in the log explains it.
|
|
36
|
+
*/
|
|
37
|
+
/** The state key, in `node.states` and `responsive[bp].states`. */
|
|
38
|
+
export const STUCK_STATE = 'stuck';
|
|
39
|
+
/**
|
|
40
|
+
* The positions that HAVE a pinned moment.
|
|
41
|
+
*
|
|
42
|
+
* `fixed` earns its place for a different reason than `sticky`: it is pinned
|
|
43
|
+
* from the first frame, so "the moment it pins" never happens to it — but "the
|
|
44
|
+
* moment the page has scrolled past where it would have been" does, and that is
|
|
45
|
+
* the same design an author reaches for (the fixed header that goes opaque once
|
|
46
|
+
* the hero is behind it). `absolute` is deliberately absent: it scrolls away
|
|
47
|
+
* with the page, so it has no relationship to the scroll position at all.
|
|
48
|
+
*/
|
|
49
|
+
const PINNED_POSITIONS = new Set(['sticky', 'fixed']);
|
|
50
|
+
/**
|
|
51
|
+
* Overflow values that make an ancestor a scroll container — an ALLOWLIST of
|
|
52
|
+
* the clipping ones, not `!== 'visible'`, so an unknown or misspelled value
|
|
53
|
+
* never produces a warning nobody can act on. Mirrors the editor's
|
|
54
|
+
* `dnd/stickyBlockers.ts`.
|
|
55
|
+
*/
|
|
56
|
+
const CLIPPING = new Set(['hidden', 'auto', 'scroll', 'clip', 'overlay']);
|
|
57
|
+
const BREAKPOINTS = ['desktop', 'laptop', 'tablet', 'mobile'];
|
|
58
|
+
function styleAt(node, bp) {
|
|
59
|
+
if (!node)
|
|
60
|
+
return {};
|
|
61
|
+
return (bp ? node.responsive?.[bp]?.style : node.style) ?? {};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Does any slot on this node ask for a position that can pin?
|
|
65
|
+
*
|
|
66
|
+
* Breakpoint-agnostic on purpose, exactly as the platform's `isPinnedNode` is: a
|
|
67
|
+
* header pinned only on desktop still compiles its stuck rules at every width —
|
|
68
|
+
* they simply never match, because the class only appears while the element is
|
|
69
|
+
* genuinely stuck, which the island re-decides per viewport.
|
|
70
|
+
*/
|
|
71
|
+
export function isPinnedNode(node) {
|
|
72
|
+
if (!node)
|
|
73
|
+
return false;
|
|
74
|
+
if (PINNED_POSITIONS.has(String(styleAt(node).position)))
|
|
75
|
+
return true;
|
|
76
|
+
return BREAKPOINTS.some((bp) => PINNED_POSITIONS.has(String(styleAt(node, bp).position)));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The pinned self-or-ancestor whose `wb-stuck` class this node's stuck rules
|
|
80
|
+
* would key off, or null. Nearest wins; a pinned node answers with itself.
|
|
81
|
+
*/
|
|
82
|
+
export function stuckHostOf(doc, id) {
|
|
83
|
+
if (isPinnedNode(doc.nodes[id]))
|
|
84
|
+
return id;
|
|
85
|
+
for (const up of ancestors(doc, id)) {
|
|
86
|
+
if (isPinnedNode(doc.nodes[up]))
|
|
87
|
+
return up;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The nearest ancestor whose overflow stops a sticky node from working, or null.
|
|
93
|
+
*
|
|
94
|
+
* Starts at the PARENT: a sticky element's own overflow clips its children, not
|
|
95
|
+
* itself, and blaming it would send the caller to fix a property that is not the
|
|
96
|
+
* problem. Reads base overlaid with `bp` — the cascade's own order for a single
|
|
97
|
+
* key — so a clip declared only at mobile is named only when writing mobile.
|
|
98
|
+
*/
|
|
99
|
+
export function stickyBlockedBy(doc, id, bp) {
|
|
100
|
+
for (const up of ancestors(doc, id)) {
|
|
101
|
+
const node = doc.nodes[up];
|
|
102
|
+
if (!node)
|
|
103
|
+
return null;
|
|
104
|
+
const s = { ...styleAt(node), ...(bp ? styleAt(node, bp) : {}) };
|
|
105
|
+
if (CLIPPING.has(String(s.overflowX)) || CLIPPING.has(String(s.overflowY)))
|
|
106
|
+
return up;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The keys the editor writes alongside `position: sticky`, for the same reasons
|
|
112
|
+
* — offered here only when the caller has not answered them and the node does
|
|
113
|
+
* not already, so an explicit choice is never overwritten.
|
|
114
|
+
*
|
|
115
|
+
* `top` is skipped when the same call pins to another edge: the inspector writes
|
|
116
|
+
* one key at a time and cannot have that case, an agent writing
|
|
117
|
+
* `{ position: 'sticky', bottom: '0px' }` in one object plainly can, and seeding
|
|
118
|
+
* `top: 0` over it would pin a bottom bar to the ceiling.
|
|
119
|
+
*
|
|
120
|
+
* `10` for the layer order is a specific number, not a large one: it sits above
|
|
121
|
+
* ordinary page content and BELOW the overlay ladder the static CSS owns (cart
|
|
122
|
+
* scrim 40, drawer 41, pop-up scrim 50, pop-up 51). A header that outranked
|
|
123
|
+
* those would cover the drawer it opens.
|
|
124
|
+
*/
|
|
125
|
+
export function stickySeeds(node, keys, bp) {
|
|
126
|
+
if (String(keys.position) !== 'sticky')
|
|
127
|
+
return {};
|
|
128
|
+
const merged = { ...styleAt(node), ...(bp ? styleAt(node, bp) : {}), ...keys };
|
|
129
|
+
const seeds = {};
|
|
130
|
+
if (merged.top === undefined && merged.bottom === undefined)
|
|
131
|
+
seeds.top = '0px';
|
|
132
|
+
if (merged.zIndex === undefined)
|
|
133
|
+
seeds.zIndex = '10';
|
|
134
|
+
return seeds;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* What a stuck state may carry in `config`.
|
|
138
|
+
*
|
|
139
|
+
* `stuckDecls` reads the style slot whole and translates exactly ONE config key
|
|
140
|
+
* — `hidden` — into `display: none`, because a state has no bands and no cascade
|
|
141
|
+
* of its own, so a declaration is the only shape available. Every other config
|
|
142
|
+
* key in a stuck slot is stored by the document and read by no compiler.
|
|
143
|
+
*
|
|
144
|
+
* And only `true` is honoured: `false` would have to mean "show it again while
|
|
145
|
+
* pinned", which needs `display: revert`, and the platform's `render/css.go`
|
|
146
|
+
* documents at length why revert is wrong here — it rolls the property back past
|
|
147
|
+
* the element's own static CSS and lands on the UA default. So OFF means "no
|
|
148
|
+
* override", i.e. remove the key.
|
|
149
|
+
*/
|
|
150
|
+
export function refuseStuckConfig(keys) {
|
|
151
|
+
const stray = Object.keys(keys).filter((k) => k !== 'hidden');
|
|
152
|
+
if (stray.length) {
|
|
153
|
+
throw new Error(`sbuilder: the stuck state translates exactly one config key — "hidden" — into a ` +
|
|
154
|
+
`declaration (display:none). ${stray.map((k) => `"${k}"`).join(', ')} would be stored ` +
|
|
155
|
+
'and read by no compiler. A state has no bands and no cascade, so style is what it ' +
|
|
156
|
+
'paints: write these in the style namespace, or at base if they are not stuck-specific.');
|
|
157
|
+
}
|
|
158
|
+
if ('hidden' in keys && keys.hidden !== true) {
|
|
159
|
+
throw new Error('sbuilder: stuck config.hidden takes only true. false would have to mean "show it again ' +
|
|
160
|
+
'while pinned", which needs display:revert — wrong here, because revert rolls past the ' +
|
|
161
|
+
"element's own static CSS to the UA default. To stop hiding it, remove the override.");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Refuse a stuck override that no selector would ever match.
|
|
166
|
+
*
|
|
167
|
+
* The message names the fix rather than the rule, because the caller who lands
|
|
168
|
+
* here is one step from the design they wanted: pin this node, or pin the
|
|
169
|
+
* section it lives in, and the same override starts painting.
|
|
170
|
+
*/
|
|
171
|
+
export function requireStuckHost(doc, id) {
|
|
172
|
+
if (stuckHostOf(doc, id))
|
|
173
|
+
return;
|
|
174
|
+
const up = ancestors(doc, id)[0];
|
|
175
|
+
throw new Error(`sbuilder: "${STUCK_STATE}" is the state a PINNED element wears once it is stuck, and ` +
|
|
176
|
+
`neither ${id} nor any ancestor can pin — so the platform compiles no rule for it ` +
|
|
177
|
+
'(render/css.go emits stuck CSS only under a stuck host). The override would be stored, ' +
|
|
178
|
+
'saved, published and never painted. Pin the element that scrolls first: ' +
|
|
179
|
+
`sb_set ${up ?? id} style { position: "sticky" } — a descendant then styles itself ` +
|
|
180
|
+
'through the host, so this node needs no position of its own.');
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The warning a sticky node earns when an ancestor's overflow will defeat it.
|
|
184
|
+
*
|
|
185
|
+
* Only for `sticky`. A `fixed` element is positioned against the viewport and
|
|
186
|
+
* an overflow ancestor cannot take that away from it — the ancestors that CAN
|
|
187
|
+
* (a `transform`, a `filter`, a `contain`) are a different question this does
|
|
188
|
+
* not pretend to answer.
|
|
189
|
+
*
|
|
190
|
+
* A warning rather than a refusal, because the clip may be the deliberate half:
|
|
191
|
+
* the author may be about to remove it, or may mean the node to pin inside a
|
|
192
|
+
* scrolling panel. Silence is the only wrong answer — the failure shows up as
|
|
193
|
+
* nothing on screen and nothing in the log.
|
|
194
|
+
*/
|
|
195
|
+
export function stickyWarning(doc, id, bp) {
|
|
196
|
+
const node = doc.nodes[id];
|
|
197
|
+
const sticky = String(styleAt(node).position) === 'sticky' ||
|
|
198
|
+
BREAKPOINTS.some((b) => String(styleAt(node, b).position) === 'sticky');
|
|
199
|
+
if (!sticky)
|
|
200
|
+
return null;
|
|
201
|
+
const blocker = stickyBlockedBy(doc, id, bp);
|
|
202
|
+
if (!blocker)
|
|
203
|
+
return null;
|
|
204
|
+
return (`${id} is sticky, but ancestor ${blocker} clips its overflow (hidden/auto/scroll/clip/` +
|
|
205
|
+
'overlay), so it becomes the scroll container this node pins inside — a box that never ' +
|
|
206
|
+
`scrolls. The node will not move. Clear overflowX/overflowY on ${blocker}, or pin a node ` +
|
|
207
|
+
'outside it.');
|
|
208
|
+
}
|
package/dist/tools/importpage.js
CHANGED
|
@@ -41,6 +41,13 @@ export function registerImportTools(server, ctx, session) {
|
|
|
41
41
|
.max(100)
|
|
42
42
|
.optional()
|
|
43
43
|
.describe('Default 24 — every image is an upload'),
|
|
44
|
+
max_nodes: z
|
|
45
|
+
.number()
|
|
46
|
+
.int()
|
|
47
|
+
.min(1)
|
|
48
|
+
.max(1000)
|
|
49
|
+
.optional()
|
|
50
|
+
.describe('Default 300 — the bound on the whole import'),
|
|
44
51
|
upload_images: z
|
|
45
52
|
.boolean()
|
|
46
53
|
.optional()
|
|
@@ -48,13 +55,17 @@ export function registerImportTools(server, ctx, session) {
|
|
|
48
55
|
dry_run: z.boolean().optional(),
|
|
49
56
|
},
|
|
50
57
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
51
|
-
}, async ({ url, site_id: given, max_sections, max_images, upload_images, dry_run }) => {
|
|
58
|
+
}, async ({ url, site_id: given, max_sections, max_images, max_nodes, upload_images, dry_run }) => {
|
|
52
59
|
const siteId = siteFor(ctx, given);
|
|
53
60
|
// THE TARGET PAGE MUST BE OPEN, and not only because that is where the
|
|
54
61
|
// nodes go: its own heading, button and section are where the tokens come
|
|
55
62
|
// from, so an import with no open page is an import with no design.
|
|
56
63
|
const doc = session.current();
|
|
57
|
-
const shot = await capture(url, {
|
|
64
|
+
const shot = await capture(url, {
|
|
65
|
+
maxSections: max_sections,
|
|
66
|
+
maxImages: max_images,
|
|
67
|
+
maxNodes: max_nodes,
|
|
68
|
+
});
|
|
58
69
|
const tokens = tokensFromPage(doc.doc);
|
|
59
70
|
const images = imageSources(shot.sections);
|
|
60
71
|
if (dry_run !== false) {
|
|
@@ -78,11 +89,16 @@ export function registerImportTools(server, ctx, session) {
|
|
|
78
89
|
const rehosted = new Map();
|
|
79
90
|
// WHY it failed, not just how many. Four images refused for the same
|
|
80
91
|
// reason is ONE thing to fix, and a bare count is the shape that sends a
|
|
81
|
-
// caller to re-run the import hoping for a different answer.
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
92
|
+
// caller to re-run the import hoping for a different answer.
|
|
93
|
+
//
|
|
94
|
+
// The measurement that proved it worth having also proved a CONCLUSION
|
|
95
|
+
// WRONG. A real import lost all four images to `only image, video, or font
|
|
96
|
+
// (woff2/woff/ttf/otf) uploads are supported`, and that was written up here
|
|
97
|
+
// as the platform refusing SVG. It does not refuse SVG: it accepts any
|
|
98
|
+
// declared `image/*`, and `image/svg+xml` is one. `uploadMedia` was sending
|
|
99
|
+
// a typeless Blob, so EVERY url upload arrived as `application/octet-stream`
|
|
100
|
+
// — a PNG was refused by the same message. A reason carried verbatim is
|
|
101
|
+
// what makes a wrong reading of it findable.
|
|
86
102
|
const failures = new Map();
|
|
87
103
|
if (upload_images !== false) {
|
|
88
104
|
for (const src of images) {
|
|
@@ -115,6 +131,11 @@ export function registerImportTools(server, ctx, session) {
|
|
|
115
131
|
return text({
|
|
116
132
|
read: shot.url,
|
|
117
133
|
added_sections: added,
|
|
134
|
+
// WHAT WAS LEFT BEHIND, on the real run too. The dry run said it and the
|
|
135
|
+
// real one did not, which is the wrong way round: a caller who skipped
|
|
136
|
+
// the preview is exactly the caller who needs to be told that 21 nodes
|
|
137
|
+
// hit the ceiling, or that the page's own header was dropped on purpose.
|
|
138
|
+
...(Object.keys(shot.skipped).length ? { skipped: shot.skipped } : {}),
|
|
118
139
|
images: {
|
|
119
140
|
copied: rehosted.size,
|
|
120
141
|
...(failed.length ? { failed } : {}),
|
package/dist/tools/page.js
CHANGED
|
@@ -13,6 +13,8 @@ import { readinessGaps, READINESS_NOTICE } from '../domains/site/readiness.js';
|
|
|
13
13
|
import { gatherReadiness } from '../domains/site/readiness-fetch.js';
|
|
14
14
|
import { globalWarning, restampPatches, RESPONSIVE_NOTICE } from '../domains/site/traps.js';
|
|
15
15
|
import { catalogMatches, traitsFor } from '../catalog/element-search.js';
|
|
16
|
+
import { applyPatches } from '../core/patch.js';
|
|
17
|
+
import { stickyWarning } from '../domains/site/sticky.js';
|
|
16
18
|
import { siteFor } from './context.js';
|
|
17
19
|
import { projectList, PAGE_FIELDS, TEMPLATE_FIELDS } from './project.js';
|
|
18
20
|
/**
|
|
@@ -262,7 +264,12 @@ export function registerPageTools(server, ctx) {
|
|
|
262
264
|
keys: z.record(z.unknown()).optional(),
|
|
263
265
|
breakpoint: z.enum(['desktop', 'laptop', 'tablet', 'mobile']).optional(),
|
|
264
266
|
base: z.boolean().optional(),
|
|
265
|
-
state: z
|
|
267
|
+
state: z
|
|
268
|
+
.string()
|
|
269
|
+
.optional()
|
|
270
|
+
.describe('An interaction state — "hover", or "stuck" for how a pinned element looks once ' +
|
|
271
|
+
'it is stuck (needs a sticky/fixed self-or-ancestor; a descendant styles itself ' +
|
|
272
|
+
'through the host).'),
|
|
266
273
|
edits: z
|
|
267
274
|
.array(z.object({
|
|
268
275
|
id: z.string(),
|
|
@@ -287,17 +294,46 @@ export function registerPageTools(server, ctx) {
|
|
|
287
294
|
batch.push({ id, namespace, keys, breakpoint: breakpoint, base, state });
|
|
288
295
|
}
|
|
289
296
|
const { patches, touched } = setMany(d, batch);
|
|
297
|
+
// THE STICKY WARNING IS COMPUTED AGAINST THE DOCUMENT AS IT WILL BE, so
|
|
298
|
+
// the dry run and the real run say the same thing. A caller who is told
|
|
299
|
+
// only after committing has already shipped a header that does not move.
|
|
300
|
+
const stuck = () => {
|
|
301
|
+
// Gated on the batch actually being able to change the answer, because
|
|
302
|
+
// a page document is hundreds of KB and sb_set is the hottest write
|
|
303
|
+
// there is: a clone on every call would tax every edit for a warning
|
|
304
|
+
// that fires on almost none. `position` and `overflow*` are the only
|
|
305
|
+
// two keys in the question.
|
|
306
|
+
const relevant = batch.some((e) => e.namespace === 'style' &&
|
|
307
|
+
('position' in e.keys || 'overflowX' in e.keys || 'overflowY' in e.keys));
|
|
308
|
+
const out = {};
|
|
309
|
+
if (!relevant)
|
|
310
|
+
return out;
|
|
311
|
+
const probe = JSON.parse(JSON.stringify(d.doc));
|
|
312
|
+
applyPatches(probe, patches);
|
|
313
|
+
for (const e of batch) {
|
|
314
|
+
const w = stickyWarning(probe, e.id, e.base ? undefined : (e.breakpoint ?? 'desktop'));
|
|
315
|
+
if (w)
|
|
316
|
+
out[e.id] = w;
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
};
|
|
290
320
|
if (dry_run !== false) {
|
|
291
321
|
const note = ctx.notices.once('responsive', RESPONSIVE_NOTICE);
|
|
292
|
-
|
|
322
|
+
const sw = stuck();
|
|
323
|
+
return text({
|
|
324
|
+
dry_run: true,
|
|
325
|
+
patches,
|
|
326
|
+
...(Object.keys(sw).length ? { warnings: sw } : {}),
|
|
327
|
+
...(note ? { note } : {}),
|
|
328
|
+
});
|
|
293
329
|
}
|
|
330
|
+
const warnings = stuck();
|
|
294
331
|
session.applyAndPublish(patches);
|
|
295
332
|
await session.save();
|
|
296
|
-
const warnings = {};
|
|
297
333
|
for (const t of touched) {
|
|
298
334
|
const w = globalWarning(d.doc, t.id);
|
|
299
335
|
if (w)
|
|
300
|
-
warnings[t.id] = w;
|
|
336
|
+
warnings[t.id] = warnings[t.id] ? `${warnings[t.id]} ${w}` : w;
|
|
301
337
|
}
|
|
302
338
|
if (!edits) {
|
|
303
339
|
const warn = warnings[batch[0].id];
|
package/dist/transport/media.js
CHANGED
|
@@ -15,10 +15,37 @@ import { siteToken } from '../tools/credentialpick.js';
|
|
|
15
15
|
*
|
|
16
16
|
* Node ≥22 has FormData, Blob and fetch as globals, so this needs no dependency.
|
|
17
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* A content type from the file NAME, for the local-path case and as the fallback
|
|
20
|
+
* when a server answers with nothing useful.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately small: the platform decides what it accepts, and duplicating its
|
|
23
|
+
* whole table here would be a second place for that policy to drift. These are
|
|
24
|
+
* the types a page actually carries.
|
|
25
|
+
*/
|
|
26
|
+
const TYPE_BY_EXT = {
|
|
27
|
+
'.png': 'image/png',
|
|
28
|
+
'.jpg': 'image/jpeg',
|
|
29
|
+
'.jpeg': 'image/jpeg',
|
|
30
|
+
'.gif': 'image/gif',
|
|
31
|
+
'.webp': 'image/webp',
|
|
32
|
+
'.avif': 'image/avif',
|
|
33
|
+
'.svg': 'image/svg+xml',
|
|
34
|
+
'.ico': 'image/x-icon',
|
|
35
|
+
'.bmp': 'image/bmp',
|
|
36
|
+
'.mp4': 'video/mp4',
|
|
37
|
+
'.webm': 'video/webm',
|
|
38
|
+
'.mov': 'video/quicktime',
|
|
39
|
+
};
|
|
40
|
+
function typeForName(name) {
|
|
41
|
+
const dot = name.lastIndexOf('.');
|
|
42
|
+
return dot < 0 ? '' : (TYPE_BY_EXT[name.slice(dot).toLowerCase()] ?? '');
|
|
43
|
+
}
|
|
18
44
|
export async function uploadMedia(ctx, siteId, source) {
|
|
19
45
|
const doFetch = ctx.fetchImpl ?? fetch;
|
|
20
46
|
let bytes;
|
|
21
47
|
let filename;
|
|
48
|
+
let declared = '';
|
|
22
49
|
if (source.path) {
|
|
23
50
|
bytes = await readFile(source.path);
|
|
24
51
|
filename = source.name ?? basename(source.path);
|
|
@@ -29,6 +56,8 @@ export async function uploadMedia(ctx, siteId, source) {
|
|
|
29
56
|
throw new ApiError(res.status, 'source_unreachable', `could not fetch ${source.url}`);
|
|
30
57
|
}
|
|
31
58
|
bytes = new Uint8Array(await res.arrayBuffer());
|
|
59
|
+
// The SOURCE'S OWN answer first — it is the only party that actually knows.
|
|
60
|
+
declared = (res.headers.get('content-type') ?? '').split(';')[0].trim();
|
|
32
61
|
// A URL's last segment is usually the filename; when it is not (a query-only
|
|
33
62
|
// CDN link), name it rather than uploading something called "".
|
|
34
63
|
filename = source.name ?? (new URL(source.url).pathname.split('/').pop() || 'image');
|
|
@@ -36,8 +65,25 @@ export async function uploadMedia(ctx, siteId, source) {
|
|
|
36
65
|
else {
|
|
37
66
|
throw new Error('sbuilder: give sb_media_upload either a local path or a url');
|
|
38
67
|
}
|
|
68
|
+
// THE HEADER ONLY WINS WHEN IT SAYS SOMETHING. A CDN answering
|
|
69
|
+
// `application/octet-stream` for a PNG is ordinary, and it is exactly the
|
|
70
|
+
// value the platform refuses — so "the server declared a type" is not the
|
|
71
|
+
// question; "the server declared a type that identifies the file" is.
|
|
72
|
+
const usable = declared.startsWith('image/') || declared.startsWith('video/');
|
|
73
|
+
const type = (usable ? declared : '') || typeForName(filename) || declared;
|
|
39
74
|
const form = new FormData();
|
|
40
|
-
|
|
75
|
+
// THE BLOB'S TYPE IS THE UPLOAD'S CONTENT TYPE, and omitting it broke every
|
|
76
|
+
// upload this function ever made from a URL. A typeless Blob is sent as
|
|
77
|
+
// `application/octet-stream`, the platform accepts a file whose declared type
|
|
78
|
+
// starts with `image/` or `video/` (or whose EXTENSION is a known font or
|
|
79
|
+
// document), and octet-stream is none of those — so a PNG fetched from a URL
|
|
80
|
+
// was refused with "only image, video, or font uploads are supported", which
|
|
81
|
+
// reads as a policy about the FILE and is really a bug in this line.
|
|
82
|
+
//
|
|
83
|
+
// It cost a wrong conclusion too: the same refusal on an SVG was written up
|
|
84
|
+
// here as "the platform deliberately refuses SVG". It does not — it accepts
|
|
85
|
+
// any `image/*`, and `image/svg+xml` is one.
|
|
86
|
+
form.set('file', new Blob([bytes], type ? { type } : undefined), filename);
|
|
41
87
|
if (source.name)
|
|
42
88
|
form.set('name', source.name);
|
|
43
89
|
if (source.folderId)
|
package/dist/vision/capture.js
CHANGED
|
@@ -13,7 +13,7 @@ function capturePage(limits) {
|
|
|
13
13
|
// page, as `ReferenceError: HEADINGS is not defined`, by which point the file
|
|
14
14
|
// already carried a comment saying exactly that.
|
|
15
15
|
const HEADINGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
|
|
16
|
-
const taken = { images: 0 };
|
|
16
|
+
const taken = { images: 0, nodes: 0 };
|
|
17
17
|
const skipped = {};
|
|
18
18
|
const skip = (why) => void (skipped[why] = (skipped[why] ?? 0) + 1);
|
|
19
19
|
const here = location.href;
|
|
@@ -67,73 +67,164 @@ function capturePage(limits) {
|
|
|
67
67
|
'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'PATH', 'IFRAME', 'CANVAS',
|
|
68
68
|
'NAV', 'FORM', 'INPUT', 'SELECT', 'TEXTAREA', 'BUTTON',
|
|
69
69
|
]);
|
|
70
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* The renderable content under one section, AS A TREE.
|
|
72
|
+
*
|
|
73
|
+
* It used to return a flat list, and flatness was the biggest thing an import
|
|
74
|
+
* lost. A source's three-column feature row came back as three stacked blocks;
|
|
75
|
+
* a card — image, heading, copy, button — came back as four siblings with
|
|
76
|
+
* nothing saying they belonged together. Everything a reader understands from
|
|
77
|
+
* the ARRANGEMENT was thrown away, and no amount of correct colour brings it
|
|
78
|
+
* back.
|
|
79
|
+
*
|
|
80
|
+
* So a container that actually lays its children out — `display:flex` or
|
|
81
|
+
* `grid` — and has two or more of them that produced something becomes a
|
|
82
|
+
* GROUP carrying its direction. Anything else flattens, because a `<div>` that
|
|
83
|
+
* merely wraps is not a design decision and reproducing it would nest the
|
|
84
|
+
* result ten deep for nothing.
|
|
85
|
+
*/
|
|
71
86
|
const leaves = (root) => {
|
|
72
|
-
const
|
|
87
|
+
const walkChildren = (el) => {
|
|
88
|
+
const kids = [];
|
|
89
|
+
for (const child of Array.from(el.children)) {
|
|
90
|
+
for (const c of walk(child))
|
|
91
|
+
kids.push(c);
|
|
92
|
+
}
|
|
93
|
+
return kids;
|
|
94
|
+
};
|
|
73
95
|
const walk = (el) => {
|
|
74
|
-
|
|
75
|
-
|
|
96
|
+
// ONE BOUND, on the whole import. There used to be a second, per section,
|
|
97
|
+
// and it kept doing the same wrong job under a new number: a page whose
|
|
98
|
+
// <body> has a single child is ONE section, so the per-section cap became
|
|
99
|
+
// the page cap and truncated it — news.ycombinator.com captured 84 links
|
|
100
|
+
// and 45 lines and lost the rest at exactly 120. Two limits for one
|
|
101
|
+
// quantity means the tighter one is always the real limit, and nobody
|
|
102
|
+
// remembers which that is.
|
|
103
|
+
if (taken.nodes >= limits.maxNodes) {
|
|
104
|
+
skip('over-node-limit');
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
76
107
|
const tag = el.tagName;
|
|
77
108
|
if (IGNORE.has(tag)) {
|
|
78
109
|
skip(tag.toLowerCase());
|
|
79
|
-
return;
|
|
110
|
+
return [];
|
|
80
111
|
}
|
|
81
112
|
if (!visible(el)) {
|
|
82
113
|
skip('hidden');
|
|
83
|
-
return;
|
|
114
|
+
return [];
|
|
84
115
|
}
|
|
85
116
|
if (HEADINGS.has(tag)) {
|
|
86
117
|
const text = clean(el.textContent);
|
|
87
|
-
if (text)
|
|
88
|
-
|
|
89
|
-
|
|
118
|
+
if (!text)
|
|
119
|
+
return [];
|
|
120
|
+
taken.nodes++;
|
|
121
|
+
return [{ kind: 'heading', level: Number(tag.slice(1)), text }];
|
|
90
122
|
}
|
|
91
123
|
if (tag === 'IMG') {
|
|
92
124
|
const src = el.getAttribute('src');
|
|
93
125
|
if (!src || src.startsWith('data:')) {
|
|
94
126
|
skip('image-without-src');
|
|
95
|
-
return;
|
|
127
|
+
return [];
|
|
96
128
|
}
|
|
97
|
-
// BOUNDED, because every image is an upload. A sponsors wall is a real
|
|
98
|
-
// page shape — one measured at 36 logos in four sections — and importing
|
|
99
|
-
// it means 36 sequential HTTP round trips inside a single tool call,
|
|
100
|
-
// which is slow, half-fails in interesting ways, and is almost never
|
|
101
|
-
// what the caller wanted from "import this page".
|
|
102
129
|
if (taken.images >= limits.maxImages) {
|
|
103
130
|
skip('over-image-limit');
|
|
104
|
-
return;
|
|
131
|
+
return [];
|
|
105
132
|
}
|
|
106
133
|
taken.images++;
|
|
107
|
-
|
|
108
|
-
return;
|
|
134
|
+
taken.nodes++;
|
|
135
|
+
return [{ kind: 'image', src: abs(src), alt: clean(el.getAttribute('alt')) }];
|
|
109
136
|
}
|
|
110
|
-
if (tag === 'A'
|
|
137
|
+
if (tag === 'A') {
|
|
111
138
|
const text = clean(el.textContent);
|
|
112
|
-
|
|
139
|
+
// A LINK THAT IS NOT A BUTTON IS STILL A LINK. It used to contribute
|
|
140
|
+
// NOTHING, and on a page whose content IS a list of links that is the
|
|
141
|
+
// whole page: news.ycombinator.com lost 1,595 characters of story titles
|
|
142
|
+
// and bylines that way. The platform has no inline-link element — its
|
|
143
|
+
// own idiom is a `button` carrying `href`, styled flat — so that is what
|
|
144
|
+
// an unpainted link becomes, and the variant is what keeps it from
|
|
145
|
+
// arriving as a call to action.
|
|
146
|
+
if (text && el.children.length === 0) {
|
|
147
|
+
const href = el.getAttribute('href');
|
|
148
|
+
taken.nodes++;
|
|
149
|
+
return [{
|
|
150
|
+
kind: 'button',
|
|
151
|
+
variant: looksLikeButton(el) ? 'cta' : 'link',
|
|
152
|
+
text,
|
|
153
|
+
...(href ? { href: abs(href) } : {}),
|
|
154
|
+
}];
|
|
155
|
+
}
|
|
156
|
+
if (looksLikeButton(el) && text) {
|
|
157
|
+
const href = el.getAttribute('href');
|
|
158
|
+
taken.nodes++;
|
|
159
|
+
return [{ kind: 'button', variant: 'cta', text, ...(href ? { href: abs(href) } : {}) }];
|
|
160
|
+
}
|
|
161
|
+
// A LINK WITH MARKUP INSIDE IT IS STILL A LINK. `<a><span>Docs</span></a>`
|
|
162
|
+
// has children, so it fell through to the child walk — and when those
|
|
163
|
+
// children produced nothing renderable (an icon font's ligature, a
|
|
164
|
+
// decorative span) the link's words went with them. Same shape as the
|
|
165
|
+
// text fallback below, and for the same reason: offer your own content
|
|
166
|
+
// only when nothing inside offered any.
|
|
167
|
+
const inner = walkChildren(el);
|
|
168
|
+
if (inner.length > 0)
|
|
169
|
+
return inner;
|
|
113
170
|
if (text) {
|
|
114
|
-
|
|
171
|
+
const href = el.getAttribute('href');
|
|
172
|
+
taken.nodes++;
|
|
173
|
+
return [{ kind: 'button', variant: 'link', text, ...(href ? { href: abs(href) } : {}) }];
|
|
115
174
|
}
|
|
116
|
-
return;
|
|
175
|
+
return [];
|
|
117
176
|
}
|
|
118
177
|
if (tag === 'UL' || tag === 'OL') {
|
|
119
178
|
const items = Array.from(el.querySelectorAll('li'))
|
|
120
179
|
.map((li) => clean(li.textContent))
|
|
121
180
|
.filter(Boolean);
|
|
122
|
-
if (items.length)
|
|
123
|
-
|
|
124
|
-
|
|
181
|
+
if (!items.length)
|
|
182
|
+
return [];
|
|
183
|
+
taken.nodes++;
|
|
184
|
+
return [{ kind: 'list', items }];
|
|
125
185
|
}
|
|
126
186
|
if (tag === 'P' || tag === 'BLOCKQUOTE') {
|
|
127
187
|
const text = clean(el.textContent);
|
|
128
|
-
if (text)
|
|
129
|
-
|
|
130
|
-
|
|
188
|
+
if (!text)
|
|
189
|
+
return [];
|
|
190
|
+
taken.nodes++;
|
|
191
|
+
return [{ kind: 'text', text }];
|
|
131
192
|
}
|
|
132
|
-
|
|
133
|
-
|
|
193
|
+
const kids = walkChildren(el);
|
|
194
|
+
if (kids.length > 0) {
|
|
195
|
+
const cs = getComputedStyle(el);
|
|
196
|
+
const lays = cs.display === 'flex' || cs.display === 'grid' ||
|
|
197
|
+
cs.display === 'inline-flex' || cs.display === 'inline-grid';
|
|
198
|
+
// A ROW is worth keeping; a column is what the page already is, so
|
|
199
|
+
// wrapping one in a group would add a level that renders identically.
|
|
200
|
+
const row = cs.display.indexOf('grid') >= 0
|
|
201
|
+
? true
|
|
202
|
+
: cs.flexDirection === 'row' || cs.flexDirection === 'row-reverse';
|
|
203
|
+
if (lays && row && kids.length >= 2) {
|
|
204
|
+
taken.nodes++;
|
|
205
|
+
return [{ kind: 'group', direction: 'row', wrap: cs.flexWrap === 'wrap', children: kids }];
|
|
206
|
+
}
|
|
207
|
+
return kids;
|
|
208
|
+
}
|
|
209
|
+
// THE TEXT FALLBACK, and it is most of the web. Capturing only <p> meant a
|
|
210
|
+
// page whose prose sits in a <div>, a <td> or a <span> came back EMPTY:
|
|
211
|
+
// measured, news.ycombinator.com (a table layout) and tailwindcss.com both
|
|
212
|
+
// kept 0 of ~4,000 and ~6,000 characters.
|
|
213
|
+
//
|
|
214
|
+
// Safe because it only fires when nothing INSIDE offered anything, so a
|
|
215
|
+
// paragraph is never taken twice — once through its <p> and again through
|
|
216
|
+
// the <div> around it. Bounded because a fallback that fires high in the
|
|
217
|
+
// tree would otherwise carry a whole page as one string.
|
|
218
|
+
const own = clean(el.textContent);
|
|
219
|
+
if (own && own.length <= limits.maxTextChars) {
|
|
220
|
+
taken.nodes++;
|
|
221
|
+
return [{ kind: 'text', text: own }];
|
|
222
|
+
}
|
|
223
|
+
if (own)
|
|
224
|
+
skip('text-too-long');
|
|
225
|
+
return [];
|
|
134
226
|
};
|
|
135
|
-
walk(root);
|
|
136
|
-
return out;
|
|
227
|
+
return walk(root);
|
|
137
228
|
};
|
|
138
229
|
// SECTION CANDIDATES, widest first: a page that marks its bands up
|
|
139
230
|
// semantically is read that way, and one that does not falls back to the
|
|
@@ -154,22 +245,59 @@ function capturePage(limits) {
|
|
|
154
245
|
// document is one candidate too, and keeping THAT would reduce every page to a
|
|
155
246
|
// single band. The finest ones are the page's actual bands.
|
|
156
247
|
candidates = candidates.filter((el) => !candidates.some((o) => o !== el && el.contains(o)));
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
248
|
+
// THE SOURCE'S OWN HEADER AND FOOTER ARE NEVER WANTED. The target site has its
|
|
249
|
+
// own, as shared globals, and importing somebody else's navigation onto a
|
|
250
|
+
// storefront is a second menu pointing at a different website. Only the
|
|
251
|
+
// PAGE-LEVEL ones are dropped — a `<header>` inside a section is a hero, and
|
|
252
|
+
// excluding those would lose the first thing on most landing pages.
|
|
253
|
+
const chrome = new Set();
|
|
254
|
+
for (const el of Array.from(document.body.children)) {
|
|
255
|
+
if (el.tagName === 'HEADER' || el.tagName === 'FOOTER')
|
|
256
|
+
chrome.add(el);
|
|
257
|
+
}
|
|
258
|
+
const build = (from) => {
|
|
259
|
+
const acc = [];
|
|
260
|
+
for (const el of from) {
|
|
261
|
+
if (chrome.has(el)) {
|
|
262
|
+
skip('page-chrome');
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (acc.length >= limits.maxSections) {
|
|
266
|
+
skip('over-section-limit');
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
if (!visible(el)) {
|
|
270
|
+
skip('hidden');
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const children = leaves(el);
|
|
274
|
+
if (children.length === 0) {
|
|
275
|
+
skip('empty-section');
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
// PINNING IS THE ONE BEHAVIOUR WORTH CARRYING OVER, and the only one this
|
|
279
|
+
// importer reads off computed style rather than off the tree. A sticky
|
|
280
|
+
// category bar or a fixed buy bar is a layout DECISION — the section is
|
|
281
|
+
// there to stay in view — and a copy that scrolls away is not the same
|
|
282
|
+
// section. Sticky and fixed only: `absolute` and `relative` describe where
|
|
283
|
+
// a box sits inside a layout this import is not copying, so carrying them
|
|
284
|
+
// would place a section against coordinates that no longer exist.
|
|
285
|
+
const pos = getComputedStyle(el).position;
|
|
286
|
+
const pinned = pos === 'sticky' || pos === 'fixed' ? pos : undefined;
|
|
287
|
+
acc.push({ kind: 'section', children, ...(pinned ? { pinned } : {}) });
|
|
171
288
|
}
|
|
172
|
-
|
|
289
|
+
return acc;
|
|
290
|
+
};
|
|
291
|
+
let sections = build(candidates);
|
|
292
|
+
// THE FALLBACK HAS TO FIRE ON AN EMPTY RESULT, not only on an empty candidate
|
|
293
|
+
// LIST. A page can offer `<section>` elements that hold nothing this platform
|
|
294
|
+
// renders — a wrapper around a canvas, a slot filled by script later — and the
|
|
295
|
+
// old order took "we found candidates" as "we found content", so the whole
|
|
296
|
+
// page came back empty. Measured: tailwindcss.com kept 0 of 6,004 characters
|
|
297
|
+
// while reporting one skipped empty section.
|
|
298
|
+
if (sections.length === 0) {
|
|
299
|
+
const main = document.querySelectorAll('main')[0] ?? document.body;
|
|
300
|
+
sections = build(Array.from(main.children));
|
|
173
301
|
}
|
|
174
302
|
return { url: here, title: clean(document.title), sections, skipped };
|
|
175
303
|
}
|
|
@@ -183,8 +311,9 @@ function capturePage(limits) {
|
|
|
183
311
|
export async function capture(url, opts = {}) {
|
|
184
312
|
const limits = {
|
|
185
313
|
maxSections: opts.maxSections ?? 24,
|
|
186
|
-
maxPerSection: opts.maxPerSection ?? 40,
|
|
187
314
|
maxImages: opts.maxImages ?? 24,
|
|
315
|
+
maxTextChars: opts.maxTextChars ?? 1200,
|
|
316
|
+
maxNodes: opts.maxNodes ?? 400,
|
|
188
317
|
};
|
|
189
318
|
let browser;
|
|
190
319
|
try {
|
package/dist/vision/shoot.js
CHANGED
|
@@ -90,15 +90,26 @@ export async function closeBrowser() {
|
|
|
90
90
|
await b.close().catch(() => { });
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
|
-
* A kept browser is a child process, and a child process outlives a parent
|
|
94
|
-
*
|
|
95
|
-
* the
|
|
96
|
-
*
|
|
97
|
-
*
|
|
93
|
+
* A kept browser is a child process, and a child process outlives a parent that
|
|
94
|
+
* forgets it. SIGINT/SIGTERM close Chrome and then re-raise, so the exit code is
|
|
95
|
+
* the one the signal would have produced. That is the path the MCP server
|
|
96
|
+
* actually takes, because a stdio server is stopped by its client.
|
|
97
|
+
*
|
|
98
|
+
* THERE USED TO BE A `beforeExit` HANDLER HERE TOO, and it could never fire.
|
|
99
|
+
* `beforeExit` runs when the event loop DRAINS, and an open browser connection
|
|
100
|
+
* is exactly what keeps it from draining — so in the one situation the handler
|
|
101
|
+
* described (a caller that finished and forgot to close) it was unreachable, and
|
|
102
|
+
* in the other (nothing open) it had nothing to do. Proved by measurement: a
|
|
103
|
+
* script that took one screenshot and returned was still alive twenty seconds
|
|
104
|
+
* later, and two of them were killed by the OS for memory during this repo's own
|
|
105
|
+
* development. `playwright-core` exposes no `browser.process()` for `launch()`,
|
|
106
|
+
* so there is nothing to `unref` and no way to make it reachable.
|
|
107
|
+
*
|
|
108
|
+
* SO THE CONTRACT IS EXPLICIT: a caller that is not a long-running server must
|
|
109
|
+
* call `closeBrowser()` when it is done — a `finally` block, not a hope. The
|
|
110
|
+
* pooling is deliberate (a vision loop shoots constantly and must not pay a
|
|
111
|
+
* launch each time); the cost of it is this one line at every other call site.
|
|
98
112
|
*/
|
|
99
|
-
process.once('beforeExit', () => {
|
|
100
|
-
void closeBrowser();
|
|
101
|
-
});
|
|
102
113
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
103
114
|
process.once(signal, () => {
|
|
104
115
|
void closeBrowser().finally(() => process.kill(process.pid, signal));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
|
|
5
5
|
"mcpName": "io.github.vuluu2k/sbuilder-mcp",
|
|
6
6
|
"type": "module",
|