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 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
- href = to;
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
+ }
@@ -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.21.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",