sbuilder-mcp 0.72.0 → 0.74.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/findings.js +3 -0
- package/dist/domains/site/readiness-fetch.js +29 -1
- package/dist/domains/site/readiness.js +94 -0
- package/dist/domains/site/review.js +30 -0
- package/dist/smoke.js +3 -1
- 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.74.0] - 2026-09-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `sb_review` gains an `unreachablePage` readiness gap: since a menu is a record separate from the page it links, a site can publish a page that no menu item and no href in the shared header, footer, or open page points at, so the address resolves and the sitemap lists it while no visitor ever arrives; it checks only published content pages (never the home page) and stays silent until both the menus and the shared sections have been read.
|
|
13
|
+
|
|
14
|
+
## [0.73.0] - 2026-09-28
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `sb_review` gains a `no_h1` finding: every heading on a page ships as h2 (the renderer's default `htmlTag`, shared by `text` and `text-dataset`), so a page with headings and no h1 has no main title for a search engine or a screen reader, invisible on the canvas; it fires on the first heading in the page's own bands, skipping overlays.
|
|
18
|
+
- `sb_review` gains a `homepage` readiness gap: a site resolves "/" by the `isHomepage` flag, so a site whose pages all carry slugs answers 404 at its own front door while every page reviews clean; it reports a draft home page separately from no home page at all, and stays silent when the page list does not carry the flag.
|
|
19
|
+
|
|
9
20
|
## [0.72.0] - 2026-09-28
|
|
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.74.0] - 2026-09-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `sb_review` có thêm gap readiness `unreachablePage`: vì menu là một bản ghi riêng biệt với trang mà nó trỏ tới, một site có thể publish một trang mà không mục menu nào và không href nào trong header/footer dùng chung hay trên trang đang mở trỏ tới, nên địa chỉ vẫn vào được và sitemap vẫn liệt kê trong khi không khách nào tới được; gap này chỉ xét các trang nội dung đã publish (không tính trang chủ) và im lặng cho tới khi cả menu lẫn các section dùng chung đều đã đọc được.
|
|
13
|
+
|
|
14
|
+
## [0.73.0] - 2026-09-28
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `sb_review` có thêm finding `no_h1`: mọi heading trên trang đều được dựng thành h2 (giá trị mặc định `htmlTag` của renderer, dùng chung với `text` và `text-dataset`), nên một trang có heading mà không có h1 thì không có tiêu đề chính cho máy tìm kiếm hay trình đọc màn hình, và điều này vô hình trên canvas; finding báo trên heading đầu tiên trong band của chính trang đó, bỏ qua overlay.
|
|
18
|
+
- `sb_review` có thêm gap readiness `homepage`: site phân giải "/" bằng cờ `isHomepage`, nên một site mà mọi trang đều có slug sẽ trả 404 ngay tại trang chủ dù mọi trang khác review sạch; gap báo riêng trường hợp trang chủ tồn tại nhưng chưa publish với trường hợp không trang nào là trang chủ, và im lặng khi danh sách trang không mang cờ này.
|
|
19
|
+
|
|
9
20
|
## [0.72.0] - 2026-09-28
|
|
10
21
|
|
|
11
22
|
### Added
|
|
@@ -86,6 +86,9 @@ export const FIX = {
|
|
|
86
86
|
hover_dead: 'Rewrite it with the same values: sb_set id "<id>", namespace style, state "hover", ' +
|
|
87
87
|
'keys { … } — read them off the node first, because that one call both writes them where ' +
|
|
88
88
|
'the renderer looks AND clears the slot nobody reads. Nothing else to do afterwards.',
|
|
89
|
+
no_h1: 'Make the page\'s main title an h1: sb_set id "<id>", namespace specials, keys ' +
|
|
90
|
+
'{ "htmlTag": "h1" } — ONE per page, on the heading the page is about (the first one ' +
|
|
91
|
+
'is named here; pick a different one if it is not the title). Every heading ships as h2.',
|
|
89
92
|
stuck_no_host: 'Pin something: sb_set id "<id>" (or the section it lives in), namespace style, keys ' +
|
|
90
93
|
'{ "position": "sticky" } — sb_set seeds the offset and the layer order with it. Until ' +
|
|
91
94
|
'then the platform compiles no rule for this state at all, so the override is stored, ' +
|
|
@@ -24,7 +24,7 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
24
24
|
}
|
|
25
25
|
};
|
|
26
26
|
const site = encodeURIComponent(siteId);
|
|
27
|
-
const [pageList, gateways, shipping, globals, productList, categoryList, pageLinks, formList, articleList, blogCategoryList, courseList, siteRecord, overlayList, settings] = await Promise.all([
|
|
27
|
+
const [pageList, gateways, shipping, globals, productList, categoryList, pageLinks, formList, articleList, blogCategoryList, courseList, siteRecord, overlayList, settings, menuList] = await Promise.all([
|
|
28
28
|
get(`/api/sites/${site}/pages`),
|
|
29
29
|
get(`/api/sites/${site}/payment-gateways`),
|
|
30
30
|
get(`/api/sites/${site}/shipping-methods`),
|
|
@@ -67,6 +67,9 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
67
67
|
get(`/api/sites/${site}/overlays`),
|
|
68
68
|
// `<html lang>` is served from this; see siteLanguage in readiness.ts.
|
|
69
69
|
get(`/api/sites/${site}/settings`),
|
|
70
|
+
// THE MENUS, for what a visitor can reach: a page nothing links to is
|
|
71
|
+
// published, listed in the sitemap, and never arrived at.
|
|
72
|
+
get(`/api/sites/${site}/menus`),
|
|
70
73
|
]);
|
|
71
74
|
// A gateway counts only when it is BOTH enabled and configured — the editor's
|
|
72
75
|
// `live` getter also filters by the store's currency, which is a narrowing:
|
|
@@ -111,8 +114,33 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
111
114
|
// A page an entity links to is that entity's own; only the shared template
|
|
112
115
|
// counts as the category template, and a row without the flag is read as one.
|
|
113
116
|
const open = openPageId ? pageList?.pages?.find((p) => p.id === openPageId) : undefined;
|
|
117
|
+
// Every level of every menu: page references by id, url links by address.
|
|
118
|
+
const menuLinks = Array.isArray(menuList?.menus)
|
|
119
|
+
? (() => {
|
|
120
|
+
const pageIds = [];
|
|
121
|
+
const hrefs = [];
|
|
122
|
+
const walk = (items) => {
|
|
123
|
+
if (!Array.isArray(items))
|
|
124
|
+
return;
|
|
125
|
+
for (const it of items) {
|
|
126
|
+
if (!it || typeof it !== 'object')
|
|
127
|
+
continue;
|
|
128
|
+
const r = it;
|
|
129
|
+
if (r.link?.type === 'page' && typeof r.link.pageId === 'string')
|
|
130
|
+
pageIds.push(r.link.pageId);
|
|
131
|
+
if (r.link?.type === 'url' && typeof r.link.url === 'string')
|
|
132
|
+
hrefs.push(r.link.url);
|
|
133
|
+
walk(r.items);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
for (const m of menuList.menus)
|
|
137
|
+
walk(m?.items);
|
|
138
|
+
return { pageIds, hrefs };
|
|
139
|
+
})()
|
|
140
|
+
: null;
|
|
114
141
|
return {
|
|
115
142
|
pages: pageList?.pages ?? null,
|
|
143
|
+
menuLinks,
|
|
116
144
|
openPageType: open && open.isDefaultTemplate !== false ? open.type : undefined,
|
|
117
145
|
products,
|
|
118
146
|
liveGateways,
|
|
@@ -81,9 +81,103 @@ const drafted = (pages, type) => pages.some((p) => p.type === type && p.status !
|
|
|
81
81
|
* different question asked second. Shared chrome is a different question asked
|
|
82
82
|
* FIRST, because it is true of every site and not only of a store.
|
|
83
83
|
*/
|
|
84
|
+
/** "/about/", "/about?x=1" and "/about" are one address; "" is the home page. */
|
|
85
|
+
function normalizePath(p) {
|
|
86
|
+
const s = p.split(/[?#]/)[0].replace(/\/+$/, '');
|
|
87
|
+
return s === '' ? '/' : s;
|
|
88
|
+
}
|
|
89
|
+
/** Every href a set of nodes carries: `specials.href`, and a menu node's own snapshot rows. */
|
|
90
|
+
function hrefsIn(nodes) {
|
|
91
|
+
const out = new Set();
|
|
92
|
+
const rows = (items) => {
|
|
93
|
+
if (!Array.isArray(items))
|
|
94
|
+
return;
|
|
95
|
+
for (const it of items) {
|
|
96
|
+
if (!it || typeof it !== 'object')
|
|
97
|
+
continue;
|
|
98
|
+
const r = it;
|
|
99
|
+
if (typeof r.href === 'string' && r.href !== '')
|
|
100
|
+
out.add(normalizePath(r.href));
|
|
101
|
+
rows(r.items);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
for (const n of nodes) {
|
|
105
|
+
const h = n.specials?.href;
|
|
106
|
+
if (typeof h === 'string' && h !== '')
|
|
107
|
+
out.add(normalizePath(h));
|
|
108
|
+
rows(n.specials?.menuItems);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
84
112
|
export function readinessGaps(input) {
|
|
85
113
|
const gaps = [];
|
|
86
114
|
const pages = input.pages;
|
|
115
|
+
// NOTHING ANSWERS AT "/".
|
|
116
|
+
//
|
|
117
|
+
// storefront.go: the empty slug is the home page, resolved by the
|
|
118
|
+
// isHomepage flag — a site whose pages all carry slugs serves a 404 at its
|
|
119
|
+
// own front door, while every page on it is reachable and reviews clean.
|
|
120
|
+
// Reached by an agent that built pages by name and never named one home, or
|
|
121
|
+
// demoted the home page by moving the star (page.ts explains the flag). Only
|
|
122
|
+
// when the list carried the flag at all.
|
|
123
|
+
if (pages && pages.length > 0 && pages.some((p) => typeof p.isHomepage === 'boolean')) {
|
|
124
|
+
const home = pages.filter((p) => p.isHomepage === true);
|
|
125
|
+
if (!home.some((p) => p.status === 'published')) {
|
|
126
|
+
const draft = home.length > 0;
|
|
127
|
+
gaps.push({
|
|
128
|
+
id: 'homepage',
|
|
129
|
+
draft,
|
|
130
|
+
problem: draft
|
|
131
|
+
? 'The home page exists but is not published, so "/" — the address on every card, ' +
|
|
132
|
+
'link and search result — answers 404 while every other page is live.'
|
|
133
|
+
: 'No page is the home page, so "/" answers 404. Every page on the site is reachable ' +
|
|
134
|
+
'by its slug and none of them is the front door.',
|
|
135
|
+
fix: draft
|
|
136
|
+
? 'Publish the home page.'
|
|
137
|
+
: 'Make one: PATCH /api/v1/pages/{id} { "isHomepage": true } on the page that should ' +
|
|
138
|
+
'answer at "/" — it moves the star and clears the slug. Or sb_page_create with ' +
|
|
139
|
+
'is_homepage: true, which adopts the home page the site already has.',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// A PAGE NO VISITOR CAN GET TO.
|
|
144
|
+
//
|
|
145
|
+
// Every tool here authors one page and a menu is a separate record, so a
|
|
146
|
+
// site can carry a published About page that nothing links to: the sitemap
|
|
147
|
+
// lists it, the address works, and no visitor ever arrives. Reachable means
|
|
148
|
+
// a menu item references the page or its path, or something in the shared
|
|
149
|
+
// chrome or on the open page carries its path as an href (a `menu` node's
|
|
150
|
+
// own snapshot included). Content pages only, never the home page, and only
|
|
151
|
+
// when both the menus and the shared sections were read — a footer link
|
|
152
|
+
// this rule could not see must not become a finding.
|
|
153
|
+
if (pages && input.menuLinks && input.globalNodes) {
|
|
154
|
+
const reach = new Set(input.menuLinks.hrefs.map(normalizePath));
|
|
155
|
+
for (const h of hrefsIn([...input.globalNodes, ...input.pageNodes]))
|
|
156
|
+
reach.add(h);
|
|
157
|
+
const byId = new Set(input.menuLinks.pageIds);
|
|
158
|
+
const orphans = pages.filter((p) => p.status === 'published' &&
|
|
159
|
+
INDEXED_PAGE_TYPES.has(p.type) &&
|
|
160
|
+
p.isHomepage !== true &&
|
|
161
|
+
typeof p.id === 'string' &&
|
|
162
|
+
typeof p.path === 'string' &&
|
|
163
|
+
!byId.has(p.id) &&
|
|
164
|
+
!reach.has(normalizePath(p.path)));
|
|
165
|
+
if (orphans.length > 0) {
|
|
166
|
+
const named = orphans.map((p) => p.name).filter((n) => !!n).slice(0, 3);
|
|
167
|
+
gaps.push({
|
|
168
|
+
id: 'unreachablePage',
|
|
169
|
+
draft: false,
|
|
170
|
+
problem: `${orphans.length} published page${orphans.length === 1 ? '' : 's'}` +
|
|
171
|
+
(named.length ? ` (${named.join(', ')}${orphans.length > named.length ? ', …' : ''})` : '') +
|
|
172
|
+
' that no menu item and no link in the shared header or footer points at. The address ' +
|
|
173
|
+
'works and the sitemap lists it; no visitor ever arrives.',
|
|
174
|
+
fix: 'Add it to the site menu — read GET /api/sites/{siteId}/menus, append ' +
|
|
175
|
+
'{ label, link: { type: "page", pageId } } and PUT the menu back; every bound menu ' +
|
|
176
|
+
'node follows. Or link it from the footer with sb_event go_to_url. A page kept out ' +
|
|
177
|
+
'on purpose (a landing page for ads) can stay out — say so.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
87
181
|
// A SITE WITH NO SHARED SECTION IS NOT A SITE, IT IS A STACK OF PAGES.
|
|
88
182
|
//
|
|
89
183
|
// Nothing asked this. Every tool here authors ONE page, so a build that never
|
|
@@ -697,5 +697,35 @@ opts = {}) {
|
|
|
697
697
|
for (const f of out)
|
|
698
698
|
if (inOverlay.has(f.nodeId))
|
|
699
699
|
f.overlay = true;
|
|
700
|
+
// A PAGE WITH HEADINGS AND NO <h1>. `heading` ships as h2
|
|
701
|
+
// (render/nodes/heading/html.go: SpecialString "htmlTag", default "h2"), and
|
|
702
|
+
// `text` / `text-dataset` take the same key, so a page built here — and every
|
|
703
|
+
// page the platform seeds, product title included — has headings at every
|
|
704
|
+
// level but the one a search engine and a screen reader look for first.
|
|
705
|
+
// Nothing on the canvas shows the tag. Page bands only (`band` excludes the
|
|
706
|
+
// overlays): a drawer heading is not the page's.
|
|
707
|
+
{
|
|
708
|
+
const level = (id) => {
|
|
709
|
+
const n = d.nodes[id];
|
|
710
|
+
const tag = n.specials?.htmlTag;
|
|
711
|
+
if (typeof tag === 'string' && /^h[1-6]$/.test(tag))
|
|
712
|
+
return tag;
|
|
713
|
+
return n.data.type === 'heading' ? 'h2' : null;
|
|
714
|
+
};
|
|
715
|
+
const headings = walkOrder.filter((id) => band.has(id) && level(id) !== null);
|
|
716
|
+
if (headings.length > 0 && !headings.some((id) => level(id) === 'h1')) {
|
|
717
|
+
const first = headings[0];
|
|
718
|
+
out.push({
|
|
719
|
+
code: 'no_h1',
|
|
720
|
+
nodeId: first,
|
|
721
|
+
type: d.nodes[first].data.type,
|
|
722
|
+
problem: `${headings.length} heading${headings.length === 1 ? '' : 's'} and none is an h1 — ` +
|
|
723
|
+
'every heading ships as h2, so the page has no main title for a search engine or a ' +
|
|
724
|
+
'screen reader. Invisible on the canvas.',
|
|
725
|
+
key: 'htmlTag',
|
|
726
|
+
fix: fill('no_h1', { id: first }),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
700
730
|
return out;
|
|
701
731
|
}
|
package/dist/smoke.js
CHANGED
|
@@ -90,7 +90,9 @@ export async function runSmoke() {
|
|
|
90
90
|
// of this check asserted the section built above was clean; it was not — the
|
|
91
91
|
// heading still carried the placeholder the element ships with, and the
|
|
92
92
|
// reviewer said so. The check was wrong, not the reviewer.
|
|
93
|
-
|
|
93
|
+
// And make it the page's h1 — every heading ships as h2, and a page with
|
|
94
|
+
// headings and no h1 is a finding too (no_h1). Same lesson, one rule later.
|
|
95
|
+
doc.apply(setKeys(doc, built.ids[1], { text: 'Autumn sale', htmlTag: 'h1' }, { namespace: 'specials' }));
|
|
94
96
|
check('a finished section reviews clean', reviewDesign(doc).length === 0);
|
|
95
97
|
// ...and an unfilled one must. A reviewer that never fires is indistinguishable
|
|
96
98
|
// from one that is not wired up.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.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",
|