sbuilder-mcp 0.19.0 → 0.21.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 +14 -0
- package/CHANGELOG.vi.md +14 -0
- package/dist/domains/site/importmap.js +37 -0
- package/dist/domains/site/readiness-fetch.js +7 -0
- package/dist/domains/site/readiness.js +56 -3
- package/dist/tools/importpage.js +36 -3
- package/dist/vision/capture.js +24 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ 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.21.0] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_import and sb_import_site now detect a `<form>` on the source page and report it as `forms_found` (field count and labels) instead of silently dropping it, pointing the caller at `sb_store action:"form"` to rebuild it with a valid field vocabulary.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- sb_import_site now rewrites links between the pages it imports so the new site's menu points at itself instead of back at the source it was copied from, and reports any same-origin links left pointing off-site under `links.still_off_site` so the caller knows to raise `max_pages`.
|
|
16
|
+
|
|
17
|
+
## [0.20.0] - 2026-09-10
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- sb_review now reports a `siteChrome` gap when a site has two or more pages and no global section, so each page is carrying its own header and footer with no way to change the menu in one place.
|
|
21
|
+
- sb_review now reports a `cartCount` gap when something opens the cart but no `cart-count` element shows what is in it, since a shopper who adds an item otherwise sees a toast fade with no lasting sign their basket is not empty.
|
|
22
|
+
|
|
9
23
|
## [0.19.0] - 2026-09-10
|
|
10
24
|
|
|
11
25
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,20 @@ 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.21.0] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_import và sb_import_site giờ phát hiện `<form>` trên trang nguồn và báo cáo qua `forms_found` (số field và nhãn) thay vì âm thầm bỏ qua, đồng thời chỉ người gọi tới `sb_store action:"form"` để dựng lại form với vocabulary field hợp lệ.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- sb_import_site giờ viết lại các link giữa các trang mà nó import, để menu của site mới trỏ về chính nó thay vì trỏ ngược về site nguồn đã copy, và báo cáo các link cùng origin còn trỏ ra ngoài qua `links.still_off_site` để người gọi biết cần tăng `max_pages`.
|
|
16
|
+
|
|
17
|
+
## [0.20.0] - 2026-09-10
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- sb_review giờ báo gap `siteChrome` khi một site có từ hai trang trở lên mà không có global section nào, nghĩa là mỗi trang đang tự mang header và footer riêng, không có cách nào đổi menu ở một chỗ duy nhất.
|
|
21
|
+
- sb_review giờ báo gap `cartCount` khi có thứ mở được giỏ hàng nhưng không có element `cart-count` nào cho thấy trong giỏ có gì, vì khách thêm hàng vào giỏ chỉ thấy một toast tắt đi mà không còn dấu hiệu lâu dài nào cho thấy giỏ không rỗng.
|
|
22
|
+
|
|
9
23
|
## [0.19.0] - 2026-09-10
|
|
10
24
|
|
|
11
25
|
### Added
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { walk } from '../../core/tree.js';
|
|
2
2
|
import { stickySeeds } from './sticky.js';
|
|
3
|
+
import { normalizeUrl } from './discover.js';
|
|
3
4
|
import { ICON_NAMES } from '../../catalog/icons.generated.js';
|
|
4
5
|
/** Style keys read off a node, ignoring anything unset. */
|
|
5
6
|
function styleOf(n) {
|
|
@@ -416,3 +417,39 @@ export function rehostImages(captured, map) {
|
|
|
416
417
|
});
|
|
417
418
|
return captured.map(visit);
|
|
418
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* POINT THE IMPORTED LINKS AT THE IMPORTED PAGES.
|
|
422
|
+
*
|
|
423
|
+
* A captured link keeps the SOURCE's absolute URL, so a site brought over with
|
|
424
|
+
* `sb_import_site` had a menu that sent every visitor back to the website it was
|
|
425
|
+
* copied from — twelve pages built here and not one way to reach any of them.
|
|
426
|
+
* The most basic feature a website has, and the import was quietly working
|
|
427
|
+
* against it.
|
|
428
|
+
*
|
|
429
|
+
* Only the links whose target was ACTUALLY IMPORTED are rewritten. A same-origin
|
|
430
|
+
* link to a page the cap left out is counted rather than pointed at a slug that
|
|
431
|
+
* does not exist here: an off-site link that works beats a local one that 404s,
|
|
432
|
+
* and the count is what tells the caller to raise `max_pages`. A genuinely
|
|
433
|
+
* external link is left alone and is not interesting.
|
|
434
|
+
*/
|
|
435
|
+
export function relink(captured, local, origin) {
|
|
436
|
+
let rewritten = 0;
|
|
437
|
+
let unimported = 0;
|
|
438
|
+
const one = (c) => {
|
|
439
|
+
let href = c.href;
|
|
440
|
+
if (href) {
|
|
441
|
+
const norm = normalizeUrl(href);
|
|
442
|
+
const to = norm ? local.get(norm) : undefined;
|
|
443
|
+
if (to) {
|
|
444
|
+
href = to;
|
|
445
|
+
rewritten += 1;
|
|
446
|
+
}
|
|
447
|
+
else if (norm && norm.indexOf(origin) === 0) {
|
|
448
|
+
unimported += 1;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const kids = c.children ? c.children.map(one) : undefined;
|
|
452
|
+
return { ...c, ...(href ? { href } : {}), ...(kids ? { children: kids } : {}) };
|
|
453
|
+
};
|
|
454
|
+
return { sections: captured.map(one), rewritten, unimported };
|
|
455
|
+
}
|
|
@@ -61,6 +61,12 @@ export async function gatherReadiness(ctx, siteId, pageNodes) {
|
|
|
61
61
|
const globalNodes = globals?.globalSections
|
|
62
62
|
? globals.globalSections.flatMap((g) => Object.values(g.document?.nodes ?? {}))
|
|
63
63
|
: null;
|
|
64
|
+
// HOW MANY SHARED SECTIONS THE SITE HAS, not just what is in them. An empty
|
|
65
|
+
// list is the answer to a question nothing asked: a site whose pages each
|
|
66
|
+
// carry their own header is not one site.
|
|
67
|
+
const globalKinds = globals?.globalSections
|
|
68
|
+
? globals.globalSections.map((g) => g.kind ?? '')
|
|
69
|
+
: null;
|
|
64
70
|
// ACTIVE means a shopper can see it; PURCHASABLE adds a price above zero. A
|
|
65
71
|
// product priced at zero renders, adds to the cart, and totals nothing — which
|
|
66
72
|
// reads as a working store right up to the money.
|
|
@@ -78,6 +84,7 @@ export async function gatherReadiness(ctx, siteId, pageNodes) {
|
|
|
78
84
|
shippingMethods,
|
|
79
85
|
pageNodes,
|
|
80
86
|
globalNodes,
|
|
87
|
+
globalKinds,
|
|
81
88
|
categories,
|
|
82
89
|
categoryPageLinks,
|
|
83
90
|
};
|
|
@@ -61,12 +61,43 @@ function isStore(input) {
|
|
|
61
61
|
}
|
|
62
62
|
const published = (pages, type) => pages.some((p) => p.type === type && p.status === 'published');
|
|
63
63
|
const drafted = (pages, type) => pages.some((p) => p.type === type && p.status !== 'published');
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* What stands between this site and a paid order, most-blocking first — and,
|
|
66
|
+
* asked FIRST because it is not about money at all, whether these pages are one
|
|
67
|
+
* SITE.
|
|
68
|
+
*
|
|
69
|
+
* The precedent is `accountPage` and `searchPage`, already here on the same
|
|
70
|
+
* reasoning: a shop with no account page still takes orders, so they are a
|
|
71
|
+
* different question asked second. Shared chrome is a different question asked
|
|
72
|
+
* FIRST, because it is true of every site and not only of a store.
|
|
73
|
+
*/
|
|
65
74
|
export function readinessGaps(input) {
|
|
66
|
-
if (!isStore(input))
|
|
67
|
-
return [];
|
|
68
75
|
const gaps = [];
|
|
69
76
|
const pages = input.pages;
|
|
77
|
+
// A SITE WITH NO SHARED SECTION IS NOT A SITE, IT IS A STACK OF PAGES.
|
|
78
|
+
//
|
|
79
|
+
// Nothing asked this. Every tool here authors ONE page, so a build that never
|
|
80
|
+
// reaches for a global section gives each page its own header and footer —
|
|
81
|
+
// and then a change to the nav is one edit per page, the copies drift, and a
|
|
82
|
+
// visitor meets a slightly different site on every click. It is the most
|
|
83
|
+
// basic thing a website has that a generated one does not, and it is
|
|
84
|
+
// invisible to `sb_review`, which reads one page and finds it perfect.
|
|
85
|
+
//
|
|
86
|
+
// Two pages is the threshold: a one-page site has nothing to share with.
|
|
87
|
+
if (pages && pages.length >= 2 && input.globalKinds !== null && input.globalKinds?.length === 0) {
|
|
88
|
+
gaps.push({
|
|
89
|
+
id: 'siteChrome',
|
|
90
|
+
draft: false,
|
|
91
|
+
problem: `This site has ${pages.length} pages and no global section, so each one carries its own ` +
|
|
92
|
+
'header and footer. Changing the menu is that many edits, the copies drift apart, and a ' +
|
|
93
|
+
'visitor meets a slightly different site on every page.',
|
|
94
|
+
fix: 'Make them shared: POST /api/sites/{siteId}/global-sections with { name, kind: "header" | ' +
|
|
95
|
+
'"footer", document }, then PUT .../{id}/document with { subtree }. A page then carries a ' +
|
|
96
|
+
'globalRef instead of a copy, and one edit reaches every page.',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (!isStore(input))
|
|
100
|
+
return gaps;
|
|
70
101
|
if (pages && !published(pages, 'checkout')) {
|
|
71
102
|
const draft = drafted(pages, 'checkout');
|
|
72
103
|
gaps.push({
|
|
@@ -181,6 +212,28 @@ export function readinessGaps(input) {
|
|
|
181
212
|
fix: draft ? `Publish the ${type} page that already exists.` : fix,
|
|
182
213
|
});
|
|
183
214
|
}
|
|
215
|
+
const everywhere = input.globalNodes !== null ? [...input.pageNodes, ...input.globalNodes] : null;
|
|
216
|
+
// A BASKET WITH NO NUMBER ON IT. The platform shipped `cart-count` to fix
|
|
217
|
+
// exactly this, and made it OPT-IN: `open_cart` is an ACTION any element can
|
|
218
|
+
// carry, not an element type, so there is no "cart icon" to give a badge to by
|
|
219
|
+
// default — minting one unasked would put a number on every social glyph in
|
|
220
|
+
// every footer. The cost of that decision is that a site authored through
|
|
221
|
+
// these tools never has one: a shopper adds an item, gets a toast that fades,
|
|
222
|
+
// and nothing anywhere on the page says their basket holds anything.
|
|
223
|
+
//
|
|
224
|
+
// Only asked when something DOES open the cart — otherwise `cartTrigger`
|
|
225
|
+
// below is the finding, and this would be a second sentence about the same
|
|
226
|
+
// missing control.
|
|
227
|
+
if (everywhere && opensCart(everywhere) && !everywhere.some((n) => n.data.type === 'cart-count')) {
|
|
228
|
+
gaps.push({
|
|
229
|
+
id: 'cartCount',
|
|
230
|
+
draft: false,
|
|
231
|
+
problem: 'Something opens the cart, but nothing shows what is in it. A shopper who adds an item ' +
|
|
232
|
+
'sees a toast that fades and then no evidence anywhere that their basket is not empty.',
|
|
233
|
+
fix: 'Give the control that opens the cart a cart-count satellite — sb_set on it with config ' +
|
|
234
|
+
'{ cartCountId: … } mints one, or add a cart-count beside it.',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
184
237
|
if (input.globalNodes !== null && !opensCart([...input.pageNodes, ...input.globalNodes])) {
|
|
185
238
|
gaps.push({
|
|
186
239
|
id: 'cartTrigger',
|
package/dist/tools/importpage.js
CHANGED
|
@@ -4,7 +4,7 @@ import { capture, captureMany, crawlLinks } from '../vision/capture.js';
|
|
|
4
4
|
import { uploadMedia } from '../transport/media.js';
|
|
5
5
|
import { addSubtree } from '../domains/site/builder.js';
|
|
6
6
|
import { middleEnd } from '../domains/site/traps.js';
|
|
7
|
-
import { toSpecs, tokensFromPage, imageSources, rehostImages, } from '../domains/site/importmap.js';
|
|
7
|
+
import { toSpecs, tokensFromPage, imageSources, rehostImages, relink, } from '../domains/site/importmap.js';
|
|
8
8
|
import { canonFor, choosePages, normalizeUrl, robotsRules, robotsSitemaps, sitemapUrls, } from '../domains/site/discover.js';
|
|
9
9
|
import { loadSource } from '../transport/pages.js';
|
|
10
10
|
import { PageDoc } from '../domains/site/document.js';
|
|
@@ -207,6 +207,7 @@ export function registerImportTools(server, ctx, session) {
|
|
|
207
207
|
title: shot.title,
|
|
208
208
|
sections: specs.length,
|
|
209
209
|
images: images.length,
|
|
210
|
+
...(shot.forms?.length ? { forms_found: shot.forms } : {}),
|
|
210
211
|
tokens,
|
|
211
212
|
skipped: shot.skipped,
|
|
212
213
|
note: 'Structure and content only — the source\'s CSS and layout are NOT copied, and the ' +
|
|
@@ -278,6 +279,7 @@ export function registerImportTools(server, ctx, session) {
|
|
|
278
279
|
return text({
|
|
279
280
|
read: shot.url,
|
|
280
281
|
added_sections: added,
|
|
282
|
+
...(shot.forms?.length ? { forms_found: shot.forms } : {}),
|
|
281
283
|
// WHAT WAS LEFT BEHIND, on the real run too. The dry run said it and the
|
|
282
284
|
// real one did not, which is the wrong way round: a caller who skipped
|
|
283
285
|
// the preview is exactly the caller who needs to be told that 21 nodes
|
|
@@ -477,6 +479,16 @@ export function registerImportTools(server, ctx, session) {
|
|
|
477
479
|
// cannot be — each page is its own create and its own save — so the honest
|
|
478
480
|
// shape is per-page outcomes. Aborting on the fourth of twelve would leave
|
|
479
481
|
// three pages built, nine not, and no report saying which.
|
|
482
|
+
// WHERE EACH IMPORTED PAGE WILL LIVE HERE, decided before the first one is
|
|
483
|
+
// built — page two's link to page seven has to work, and page seven does
|
|
484
|
+
// not exist yet. The entry resolves to "/" when it merges into the site's
|
|
485
|
+
// own home page, because that is the address it will answer on.
|
|
486
|
+
const origin = new URL(entry).origin;
|
|
487
|
+
const localPath = new Map();
|
|
488
|
+
for (const p of plan.pages) {
|
|
489
|
+
const isEntry = p.url === entry;
|
|
490
|
+
localPath.set(p.url, isEntry && homepage !== false && home ? '/' : `/${p.slug}`);
|
|
491
|
+
}
|
|
480
492
|
const built = [];
|
|
481
493
|
const failed = [];
|
|
482
494
|
// WHAT EACH PAGE SAYS ITS OWN ADDRESS IS. A sitemap cannot tell you that
|
|
@@ -486,6 +498,9 @@ export function registerImportTools(server, ctx, session) {
|
|
|
486
498
|
// under two slugs, and nothing in the plan looks wrong.
|
|
487
499
|
const identities = new Set();
|
|
488
500
|
const aliased = [];
|
|
501
|
+
let relinked = 0;
|
|
502
|
+
let unimported = 0;
|
|
503
|
+
const formsSeen = [];
|
|
489
504
|
let lastOpened = '';
|
|
490
505
|
for (const p of plan.pages) {
|
|
491
506
|
const shot = byUrl.get(p.url);
|
|
@@ -500,8 +515,13 @@ export function registerImportTools(server, ctx, session) {
|
|
|
500
515
|
}
|
|
501
516
|
identities.add(identity);
|
|
502
517
|
try {
|
|
503
|
-
const
|
|
504
|
-
const
|
|
518
|
+
const hosted = rehosted.size > 0 ? rehostImages(shot.result.sections, rehosted) : shot.result.sections;
|
|
519
|
+
for (const f of shot.result.forms ?? [])
|
|
520
|
+
formsSeen.push({ page: p.slug, ...f });
|
|
521
|
+
const linked = relink(hosted, localPath, origin);
|
|
522
|
+
relinked += linked.rewritten;
|
|
523
|
+
unimported += linked.unimported;
|
|
524
|
+
const specs = toSpecs(linked.sections, tokens);
|
|
505
525
|
if (specs.length === 0) {
|
|
506
526
|
failed.push({
|
|
507
527
|
url: p.url,
|
|
@@ -583,6 +603,19 @@ export function registerImportTools(server, ctx, session) {
|
|
|
583
603
|
built,
|
|
584
604
|
...(failed.length ? { failed } : {}),
|
|
585
605
|
...(aliased.length ? { same_page: aliased } : {}),
|
|
606
|
+
links: {
|
|
607
|
+
rewritten: relinked,
|
|
608
|
+
...(unimported ? { still_off_site: unimported } : {}),
|
|
609
|
+
},
|
|
610
|
+
...(formsSeen.length
|
|
611
|
+
? {
|
|
612
|
+
forms_found: formsSeen,
|
|
613
|
+
forms_note: 'A form is NOT a page node here — it lives in its own document, and its fields ' +
|
|
614
|
+
'are a vocabulary the server validates. sb_store action:"form" seeds any of the ' +
|
|
615
|
+
"platform's 17 templates (contact, subscribe, booking, login, register …) with " +
|
|
616
|
+
'that document already correct; place the form on the page after.',
|
|
617
|
+
}
|
|
618
|
+
: {}),
|
|
586
619
|
...(Object.keys(plan.skipped).length || found.aliases
|
|
587
620
|
? { skipped: { ...plan.skipped, ...(found.aliases ? { 'canonical-alias': found.aliases } : {}) } }
|
|
588
621
|
: {}),
|
package/dist/vision/capture.js
CHANGED
|
@@ -70,8 +70,13 @@ function capturePage(limits) {
|
|
|
70
70
|
// one kind of content that could not survive the trip at all.
|
|
71
71
|
const IGNORE = new Set([
|
|
72
72
|
'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'PATH', 'CANVAS',
|
|
73
|
-
|
|
73
|
+
// FORM is NOT here: its own branch below records it before returning
|
|
74
|
+
// nothing, so a contact page says it had one. The CONTROLS stay ignored —
|
|
75
|
+
// a stray input outside a form is chrome, and the fields of a form that IS
|
|
76
|
+
// reported are counted there rather than walked into.
|
|
77
|
+
'NAV', 'INPUT', 'SELECT', 'TEXTAREA', 'BUTTON',
|
|
74
78
|
]);
|
|
79
|
+
const forms = [];
|
|
75
80
|
/** The provider and id behind an embed URL, or null if this platform has no element for it. */
|
|
76
81
|
const embedOf = (raw) => {
|
|
77
82
|
const u = raw.split('?')[0];
|
|
@@ -322,6 +327,23 @@ function capturePage(limits) {
|
|
|
322
327
|
return [{ kind: 'accordion', children: [{ kind: 'accordion-item', text: label, children: body }] }];
|
|
323
328
|
}
|
|
324
329
|
// A RULE BETWEEN SECTIONS IS A DESIGN DECISION, and it is one node.
|
|
330
|
+
// A FORM IS SEEN AND NOT REBUILT. Its fields are a vocabulary the server
|
|
331
|
+
// validates (`mapTo`), and `sb_store action:"form"` owns that; what this
|
|
332
|
+
// walk can honestly do is say the page had one, so a contact page does not
|
|
333
|
+
// arrive with no way to contact anybody and nothing saying why.
|
|
334
|
+
if (tag === 'FORM') {
|
|
335
|
+
const controls = Array.from(el.querySelectorAll('input, textarea, select'));
|
|
336
|
+
const labels = [];
|
|
337
|
+
for (const l of Array.from(el.querySelectorAll('label'))) {
|
|
338
|
+
const t = clean(l.textContent);
|
|
339
|
+
if (t && labels.length < 10)
|
|
340
|
+
labels.push(t);
|
|
341
|
+
}
|
|
342
|
+
if (controls.length > 0)
|
|
343
|
+
forms.push({ fields: controls.length, labels });
|
|
344
|
+
skip('form');
|
|
345
|
+
return [];
|
|
346
|
+
}
|
|
325
347
|
if (tag === 'HR') {
|
|
326
348
|
taken.nodes++;
|
|
327
349
|
return [{ kind: 'divider' }];
|
|
@@ -608,6 +630,7 @@ function capturePage(limits) {
|
|
|
608
630
|
url: here,
|
|
609
631
|
title: clean(document.title),
|
|
610
632
|
...(canonical ? { canonical: abs(canonical) } : {}),
|
|
633
|
+
...(forms.length ? { forms } : {}),
|
|
611
634
|
sections,
|
|
612
635
|
skipped,
|
|
613
636
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|