sbuilder-mcp 0.21.0 → 0.22.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 +11 -0
- package/CHANGELOG.vi.md +11 -0
- package/dist/domains/site/importmap.js +63 -1
- package/dist/tools/importpage.js +104 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,17 @@ 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.22.0] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_import_site now builds one shared global `header` section from the pages it created and gives every page a reference to it, so editing the menu once changes it on every page instead of leaving each page to carry its own copy.
|
|
13
|
+
- sb_import_site takes a `nav` argument (default true) to skip the shared header, and reports what happened under `shared_header` in its result.
|
|
14
|
+
|
|
15
|
+
## [0.21.1] - 2026-09-10
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- sb_import_site now keeps the fragment (e.g. `#community`) when it rewrites a same-page section link to point at the imported page, instead of dropping it and sending every such link to the top of the page.
|
|
19
|
+
|
|
9
20
|
## [0.21.0] - 2026-09-10
|
|
10
21
|
|
|
11
22
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,17 @@ 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.22.0] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_import_site giờ dựng một global section `header` dùng chung từ các trang nó đã tạo và cho mỗi trang một tham chiếu tới đó, nên sửa menu một lần là đổi trên mọi trang thay vì mỗi trang tự mang một bản riêng.
|
|
13
|
+
- sb_import_site nhận tham số `nav` (mặc định true) để bỏ qua header dùng chung, và báo kết quả qua `shared_header` trong output.
|
|
14
|
+
|
|
15
|
+
## [0.21.1] - 2026-09-10
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- sb_import_site giờ giữ lại fragment (ví dụ `#community`) khi viết lại một link trỏ tới section trong cùng trang đã import, thay vì bỏ mất nó và khiến mọi link kiểu này đều trỏ về đầu trang.
|
|
19
|
+
|
|
9
20
|
## [0.21.0] - 2026-09-10
|
|
10
21
|
|
|
11
22
|
### Added
|
|
@@ -441,7 +441,14 @@ export function relink(captured, local, origin) {
|
|
|
441
441
|
const norm = normalizeUrl(href);
|
|
442
442
|
const to = norm ? local.get(norm) : undefined;
|
|
443
443
|
if (to) {
|
|
444
|
-
|
|
444
|
+
// THE FRAGMENT SURVIVES. `normalizeUrl` drops it because it is not part
|
|
445
|
+
// of a page's IDENTITY — that is what folds `/a` and `/a#top` into one
|
|
446
|
+
// page — but it is very much part of the link, and a source page's
|
|
447
|
+
// "jump to the forums" section link would otherwise land at the top of
|
|
448
|
+
// the page and look broken. Measured on a real import: six of them on
|
|
449
|
+
// one page.
|
|
450
|
+
const hash = href.indexOf('#');
|
|
451
|
+
href = hash >= 0 ? `${to}${href.slice(hash)}` : to;
|
|
445
452
|
rewritten += 1;
|
|
446
453
|
}
|
|
447
454
|
else if (norm && norm.indexOf(origin) === 0) {
|
|
@@ -453,3 +460,58 @@ export function relink(captured, local, origin) {
|
|
|
453
460
|
};
|
|
454
461
|
return { sections: captured.map(one), rewritten, unimported };
|
|
455
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* A menu label from a page's own `<title>`.
|
|
465
|
+
*
|
|
466
|
+
* A title is written for a browser tab and a search result — "Example Servers —
|
|
467
|
+
* Model Context Protocol" — and a menu row of those wraps to three lines. The
|
|
468
|
+
* part before the first separator is what the page calls itself; the cap is
|
|
469
|
+
* what keeps one long name from owning the row.
|
|
470
|
+
*/
|
|
471
|
+
export function menuLabel(name) {
|
|
472
|
+
const head = name.split(/\s+[|—–·:]\s+/)[0].trim() || name.trim();
|
|
473
|
+
return head.length > 28 ? `${head.slice(0, 27).trimEnd()}…` : head;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* THE SHARED HEADER, built from the pages that were actually created.
|
|
477
|
+
*
|
|
478
|
+
* NOT from the source's own nav, deliberately. Its links point at the site it
|
|
479
|
+
* was copied from, half of them at pages the cap left out, and its structure is
|
|
480
|
+
* somebody else's — three reasons the capture skips page chrome in the first
|
|
481
|
+
* place. What the merchant needs is a way to reach THESE pages, and that list is
|
|
482
|
+
* already known exactly.
|
|
483
|
+
*
|
|
484
|
+
* Built through `toSpecs` rather than hand-assembled so it wears the same tokens
|
|
485
|
+
* every imported section does — a header that answers the accent differently is
|
|
486
|
+
* rule 0 broken on the one band that appears on every page. Only the padding is
|
|
487
|
+
* overridden: a section's 64px is right for a band of content and absurd for a
|
|
488
|
+
* menu.
|
|
489
|
+
*/
|
|
490
|
+
export function navSpec(links, t) {
|
|
491
|
+
if (links.length === 0)
|
|
492
|
+
return null;
|
|
493
|
+
const [section] = toSpecs([
|
|
494
|
+
{
|
|
495
|
+
kind: 'section',
|
|
496
|
+
children: [
|
|
497
|
+
{
|
|
498
|
+
kind: 'group',
|
|
499
|
+
direction: 'row',
|
|
500
|
+
wrap: true,
|
|
501
|
+
children: links.map((l) => ({
|
|
502
|
+
kind: 'button',
|
|
503
|
+
variant: 'link',
|
|
504
|
+
text: l.text,
|
|
505
|
+
href: l.href,
|
|
506
|
+
})),
|
|
507
|
+
},
|
|
508
|
+
],
|
|
509
|
+
},
|
|
510
|
+
], t);
|
|
511
|
+
if (!section)
|
|
512
|
+
return null;
|
|
513
|
+
return {
|
|
514
|
+
...section,
|
|
515
|
+
style: { ...section.style, padding: '16px 24px' },
|
|
516
|
+
};
|
|
517
|
+
}
|
package/dist/tools/importpage.js
CHANGED
|
@@ -4,7 +4,8 @@ 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, relink, } from '../domains/site/importmap.js';
|
|
7
|
+
import { toSpecs, tokensFromPage, imageSources, menuLabel, navSpec, rehostImages, relink, } from '../domains/site/importmap.js';
|
|
8
|
+
import { subtreeIds } from '../core/tree.js';
|
|
8
9
|
import { canonFor, choosePages, normalizeUrl, robotsRules, robotsSitemaps, sitemapUrls, } from '../domains/site/discover.js';
|
|
9
10
|
import { loadSource } from '../transport/pages.js';
|
|
10
11
|
import { PageDoc } from '../domains/site/document.js';
|
|
@@ -156,6 +157,26 @@ async function discoverSite(ctx, entry, opts) {
|
|
|
156
157
|
}
|
|
157
158
|
return { source: 'links', urls, titles: crawled.titles, visited: crawled.visited, robots, aliases };
|
|
158
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* A built subtree as the document a GLOBAL SECTION stores.
|
|
162
|
+
*
|
|
163
|
+
* A global's document is page-document SHAPED but its `root_node_id` IS the
|
|
164
|
+
* section — compose carries its nodes over and re-parents that root onto the
|
|
165
|
+
* page's ROOT (`server/internal/page/compose.go:133`). So the section is built
|
|
166
|
+
* the ordinary way, under a throwaway ROOT, and then lifted out with its parent
|
|
167
|
+
* cleared: a master has no parent until a page composes it.
|
|
168
|
+
*/
|
|
169
|
+
export function globalDocumentFrom(spec) {
|
|
170
|
+
const scratch = PageDoc.from({ schema_version: 2, root_node_id: '', nodes: {} });
|
|
171
|
+
const { patches, ids } = addSubtree(scratch, scratch.doc.root_node_id, spec);
|
|
172
|
+
scratch.apply(patches);
|
|
173
|
+
const rootId = ids[0];
|
|
174
|
+
const nodes = {};
|
|
175
|
+
for (const id of subtreeIds(scratch.doc, rootId))
|
|
176
|
+
nodes[id] = scratch.doc.nodes[id];
|
|
177
|
+
nodes[rootId].data.parent = null;
|
|
178
|
+
return { schema_version: 2, root_node_id: rootId, nodes };
|
|
179
|
+
}
|
|
159
180
|
export function registerImportTools(server, ctx, session) {
|
|
160
181
|
server.registerTool('sb_import', {
|
|
161
182
|
description: 'Read a page from any public URL and add its structure and content to the OPEN page as ' +
|
|
@@ -313,10 +334,11 @@ export function registerImportTools(server, ctx, session) {
|
|
|
313
334
|
max_nodes: z.number().int().min(1).max(1000).optional().describe('Per page, default 300'),
|
|
314
335
|
upload_images: z.boolean().optional(),
|
|
315
336
|
homepage: z.boolean().optional().describe("Entry into this site's home page, default true"),
|
|
337
|
+
nav: z.boolean().optional().describe('Shared header linking the new pages, default true'),
|
|
316
338
|
dry_run: z.boolean().optional(),
|
|
317
339
|
},
|
|
318
340
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
319
|
-
}, async ({ url, site_id: given, max_pages, depth, include, exclude, max_images, max_nodes, upload_images, homepage, dry_run, }) => {
|
|
341
|
+
}, async ({ url, site_id: given, max_pages, depth, include, exclude, max_images, max_nodes, upload_images, homepage, nav, dry_run, }) => {
|
|
320
342
|
const siteId = siteFor(ctx, given);
|
|
321
343
|
const entry = normalizeUrl(url);
|
|
322
344
|
if (!entry) {
|
|
@@ -597,6 +619,85 @@ export function registerImportTools(server, ctx, session) {
|
|
|
597
619
|
failed.push({ url: p.url, why: e.message.replace(/^sbuilder:\s*/, '').slice(0, 200) });
|
|
598
620
|
}
|
|
599
621
|
}
|
|
622
|
+
// THE MENU THAT LINKS THEM TOGETHER — the last thing the directive said this
|
|
623
|
+
// could not do for you.
|
|
624
|
+
//
|
|
625
|
+
// Built from the pages that were ACTUALLY created, never from the source's
|
|
626
|
+
// own nav: that one points at the site this was copied from, half of it at
|
|
627
|
+
// pages the cap left out, and its structure is somebody else's. What the
|
|
628
|
+
// merchant needs is a way to reach THESE pages, and that list is known
|
|
629
|
+
// exactly.
|
|
630
|
+
//
|
|
631
|
+
// Skipped when the site already has a header, because a second one is not
|
|
632
|
+
// an improvement — it is two headers. Also skipped below two pages: a menu
|
|
633
|
+
// to one page is a link to itself.
|
|
634
|
+
let chrome;
|
|
635
|
+
if (nav !== false && built.length >= 2) {
|
|
636
|
+
try {
|
|
637
|
+
const existingGlobals = (await request({
|
|
638
|
+
base: ctx.base,
|
|
639
|
+
method: 'GET',
|
|
640
|
+
path: `/api/sites/${encodeURIComponent(siteId)}/global-sections`,
|
|
641
|
+
token: siteToken(ctx),
|
|
642
|
+
fetchImpl: ctx.fetchImpl,
|
|
643
|
+
}));
|
|
644
|
+
const hasHeader = (existingGlobals.globalSections ?? []).some((g) => g.kind === 'header');
|
|
645
|
+
if (hasHeader) {
|
|
646
|
+
chrome = {
|
|
647
|
+
skipped: 'this site already has a header global section — a second one is two headers, not a menu',
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
const links = built
|
|
652
|
+
.map((b) => {
|
|
653
|
+
const planned = plan.pages.find((p) => p.url === b.url);
|
|
654
|
+
const to = localPath.get(String(b.url));
|
|
655
|
+
return planned && to ? { text: menuLabel(planned.name), href: to } : null;
|
|
656
|
+
})
|
|
657
|
+
.filter((l) => l !== null);
|
|
658
|
+
const spec = navSpec(links, tokens);
|
|
659
|
+
if (spec) {
|
|
660
|
+
const made = (await request({
|
|
661
|
+
base: ctx.base,
|
|
662
|
+
method: 'POST',
|
|
663
|
+
path: `/api/sites/${encodeURIComponent(siteId)}/global-sections`,
|
|
664
|
+
token: siteToken(ctx),
|
|
665
|
+
body: { name: 'Header', kind: 'header', document: globalDocumentFrom(spec) },
|
|
666
|
+
fetchImpl: ctx.fetchImpl,
|
|
667
|
+
}));
|
|
668
|
+
const gid = made.globalSection?.id;
|
|
669
|
+
if (typeof gid === 'string' && gid) {
|
|
670
|
+
// A PAGE REFERENCES a master with a ROOT child carrying
|
|
671
|
+
// `globalRef` — the shape the platform's own decompose writes
|
|
672
|
+
// (`decompose.go:382`), down to the flex-section type and the
|
|
673
|
+
// `globalKind` beside it. FIRST, because compose turns it into a
|
|
674
|
+
// real header and a header after middle content is a band-order
|
|
675
|
+
// refusal on the next save.
|
|
676
|
+
const carried = [];
|
|
677
|
+
for (const b of built) {
|
|
678
|
+
try {
|
|
679
|
+
await session.open(siteId, String(b.page_id));
|
|
680
|
+
const doc = session.current();
|
|
681
|
+
const { patches } = addSubtree(doc, doc.doc.root_node_id, { type: 'flex-section', specials: { globalRef: gid, globalKind: 'header' } }, 0);
|
|
682
|
+
await session.applyAndSave(patches);
|
|
683
|
+
carried.push(String(b.slug));
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
// One page that will not take the header does not undo the
|
|
687
|
+
// header: the master exists and the others carry it.
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
chrome = { header: gid, on: carried, links: links.length };
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
catch (e) {
|
|
696
|
+
// The pages are built and correct; a header that could not be made is
|
|
697
|
+
// a thing to report, never a reason to fail the import.
|
|
698
|
+
chrome = { failed: e.message.replace(/^sbuilder:\s*/, '').slice(0, 160) };
|
|
699
|
+
}
|
|
700
|
+
}
|
|
600
701
|
return text({
|
|
601
702
|
entry,
|
|
602
703
|
discovered_by: found.source,
|
|
@@ -607,6 +708,7 @@ export function registerImportTools(server, ctx, session) {
|
|
|
607
708
|
rewritten: relinked,
|
|
608
709
|
...(unimported ? { still_off_site: unimported } : {}),
|
|
609
710
|
},
|
|
711
|
+
...(chrome ? { shared_header: chrome } : {}),
|
|
610
712
|
...(formsSeen.length
|
|
611
713
|
? {
|
|
612
714
|
forms_found: formsSeen,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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",
|