sbuilder-mcp 0.7.2 → 0.8.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,19 @@ 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.8.0] - 2026-09-08
10
+
11
+ ### Added
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+
16
+ ### Fixed
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+ - 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.
21
+
9
22
  ## [0.7.2] - 2026-09-08
10
23
 
11
24
  ### Fixed
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,19 @@ 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.8.0] - 2026-09-08
10
+
11
+ ### Added
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+
16
+ ### Fixed
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+ - 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.
21
+
9
22
  ## [0.7.2] - 2026-09-08
10
23
 
11
24
  ### Fixed
@@ -90,6 +90,7 @@ export function addSubtree(doc, parentId, spec, index) {
90
90
  style: s.style,
91
91
  config: s.config,
92
92
  specials: s.specials,
93
+ responsive: s.responsive,
93
94
  });
94
95
  // Before the owner is handed to a patch: minting rewrites its `config`.
95
96
  const sats = mintSatellites(n);
@@ -125,6 +125,26 @@ function one(c, t) {
125
125
  const text = c.text?.trim();
126
126
  if (!text)
127
127
  return null;
128
+ // TWO KINDS OF LINK, and giving them one look was wrong in both
129
+ // directions. Painting every link produced 38 pink pills out of a
130
+ // documentation sidebar; dropping the unpainted ones lost a whole page of
131
+ // story titles. A call to action takes the target's FILL; a link takes its
132
+ // accent as INK and nothing else, which is the platform's own idiom for a
133
+ // link (a button carrying href, styled flat).
134
+ if (c.variant === 'link') {
135
+ return {
136
+ type: 'button',
137
+ specials: { text, ...(c.href ? { href: c.href } : {}) },
138
+ style: {
139
+ width: 'fit-content',
140
+ backgroundColor: 'transparent',
141
+ border: 'none',
142
+ padding: '0',
143
+ fontWeight: '500',
144
+ ...(t.buttonBg ? { color: t.buttonBg } : {}),
145
+ },
146
+ };
147
+ }
128
148
  return {
129
149
  type: 'button',
130
150
  specials: { text, ...(c.href ? { href: c.href } : {}) },
@@ -157,6 +177,40 @@ function one(c, t) {
157
177
  })),
158
178
  };
159
179
  }
180
+ case 'group': {
181
+ const kids = (c.children ?? []).map((k) => one(k, t)).filter((n) => n !== null);
182
+ if (kids.length === 0)
183
+ return null;
184
+ if (kids.length === 1)
185
+ return kids[0];
186
+ // A ROW OF TWO OR MORE COLUMNS NEEDS AN EXPLICIT STACK BREAKPOINT, and
187
+ // nothing catches it for you: the columns SHRINK to fit, so no box
188
+ // overflows and `measure` stays silent while a photo becomes a sliver and
189
+ // a label truncates mid-word. Rule 3 of the design skill, and an import is
190
+ // the one place a row arrives without anybody having thought about 390.
191
+ //
192
+ // The wide answer is said out loud at base too, because the cascade
193
+ // resolves narrower slots LAST but does consult them: a `column` written
194
+ // only at mobile would otherwise reach desktop whenever base declares
195
+ // nothing.
196
+ return {
197
+ type: 'flex-block',
198
+ style: {
199
+ width: '100%',
200
+ display: 'flex',
201
+ flexDirection: 'row',
202
+ flexWrap: c.wrap ? 'wrap' : 'nowrap',
203
+ alignItems: 'flex-start',
204
+ gap: '24px',
205
+ },
206
+ responsive: { mobile: { style: { flexDirection: 'column', gap: '16px' } } },
207
+ children: kids.map((k) => ({
208
+ type: 'flex-block',
209
+ style: { flex: '1 1 280px', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '12px' },
210
+ children: [k],
211
+ })),
212
+ };
213
+ }
160
214
  case 'section': {
161
215
  const kids = (c.children ?? []).map((k) => one(k, t)).filter((n) => n !== null);
162
216
  if (kids.length === 0)
@@ -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.
@@ -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) {
@@ -115,6 +126,11 @@ export function registerImportTools(server, ctx, session) {
115
126
  return text({
116
127
  read: shot.url,
117
128
  added_sections: added,
129
+ // WHAT WAS LEFT BEHIND, on the real run too. The dry run said it and the
130
+ // real one did not, which is the wrong way round: a caller who skipped
131
+ // the preview is exactly the caller who needs to be told that 21 nodes
132
+ // hit the ceiling, or that the page's own header was dropped on purpose.
133
+ ...(Object.keys(shot.skipped).length ? { skipped: shot.skipped } : {}),
118
134
  images: {
119
135
  copied: rehosted.size,
120
136
  ...(failed.length ? { failed } : {}),
@@ -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,145 @@ 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 = [];
73
87
  const walk = (el) => {
74
- if (out.length >= limits.maxPerSection)
75
- return;
88
+ // ONE BOUND, on the whole import. There used to be a second, per section,
89
+ // and it kept doing the same wrong job under a new number: a page whose
90
+ // <body> has a single child is ONE section, so the per-section cap became
91
+ // the page cap and truncated it — news.ycombinator.com captured 84 links
92
+ // and 45 lines and lost the rest at exactly 120. Two limits for one
93
+ // quantity means the tighter one is always the real limit, and nobody
94
+ // remembers which that is.
95
+ if (taken.nodes >= limits.maxNodes) {
96
+ skip('over-node-limit');
97
+ return [];
98
+ }
76
99
  const tag = el.tagName;
77
100
  if (IGNORE.has(tag)) {
78
101
  skip(tag.toLowerCase());
79
- return;
102
+ return [];
80
103
  }
81
104
  if (!visible(el)) {
82
105
  skip('hidden');
83
- return;
106
+ return [];
84
107
  }
85
108
  if (HEADINGS.has(tag)) {
86
109
  const text = clean(el.textContent);
87
- if (text)
88
- out.push({ kind: 'heading', level: Number(tag.slice(1)), text });
89
- return;
110
+ if (!text)
111
+ return [];
112
+ taken.nodes++;
113
+ return [{ kind: 'heading', level: Number(tag.slice(1)), text }];
90
114
  }
91
115
  if (tag === 'IMG') {
92
116
  const src = el.getAttribute('src');
93
117
  if (!src || src.startsWith('data:')) {
94
118
  skip('image-without-src');
95
- return;
119
+ return [];
96
120
  }
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
121
  if (taken.images >= limits.maxImages) {
103
122
  skip('over-image-limit');
104
- return;
123
+ return [];
105
124
  }
106
125
  taken.images++;
107
- out.push({ kind: 'image', src: abs(src), alt: clean(el.getAttribute('alt')) });
108
- return;
126
+ taken.nodes++;
127
+ return [{ kind: 'image', src: abs(src), alt: clean(el.getAttribute('alt')) }];
109
128
  }
110
- if (tag === 'A' && looksLikeButton(el)) {
129
+ if (tag === 'A') {
111
130
  const text = clean(el.textContent);
112
- const href = el.getAttribute('href');
113
- if (text) {
114
- out.push({ kind: 'button', text, ...(href ? { href: abs(href) } : {}) });
131
+ // A LINK THAT IS NOT A BUTTON IS STILL A LINK. It used to contribute
132
+ // NOTHING, and on a page whose content IS a list of links that is the
133
+ // whole page: news.ycombinator.com lost 1,595 characters of story titles
134
+ // and bylines that way. The platform has no inline-link element — its
135
+ // own idiom is a `button` carrying `href`, styled flat — so that is what
136
+ // an unpainted link becomes, and the variant is what keeps it from
137
+ // arriving as a call to action.
138
+ if (text && el.children.length === 0) {
139
+ const href = el.getAttribute('href');
140
+ taken.nodes++;
141
+ return [{
142
+ kind: 'button',
143
+ variant: looksLikeButton(el) ? 'cta' : 'link',
144
+ text,
145
+ ...(href ? { href: abs(href) } : {}),
146
+ }];
147
+ }
148
+ if (looksLikeButton(el) && text) {
149
+ const href = el.getAttribute('href');
150
+ taken.nodes++;
151
+ return [{ kind: 'button', variant: 'cta', text, ...(href ? { href: abs(href) } : {}) }];
115
152
  }
116
- return;
117
153
  }
118
154
  if (tag === 'UL' || tag === 'OL') {
119
155
  const items = Array.from(el.querySelectorAll('li'))
120
156
  .map((li) => clean(li.textContent))
121
157
  .filter(Boolean);
122
- if (items.length)
123
- out.push({ kind: 'list', items });
124
- return;
158
+ if (!items.length)
159
+ return [];
160
+ taken.nodes++;
161
+ return [{ kind: 'list', items }];
125
162
  }
126
163
  if (tag === 'P' || tag === 'BLOCKQUOTE') {
127
164
  const text = clean(el.textContent);
128
- if (text)
129
- out.push({ kind: 'text', text });
130
- return;
165
+ if (!text)
166
+ return [];
167
+ taken.nodes++;
168
+ return [{ kind: 'text', text }];
169
+ }
170
+ const kids = [];
171
+ for (const child of Array.from(el.children)) {
172
+ for (const c of walk(child))
173
+ kids.push(c);
174
+ }
175
+ if (kids.length > 0) {
176
+ const cs = getComputedStyle(el);
177
+ const lays = cs.display === 'flex' || cs.display === 'grid' ||
178
+ cs.display === 'inline-flex' || cs.display === 'inline-grid';
179
+ // A ROW is worth keeping; a column is what the page already is, so
180
+ // wrapping one in a group would add a level that renders identically.
181
+ const row = cs.display.indexOf('grid') >= 0
182
+ ? true
183
+ : cs.flexDirection === 'row' || cs.flexDirection === 'row-reverse';
184
+ if (lays && row && kids.length >= 2) {
185
+ taken.nodes++;
186
+ return [{ kind: 'group', direction: 'row', wrap: cs.flexWrap === 'wrap', children: kids }];
187
+ }
188
+ return kids;
131
189
  }
132
- for (const child of Array.from(el.children))
133
- walk(child);
190
+ // THE TEXT FALLBACK, and it is most of the web. Capturing only <p> meant a
191
+ // page whose prose sits in a <div>, a <td> or a <span> came back EMPTY:
192
+ // measured, news.ycombinator.com (a table layout) and tailwindcss.com both
193
+ // kept 0 of ~4,000 and ~6,000 characters.
194
+ //
195
+ // Safe because it only fires when nothing INSIDE offered anything, so a
196
+ // paragraph is never taken twice — once through its <p> and again through
197
+ // the <div> around it. Bounded because a fallback that fires high in the
198
+ // tree would otherwise carry a whole page as one string.
199
+ const own = clean(el.textContent);
200
+ if (own && own.length <= limits.maxTextChars) {
201
+ taken.nodes++;
202
+ return [{ kind: 'text', text: own }];
203
+ }
204
+ if (own)
205
+ skip('text-too-long');
206
+ return [];
134
207
  };
135
- walk(root);
136
- return out;
208
+ return walk(root);
137
209
  };
138
210
  // SECTION CANDIDATES, widest first: a page that marks its bands up
139
211
  // semantically is read that way, and one that does not falls back to the
@@ -154,22 +226,50 @@ function capturePage(limits) {
154
226
  // document is one candidate too, and keeping THAT would reduce every page to a
155
227
  // single band. The finest ones are the page's actual bands.
156
228
  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;
229
+ // THE SOURCE'S OWN HEADER AND FOOTER ARE NEVER WANTED. The target site has its
230
+ // own, as shared globals, and importing somebody else's navigation onto a
231
+ // storefront is a second menu pointing at a different website. Only the
232
+ // PAGE-LEVEL ones are dropped — a `<header>` inside a section is a hero, and
233
+ // excluding those would lose the first thing on most landing pages.
234
+ const chrome = new Set();
235
+ for (const el of Array.from(document.body.children)) {
236
+ if (el.tagName === 'HEADER' || el.tagName === 'FOOTER')
237
+ chrome.add(el);
238
+ }
239
+ const build = (from) => {
240
+ const acc = [];
241
+ for (const el of from) {
242
+ if (chrome.has(el)) {
243
+ skip('page-chrome');
244
+ continue;
245
+ }
246
+ if (acc.length >= limits.maxSections) {
247
+ skip('over-section-limit');
248
+ break;
249
+ }
250
+ if (!visible(el)) {
251
+ skip('hidden');
252
+ continue;
253
+ }
254
+ const children = leaves(el);
255
+ if (children.length === 0) {
256
+ skip('empty-section');
257
+ continue;
258
+ }
259
+ acc.push({ kind: 'section', children });
171
260
  }
172
- sections.push({ kind: 'section', children });
261
+ return acc;
262
+ };
263
+ let sections = build(candidates);
264
+ // THE FALLBACK HAS TO FIRE ON AN EMPTY RESULT, not only on an empty candidate
265
+ // LIST. A page can offer `<section>` elements that hold nothing this platform
266
+ // renders — a wrapper around a canvas, a slot filled by script later — and the
267
+ // old order took "we found candidates" as "we found content", so the whole
268
+ // page came back empty. Measured: tailwindcss.com kept 0 of 6,004 characters
269
+ // while reporting one skipped empty section.
270
+ if (sections.length === 0) {
271
+ const main = document.querySelectorAll('main')[0] ?? document.body;
272
+ sections = build(Array.from(main.children));
173
273
  }
174
274
  return { url: here, title: clean(document.title), sections, skipped };
175
275
  }
@@ -183,8 +283,9 @@ function capturePage(limits) {
183
283
  export async function capture(url, opts = {}) {
184
284
  const limits = {
185
285
  maxSections: opts.maxSections ?? 24,
186
- maxPerSection: opts.maxPerSection ?? 40,
187
286
  maxImages: opts.maxImages ?? 24,
287
+ maxTextChars: opts.maxTextChars ?? 1200,
288
+ maxNodes: opts.maxNodes ?? 400,
188
289
  };
189
290
  let browser;
190
291
  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.8.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",