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 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
@@ -1,6 +1,6 @@
1
1
  export const DEAD_KEY_SOURCE = {
2
- "files": 5688,
3
- "identifiers": 104011,
2
+ "files": 5692,
3
+ "identifiers": 104086,
4
4
  "seededKeys": 445,
5
5
  "dead": 0
6
6
  };
@@ -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
- return API_OPERATIONS.find((o) => o.id === id);
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;
@@ -4,7 +4,7 @@ export const SHAPE_SOURCE = {
4
4
  "fromHandlers": 302,
5
5
  "fromSwaggerOnly": 10,
6
6
  "withReadOnly": 30,
7
- "structsRead": 3224
7
+ "structsRead": 3226
8
8
  };
9
9
  export const REQUEST_SHAPES = {
10
10
  "post:/api/sites/{id}/ai-credits/purchase": {
@@ -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": "c296c1a134378574c7d8643ce020485c3c53535b",
5
- "committedAt": "2026-10-12T01:19:39+07:00",
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, walk } from '../../core/tree.js';
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
- walk(doc, id, visit);
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 withFreshIds(JSON.parse(raw));
71
+ return out(JSON.parse(raw));
51
72
  }
52
- const lang = opts.locale && opts.locale in COMPLETION_HEADLINE ? opts.locale : 'vi';
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 withFreshIds(JSON.parse(raw.split(COMPLETION_HEADLINE_SENTINEL).join(escaped)));
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 = API_OPERATIONS.find((o) => o.id === args.id);
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;
@@ -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
- const { patches, ids } = addSubtree(d, parent_id, spec, index, guard);
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 (default) or en — the complete page's wording."),
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
- const document = layout ? layoutDocument(layout) : seedDocument(type ?? '', { locale, headline });
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.86.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",