sbuilder-mcp 0.33.0 → 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 +8 -0
- package/CHANGELOG.vi.md +8 -0
- package/dist/domains/site/importmap.js +31 -1
- package/dist/server.js +1 -1
- package/dist/tools/chrome.js +93 -0
- package/dist/tools/store.js +52 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,14 @@ 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
|
+
|
|
9
17
|
## [0.33.0] - 2026-09-11
|
|
10
18
|
|
|
11
19
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,14 @@ 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
|
+
|
|
9
17
|
## [0.33.0] - 2026-09-11
|
|
10
18
|
|
|
11
19
|
### Added
|
|
@@ -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
|
-
|
|
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 };
|
package/dist/tools/store.js
CHANGED
|
@@ -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.
|
|
285
|
-
'the
|
|
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.
|
|
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",
|