sbuilder-mcp 0.32.2 → 0.34.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,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.34.0] - 2026-09-11
10
+
11
+ ### Added
12
+ - sb_store's new `action: "chrome"` builds one shared header (or, with `footer: true`, a shared footer) from the pages a site already has, closing the `siteChrome` gap sb_review has reported since it learned to ask: the menu labels and links come from the site's own pages (home first), the look is read off the home page, and the master is given to every page as a reference instead of copied onto each one. It skips a site that already shares a header or footer, and a site with fewer than two pages. dry_run (the default) returns the planned menu and which pages it would touch without writing anything.
13
+
14
+ ### Fixed
15
+ - The menu built for a shared header or footer (by sb_store's new `action: "chrome"` and by sb_import_site, which already built one) now packs its links against each other at their own width instead of splitting the row into equal-width columns, so three links no longer land spread across the thirds of a wide header with large gaps between them.
16
+
17
+ ## [0.33.0] - 2026-09-11
18
+
19
+ ### Added
20
+ - The catalog now declares seven config keys the platform's renderers always read but had never been listed anywhere: `image`'s `imageEager` (the override for a hero image that is not first, deciding the page's `<link rel=preload>` target), `menu`'s `activeColor`, `dataset-block`'s `contentWidthCustom`, `breadcrumb`'s `currentColor` and `maxItems`, `product-variants`' `optionGap`, and `list-dataset`'s `articleSourceType`. `sb_add`, `sb_set`, and `sb_traits_for` can now read and write all seven.
21
+ - `sb_traits_for`'s `config_values` vocabulary for `list-dataset`'s `articleSourceType` is now reachable: the value vocabulary already existed but the element had never listed the key, so no call could reach it.
22
+
9
23
  ## [0.32.2] - 2026-09-11
10
24
 
11
25
  ### Fixed
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.34.0] - 2026-09-11
10
+
11
+ ### Added
12
+ - Hành động mới `action: "chrome"` của sb_store dựng một header dùng chung (hoặc, với `footer: true`, một footer dùng chung) từ các trang site đã có sẵn, khép lại khoảng trống `siteChrome` mà sb_review đã báo cáo kể từ khi biết hỏi câu này: nhãn và liên kết của menu lấy từ chính các trang của site (trang chủ trước tiên), giao diện được đọc từ trang chủ, và master được gán cho mọi trang dưới dạng tham chiếu thay vì sao chép riêng vào từng trang. Lệnh này bỏ qua nếu site đã có sẵn header hoặc footer dùng chung, và nếu site có ít hơn hai trang. dry_run (mặc định bật) trả về menu dự kiến và các trang sẽ bị tác động mà không ghi gì cả.
13
+
14
+ ### Fixed
15
+ - Menu được dựng cho header hoặc footer dùng chung (bởi `action: "chrome"` mới của sb_store, và bởi sb_import_site — vốn đã dựng sẵn menu này) giờ xếp các liên kết sát nhau theo đúng chiều rộng nội dung của chúng thay vì chia hàng thành các cột bằng nhau, nên ba liên kết không còn bị dàn trải ra ba phần bằng nhau của một header rộng với khoảng trống lớn giữa chúng.
16
+
17
+ ## [0.33.0] - 2026-09-11
18
+
19
+ ### Added
20
+ - Catalog giờ khai báo bảy config key mà renderer của nền tảng luôn đọc nhưng chưa từng được liệt kê ở đâu cả: `imageEager` của `image` (giá trị ghi đè cho ảnh hero không phải ảnh đầu tiên, quyết định đích `<link rel=preload>` của trang), `activeColor` của `menu`, `contentWidthCustom` của `dataset-block`, `currentColor` và `maxItems` của `breadcrumb`, `optionGap` của `product-variants`, và `articleSourceType` của `list-dataset`. sb_add, sb_set và sb_traits_for giờ đọc và ghi được cả bảy key này.
21
+ - Bảng từ vựng `config_values` của sb_traits_for cho `articleSourceType` thuộc `list-dataset` giờ có thể truy cập được: từ vựng giá trị này đã tồn tại từ trước nhưng element chưa từng liệt kê key này, nên không lệnh gọi nào chạm tới được nó.
22
+
9
23
  ## [0.32.2] - 2026-09-11
10
24
 
11
25
  ### Fixed
@@ -1608,7 +1608,8 @@ export const ELEMENTS = {
1608
1608
  "config": {
1609
1609
  "imageRatio": "auto",
1610
1610
  "customImageRatioWidth": 16,
1611
- "customImageRatioHeight": 9
1611
+ "customImageRatioHeight": 9,
1612
+ "imageEager": false
1612
1613
  }
1613
1614
  },
1614
1615
  "inspector": [
@@ -12457,7 +12458,9 @@ export const ELEMENTS = {
12457
12458
  "config": {
12458
12459
  "iconSize": 16,
12459
12460
  "iconColor": "#000000",
12460
- "textGlobalStyle": "text-1"
12461
+ "textGlobalStyle": "text-1",
12462
+ "currentColor": "",
12463
+ "maxItems": 0
12461
12464
  }
12462
12465
  },
12463
12466
  "inspector": [
@@ -12654,7 +12657,8 @@ export const ELEMENTS = {
12654
12657
  "contentWidth": "fill_container",
12655
12658
  "dropdownIcon": "down",
12656
12659
  "expandType": "click",
12657
- "submenuStyle": "dropdown"
12660
+ "submenuStyle": "dropdown",
12661
+ "activeColor": ""
12658
12662
  },
12659
12663
  "specials": {
12660
12664
  "menuId": "",
@@ -19468,7 +19472,8 @@ export const ELEMENTS = {
19468
19472
  "active": true,
19469
19473
  "direction": "vertical",
19470
19474
  "gap": 8
19471
- }
19475
+ },
19476
+ "optionGap": ""
19472
19477
  },
19473
19478
  "specials": {
19474
19479
  "displayType": "select",
@@ -30074,7 +30079,8 @@ export const ELEMENTS = {
30074
30079
  "height": "fit-content"
30075
30080
  },
30076
30081
  "config": {
30077
- "contentWidth": "fill_container"
30082
+ "contentWidth": "fill_container",
30083
+ "contentWidthCustom": 0
30078
30084
  },
30079
30085
  "bindings": [
30080
30086
  {
@@ -31518,6 +31524,7 @@ export const ELEMENTS = {
31518
31524
  },
31519
31525
  "config": {
31520
31526
  "datasetSource": "product",
31527
+ "articleSourceType": "category",
31521
31528
  "loadingMode": "none",
31522
31529
  "layout": "grid",
31523
31530
  "quantity": 4,
@@ -86,6 +86,7 @@ export const STORE_PAGE_SEEDS = {
86
86
  },
87
87
  "config": {
88
88
  "contentWidth": "fill_container",
89
+ "contentWidthCustom": 0,
89
90
  "emptyStateId": "sppro_20"
90
91
  },
91
92
  "specials": {},
@@ -661,6 +662,7 @@ export const STORE_PAGE_SEEDS = {
661
662
  "direction": "vertical",
662
663
  "gap": 8
663
664
  },
665
+ "optionGap": "",
664
666
  "variantLabelId": "sppro_12",
665
667
  "variantOptionId": "sppro_13"
666
668
  },
@@ -1258,6 +1260,7 @@ export const STORE_PAGE_SEEDS = {
1258
1260
  },
1259
1261
  "config": {
1260
1262
  "datasetSource": "product",
1263
+ "articleSourceType": "category",
1261
1264
  "loadingMode": "none",
1262
1265
  "layout": "grid",
1263
1266
  "quantity": 4,
@@ -1341,6 +1344,7 @@ export const STORE_PAGE_SEEDS = {
1341
1344
  },
1342
1345
  "config": {
1343
1346
  "contentWidth": "fill_container",
1347
+ "contentWidthCustom": 0,
1344
1348
  "datasetSource": "product"
1345
1349
  },
1346
1350
  "specials": {},
@@ -1927,6 +1931,7 @@ export const STORE_PAGE_SEEDS = {
1927
1931
  },
1928
1932
  "config": {
1929
1933
  "datasetSource": "product",
1934
+ "articleSourceType": "category",
1930
1935
  "loadingMode": "none",
1931
1936
  "layout": "grid",
1932
1937
  "quantity": 4,
@@ -2010,6 +2015,7 @@ export const STORE_PAGE_SEEDS = {
2010
2015
  },
2011
2016
  "config": {
2012
2017
  "contentWidth": "fill_container",
2018
+ "contentWidthCustom": 0,
2013
2019
  "datasetSource": "product"
2014
2020
  },
2015
2021
  "specials": {},
@@ -2602,6 +2608,7 @@ export const STORE_PAGE_SEEDS = {
2602
2608
  },
2603
2609
  "config": {
2604
2610
  "datasetSource": "article",
2611
+ "articleSourceType": "category",
2605
2612
  "loadingMode": "none",
2606
2613
  "layout": "grid",
2607
2614
  "quantity": 4,
@@ -2686,6 +2693,7 @@ export const STORE_PAGE_SEEDS = {
2686
2693
  },
2687
2694
  "config": {
2688
2695
  "contentWidth": "fill_container",
2696
+ "contentWidthCustom": 0,
2689
2697
  "datasetSource": "article"
2690
2698
  },
2691
2699
  "specials": {},
@@ -423,6 +423,35 @@ function one(c, t) {
423
423
  // Mobile gets one column by name. The flex path's `flexDirection: column`
424
424
  // says nothing to a grid, and a mobile answer that silently does nothing
425
425
  // is rule 3 failing with a value in the document to prove it tried.
426
+ // A PACKED ROW IS NOT A GRID AND NOT AN EQUAL-SHARE ROW. It wraps, so it
427
+ // needs no stack breakpoint of its own — a menu that runs out of width
428
+ // starts a second line, which is what a menu should do.
429
+ if (c.pack) {
430
+ return {
431
+ type: 'flex-block',
432
+ style: {
433
+ width: '100%',
434
+ display: 'flex',
435
+ flexDirection: 'row',
436
+ flexWrap: 'wrap',
437
+ alignItems: 'center',
438
+ gap: '24px',
439
+ ...(c.align === 'center' ? { justifyContent: 'center' } : {}),
440
+ },
441
+ // `width: auto` IS THE LOAD-BEARING HALF, and leaving it out looked
442
+ // like the whole idea had failed. `flex-block` seeds `width: 100%`
443
+ // from its element defaults, and `flex: 0 0 auto` only says "do not
444
+ // grow or shrink from the BASIS" — the basis being `auto`, which
445
+ // reads the width. So every packed cell stayed 100% wide and the
446
+ // menu came out as a vertical list: measured, three links stacked in
447
+ // a 140px header where the equal-share version had been 52.
448
+ children: kids.map((k) => ({
449
+ type: 'flex-block',
450
+ style: { flex: '0 0 auto', width: 'auto', display: 'flex', flexDirection: 'column' },
451
+ children: [k],
452
+ })),
453
+ };
454
+ }
426
455
  if (c.wrap) {
427
456
  return {
428
457
  type: 'flex-block',
@@ -654,7 +683,8 @@ export function navSpec(links, t) {
654
683
  {
655
684
  kind: 'group',
656
685
  direction: 'row',
657
- wrap: true,
686
+ // PACKED, not an equal share: a menu's links sit against each other.
687
+ pack: true,
658
688
  children: links.map((l) => ({
659
689
  kind: 'button',
660
690
  variant: 'link',
package/dist/server.js CHANGED
@@ -70,7 +70,7 @@ export function createServer(ctx = buildContext()) {
70
70
  registerApiTools(server, ctx);
71
71
  const pageSession = registerPageTools(server, ctx);
72
72
  registerLiveTools(server, ctx, pageSession);
73
- registerStoreTools(server, ctx);
73
+ registerStoreTools(server, ctx, pageSession);
74
74
  registerThemeTools(server, ctx);
75
75
  registerImportTools(server, ctx, pageSession);
76
76
  registerUndoTools(server, ctx);
@@ -0,0 +1,93 @@
1
+ import { request } from '../transport/http.js';
2
+ import { siteToken } from './credentialpick.js';
3
+ import { siteFor } from './context.js';
4
+ import { addSubtree } from '../domains/site/builder.js';
5
+ import { menuLabel, navSpec, tokensFromPage } from '../domains/site/importmap.js';
6
+ import { globalDocumentFrom } from './importpage.js';
7
+ /** Does this site already share a section of this kind? */
8
+ export async function hasGlobal(ctx, siteId, kind) {
9
+ const got = (await request({
10
+ base: ctx.base,
11
+ method: 'GET',
12
+ path: `/api/sites/${encodeURIComponent(siteId)}/global-sections`,
13
+ token: siteToken(ctx),
14
+ fetchImpl: ctx.fetchImpl,
15
+ }));
16
+ return (got.globalSections ?? []).some((g) => g.kind === kind);
17
+ }
18
+ /**
19
+ * Create ONE global section from a link list and give every named page a
20
+ * reference to it.
21
+ *
22
+ * NOT ATOMIC, and it must not pretend to be: one page that will not take the
23
+ * header does not undo the header. The master exists, the others carry it, and
24
+ * the refusal is reported per page — the same shape `sb_import_site` uses for
25
+ * its own per-page report.
26
+ */
27
+ export async function shareChrome(ctx, session, siteId, kind, links, pages, tokens) {
28
+ const out = { carried: [], failed: [] };
29
+ const spec = navSpec(links, tokens);
30
+ if (!spec) {
31
+ out.skipped = 'no links to put in it';
32
+ return out;
33
+ }
34
+ const made = (await request({
35
+ base: ctx.base,
36
+ method: 'POST',
37
+ path: `/api/sites/${encodeURIComponent(siteId)}/global-sections`,
38
+ token: siteToken(ctx),
39
+ body: {
40
+ name: kind === 'header' ? 'Header' : 'Footer',
41
+ kind,
42
+ document: globalDocumentFrom(spec),
43
+ },
44
+ fetchImpl: ctx.fetchImpl,
45
+ }));
46
+ const gid = made.globalSection?.id;
47
+ if (typeof gid !== 'string' || !gid) {
48
+ out.skipped = 'the platform created no global section';
49
+ return out;
50
+ }
51
+ out.created = gid;
52
+ for (const p of pages) {
53
+ try {
54
+ await session.open(siteId, p.id);
55
+ const doc = session.current();
56
+ // A HEADER GOES IN FIRST AND A FOOTER LAST. ROOT's children must read
57
+ // [header*][middle*][footer*] and the platform refuses EVERY save
58
+ // otherwise — compose turns the reference into a real band, so its index
59
+ // is the band it becomes.
60
+ const at = kind === 'header' ? 0 : doc.doc.nodes[doc.doc.root_node_id]?.data?.nodes?.length ?? 0;
61
+ const { patches } = addSubtree(doc, doc.doc.root_node_id, { type: 'flex-section', specials: { globalRef: gid, globalKind: kind } }, at);
62
+ await session.applyAndSave(patches);
63
+ out.carried.push(p.slug);
64
+ }
65
+ catch (e) {
66
+ out.failed.push({ page: p.slug, why: e.message.replace(/^sbuilder:\s*/, '').slice(0, 160) });
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+ /** Every page this site has, as the menu would name them. */
72
+ export async function sitePages(ctx, siteId) {
73
+ const got = (await request({
74
+ base: ctx.base,
75
+ method: 'GET',
76
+ path: `/api/sites/${encodeURIComponent(siteId)}/pages`,
77
+ token: siteToken(ctx),
78
+ fetchImpl: ctx.fetchImpl,
79
+ }));
80
+ return (got.pages ?? []).map((p) => ({
81
+ id: String(p.id ?? ''),
82
+ slug: String(p.slug ?? ''),
83
+ name: String(p.name ?? p.title ?? p.slug ?? ''),
84
+ isHome: p.isHomepage === true || p.slug === '',
85
+ }));
86
+ }
87
+ /** The link list a menu built from these pages would carry, home first. */
88
+ export function chromeLinks(pages) {
89
+ return [...pages]
90
+ .sort((a, b) => Number(b.isHome) - Number(a.isHome))
91
+ .map((p) => ({ text: menuLabel(p.name), href: p.isHome ? '/' : `/${p.slug}` }));
92
+ }
93
+ export { siteFor, tokensFromPage };
@@ -33,6 +33,8 @@ import { request, redact } from '../transport/http.js';
33
33
  import { siteToken } from './credentialpick.js';
34
34
  import { siteFor } from './context.js';
35
35
  import { genId } from '../domains/site/ids.js';
36
+ import { chromeLinks, hasGlobal, shareChrome, sitePages } from './chrome.js';
37
+ import { tokensFromPage } from '../domains/site/importmap.js';
36
38
  import { CHECKOUT_FORM, CHECKOUT_FORM_DOCUMENT, CHECKOUT_PAGE_DOCUMENT, CHECKOUT_TEXT, FORM_ID_SENTINEL, HEADLINE_SENTINEL, FORM_TEMPLATES, } from '../catalog/checkout.generated.js';
37
39
  /**
38
40
  * The store's live gateways and delivery options.
@@ -274,17 +276,18 @@ async function seedForm(ctx, siteId, key, formName, dryRun) {
274
276
  'what makes a form-document edit visible.',
275
277
  };
276
278
  }
277
- export function registerStoreTools(server, ctx) {
279
+ export function registerStoreTools(server, ctx, session) {
278
280
  server.registerTool('sb_store', {
279
281
  description: 'Run a store flow that must happen in a fixed order. action:"checkout" makes the order ' +
280
282
  'form, configures it, saves its fields with this store\'s real payment and delivery ' +
281
283
  'options, then creates and PUBLISHES the checkout page — /checkout 404s without all ' +
282
284
  'four. action:"form" seeds any of the platform\'s other form templates (login, ' +
283
285
  'register, forgot, reset, verify, contact, subscribe, booking, review and more) with ' +
284
- 'its own field document, which is the part that cannot be guessed. Dry run returns ' +
285
- 'the plan.',
286
+ 'its own field document, which is the part that cannot be guessed. action:"chrome" ' +
287
+ 'gives every page ONE shared header, built from the pages this site already has — the ' +
288
+ 'gap sb_review reports as siteChrome. Dry run returns the plan.',
286
289
  inputSchema: {
287
- action: z.enum(['checkout', 'form']),
290
+ action: z.enum(['checkout', 'form', 'chrome']),
288
291
  site_id: z.string().optional(),
289
292
  language: z.enum(['vi', 'en']).optional().describe('Copy language, default vi'),
290
293
  page_name: z.string().optional(),
@@ -294,11 +297,55 @@ export function registerStoreTools(server, ctx) {
294
297
  .optional()
295
298
  .describe('action:"form" — which of the platform\'s own form templates to seed'),
296
299
  name: z.string().optional().describe('action:"form" — the form\'s name in the merchant\'s list'),
300
+ footer: z
301
+ .boolean()
302
+ .optional()
303
+ .describe('action:"chrome" — build a shared FOOTER instead of a header'),
297
304
  dry_run: z.boolean().optional(),
298
305
  },
299
306
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
300
- }, async ({ action, site_id: given, language, page_name, headline, template, name, dry_run }) => {
307
+ }, async ({ action, site_id: given, language, page_name, headline, template, name, footer, dry_run }) => {
301
308
  const siteId = siteFor(ctx, given);
309
+ if (action === 'chrome') {
310
+ // SKIPPED WHEN THE SITE ALREADY SHARES ONE, because a second header is
311
+ // two headers rather than a menu — and below two pages, because a menu
312
+ // to one page is a link to itself. Both are `sb_import_site`'s own
313
+ // rules, kept because they were right there.
314
+ const kind = footer === true ? 'footer' : 'header';
315
+ if (await hasGlobal(ctx, siteId, kind)) {
316
+ return text({
317
+ skipped: `this site already shares a ${kind} — a second one is two of them, not a menu`,
318
+ });
319
+ }
320
+ const pages = await sitePages(ctx, siteId);
321
+ if (pages.length < 2) {
322
+ return text({ skipped: 'a menu to one page is a link to itself' });
323
+ }
324
+ const links = chromeLinks(pages);
325
+ // RULE 0: the look comes off the HOME page, which is the one page whose
326
+ // pattern the rest of the site already follows. A site with nothing on
327
+ // its home page yields no tokens rather than an invented palette.
328
+ const home = pages.find((p) => p.isHome) ?? pages[0];
329
+ await session.open(siteId, home.id);
330
+ const tokens = tokensFromPage(session.current().doc);
331
+ if (dry_run !== false) {
332
+ return text({
333
+ dry_run: true,
334
+ would_create: kind,
335
+ menu: links,
336
+ onto: pages.map((p) => p.slug),
337
+ tokens_from: Object.keys(tokens).length ? 'the home page' : 'nothing — the home page is blank',
338
+ note: 'Nothing was sent. This creates ONE shared master and gives every page a reference ' +
339
+ 'to it, so the menu becomes one edit instead of one per page.',
340
+ });
341
+ }
342
+ const out = await shareChrome(ctx, session, siteId, kind, links, pages, tokens);
343
+ return text({
344
+ ...out,
345
+ next: 'Publish the pages: a global section reaches a visitor through each page it is ' +
346
+ 'composed onto, so a saved page keeps the old chrome until it is published again.',
347
+ });
348
+ }
302
349
  if (action === 'form') {
303
350
  if (!template) {
304
351
  throw new Error(`sbuilder: action:"form" needs a template. One of: ${FORM_TEMPLATE_KEYS.join(', ')}.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.32.2",
3
+ "version": "0.34.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",