sbuilder-mcp 0.73.0 → 0.74.1
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 +10 -0
- package/CHANGELOG.vi.md +10 -0
- package/dist/domains/site/readiness-fetch.js +48 -2
- package/dist/domains/site/readiness.js +84 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,16 @@ 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.1] - 2026-09-28
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `sb_review`'s `unreachablePage` gap no longer false-positives on a page that is only linked from the body of another page, such as a "read our story" button on the home page; when a candidate orphan page is found, it now also reads the other published pages' bodies (up to 40) and counts any href found there as reaching it.
|
|
13
|
+
|
|
14
|
+
## [0.74.0] - 2026-09-28
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `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.
|
|
18
|
+
|
|
9
19
|
## [0.73.0] - 2026-09-28
|
|
10
20
|
|
|
11
21
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,16 @@ 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.1] - 2026-09-28
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Gap `unreachablePage` của `sb_review` không còn báo sai với trang chỉ được liên kết từ thân nội dung của một trang khác, ví dụ nút "đọc câu chuyện" trên trang chủ; khi tìm thấy một trang mồ côi ứng viên, gap giờ đọc thêm thân các trang đã publish khác (tối đa 40 trang) và tính mọi href tìm thấy ở đó là đã tới được trang này.
|
|
13
|
+
|
|
14
|
+
## [0.74.0] - 2026-09-28
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `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.
|
|
18
|
+
|
|
9
19
|
## [0.73.0] - 2026-09-28
|
|
10
20
|
|
|
11
21
|
### Added
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { request } from '../../transport/http.js';
|
|
2
2
|
import { siteToken } from '../../tools/credentialpick.js';
|
|
3
|
+
import { hrefsIn, unreachablePages } from './readiness.js';
|
|
3
4
|
/**
|
|
4
5
|
* Gather what the readiness rules need, tolerating every failure.
|
|
5
6
|
*
|
|
@@ -24,7 +25,7 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
24
25
|
}
|
|
25
26
|
};
|
|
26
27
|
const site = encodeURIComponent(siteId);
|
|
27
|
-
const [pageList, gateways, shipping, globals, productList, categoryList, pageLinks, formList, articleList, blogCategoryList, courseList, siteRecord, overlayList, settings] = await Promise.all([
|
|
28
|
+
const [pageList, gateways, shipping, globals, productList, categoryList, pageLinks, formList, articleList, blogCategoryList, courseList, siteRecord, overlayList, settings, menuList] = await Promise.all([
|
|
28
29
|
get(`/api/sites/${site}/pages`),
|
|
29
30
|
get(`/api/sites/${site}/payment-gateways`),
|
|
30
31
|
get(`/api/sites/${site}/shipping-methods`),
|
|
@@ -67,6 +68,9 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
67
68
|
get(`/api/sites/${site}/overlays`),
|
|
68
69
|
// `<html lang>` is served from this; see siteLanguage in readiness.ts.
|
|
69
70
|
get(`/api/sites/${site}/settings`),
|
|
71
|
+
// THE MENUS, for what a visitor can reach: a page nothing links to is
|
|
72
|
+
// published, listed in the sitemap, and never arrived at.
|
|
73
|
+
get(`/api/sites/${site}/menus`),
|
|
70
74
|
]);
|
|
71
75
|
// A gateway counts only when it is BOTH enabled and configured — the editor's
|
|
72
76
|
// `live` getter also filters by the store's currency, which is a narrowing:
|
|
@@ -111,8 +115,33 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
111
115
|
// A page an entity links to is that entity's own; only the shared template
|
|
112
116
|
// counts as the category template, and a row without the flag is read as one.
|
|
113
117
|
const open = openPageId ? pageList?.pages?.find((p) => p.id === openPageId) : undefined;
|
|
114
|
-
|
|
118
|
+
// Every level of every menu: page references by id, url links by address.
|
|
119
|
+
const menuLinks = Array.isArray(menuList?.menus)
|
|
120
|
+
? (() => {
|
|
121
|
+
const pageIds = [];
|
|
122
|
+
const hrefs = [];
|
|
123
|
+
const walk = (items) => {
|
|
124
|
+
if (!Array.isArray(items))
|
|
125
|
+
return;
|
|
126
|
+
for (const it of items) {
|
|
127
|
+
if (!it || typeof it !== 'object')
|
|
128
|
+
continue;
|
|
129
|
+
const r = it;
|
|
130
|
+
if (r.link?.type === 'page' && typeof r.link.pageId === 'string')
|
|
131
|
+
pageIds.push(r.link.pageId);
|
|
132
|
+
if (r.link?.type === 'url' && typeof r.link.url === 'string')
|
|
133
|
+
hrefs.push(r.link.url);
|
|
134
|
+
walk(r.items);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
for (const m of menuList.menus)
|
|
138
|
+
walk(m?.items);
|
|
139
|
+
return { pageIds, hrefs };
|
|
140
|
+
})()
|
|
141
|
+
: null;
|
|
142
|
+
const input = {
|
|
115
143
|
pages: pageList?.pages ?? null,
|
|
144
|
+
menuLinks,
|
|
116
145
|
openPageType: open && open.isDefaultTemplate !== false ? open.type : undefined,
|
|
117
146
|
products,
|
|
118
147
|
liveGateways,
|
|
@@ -139,4 +168,21 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
139
168
|
? siteRecord.site.maintenanceMode
|
|
140
169
|
: null,
|
|
141
170
|
};
|
|
171
|
+
// THE SECOND PASS, PAID ONLY WHEN THE FIRST FOUND A CANDIDATE. A page linked
|
|
172
|
+
// from the BODY of another page — a "read our story" button on the home
|
|
173
|
+
// page — is reachable, and neither the menus nor the chrome show it. Reading
|
|
174
|
+
// every page's document on every review would be one GET per page for a
|
|
175
|
+
// question that is usually already answered, so the sources are read only
|
|
176
|
+
// when a page looks unreachable, and only the other published pages' — the
|
|
177
|
+
// open page's nodes are already in hand. Capped; a source that fails to read
|
|
178
|
+
// counts as carrying no link.
|
|
179
|
+
if (input.menuLinks && unreachablePages(input).length > 0) {
|
|
180
|
+
const others = (input.pages ?? [])
|
|
181
|
+
.filter((p) => p.status === 'published' && typeof p.id === 'string' && p.id !== openPageId)
|
|
182
|
+
.slice(0, 40);
|
|
183
|
+
const sources = await Promise.all(others.map((p) => get(`/api/sites/${site}/pages/${encodeURIComponent(p.id)}/source`)));
|
|
184
|
+
const nodes = sources.flatMap((src) => Object.values(src?.document?.nodes ?? {}));
|
|
185
|
+
input.menuLinks.hrefs.push(...hrefsIn(nodes));
|
|
186
|
+
}
|
|
187
|
+
return input;
|
|
142
188
|
}
|
|
@@ -81,6 +81,63 @@ 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
|
+
/** "N published pages (a, b, c, …)" — the head of a problem sentence that names its pages. */
|
|
85
|
+
function pageCount(rows) {
|
|
86
|
+
const named = rows.map((p) => p.name).filter((n) => !!n).slice(0, 3);
|
|
87
|
+
return (`${rows.length} published page${rows.length === 1 ? '' : 's'}` +
|
|
88
|
+
(named.length ? ` (${named.join(', ')}${rows.length > named.length ? ', …' : ''})` : ''));
|
|
89
|
+
}
|
|
90
|
+
/** "/about/", "/about?x=1" and "/about" are one address; "" is the home page. */
|
|
91
|
+
function normalizePath(p) {
|
|
92
|
+
const s = p.split(/[?#]/)[0].replace(/\/+$/, '');
|
|
93
|
+
return s === '' ? '/' : s;
|
|
94
|
+
}
|
|
95
|
+
/** Every href a set of nodes carries: `specials.href`, and a menu node's own snapshot rows. */
|
|
96
|
+
export function hrefsIn(nodes) {
|
|
97
|
+
const out = new Set();
|
|
98
|
+
const rows = (items) => {
|
|
99
|
+
if (!Array.isArray(items))
|
|
100
|
+
return;
|
|
101
|
+
for (const it of items) {
|
|
102
|
+
if (!it || typeof it !== 'object')
|
|
103
|
+
continue;
|
|
104
|
+
const r = it;
|
|
105
|
+
if (typeof r.href === 'string' && r.href !== '')
|
|
106
|
+
out.add(normalizePath(r.href));
|
|
107
|
+
rows(r.items);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
for (const n of nodes) {
|
|
111
|
+
const h = n.specials?.href;
|
|
112
|
+
if (typeof h === 'string' && h !== '')
|
|
113
|
+
out.add(normalizePath(h));
|
|
114
|
+
rows(n.specials?.menuItems);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Published content pages nothing points at. Reachable means a menu item
|
|
120
|
+
* references the page or its path, or a node in the shared chrome, on the open
|
|
121
|
+
* page, or (menuLinks.hrefs, appended by gatherReadiness only when this list
|
|
122
|
+
* is otherwise non-empty) in the body of any other published page carries its
|
|
123
|
+
* path as an href. Empty when the menus or the shared sections were unread.
|
|
124
|
+
*/
|
|
125
|
+
export function unreachablePages(input) {
|
|
126
|
+
const pages = input.pages;
|
|
127
|
+
if (!pages || !input.menuLinks || !input.globalNodes)
|
|
128
|
+
return [];
|
|
129
|
+
const reach = new Set(input.menuLinks.hrefs.map(normalizePath));
|
|
130
|
+
for (const h of hrefsIn([...input.globalNodes, ...input.pageNodes]))
|
|
131
|
+
reach.add(h);
|
|
132
|
+
const byId = new Set(input.menuLinks.pageIds);
|
|
133
|
+
return pages.filter((p) => p.status === 'published' &&
|
|
134
|
+
INDEXED_PAGE_TYPES.has(p.type) &&
|
|
135
|
+
p.isHomepage !== true &&
|
|
136
|
+
typeof p.id === 'string' &&
|
|
137
|
+
typeof p.path === 'string' &&
|
|
138
|
+
!byId.has(p.id) &&
|
|
139
|
+
!reach.has(normalizePath(p.path)));
|
|
140
|
+
}
|
|
84
141
|
export function readinessGaps(input) {
|
|
85
142
|
const gaps = [];
|
|
86
143
|
const pages = input.pages;
|
|
@@ -112,6 +169,32 @@ export function readinessGaps(input) {
|
|
|
112
169
|
});
|
|
113
170
|
}
|
|
114
171
|
}
|
|
172
|
+
// A PAGE NO VISITOR CAN GET TO — see unreachablePages().
|
|
173
|
+
//
|
|
174
|
+
// Every tool here authors one page and a menu is a separate record, so a
|
|
175
|
+
// site can carry a published About page that nothing links to: the sitemap
|
|
176
|
+
// lists it, the address works, and no visitor ever arrives. Reachable means
|
|
177
|
+
// a menu item references the page or its path, or something in the shared
|
|
178
|
+
// chrome or on the open page carries its path as an href (a `menu` node's
|
|
179
|
+
// own snapshot included). Content pages only, never the home page, and only
|
|
180
|
+
// when both the menus and the shared sections were read — a footer link
|
|
181
|
+
// this rule could not see must not become a finding.
|
|
182
|
+
{
|
|
183
|
+
const orphans = unreachablePages(input);
|
|
184
|
+
if (orphans.length > 0) {
|
|
185
|
+
gaps.push({
|
|
186
|
+
id: 'unreachablePage',
|
|
187
|
+
draft: false,
|
|
188
|
+
problem: pageCount(orphans) +
|
|
189
|
+
' that no menu item and no link in the shared header, footer or any other page ' +
|
|
190
|
+
'points at. The address works and the sitemap lists it; no visitor ever arrives.',
|
|
191
|
+
fix: 'Add it to the site menu — read GET /api/sites/{siteId}/menus, append ' +
|
|
192
|
+
'{ label, link: { type: "page", pageId } } and PUT the menu back; every bound menu ' +
|
|
193
|
+
'node follows. Or link it from the footer with sb_event go_to_url. A page kept out ' +
|
|
194
|
+
'on purpose (a landing page for ads) can stay out — say so.',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
115
198
|
// A SITE WITH NO SHARED SECTION IS NOT A SITE, IT IS A STACK OF PAGES.
|
|
116
199
|
//
|
|
117
200
|
// Nothing asked this. Every tool here authors ONE page, so a build that never
|
|
@@ -223,12 +306,10 @@ export function readinessGaps(input) {
|
|
|
223
306
|
return !(has('title') && has('description'));
|
|
224
307
|
});
|
|
225
308
|
if (bare.length > 0) {
|
|
226
|
-
const named = bare.map((p) => p.name).filter((n) => !!n).slice(0, 3);
|
|
227
309
|
gaps.push({
|
|
228
310
|
id: 'pageSeo',
|
|
229
311
|
draft: false,
|
|
230
|
-
problem:
|
|
231
|
-
(named.length ? ` (${named.join(', ')}${bare.length > named.length ? ', …' : ''})` : '') +
|
|
312
|
+
problem: pageCount(bare) +
|
|
232
313
|
' carry no settings.title or settings.description. Each serves the SITE NAME as its ' +
|
|
233
314
|
'<title> and no meta description — a search result cannot tell them apart, and ' +
|
|
234
315
|
'nothing on the canvas or in a screenshot shows it.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.1",
|
|
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",
|