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 +13 -0
- package/CHANGELOG.vi.md +13 -0
- package/dist/domains/site/builder.js +1 -0
- package/dist/domains/site/importmap.js +54 -0
- package/dist/domains/site/node.js +22 -1
- package/dist/tools/importpage.js +18 -2
- package/dist/vision/capture.js +151 -50
- package/dist/vision/shoot.js +19 -8
- package/package.json +1 -1
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.
|
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) {
|
|
@@ -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 } : {}),
|
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,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
|
-
/**
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
108
|
-
return;
|
|
126
|
+
taken.nodes++;
|
|
127
|
+
return [{ kind: 'image', src: abs(src), alt: clean(el.getAttribute('alt')) }];
|
|
109
128
|
}
|
|
110
|
-
if (tag === 'A'
|
|
129
|
+
if (tag === 'A') {
|
|
111
130
|
const text = clean(el.textContent);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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 {
|
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.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",
|