sbuilder-mcp 0.23.1 → 0.25.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 +16 -0
- package/CHANGELOG.vi.md +16 -0
- package/dist/catalog/api.generated.js +79 -3
- package/dist/catalog/elements.generated.js +1 -4
- package/dist/catalog/shapes.generated.js +49 -4
- package/dist/domains/site/patterns.js +76 -15
- package/dist/tools/page.js +142 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,22 @@ 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.25.0] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_page_create now attaches the site's own header and footer to a newly created page by default, read off the site's home page, so a page built with these tools carries the same navigation and footer as the rest of the site instead of arriving bare; pass chrome:false to skip it.
|
|
13
|
+
|
|
14
|
+
## [0.24.0] - 2026-09-10
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- sb_template_use now fills a picture slot in a built-in layout with a real image from the site's own media library, preferring a landscape image where the layout wants one, instead of leaving a placeholder note asking the caller to add one by hand.
|
|
18
|
+
- sb_templates gained a new built-in layout, sb_gallery, a row of up to six images pulled from the site's media library.
|
|
19
|
+
- The API catalog gained three operations: `POST /api/media/{siteId}/from-url` (reachable through sb_api_call as well as sb_media_upload), `GET /api/sites/{siteId}/forms/submissions`, and `POST /api/sites/{siteId}/translations/review/bulk`.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- sb_template_use no longer repeats a photo already placed on the page: each picture slot it fills draws only from images the page is not already showing, so a hero added first and a gallery added second no longer show the same image twice.
|
|
23
|
+
- sb_template_use now reports `images_available` (the count of unused images in the site's library) in its dry-run preview, and names sb_media_upload in its result when the site's library has no images to draw from.
|
|
24
|
+
|
|
9
25
|
## [0.23.1] - 2026-09-10
|
|
10
26
|
|
|
11
27
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,22 @@ 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.25.0] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_page_create giờ mặc định gắn header và footer của chính site vào trang mới tạo, đọc từ trang chủ của site, nên một trang được dựng bằng các tool này mang cùng menu điều hướng và footer với phần còn lại của site thay vì trống trơn; truyền chrome:false để bỏ qua.
|
|
13
|
+
|
|
14
|
+
## [0.24.0] - 2026-09-10
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- sb_template_use giờ lấp ô chứa ảnh trong một layout dựng sẵn bằng ảnh thật từ thư viện media của chính site, ưu tiên ảnh nằm ngang khi layout cần, thay vì để lại một ghi chú yêu cầu người gọi tự thêm ảnh.
|
|
18
|
+
- sb_templates có thêm một layout dựng sẵn mới, sb_gallery, một hàng tối đa sáu ảnh lấy từ thư viện media của site.
|
|
19
|
+
- Catalog API có thêm ba operation: `POST /api/media/{siteId}/from-url` (gọi được qua sb_api_call cũng như qua sb_media_upload), `GET /api/sites/{siteId}/forms/submissions`, và `POST /api/sites/{siteId}/translations/review/bulk`.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- sb_template_use giờ không còn lặp lại một ảnh đã đặt trên trang: mỗi ô chứa ảnh nó lấp chỉ lấy từ những ảnh trang chưa hiển thị, nên một hero thêm trước và một dải ảnh thêm sau không còn hiện cùng một ảnh hai lần.
|
|
23
|
+
- sb_template_use giờ báo `images_available` (số ảnh chưa dùng trong thư viện site) trong bản xem trước dry-run, và nêu tên sb_media_upload trong kết quả khi thư viện site không có ảnh nào để lấy.
|
|
24
|
+
|
|
9
25
|
## [0.23.1] - 2026-09-10
|
|
10
26
|
|
|
11
27
|
### Added
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export const SWAGGER_SOURCE = {
|
|
2
|
-
"operations":
|
|
2
|
+
"operations": 498,
|
|
3
3
|
"definitions": 104,
|
|
4
|
-
"bodyCarrying":
|
|
5
|
-
"bodyUndescribed":
|
|
4
|
+
"bodyCarrying": 178,
|
|
5
|
+
"bodyUndescribed": 64,
|
|
6
6
|
"generatedFrom": "server/docs/swagger.json"
|
|
7
7
|
};
|
|
8
8
|
export const API_OPERATIONS = [
|
|
@@ -1882,6 +1882,34 @@ export const API_OPERATIONS = [
|
|
|
1882
1882
|
"bodyRef": null,
|
|
1883
1883
|
"credential": "siteScoped"
|
|
1884
1884
|
},
|
|
1885
|
+
{
|
|
1886
|
+
"id": "post:/api/media/{siteId}/from-url",
|
|
1887
|
+
"method": "POST",
|
|
1888
|
+
"path": "/api/media/{siteId}/from-url",
|
|
1889
|
+
"tags": [
|
|
1890
|
+
"media"
|
|
1891
|
+
],
|
|
1892
|
+
"summary": "Upload media by URL, fetched server-side",
|
|
1893
|
+
"params": [
|
|
1894
|
+
{
|
|
1895
|
+
"name": "siteId",
|
|
1896
|
+
"in": "path",
|
|
1897
|
+
"required": true,
|
|
1898
|
+
"type": "string",
|
|
1899
|
+
"description": "Site ID"
|
|
1900
|
+
},
|
|
1901
|
+
{
|
|
1902
|
+
"name": "body",
|
|
1903
|
+
"in": "body",
|
|
1904
|
+
"required": true,
|
|
1905
|
+
"type": "object",
|
|
1906
|
+
"description": "url, and optionally folderId and name"
|
|
1907
|
+
}
|
|
1908
|
+
],
|
|
1909
|
+
"bodyDescribed": false,
|
|
1910
|
+
"bodyRef": null,
|
|
1911
|
+
"credential": "siteScoped"
|
|
1912
|
+
},
|
|
1885
1913
|
{
|
|
1886
1914
|
"id": "get:/api/notifications",
|
|
1887
1915
|
"method": "GET",
|
|
@@ -6653,6 +6681,41 @@ export const API_OPERATIONS = [
|
|
|
6653
6681
|
"bodyRef": null,
|
|
6654
6682
|
"credential": "siteScoped"
|
|
6655
6683
|
},
|
|
6684
|
+
{
|
|
6685
|
+
"id": "get:/api/sites/{siteId}/forms/submissions",
|
|
6686
|
+
"method": "GET",
|
|
6687
|
+
"path": "/api/sites/{siteId}/forms/submissions",
|
|
6688
|
+
"tags": [
|
|
6689
|
+
"forms"
|
|
6690
|
+
],
|
|
6691
|
+
"summary": "Search a site's responses across all of its forms",
|
|
6692
|
+
"params": [
|
|
6693
|
+
{
|
|
6694
|
+
"name": "siteId",
|
|
6695
|
+
"in": "path",
|
|
6696
|
+
"required": true,
|
|
6697
|
+
"type": "string",
|
|
6698
|
+
"description": "Site id"
|
|
6699
|
+
},
|
|
6700
|
+
{
|
|
6701
|
+
"name": "q",
|
|
6702
|
+
"in": "query",
|
|
6703
|
+
"required": false,
|
|
6704
|
+
"type": "string",
|
|
6705
|
+
"description": "Folded substring over every answer"
|
|
6706
|
+
},
|
|
6707
|
+
{
|
|
6708
|
+
"name": "limit",
|
|
6709
|
+
"in": "query",
|
|
6710
|
+
"required": false,
|
|
6711
|
+
"type": "integer",
|
|
6712
|
+
"description": "Page size (max 200)"
|
|
6713
|
+
}
|
|
6714
|
+
],
|
|
6715
|
+
"bodyDescribed": false,
|
|
6716
|
+
"bodyRef": null,
|
|
6717
|
+
"credential": "siteScoped"
|
|
6718
|
+
},
|
|
6656
6719
|
{
|
|
6657
6720
|
"id": "get:/api/sites/{siteId}/forms/{id}",
|
|
6658
6721
|
"method": "GET",
|
|
@@ -12500,6 +12563,19 @@ export const API_OPERATIONS = [
|
|
|
12500
12563
|
"bodyRef": null,
|
|
12501
12564
|
"credential": "siteScoped"
|
|
12502
12565
|
},
|
|
12566
|
+
{
|
|
12567
|
+
"id": "post:/api/sites/{siteId}/translations/review/bulk",
|
|
12568
|
+
"method": "POST",
|
|
12569
|
+
"path": "/api/sites/{siteId}/translations/review/bulk",
|
|
12570
|
+
"tags": [
|
|
12571
|
+
"translations"
|
|
12572
|
+
],
|
|
12573
|
+
"summary": "Approve many machine-written values at once",
|
|
12574
|
+
"params": [],
|
|
12575
|
+
"bodyDescribed": false,
|
|
12576
|
+
"bodyRef": null,
|
|
12577
|
+
"credential": "siteScoped"
|
|
12578
|
+
},
|
|
12503
12579
|
{
|
|
12504
12580
|
"id": "delete:/api/sites/{siteId}/trash",
|
|
12505
12581
|
"method": "DELETE",
|
|
@@ -18195,8 +18195,7 @@ export const ELEMENTS = {
|
|
|
18195
18195
|
"overflow": "hidden"
|
|
18196
18196
|
},
|
|
18197
18197
|
"config": {
|
|
18198
|
-
"defaultNum": 1
|
|
18199
|
-
"activeButton": ""
|
|
18198
|
+
"defaultNum": 1
|
|
18200
18199
|
},
|
|
18201
18200
|
"specials": {
|
|
18202
18201
|
"button": true
|
|
@@ -19100,7 +19099,6 @@ export const ELEMENTS = {
|
|
|
19100
19099
|
"quantity_default",
|
|
19101
19100
|
"toggle_button",
|
|
19102
19101
|
"defaultNum",
|
|
19103
|
-
"activeButton",
|
|
19104
19102
|
"quantityButtonId",
|
|
19105
19103
|
"quantityInputId"
|
|
19106
19104
|
]
|
|
@@ -19179,7 +19177,6 @@ export const ELEMENTS = {
|
|
|
19179
19177
|
"quantity_default",
|
|
19180
19178
|
"toggle_button",
|
|
19181
19179
|
"defaultNum",
|
|
19182
|
-
"activeButton",
|
|
19183
19180
|
"quantityButtonId",
|
|
19184
19181
|
"quantityInputId",
|
|
19185
19182
|
"width_select",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export const SHAPE_SOURCE = {
|
|
2
|
-
"writeOperations":
|
|
3
|
-
"shaped":
|
|
4
|
-
"fromHandlers":
|
|
2
|
+
"writeOperations": 218,
|
|
3
|
+
"shaped": 168,
|
|
4
|
+
"fromHandlers": 168,
|
|
5
5
|
"fromSwaggerOnly": 0,
|
|
6
6
|
"withReadOnly": 26,
|
|
7
|
-
"structsRead":
|
|
7
|
+
"structsRead": 1649
|
|
8
8
|
};
|
|
9
9
|
export const REQUEST_SHAPES = {
|
|
10
10
|
"post:/api/sites/{siteId}/api-keys": {
|
|
@@ -3669,6 +3669,24 @@ export const REQUEST_SHAPES = {
|
|
|
3669
3669
|
"source": "go",
|
|
3670
3670
|
"goType": "(inline)"
|
|
3671
3671
|
},
|
|
3672
|
+
"post:/api/media/{siteId}/from-url": {
|
|
3673
|
+
"fields": [
|
|
3674
|
+
{
|
|
3675
|
+
"name": "url",
|
|
3676
|
+
"type": "string"
|
|
3677
|
+
},
|
|
3678
|
+
{
|
|
3679
|
+
"name": "folderId",
|
|
3680
|
+
"type": "string"
|
|
3681
|
+
},
|
|
3682
|
+
{
|
|
3683
|
+
"name": "name",
|
|
3684
|
+
"type": "string"
|
|
3685
|
+
}
|
|
3686
|
+
],
|
|
3687
|
+
"source": "go",
|
|
3688
|
+
"goType": "fromURLRequest"
|
|
3689
|
+
},
|
|
3672
3690
|
"put:/api/sites/{siteId}/media/assets/{assetId}": {
|
|
3673
3691
|
"fields": [
|
|
3674
3692
|
{
|
|
@@ -6785,6 +6803,33 @@ export const REQUEST_SHAPES = {
|
|
|
6785
6803
|
"source": "go",
|
|
6786
6804
|
"goType": "(inline)"
|
|
6787
6805
|
},
|
|
6806
|
+
"post:/api/sites/{siteId}/translations/review/bulk": {
|
|
6807
|
+
"fields": [
|
|
6808
|
+
{
|
|
6809
|
+
"name": "locale",
|
|
6810
|
+
"type": "string"
|
|
6811
|
+
},
|
|
6812
|
+
{
|
|
6813
|
+
"name": "Entries",
|
|
6814
|
+
"type": "object"
|
|
6815
|
+
},
|
|
6816
|
+
{
|
|
6817
|
+
"name": "all",
|
|
6818
|
+
"type": "boolean",
|
|
6819
|
+
"note": "All asks for the whole match set instead of a list."
|
|
6820
|
+
},
|
|
6821
|
+
{
|
|
6822
|
+
"name": "search",
|
|
6823
|
+
"type": "string"
|
|
6824
|
+
},
|
|
6825
|
+
{
|
|
6826
|
+
"name": "entity",
|
|
6827
|
+
"type": "string"
|
|
6828
|
+
}
|
|
6829
|
+
],
|
|
6830
|
+
"source": "go",
|
|
6831
|
+
"goType": "(inline)"
|
|
6832
|
+
},
|
|
6788
6833
|
"post:/api/sites/{siteId}/translations/auto": {
|
|
6789
6834
|
"fields": [
|
|
6790
6835
|
{
|
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
import { toSpecs } from './importmap.js';
|
|
2
|
+
/**
|
|
3
|
+
* A REAL IMAGE, from the site's own library, or nothing.
|
|
4
|
+
*
|
|
5
|
+
* The instinct a pattern library invites is a placeholder — a grey box, a stock
|
|
6
|
+
* photo keyed off a word — and both are worse than an empty slot. This repo
|
|
7
|
+
* already records why stock is not a source: `loremflickr` answered
|
|
8
|
+
* "kids,clothing" with a cat statue and a photo of an adult, and ten photos from
|
|
9
|
+
* ten sources read as a scrape. A grey box reads as unfinished, which it is.
|
|
10
|
+
*
|
|
11
|
+
* The library is the honest source: those are the merchant's own images, already
|
|
12
|
+
* uploaded, already the right subject. Measured on a live store: 164 assets, 50
|
|
13
|
+
* of them images. A `want` of "landscape" is a shape request, not a subject one —
|
|
14
|
+
* a hero panel filled with a portrait crop is the aspect-ratio mistake rule 6 is
|
|
15
|
+
* about — and it degrades to any unused image rather than to none.
|
|
16
|
+
*/
|
|
17
|
+
function pick(pool, used, want) {
|
|
18
|
+
if (!pool?.length)
|
|
19
|
+
return null;
|
|
20
|
+
const free = pool.filter((m) => m.url && !used.has(m.url));
|
|
21
|
+
const wide = free.filter((m) => (m.width ?? 0) > (m.height ?? 0));
|
|
22
|
+
const chosen = (want === 'landscape' && wide[0]) || free[0] || null;
|
|
23
|
+
if (chosen)
|
|
24
|
+
used.add(chosen.url);
|
|
25
|
+
return chosen;
|
|
26
|
+
}
|
|
2
27
|
/**
|
|
3
28
|
* THE LOOK A PAGE HAS BEFORE IT HAS ANYTHING, and it is not nothing.
|
|
4
29
|
*
|
|
@@ -47,15 +72,17 @@ const row = (children, wrap = false) => ({
|
|
|
47
72
|
const h = (text, level = 2) => ({ kind: 'heading', level, text });
|
|
48
73
|
const p = (text) => ({ kind: 'text', text });
|
|
49
74
|
const cta = (text, href = '#') => ({ kind: 'button', variant: 'cta', text, href });
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The picture slot: a real image when the site has one, and WORDS when it does
|
|
77
|
+
* not.
|
|
78
|
+
*
|
|
79
|
+
* An image with no src is dropped by the mapper, which is right — an empty frame
|
|
80
|
+
* is not content — so the fallback is a sentence naming the call that fills it,
|
|
81
|
+
* not a grey box. A grey box reads as unfinished because it is.
|
|
82
|
+
*/
|
|
83
|
+
const img = (m, alt, hint) => m
|
|
84
|
+
? { kind: 'image', src: m.url, alt: m.name ?? alt }
|
|
85
|
+
: { kind: 'text', text: hint };
|
|
59
86
|
/**
|
|
60
87
|
* The set. Deliberately small and deliberately ordinary: these are the bands
|
|
61
88
|
* every commercial page is built from, and a library that tried to be clever
|
|
@@ -66,12 +93,21 @@ export const LAYOUT_PATTERNS = [
|
|
|
66
93
|
id: 'sb_hero_split',
|
|
67
94
|
name: 'Hero — hai cột',
|
|
68
95
|
use: 'Mở đầu trang: tiêu đề, một câu, nút hành động bên trái; chỗ cho ảnh bên phải',
|
|
69
|
-
build: (t) =>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
96
|
+
build: (t, pool) => {
|
|
97
|
+
const used = new Set();
|
|
98
|
+
return section([
|
|
99
|
+
row([
|
|
100
|
+
{ kind: 'group', direction: 'column', children: [h('Tiêu đề chính', 1), p('Một câu nói rõ bạn bán gì và cho ai.'), cta('Mua ngay')] },
|
|
101
|
+
{
|
|
102
|
+
kind: 'group',
|
|
103
|
+
direction: 'column',
|
|
104
|
+
children: [
|
|
105
|
+
img(pick(pool, used, 'landscape'), 'Ảnh mở đầu', 'Chưa có ảnh nào trong thư viện — sb_media_upload một URL, rồi sb_set src lên khối này.'),
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
]),
|
|
109
|
+
], t);
|
|
110
|
+
},
|
|
75
111
|
},
|
|
76
112
|
{
|
|
77
113
|
id: 'sb_hero_centered',
|
|
@@ -110,6 +146,31 @@ export const LAYOUT_PATTERNS = [
|
|
|
110
146
|
use: 'Một câu và một nút, đặt cuối trang hoặc giữa hai band nội dung',
|
|
111
147
|
build: (t) => section([h('Sẵn sàng bắt đầu?'), p('Một câu nhắc lại lời hứa chính.'), cta('Mua ngay')], t, { alignItems: 'center', textAlign: 'center' }, { alignItems: 'center' }),
|
|
112
148
|
},
|
|
149
|
+
{
|
|
150
|
+
id: 'sb_gallery',
|
|
151
|
+
name: 'Dải ảnh',
|
|
152
|
+
use: 'Một hàng ảnh cuốn dòng, lấy từ thư viện ảnh của chính site',
|
|
153
|
+
build: (t, pool) => {
|
|
154
|
+
const used = new Set();
|
|
155
|
+
const shots = [];
|
|
156
|
+
// SIX IS A WALL, THREE IS A ROW. Bounded because a gallery is a design
|
|
157
|
+
// decision and not a dump of the library — a merchant with 164 assets does
|
|
158
|
+
// not want 164 of them in one band.
|
|
159
|
+
for (let i = 0; i < 6; i += 1) {
|
|
160
|
+
const m = pick(pool, used, 'any');
|
|
161
|
+
if (!m)
|
|
162
|
+
break;
|
|
163
|
+
shots.push({ kind: 'image', src: m.url, alt: m.name ?? 'Ảnh' });
|
|
164
|
+
}
|
|
165
|
+
if (shots.length === 0) {
|
|
166
|
+
return section([
|
|
167
|
+
h('Thư viện ảnh'),
|
|
168
|
+
p('Chưa có ảnh nào trong thư viện — sb_media_upload một URL rồi dựng lại dải này.'),
|
|
169
|
+
], t);
|
|
170
|
+
}
|
|
171
|
+
return section([h('Thư viện ảnh'), row(shots, true)], t);
|
|
172
|
+
},
|
|
173
|
+
},
|
|
113
174
|
{
|
|
114
175
|
id: 'sb_faq',
|
|
115
176
|
name: 'Câu hỏi thường gặp',
|
package/dist/tools/page.js
CHANGED
|
@@ -20,7 +20,7 @@ import { readinessGaps, READINESS_NOTICE } from '../domains/site/readiness.js';
|
|
|
20
20
|
import { gatherReadiness } from '../domains/site/readiness-fetch.js';
|
|
21
21
|
import { globalWarning, restampPatches, RESPONSIVE_NOTICE } from '../domains/site/traps.js';
|
|
22
22
|
import { catalogMatches, traitsFor } from '../catalog/element-search.js';
|
|
23
|
-
import { LAYOUT_PATTERNS, PATTERN_BY_ID, THEME_TOKENS } from '../domains/site/patterns.js';
|
|
23
|
+
import { LAYOUT_PATTERNS, PATTERN_BY_ID, THEME_TOKENS, } from '../domains/site/patterns.js';
|
|
24
24
|
import { tokensFromPage } from '../domains/site/importmap.js';
|
|
25
25
|
import { middleEnd } from '../domains/site/traps.js';
|
|
26
26
|
import { applyPatches } from '../core/patch.js';
|
|
@@ -224,6 +224,49 @@ const specSchema = z.lazy(() => z.object({
|
|
|
224
224
|
specials: z.record(z.unknown()).optional(),
|
|
225
225
|
children: z.array(specSchema).optional(),
|
|
226
226
|
}));
|
|
227
|
+
/**
|
|
228
|
+
* THE SITE'S OWN HEADER AND FOOTER, read off the page that already answers it.
|
|
229
|
+
*
|
|
230
|
+
* A site can hold several globals of each kind — this one holds four headers and
|
|
231
|
+
* two footers, most of them experiments — so "the first header" is a guess and
|
|
232
|
+
* a name is a label nobody promised to keep. The HOME PAGE is the site's own
|
|
233
|
+
* answer: whatever chrome it carries is the chrome this site wears.
|
|
234
|
+
*
|
|
235
|
+
* Silent on anything it cannot read. A page created without its chrome is a page
|
|
236
|
+
* a person can fix; a page created with the WRONG chrome is one nobody notices.
|
|
237
|
+
*/
|
|
238
|
+
async function siteChrome(ctx, siteId) {
|
|
239
|
+
try {
|
|
240
|
+
const listed = (await request({
|
|
241
|
+
base: ctx.base,
|
|
242
|
+
method: 'GET',
|
|
243
|
+
path: `/api/sites/${encodeURIComponent(siteId)}/pages`,
|
|
244
|
+
token: siteToken(ctx),
|
|
245
|
+
fetchImpl: ctx.fetchImpl,
|
|
246
|
+
}));
|
|
247
|
+
const home = (listed.pages ?? []).find((p) => p.isHomepage === true);
|
|
248
|
+
if (!home || typeof home.id !== 'string')
|
|
249
|
+
return {};
|
|
250
|
+
const src = await loadSource(ctx, siteId, home.id);
|
|
251
|
+
const doc = PageDoc.from(src.document);
|
|
252
|
+
const out = {};
|
|
253
|
+
for (const id of doc.node(doc.doc.root_node_id).data.nodes) {
|
|
254
|
+
const sp = doc.doc.nodes[id]?.specials;
|
|
255
|
+
const gid = sp?.globalId;
|
|
256
|
+
const kind = sp?.globalKind;
|
|
257
|
+
if (typeof gid !== 'string')
|
|
258
|
+
continue;
|
|
259
|
+
if (kind === 'header' && !out.header)
|
|
260
|
+
out.header = gid;
|
|
261
|
+
if (kind === 'footer' && !out.footer)
|
|
262
|
+
out.footer = gid;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return {};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
227
270
|
export function registerPageTools(server, ctx) {
|
|
228
271
|
const session = new PageSession(ctx);
|
|
229
272
|
server.registerTool('sb_page_open', {
|
|
@@ -703,7 +746,42 @@ export function registerPageTools(server, ctx) {
|
|
|
703
746
|
// goes on following the theme instead of freezing today's hex into it.
|
|
704
747
|
const fromTheme = Object.keys(read).length === 0;
|
|
705
748
|
const tokens = fromTheme ? THEME_TOKENS : read;
|
|
706
|
-
|
|
749
|
+
// REAL IMAGES, from the site's own library. The instinct a pattern
|
|
750
|
+
// library invites is a placeholder — a grey box or a stock photo keyed
|
|
751
|
+
// off a word — and this repo already records why the second is not a
|
|
752
|
+
// source (`loremflickr` answered "kids,clothing" with a cat statue). The
|
|
753
|
+
// merchant's own uploads are the honest answer, and a failure to read
|
|
754
|
+
// them costs the picture, never the band.
|
|
755
|
+
let pool = [];
|
|
756
|
+
try {
|
|
757
|
+
const listed = (await request({
|
|
758
|
+
base: ctx.base,
|
|
759
|
+
method: 'GET',
|
|
760
|
+
path: `/api/sites/${encodeURIComponent(site_id)}/media?limit=50`,
|
|
761
|
+
token: siteToken(ctx),
|
|
762
|
+
fetchImpl: ctx.fetchImpl,
|
|
763
|
+
}));
|
|
764
|
+
const rows = listed.media ?? listed.assets ?? [];
|
|
765
|
+
pool = rows
|
|
766
|
+
.filter((m) => m.mediaType === 'image' && typeof m.url === 'string' && m.state !== 'deleted')
|
|
767
|
+
.map((m) => ({
|
|
768
|
+
url: String(m.url),
|
|
769
|
+
name: typeof m.name === 'string' ? m.name : undefined,
|
|
770
|
+
width: typeof m.width === 'number' ? m.width : undefined,
|
|
771
|
+
height: typeof m.height === 'number' ? m.height : undefined,
|
|
772
|
+
}));
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
// A library that cannot be read leaves the picture slot saying so.
|
|
776
|
+
}
|
|
777
|
+
// WHAT THE PAGE IS ALREADY SHOWING comes out of the pool. Each call
|
|
778
|
+
// starts its own selection, so a hero added first and a gallery added
|
|
779
|
+
// second both reached for the same best landscape — and the page then
|
|
780
|
+
// showed one photo twice, which reads as a mistake because it is one.
|
|
781
|
+
const onPage = new Set(Object.values(doc.doc.nodes)
|
|
782
|
+
.map((n) => n.specials?.src)
|
|
783
|
+
.filter((v) => typeof v === 'string' && v.length > 0));
|
|
784
|
+
const spec = pattern.build(tokens, pool.filter((m) => !onPage.has(m.url)));
|
|
707
785
|
if (!spec) {
|
|
708
786
|
throw new Error(`sbuilder: the built-in "${template_id}" produced nothing to add.`);
|
|
709
787
|
}
|
|
@@ -715,6 +793,7 @@ export function registerPageTools(server, ctx) {
|
|
|
715
793
|
nodes: ids.length,
|
|
716
794
|
into: page_id,
|
|
717
795
|
tokens_from: fromTheme ? "this site's theme — the page has no look of its own yet" : 'this page',
|
|
796
|
+
images_available: pool.length,
|
|
718
797
|
note: 'Composed against THIS page\'s tokens, not copied — the same heading ink, button ' +
|
|
719
798
|
'fill and section padding the page already uses. Pass dry_run:false to add it.',
|
|
720
799
|
});
|
|
@@ -726,6 +805,13 @@ export function registerPageTools(server, ctx) {
|
|
|
726
805
|
nodes: ids.length,
|
|
727
806
|
into: page_id,
|
|
728
807
|
rev: doc.rev,
|
|
808
|
+
...(pool.length === 0
|
|
809
|
+
? {
|
|
810
|
+
images: 'This site has no images in its library, so any picture slot in this band says ' +
|
|
811
|
+
'so in words rather than showing a grey box. sb_media_upload takes a URL and ' +
|
|
812
|
+
'the platform fetches it server-side.',
|
|
813
|
+
}
|
|
814
|
+
: {}),
|
|
729
815
|
...(fromTheme
|
|
730
816
|
? {
|
|
731
817
|
tokens_from: "this site's theme — the page had no heading, button or section to read a look " +
|
|
@@ -778,12 +864,13 @@ export function registerPageTools(server, ctx) {
|
|
|
778
864
|
is_homepage: z.boolean().optional(),
|
|
779
865
|
settings: z.record(z.unknown()).optional(),
|
|
780
866
|
seed: z.boolean().optional().describe('Default true; false creates a blank page.'),
|
|
867
|
+
chrome: z.boolean().optional().describe("Carry the site's header and footer, default true"),
|
|
781
868
|
locale: z.string().optional().describe("vi (default) or en — the complete page's wording."),
|
|
782
869
|
headline: z.string().optional().describe("The complete page's thank-you line."),
|
|
783
870
|
dry_run: z.boolean().optional(),
|
|
784
871
|
},
|
|
785
872
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
786
|
-
}, async ({ site_id: given, name, type, slug, is_homepage, settings, seed, locale, headline, dry_run }) => {
|
|
873
|
+
}, async ({ site_id: given, name, type, slug, is_homepage, settings, seed, chrome, locale, headline, dry_run }) => {
|
|
787
874
|
const site_id = siteFor(ctx, given);
|
|
788
875
|
const path = `/api/sites/${encodeURIComponent(site_id)}/pages`;
|
|
789
876
|
// TYPE IS THE ROUTE for several kinds of page: /checkout and
|
|
@@ -808,12 +895,25 @@ export function registerPageTools(server, ctx) {
|
|
|
808
895
|
// exists.
|
|
809
896
|
const willSeed = seed !== false && hasSeed(type);
|
|
810
897
|
const summary = willSeed && type ? seedSummary(type) : null;
|
|
898
|
+
// WHAT MAKES THE NEW PAGE PART OF THE SITE.
|
|
899
|
+
//
|
|
900
|
+
// A page created through the editor carries the site's header and footer;
|
|
901
|
+
// one created here carried NEITHER, so an agent building a site produced
|
|
902
|
+
// pages with no navigation and no footer on a site that has both — and
|
|
903
|
+
// nothing reported it, because `sb_review` reads the page and the page is
|
|
904
|
+
// fine, while `siteChrome` asks whether the SITE has globals and it does.
|
|
905
|
+
// Measured: three pages built with these tools, every one of them bare,
|
|
906
|
+
// beside a store page carrying its header as ROOT's first child.
|
|
907
|
+
const wear = chrome !== false ? await siteChrome(ctx, site_id) : {};
|
|
811
908
|
if (dry_run !== false) {
|
|
812
909
|
return text({
|
|
813
910
|
dry_run: true,
|
|
814
911
|
would_post: path,
|
|
815
912
|
body: redact(body),
|
|
816
913
|
...(summary ? { would_seed: { type, ...summary } } : {}),
|
|
914
|
+
...(wear.header || wear.footer
|
|
915
|
+
? { would_wear: { ...(wear.header ? { header: wear.header } : {}), ...(wear.footer ? { footer: wear.footer } : {}) } }
|
|
916
|
+
: {}),
|
|
817
917
|
});
|
|
818
918
|
}
|
|
819
919
|
const res = redact(await request({
|
|
@@ -864,9 +964,48 @@ export function registerPageTools(server, ctx) {
|
|
|
864
964
|
}
|
|
865
965
|
}
|
|
866
966
|
}
|
|
967
|
+
// THE REFERENCE SHAPE IS THE PLATFORM'S OWN (decompose.go:382): a
|
|
968
|
+
// flex-section carrying `globalRef` + `globalKind`. Header FIRST and
|
|
969
|
+
// footer LAST, because compose turns them into real bands and ROOT's
|
|
970
|
+
// children must read header, middle, footer or every save is refused.
|
|
971
|
+
let wearing;
|
|
972
|
+
if ((wear.header || wear.footer) && typeof newId === 'string' && newId) {
|
|
973
|
+
try {
|
|
974
|
+
await session.open(site_id, newId);
|
|
975
|
+
const doc = session.current();
|
|
976
|
+
const patches = [];
|
|
977
|
+
const ids = [];
|
|
978
|
+
if (wear.header) {
|
|
979
|
+
const made = addSubtree(doc, doc.doc.root_node_id, {
|
|
980
|
+
type: 'flex-section',
|
|
981
|
+
specials: { globalRef: wear.header, globalKind: 'header' },
|
|
982
|
+
}, 0);
|
|
983
|
+
doc.apply(made.patches);
|
|
984
|
+
ids.push('header');
|
|
985
|
+
}
|
|
986
|
+
if (wear.footer) {
|
|
987
|
+
const made = addSubtree(doc, doc.doc.root_node_id, {
|
|
988
|
+
type: 'flex-section',
|
|
989
|
+
specials: { globalRef: wear.footer, globalKind: 'footer' },
|
|
990
|
+
});
|
|
991
|
+
doc.apply(made.patches);
|
|
992
|
+
ids.push('footer');
|
|
993
|
+
}
|
|
994
|
+
void patches;
|
|
995
|
+
await session.save();
|
|
996
|
+
wearing = { carries: ids, open: newId };
|
|
997
|
+
}
|
|
998
|
+
catch (e) {
|
|
999
|
+
wearing = {
|
|
1000
|
+
failed: e.message.replace(/^sbuilder:\s*/, '').slice(0, 160),
|
|
1001
|
+
note: 'The page exists. Attach the chrome by hand, or create it again.',
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
867
1005
|
return text({
|
|
868
1006
|
...res,
|
|
869
1007
|
...(seeded ? { seeded } : {}),
|
|
1008
|
+
...(wearing ? { chrome: wearing } : {}),
|
|
870
1009
|
...(renamed
|
|
871
1010
|
? {
|
|
872
1011
|
slug_renamed: `The slug "${slug}" was already taken, so the platform stored ` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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",
|