sbuilder-mcp 0.51.0 → 0.53.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,24 @@ 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.53.0] - 2026-09-15
10
+
11
+ ### Added
12
+ - `sb_store action:"form"` now takes a `page_name` (and optional `headline`) that builds a page and places the seeded form on it in the same call, instead of leaving the caller to create a page, `sb_add` a form element, and `sb_set` its `formId` by hand.
13
+ - `sb_page_list`'s advisory for a missing login, register, forgot-password, or contact page now names the single `sb_store action:"form" template:"..." page_name:"..."` call that builds both the form and its page, instead of pointing at the form template alone.
14
+
15
+ ### Changed
16
+ - A page created for a seeded form is a second write and never undoes the first; if the page create is refused, `sb_store` still reports the form it already made, under a `page_failed` field, rather than rolling the form back.
17
+
18
+ ## [0.52.0] - 2026-09-15
19
+
20
+ ### Added
21
+ - `sb_page_list`'s `usually_also` advisory now names a missing `faq` page.
22
+ - `sb_page_list`'s `usually_also` advisory now names a missing blog listing page, but only on a site that already has a `post` (article) template to list — a site with no articles is no longer told to build one.
23
+
24
+ ### Changed
25
+ - `sb_page_list`'s `usually_also` advisory splits the single `policy` entry into `policy-delivery` (shipping and returns) and `policy-privacy` (privacy and terms of use), so a site carrying only one of the two no longer reads as having both.
26
+
9
27
  ## [0.51.0] - 2026-09-15
10
28
 
11
29
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,24 @@ 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.53.0] - 2026-09-15
10
+
11
+ ### Added
12
+ - `sb_store action:"form"` giờ nhận thêm `page_name` (và `headline` tuỳ chọn) để dựng luôn một trang và đặt form vừa tạo lên đó trong cùng một lần gọi, thay vì để người gọi phải tự tạo trang, `sb_add` phần tử form rồi `sb_set` `formId` của nó.
13
+ - Gợi ý của `sb_page_list` khi thiếu trang đăng nhập, đăng ký, quên mật khẩu hoặc liên hệ giờ nêu đích danh một lệnh gọi duy nhất `sb_store action:"form" template:"..." page_name:"..."` để dựng cả form lẫn trang chứa nó, thay vì chỉ trỏ tới mỗi template của form.
14
+
15
+ ### Changed
16
+ - Trang được tạo cho một form vừa dựng là một lần ghi riêng và không bao giờ huỷ lần ghi trước đó; nếu việc tạo trang bị từ chối, `sb_store` vẫn báo cáo form đã tạo thành công, kèm trường `page_failed`, thay vì xoá form đó đi.
17
+
18
+ ## [0.52.0] - 2026-09-15
19
+
20
+ ### Added
21
+ - Gợi ý `usually_also` của `sb_page_list` giờ nêu tên trang `faq` nếu site còn thiếu.
22
+ - Gợi ý `usually_also` của `sb_page_list` giờ nêu tên trang danh sách blog nếu site còn thiếu, nhưng chỉ khi site đã có template bài viết (`post`) để liệt kê — site chưa có bài viết nào thì không còn bị nhắc xây trang này.
23
+
24
+ ### Changed
25
+ - Gợi ý `usually_also` của `sb_page_list` tách mục `policy` duy nhất thành `policy-delivery` (giao hàng và đổi trả) và `policy-privacy` (bảo mật và điều khoản sử dụng), để một site chỉ có một trong hai trang không còn bị đọc nhầm là đã có đủ cả hai.
26
+
9
27
  ## [0.51.0] - 2026-09-15
10
28
 
11
29
  ### Added
@@ -29,40 +29,71 @@ export const USUAL_PAGES = [
29
29
  key: 'login',
30
30
  match: ['login', 'signin', 'sign-in', 'dang-nhap', 'đăng nhập'],
31
31
  why: 'Nothing for a header, an order email or /account\'s signed-out state to link to. ' +
32
- 'Seed it with sb_store action:"form" template "login" on a page of type "page".',
32
+ 'sb_store action:"form" template:"login" page_name:"Đăng nhập" builds the form AND the page it lives on.',
33
33
  },
34
34
  {
35
35
  key: 'register',
36
36
  match: ['register', 'signup', 'sign-up', 'dang-ky', 'đăng ký'],
37
37
  why: 'A shopper cannot open an account, so order history, addresses and any member-gated ' +
38
- 'page are unreachable. Template "register".',
38
+ 'page are unreachable. sb_store action:"form" template:"register" with a page_name.',
39
39
  },
40
40
  {
41
41
  key: 'forgot',
42
42
  match: ['forgot', 'reset', 'quen-mat-khau', 'quên mật khẩu', 'doi-mat-khau'],
43
43
  why: 'A customer who forgets a password has no way back in and writes to support instead. ' +
44
- 'Template "forgot".',
44
+ 'sb_store action:"form" template:"forgot" with a page_name.',
45
45
  },
46
46
  {
47
47
  key: 'contact',
48
48
  match: ['contact', 'lien-he', 'liên hệ'],
49
49
  why: 'No address, phone or form anywhere, so a shopper with a question about an order has ' +
50
- 'nowhere to put it. Template "contact".',
50
+ 'nowhere to put it. sb_store action:"form" template:"contact" with a page_name.',
51
51
  },
52
52
  {
53
53
  key: 'about',
54
54
  match: ['about', 'gioi-thieu', 'giới thiệu', 've-chung-toi'],
55
55
  why: 'Nothing says who the shop is, which is the page a first-time buyer opens before paying.',
56
56
  },
57
+ // TWO POLICIES, NOT ONE BUCKET. These were a single entry, and it read a shop
58
+ // carrying only "Chính sách giao hàng & đổi trả" as complete — which is
59
+ // exactly the shop that was measured, and it had no privacy terms at all. One
60
+ // policy page satisfying a check about all of them is the check answering a
61
+ // question it was not asked.
57
62
  {
58
- key: 'policy',
63
+ key: 'policy-delivery',
59
64
  match: [
60
- 'policy', 'policies', 'chinh-sach', 'chính sách', 'dieu-khoan', 'điều khoản',
61
- 'terms', 'privacy', 'bao-mat', 'bảo mật', 'doi-tra', 'đổi trả', 'return',
62
- 'shipping', 'van-chuyen', 'vận chuyển', 'giao-hang', 'giao hàng', 'refund',
65
+ 'doi-tra', 'đổi trả', 'return', 'refund', 'hoan-tien', 'hoàn tiền',
66
+ 'shipping', 'van-chuyen', 'vận chuyển', 'giao-hang', 'giao hàng', 'delivery',
63
67
  ],
64
- why: 'No delivery, return or privacy terms a shopper can read before paying — the pages a ' +
65
- 'marketplace and a payment provider both ask for.',
68
+ why: 'No delivery or return terms a shopper can read before paying. This is the page a buyer ' +
69
+ 'looks for when the parcel is late and the one a dispute is settled against.',
70
+ },
71
+ {
72
+ key: 'policy-privacy',
73
+ match: [
74
+ 'privacy', 'bao-mat', 'bảo mật', 'dieu-khoan', 'điều khoản', 'terms',
75
+ 'quy-dinh', 'quy định',
76
+ ],
77
+ why: 'No privacy policy or terms of use. Payment providers and marketplaces ask for both ' +
78
+ 'before they will list a shop, and a checkout form collects personal data either way.',
79
+ },
80
+ {
81
+ key: 'faq',
82
+ match: ['faq', 'cau-hoi', 'câu hỏi', 'hoi-dap', 'hỏi đáp', 'help', 'tro-giup', 'trợ giúp'],
83
+ why: 'The same handful of questions reach support one message at a time, with no page to link ' +
84
+ 'an answer to. The accordion element is what this page is built from.',
85
+ },
86
+ {
87
+ key: 'blog',
88
+ // ONLY ONCE THERE IS SOMETHING TO LIST. `post` is the ARTICLE template —
89
+ // it answers /blog/{slug} and has no address of its own — so a site with
90
+ // one can publish articles that nothing on the site links to. A site
91
+ // without one has no articles, and telling it to build a listing page is
92
+ // advice about a section it never asked for.
93
+ when: (pages) => pages.some((x) => x.type === 'post'),
94
+ match: ['blog', 'tin-tuc', 'tin tức', 'news', 'bai-viet', 'bài viết', 'kien-thuc', 'cẩm nang'],
95
+ why: 'This site has an article template, so /blog/{slug} works — but no page LISTS the ' +
96
+ 'articles, so each one is reachable only by someone who already has its URL.',
66
97
  },
67
98
  ];
68
99
  /**
@@ -80,5 +111,5 @@ export function missingUsualPages(pages) {
80
111
  .filter((p) => (typeof p.type === 'string' ? p.type === 'page' : true))
81
112
  .map((p) => `${typeof p.slug === 'string' ? p.slug : ''} ${typeof p.name === 'string' ? p.name : ''}`.toLowerCase())
82
113
  .join('\n');
83
- return USUAL_PAGES.filter((u) => !u.match.some((m) => hay.includes(m)));
114
+ return USUAL_PAGES.filter((u) => (u.when ? u.when(pages) : true) && !u.match.some((m) => hay.includes(m)));
84
115
  }
@@ -33,6 +33,7 @@ import { request, redact } from '../transport/http.js';
33
33
  import { siteToken } from './credentialpick.js';
34
34
  import { siteFor } from './context.js';
35
35
  import { genId } from '../domains/site/ids.js';
36
+ import { addSubtree } from '../domains/site/builder.js';
36
37
  import { chromeLinks, hasGlobal, shareChrome, sitePages } from './chrome.js';
37
38
  import { tokensFromPage } from '../domains/site/importmap.js';
38
39
  import { CHECKOUT_FORM, CHECKOUT_FORM_DOCUMENT, CHECKOUT_PAGE_DOCUMENT, CHECKOUT_TEXT, FORM_ID_SENTINEL, HEADLINE_SENTINEL, FORM_TEMPLATES, } from '../catalog/checkout.generated.js';
@@ -207,7 +208,54 @@ const FORM_TEMPLATE_KEYS = Object.keys(FORM_TEMPLATES).sort();
207
208
  * returns — and `/account` is the one page that is not a free choice, because
208
209
  * `membersOnlyRedirectTarget` sends every gated visitor there.
209
210
  */
210
- async function seedForm(ctx, siteId, key, formName, dryRun) {
211
+ /**
212
+ * The page a seeded form goes on, built and saved in the same call.
213
+ *
214
+ * WHY THIS EXISTS. seedForm made a form and stopped, and said so: "No page is
215
+ * made. Where a login form belongs is a design decision." True, and it left the
216
+ * caller three steps — create a page, sb_add a form element, sb_set its formId —
217
+ * with nothing insisting they belong together. Measured: a store built with
218
+ * these tools had no login page, no register page and no forgot page, and its
219
+ * own header had nothing to link to. The design decision was never the
220
+ * obstacle; the three steps were.
221
+ *
222
+ * A BLANK PAGE FIRST, THEN THE SUBTREE, rather than a document posted with the
223
+ * create. The checkout path can post one because it has a whole seeded document
224
+ * to post; here the document is one element, and going through the session is
225
+ * how every other builder in this server writes a page — band rules, id
226
+ * minting and the save contract all come with it instead of being re-derived.
227
+ */
228
+ async function placeFormOnPage(ctx, session, siteId, formId, pageName, headline) {
229
+ const made = (await request({
230
+ base: ctx.base,
231
+ method: 'POST',
232
+ path: `/api/sites/${encodeURIComponent(siteId)}/pages`,
233
+ token: siteToken(ctx),
234
+ body: { name: pageName, type: 'page' },
235
+ fetchImpl: ctx.fetchImpl,
236
+ }));
237
+ const id = made.page?.id;
238
+ if (typeof id !== 'string' || !id) {
239
+ throw new Error('sbuilder: the platform accepted the page create and returned no page');
240
+ }
241
+ await session.open(siteId, id);
242
+ const doc = session.current();
243
+ const { patches } = addSubtree(doc, doc.doc.root_node_id, {
244
+ type: 'flex-section',
245
+ children: [
246
+ {
247
+ type: 'flex-block',
248
+ children: [
249
+ ...(headline ? [{ type: 'heading', specials: { text: headline } }] : []),
250
+ { type: 'form', specials: { formId } },
251
+ ],
252
+ },
253
+ ],
254
+ });
255
+ await session.applyAndSave(patches);
256
+ return { id, name: typeof made.page?.name === 'string' ? made.page.name : pageName };
257
+ }
258
+ async function seedForm(ctx, session, siteId, key, formName, pageName, headline, dryRun) {
211
259
  const tpl = FORM_TEMPLATES[key];
212
260
  const site = encodeURIComponent(siteId);
213
261
  const name = formName ?? tpl.key;
@@ -236,6 +284,15 @@ async function seedForm(ctx, siteId, key, formName, dryRun) {
236
284
  path: `/api/sites/${site}/forms/{formId}/document`,
237
285
  },
238
286
  ],
287
+ ...(pageName
288
+ ? {
289
+ would_also: `create a page named ${JSON.stringify(pageName)} of type "page" and put ` +
290
+ 'the form on it, so the form has an address a header can link to',
291
+ }
292
+ : {
293
+ no_page: 'Only the form. Pass page_name to have the page made and the form placed ' +
294
+ 'on it in this same call — the three steps that otherwise get skipped.',
295
+ }),
239
296
  preview: redact({ name, type: tpl.type }),
240
297
  };
241
298
  }
@@ -268,12 +325,34 @@ async function seedForm(ctx, siteId, key, formName, dryRun) {
268
325
  await send('DELETE', `/api/sites/${site}/forms/${encodeURIComponent(form.id)}`).catch(() => undefined);
269
326
  throw e;
270
327
  }
328
+ // THE PAGE IS A SECOND WRITE AND MUST NOT UNDO THE FIRST. The form EXISTS the
329
+ // moment its three calls land; a refused page create leaves a form the caller
330
+ // can still place by hand, which is exactly what they had before this
331
+ // argument existed. Reporting the failure beats deleting a form they asked
332
+ // for — the same rule the seed follows in sb_page_create.
333
+ let page;
334
+ let page_failed;
335
+ if (pageName) {
336
+ try {
337
+ page = await placeFormOnPage(ctx, session, siteId, form.id, pageName, headline);
338
+ }
339
+ catch (e) {
340
+ page_failed = e.message.replace(/^sbuilder:\s*/, '').slice(0, 160);
341
+ }
342
+ }
271
343
  return {
272
344
  form: { id: form.id, type: form.type, name: form.name },
273
345
  fields,
274
- next: `Place it: sb_add a "form" element, then sb_set its specials.formId to "${form.id}". ` +
275
- 'The submit button lives in the FORM DOCUMENT, not on the page, and a page republish is ' +
276
- 'what makes a form-document edit visible.',
346
+ ...(page ? { page } : {}),
347
+ ...(page_failed ? { page_failed } : {}),
348
+ next: page
349
+ ? `The form is on page ${page.id}. Publish it with sb_publish, and link to it from the ` +
350
+ 'header. The submit button lives in the FORM DOCUMENT, not on the page, and a page ' +
351
+ 'republish is what makes a form-document edit visible.'
352
+ : `Place it: sb_add a "form" element, then sb_set its specials.formId to "${form.id}" — ` +
353
+ 'or pass page_name to have that page made for you. The submit button lives in the FORM ' +
354
+ 'DOCUMENT, not on the page, and a page republish is what makes a form-document edit ' +
355
+ 'visible.',
277
356
  };
278
357
  }
279
358
  export function registerStoreTools(server, ctx, session) {
@@ -350,7 +429,7 @@ export function registerStoreTools(server, ctx, session) {
350
429
  if (!template) {
351
430
  throw new Error(`sbuilder: action:"form" needs a template. One of: ${FORM_TEMPLATE_KEYS.join(', ')}.`);
352
431
  }
353
- return text(await seedForm(ctx, siteId, template, name, dry_run !== false));
432
+ return text(await seedForm(ctx, session, siteId, template, name, page_name, headline, dry_run !== false));
354
433
  }
355
434
  const lang = (language ?? 'vi');
356
435
  const t = CHECKOUT_TEXT[lang];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.51.0",
3
+ "version": "0.53.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",