sbuilder-mcp 0.13.0 → 0.14.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,17 @@ 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.14.0] - 2026-09-09
10
+
11
+ ### Added
12
+ - sb_review now reports `categoryScope` when a store has more than one product category and none of them is linked to a page of its own, since `/collections/{slug}` then falls back to the category type's default template for every category, and nothing on that shared template narrows the product feed to the category in the URL, so a shopper sees the whole catalogue (or another category's products) no matter which one they open; the finding names the fix — a page per category with its repeater set to `{ "collectionType": "collection", "collectionId": "<category id>" }`, linked with `sb_api_call post:/api/sites/{siteId}/page-links/bulk`.
13
+
14
+ ## [0.13.1] - 2026-09-09
15
+
16
+ ### Fixed
17
+ - sb_store's checkout build now seeds a `form:success` event that sends the shopper to `/checkout/complete`, since the form record's own `settings.afterSubmit.action = "redirect"` is stored by the API but carried nowhere by the platform, leaving a completed order on the checkout page with every cart total reading 0.
18
+ - sb_review now reports `order_goes_nowhere` for a checkout-shaped page (a form alongside a cart total) whose form has no navigating `form:success` event, naming the sb_event call that fixes it.
19
+
9
20
  ## [0.13.0] - 2026-09-09
10
21
 
11
22
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,17 @@ 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.14.0] - 2026-09-09
10
+
11
+ ### Added
12
+ - sb_review giờ báo cáo `categoryScope` khi một store có nhiều hơn một product category mà không category nào được link tới một page riêng, vì khi đó `/collections/{slug}` sẽ rơi về default template của loại category cho mọi category, và không có gì trên template dùng chung đó thu hẹp product feed theo category trong URL, nên khách hàng sẽ thấy toàn bộ catalogue (hoặc sản phẩm của category khác) dù mở category nào; finding nêu rõ cách khắc phục — mỗi category một page riêng với repeater đặt `{ "collectionType": "collection", "collectionId": "<id category>" }`, liên kết bằng `sb_api_call post:/api/sites/{siteId}/page-links/bulk`.
13
+
14
+ ## [0.13.1] - 2026-09-09
15
+
16
+ ### Fixed
17
+ - Việc dựng checkout của sb_store giờ tự gieo một event `form:success` đưa khách hàng tới `/checkout/complete`, vì `settings.afterSubmit.action = "redirect"` của chính form record được API lưu lại nhưng nền tảng không mang nó đi đâu cả, khiến một đơn hàng đã hoàn tất vẫn ở lại trang checkout với mọi tổng giỏ hàng hiện 0.
18
+ - sb_review giờ báo cáo `order_goes_nowhere` cho một trang có dáng checkout (có form và có cart total) mà form của nó không có event `form:success` nào điều hướng đi, nêu rõ lệnh sb_event để khắc phục.
19
+
9
20
  ## [0.13.0] - 2026-09-09
10
21
 
11
22
  ### Added
@@ -60,6 +60,11 @@ export const FIX = {
60
60
  '{ "overflowX": "visible", "overflowY": "visible" } — or move the pinned node outside it. ' +
61
61
  'Sticky resolves against its nearest SCROLLING ancestor, so a clipping one becomes that ' +
62
62
  'ancestor and the node pins inside a box that never scrolls.',
63
+ order_goes_nowhere: 'Send the shopper on: sb_event id "<id>", trigger "form:success", action "go_to_url", ' +
64
+ 'payload { "url": "/checkout/complete" }. That path resolves by page TYPE and serves a ' +
65
+ 'built-in receipt even with no completion page, so it is never a link to a 404. Do NOT ' +
66
+ 'reach for the form record\'s settings.afterSubmit "redirect" — the API stores it and the ' +
67
+ 'platform carries it nowhere.',
63
68
  hover_dead: 'Rewrite it with the same values: sb_set id "<id>", namespace style, state "hover", ' +
64
69
  'keys { … } — read them off the node first, because that one call both writes them where ' +
65
70
  'the renderer looks AND clears the slot nobody reads. Nothing else to do afterwards.',
@@ -24,7 +24,7 @@ export async function gatherReadiness(ctx, siteId, pageNodes) {
24
24
  }
25
25
  };
26
26
  const site = encodeURIComponent(siteId);
27
- const [pageList, gateways, shipping, globals, productList] = await Promise.all([
27
+ const [pageList, gateways, shipping, globals, productList, categoryList, pageLinks] = await Promise.all([
28
28
  get(`/api/sites/${site}/pages`),
29
29
  get(`/api/sites/${site}/payment-gateways`),
30
30
  get(`/api/sites/${site}/shipping-methods`),
@@ -34,6 +34,14 @@ export async function gatherReadiness(ctx, siteId, pageNodes) {
34
34
  // key-only one. A page is enough to tell empty from not; `total` carries the
35
35
  // real count when the platform sends it.
36
36
  get(`/api/sites/${site}/products?limit=200`),
37
+ // THE CATEGORIES, and the pages they point at. `/collections/{slug}` resolves
38
+ // through PublishedForEntity: the category's OWN page when a page-link names
39
+ // one, else the DEFAULT TEMPLATE for the `category` type — and nothing on
40
+ // that shared template narrows the product feed to the category in the URL.
41
+ // So two categories with no page-links means at most one of them can be
42
+ // right, and the rest list the whole catalogue.
43
+ get(`/api/sites/${site}/product-categories`),
44
+ get(`/api/sites/${site}/page-links`),
37
45
  ]);
38
46
  // A gateway counts only when it is BOTH enabled and configured — the editor's
39
47
  // `live` getter also filters by the store's currency, which is a narrowing:
@@ -44,6 +52,12 @@ export async function gatherReadiness(ctx, siteId, pageNodes) {
44
52
  : null;
45
53
  const methods = shipping?.shippingMethods ?? shipping?.methods;
46
54
  const shippingMethods = Array.isArray(methods) ? methods.length : null;
55
+ const cats = categoryList?.categories ?? categoryList?.productCategories;
56
+ const categories = Array.isArray(cats) ? (categoryList?.total ?? cats.length) : null;
57
+ const links = pageLinks?.links ?? pageLinks?.pageLinks;
58
+ const categoryPageLinks = Array.isArray(links)
59
+ ? links.filter((l) => l?.linkType === 'productCategory').length
60
+ : null;
47
61
  const globalNodes = globals?.globalSections
48
62
  ? globals.globalSections.flatMap((g) => Object.values(g.document?.nodes ?? {}))
49
63
  : null;
@@ -64,5 +78,7 @@ export async function gatherReadiness(ctx, siteId, pageNodes) {
64
78
  shippingMethods,
65
79
  pageNodes,
66
80
  globalNodes,
81
+ categories,
82
+ categoryPageLinks,
67
83
  };
68
84
  }
@@ -190,6 +190,39 @@ export function readinessGaps(input) {
190
190
  fix: 'Put a control with the open_cart event in the header global section.',
191
191
  });
192
192
  }
193
+ // EVERY CATEGORY SHOWS EVERY PRODUCT, which is what a shared template does.
194
+ //
195
+ // `/collections/{slug}` resolves through PublishedForEntity: the category's
196
+ // OWN page when a page-link names one, else the DEFAULT TEMPLATE for the
197
+ // `category` type. Nothing on that shared template narrows the product feed to
198
+ // the category in the URL — `entityScope` threads the entity into the article
199
+ // feed for a blogCategory and the review feed for a product, and into nothing
200
+ // at all for a productCategory — so a repeater on `all_products` repeats the
201
+ // whole catalogue, and one on `collection` names ONE fixed id. Either way at
202
+ // most one category can be right.
203
+ //
204
+ // Reported on the COUNTS rather than by reading the template's document: two
205
+ // categories and no page-links is already the defect, whatever the template
206
+ // says, and a document fetch per review to confirm it would cost every store
207
+ // that has this right. Silent when either count could not be read, like every
208
+ // other check here.
209
+ if ((input.categories ?? 0) > 1 && input.categoryPageLinks === 0) {
210
+ gaps.push({
211
+ id: 'categoryScope',
212
+ draft: false,
213
+ problem: `${input.categories} product categories share ONE page — none of them points at a page ` +
214
+ 'of its own, so /collections/{slug} serves the default template for every one. Nothing ' +
215
+ 'on that template narrows the product feed to the category in the URL (the blog twin ' +
216
+ 'auto-scopes; this one does not), so a shopper who picks a category sees the whole ' +
217
+ 'catalogue, or one other category, on all of them.',
218
+ fix: 'Give each category its own page and set its product repeater to config ' +
219
+ '{ "collectionType": "collection", "collectionId": "<that category id>" }, then link ' +
220
+ 'them in one call: sb_api_call post:/api/sites/{siteId}/page-links/bulk with body ' +
221
+ '{ "linkType": "productCategory", "linkIds": ["<category ids>"], "pageId": "<page id>" } ' +
222
+ '— one call per page, since each page is one category. sb_duplicate the template you ' +
223
+ 'already have rather than rebuilding it.',
224
+ });
225
+ }
193
226
  return gaps;
194
227
  }
195
228
  export const READINESS_NOTICE = 'These are STORE gaps, not page defects: each one survives publish without a warning and is ' +
@@ -225,6 +225,39 @@ export function reviewDesign(doc) {
225
225
  styled.states?.[STUCK_STATE],
226
226
  ...Object.values(styled.responsive ?? {}).map((r) => r?.states?.[STUCK_STATE]),
227
227
  ];
228
+ // AN ORDER FORM THAT LEAVES THE SHOPPER WHERE THEY WERE.
229
+ //
230
+ // Measured on a store built entirely with these tools: the order is created
231
+ // and the page stays put, showing a receipt-shaped list of totals that all
232
+ // read 0 because the cart it was summing has just been emptied. Nothing
233
+ // errors, and the shopper cannot tell a completed order from a failed one.
234
+ //
235
+ // Checked on the FORM NODE rather than on the form record, because the
236
+ // record's own `afterSubmit: "redirect"` is stored by the API and carried
237
+ // nowhere (`forms/pagesource.go`) — reading it would report a page as fine
238
+ // on the strength of a setting the platform ignores.
239
+ if (type === 'form') {
240
+ const events = n.events ?? [];
241
+ const navigates = events.some((e) => e.name === 'form:success' &&
242
+ (e.action === 'go_to_url' || e.action === 'open_page' || e.action === 'go_to_checkout'));
243
+ // AN ORDER-SHAPED PAGE, read off the document rather than off the page
244
+ // record. The record's `type` would need a second round trip on every
245
+ // open, and the form's own type a third; a page that carries BOTH a form
246
+ // and a cart total is the checkout shape and nothing else is — a contact
247
+ // page has no cart to total.
248
+ const sellsFromCart = Object.values(d.nodes).some((x) => x.data?.type === 'cart-total');
249
+ if (!navigates && sellsFromCart) {
250
+ out.push({
251
+ code: 'order_goes_nowhere',
252
+ nodeId: id,
253
+ type,
254
+ problem: 'This is the checkout form and nothing sends the shopper anywhere once the order ' +
255
+ 'is placed. They stay on this page, where every cart total now reads 0 because the ' +
256
+ 'cart was just emptied — a completed order that looks like a failed one.',
257
+ fix: fill('order_goes_nowhere', { id }),
258
+ });
259
+ }
260
+ }
228
261
  // A HOVER STORED WHERE NOBODY READS IT. The universal hover state is not the
229
262
  // only home: an element whose meta declares a Hover variant keeps it
230
263
  // somewhere its own renderer looks, and for a `button` that is the flat
@@ -132,8 +132,54 @@ function pageDocumentFor(formId, headline) {
132
132
  .join(formId)
133
133
  .split(HEADLINE_SENTINEL)
134
134
  .join(headline);
135
- return JSON.parse(json);
135
+ const doc = JSON.parse(json);
136
+ sendTheShopperOn(doc);
137
+ return doc;
138
+ }
139
+ /**
140
+ * WHERE THE SHOPPER GOES AFTER THE ORDER, which nothing else supplies.
141
+ *
142
+ * Measured on a store built entirely with these tools: press "Đặt hàng" and the
143
+ * order is created — and the shopper stays on the checkout page, looking at a
144
+ * receipt-shaped list of totals that all read 0 ₫ because the cart it was
145
+ * summing has just been emptied, under an inline confirmation whose default
146
+ * wording runs the order number into the total ("Đơn hàng #1003129.000 ₫").
147
+ * Nothing is broken; nothing sent them anywhere.
148
+ *
149
+ * The form record HAS a setting for this and it does not work:
150
+ * `settings.afterSubmit.action = "redirect"` is accepted by the API, stored, and
151
+ * carried nowhere — `forms/pagesource.go` says so outright ("Only the MESSAGE
152
+ * behaviour is carried today"), and `page.FormRef.RedirectPath` is assigned by
153
+ * nothing in the platform. Writing `specials.sentRedirect` on the node does not
154
+ * survive either: the composer overwrites that special from the same empty
155
+ * field on every render. So the platform's seed cannot carry this, and neither
156
+ * can any setting an agent can reach.
157
+ *
158
+ * What does work is the form's own success chain, which the island runs BEFORE
159
+ * afterSubmit and which `form`'s meta declares `go_to_url` on. `/checkout/complete`
160
+ * is the right destination unconditionally: it resolves by page TYPE, and it
161
+ * backstops itself with a built-in receipt when the store has no completion page
162
+ * of its own, so this is never a link to a 404.
163
+ */
164
+ function sendTheShopperOn(doc) {
165
+ for (const node of Object.values(doc.nodes)) {
166
+ if (node.data?.type !== 'form')
167
+ continue;
168
+ const events = (node.events ?? []);
169
+ if (events.some((e) => e.name === 'form:success'))
170
+ continue;
171
+ events.unshift({
172
+ id: 'ev_go_to_url',
173
+ name: 'form:success',
174
+ action: 'go_to_url',
175
+ payload: { url: COMPLETE_PATH },
176
+ });
177
+ node.events = events;
178
+ }
136
179
  }
180
+ /** The path a completed order lands on. Resolves by page TYPE, and serves a
181
+ * built-in receipt when the store has authored no completion page. */
182
+ const COMPLETE_PATH = '/checkout/complete';
137
183
  /**
138
184
  * THE FORM TEMPLATES, as a tuple zod can turn into an enum.
139
185
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",