sbuilder-mcp 0.74.0 → 0.75.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 +10 -0
- package/CHANGELOG.vi.md +10 -0
- package/dist/domains/site/readiness-fetch.js +22 -1
- package/dist/domains/site/readiness.js +57 -22
- 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.75.0] - 2026-09-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `sb_review` gains a `productImages` readiness gap: since the renderer falls back to its grey `ImagePlaceholder` whenever a node has no `src`, an active product created without `images` shows that same placeholder on every card, cart line, and its own product page with nothing erroring anywhere; it counts active products with no non-empty `images` entry and fires after the pricing gaps, pointing at `sb_media_upload` (or `POST /api/media/{siteId}/from-url`) followed by a `PUT` of the product with `images: [url, …]`.
|
|
13
|
+
|
|
14
|
+
## [0.74.1] - 2026-09-28
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- `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.
|
|
18
|
+
|
|
9
19
|
## [0.74.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.75.0] - 2026-09-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `sb_review` có thêm gap readiness `productImages`: vì renderer dùng `ImagePlaceholder` màu xám mặc định bất cứ khi nào một node không có `src`, một sản phẩm active được tạo mà không có `images` sẽ hiện cùng ô xám đó trên mọi thẻ, mọi dòng giỏ hàng và trên chính trang sản phẩm mà không lỗi ở đâu cả; gap đếm số sản phẩm active không có mục `images` nào khác rỗng và báo sau các gap về giá, trỏ tới `sb_media_upload` (hoặc `POST /api/media/{siteId}/from-url`) rồi `PUT` lại sản phẩm với `images: [url, …]`.
|
|
13
|
+
|
|
14
|
+
## [0.74.1] - 2026-09-28
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
9
19
|
## [0.74.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
|
*
|
|
@@ -109,6 +110,9 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
109
110
|
purchasable: rows.filter((p) => (p.status ?? 'active') === 'active' && (p.priceCents ?? 0) > 0).length,
|
|
110
111
|
// Drafts and archived: what a catalogue built without status:"active" is.
|
|
111
112
|
inactive: rows.filter((p) => (p.status ?? 'active') !== 'active').length,
|
|
113
|
+
// Active and pictureless: the grey placeholder on every card.
|
|
114
|
+
unpictured: rows.filter((p) => (p.status ?? 'active') === 'active' &&
|
|
115
|
+
!(Array.isArray(p.images) && p.images.some((u) => typeof u === 'string' && u !== ''))).length,
|
|
112
116
|
}
|
|
113
117
|
: null;
|
|
114
118
|
// A page an entity links to is that entity's own; only the shared template
|
|
@@ -138,7 +142,7 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
138
142
|
return { pageIds, hrefs };
|
|
139
143
|
})()
|
|
140
144
|
: null;
|
|
141
|
-
|
|
145
|
+
const input = {
|
|
142
146
|
pages: pageList?.pages ?? null,
|
|
143
147
|
menuLinks,
|
|
144
148
|
openPageType: open && open.isDefaultTemplate !== false ? open.type : undefined,
|
|
@@ -167,4 +171,21 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
167
171
|
? siteRecord.site.maintenanceMode
|
|
168
172
|
: null,
|
|
169
173
|
};
|
|
174
|
+
// THE SECOND PASS, PAID ONLY WHEN THE FIRST FOUND A CANDIDATE. A page linked
|
|
175
|
+
// from the BODY of another page — a "read our story" button on the home
|
|
176
|
+
// page — is reachable, and neither the menus nor the chrome show it. Reading
|
|
177
|
+
// every page's document on every review would be one GET per page for a
|
|
178
|
+
// question that is usually already answered, so the sources are read only
|
|
179
|
+
// when a page looks unreachable, and only the other published pages' — the
|
|
180
|
+
// open page's nodes are already in hand. Capped; a source that fails to read
|
|
181
|
+
// counts as carrying no link.
|
|
182
|
+
if (input.menuLinks && unreachablePages(input).length > 0) {
|
|
183
|
+
const others = (input.pages ?? [])
|
|
184
|
+
.filter((p) => p.status === 'published' && typeof p.id === 'string' && p.id !== openPageId)
|
|
185
|
+
.slice(0, 40);
|
|
186
|
+
const sources = await Promise.all(others.map((p) => get(`/api/sites/${site}/pages/${encodeURIComponent(p.id)}/source`)));
|
|
187
|
+
const nodes = sources.flatMap((src) => Object.values(src?.document?.nodes ?? {}));
|
|
188
|
+
input.menuLinks.hrefs.push(...hrefsIn(nodes));
|
|
189
|
+
}
|
|
190
|
+
return input;
|
|
170
191
|
}
|
|
@@ -81,13 +81,19 @@ 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
|
+
}
|
|
84
90
|
/** "/about/", "/about?x=1" and "/about" are one address; "" is the home page. */
|
|
85
91
|
function normalizePath(p) {
|
|
86
92
|
const s = p.split(/[?#]/)[0].replace(/\/+$/, '');
|
|
87
93
|
return s === '' ? '/' : s;
|
|
88
94
|
}
|
|
89
95
|
/** Every href a set of nodes carries: `specials.href`, and a menu node's own snapshot rows. */
|
|
90
|
-
function hrefsIn(nodes) {
|
|
96
|
+
export function hrefsIn(nodes) {
|
|
91
97
|
const out = new Set();
|
|
92
98
|
const rows = (items) => {
|
|
93
99
|
if (!Array.isArray(items))
|
|
@@ -109,6 +115,29 @@ function hrefsIn(nodes) {
|
|
|
109
115
|
}
|
|
110
116
|
return out;
|
|
111
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
|
+
}
|
|
112
141
|
export function readinessGaps(input) {
|
|
113
142
|
const gaps = [];
|
|
114
143
|
const pages = input.pages;
|
|
@@ -140,7 +169,7 @@ export function readinessGaps(input) {
|
|
|
140
169
|
});
|
|
141
170
|
}
|
|
142
171
|
}
|
|
143
|
-
// A PAGE NO VISITOR CAN GET TO.
|
|
172
|
+
// A PAGE NO VISITOR CAN GET TO — see unreachablePages().
|
|
144
173
|
//
|
|
145
174
|
// Every tool here authors one page and a menu is a separate record, so a
|
|
146
175
|
// site can carry a published About page that nothing links to: the sitemap
|
|
@@ -150,27 +179,15 @@ export function readinessGaps(input) {
|
|
|
150
179
|
// own snapshot included). Content pages only, never the home page, and only
|
|
151
180
|
// when both the menus and the shared sections were read — a footer link
|
|
152
181
|
// this rule could not see must not become a finding.
|
|
153
|
-
|
|
154
|
-
const
|
|
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)));
|
|
182
|
+
{
|
|
183
|
+
const orphans = unreachablePages(input);
|
|
165
184
|
if (orphans.length > 0) {
|
|
166
|
-
const named = orphans.map((p) => p.name).filter((n) => !!n).slice(0, 3);
|
|
167
185
|
gaps.push({
|
|
168
186
|
id: 'unreachablePage',
|
|
169
187
|
draft: false,
|
|
170
|
-
problem:
|
|
171
|
-
|
|
172
|
-
'
|
|
173
|
-
'works and the sitemap lists it; no visitor ever arrives.',
|
|
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.',
|
|
174
191
|
fix: 'Add it to the site menu — read GET /api/sites/{siteId}/menus, append ' +
|
|
175
192
|
'{ label, link: { type: "page", pageId } } and PUT the menu back; every bound menu ' +
|
|
176
193
|
'node follows. Or link it from the footer with sb_event go_to_url. A page kept out ' +
|
|
@@ -289,12 +306,10 @@ export function readinessGaps(input) {
|
|
|
289
306
|
return !(has('title') && has('description'));
|
|
290
307
|
});
|
|
291
308
|
if (bare.length > 0) {
|
|
292
|
-
const named = bare.map((p) => p.name).filter((n) => !!n).slice(0, 3);
|
|
293
309
|
gaps.push({
|
|
294
310
|
id: 'pageSeo',
|
|
295
311
|
draft: false,
|
|
296
|
-
problem:
|
|
297
|
-
(named.length ? ` (${named.join(', ')}${bare.length > named.length ? ', …' : ''})` : '') +
|
|
312
|
+
problem: pageCount(bare) +
|
|
298
313
|
' carry no settings.title or settings.description. Each serves the SITE NAME as its ' +
|
|
299
314
|
'<title> and no meta description — a search result cannot tell them apart, and ' +
|
|
300
315
|
'nothing on the canvas or in a screenshot shows it.',
|
|
@@ -543,6 +558,26 @@ export function readinessGaps(input) {
|
|
|
543
558
|
});
|
|
544
559
|
}
|
|
545
560
|
}
|
|
561
|
+
// A CARD WITH THE PLATFORM'S GREY SQUARE WHERE THE PHOTO GOES.
|
|
562
|
+
//
|
|
563
|
+
// render/nodes/image/html.go: no src → nodes.ImagePlaceholder, "a card
|
|
564
|
+
// instead of a broken icon". So a product created without `images` renders
|
|
565
|
+
// on every card, in every drawer line and on its own page as the same grey
|
|
566
|
+
// placeholder — nothing errors, and a catalogue of twenty reads as a
|
|
567
|
+
// template nobody finished. Asked after the money questions: a store with
|
|
568
|
+
// no photos still takes an order.
|
|
569
|
+
if (input.products && input.products.active > 0 && (input.products.unpictured ?? 0) > 0) {
|
|
570
|
+
const n = input.products.unpictured;
|
|
571
|
+
gaps.push({
|
|
572
|
+
id: 'productImages',
|
|
573
|
+
draft: false,
|
|
574
|
+
problem: `${n} of ${input.products.active} active products have no image, so every card, cart ` +
|
|
575
|
+
"line and product page shows the platform's grey placeholder where the photo goes.",
|
|
576
|
+
fix: 'Get a picture into the library (sb_media_upload: a file, a url, or query + pick), then ' +
|
|
577
|
+
'PUT the whole product back with images: [url, …] — first is the primary. POST ' +
|
|
578
|
+
'/api/media/{siteId}/from-url ingests straight from a url in one hop.',
|
|
579
|
+
});
|
|
580
|
+
}
|
|
546
581
|
if (input.shippingMethods === 0) {
|
|
547
582
|
gaps.push({
|
|
548
583
|
id: 'shipping',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.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",
|