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 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
+ }
@@ -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, { maxSections: max_sections, maxImages: max_images });
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. Measured: a
82
- // real import lost all four images to `only image, video, or font
83
- // (woff2/woff/ttf/otf) uploads are supported` the platform refusing SVG,
84
- // whose own sentinel exists precisely so a caller can be told "convert it
85
- // first". The count alone said none of that.
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 } : {}),
@@ -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.string().optional().describe('An interaction state, e.g. "hover"'),
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
- return text({ dry_run: true, patches, ...(note ? { note } : {}) });
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];
@@ -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
- form.set('file', new Blob([bytes]), filename);
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)
@@ -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
- /** Collect the renderable leaves under one section, in document order. */
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 out = [];
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
- if (out.length >= limits.maxPerSection)
75
- return;
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
- out.push({ kind: 'heading', level: Number(tag.slice(1)), text });
89
- return;
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
- out.push({ kind: 'image', src: abs(src), alt: clean(el.getAttribute('alt')) });
108
- return;
134
+ taken.nodes++;
135
+ return [{ kind: 'image', src: abs(src), alt: clean(el.getAttribute('alt')) }];
109
136
  }
110
- if (tag === 'A' && looksLikeButton(el)) {
137
+ if (tag === 'A') {
111
138
  const text = clean(el.textContent);
112
- const href = el.getAttribute('href');
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
- out.push({ kind: 'button', text, ...(href ? { href: abs(href) } : {}) });
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
- out.push({ kind: 'list', items });
124
- return;
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
- out.push({ kind: 'text', text });
130
- return;
188
+ if (!text)
189
+ return [];
190
+ taken.nodes++;
191
+ return [{ kind: 'text', text }];
131
192
  }
132
- for (const child of Array.from(el.children))
133
- walk(child);
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
- const sections = [];
158
- for (const el of candidates) {
159
- if (sections.length >= limits.maxSections) {
160
- skip('over-section-limit');
161
- break;
162
- }
163
- if (!visible(el)) {
164
- skip('hidden');
165
- continue;
166
- }
167
- const children = leaves(el);
168
- if (children.length === 0) {
169
- skip('empty-section');
170
- continue;
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
- sections.push({ kind: 'section', children });
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 {
@@ -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
- * that forgets it. `beforeExit` fires when the event loop drains and CAN await
95
- * the close (`exit` cannot). A signal kills the loop without draining it, so
96
- * SIGINT/SIGTERM close Chrome too and then re-raise so the exit code is the
97
- * one the signal would have produced. Registered once, at module load.
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.7.2",
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",