sbuilder-mcp 0.85.0 → 0.87.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,30 @@ 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.87.0] - 2026-10-12
10
+
11
+ ### Added
12
+ - `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.
13
+ - `sb_review` reports `invisible_text` for text whose colour matches its background.
14
+ - `sb_review` reports `demo_variant_options` for a variant picker that shows fallback options instead of the product's own.
15
+ - `sb_review` reports `empty_page` for a page that has only a header and footer.
16
+ - `sb_review` flags product categories without an image as a `categoryImages` readiness gap when the page shows category tiles.
17
+ - `sb_template_use` reports how many elements still carry the layout's example copy.
18
+ - `sb_page_create` reports how many findings a seeded layout skeleton already has, so the placeholder copy and empty slots get filled.
19
+
20
+ ### Changed
21
+ - `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.
22
+ - `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.
23
+
24
+ ### Fixed
25
+ - `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.
26
+ - `sb_review` no longer reports `off_canvas` findings for a header's closed mobile menu.
27
+
28
+ ## [0.86.0] - 2026-10-12
29
+
30
+ ### Added
31
+ - `sb_api_find` and `sb_api_call` reach 849 platform operations, including the GrabFood delivery app: connecting a site, listing the orders it pushes, and accepting or rejecting them.
32
+
9
33
  ## [0.85.0] - 2026-10-11
10
34
 
11
35
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,30 @@ 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.87.0] - 2026-10-12
10
+
11
+ ### Added
12
+ - `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.
13
+ - `sb_review` báo `invisible_text` cho chữ có màu trùng với nền.
14
+ - `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.
15
+ - `sb_review` báo `empty_page` cho trang chỉ có header và footer.
16
+ - `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.
17
+ - `sb_template_use` cho biết còn bao nhiêu phần tử mang nội dung mẫu của layout.
18
+ - `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.
19
+
20
+ ### Changed
21
+ - `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.
22
+ - `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.
23
+
24
+ ### Fixed
25
+ - `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.
26
+ - `sb_review` không còn báo `off_canvas` cho menu di động đang đóng trong header.
27
+
28
+ ## [0.86.0] - 2026-10-12
29
+
30
+ ### Added
31
+ - `sb_api_find` và `sb_api_call` truy cập được 849 operation của platform, gồm app giao hàng GrabFood: kết nối site, liệt kê đơn hàng được đẩy về, và nhận hoặc từ chối đơn.
32
+
9
33
  ## [0.85.0] - 2026-10-11
10
34
 
11
35
  ### Added
package/README.md CHANGED
@@ -118,7 +118,7 @@ in this client: one would put the very key the platform exists to hold back into
118
118
  | `sb_store` | Run a store flow that must happen in a fixed order — `checkout` (the four writes that make a working one), `checkout_sync` (the store's current payment and delivery methods into every existing order form, checkout page republished), `form` (any of the platform's 17 templates with its own field document), `chrome` (one shared header or footer on a real site menu — desktop menu, mobile drawer, cart and account icons), `menu` (a menu node bound to the site's menu, its links resolved), `overlay_attach` (a pop-up or quick view on the open page), `cart` (the site's cart drawer, when it has none; `relocalize` puts an existing one in the site's language) and `app` (a built-in app plus the pages it needs), `global_attach` / `global_detach` (put an EXISTING shared section on the open page, or take it off), `global_promote` (make a section on the page a NEW shared section) and `template_save` (save a section as a reusable template) |
119
119
  | `sb_undo` | Undo / redo this session's writes — page and form edits and `sb_api_call` PUTs; a page is refused if someone else saved it since. The platform's versions and history (`sb_page_version`) outlive this process |
120
120
 
121
- Twenty-eight tools, **843 API operations** (309 of the 389 writes carrying a body shape read
121
+ Twenty-eight tools, **849 API operations** (312 of the 393 writes carrying a body shape read
122
122
  off the handler), 123 elements, 94 binding sources. `sb_api_find`
123
123
  is an index rather than a tool per endpoint, so the tool list stays short while everything
124
124
  the platform can do stays reachable — and operations added to the platform arrive with the
package/README.vi.md CHANGED
@@ -115,7 +115,7 @@ nhét ngược lại vào mọi bản cài.
115
115
  | `sb_store` | Chạy một luồng cửa hàng bắt buộc đúng thứ tự — `checkout` (bốn lệnh ghi tạo nên trang thanh toán), `checkout_sync` (đưa phương thức thanh toán và giao hàng hiện tại vào mọi form đặt hàng đã có, publish lại trang thanh toán), `form` (một trong 17 template của nền tảng kèm field document của nó), `chrome` (một header hoặc footer dùng chung trên menu thật của site — menu desktop, drawer mobile, icon giỏ hàng và tài khoản), `menu` (một node menu bind vào menu của site, link đã phân giải), `overlay_attach` (một pop-up hay quick view trên trang đang mở), `cart` (ngăn giỏ hàng của site, khi chưa có; `relocalize` đưa ngăn giỏ đã có về ngôn ngữ của site), `app` (một app dựng sẵn kèm những trang nó cần), và `global_attach` / `global_detach` (đặt một section dùng chung ĐÃ CÓ lên trang đang mở, hoặc gỡ ra), `global_promote` (biến một section trên trang thành section dùng chung MỚI) và `template_save` (lưu một section làm mẫu dùng lại) |
116
116
  | `sb_undo` | Hoàn tác / làm lại các thao tác ghi trong phiên — chỉnh sửa trang, form và PUT qua `sb_api_call`; trang bị từ chối nếu người khác đã lưu sau đó. Version và history của nền tảng (`sb_page_version`) sống lâu hơn process này |
117
117
 
118
- Hai mươi tám tool, **843 operation API** (309 trong 389 lệnh ghi có hình dạng body đọc thẳng
118
+ Hai mươi tám tool, **849 operation API** (312 trong 393 lệnh ghi có hình dạng body đọc thẳng
119
119
  từ handler), 123 element, 94 nguồn binding. `sb_api_find` là
120
120
  một chỉ mục chứ không phải mỗi endpoint một tool, nên danh sách tool vẫn ngắn trong khi mọi
121
121
  thứ nền tảng làm được vẫn với tới — và operation mới thêm bên nền tảng sẽ tự có sau lần
@@ -1,7 +1,7 @@
1
1
  export const SWAGGER_SOURCE = {
2
- "operations": 843,
3
- "definitions": 278,
4
- "bodyCarrying": 373,
2
+ "operations": 849,
3
+ "definitions": 280,
4
+ "bodyCarrying": 376,
5
5
  "bodyUndescribed": 102,
6
6
  "generatedFrom": "server/docs/swagger.json"
7
7
  };
@@ -781,6 +781,34 @@ export const API_OPERATIONS = [
781
781
  "bodyRef": null,
782
782
  "credential": "siteScoped"
783
783
  },
784
+ {
785
+ "id": "post:/_wb/delivery/{app}/{path}",
786
+ "method": "POST",
787
+ "path": "/_wb/delivery/{app}/{path}",
788
+ "tags": [
789
+ "pos"
790
+ ],
791
+ "summary": "Delivery-app partner endpoints (GrabFood)",
792
+ "params": [
793
+ {
794
+ "name": "app",
795
+ "in": "path",
796
+ "required": true,
797
+ "type": "string",
798
+ "description": "App id (grabfood)"
799
+ },
800
+ {
801
+ "name": "path",
802
+ "in": "path",
803
+ "required": true,
804
+ "type": "string",
805
+ "description": "oauth/token | orders | order-state | menu-sync-state"
806
+ }
807
+ ],
808
+ "bodyDescribed": false,
809
+ "bodyRef": null,
810
+ "credential": "siteScoped"
811
+ },
784
812
  {
785
813
  "id": "get:/_wb/feed/facets",
786
814
  "method": "GET",
@@ -7550,6 +7578,181 @@ export const API_OPERATIONS = [
7550
7578
  "bodyRef": null,
7551
7579
  "credential": "siteScoped"
7552
7580
  },
7581
+ {
7582
+ "id": "get:/api/sites/{siteId}/delivery-apps/orders",
7583
+ "method": "GET",
7584
+ "path": "/api/sites/{siteId}/delivery-apps/orders",
7585
+ "tags": [
7586
+ "pos"
7587
+ ],
7588
+ "summary": "List orders pushed by delivery apps",
7589
+ "params": [
7590
+ {
7591
+ "name": "siteId",
7592
+ "in": "path",
7593
+ "required": true,
7594
+ "type": "string",
7595
+ "description": "Site ID"
7596
+ },
7597
+ {
7598
+ "name": "state",
7599
+ "in": "query",
7600
+ "required": false,
7601
+ "type": "string",
7602
+ "description": "new|deciding|accepted|rejected|cancelled|completed"
7603
+ },
7604
+ {
7605
+ "name": "limit",
7606
+ "in": "query",
7607
+ "required": false,
7608
+ "type": "integer",
7609
+ "description": "page size"
7610
+ },
7611
+ {
7612
+ "name": "offset",
7613
+ "in": "query",
7614
+ "required": false,
7615
+ "type": "integer",
7616
+ "description": "offset"
7617
+ }
7618
+ ],
7619
+ "bodyDescribed": false,
7620
+ "bodyRef": null,
7621
+ "credential": "siteScoped"
7622
+ },
7623
+ {
7624
+ "id": "post:/api/sites/{siteId}/delivery-apps/orders/{id}/accept",
7625
+ "method": "POST",
7626
+ "path": "/api/sites/{siteId}/delivery-apps/orders/{id}/accept",
7627
+ "tags": [
7628
+ "pos"
7629
+ ],
7630
+ "summary": "Accept a delivery-app order",
7631
+ "params": [
7632
+ {
7633
+ "name": "siteId",
7634
+ "in": "path",
7635
+ "required": true,
7636
+ "type": "string",
7637
+ "description": "Site ID"
7638
+ },
7639
+ {
7640
+ "name": "id",
7641
+ "in": "path",
7642
+ "required": true,
7643
+ "type": "string",
7644
+ "description": "Order ID"
7645
+ }
7646
+ ],
7647
+ "bodyDescribed": false,
7648
+ "bodyRef": null,
7649
+ "credential": "siteScoped"
7650
+ },
7651
+ {
7652
+ "id": "post:/api/sites/{siteId}/delivery-apps/orders/{id}/reject",
7653
+ "method": "POST",
7654
+ "path": "/api/sites/{siteId}/delivery-apps/orders/{id}/reject",
7655
+ "tags": [
7656
+ "pos"
7657
+ ],
7658
+ "summary": "Reject a delivery-app order",
7659
+ "params": [
7660
+ {
7661
+ "name": "siteId",
7662
+ "in": "path",
7663
+ "required": true,
7664
+ "type": "string",
7665
+ "description": "Site ID"
7666
+ },
7667
+ {
7668
+ "name": "id",
7669
+ "in": "path",
7670
+ "required": true,
7671
+ "type": "string",
7672
+ "description": "Order ID"
7673
+ },
7674
+ {
7675
+ "name": "body",
7676
+ "in": "body",
7677
+ "required": true,
7678
+ "type": "object",
7679
+ "description": "{reason}"
7680
+ }
7681
+ ],
7682
+ "bodyDescribed": true,
7683
+ "bodyRef": "internal_deliveryapps_rest.rejectBody",
7684
+ "credential": "siteScoped"
7685
+ },
7686
+ {
7687
+ "id": "get:/api/sites/{siteId}/delivery-apps/{app}/connection",
7688
+ "method": "GET",
7689
+ "path": "/api/sites/{siteId}/delivery-apps/{app}/connection",
7690
+ "tags": [
7691
+ "pos"
7692
+ ],
7693
+ "summary": "Read or replace the site's connection to a delivery app",
7694
+ "params": [
7695
+ {
7696
+ "name": "siteId",
7697
+ "in": "path",
7698
+ "required": true,
7699
+ "type": "string",
7700
+ "description": "Site ID"
7701
+ },
7702
+ {
7703
+ "name": "app",
7704
+ "in": "path",
7705
+ "required": true,
7706
+ "type": "string",
7707
+ "description": "App id (grabfood, shopeefood)"
7708
+ },
7709
+ {
7710
+ "name": "connection",
7711
+ "in": "body",
7712
+ "required": false,
7713
+ "type": "object",
7714
+ "description": "Connection (PUT only)"
7715
+ }
7716
+ ],
7717
+ "bodyDescribed": true,
7718
+ "bodyRef": "internal_deliveryapps_rest.connectionInput",
7719
+ "credential": "siteScoped"
7720
+ },
7721
+ {
7722
+ "id": "put:/api/sites/{siteId}/delivery-apps/{app}/connection",
7723
+ "method": "PUT",
7724
+ "path": "/api/sites/{siteId}/delivery-apps/{app}/connection",
7725
+ "tags": [
7726
+ "pos"
7727
+ ],
7728
+ "summary": "Read or replace the site's connection to a delivery app",
7729
+ "params": [
7730
+ {
7731
+ "name": "siteId",
7732
+ "in": "path",
7733
+ "required": true,
7734
+ "type": "string",
7735
+ "description": "Site ID"
7736
+ },
7737
+ {
7738
+ "name": "app",
7739
+ "in": "path",
7740
+ "required": true,
7741
+ "type": "string",
7742
+ "description": "App id (grabfood, shopeefood)"
7743
+ },
7744
+ {
7745
+ "name": "connection",
7746
+ "in": "body",
7747
+ "required": false,
7748
+ "type": "object",
7749
+ "description": "Connection (PUT only)"
7750
+ }
7751
+ ],
7752
+ "bodyDescribed": true,
7753
+ "bodyRef": "internal_deliveryapps_rest.connectionInput",
7754
+ "credential": "siteScoped"
7755
+ },
7553
7756
  {
7554
7757
  "id": "get:/api/sites/{siteId}/discounts",
7555
7758
  "method": "GET",
@@ -32486,6 +32689,25 @@ export const API_DEFINITIONS = {
32486
32689
  }
32487
32690
  }
32488
32691
  },
32692
+ "internal_deliveryapps_rest.connectionInput": {
32693
+ "type": "object",
32694
+ "properties": {
32695
+ "enabled": {
32696
+ "type": "boolean"
32697
+ },
32698
+ "externalMerchantId": {
32699
+ "type": "string"
32700
+ }
32701
+ }
32702
+ },
32703
+ "internal_deliveryapps_rest.rejectBody": {
32704
+ "type": "object",
32705
+ "properties": {
32706
+ "reason": {
32707
+ "type": "string"
32708
+ }
32709
+ }
32710
+ },
32489
32711
  "internal_discounts_rest.grantRequest": {
32490
32712
  "type": "object",
32491
32713
  "properties": {
@@ -1,6 +1,6 @@
1
1
  export const DEAD_KEY_SOURCE = {
2
- "files": 5665,
3
- "identifiers": 103621,
2
+ "files": 5691,
3
+ "identifiers": 104085,
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;
@@ -1,10 +1,10 @@
1
1
  export const SHAPE_SOURCE = {
2
- "writeOperations": 389,
3
- "shaped": 309,
4
- "fromHandlers": 299,
2
+ "writeOperations": 393,
3
+ "shaped": 312,
4
+ "fromHandlers": 302,
5
5
  "fromSwaggerOnly": 10,
6
6
  "withReadOnly": 30,
7
- "structsRead": 3194
7
+ "structsRead": 3226
8
8
  };
9
9
  export const REQUEST_SHAPES = {
10
10
  "post:/api/sites/{id}/ai-credits/purchase": {
@@ -3188,6 +3188,48 @@ export const REQUEST_SHAPES = {
3188
3188
  "updatedAt"
3189
3189
  ]
3190
3190
  },
3191
+ "put:/api/sites/{siteId}/delivery-apps/{app}/connection": {
3192
+ "fields": [
3193
+ {
3194
+ "name": "enabled",
3195
+ "type": "boolean"
3196
+ },
3197
+ {
3198
+ "name": "externalMerchantId",
3199
+ "type": "string"
3200
+ }
3201
+ ],
3202
+ "source": "go",
3203
+ "goType": "connectionInput"
3204
+ },
3205
+ "post:/api/sites/{siteId}/delivery-apps/orders/{id}/reject": {
3206
+ "fields": [
3207
+ {
3208
+ "name": "reason",
3209
+ "type": "string"
3210
+ }
3211
+ ],
3212
+ "source": "go",
3213
+ "goType": "rejectBody"
3214
+ },
3215
+ "post:/_wb/delivery/{app}/{path}": {
3216
+ "fields": [
3217
+ {
3218
+ "name": "client_id",
3219
+ "type": "string"
3220
+ },
3221
+ {
3222
+ "name": "client_secret",
3223
+ "type": "string"
3224
+ },
3225
+ {
3226
+ "name": "grant_type",
3227
+ "type": "string"
3228
+ }
3229
+ ],
3230
+ "source": "go",
3231
+ "goType": "(inline)"
3232
+ },
3191
3233
  "post:/api/sites/{siteId}/discounts": {
3192
3234
  "fields": [
3193
3235
  {
@@ -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": "8384af1dd6df12bb80a75a8b53e88c377d3f722c",
5
- "committedAt": "2026-10-12T01:13:06+07:00",
4
+ "commit": "df9accca9c8a0e402ca5520beddcb52c0843f7f5",
5
+ "committedAt": "2026-10-12T09:14:30+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,23 @@ 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 IS A BROKEN-IMAGE ICON, not a grey card:
616
+ // render/nodes/collection-media writes `<img src="">` (the image element has a
617
+ // placeholder; this one does not). Measured on the sb_category_strip a store
618
+ // built here put on its home page — two broken icons above the category names.
619
+ // Asked only where a collection tile is actually 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 renders a broken-image icon above its name.',
628
+ fix: 'Upload a picture (sb_media_upload), then PUT the category back with image: url ' +
629
+ '(sb_api_call put:/api/sites/{siteId}/product-categories/{id}, merge:true).',
630
+ });
631
+ }
615
632
  if (input.shippingMethods === 0) {
616
633
  gaps.push({
617
634
  id: 'shipping',
@@ -1,6 +1,8 @@
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';
4
6
  import { HOVER_STATE, hoverHome } from './hover.js';
5
7
  import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
6
8
  import { fill } from './findings.js';
@@ -130,6 +132,33 @@ const LINK_SPECIALS = new Set(['boundHref', 'boundHrefLabel', 'boundProductURL']
130
132
  function drawsItsOwnContent(type) {
131
133
  return (BOUND_SPECIALS[type] ?? []).some((k) => !LINK_SPECIALS.has(k));
132
134
  }
135
+ /** A literal #rgb / #rrggbb / rgb() colour as 0–255 channels, or null. */
136
+ function rgbOf(v) {
137
+ const c = typeof v === 'string' ? v.trim().toLowerCase() : '';
138
+ if (c === 'white')
139
+ return [255, 255, 255];
140
+ if (c === 'black')
141
+ return [0, 0, 0];
142
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(c);
143
+ if (hex) {
144
+ const h = hex[1].length === 3 ? hex[1].replace(/./g, (x) => x + x) : hex[1];
145
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
146
+ }
147
+ const m = /^rgb\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)\s*\)$/.exec(c);
148
+ return m ? [m[1], m[2], m[3]].map(Number) : null;
149
+ }
150
+ /** WCAG contrast ratio of two sRGB colours. */
151
+ function contrast(a, b) {
152
+ const lum = (rgb) => {
153
+ const [r, g, bl] = rgb.map((v) => {
154
+ const s = v / 255;
155
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
156
+ });
157
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
158
+ };
159
+ const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
160
+ return (hi + 0.05) / (lo + 0.05);
161
+ }
133
162
  /** Binding-source prefixes that name a RECORD (not `site.*`, which every page has). */
134
163
  // cartLine: only a cart-order's line row supplies it (render/nodes/cart-order,
135
164
  // scope.PlaceholderCartLine) — see ROW_SUPPLIER. searchRow is left out: no
@@ -248,6 +277,27 @@ opts = {}) {
248
277
  });
249
278
  return out;
250
279
  }
280
+ // ONLY THE SHARED HEADER AND FOOTER is a blank page too. Measured: an "account"
281
+ // page created by sb_page_create (no seed for that type) wore the site chrome
282
+ // and nothing else, reviewed clean, and so did the readiness gap it was made
283
+ // to close — a shopper landing on /account met a header, a footer and nothing.
284
+ // Composed (globalId, what a read carries) or stored (globalRef) alike.
285
+ // HEADER AND FOOTER only: a `custom` shared section is body content.
286
+ const isChrome = (id) => {
287
+ const sp = d.nodes[id]?.specials ?? {};
288
+ if (isGlobal(d, id))
289
+ return bandOf(d, id) !== 'middle';
290
+ return sp[SPEC_GLOBAL_REF] !== undefined && (sp.globalKind === 'header' || sp.globalKind === 'footer');
291
+ };
292
+ if (pageChildren(d).every(isChrome)) {
293
+ out.push({
294
+ code: 'empty_page',
295
+ nodeId: d.root_node_id,
296
+ type: 'root',
297
+ problem: 'Only the shared header and footer — the page between them is blank.',
298
+ fix: fill('empty_page', {}),
299
+ });
300
+ }
251
301
  // Document order, depth-first from ROOT: the order a reader meets them.
252
302
  const seen = new Set();
253
303
  const walkOrder = [];
@@ -383,6 +433,54 @@ opts = {}) {
383
433
  });
384
434
  continue;
385
435
  }
436
+ // TYPE THE SAME COLOUR AS WHAT IT SITS ON. Measured: a pattern dressed in
437
+ // tokens read off an inverted band published a white "Mua ngay" on its own
438
+ // white fill — present in the HTML, invisible on the page, clean in review.
439
+ // LITERAL COLOURS ONLY, on both sides, and the ground is the element's own
440
+ // fill or the nearest ancestor that sets one: a theme var or an unset page
441
+ // ground is not known here, and a dark theme would make a guessed "white"
442
+ // ground cry wolf on every light heading.
443
+ if ((type === 'text' || type === 'heading' || type === 'button') && !inOverlay.has(id)) {
444
+ const fg = rgbOf(n.style?.color);
445
+ let ground = null;
446
+ for (let x = id; x && d.nodes[x] && !ground; x = d.nodes[x].data.parent) {
447
+ const bg = d.nodes[x].style?.backgroundColor;
448
+ if (bg === undefined || bg === 'transparent' || bg === '')
449
+ continue;
450
+ ground = rgbOf(bg);
451
+ if (!ground)
452
+ break; // a var() or a gradient: unknown, say nothing
453
+ }
454
+ if (fg && ground && contrast(fg, ground) < 1.5) {
455
+ out.push({
456
+ code: 'invisible_text',
457
+ nodeId: id,
458
+ type,
459
+ 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.`,
460
+ fix: fill('invisible_text', { id }),
461
+ });
462
+ }
463
+ }
464
+ // THE DEMO OPTIONS. product-variants seeds config.attributes Color
465
+ // Red/Green/Blue, Size S/M/L, and that authored config is the documented
466
+ // FALLBACK for a product with no option axes of its own (schema binding.ts:
467
+ // "the picker keeps its authored config"). So every simple product on a
468
+ // published page offered three colours and three sizes that do not exist —
469
+ // measured on a store built here, and reviewed clean.
470
+ if (type === 'product-variants') {
471
+ const authored = n.config?.attributes;
472
+ const seeded = ELEMENTS[type]?.defaults?.config?.attributes;
473
+ if (Array.isArray(seeded) && seeded.length && isDeepStrictEqual(authored, seeded)) {
474
+ out.push({
475
+ code: 'demo_variant_options',
476
+ nodeId: id,
477
+ type,
478
+ problem: 'Still the picker\'s demo options (Color Red/Green/Blue, Size S/M/L). They render for ' +
479
+ 'every product that has no options of its own — a shopper is offered choices that do not exist.',
480
+ fix: fill('demo_variant_options', { id }),
481
+ });
482
+ }
483
+ }
386
484
  // A NAVIGATION CONTROL WITH NOWHERE TO GO, and it is the sharpest silent
387
485
  // failure on this list because the page is CORRECT in every way a check can
388
486
  // otherwise see. The tree holds the event the author asked for; the pixels
@@ -669,11 +767,18 @@ opts = {}) {
669
767
  // is "add something inside it", which on the next save is decomposed away
670
768
  // again. Measured on a page built with `sb_add`: the header and footer refs
671
769
  // both flagged, and both correct.
770
+ // OR IT DRAWS ITS OWN CONTROL. `search-input` is a container only for the
771
+ // dropdown an author MAY compose under it; with no children it still renders
772
+ // its `<input>` (render/nodes/search-input/html.go). The platform's own
773
+ // search seed ships it childless, so every seeded search page was flagged.
774
+ // Not `select`: its face lives in its trigger/panel children.
775
+ const drawsOwnControl = type === 'search-input';
672
776
  const isReference = (n.specials ?? {})[SPEC_GLOBAL_REF] !== undefined ||
673
777
  (n.specials ?? {})[SPEC_APP_BLOCK_REF] !== undefined;
674
778
  if (meta.isContainer &&
675
779
  childrenOf(d, id).length === 0 &&
676
780
  !isReference &&
781
+ !drawsOwnControl &&
677
782
  !(bindings.length > 0 && drawsItsOwnContent(type))) {
678
783
  out.push({
679
784
  code: 'empty_container',
@@ -762,6 +867,19 @@ opts = {}) {
762
867
  fix: fill('default_seed_copy', { id, key }),
763
868
  });
764
869
  }
870
+ // A BUILT-IN LAYOUT'S EXAMPLE COPY — see examplecopy.ts.
871
+ else if (typeof value === 'string' && exampleCopy().has(value)) {
872
+ out.push({
873
+ code: 'example_copy',
874
+ nodeId: id,
875
+ type,
876
+ problem: `Still the "${exampleCopy().get(value)}" layout's example copy (${JSON.stringify(value)}). ` +
877
+ 'A shopper reads it as this shop speaking — and a number, a promise or a ' +
878
+ 'testimonial in it is a claim nobody made.',
879
+ key,
880
+ fix: fill('example_copy', { id, key }),
881
+ });
882
+ }
765
883
  }
766
884
  // A FORM NOBODY LINKED publishes as an empty box, and the platform stays
767
885
  // deliberately quiet about it: form.go:221 skips an empty formId with the
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';
@@ -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
@@ -2045,9 +2055,22 @@ export function registerPageTools(server, ctx) {
2045
2055
  });
2046
2056
  }
2047
2057
  await session.applyAndSave(patches);
2058
+ // Say it at the moment it is placed: the example copy publishes as if the
2059
+ // shop wrote it, and sb_review is the only other place it surfaces.
2060
+ const examples = ids.filter((id) => {
2061
+ const sp = doc.doc.nodes[id]?.specials ?? {};
2062
+ return ['text', 'label', 'alt'].some((k) => typeof sp[k] === 'string' && exampleCopy().has(sp[k]));
2063
+ }).length;
2048
2064
  return text({
2049
2065
  added: pattern.name,
2050
2066
  section: ids[0],
2067
+ ...(examples
2068
+ ? {
2069
+ copy: `${examples} element(s) still carry the layout's example copy — write this shop's own ` +
2070
+ 'words over them (sb_review lists each as example_copy); remove any number, promise ' +
2071
+ 'or quote you cannot vouch for.',
2072
+ }
2073
+ : {}),
2051
2074
  nodes: ids.length,
2052
2075
  into: page_id,
2053
2076
  rev: doc.rev,
@@ -2429,10 +2452,21 @@ export function registerPageTools(server, ctx) {
2429
2452
  // entity URL once published — say what to add, whatever the reason
2430
2453
  // (no platform seed, seed:false, a refused seed write).
2431
2454
  const blankNote = !seeded || 'failed' in seeded ? emptyEntityTemplateNote(type) : null;
2455
+ // A LAYOUT is the editor's skeleton, and it ships its elements' own
2456
+ // placeholders ("Heading", "Enter your text here", an image with no src).
2457
+ // Measured: an "about" page created here reviewed with four findings before
2458
+ // anything else was done, and the create answer had said nothing about it.
2459
+ const skeletonFindings = seeded && 'layout' in seeded && layout
2460
+ ? reviewDesign(PageDoc.from(layoutDocument(layout)), { pageType: type ?? 'page' }).length
2461
+ : 0;
2462
+ const skeletonNote = skeletonFindings
2463
+ ? `This is the "${String(seeded?.layout)}" skeleton and it already has ${skeletonFindings} ` +
2464
+ 'finding(s) — placeholder copy, empty slots. Open it and fill them; sb_review lists each.'
2465
+ : null;
2432
2466
  return text({
2433
2467
  ...res,
2434
2468
  ...(seeded ? { seeded } : {}),
2435
- ...(blankNote ? { next: blankNote } : {}),
2469
+ ...(blankNote ? { next: blankNote } : skeletonNote ? { next: skeletonNote } : {}),
2436
2470
  ...(wearing ? { chrome: wearing } : {}),
2437
2471
  ...(renamed
2438
2472
  ? {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.85.0",
3
+ "version": "0.87.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",