sbuilder-mcp 0.47.0 → 0.47.2

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,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.47.2] - 2026-09-14
10
+
11
+ ### Fixed
12
+ - `sb_page_create` no longer stores an HTML entity verbatim in a page name; a name like `Chính sách giao hàng & đổi trả`, lifted undecoded from a page's own source, is now decoded once before it is sent, on both the create and the home-page-adopt rename path.
13
+
14
+ ## [0.47.1] - 2026-09-14
15
+
16
+ ### Fixed
17
+ - `sb_page_create` with `is_homepage` set on a site that already has one no longer creates a duplicate that steals the star and leaves the platform's own original home page reachable at no address; it now adopts the existing home page instead, renaming it when the caller named it something else, and reports what it did instead of creating anything.
18
+
9
19
  ## [0.47.0] - 2026-09-14
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.47.2] - 2026-09-14
10
+
11
+ ### Fixed
12
+ - `sb_page_create` không còn lưu nguyên một HTML entity trong tên trang; một tên như `Chính sách giao hàng & đổi trả`, lấy chưa giải mã từ chính nguồn của một trang, giờ được giải mã một lần trước khi gửi đi, trên cả đường tạo trang lẫn đường đổi tên khi nhận lại trang chủ.
13
+
14
+ ## [0.47.1] - 2026-09-14
15
+
16
+ ### Fixed
17
+ - `sb_page_create` với `is_homepage` được bật trên một site đã có sẵn trang chủ giờ không còn tạo ra một trang trùng lặp cướp mất ngôi sao và khiến trang chủ gốc của nền tảng trở nên không thể truy cập ở bất kỳ địa chỉ nào; giờ nó nhận lại trang chủ hiện có, đổi tên nếu người gọi đặt tên khác, và báo cáo việc đã làm thay vì tạo trang mới.
18
+
9
19
  ## [0.47.0] - 2026-09-14
10
20
 
11
21
  ### Added
@@ -311,6 +311,63 @@ async function siteChrome(ctx, siteId) {
311
311
  return {};
312
312
  }
313
313
  }
314
+ /**
315
+ * Plain text, for a field the platform renders AS text.
316
+ *
317
+ * A page name is shown in the pages panel and in the browser tab and is never
318
+ * parsed as markup, so an HTML entity in one is always a mistake: text lifted
319
+ * from a page's source and handed on without being decoded. Measured on a real
320
+ * store, read back through sb_page_list: a page stored as "Chính sách giao hàng
321
+ * & đổi trả" — which is what the merchant then reads in their own editor,
322
+ * and what a browser tab then shows.
323
+ *
324
+ * ONE PASS, and only over what escaping actually produces. "&" decodes to
325
+ * "&" and stops there, because a second pass would be inventing an intent
326
+ * nobody expressed; an unknown entity is left exactly as it arrived. This
327
+ * un-escapes text that was escaped once. It does not interpret markup.
328
+ */
329
+ const ENTITIES = {
330
+ amp: '&',
331
+ lt: '<',
332
+ gt: '>',
333
+ quot: '"',
334
+ apos: "'",
335
+ nbsp: ' ',
336
+ };
337
+ export function plainText(s) {
338
+ return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (whole, body) => {
339
+ if (body.startsWith('#')) {
340
+ const code = body.startsWith('#x') ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
341
+ // A code point that is not one is not a reference — leave the text alone
342
+ // rather than writing a replacement character into somebody's page name.
343
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
344
+ }
345
+ return ENTITIES[body.toLowerCase()] ?? whole;
346
+ });
347
+ }
348
+ /**
349
+ * The site's own home page, or null when it genuinely has none.
350
+ *
351
+ * THROWS RATHER THAN GUESSES. Answering "none" for a listing that could not be
352
+ * read is the one wrong answer available here: it is indistinguishable from an
353
+ * empty site, and the caller acts on it by creating a home page the site
354
+ * already had. `siteChrome` above may swallow its read because a page created
355
+ * without chrome is a page a person can fix; this one may not, because the page
356
+ * it creates cannot be un-created and takes the star off whichever page held it.
357
+ */
358
+ async function existingHomepage(ctx, siteId) {
359
+ const listed = (await request({
360
+ base: ctx.base,
361
+ method: 'GET',
362
+ path: `/api/sites/${encodeURIComponent(siteId)}/pages`,
363
+ token: siteToken(ctx),
364
+ fetchImpl: ctx.fetchImpl,
365
+ }));
366
+ const home = (listed.pages ?? []).find((p) => p.isHomepage === true);
367
+ if (!home || typeof home.id !== 'string' || !home.id)
368
+ return null;
369
+ return { id: home.id, name: typeof home.name === 'string' ? home.name : '' };
370
+ }
314
371
  export function registerPageTools(server, ctx) {
315
372
  const session = new PageSession(ctx);
316
373
  server.registerTool('sb_page_open', {
@@ -981,6 +1038,8 @@ export function registerPageTools(server, ctx) {
981
1038
  }, async ({ site_id: given, name, type, slug, is_homepage, settings, seed, chrome, locale, headline, dry_run }) => {
982
1039
  const site_id = siteFor(ctx, given);
983
1040
  const path = `/api/sites/${encodeURIComponent(site_id)}/pages`;
1041
+ // The platform stores this verbatim and renders it as text — see plainText.
1042
+ name = plainText(name);
984
1043
  // TYPE IS THE ROUTE for several kinds of page: /checkout and
985
1044
  // /products/{slug} resolve to the site's PUBLISHED page of that type and
986
1045
  // fall through to a 404 when there is none. Without this argument the
@@ -1012,7 +1071,85 @@ export function registerPageTools(server, ctx) {
1012
1071
  // fine, while `siteChrome` asks whether the SITE has globals and it does.
1013
1072
  // Measured: three pages built with these tools, every one of them bare,
1014
1073
  // beside a store page carrying its header as ROOT's first child.
1015
- const wear = chrome !== false ? await siteChrome(ctx, site_id) : {};
1074
+ // A SITE HAS ONE HOME PAGE, AND BY THE TIME AN AGENT ASKS FOR ONE IT
1075
+ // USUALLY EXISTS ALREADY.
1076
+ //
1077
+ // `isHomepage: true` does not mean "make this the home page" to the
1078
+ // platform. It means MOVE THE STAR: CreatePage demotes whoever holds it
1079
+ // and promotes this one. An agent building a store reads the flag the
1080
+ // first way and asks for it on a site the editor already gave a home page
1081
+ // to, so the site ends up with two — the new one on "/", the old one
1082
+ // demoted and holding nothing. Measured on a real store: pg_439cb121
1083
+ // "Home" and pg_237719d4 "Trang chủ", both slug "", both path "/", the
1084
+ // first reachable at no address at all and still listed as a page.
1085
+ //
1086
+ // So the flag is honoured as what the caller meant — the site's home page
1087
+ // — which is the one it already has. Adopted, renamed when the caller
1088
+ // named it something else, never duplicated. The same rule sb_import_site
1089
+ // already follows when its entry page lands on a site with a home page.
1090
+ //
1091
+ // REPLACING the home page with a DIFFERENT page stays possible and stays
1092
+ // explicit: create it without the flag, build it, then PATCH isHomepage
1093
+ // through sb_api_call. That is a decision, and it should read like one.
1094
+ const adopt = is_homepage === true ? await existingHomepage(ctx, site_id) : null;
1095
+ const rename = adopt && name.trim() !== '' && name !== adopt.name ? name : '';
1096
+ // Read AFTER the adopt decision and skipped when it holds: siteChrome
1097
+ // reads the header and footer off the home page, so asking it which
1098
+ // chrome to dress the home page in is two requests to answer "its own".
1099
+ const wear = chrome !== false && !adopt ? await siteChrome(ctx, site_id) : {};
1100
+ if (adopt && dry_run !== false) {
1101
+ return text({
1102
+ dry_run: true,
1103
+ into: 'the existing home page',
1104
+ page: adopt,
1105
+ ...(rename ? { would_rename: { from: adopt.name, to: rename } } : {}),
1106
+ note: 'Nothing would be created. This site already has a home page and a site has ' +
1107
+ 'exactly one, so a create carrying isHomepage would have taken the star off ' +
1108
+ `${JSON.stringify(adopt.name)} and left it with no address. Open ${adopt.id} ` +
1109
+ 'with sb_page_open and build it.',
1110
+ });
1111
+ }
1112
+ if (adopt) {
1113
+ // THE RENAME IS THE ONLY WRITE. Not the seed — this page may already be
1114
+ // the site's front door, and overwriting a document nobody asked to
1115
+ // replace is the one thing worse than the duplicate this branch exists
1116
+ // to prevent. Not the chrome either: siteChrome reads the header and
1117
+ // footer OFF the home page, so this page is where they came from.
1118
+ let renamed_to;
1119
+ let rename_failed;
1120
+ if (rename) {
1121
+ try {
1122
+ await request({
1123
+ base: ctx.base,
1124
+ method: 'PATCH',
1125
+ path: `${path}/${encodeURIComponent(adopt.id)}`,
1126
+ token: siteToken(ctx),
1127
+ body: { name: rename },
1128
+ fetchImpl: ctx.fetchImpl,
1129
+ });
1130
+ renamed_to = rename;
1131
+ }
1132
+ catch (e) {
1133
+ // The page is still the right one to build on, so a failed rename
1134
+ // is reported, never raised — the caller asked for a home page and
1135
+ // this is it, under its old name.
1136
+ rename_failed = e.message.replace(/^sbuilder:\s*/, '').slice(0, 160);
1137
+ }
1138
+ }
1139
+ return text({
1140
+ into: 'the existing home page',
1141
+ page: { id: adopt.id, name: renamed_to ?? adopt.name },
1142
+ ...(renamed_to ? { renamed: { from: adopt.name, to: renamed_to } } : {}),
1143
+ ...(rename_failed ? { rename_failed } : {}),
1144
+ ...(slug ? { slug_ignored: 'A home page is served at "/" and carries no slug.' } : {}),
1145
+ ...(type && type !== 'page' ? { type_ignored: `Kept as it is; adopting does not retype a page to "${type}".` } : {}),
1146
+ note: 'Nothing was created. This site already had a home page and a site has exactly ' +
1147
+ 'one, so creating another would have taken the star off this page and left it ' +
1148
+ `with no address. Open ${adopt.id} with sb_page_open and build it. To hand the ` +
1149
+ 'home page over to a DIFFERENT page instead, create that page WITHOUT ' +
1150
+ 'is_homepage and then PATCH isHomepage on it through sb_api_call.',
1151
+ });
1152
+ }
1016
1153
  if (dry_run !== false) {
1017
1154
  return text({
1018
1155
  dry_run: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.47.0",
3
+ "version": "0.47.2",
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",