sbuilder-mcp 0.52.0 → 0.54.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,20 @@ 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.54.0] - 2026-09-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `sb_review` reports a `mergedAuthPage` readiness gap when a single page carries two or more account forms (login, register, forgot-password, reset, or verify), since none of them then has an address a header can link to or a password-reset email can point at; the fix names `sb_store action:"form"` with a `page_name` to give each its own page.
|
|
13
|
+
|
|
14
|
+
## [0.53.0] - 2026-09-15
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `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.
|
|
18
|
+
- `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.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
9
23
|
## [0.52.0] - 2026-09-15
|
|
10
24
|
|
|
11
25
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,20 @@ 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.54.0] - 2026-09-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `sb_review` giờ báo cáo lỗi sẵn sàng `mergedAuthPage` khi một trang duy nhất chứa từ hai form tài khoản trở lên (đăng nhập, đăng ký, quên mật khẩu, đặt lại, hoặc xác thực), vì khi đó không form nào có địa chỉ riêng để header liên kết tới hay để email đặt lại mật khẩu trỏ đến; hướng khắc phục nêu tên `sb_store action:"form"` kèm `page_name` để tách mỗi form ra trang riêng của nó.
|
|
13
|
+
|
|
14
|
+
## [0.53.0] - 2026-09-15
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `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ó.
|
|
18
|
+
- 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.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
9
23
|
## [0.52.0] - 2026-09-15
|
|
10
24
|
|
|
11
25
|
### Added
|
|
@@ -29,25 +29,25 @@ 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
|
-
'
|
|
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.
|
|
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
|
-
'
|
|
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.
|
|
50
|
+
'nowhere to put it. sb_store action:"form" template:"contact" with a page_name.',
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
key: 'about',
|
|
@@ -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, categoryList, pageLinks] = await Promise.all([
|
|
27
|
+
const [pageList, gateways, shipping, globals, productList, categoryList, pageLinks, formList] = 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`),
|
|
@@ -42,6 +42,12 @@ export async function gatherReadiness(ctx, siteId, pageNodes) {
|
|
|
42
42
|
// right, and the rest list the whole catalogue.
|
|
43
43
|
get(`/api/sites/${site}/product-categories`),
|
|
44
44
|
get(`/api/sites/${site}/page-links`),
|
|
45
|
+
// THE FORMS, BY TYPE. A page document carries only `specials.formId` — which
|
|
46
|
+
// form a node shows, never what KIND of form it is — so nothing reading a
|
|
47
|
+
// page can tell a login form from a register form without this list. That
|
|
48
|
+
// blindness is what let one page quietly become the site's whole account
|
|
49
|
+
// area, which is the shape `mergedAuthPage` reports.
|
|
50
|
+
get(`/api/sites/${site}/forms`),
|
|
45
51
|
]);
|
|
46
52
|
// A gateway counts only when it is BOTH enabled and configured — the editor's
|
|
47
53
|
// `live` getter also filters by the store's currency, which is a narrowing:
|
|
@@ -87,5 +93,6 @@ export async function gatherReadiness(ctx, siteId, pageNodes) {
|
|
|
87
93
|
globalKinds,
|
|
88
94
|
categories,
|
|
89
95
|
categoryPageLinks,
|
|
96
|
+
forms: formList?.forms ?? null,
|
|
90
97
|
};
|
|
91
98
|
}
|
|
@@ -132,6 +132,52 @@ export function readinessGaps(input) {
|
|
|
132
132
|
'footer and a link back to the home page.',
|
|
133
133
|
});
|
|
134
134
|
}
|
|
135
|
+
// ONE PAGE QUIETLY BECAME THE WHOLE ACCOUNT AREA.
|
|
136
|
+
//
|
|
137
|
+
// This server used to tell agents, in as many words, to put the login and the
|
|
138
|
+
// register form behind a member-gate on /account. They did. The result is a
|
|
139
|
+
// page carrying two or three different jobs, and — the part that is not a
|
|
140
|
+
// matter of taste — NEITHER JOB HAS AN ADDRESS. A header can link to one
|
|
141
|
+
// thing, an email that says "reset your password" has nowhere specific to
|
|
142
|
+
// point, and a visitor who came to register meets a login form first because
|
|
143
|
+
// that is what was stacked on top.
|
|
144
|
+
//
|
|
145
|
+
// The rule was fixed; the pages it already built were not, and nothing could
|
|
146
|
+
// see them: a page document carries `specials.formId` and never the KIND of
|
|
147
|
+
// form, so only the site's own form list can tell these apart.
|
|
148
|
+
//
|
|
149
|
+
// COUNTED BY DISTINCT TYPE, not by how many form nodes there are. A form split
|
|
150
|
+
// across segments is several nodes of ONE type and is not this defect.
|
|
151
|
+
const authTypes = new Set(['login', 'register', 'forgot', 'reset', 'verify']);
|
|
152
|
+
if (input.forms && input.forms.length > 0) {
|
|
153
|
+
const typeOf = new Map(input.forms
|
|
154
|
+
.filter((f) => !!f?.id && !!f?.type)
|
|
155
|
+
.map((f) => [f.id, f.type]));
|
|
156
|
+
const onPage = new Set();
|
|
157
|
+
for (const n of input.pageNodes) {
|
|
158
|
+
if (n.data.type !== 'form')
|
|
159
|
+
continue;
|
|
160
|
+
const id = n.specials?.formId;
|
|
161
|
+
const t = typeof id === 'string' ? typeOf.get(id) : undefined;
|
|
162
|
+
if (t && authTypes.has(t))
|
|
163
|
+
onPage.add(t);
|
|
164
|
+
}
|
|
165
|
+
if (onPage.size >= 2) {
|
|
166
|
+
const named = [...onPage].sort().join(', ');
|
|
167
|
+
gaps.push({
|
|
168
|
+
id: 'mergedAuthPage',
|
|
169
|
+
draft: false,
|
|
170
|
+
problem: `This page carries ${onPage.size} different account forms (${named}). Neither has an ` +
|
|
171
|
+
'address of its own, so a header can link to only one of them, a password-reset mail ' +
|
|
172
|
+
'has nowhere specific to point, and whoever came to do the second thing meets the ' +
|
|
173
|
+
'first one stacked on top.',
|
|
174
|
+
fix: 'Give each its own page: sb_store action:"form" with template "login", "register" or ' +
|
|
175
|
+
'"forgot" and a page_name builds the form AND the page in one call. Leave /account ' +
|
|
176
|
+
'showing the profile behind a member-gate with audience "members", and a short ' +
|
|
177
|
+
'sign-in prompt LINKING to the login page behind audience "guests".',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
135
181
|
if (!isStore(input))
|
|
136
182
|
return gaps;
|
|
137
183
|
if (pages && !published(pages, 'checkout')) {
|
package/dist/tools/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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.
|
|
3
|
+
"version": "0.54.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",
|