sbuilder-mcp 0.86.0 → 0.88.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 +29 -0
- package/CHANGELOG.vi.md +29 -0
- package/dist/catalog/deadkeys.generated.js +2 -2
- package/dist/catalog/search.js +9 -1
- package/dist/catalog/shapes.generated.js +1 -1
- package/dist/catalog/source.generated.js +2 -2
- package/dist/domains/site/examplecopy.js +56 -0
- package/dist/domains/site/findings.js +7 -0
- package/dist/domains/site/importmap.js +48 -2
- package/dist/domains/site/offscreen.js +13 -0
- package/dist/domains/site/readiness-fetch.js +4 -0
- package/dist/domains/site/readiness.js +18 -0
- package/dist/domains/site/review.js +137 -1
- package/dist/domains/site/seedcopy.js +28 -0
- package/dist/domains/site/storepage.js +26 -3
- package/dist/tools/api.js +1 -1
- package/dist/tools/page.js +61 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,35 @@ 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.88.0] - 2026-10-12
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `sb_review` reports `default_seed_copy` for pages built earlier that still carry the English seed copy, such as "Add To Cart", on a Vietnamese site, with the Vietnamese wording to use instead.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- `sb_page_create` writes the store page seeds in Vietnamese for a Vietnamese site, taking the locale from the site's settings when none is passed.
|
|
16
|
+
- `sb_page_create` uses English thank-you copy on the completed-order page of an `en-US` site.
|
|
17
|
+
- `sb_review` words its `categoryImages` note to say that a collection tile without an image now shows a placeholder instead of a broken image.
|
|
18
|
+
|
|
19
|
+
## [0.87.0] - 2026-10-12
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- `sb_review` reports `example_copy` for text, labels and alt text that still carry a built-in layout's example copy, such as "Tiêu đề chính", invented figures, promises and customer quotes.
|
|
23
|
+
- `sb_review` reports `invisible_text` for text whose colour matches its background.
|
|
24
|
+
- `sb_review` reports `demo_variant_options` for a variant picker that shows fallback options instead of the product's own.
|
|
25
|
+
- `sb_review` reports `empty_page` for a page that has only a header and footer.
|
|
26
|
+
- `sb_review` flags product categories without an image as a `categoryImages` readiness gap when the page shows category tiles.
|
|
27
|
+
- `sb_template_use` reports how many elements still carry the layout's example copy.
|
|
28
|
+
- `sb_page_create` reports how many findings a seeded layout skeleton already has, so the placeholder copy and empty slots get filled.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- `sb_api_find` and `sb_api_call` resolve an operation id regardless of letter case, so `{siteID}` and `{siteId}` reach the same route when the match is unique.
|
|
32
|
+
- `sb_import` reads page tokens only from bands on a light ground, so an inverted band such as a call-to-action no longer supplies the page's button fill.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- `sb_add` on the page root without `index` places the new section before the footer instead of after it, which the band-order check refused to save.
|
|
36
|
+
- `sb_review` no longer reports `off_canvas` findings for a header's closed mobile menu.
|
|
37
|
+
|
|
9
38
|
## [0.86.0] - 2026-10-12
|
|
10
39
|
|
|
11
40
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,35 @@ 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.88.0] - 2026-10-12
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `sb_review` báo `default_seed_copy` cho trang dựng trước đó còn mang chữ mẫu tiếng Anh, như "Add To Cart", trên site tiếng Việt, kèm bản tiếng Việt nên dùng.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- `sb_page_create` viết seed trang cửa hàng bằng tiếng Việt cho site tiếng Việt, lấy locale từ cài đặt của site khi không truyền.
|
|
16
|
+
- `sb_page_create` dùng câu cảm ơn tiếng Anh cho trang hoàn tất đơn hàng của site `en-US`.
|
|
17
|
+
- `sb_review` sửa câu báo `categoryImages`: ô danh mục thiếu ảnh giờ hiện placeholder thay vì ảnh vỡ.
|
|
18
|
+
|
|
19
|
+
## [0.87.0] - 2026-10-12
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- `sb_review` báo `example_copy` cho chữ, nhãn và alt còn mang nội dung mẫu của layout dựng sẵn, như "Tiêu đề chính", số liệu, lời hứa và lời khen khách hàng bịa.
|
|
23
|
+
- `sb_review` báo `invisible_text` cho chữ có màu trùng với nền.
|
|
24
|
+
- `sb_review` báo `demo_variant_options` cho bộ chọn biến thể hiện tuỳ chọn giả thay vì tuỳ chọn của chính sản phẩm.
|
|
25
|
+
- `sb_review` báo `empty_page` cho trang chỉ có header và footer.
|
|
26
|
+
- `sb_review` đánh dấu danh mục sản phẩm chưa có ảnh là thiếu sót `categoryImages` khi trang hiển thị ô danh mục.
|
|
27
|
+
- `sb_template_use` cho biết còn bao nhiêu phần tử mang nội dung mẫu của layout.
|
|
28
|
+
- `sb_page_create` cho biết khung layout vừa tạo đã có bao nhiêu phát hiện, để điền chữ giữ chỗ và các ô trống.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- `sb_api_find` và `sb_api_call` nhận id operation không phân biệt hoa thường, nên `{siteID}` và `{siteId}` cùng tới một route khi khớp là duy nhất.
|
|
32
|
+
- `sb_import` chỉ đọc token của trang từ các dải có nền sáng, nên dải đảo màu như call-to-action không còn quyết định màu nền nút của trang.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- `sb_add` vào gốc trang khi không có `index` đặt section mới trước footer thay vì sau footer, vị trí mà kiểm tra thứ tự dải từng từ chối lưu.
|
|
36
|
+
- `sb_review` không còn báo `off_canvas` cho menu di động đang đóng trong header.
|
|
37
|
+
|
|
9
38
|
## [0.86.0] - 2026-10-12
|
|
10
39
|
|
|
11
40
|
### Added
|
package/dist/catalog/search.js
CHANGED
|
@@ -28,7 +28,15 @@ const PRODUCT_WRITE_IDS = new Set([
|
|
|
28
28
|
'put:/api/v1/products/{id}',
|
|
29
29
|
]);
|
|
30
30
|
export function findOperation(id) {
|
|
31
|
-
|
|
31
|
+
const exact = API_OPERATIONS.find((o) => o.id === id);
|
|
32
|
+
if (exact)
|
|
33
|
+
return exact;
|
|
34
|
+
// The platform spells one parameter two ways — `{siteId}` almost everywhere,
|
|
35
|
+
// `{siteID}` on the menu routes — and a caller (or a readiness fix text) that
|
|
36
|
+
// learned the common one was told "unknown operation" for one letter. Only a
|
|
37
|
+
// UNIQUE fold is taken, so two routes differing by case alone stay refused.
|
|
38
|
+
const folded = API_OPERATIONS.filter((o) => o.id.toLowerCase() === id.toLowerCase());
|
|
39
|
+
return folded.length === 1 ? folded[0] : undefined;
|
|
32
40
|
}
|
|
33
41
|
export function searchOperations(query, opts = {}) {
|
|
34
42
|
const limit = opts.limit ?? DEFAULT_FIND_LIMIT;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GENERATED by scripts/gen-catalog.ts — do not edit by hand.
|
|
2
2
|
// The platform commit every other generated file here describes.
|
|
3
3
|
export const PLATFORM_SOURCE = {
|
|
4
|
-
"commit": "
|
|
5
|
-
"committedAt": "2026-10-
|
|
4
|
+
"commit": "74ca59dd2340133e4ecf7f2faaf0947874ec3554",
|
|
5
|
+
"committedAt": "2026-10-12T10:37:24+07:00",
|
|
6
6
|
"dirty": false
|
|
7
7
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { LAYOUT_PATTERNS, THEME_TOKENS } from './patterns.js';
|
|
2
|
+
/**
|
|
3
|
+
* THE BUILT-IN LAYOUTS' EXAMPLE COPY, so a review can tell it was never replaced.
|
|
4
|
+
*
|
|
5
|
+
* Measured on a store built end to end with these tools: the home page published
|
|
6
|
+
* "Tiêu đề chính", "Lợi ích một / Một hoặc hai câu." and, in the hero's picture
|
|
7
|
+
* slot, the sentence telling the AGENT to run sb_media_upload — all to shoppers —
|
|
8
|
+
* and sb_review called the page clean, because none of it is an element's own
|
|
9
|
+
* seed. Worse are the CLAIMS: "1.000+ khách hàng", "4,9/5", "Đổi mới trong 7 ngày",
|
|
10
|
+
* a testimonial signed "Nguyễn Minh Anh" — published, they are statements about
|
|
11
|
+
* this shop that nobody made.
|
|
12
|
+
*
|
|
13
|
+
* DERIVED by building every pattern, minus the short list of labels that are fine
|
|
14
|
+
* as they stand. Derived rather than listed so a pattern added or reworded later is
|
|
15
|
+
* covered on the day it lands: new copy is example copy until someone says it is a
|
|
16
|
+
* usable label — the safe direction to be wrong in.
|
|
17
|
+
*/
|
|
18
|
+
const USABLE_AS_IS = new Set([
|
|
19
|
+
'Mua ngay', 'Tìm hiểu thêm', 'Tìm hiểu thêm →', 'Xem tất cả', 'Sản phẩm nổi bật', 'Danh mục sản phẩm',
|
|
20
|
+
'Bộ sưu tập mới', 'Chào mừng', 'Lợi ích', 'Vì sao chọn chúng tôi', 'Sẵn sàng bắt đầu?', 'Thư viện ảnh',
|
|
21
|
+
'Hỗ trợ', 'Câu hỏi thường gặp', 'Giao hàng mất bao lâu?', 'Đổi trả thế nào?', 'Thanh toán ra sao?',
|
|
22
|
+
'Đối tác của chúng tôi', 'Cam kết của chúng tôi', 'Giao hàng nhanh', 'Bảo hành chính hãng',
|
|
23
|
+
'Đổi trả dễ dàng', 'Thanh toán an toàn', 'Khách hàng nói gì', '★★★★★', 'Cách hoạt động',
|
|
24
|
+
'Ba bước đơn giản', '01', '02', '03', 'Chọn sản phẩm', 'Đặt hàng', 'Nhận hàng', 'Câu chuyện',
|
|
25
|
+
'Bảng giá', 'Chọn gói phù hợp', 'Cơ bản', 'Chọn gói', 'Khách hàng', 'Đánh giá trung bình',
|
|
26
|
+
'Giao trong nội thành', 'Tiêu chuẩn', 'Cao cấp',
|
|
27
|
+
]);
|
|
28
|
+
const TEXT_KEYS = ['text', 'label', 'alt'];
|
|
29
|
+
let cache = null;
|
|
30
|
+
/** Example copy → the id of the layout it came from. Built once per process. */
|
|
31
|
+
export function exampleCopy() {
|
|
32
|
+
if (cache)
|
|
33
|
+
return cache;
|
|
34
|
+
const out = new Map();
|
|
35
|
+
const walk = (n, id) => {
|
|
36
|
+
if (!n)
|
|
37
|
+
return;
|
|
38
|
+
for (const k of TEXT_KEYS) {
|
|
39
|
+
const v = n.specials?.[k];
|
|
40
|
+
if (typeof v === 'string' && v.trim() && !USABLE_AS_IS.has(v) && !out.has(v))
|
|
41
|
+
out.set(v, id);
|
|
42
|
+
}
|
|
43
|
+
for (const c of n.children ?? [])
|
|
44
|
+
walk(c, id);
|
|
45
|
+
};
|
|
46
|
+
for (const p of LAYOUT_PATTERNS) {
|
|
47
|
+
try {
|
|
48
|
+
walk(p.build(THEME_TOKENS, []), p.id);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// A pattern that cannot build without a pool contributes nothing here.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
cache = out;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
@@ -22,6 +22,13 @@ export const FIX = {
|
|
|
22
22
|
// this surface EXISTS. An empty state is authored out of sight — it only shows
|
|
23
23
|
// when the list is empty — so an agent that never saw it does not know there is
|
|
24
24
|
// anything to write.
|
|
25
|
+
demo_variant_options: 'Clear the fallback: sb_set id "<id>", namespace config, keys { "attributes": [] }, base: true. ' +
|
|
26
|
+
'Products WITH options still show their own (the binding wins); products without show no picker.',
|
|
27
|
+
invisible_text: 'Give it a colour that reads on its ground: sb_set id "<id>", namespace style, keys { "color": … } ' +
|
|
28
|
+
'(or unset "color" so the theme decides). For a button, change its fill or its text — not both to the same.',
|
|
29
|
+
example_copy: 'Write this shop\'s own words: sb_set id "<id>", namespace specials, keys { "<key>": … }. ' +
|
|
30
|
+
'A figure, a delivery or return promise, or a customer quote must be TRUE for this shop — ' +
|
|
31
|
+
'if you do not know it, remove the element (sb_remove) rather than guess.',
|
|
25
32
|
default_seed_copy: 'Write it in the shop\'s own language and ink: sb_set id "<id>", namespace specials, ' +
|
|
26
33
|
'keys { "<key>": … }. It is a seeded satellite (an empty state, an element\'s chrome), ' +
|
|
27
34
|
'so it only appears when the list is empty or the control is idle — sb_outline lists it ' +
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pageChildren
|
|
1
|
+
import { pageChildren } from '../../core/tree.js';
|
|
2
2
|
import { isGlobal } from './traps.js';
|
|
3
3
|
import { stickySeeds } from './sticky.js';
|
|
4
4
|
import { TEXT_STYLE_KEYS } from '../../catalog/elements.generated.js';
|
|
@@ -79,9 +79,29 @@ export function tokensFromPage(doc) {
|
|
|
79
79
|
else if (!block && t === 'flex-block')
|
|
80
80
|
block = n;
|
|
81
81
|
};
|
|
82
|
+
// NOT FROM A BAND ON A DARK OR COLOURED GROUND. Its type and buttons are
|
|
83
|
+
// inverted on purpose, so they say nothing about the page's own. Measured: a
|
|
84
|
+
// hero re-added below an sb_cta_band (primary-green ground, white button with
|
|
85
|
+
// green text) took that white button as the page's fill — and published a
|
|
86
|
+
// white "Mua ngay" on the white hero, plus a white eyebrow, both invisible.
|
|
87
|
+
// By id, not through walk(): its visitor gets the node, and a node is not
|
|
88
|
+
// guaranteed to carry its own id.
|
|
89
|
+
const read = (id, onDark, seen) => {
|
|
90
|
+
const n = doc.nodes[id];
|
|
91
|
+
if (!n || seen.has(id))
|
|
92
|
+
return;
|
|
93
|
+
seen.add(id);
|
|
94
|
+
const t = n.data.type;
|
|
95
|
+
const dark = onDark || ((t === 'flex-section' || t === 'flex-block') && !lightGround(styleOf(n).backgroundColor));
|
|
96
|
+
if (!dark)
|
|
97
|
+
visit(n);
|
|
98
|
+
for (const k of n.data.nodes ?? [])
|
|
99
|
+
read(k, dark, seen);
|
|
100
|
+
};
|
|
101
|
+
const seen = new Set();
|
|
82
102
|
for (const id of pageChildren(doc))
|
|
83
103
|
if (!isGlobal(doc, id))
|
|
84
|
-
|
|
104
|
+
read(id, false, seen);
|
|
85
105
|
const h = styleOf(heading);
|
|
86
106
|
const p = styleOf(text);
|
|
87
107
|
const b = styleOf(button);
|
|
@@ -99,6 +119,32 @@ export function tokensFromPage(doc) {
|
|
|
99
119
|
...(str(bl.maxWidth) ? { sectionMaxWidth: str(bl.maxWidth) } : {}),
|
|
100
120
|
};
|
|
101
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Is this a ground ordinary type reads on? Unset, transparent, white, the
|
|
124
|
+
* theme's own background token, or a literal colour light enough. Anything
|
|
125
|
+
* else — a dark or saturated literal, another theme var — is treated as an
|
|
126
|
+
* inverted band, which only ever costs a token read, never a wrong one.
|
|
127
|
+
*/
|
|
128
|
+
function lightGround(bg) {
|
|
129
|
+
const v = typeof bg === 'string' ? bg.trim().toLowerCase() : '';
|
|
130
|
+
if (!v || v === 'transparent' || v === 'white' || v === 'var(--wb-color-background)')
|
|
131
|
+
return true;
|
|
132
|
+
let rgb = null;
|
|
133
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(v);
|
|
134
|
+
if (hex) {
|
|
135
|
+
const h = hex[1].length === 3 ? hex[1].replace(/./g, (c) => c + c) : hex[1];
|
|
136
|
+
rgb = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const m = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/.exec(v);
|
|
140
|
+
if (m)
|
|
141
|
+
rgb = [m[1], m[2], m[3]].map(Number);
|
|
142
|
+
}
|
|
143
|
+
if (!rgb)
|
|
144
|
+
return false;
|
|
145
|
+
const [r, g, b] = rgb.map((c) => c / 255);
|
|
146
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.8;
|
|
147
|
+
}
|
|
102
148
|
/** A heading's tag, clamped to what HTML has. */
|
|
103
149
|
function headingTag(level) {
|
|
104
150
|
const n = Math.min(6, Math.max(1, Math.round(level ?? 2)));
|
|
@@ -80,8 +80,21 @@ export function offscreenNodes(doc) {
|
|
|
80
80
|
for (const n of subtreeIds(doc, id))
|
|
81
81
|
out.add(n);
|
|
82
82
|
}
|
|
83
|
+
// THE MOBILE MENU LIVES INSIDE THE HEADER, legitimately, so the ROOT-only
|
|
84
|
+
// sweep above never reached it. Measured: a store header from sb_store
|
|
85
|
+
// action:"chrome" — its closed menu-drawer, parked at x −420 — gave four
|
|
86
|
+
// off_canvas findings at 390px on every page. Only the menu types: a popup
|
|
87
|
+
// nested in a section is still a document the platform refuses.
|
|
88
|
+
for (const [id, n] of Object.entries(doc.nodes)) {
|
|
89
|
+
if (!NESTED_OFFSCREEN.has(n?.data?.type ?? '') || out.has(id))
|
|
90
|
+
continue;
|
|
91
|
+
for (const k of subtreeIds(doc, id))
|
|
92
|
+
out.add(k);
|
|
93
|
+
}
|
|
83
94
|
return out;
|
|
84
95
|
}
|
|
96
|
+
/** Overlay types that legitimately sit deep in a tree (a header's mobile menu). */
|
|
97
|
+
const NESTED_OFFSCREEN = new Set(['hamburger-menu', 'menu-drawer', 'menu-panel']);
|
|
85
98
|
/**
|
|
86
99
|
* The overlay this node sits in, for the RENDER question "what must be opened
|
|
87
100
|
* before I can photograph this".
|
|
@@ -89,6 +89,9 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
89
89
|
const blogCats = Array.isArray(rawBlogCats) ? rawBlogCats : null;
|
|
90
90
|
const cats = categoryList?.categories ?? categoryList?.productCategories;
|
|
91
91
|
const categories = Array.isArray(cats) ? (categoryList?.total ?? cats.length) : null;
|
|
92
|
+
const categoriesUnpictured = Array.isArray(cats)
|
|
93
|
+
? cats.filter((c) => !String(c?.image ?? '').trim()).length
|
|
94
|
+
: null;
|
|
92
95
|
const links = pageLinks?.links ?? pageLinks?.pageLinks;
|
|
93
96
|
const categoryPageLinks = Array.isArray(links)
|
|
94
97
|
? links.filter((l) => l?.linkType === 'productCategory').length
|
|
@@ -159,6 +162,7 @@ export async function gatherReadiness(ctx, siteId, pageNodes, openPageId) {
|
|
|
159
162
|
: null,
|
|
160
163
|
overlayKinds: Array.isArray(overlayList?.overlays) ? overlayList.overlays.map((o) => o.kind ?? '') : null,
|
|
161
164
|
categories,
|
|
165
|
+
categoriesUnpictured,
|
|
162
166
|
categoryPageLinks,
|
|
163
167
|
forms: formList?.forms ?? null,
|
|
164
168
|
articles: Array.isArray(articleList?.articles)
|
|
@@ -612,6 +612,24 @@ export function readinessGaps(input) {
|
|
|
612
612
|
'/api/media/{siteId}/from-url ingests straight from a url in one hop.',
|
|
613
613
|
});
|
|
614
614
|
}
|
|
615
|
+
// A COLLECTION TILE WITH NO PICTURE. Measured on the sb_category_strip a store
|
|
616
|
+
// built here put on its home page: render/nodes/collection-media wrote
|
|
617
|
+
// `<img src="">`, two broken icons above the category names. web_builder PR
|
|
618
|
+
// #118 (74ca59dd2) gives it the image element's grey placeholder instead —
|
|
619
|
+
// better, still not the category. Asked only where a tile is on screen.
|
|
620
|
+
if ((input.categoriesUnpictured ?? 0) > 0 &&
|
|
621
|
+
[...input.pageNodes, ...(input.globalNodes ?? [])].some((n) => n.data?.type === 'collection-media')) {
|
|
622
|
+
const n = input.categoriesUnpictured;
|
|
623
|
+
gaps.push({
|
|
624
|
+
id: 'categoryImages',
|
|
625
|
+
draft: false,
|
|
626
|
+
problem: `${n} product categor${n === 1 ? 'y has' : 'ies have'} no image, and this page shows category ` +
|
|
627
|
+
"tiles — each draws the platform's grey placeholder (a broken-image icon on a platform " +
|
|
628
|
+
'older than web_builder 74ca59dd2) where the picture goes.',
|
|
629
|
+
fix: 'Upload a picture (sb_media_upload), then PUT the category back with image: url ' +
|
|
630
|
+
'(sb_api_call put:/api/sites/{siteId}/product-categories/{id}, merge:true).',
|
|
631
|
+
});
|
|
632
|
+
}
|
|
615
633
|
if (input.shippingMethods === 0) {
|
|
616
634
|
gaps.push({
|
|
617
635
|
id: 'shipping',
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
1
2
|
import { childrenOf, childrenWithSatellites, isOverlay, isSharedSection, pageChildren, appBlockRoot, SPEC_GLOBAL_REF, SPEC_APP_BLOCK_REF } from '../../core/tree.js';
|
|
2
3
|
import { STUCK_STATE, stickyBlockedBy, stuckHostOf, isPinnedNode } from './sticky.js';
|
|
3
|
-
import { bandOf } from './traps.js';
|
|
4
|
+
import { bandOf, isGlobal } from './traps.js';
|
|
5
|
+
import { exampleCopy } from './examplecopy.js';
|
|
6
|
+
import { SEED_COPY_VI, langOf } from './seedcopy.js';
|
|
4
7
|
import { HOVER_STATE, hoverHome } from './hover.js';
|
|
5
8
|
import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
|
|
6
9
|
import { fill } from './findings.js';
|
|
@@ -130,6 +133,33 @@ const LINK_SPECIALS = new Set(['boundHref', 'boundHrefLabel', 'boundProductURL']
|
|
|
130
133
|
function drawsItsOwnContent(type) {
|
|
131
134
|
return (BOUND_SPECIALS[type] ?? []).some((k) => !LINK_SPECIALS.has(k));
|
|
132
135
|
}
|
|
136
|
+
/** A literal #rgb / #rrggbb / rgb() colour as 0–255 channels, or null. */
|
|
137
|
+
function rgbOf(v) {
|
|
138
|
+
const c = typeof v === 'string' ? v.trim().toLowerCase() : '';
|
|
139
|
+
if (c === 'white')
|
|
140
|
+
return [255, 255, 255];
|
|
141
|
+
if (c === 'black')
|
|
142
|
+
return [0, 0, 0];
|
|
143
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(c);
|
|
144
|
+
if (hex) {
|
|
145
|
+
const h = hex[1].length === 3 ? hex[1].replace(/./g, (x) => x + x) : hex[1];
|
|
146
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
147
|
+
}
|
|
148
|
+
const m = /^rgb\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)\s*\)$/.exec(c);
|
|
149
|
+
return m ? [m[1], m[2], m[3]].map(Number) : null;
|
|
150
|
+
}
|
|
151
|
+
/** WCAG contrast ratio of two sRGB colours. */
|
|
152
|
+
function contrast(a, b) {
|
|
153
|
+
const lum = (rgb) => {
|
|
154
|
+
const [r, g, bl] = rgb.map((v) => {
|
|
155
|
+
const s = v / 255;
|
|
156
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
157
|
+
});
|
|
158
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
|
|
159
|
+
};
|
|
160
|
+
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
|
|
161
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
162
|
+
}
|
|
133
163
|
/** Binding-source prefixes that name a RECORD (not `site.*`, which every page has). */
|
|
134
164
|
// cartLine: only a cart-order's line row supplies it (render/nodes/cart-order,
|
|
135
165
|
// scope.PlaceholderCartLine) — see ROW_SUPPLIER. searchRow is left out: no
|
|
@@ -248,6 +278,27 @@ opts = {}) {
|
|
|
248
278
|
});
|
|
249
279
|
return out;
|
|
250
280
|
}
|
|
281
|
+
// ONLY THE SHARED HEADER AND FOOTER is a blank page too. Measured: an "account"
|
|
282
|
+
// page created by sb_page_create (no seed for that type) wore the site chrome
|
|
283
|
+
// and nothing else, reviewed clean, and so did the readiness gap it was made
|
|
284
|
+
// to close — a shopper landing on /account met a header, a footer and nothing.
|
|
285
|
+
// Composed (globalId, what a read carries) or stored (globalRef) alike.
|
|
286
|
+
// HEADER AND FOOTER only: a `custom` shared section is body content.
|
|
287
|
+
const isChrome = (id) => {
|
|
288
|
+
const sp = d.nodes[id]?.specials ?? {};
|
|
289
|
+
if (isGlobal(d, id))
|
|
290
|
+
return bandOf(d, id) !== 'middle';
|
|
291
|
+
return sp[SPEC_GLOBAL_REF] !== undefined && (sp.globalKind === 'header' || sp.globalKind === 'footer');
|
|
292
|
+
};
|
|
293
|
+
if (pageChildren(d).every(isChrome)) {
|
|
294
|
+
out.push({
|
|
295
|
+
code: 'empty_page',
|
|
296
|
+
nodeId: d.root_node_id,
|
|
297
|
+
type: 'root',
|
|
298
|
+
problem: 'Only the shared header and footer — the page between them is blank.',
|
|
299
|
+
fix: fill('empty_page', {}),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
251
302
|
// Document order, depth-first from ROOT: the order a reader meets them.
|
|
252
303
|
const seen = new Set();
|
|
253
304
|
const walkOrder = [];
|
|
@@ -383,6 +434,54 @@ opts = {}) {
|
|
|
383
434
|
});
|
|
384
435
|
continue;
|
|
385
436
|
}
|
|
437
|
+
// TYPE THE SAME COLOUR AS WHAT IT SITS ON. Measured: a pattern dressed in
|
|
438
|
+
// tokens read off an inverted band published a white "Mua ngay" on its own
|
|
439
|
+
// white fill — present in the HTML, invisible on the page, clean in review.
|
|
440
|
+
// LITERAL COLOURS ONLY, on both sides, and the ground is the element's own
|
|
441
|
+
// fill or the nearest ancestor that sets one: a theme var or an unset page
|
|
442
|
+
// ground is not known here, and a dark theme would make a guessed "white"
|
|
443
|
+
// ground cry wolf on every light heading.
|
|
444
|
+
if ((type === 'text' || type === 'heading' || type === 'button') && !inOverlay.has(id)) {
|
|
445
|
+
const fg = rgbOf(n.style?.color);
|
|
446
|
+
let ground = null;
|
|
447
|
+
for (let x = id; x && d.nodes[x] && !ground; x = d.nodes[x].data.parent) {
|
|
448
|
+
const bg = d.nodes[x].style?.backgroundColor;
|
|
449
|
+
if (bg === undefined || bg === 'transparent' || bg === '')
|
|
450
|
+
continue;
|
|
451
|
+
ground = rgbOf(bg);
|
|
452
|
+
if (!ground)
|
|
453
|
+
break; // a var() or a gradient: unknown, say nothing
|
|
454
|
+
}
|
|
455
|
+
if (fg && ground && contrast(fg, ground) < 1.5) {
|
|
456
|
+
out.push({
|
|
457
|
+
code: 'invisible_text',
|
|
458
|
+
nodeId: id,
|
|
459
|
+
type,
|
|
460
|
+
problem: `Its colour is the colour it sits on (contrast ${contrast(fg, ground).toFixed(2)}:1) — the words are there and nobody can read them.`,
|
|
461
|
+
fix: fill('invisible_text', { id }),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// THE DEMO OPTIONS. product-variants seeds config.attributes Color
|
|
466
|
+
// Red/Green/Blue, Size S/M/L, and that authored config is the documented
|
|
467
|
+
// FALLBACK for a product with no option axes of its own (schema binding.ts:
|
|
468
|
+
// "the picker keeps its authored config"). So every simple product on a
|
|
469
|
+
// published page offered three colours and three sizes that do not exist —
|
|
470
|
+
// measured on a store built here, and reviewed clean.
|
|
471
|
+
if (type === 'product-variants') {
|
|
472
|
+
const authored = n.config?.attributes;
|
|
473
|
+
const seeded = ELEMENTS[type]?.defaults?.config?.attributes;
|
|
474
|
+
if (Array.isArray(seeded) && seeded.length && isDeepStrictEqual(authored, seeded)) {
|
|
475
|
+
out.push({
|
|
476
|
+
code: 'demo_variant_options',
|
|
477
|
+
nodeId: id,
|
|
478
|
+
type,
|
|
479
|
+
problem: 'Still the picker\'s demo options (Color Red/Green/Blue, Size S/M/L). They render for ' +
|
|
480
|
+
'every product that has no options of its own — a shopper is offered choices that do not exist.',
|
|
481
|
+
fix: fill('demo_variant_options', { id }),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
386
485
|
// A NAVIGATION CONTROL WITH NOWHERE TO GO, and it is the sharpest silent
|
|
387
486
|
// failure on this list because the page is CORRECT in every way a check can
|
|
388
487
|
// otherwise see. The tree holds the event the author asked for; the pixels
|
|
@@ -669,11 +768,18 @@ opts = {}) {
|
|
|
669
768
|
// is "add something inside it", which on the next save is decomposed away
|
|
670
769
|
// again. Measured on a page built with `sb_add`: the header and footer refs
|
|
671
770
|
// both flagged, and both correct.
|
|
771
|
+
// OR IT DRAWS ITS OWN CONTROL. `search-input` is a container only for the
|
|
772
|
+
// dropdown an author MAY compose under it; with no children it still renders
|
|
773
|
+
// its `<input>` (render/nodes/search-input/html.go). The platform's own
|
|
774
|
+
// search seed ships it childless, so every seeded search page was flagged.
|
|
775
|
+
// Not `select`: its face lives in its trigger/panel children.
|
|
776
|
+
const drawsOwnControl = type === 'search-input';
|
|
672
777
|
const isReference = (n.specials ?? {})[SPEC_GLOBAL_REF] !== undefined ||
|
|
673
778
|
(n.specials ?? {})[SPEC_APP_BLOCK_REF] !== undefined;
|
|
674
779
|
if (meta.isContainer &&
|
|
675
780
|
childrenOf(d, id).length === 0 &&
|
|
676
781
|
!isReference &&
|
|
782
|
+
!drawsOwnControl &&
|
|
677
783
|
!(bindings.length > 0 && drawsItsOwnContent(type))) {
|
|
678
784
|
out.push({
|
|
679
785
|
code: 'empty_container',
|
|
@@ -762,6 +868,36 @@ opts = {}) {
|
|
|
762
868
|
fix: fill('default_seed_copy', { id, key }),
|
|
763
869
|
});
|
|
764
870
|
}
|
|
871
|
+
// THE STORE SEEDS' ENGLISH COPY ON A VIETNAMESE SITE ("Add To Cart") —
|
|
872
|
+
// fine on an English site, so asked only when the site's language is
|
|
873
|
+
// known. sb_page_create now seeds it in Vietnamese; this is for pages
|
|
874
|
+
// made before that, or with locale:"en" on a Vietnamese site.
|
|
875
|
+
else if (typeof value === 'string' &&
|
|
876
|
+
SEED_COPY_VI[value] &&
|
|
877
|
+
langOf(opts.siteLocale) === 'vi') {
|
|
878
|
+
out.push({
|
|
879
|
+
code: 'default_seed_copy',
|
|
880
|
+
nodeId: id,
|
|
881
|
+
type,
|
|
882
|
+
problem: `Still the platform's English seed copy (${JSON.stringify(value)}) on a Vietnamese site — ` +
|
|
883
|
+
`"${SEED_COPY_VI[value]}" is what this storefront should say.`,
|
|
884
|
+
key,
|
|
885
|
+
fix: fill('default_seed_copy', { id, key }),
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
// A BUILT-IN LAYOUT'S EXAMPLE COPY — see examplecopy.ts.
|
|
889
|
+
else if (typeof value === 'string' && exampleCopy().has(value)) {
|
|
890
|
+
out.push({
|
|
891
|
+
code: 'example_copy',
|
|
892
|
+
nodeId: id,
|
|
893
|
+
type,
|
|
894
|
+
problem: `Still the "${exampleCopy().get(value)}" layout's example copy (${JSON.stringify(value)}). ` +
|
|
895
|
+
'A shopper reads it as this shop speaking — and a number, a promise or a ' +
|
|
896
|
+
'testimonial in it is a claim nobody made.',
|
|
897
|
+
key,
|
|
898
|
+
fix: fill('example_copy', { id, key }),
|
|
899
|
+
});
|
|
900
|
+
}
|
|
765
901
|
}
|
|
766
902
|
// A FORM NOBODY LINKED publishes as an empty box, and the platform stays
|
|
767
903
|
// deliberately quiet about it: form.go:221 skips an empty formId with the
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STORE SEEDS' ENGLISH COPY, IN VIETNAMESE — applied when the page is made
|
|
3
|
+
* for a Vietnamese site.
|
|
4
|
+
*
|
|
5
|
+
* The platform seeds in English on purpose (editor/src/element/emptyState.ts:
|
|
6
|
+
* "a seed is a seed", decided 2026-09-17) and a table per language was offered
|
|
7
|
+
* there and declined. Here the agent IS the author, and it knows the site's
|
|
8
|
+
* language: measured on a store built end to end, the product page published
|
|
9
|
+
* "Add To Cart" / "Buy It Now" to a Vietnamese storefront and every page
|
|
10
|
+
* reviewed clean. sb_page_create seeds through this; sb_review (which reads the
|
|
11
|
+
* site's locale) reports a page still carrying the English. Exact strings only; a seed string
|
|
12
|
+
* this table does not carry is left as it was — and test/store-page-seeds
|
|
13
|
+
* fails when codegen brings one in, so the table cannot fall behind silently.
|
|
14
|
+
*/
|
|
15
|
+
export const SEED_COPY_VI = {
|
|
16
|
+
'Add To Cart': 'Thêm vào giỏ',
|
|
17
|
+
'Buy It Now': 'Mua ngay',
|
|
18
|
+
'This item is unavailable': 'Sản phẩm tạm hết',
|
|
19
|
+
'It may have been removed. Have a look at the rest of the shop.': 'Sản phẩm có thể đã ngừng bán. Mời bạn xem các sản phẩm khác.',
|
|
20
|
+
'No products yet': 'Chưa có sản phẩm',
|
|
21
|
+
'New arrivals will show up here. Check back soon.': 'Sản phẩm mới sẽ sớm có ở đây — mời bạn quay lại sau.',
|
|
22
|
+
'No posts yet': 'Chưa có bài viết',
|
|
23
|
+
'New stories will show up here. Check back soon.': 'Bài viết mới sẽ sớm có ở đây.',
|
|
24
|
+
};
|
|
25
|
+
/** The language part of a locale tag — "vi-VN", "vi_VN" and "VI" are all "vi". */
|
|
26
|
+
export function langOf(locale) {
|
|
27
|
+
return String(locale ?? '').toLowerCase().split(/[-_]/)[0];
|
|
28
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SEED_COPY_VI, langOf } from './seedcopy.js';
|
|
1
2
|
import { COMPLETION_HEADLINE, COMPLETION_HEADLINE_SENTINEL, PAGE_LAYOUT_SEEDS, PAGE_TYPES, STORE_PAGE_SEEDS } from '../../catalog/storepages.generated.js';
|
|
2
3
|
import { BINDING_SOURCES } from '../../catalog/elements.generated.js';
|
|
3
4
|
import { withFreshIds } from './ids.js';
|
|
@@ -40,22 +41,44 @@ export function seededTypes() {
|
|
|
40
41
|
* An explicit `headline` wins over both — a merchant's own wording is not the
|
|
41
42
|
* client's to overrule.
|
|
42
43
|
*/
|
|
44
|
+
/** Rewrite every exact seed string the table carries, in place. */
|
|
45
|
+
function localizeSeed(doc, table) {
|
|
46
|
+
for (const n of Object.values(doc.nodes)) {
|
|
47
|
+
const sp = n.specials;
|
|
48
|
+
if (!sp)
|
|
49
|
+
continue;
|
|
50
|
+
for (const k of ['text', 'label']) {
|
|
51
|
+
const v = sp[k];
|
|
52
|
+
if (typeof v === 'string' && table[v])
|
|
53
|
+
sp[k] = table[v];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export { SEED_COPY_VI } from './seedcopy.js';
|
|
43
58
|
export function seedDocument(type, opts = {}) {
|
|
44
59
|
const seed = STORE_PAGE_SEEDS[type];
|
|
45
60
|
if (!seed)
|
|
46
61
|
return null;
|
|
47
62
|
const raw = JSON.stringify(seed);
|
|
63
|
+
const vi = langOf(opts.locale ?? 'vi') === 'vi';
|
|
64
|
+
const out = (doc) => {
|
|
65
|
+
if (vi)
|
|
66
|
+
localizeSeed(doc, SEED_COPY_VI);
|
|
67
|
+
return withFreshIds(doc);
|
|
68
|
+
};
|
|
48
69
|
// Fresh ids per create, so two pages from one seed share nothing but ROOT.
|
|
49
70
|
if (!raw.includes(COMPLETION_HEADLINE_SENTINEL)) {
|
|
50
|
-
return
|
|
71
|
+
return out(JSON.parse(raw));
|
|
51
72
|
}
|
|
52
|
-
|
|
73
|
+
// Normalized: a site whose locale is "en-US" reads its own language here too.
|
|
74
|
+
const tag = langOf(opts.locale);
|
|
75
|
+
const lang = tag && tag in COMPLETION_HEADLINE ? tag : 'vi';
|
|
53
76
|
const headline = opts.headline || COMPLETION_HEADLINE[lang];
|
|
54
77
|
// JSON.stringify already escaped the seed; the substitute has to be escaped
|
|
55
78
|
// the same way or an apostrophe in a merchant's own headline breaks the
|
|
56
79
|
// document. Slicing the quotes off a stringified string is that escaping.
|
|
57
80
|
const escaped = JSON.stringify(headline).slice(1, -1);
|
|
58
|
-
return
|
|
81
|
+
return out(JSON.parse(raw.split(COMPLETION_HEADLINE_SENTINEL).join(escaped)));
|
|
59
82
|
}
|
|
60
83
|
/**
|
|
61
84
|
* What to put on an ENTITY TEMPLATE that opens empty, or null.
|
package/dist/tools/api.js
CHANGED
|
@@ -455,7 +455,7 @@ export function resolveOperation(args) {
|
|
|
455
455
|
'method+path for a route the catalog does not carry.');
|
|
456
456
|
}
|
|
457
457
|
if (args.id) {
|
|
458
|
-
const op =
|
|
458
|
+
const op = findOperation(args.id);
|
|
459
459
|
if (!op)
|
|
460
460
|
throw new Error(`sbuilder: unknown operation "${args.id}" — use sb_api_find first`);
|
|
461
461
|
return op;
|
package/dist/tools/page.js
CHANGED
|
@@ -24,6 +24,7 @@ import { siteToken } from './credentialpick.js';
|
|
|
24
24
|
import { validateForSave } from '../domains/site/validate.js';
|
|
25
25
|
import { formDocChecks, sayChecks, specCheck, writeCheck } from '../domains/site/writecheck.js';
|
|
26
26
|
import { reviewDesign, REVIEW_NOTICE } from '../domains/site/review.js';
|
|
27
|
+
import { exampleCopy } from '../domains/site/examplecopy.js';
|
|
27
28
|
import { compactFindings } from '../domains/site/findings.js';
|
|
28
29
|
import { readinessGaps, READINESS_NOTICE, siteLanguage } from '../domains/site/readiness.js';
|
|
29
30
|
import { gatherReadiness } from '../domains/site/readiness-fetch.js';
|
|
@@ -46,8 +47,8 @@ import { projectList, PAGE_FIELDS, TEMPLATE_FIELDS } from './project.js';
|
|
|
46
47
|
* copies of a directive is how one of them quietly loses it. The fix for each
|
|
47
48
|
* KIND is sent once under `fixes`, and the directive once per process.
|
|
48
49
|
*/
|
|
49
|
-
export function reviewField(ctx, doc, formTypes, pageType, formDocType, formRedirects) {
|
|
50
|
-
const all = reviewDesign(doc, { formTypes, pageType, formDocType, formRedirects });
|
|
50
|
+
export function reviewField(ctx, doc, formTypes, pageType, formDocType, formRedirects, siteLocale) {
|
|
51
|
+
const all = reviewDesign(doc, { formTypes, pageType, formDocType, formRedirects, siteLocale });
|
|
51
52
|
if (all.length === 0)
|
|
52
53
|
return {};
|
|
53
54
|
// MAINTENANCE IS ADVICE, kept out of `findings`: it works for a visitor, so
|
|
@@ -1427,7 +1428,16 @@ export function registerPageTools(server, ctx) {
|
|
|
1427
1428
|
}, async ({ parent_id, spec, index, dry_run, force }) => {
|
|
1428
1429
|
const d = session.current();
|
|
1429
1430
|
const guard = { force, forced: [] };
|
|
1430
|
-
|
|
1431
|
+
// ROOT WITH NO INDEX MEANS "ANOTHER SECTION OF THE PAGE": the end of the
|
|
1432
|
+
// middle band, as sb_template_use and sb_duplicate already place one.
|
|
1433
|
+
// Appended last it landed after a shared footer, and the band-order check
|
|
1434
|
+
// refused the save — on exactly the call empty_page's fix names.
|
|
1435
|
+
// Only when a footer is there to land behind: a FORM document's root has
|
|
1436
|
+
// none, and addSubtree's own default (above the submit button) is right.
|
|
1437
|
+
const rootKids = d.doc.nodes[d.doc.root_node_id]?.data.nodes.length ?? 0;
|
|
1438
|
+
const mid = parent_id === d.doc.root_node_id ? middleEnd(d.doc) : rootKids;
|
|
1439
|
+
const at = index ?? (mid < rootKids ? mid : undefined);
|
|
1440
|
+
const { patches, ids } = addSubtree(d, parent_id, spec, at, guard);
|
|
1431
1441
|
const forced = guard.forced.length ? { forced: guard.forced } : {};
|
|
1432
1442
|
// ELEMENTS THAT RENDER CONVINCINGLY WHILE WIRED TO NOTHING. The add
|
|
1433
1443
|
// succeeds completely, the tree is correct and the page photographs
|
|
@@ -1797,6 +1807,7 @@ export function registerPageTools(server, ctx) {
|
|
|
1797
1807
|
// The page's TYPE says which record it supplies — `no_data_context` stays
|
|
1798
1808
|
// silent without it rather than guessing.
|
|
1799
1809
|
let pageType;
|
|
1810
|
+
let siteLocale;
|
|
1800
1811
|
try {
|
|
1801
1812
|
const { siteId, pageId } = session.location();
|
|
1802
1813
|
const input = await gatherReadiness(ctx, siteId, Object.values(doc.doc.nodes), pageId);
|
|
@@ -1807,6 +1818,7 @@ export function registerPageTools(server, ctx) {
|
|
|
1807
1818
|
return f.id && a?.action === 'redirect' && (a.url || a.pageId) ? [f.id] : [];
|
|
1808
1819
|
}));
|
|
1809
1820
|
pageType = input.pages?.find((p) => p.id === pageId)?.type;
|
|
1821
|
+
siteLocale = input.siteLocale;
|
|
1810
1822
|
const gaps = readinessGaps(input);
|
|
1811
1823
|
const lang = siteLanguage(input.siteLocale, input.pageNodes);
|
|
1812
1824
|
const said = lang ? ctx.notices.once(`site-language:${siteId}`, lang) : undefined;
|
|
@@ -1820,7 +1832,7 @@ export function registerPageTools(server, ctx) {
|
|
|
1820
1832
|
catch {
|
|
1821
1833
|
// Readiness is additional information, never the reason a review fails.
|
|
1822
1834
|
}
|
|
1823
|
-
const field = reviewField(ctx, doc, formTypes, pageType, session.formType(), formRedirects);
|
|
1835
|
+
const field = reviewField(ctx, doc, formTypes, pageType, session.formType(), formRedirects, siteLocale);
|
|
1824
1836
|
// Clean = no finding a visitor meets; `advice` (maintenance) rides beside the verdict.
|
|
1825
1837
|
const clean = !field.findings;
|
|
1826
1838
|
return text({
|
|
@@ -2045,9 +2057,22 @@ export function registerPageTools(server, ctx) {
|
|
|
2045
2057
|
});
|
|
2046
2058
|
}
|
|
2047
2059
|
await session.applyAndSave(patches);
|
|
2060
|
+
// Say it at the moment it is placed: the example copy publishes as if the
|
|
2061
|
+
// shop wrote it, and sb_review is the only other place it surfaces.
|
|
2062
|
+
const examples = ids.filter((id) => {
|
|
2063
|
+
const sp = doc.doc.nodes[id]?.specials ?? {};
|
|
2064
|
+
return ['text', 'label', 'alt'].some((k) => typeof sp[k] === 'string' && exampleCopy().has(sp[k]));
|
|
2065
|
+
}).length;
|
|
2048
2066
|
return text({
|
|
2049
2067
|
added: pattern.name,
|
|
2050
2068
|
section: ids[0],
|
|
2069
|
+
...(examples
|
|
2070
|
+
? {
|
|
2071
|
+
copy: `${examples} element(s) still carry the layout's example copy — write this shop's own ` +
|
|
2072
|
+
'words over them (sb_review lists each as example_copy); remove any number, promise ' +
|
|
2073
|
+
'or quote you cannot vouch for.',
|
|
2074
|
+
}
|
|
2075
|
+
: {}),
|
|
2051
2076
|
nodes: ids.length,
|
|
2052
2077
|
into: page_id,
|
|
2053
2078
|
rev: doc.rev,
|
|
@@ -2214,7 +2239,7 @@ export function registerPageTools(server, ctx) {
|
|
|
2214
2239
|
settings: z.record(z.unknown()).optional(),
|
|
2215
2240
|
seed: z.boolean().optional().describe('Default true; false creates a blank page.'),
|
|
2216
2241
|
chrome: z.boolean().optional().describe("Carry the site's header and footer, default true"),
|
|
2217
|
-
locale: z.string().optional().describe("vi
|
|
2242
|
+
locale: z.string().optional().describe("vi or en — a store seed's wording; defaults to the site's language, else vi."),
|
|
2218
2243
|
headline: z.string().optional().describe("The complete page's thank-you line."),
|
|
2219
2244
|
dry_run: z.boolean().optional(),
|
|
2220
2245
|
},
|
|
@@ -2397,7 +2422,25 @@ export function registerPageTools(server, ctx) {
|
|
|
2397
2422
|
let seeded = null;
|
|
2398
2423
|
const newId = res.page?.id;
|
|
2399
2424
|
if ((willSeed || layout) && typeof newId === 'string' && newId) {
|
|
2400
|
-
|
|
2425
|
+
// The SITE's language when the caller named none — the seed's English
|
|
2426
|
+
// copy is rewritten for a Vietnamese site (SEED_COPY_VI). Unreadable
|
|
2427
|
+
// settings fall back to the old default rather than block the create.
|
|
2428
|
+
const lang = locale ??
|
|
2429
|
+
(layout
|
|
2430
|
+
? undefined
|
|
2431
|
+
: await request({
|
|
2432
|
+
base: ctx.base,
|
|
2433
|
+
method: 'GET',
|
|
2434
|
+
path: `/api/sites/${encodeURIComponent(site_id)}/settings`,
|
|
2435
|
+
token: siteToken(ctx),
|
|
2436
|
+
fetchImpl: ctx.fetchImpl,
|
|
2437
|
+
})
|
|
2438
|
+
.then((r) => {
|
|
2439
|
+
const l = r?.settings?.locale;
|
|
2440
|
+
return typeof l === 'string' && l ? l : undefined;
|
|
2441
|
+
})
|
|
2442
|
+
.catch(() => undefined));
|
|
2443
|
+
const document = layout ? layoutDocument(layout) : seedDocument(type ?? '', { locale: lang, headline });
|
|
2401
2444
|
if (document) {
|
|
2402
2445
|
try {
|
|
2403
2446
|
await request({
|
|
@@ -2429,10 +2472,21 @@ export function registerPageTools(server, ctx) {
|
|
|
2429
2472
|
// entity URL once published — say what to add, whatever the reason
|
|
2430
2473
|
// (no platform seed, seed:false, a refused seed write).
|
|
2431
2474
|
const blankNote = !seeded || 'failed' in seeded ? emptyEntityTemplateNote(type) : null;
|
|
2475
|
+
// A LAYOUT is the editor's skeleton, and it ships its elements' own
|
|
2476
|
+
// placeholders ("Heading", "Enter your text here", an image with no src).
|
|
2477
|
+
// Measured: an "about" page created here reviewed with four findings before
|
|
2478
|
+
// anything else was done, and the create answer had said nothing about it.
|
|
2479
|
+
const skeletonFindings = seeded && 'layout' in seeded && layout
|
|
2480
|
+
? reviewDesign(PageDoc.from(layoutDocument(layout)), { pageType: type ?? 'page' }).length
|
|
2481
|
+
: 0;
|
|
2482
|
+
const skeletonNote = skeletonFindings
|
|
2483
|
+
? `This is the "${String(seeded?.layout)}" skeleton and it already has ${skeletonFindings} ` +
|
|
2484
|
+
'finding(s) — placeholder copy, empty slots. Open it and fill them; sb_review lists each.'
|
|
2485
|
+
: null;
|
|
2432
2486
|
return text({
|
|
2433
2487
|
...res,
|
|
2434
2488
|
...(seeded ? { seeded } : {}),
|
|
2435
|
-
...(blankNote ? { next: blankNote } : {}),
|
|
2489
|
+
...(blankNote ? { next: blankNote } : skeletonNote ? { next: skeletonNote } : {}),
|
|
2436
2490
|
...(wearing ? { chrome: wearing } : {}),
|
|
2437
2491
|
...(renamed
|
|
2438
2492
|
? {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.88.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",
|