sbuilder-mcp 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/CHANGELOG.vi.md +10 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/dist/catalog/elements.generated.js +39 -0
- package/dist/domains/site/document.js +36 -1
- package/dist/domains/site/findings.js +10 -1
- package/dist/domains/site/review.js +74 -5
- package/dist/tools/page.js +14 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,16 @@ All notable changes to this project are documented in this file.
|
|
|
6
6
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
7
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-09-07
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_review reports static_in_dataset when an element inside a repeater (such as a plain image in a product card) can only render one authored value across every record, and names the record-capable element to swap in instead.
|
|
13
|
+
- sb_review reports unbound_dataset_element when a dataset element capable of showing a record carries no binding at all, instead of silently rendering the same authored content on every row.
|
|
14
|
+
- sb_page_open reports blank_page_repair when a page's stored document names its root under rootId instead of root_node_id, the alias that renders as an empty page.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Opening a page whose document names its root under rootId no longer fails as a damaged document; the document adopts the alias so the next save writes the canonical key and the page stops rendering blank.
|
|
18
|
+
|
|
9
19
|
## [0.1.5] - 2026-09-07
|
|
10
20
|
|
|
11
21
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,16 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
|
|
|
6
6
|
Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
7
|
và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-09-07
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_review giờ báo cáo static_in_dataset khi một element bên trong repeater (như một image thường trong thẻ sản phẩm) chỉ có thể render một giá trị đã gán cho mọi record, đồng thời chỉ ra element có thể hiển thị record để thay thế.
|
|
13
|
+
- sb_review giờ báo cáo unbound_dataset_element khi một dataset element có khả năng hiển thị record nhưng không mang binding nào, thay vì âm thầm render cùng một nội dung đã gán cho mọi dòng.
|
|
14
|
+
- sb_page_open giờ báo cáo blank_page_repair khi document lưu trữ của trang đặt tên root là rootId thay vì root_node_id — alias khiến trang render rỗng.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Mở một trang có document đặt tên root là rootId không còn bị từ chối như document hỏng; document giờ tự nhận alias này để lần lưu kế tiếp ghi đúng khóa chuẩn và trang không còn render rỗng.
|
|
18
|
+
|
|
9
19
|
## [0.1.5] - 2026-09-07
|
|
10
20
|
|
|
11
21
|
### Added
|
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ make, because those mean "this person's account".
|
|
|
87
87
|
| `sb_look` | Save, render, and return screenshots plus measured node boxes and layout defects measured on the render |
|
|
88
88
|
| `sb_bind` | Bind a node's content to real store data |
|
|
89
89
|
|
|
90
|
-
Twenty-five tools, **412 API operations**, 106 elements,
|
|
90
|
+
Twenty-five tools, **412 API operations**, 106 elements, 77 binding sources. `sb_api_find`
|
|
91
91
|
is an index rather than a tool per endpoint, so the tool list stays short while everything
|
|
92
92
|
the platform can do stays reachable — and operations added to the platform arrive with the
|
|
93
93
|
next `npm run codegen`.
|
package/README.vi.md
CHANGED
|
@@ -84,7 +84,7 @@ là "tài khoản của người này".
|
|
|
84
84
|
| `sb_look` | Lưu, render, trả về ảnh chụp kèm box đo được của node và lỗi bố cục đo trên bản render |
|
|
85
85
|
| `sb_bind` | Gắn nội dung một node vào dữ liệu cửa hàng thật |
|
|
86
86
|
|
|
87
|
-
Hai mươi lăm tool, **412 operation API**, 106 element,
|
|
87
|
+
Hai mươi lăm tool, **412 operation API**, 106 element, 77 nguồn binding. `sb_api_find` là
|
|
88
88
|
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
|
|
89
89
|
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
|
|
90
90
|
`npm run codegen` kế tiếp.
|
|
@@ -31590,6 +31590,45 @@ export const BINDING_SOURCES = [
|
|
|
31590
31590
|
"site.tiktok",
|
|
31591
31591
|
"site.youtube"
|
|
31592
31592
|
];
|
|
31593
|
+
export const BOUND_SPECIALS = {
|
|
31594
|
+
"collection-media": [
|
|
31595
|
+
"boundImage"
|
|
31596
|
+
],
|
|
31597
|
+
"dataset-block": [
|
|
31598
|
+
"boundHref",
|
|
31599
|
+
"boundHrefLabel"
|
|
31600
|
+
],
|
|
31601
|
+
"media-dataset": [
|
|
31602
|
+
"boundImage",
|
|
31603
|
+
"boundImages"
|
|
31604
|
+
],
|
|
31605
|
+
"pricing-dataset": [
|
|
31606
|
+
"boundCompare",
|
|
31607
|
+
"boundCompareCents",
|
|
31608
|
+
"boundMoneyOverride",
|
|
31609
|
+
"boundPrice",
|
|
31610
|
+
"boundPriceCents"
|
|
31611
|
+
],
|
|
31612
|
+
"product-image-feature": [
|
|
31613
|
+
"boundImage",
|
|
31614
|
+
"boundImages",
|
|
31615
|
+
"boundProductURL"
|
|
31616
|
+
],
|
|
31617
|
+
"product-image-list": [
|
|
31618
|
+
"boundImages"
|
|
31619
|
+
],
|
|
31620
|
+
"product-variants": [
|
|
31621
|
+
"boundAttributes",
|
|
31622
|
+
"boundVariations"
|
|
31623
|
+
],
|
|
31624
|
+
"qr-code": [
|
|
31625
|
+
"boundValue"
|
|
31626
|
+
],
|
|
31627
|
+
"text-dataset": [
|
|
31628
|
+
"boundHtml",
|
|
31629
|
+
"boundText"
|
|
31630
|
+
]
|
|
31631
|
+
};
|
|
31593
31632
|
export const TRAIT_WRITES = {
|
|
31594
31633
|
"font_size": {
|
|
31595
31634
|
"key": "font_size",
|
|
@@ -51,13 +51,48 @@ export class PageDoc {
|
|
|
51
51
|
if (!d.schema_version)
|
|
52
52
|
d.schema_version = 2;
|
|
53
53
|
}
|
|
54
|
+
// A DOCUMENT THAT NAMES ITS ROOT UNDER THE WRONG KEY.
|
|
55
|
+
//
|
|
56
|
+
// `rootId` is the key an APP BLOCK and a section template use for the same
|
|
57
|
+
// idea (server/internal/apps/blocks.go), so a document assembled from that
|
|
58
|
+
// shape and PUT to a page carries it. The page renderer reads only
|
|
59
|
+
// `root_node_id`: it finds no root, walks nothing, and publishes a page that
|
|
60
|
+
// answers 200 with an EMPTY BODY. Found in the wild on a live storefront's
|
|
61
|
+
// order-complete page, where the shopper who had just paid saw a blank
|
|
62
|
+
// screen.
|
|
63
|
+
//
|
|
64
|
+
// Adopted rather than refused, because refusing left the one tool that can
|
|
65
|
+
// SEE the problem unable to open the page, and the repair is a single save:
|
|
66
|
+
// the canonical key is written and the alias dropped from what this holds.
|
|
67
|
+
let adopted;
|
|
68
|
+
if (!d.root_node_id || !d.nodes[d.root_node_id]) {
|
|
69
|
+
const bag = d;
|
|
70
|
+
for (const key of ['rootId', 'rootNodeId']) {
|
|
71
|
+
const value = bag[key];
|
|
72
|
+
if (typeof value === 'string' && d.nodes[value]) {
|
|
73
|
+
d.root_node_id = value;
|
|
74
|
+
delete bag[key];
|
|
75
|
+
adopted = key;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
54
80
|
if (!d.root_node_id || !d.nodes[d.root_node_id]) {
|
|
55
81
|
throw new Error(`sbuilder: document is damaged — root_node_id=${JSON.stringify(d.root_node_id)} names no ` +
|
|
56
82
|
`node, but ${Object.keys(d.nodes).length} nodes are present. Open the page in the ` +
|
|
57
83
|
'editor once; its hydrate path repairs this.');
|
|
58
84
|
}
|
|
59
|
-
|
|
85
|
+
const out = new PageDoc(d);
|
|
86
|
+
out.adoptedRootKey = adopted;
|
|
87
|
+
return out;
|
|
60
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* The wrong key this document named its root under, when it did.
|
|
91
|
+
*
|
|
92
|
+
* Set means the page currently PUBLISHES BLANK and the next save fixes it —
|
|
93
|
+
* a fact no other surface reports, so the tool layer says it out loud.
|
|
94
|
+
*/
|
|
95
|
+
adoptedRootKey;
|
|
61
96
|
get rev() {
|
|
62
97
|
return this.revision;
|
|
63
98
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { BINDING_SOURCES } from '../../catalog/elements.generated.js';
|
|
1
|
+
import { BINDING_SOURCES, BOUND_SPECIALS } from '../../catalog/elements.generated.js';
|
|
2
|
+
/** The elements whose renderer reads a bound special — the ones that can show a record. */
|
|
3
|
+
const RECORD_ELEMENTS = Object.keys(BOUND_SPECIALS).sort().join(', ');
|
|
2
4
|
/**
|
|
3
5
|
* One fix per KIND of finding, as a template.
|
|
4
6
|
*
|
|
@@ -16,6 +18,13 @@ export const FIX = {
|
|
|
16
18
|
empty_text: 'Set it: sb_set id "<id>", namespace specials, keys { "<key>": … }.',
|
|
17
19
|
missing_media: 'Set it: sb_set id "<id>", namespace specials, keys { "<key>": … }.',
|
|
18
20
|
placeholder_content: 'Write the real copy: sb_set id "<id>", namespace specials, keys { "<key>": … }.',
|
|
21
|
+
// The fix is a DIFFERENT ELEMENT, not a value: setting "<key>" here would put
|
|
22
|
+
// one authored value on every row of the repeater.
|
|
23
|
+
static_in_dataset: `Swap the element: sb_add one of ${RECORD_ELEMENTS} in the same parent, sb_bind it ` +
|
|
24
|
+
'(field "specials.bound…", source matching the record), sb_move it into place, then ' +
|
|
25
|
+
'sb_remove id "<id>". Setting "<key>" on this one would show the same value in every row.',
|
|
26
|
+
unbound_dataset_element: 'Bind it: sb_bind id "<id>", field "specials.<key>", and the source that names the record ' +
|
|
27
|
+
'field you want (sb_bind refuses an unknown source and lists every valid one).',
|
|
19
28
|
dead_binding_source: `Rebind with sb_bind using one of: ${BINDING_SOURCES.join(', ')}.`,
|
|
20
29
|
// "<key>" here is documentation, not a placeholder: this template is never
|
|
21
30
|
// filled with a key, so the reader sees the form a field must take.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { childrenOf, isOverlay, pageChildren, appBlockRoot } from '../../core/tree.js';
|
|
2
|
-
import { ELEMENTS, BINDING_SOURCES } from '../../catalog/elements.generated.js';
|
|
2
|
+
import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS } from '../../catalog/elements.generated.js';
|
|
3
3
|
import { fill } from './findings.js';
|
|
4
4
|
/**
|
|
5
5
|
* Shipped with every non-empty finding list.
|
|
@@ -21,6 +21,23 @@ function contentKeys(type) {
|
|
|
21
21
|
function seededValue(type, key) {
|
|
22
22
|
return (ELEMENTS[type]?.defaults.specials ?? {})[key];
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* A container that renders its subtree ONCE PER RECORD.
|
|
26
|
+
*
|
|
27
|
+
* `list-dataset` is the repeater and `dataset-block` its per-record template;
|
|
28
|
+
* both are read off the catalog rather than named here, so a new repeater the
|
|
29
|
+
* platform ships is covered the day the catalog is regenerated.
|
|
30
|
+
*/
|
|
31
|
+
function repeats(type) {
|
|
32
|
+
const meta = ELEMENTS[type];
|
|
33
|
+
return Boolean(meta?.isContainer && meta.category === 'dataset');
|
|
34
|
+
}
|
|
35
|
+
/** The element types that can actually show a record: their renderer reads a bound special. */
|
|
36
|
+
const BOUND_TYPES = Object.keys(BOUND_SPECIALS).sort();
|
|
37
|
+
/** Whether this element type's renderer reads anything a binding can write. */
|
|
38
|
+
function canShowARecord(type) {
|
|
39
|
+
return (BOUND_SPECIALS[type] ?? []).length > 0;
|
|
40
|
+
}
|
|
24
41
|
/**
|
|
25
42
|
* Everything wrong with this page that a person would notice.
|
|
26
43
|
*
|
|
@@ -48,15 +65,22 @@ export function reviewDesign(doc) {
|
|
|
48
65
|
// Document order, depth-first from ROOT: the order a reader meets them.
|
|
49
66
|
const seen = new Set();
|
|
50
67
|
const walkOrder = [];
|
|
51
|
-
|
|
68
|
+
// Which repeater each node sits inside, filled on the way down. A repeater
|
|
69
|
+
// itself belongs to the scope OUTSIDE it, which is why this is set before the
|
|
70
|
+
// scope for the children is computed.
|
|
71
|
+
const inRepeater = new Map();
|
|
72
|
+
const go = (id, repeater) => {
|
|
52
73
|
if (seen.has(id) || overlayIds.has(id))
|
|
53
74
|
return;
|
|
54
75
|
seen.add(id);
|
|
55
76
|
walkOrder.push(id);
|
|
77
|
+
if (repeater)
|
|
78
|
+
inRepeater.set(id, repeater);
|
|
56
79
|
if (appBlockRoot(d, id) === id)
|
|
57
80
|
return;
|
|
81
|
+
const inner = repeats(d.nodes[id]?.data.type ?? '') ? (repeater ?? id) : repeater;
|
|
58
82
|
for (const k of childrenOf(d, id))
|
|
59
|
-
go(k);
|
|
83
|
+
go(k, inner);
|
|
60
84
|
};
|
|
61
85
|
go(d.root_node_id);
|
|
62
86
|
for (const id of walkOrder) {
|
|
@@ -86,10 +110,41 @@ export function reviewDesign(doc) {
|
|
|
86
110
|
fix: fill('empty_container', { id }),
|
|
87
111
|
});
|
|
88
112
|
}
|
|
113
|
+
const bindings = n.bindings ?? [];
|
|
114
|
+
const repeater = inRepeater.get(id);
|
|
115
|
+
const boundFields = new Set(bindings.map((b) => b.field));
|
|
116
|
+
/**
|
|
117
|
+
* Whether a binding, not the author, supplies this key at render time.
|
|
118
|
+
*
|
|
119
|
+
* Either the binding names the key outright, or the element is one whose
|
|
120
|
+
* renderer PREFERS its bound special over the authored one — a bound
|
|
121
|
+
* `collection-media` with an empty `src` is finished, not unfinished, and
|
|
122
|
+
* reporting it would be the false positive that teaches a reader to ignore
|
|
123
|
+
* the list.
|
|
124
|
+
*/
|
|
125
|
+
const supplied = (key) => boundFields.has(`specials.${key}`) || (bindings.length > 0 && canShowARecord(type));
|
|
89
126
|
for (const key of contentKeys(type)) {
|
|
90
127
|
const value = (n.specials ?? {})[key];
|
|
91
128
|
const seed = seededValue(type, key);
|
|
92
129
|
const isBlank = value === undefined || value === null || String(value).trim() === '';
|
|
130
|
+
if (supplied(key))
|
|
131
|
+
continue;
|
|
132
|
+
// INSIDE A REPEATER the generic advice is actively wrong: setting a static
|
|
133
|
+
// src on an image in a product card puts the SAME picture on every card and
|
|
134
|
+
// the product's own photo can never appear. The defect is the element
|
|
135
|
+
// choice, not the missing value, so it gets its own finding and its own fix.
|
|
136
|
+
if (repeater && (isBlank || (seed !== undefined && value === seed))) {
|
|
137
|
+
out.push({
|
|
138
|
+
code: 'static_in_dataset',
|
|
139
|
+
nodeId: id,
|
|
140
|
+
type,
|
|
141
|
+
problem: `Inside the repeater "${repeater}", but "${type}" renders only what the document ` +
|
|
142
|
+
`authors — so every record shows the same ${key}, and the record's own never appears.`,
|
|
143
|
+
key,
|
|
144
|
+
fix: fill('static_in_dataset', { id, key }),
|
|
145
|
+
});
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
93
148
|
if (isBlank) {
|
|
94
149
|
// An element that seeds a blank (image.src is "") is not misconfigured —
|
|
95
150
|
// it is unfinished, and it renders as a gap or a broken frame.
|
|
@@ -120,11 +175,25 @@ export function reviewDesign(doc) {
|
|
|
120
175
|
});
|
|
121
176
|
}
|
|
122
177
|
}
|
|
178
|
+
// A dataset element with NO binding at all is the same silence from the other
|
|
179
|
+
// direction: its renderer is waiting for a bound special that nothing writes,
|
|
180
|
+
// so it falls back to whatever the document authored — once, for every record.
|
|
181
|
+
if (repeater && canShowARecord(type) && bindings.length === 0) {
|
|
182
|
+
const key = BOUND_SPECIALS[type][0];
|
|
183
|
+
out.push({
|
|
184
|
+
code: 'unbound_dataset_element',
|
|
185
|
+
nodeId: id,
|
|
186
|
+
type,
|
|
187
|
+
problem: `"${type}" reads specials.${key} from a binding and has none, so every row in ` +
|
|
188
|
+
`"${repeater}" renders the same authored content.`,
|
|
189
|
+
key,
|
|
190
|
+
fix: fill('unbound_dataset_element', { id, key }),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
123
193
|
// A binding whose source the renderer does not provide resolves to nothing,
|
|
124
194
|
// and the element falls back to its own placeholder — indistinguishable, on
|
|
125
195
|
// screen, from data that has not loaded.
|
|
126
|
-
for (const b of
|
|
127
|
-
.bindings ?? []) {
|
|
196
|
+
for (const b of bindings) {
|
|
128
197
|
if (b.source && !BINDING_SOURCES.includes(b.source)) {
|
|
129
198
|
out.push({
|
|
130
199
|
code: 'dead_binding_source',
|
package/dist/tools/page.js
CHANGED
|
@@ -142,7 +142,20 @@ export function registerPageTools(server, ctx) {
|
|
|
142
142
|
annotations: { readOnlyHint: true },
|
|
143
143
|
}, async ({ site_id, page_id }) => {
|
|
144
144
|
const outline = await session.open(site_id, page_id);
|
|
145
|
-
|
|
145
|
+
const doc = session.current();
|
|
146
|
+
// A page whose stored document named its root under the app-block key
|
|
147
|
+
// renders as an empty <body> and says nothing about why. Nobody else can
|
|
148
|
+
// see this, so it is reported on open rather than left for the screenshot.
|
|
149
|
+
const blank_page_repair = doc.adoptedRootKey
|
|
150
|
+
? `This page's document names its root as "${doc.adoptedRootKey}", not "root_node_id", so ` +
|
|
151
|
+
'the renderer finds no root and publishes an EMPTY BODY. The next save from here writes ' +
|
|
152
|
+
'the canonical key and fixes it; publish afterwards.'
|
|
153
|
+
: undefined;
|
|
154
|
+
return text({
|
|
155
|
+
outline,
|
|
156
|
+
...(blank_page_repair ? { blank_page_repair } : {}),
|
|
157
|
+
...reviewField(ctx, doc),
|
|
158
|
+
});
|
|
146
159
|
});
|
|
147
160
|
server.registerTool('sb_outline', {
|
|
148
161
|
description: 'The open page as a compressed tree — id, type, name, child count, band, and whether a ' +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|