webcake-storefront-mcp 1.1.4 → 1.3.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/README.md CHANGED
@@ -149,7 +149,7 @@ Base URLs come from a **named environment** — set `WEBCAKE_ENV` (or `--env`) a
149
149
  | **`prod`** (default) | `https://api.storefront.webcake.io` | `https://webcake.io` | `<site_slug>.webcake.me` |
150
150
 
151
151
  Override a preset with `WEBCAKE_API_URL` / `WEBCAKE_APP_URL`. Optional, configured server-side:
152
- `PEXELS_API_KEY` (search_images), `MONGO_URI` (image-alt cache). Token / session / site can also be set
152
+ `PEXELS_API_KEY` (search_images). Token / session / site can also be set
153
153
  in chat via `update_auth` and `switch_site` — saved to a local config file at `~/.webcake-storefront-mcp/`.
154
154
 
155
155
  <details>
@@ -171,7 +171,7 @@ in chat via `update_auth` and `switch_site` — saved to a local config file at
171
171
  | Group | Tools | Needs |
172
172
  |-------|-------|-------|
173
173
  | **Build a page** | `get_build_guide` · `list_elements` · `get_element` · `new_element` · `new_section` · `new_page_skeleton` · `validate_page` · `build_page` · `add_section` | catalog tools: nothing |
174
- | **Media & ingest** | `search_images` (Pexels) · `upload_image` (CDN) · `ingest_html` · `ingest_url` (recreate a reference page) | — |
174
+ | **Media & ingest** | `search_images` (Pexels) · `upload_images` (CDN) · `ingest_html` · `ingest_url` (recreate a reference page) | — |
175
175
  | **Pages & code** | `list_pages` · `get_page_source` · `search_page_elements` · `get_page_element` · `update_page_element(s)` · `create_page` · `update_page` · `update_page_source` · custom CSS/JS · page contents · global sections · `publish_site` | token + session |
176
176
  | **Commerce** | products · orders · collections · promotions · combos | token + session |
177
177
  | **Content & store** | blog articles · themes / site style · apps · customers · `send_mail` | token + session |
package/README.vi.md CHANGED
@@ -148,7 +148,7 @@ URL gốc lấy theo **môi trường có tên** — đặt `WEBCAKE_ENV` (hoặ
148
148
  | **`prod`** (mặc định) | `https://api.storefront.webcake.io` | `https://webcake.io` | `<site_slug>.webcake.me` |
149
149
 
150
150
  Override bằng `WEBCAKE_API_URL` / `WEBCAKE_APP_URL`. Tuỳ chọn, đặt phía server:
151
- `PEXELS_API_KEY` (search_images), `MONGO_URI` (cache alt ảnh). Token / session / site cũng có thể đặt
151
+ `PEXELS_API_KEY` (search_images). Token / session / site cũng có thể đặt
152
152
  trong chat bằng `update_auth` và `switch_site` — lưu vào file cấu hình tại `~/.webcake-storefront-mcp/`.
153
153
 
154
154
  <details>
@@ -170,7 +170,7 @@ trong chat bằng `update_auth` và `switch_site` — lưu vào file cấu hình
170
170
  | Nhóm | Tool | Cần |
171
171
  |-------|-------|-------|
172
172
  | **Dựng trang** | `get_build_guide` · `list_elements` · `get_element` · `new_element` · `new_section` · `new_page_skeleton` · `validate_page` · `build_page` · `add_section` | tool catalog: không cần |
173
- | **Media & ingest** | `search_images` (Pexels) · `upload_image` (CDN) · `ingest_html` · `ingest_url` (dựng lại trang tham khảo) | — |
173
+ | **Media & ingest** | `search_images` (Pexels) · `upload_images` (CDN) · `ingest_html` · `ingest_url` (dựng lại trang tham khảo) | — |
174
174
  | **Trang & code** | `list_pages` · `get_page_source` · `search_page_elements` · `get_page_element` · `update_page_element(s)` · `create_page` · `update_page` · `update_page_source` · custom CSS/JS · nội dung trang · global section · `publish_site` | token + session |
175
175
  | **Thương mại** | sản phẩm · đơn hàng · collection · khuyến mãi · combo | token + session |
176
176
  | **Nội dung & store** | bài blog · theme / site style · app · khách hàng · `send_mail` | token + session |
package/dist/api.js CHANGED
@@ -70,6 +70,12 @@ export class WebcakeCmsApi {
70
70
  listMySites(query) {
71
71
  return this.request("GET", `/api/v1/dashboard/site/all`, { query });
72
72
  }
73
+ /** Create a brand-new personal site. The backend seeds sample categories/products/blog
74
+ * but NO pages. Returns { data: { site: { id, site_slug:{slug}, ... } } }.
75
+ * Fails with 403 when the account's site quota is reached (free plan: 4 sites). */
76
+ createSite(params) {
77
+ return this.request("POST", `/api/v1/dashboard/site/create`, { body: params, timeout: 60000 });
78
+ }
73
79
  getSiteInfo() {
74
80
  return this.request("GET", `/api/v1/site/${this.siteId}/`);
75
81
  }
@@ -208,6 +214,32 @@ export class WebcakeCmsApi {
208
214
  async updateSiteSettings(newSettings) {
209
215
  return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/update_site`, { body: { settings: newSettings }, timeout: 60000 });
210
216
  }
217
+ /** Read the full site.settings object (parsed). Empty object if unset/unparseable. */
218
+ async getSiteSettings() {
219
+ const siteRes = await this.request("GET", `/api/v1/site/${this.siteId}/`, { timeout: 60000 });
220
+ const raw = (siteRes && siteRes.data && siteRes.data.settings) || "";
221
+ if (raw && typeof raw === "object")
222
+ return raw;
223
+ if (typeof raw === "string" && raw.trim()) {
224
+ try {
225
+ return JSON.parse(raw);
226
+ }
227
+ catch {
228
+ return {};
229
+ }
230
+ }
231
+ return {};
232
+ }
233
+ /** Ensure a site data-source flag (use_store/use_member/use_blog/use_error/use_maintain)
234
+ * is enabled so special pages' bindings resolve. Merges into existing settings;
235
+ * no-op if already on. */
236
+ async enableSiteFeature(flag) {
237
+ const settings = await this.getSiteSettings();
238
+ if (settings[flag] === true)
239
+ return { changed: false, flag };
240
+ await this.updateSiteSettings({ ...settings, [flag]: true });
241
+ return { changed: true, flag };
242
+ }
211
243
  // ── Collections ──
212
244
  listCollections(query) {
213
245
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/db_collections`, { query });
@@ -0,0 +1,68 @@
1
+ // Breakpoint + grid model, ported from builderx_spa (composable/grid.js + common/index.js).
2
+ //
3
+ // CRITICAL: BuilderX persists each node's style/layout under per-breakpoint keys
4
+ // `bp1`/`bp2`/`bp3`/`bp4` — each `{ style, config }` — NOT under `runtime`. The
5
+ // `runtime` key the factory emits is only a staging area inside the Vue editor; the
6
+ // storefront renderer reads `node[breakpointActive]` (see getStyle/getConfig in
7
+ // builderx_spa/src/composable/get.js) and does NOT fall back to `runtime`. A node that
8
+ // only has `runtime` renders with no styles/grid placement (a broken-looking page).
9
+ // page.ts:finalizeForRender() converts runtime -> bp1..bp4 before a page is saved.
10
+ /** Site default breakpoints, largest first. [minWidth, maxWidth]. bp1 is the base. */
11
+ export const BREAKPOINTS = {
12
+ bp1: [1320, 1e9], // desktop (base / largest, default active)
13
+ bp2: [993, 1319], // laptop
14
+ bp3: [641, 992], // tablet
15
+ bp4: [320, 640], // mobile
16
+ };
17
+ export const BREAKPOINT_KEYS = Object.keys(BREAKPOINTS); // ['bp1','bp2','bp3','bp4']
18
+ export const BASE_BP = "bp1";
19
+ // Position / layout keys that are breakpoint-specific. The builder does NOT copy these
20
+ // when syncing one breakpoint onto another (placement differs per device). We keep them
21
+ // in whichever breakpoint they were authored, and copy only the non-async keys across.
22
+ export const STYLE_ASYNC = ["top", "left", "right", "bottom", "width", "height", "zIndex", "position", "fontSize"];
23
+ export const CONFIG_ASYNC = [
24
+ "constraintX", "constraintY", "leftUnit", "rightUnit", "relLeft", "relRight", "absRight",
25
+ "relWidth", "widthUnit", "topUnit", "bottomUnit", "relTop", "relBottom", "absBottom", "absLeftCenterX",
26
+ "relLeftCenterX", "leftCenterXUnit", "absRightCenterX", "relRightCenterX", "rightCenterXUnit", "topCenterYUnit",
27
+ "absTopCenterY", "relTopCenterY", "bottomCenterYUnit", "absBottomCenterY", "relBottomCenterY", "heightUnit",
28
+ "relHeight", "vhHeight", "columnStart", "columnEnd", "rowStart", "rowEnd", "isHidden", "columns", "rows", "grid",
29
+ "is_use_width_outer_parent", "area", "lockCellGrid", "slideWidth", "slideWidthUnit", "relSlideWidth",
30
+ "posts_per_row", "is_pin_video", "sizeThumbnail", "layout", "scrollDirection",
31
+ ];
32
+ /**
33
+ * Section "centered content" grid for a given breakpoint width — verbatim port of
34
+ * builderx_spa composable/grid.js:genGridByBp. A section is a 3-column grid: a flexible
35
+ * margin on each side and the page content in the centre column (max 1300px on desktop).
36
+ * `rows` here is a single placeholder row; callers override `rows`/`grid` for the real
37
+ * number of stacked children.
38
+ */
39
+ export function genGridByBp(bp) {
40
+ const rows = [{ unit: "min/max", min: { unit: "px", absValue: 600 }, max: { unit: "max-c" } }];
41
+ if (bp >= 1320) {
42
+ return {
43
+ grid: "3x1",
44
+ columns: [{ unit: "fr", value: 1 }, { unit: "px", absValue: 1300, value: 1 }, { unit: "fr", value: 1 }],
45
+ rows,
46
+ loaded: true,
47
+ };
48
+ }
49
+ else if (bp >= 993) {
50
+ return {
51
+ grid: "3x1",
52
+ columns: [{ unit: "px", absValue: 10 }, { unit: "fr", value: 1 }, { unit: "px", absValue: 10 }],
53
+ rows,
54
+ loaded: true,
55
+ };
56
+ }
57
+ else {
58
+ return {
59
+ grid: "3x1",
60
+ columns: [{ unit: "px", absValue: 5 }, { unit: "fr", value: 1 }, { unit: "px", absValue: 5 }],
61
+ rows,
62
+ loaded: true,
63
+ };
64
+ }
65
+ }
66
+ /** The centre (content) column index in a section's 3-column grid (1-based grid lines). */
67
+ export const SECTION_CONTENT_COL_START = 2;
68
+ export const SECTION_CONTENT_COL_END = 3;
@@ -27,23 +27,40 @@ fills the correct defaults, then edit specials/style.
27
27
  ## Layout = CSS grid (NOT absolute top/left)
28
28
  This is the key difference from landing-page builders. A section/container positions its
29
29
  children with a grid:
30
- - container \`runtime.config\`: \`grid: "1xN"\`, \`columns: [{unit:'fr',value:1}]\`,
31
- \`rows: [{unit:'min/max', min:{unit:'px',absValue:H}, max:{unit:'max-c'}}, ...]\`.
32
- - each child \`runtime.config\`: \`columnStart/columnEnd\`, \`rowStart/rowEnd\` (1-based grid
33
- lines), \`constraintX\` (['left'|'right'|'centerLeft']), \`constraintY\` (['top'|'bottom'|'centerTop']).
34
- new_section does this for you: pass children and they are stacked one row each. To build
35
- multi-column layouts, nest a container child and give it its own grid.
30
+ - A SECTION uses a centred 3-column grid: \`grid: "3xN"\`, columns
31
+ \`[{unit:'fr',value:1}, {unit:'px',absValue:1300,value:1}, {unit:'fr',value:1}]\`
32
+ flexible margin · 1300px content · flexible margin. Children sit in the CENTRE column
33
+ (\`columnStart:2, columnEnd:3\`). \`rows\` = one \`{unit:'min/max', min:{unit:'px',absValue:H}, max:{unit:'max-c'}}\` per child.
34
+ - A nested CONTAINER uses a simple \`grid: "1xN"\` with \`columns:[{unit:'fr',value:1}]\`;
35
+ its children sit in \`columnStart:1, columnEnd:2\`.
36
+ - each child config also has \`rowStart/rowEnd\` (1-based grid lines),
37
+ \`constraintX\` (['left'|'right'|'centerLeft']), \`constraintY\` (['top'|'bottom'|'centerTop']).
38
+ new_section does ALL of this for you: pass children and they are stacked one row each in
39
+ the centre column. To build multi-column layouts, nest a container child with its own grid.
40
+
41
+ ## Where layout/style live: per-breakpoint keys (NOT \`runtime\`)
42
+ new_section / new_element emit a temporary \`runtime: { style, config }\`. That is a
43
+ STAGING shape — the storefront does NOT read \`runtime\`. On save, build_page / add_section
44
+ automatically expand \`runtime\` into the four breakpoint keys the renderer actually reads:
45
+ \`node.bp1\`, \`node.bp2\`, \`node.bp3\`, \`node.bp4\` (each \`{ style, config }\`). You normally
46
+ never write these by hand for new pages. When EDITING an existing page, elements are
47
+ already in this shape — see the \`responsive\` field on get_page_element/update_page_element.
36
48
 
37
49
  ## Styling
38
50
  - \`runtime.style\` holds CSS-ish props: width/height (numbers = px), color, background,
39
51
  fontSize ("16px"), fontWeight, textAlign, border*, boxShadow, etc.
40
52
  - \`runtime.config.heightUnit\`: "auto" lets content set height (default for text/image).
41
- - Colours as hex or rgba(). Use the site theme colours where possible.
53
+ - Colours: prefer the site THEME variables \`var(--color_00)\`, \`var(--color_01)\`, …
54
+ (the published site themes them); plain hex or rgba() also work.
42
55
 
43
56
  ## Responsive breakpoints
44
- Override style/config per breakpoint by adding a key on the node: \`bp1\`, \`tablet\`,
45
- \`laptop\` \`{ style: {...}, config: {...} }\`. Desktop values live in \`runtime\`.
46
- Breakpoint widths: large_desktop 1920, desktop 1280, laptop 992, tablet 640.
57
+ The four breakpoints (largest smallest), keyed bp1..bp4, are:
58
+ - \`bp1\` ≥1320px (desktop, the base) · \`bp2\` 993–1319 (laptop) · \`bp3\` 641–992 (tablet) · \`bp4\` 320–640 (mobile).
59
+ For NEW pages you author once in \`runtime\` (desktop) and build_page copies it to all four
60
+ breakpoints automatically — the page renders identically across devices. To make a node
61
+ look DIFFERENT on a smaller screen, set that breakpoint's key explicitly, e.g.
62
+ \`node.bp4 = { style: { fontSize: "20px" }, config: {...} }\`. (There is no \`tablet\`/\`laptop\`
63
+ key — only bp1..bp4.)
47
64
 
48
65
  ## Content & data
49
66
  - Text: \`specials.text\` (HTML allowed), \`specials.tag\` ("h1".."p").
@@ -51,8 +68,31 @@ Breakpoint widths: large_desktop 1920, desktop 1280, laptop 992, tablet 640.
51
68
  - Form: wrap inputs in a \`form\`; set \`form.specials.type\`
52
69
  (form_order | form_login | form_signup | form_discount | order_tracking). Each input
53
70
  needs \`specials.field_name\`.
54
- - Dataset elements (text-dataset, image-dataset, grid-product...) use \`bindings\` to pull
55
- product/category/blog data leave bindings to dataset-driven pages.
71
+ - Dataset elements (text-dataset, image-dataset, rectangle-dataset...) pull live data via
72
+ a \`bindings\` array. Each binding is \`{ id:"BINDING"+random, name:<source>, target:"<source>::<field>" }\`.
73
+ Real target field names (use these EXACTLY — there is no \`product::price\`):
74
+ - product: \`product::product_image\`, \`product::product_name\`, \`product::product_price\`
75
+ - cart_item: \`cart_item::cart_item_image\`, \`cart_item::cart_item_name\`, \`cart_item::cart_item_price\`, \`cart_item::cart_item_total_price\`, \`cart_item::cart_item_prod_attr\`
76
+ - order_item: \`order_item::product_image\`, \`order_item::product_name\`, \`order_item::product_quantity\`, \`order_item::items_sum_up_price\`, \`order_item::product_attrs\`
77
+ - customer_address: \`customer_address::full_name\`, \`customer_address::phone_number\`, \`customer_address::address\`, \`customer_address::pdc\`
78
+ A target only resolves on a page of the matching \`type\` (see below).
79
+
80
+ ## Page types & data sources (IMPORTANT for special pages)
81
+ A page's \`type\` decides which live data it can bind to. A SPECIAL page only works if the
82
+ matching site data-source flag (on site.settings) is enabled — otherwise the page renders
83
+ but every product/customer/blog binding resolves to NULL (an empty, broken-looking page).
84
+ \`build_page\` enables the right flag for you when you pass \`type\`:
85
+ - \`main\` — homepage / normal content. No flag needed.
86
+ - \`store\` — product detail, category, cart, checkout, thank-you. Needs \`use_store\`.
87
+ Bindings: \`product::product_*\`, \`cart_item::cart_item_*\`.
88
+ - \`member\` — login, register, profile, order history. Needs \`use_member\`.
89
+ Bindings: \`customer_address::*\`, \`order_item::*\`.
90
+ - \`blog\` — blog list, article/post. Needs \`use_blog\`.
91
+ - \`error\` / \`maintain\` — 404 / maintenance. Need \`use_error\` / \`use_maintain\`.
92
+ - \`custom\` — a free page with no special data. No flag needed.
93
+ Rule of thumb: if the page shows products, a cart, customer/order data, or blog posts,
94
+ set \`type\` accordingly so the binding source is turned on. A binding target like
95
+ \`product::product_price\` REQUIRES its page to be the matching type.
56
96
 
57
97
  ## Workflow (do this every time)
58
98
  1. Intake: confirm goal, brand, colours, sections wanted (ask 3-5 questions if unclear).
@@ -6,6 +6,8 @@
6
6
  // produce that structure the same way the builder does, so generated pages render.
7
7
  import { buildElement, isKnownType, ELEMENT_TYPES } from "./catalog.js";
8
8
  import { randomString } from "./factory.js";
9
+ import { BREAKPOINTS, genGridByBp, SECTION_CONTENT_COL_START, SECTION_CONTENT_COL_END, } from "./grid.js";
10
+ const clone = (o) => structuredClone(o);
9
11
  /** Walk every node in a source tree (depth-first). Return false from fn to stop. */
10
12
  export function walk(source, fn) {
11
13
  const sections = source && Array.isArray(source.sections) ? source.sections : [];
@@ -42,10 +44,15 @@ export function reassignIds(node) {
42
44
  return node;
43
45
  }
44
46
  /**
45
- * Lay children out vertically inside a section/container using a single-column grid
46
- * the same shape the builder emits. `children` are placed top-to-bottom, one grid row each.
47
+ * Lay children out vertically inside a section/container one grid row per child,
48
+ * top-to-bottom — the same shape the builder emits. Values are written to `runtime`;
49
+ * finalizeForRender() later expands `runtime` into the per-breakpoint keys the
50
+ * storefront actually reads (bp1..bp4).
47
51
  */
48
- export function stackChildren(container, children) {
52
+ export function stackChildren(container, children, opts = {}) {
53
+ const gridCols = opts.gridCols || 1;
54
+ const colStart = opts.contentColStart || 1;
55
+ const colEnd = opts.contentColEnd || 2;
49
56
  const rows = children.map((child) => {
50
57
  const h = (child.runtime && child.runtime.style && child.runtime.style.height) || 50;
51
58
  return { unit: "min/max", min: { unit: "px", absValue: h }, max: { unit: "max-c" } };
@@ -53,8 +60,8 @@ export function stackChildren(container, children) {
53
60
  container.runtime = container.runtime || {};
54
61
  container.runtime.config = {
55
62
  ...(container.runtime.config || {}),
56
- grid: `1x${children.length || 1}`,
57
- columns: [{ unit: "fr", value: 1 }],
63
+ grid: `${gridCols}x${children.length || 1}`,
64
+ columns: opts.columns || [{ unit: "fr", value: 1 }],
58
65
  rows: rows.length ? rows : [{ unit: "min/max", min: { unit: "px", absValue: 50 }, max: { unit: "max-c" } }],
59
66
  heightUnit: "auto",
60
67
  };
@@ -62,8 +69,8 @@ export function stackChildren(container, children) {
62
69
  child.runtime = child.runtime || {};
63
70
  child.runtime.config = {
64
71
  ...(child.runtime.config || {}),
65
- columnStart: 1,
66
- columnEnd: 2,
72
+ columnStart: colStart,
73
+ columnEnd: colEnd,
67
74
  rowStart: i + 1,
68
75
  rowEnd: i + 2,
69
76
  constraintX: (child.runtime.config && child.runtime.config.constraintX) || ["centerLeft"],
@@ -77,11 +84,19 @@ export function stackChildren(container, children) {
77
84
  /**
78
85
  * Build a ready-to-place section from a list of child specs.
79
86
  * Each spec: { type, opts?, children? } where children is a nested array of specs.
87
+ * A section uses the builder's centred 3-column grid (margin · content · margin); the
88
+ * children live in the centre content column. finalizeForRender() sets the correct
89
+ * per-breakpoint column widths via genGridByBp.
80
90
  */
81
91
  export function buildSection(childSpecs = [], sectionOpts = {}) {
82
92
  const section = buildElement("section", sectionOpts);
83
93
  const children = childSpecs.map((spec) => buildFromSpec(spec));
84
- stackChildren(section, children);
94
+ stackChildren(section, children, {
95
+ gridCols: 3,
96
+ columns: genGridByBp(BREAKPOINTS.bp1[0]).columns,
97
+ contentColStart: SECTION_CONTENT_COL_START,
98
+ contentColEnd: SECTION_CONTENT_COL_END,
99
+ });
85
100
  return section;
86
101
  }
87
102
  function buildFromSpec(spec) {
@@ -146,4 +161,49 @@ export function validatePage(source) {
146
161
  stats: { sections: source.sections.length, total_elements: total, element_types: typeCounts },
147
162
  };
148
163
  }
164
+ /**
165
+ * Expand one node's `runtime.{style,config}` into the per-breakpoint keys the storefront
166
+ * renderer reads (bp1..bp4). Mirrors builderx_spa's syncBreakpoint: the authored
167
+ * (desktop) values are copied onto every breakpoint. Sections additionally get their
168
+ * centred 3-column grid recomputed per breakpoint via genGridByBp (the side-margin
169
+ * widths shrink on smaller screens). Nodes already in breakpoint shape are left as-is,
170
+ * so this is safe to run over a mixed source (e.g. add_section onto an existing page).
171
+ */
172
+ function expandNodeToBreakpoints(node) {
173
+ const rt = node && node.runtime;
174
+ if (rt && (rt.style || rt.config)) {
175
+ const baseStyle = rt.style || {};
176
+ const baseConfig = { ...(rt.config || {}), loaded: true };
177
+ const isSection = node.type === "section";
178
+ for (const [bp, [minW]] of Object.entries(BREAKPOINTS)) {
179
+ const style = clone(baseStyle);
180
+ const config = clone(baseConfig);
181
+ if (isSection) {
182
+ const g = genGridByBp(minW);
183
+ const sectionRows = baseConfig.rows && baseConfig.rows.length ? clone(baseConfig.rows) : clone(g.rows);
184
+ config.columns = clone(g.columns);
185
+ config.rows = sectionRows;
186
+ config.grid = `3x${sectionRows.length}`;
187
+ config.heightUnit = config.heightUnit || "auto";
188
+ }
189
+ node[bp] = { style, config };
190
+ }
191
+ delete node.runtime;
192
+ }
193
+ for (const child of node.children || [])
194
+ expandNodeToBreakpoints(child);
195
+ return node;
196
+ }
197
+ /**
198
+ * Convert a freshly-built page source (whose nodes carry `runtime`) into the shape the
199
+ * storefront actually renders: every node gets bp1..bp4 `{style,config}` and `runtime`
200
+ * is removed. MUST be called before saving a page built with new_section/new_element —
201
+ * otherwise the page renders with no styling or grid placement.
202
+ */
203
+ export function finalizeForRender(source) {
204
+ const sections = source && Array.isArray(source.sections) ? source.sections : [];
205
+ for (const s of sections)
206
+ expandNodeToBreakpoints(s);
207
+ return source;
208
+ }
149
209
  export { ELEMENT_TYPES };
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.3.0",
4
+ "d": "23/06/2026",
5
+ "type": "Added",
6
+ "en": "New create_site tool creates a brand-new storefront site for the current account (seeded with sample products, categories, and a blog), optionally…",
7
+ "vi": "Tool mới create_site tạo một site storefront hoàn toàn mới cho tài khoản hiện tại (kèm sản phẩm, danh mục và blog mẫu), tự động chuyển sang site vừa…"
8
+ },
9
+ {
10
+ "v": "1.2.0",
11
+ "d": "23/06/2026",
12
+ "type": "Added",
13
+ "en": "upload_images replaces upload_image with batch support (1–20 sources per call), parallel uploads, and a dry_run mode that previews what would be…",
14
+ "vi": "upload_images thay thế upload_image với khả năng xử lý hàng loạt (1–20 nguồn mỗi lần gọi), tải song song, và chế độ dry_run cho phép xem trước những…"
15
+ },
2
16
  {
3
17
  "v": "1.1.4",
4
18
  "d": "23/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "The serve command's OAuth token store now optionally uses Postgres (via DATABASE_URL) for durable persistence across restarts and shared state…",
28
42
  "vi": "Kho lưu trữ token OAuth của lệnh serve nay hỗ trợ tùy chọn sử dụng Postgres (qua DATABASE_URL) để lưu token bền vững qua các lần khởi động lại và…"
29
- },
30
- {
31
- "v": "1.1.0",
32
- "d": "23/06/2026",
33
- "type": "Added",
34
- "en": "The serve (remote Streamable-HTTP) mode now embeds a full OAuth 2.1 Authorization Server at /authorize, /token, /revoke, /register, and…",
35
- "vi": "Chế độ serve (remote Streamable-HTTP) nay tích hợp sẵn một Authorization Server OAuth 2.1 đầy đủ tại các endpoint /authorize, /token, /revoke,…"
36
- },
37
- {
38
- "v": "1.0.3",
39
- "d": "23/06/2026",
40
- "type": "Added",
41
- "en": "MIT license (Copyright vuluu2k) added to the package.",
42
- "vi": "Thêm giấy phép MIT (Copyright vuluu2k) vào package."
43
43
  }
44
44
  ]
package/dist/db.js CHANGED
@@ -1,8 +1,8 @@
1
- // Tiny JSON-file persistence (no native deps) for: (1) the saved connection config
2
- // (token / session / site / api_url / confirm_mode) and (2) the image-alt cache.
1
+ // Tiny JSON-file persistence (no native deps) for the saved connection config
2
+ // (token / session / site / api_url / confirm_mode).
3
3
  //
4
4
  // Stored under a stable home dir so it survives `npx` (ephemeral package cache) and
5
- // container restarts. Two flat JSON files instead of SQLite — keeps the package light
5
+ // container restarts. A flat JSON file instead of SQLite — keeps the package light
6
6
  // and works in any runtime (Alpine, Docker `--ignore-scripts`, serverless) with no
7
7
  // native binding to build. The API is synchronous to match the call sites.
8
8
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -11,7 +11,6 @@ import { join } from "node:path";
11
11
  const CONFIG_DIR = process.env.WEBCAKE_CONFIG_DIR || join(homedir(), ".webcake-storefront-mcp");
12
12
  mkdirSync(CONFIG_DIR, { recursive: true });
13
13
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
14
- const ALT_FILE = join(CONFIG_DIR, "image-alt-cache.json");
15
14
  function readJson(file, fallback) {
16
15
  try {
17
16
  return JSON.parse(readFileSync(file, "utf-8"));
@@ -44,40 +43,3 @@ export function delConfig(key) {
44
43
  export function getAllConfig() {
45
44
  return { ...config };
46
45
  }
47
- const altCache = readJson(ALT_FILE, {});
48
- export function getImageAlt(urlKey) {
49
- return altCache[urlKey] || null;
50
- }
51
- export function getImageAlts(urlKeys) {
52
- const out = new Map();
53
- for (const k of urlKeys) {
54
- const row = altCache[k];
55
- if (row)
56
- out.set(k, row);
57
- }
58
- return out;
59
- }
60
- export function setImageAlt({ url_key, url, alt, source = "ai" }) {
61
- altCache[url_key] = { url_key, url, alt, source, updated_at: Date.now() };
62
- writeJson(ALT_FILE, altCache);
63
- }
64
- export function setImageAlts(items) {
65
- for (const it of items) {
66
- altCache[it.url_key] = {
67
- url_key: it.url_key,
68
- url: it.url,
69
- alt: it.alt,
70
- source: it.source || "ai",
71
- updated_at: Date.now(),
72
- };
73
- }
74
- writeJson(ALT_FILE, altCache);
75
- }
76
- export function listImageAlts(limit = 100, offset = 0) {
77
- return Object.values(altCache)
78
- .sort((a, b) => b.updated_at - a.updated_at)
79
- .slice(offset, offset + limit);
80
- }
81
- export function countImageAlts() {
82
- return Object.keys(altCache).length;
83
- }
package/dist/http.js CHANGED
@@ -385,7 +385,7 @@ export async function startHttpServer(port) {
385
385
  transports.delete(transport.sessionId);
386
386
  };
387
387
  const api = apiFromRequest(req);
388
- const server = createServer(api);
388
+ const server = createServer(api, { allowLocalFiles: false }); // remote: never read server-side files
389
389
  await server.connect(transport);
390
390
  await transport.handleRequest(req, res, body);
391
391
  return;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ async function main() {
58
58
  // no env is required to start — auth (token + session) is set via env or `login`,
59
59
  // and the site is chosen at runtime with switch_site.
60
60
  const api = makeApi();
61
- const server = createServer(api);
61
+ const server = createServer(api, { allowLocalFiles: true }); // stdio = user's own machine
62
62
  const transport = new StdioServerTransport();
63
63
  await server.connect(transport);
64
64
  console.error("[webcake-storefront] MCP server ready on stdio.");
package/dist/server.js CHANGED
@@ -23,7 +23,7 @@ IMPORTANT: When the user asks ANY question about their website, store, products,
23
23
  You can also BUILD pages: use get_build_guide, list_elements, get_element to learn the BuilderX component model, new_section/new_element to compose, validate_page to check, then build_page (dry_run first) to create. Publishing is site-level via publish_site.
24
24
 
25
25
  Workflow:
26
- 1. On first interaction, call get_current_context. The site is NOT set from env — if no site is selected yet, call list_my_sites and ask the user which site to work on, then switch_site (the choice is saved and reused next session).
26
+ 1. On first interaction, call get_current_context. The site is NOT set from env — if no site is selected yet, call list_my_sites and ask the user which site to work on, then switch_site (the choice is saved and reused next session). To start from scratch, create_site makes a new site and switches to it; then build a homepage with build_page (type:'main', is_homepage:true).
27
27
  2. Before answering a site-specific question, query the relevant tool.
28
28
  3. When building a page, read get_build_guide first and validate before saving.
29
29
  4. Always reply in the user's language; keep Vietnamese with full diacritics.`;
@@ -31,7 +31,7 @@ function makeResult(data) {
31
31
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
32
32
  }
33
33
  /** Build a fully-wired MCP server bound to the given API client. */
34
- export function createServer(api) {
34
+ export function createServer(api, opts = {}) {
35
35
  const server = new McpServer({ name: "webcake-storefront", version: "1.0.0" }, { instructions: INSTRUCTIONS });
36
36
  const handle = async (fn) => {
37
37
  try {
@@ -58,6 +58,6 @@ export function createServer(api) {
58
58
  registerGlobalSourceTools(server, api, handle);
59
59
  registerImageTools(server, api, handle);
60
60
  registerBuilderTools(server, api, handle);
61
- registerBuilderExtraTools(server, api, handle);
61
+ registerBuilderExtraTools(server, api, handle, { allowLocalFiles: opts.allowLocalFiles === true });
62
62
  return server;
63
63
  }
package/dist/smoke.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * building blocks so a release can verify them without a client. Run: npm run smoke
4
4
  */
5
5
  import { listElements, getElement, buildElement, ELEMENT_TYPES, isKnownType } from "./builder/catalog.js";
6
- import { newPageSkeleton, buildSection, validatePage, reassignIds, walk, } from "./builder/page.js";
6
+ import { newPageSkeleton, buildSection, validatePage, finalizeForRender, reassignIds, walk, } from "./builder/page.js";
7
7
  let failures = 0;
8
8
  const check = (name, cond, extra) => {
9
9
  if (cond) {
@@ -49,13 +49,22 @@ console.log("== page: grid composition + validation ==");
49
49
  { type: "text", opts: { text: "Welcome" } },
50
50
  { type: "button", opts: { text: "Buy" } },
51
51
  ]);
52
- check("section grid is 1xN", hero.runtime.config.grid === "1x2", hero.runtime.config.grid);
53
- check("children get grid positions", hero.children.every((c) => c.runtime.config.columnStart === 1));
52
+ // A section uses the builder's centred 3-column grid; children sit in the centre column.
53
+ check("section grid is 3xN", hero.runtime.config.grid === "3x2", hero.runtime.config.grid);
54
+ check("children placed in centre column", hero.children.every((c) => c.runtime.config.columnStart === 2));
54
55
  const src = newPageSkeleton();
55
56
  src.sections.push(hero);
56
57
  const v = validatePage(src);
57
58
  check("built page validates", v.valid === true, v.errors);
58
59
  check("stats count elements", v.stats.total_elements === 3, v.stats);
60
+ // finalizeForRender must convert runtime -> bp1..bp4 (the shape the storefront renders).
61
+ finalizeForRender(src);
62
+ const sec0 = src.sections[0];
63
+ check("finalize removes runtime", !("runtime" in sec0), Object.keys(sec0));
64
+ check("finalize adds bp1..bp4", ["bp1", "bp2", "bp3", "bp4"].every((bp) => sec0[bp]?.config), Object.keys(sec0));
65
+ check("section bp4 is mobile grid", sec0.bp4.config.grid === "3x2" && sec0.bp4.config.columns[0].absValue === 5, sec0.bp4.config.columns?.[0]);
66
+ check("child bp1 keeps centre column", sec0.children[0].bp1.config.columnStart === 2, sec0.children[0].bp1?.config);
67
+ check("finalize is idempotent", (finalizeForRender(src), !("runtime" in sec0)));
59
68
  // duplicate ids must fail validation
60
69
  const dup = newPageSkeleton();
61
70
  const a = buildElement("section");
@@ -1,7 +1,41 @@
1
1
  import { z } from "zod";
2
2
  import { resolvePreviewUrl } from "../config.js";
3
3
  import { parse as parseHtml } from "node-html-parser";
4
+ import { stat, readFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
4
8
  const ALLOWED_IMG = /^image\/(jpe?g|png|webp)$/;
9
+ const LOCAL_MAX = 200 * 1024 * 1024; // 200 MB, matches the backend multipart limit
10
+ /** Is this entry a LOCAL filesystem path (vs an http(s) URL or data: URI)? */
11
+ function isLocalPath(s) {
12
+ if (s.startsWith("data:") || /^https?:\/\//i.test(s))
13
+ return false;
14
+ return s.startsWith("file://") || s.startsWith("/") || s.startsWith("~") || /^[a-zA-Z]:[\\/]/.test(s);
15
+ }
16
+ /** Resolve ~ and file:// to an absolute path. */
17
+ function resolveLocalPath(s) {
18
+ if (s.startsWith("file://"))
19
+ return fileURLToPath(s);
20
+ if (s.startsWith("~"))
21
+ return join(homedir(), s.slice(1));
22
+ return s;
23
+ }
24
+ /** Read a local image file into a buffer (with a size cap) + guess its content type. */
25
+ async function readLocalImage(s) {
26
+ const p = resolveLocalPath(s);
27
+ const st = await stat(p);
28
+ if (st.size > LOCAL_MAX)
29
+ throw new Error(`File too large (${st.size} bytes, max ${LOCAL_MAX}).`);
30
+ const buf = await readFile(p);
31
+ const ext = (p.split(".").pop() || "").toLowerCase();
32
+ const contentType = ext === "png" ? "image/png"
33
+ : ext === "webp" ? "image/webp"
34
+ : ext === "gif" ? "image/gif"
35
+ : ext === "jpg" || ext === "jpeg" ? "image/jpeg"
36
+ : "application/octet-stream";
37
+ return { buf, contentType };
38
+ }
5
39
  /** Fetch a URL into a Buffer with a size cap. */
6
40
  async function fetchBuffer(url, maxBytes = 15 * 1024 * 1024) {
7
41
  const res = await fetch(url, { redirect: "follow" });
@@ -21,7 +55,7 @@ async function toAllowedImage(buf, contentType) {
21
55
  const out = await sharp(buf).jpeg({ quality: 85 }).toBuffer();
22
56
  return { buf: out, contentType: "image/jpeg" };
23
57
  }
24
- export function registerBuilderExtraTools(server, api, handle) {
58
+ export function registerBuilderExtraTools(server, api, handle, opts = {}) {
25
59
  // ── Stock images (Pexels) ──────────────────────────────────────────────────
26
60
  server.tool("search_images", `Search stock photos (Pexels) to use on a page. Returns hosted image URLs you can put straight into an image element's runtime.config.src.
27
61
  Requires the PEXELS_API_KEY environment variable.`, {
@@ -52,29 +86,78 @@ Requires the PEXELS_API_KEY environment variable.`, {
52
86
  }));
53
87
  return { query, total_results: json.total_results, photos };
54
88
  }));
55
- // ── Upload an image to the site CDN ─────────────────────────────────────────
56
- server.tool("upload_image", `Upload an image to the site's CDN and get back a hosted URL. Accepts an http(s) URL or a data:image/...;base64 data URI. Non jpeg/png/webp inputs are converted to JPEG.
57
- Use this for the user's own images; stock photos from search_images are already hosted and don't need uploading.`, {
58
- url: z.string().describe("http(s) URL or data:image/...;base64,... data URI"),
59
- }, ({ url }) => handle(async () => {
60
- let buf, contentType;
61
- if (url.startsWith("data:")) {
62
- const m = url.match(/^data:([^;]+);base64,(.*)$/s);
63
- if (!m)
64
- return { error: "Malformed data URI." };
65
- contentType = m[1];
66
- buf = Buffer.from(m[2], "base64");
67
- }
68
- else {
69
- ({ buf, contentType } = await fetchBuffer(url));
89
+ // ── Upload images to the site CDN ───────────────────────────────────────────
90
+ server.tool("upload_images", `Convert external image URLs, data: URIs, or LOCAL FILE PATHS into site-hosted CDN URLs by reading/downloading each image and re-uploading it to the WebCake backend. Use this whenever the user supplies their OWN images (their URLs or files from their machine), or a page is built from a reference HTML/URL. The returned hosted URLs go straight into an image element's specials.src / runtime.config.src — same as search_images results. Stock photos from search_images are already hosted and don't need uploading.
91
+ Processes up to 20 entries per call in parallel; non jpeg/png/webp inputs are converted to JPEG. UPLOADS BY DEFAULT (dry_run defaults to FALSE — this touches no account data): returns an "images" map (original source hosted URL). Pass dry_run:true to only preview the entries that WOULD be processed (local paths report whether the file exists + its size) without any network/filesystem upload. Local file paths are only permitted when the MCP server runs locally (stdio); on the remote HTTP transport they are rejected per-entry.`, {
92
+ urls: z
93
+ .array(z.string())
94
+ .min(1)
95
+ .max(20)
96
+ .describe("Image sources 1–20 per call. Accepts: http(s) URLs, data:image/...;base64,... URIs, or local file paths (absolute /path, ~/path, file:// — stdio mode only)."),
97
+ dry_run: z
98
+ .boolean()
99
+ .default(false)
100
+ .describe("Default FALSE — actually reads/downloads and uploads, returning hosted URLs. Set true to only preview what would be processed."),
101
+ }, ({ urls, dry_run }) => handle(async () => {
102
+ const deduped = [...new Set(urls)];
103
+ const localAllowed = opts.allowLocalFiles === true;
104
+ if (dry_run) {
105
+ const entries = await Promise.all(deduped.map(async (entry) => {
106
+ if (entry.startsWith("data:"))
107
+ return { entry, kind: "data-uri" };
108
+ if (isLocalPath(entry)) {
109
+ if (!localAllowed)
110
+ return { entry, kind: "local", error: "Local file paths are only supported in stdio mode." };
111
+ try {
112
+ const st = await stat(resolveLocalPath(entry));
113
+ return { entry, kind: "local", exists: true, size: st.size };
114
+ }
115
+ catch (e) {
116
+ return { entry, kind: "local", exists: false, error: e?.message ?? String(e) };
117
+ }
118
+ }
119
+ return { entry, kind: "url" };
120
+ }));
121
+ return { dry_run: true, count: deduped.length, entries };
70
122
  }
71
- const norm = await toAllowedImage(buf, contentType);
72
- const dataUri = `data:${norm.contentType};base64,${norm.buf.toString("base64")}`;
73
- const res = await api.uploadImageBase64({ base64: dataUri, content_type: norm.contentType });
74
- const hosted = (res && res.data) || (res && res.url) || null;
75
- if (!hosted)
76
- return { error: "Upload returned no URL.", raw: res };
77
- return { success: true, url: hosted, content_type: norm.contentType };
123
+ const images = {};
124
+ const errors = [];
125
+ await Promise.all(deduped.map(async (entry) => {
126
+ try {
127
+ let buf, contentType;
128
+ if (entry.startsWith("data:")) {
129
+ const m = entry.match(/^data:([^;]+);base64,(.*)$/s);
130
+ if (!m)
131
+ throw new Error("Malformed data URI.");
132
+ contentType = m[1];
133
+ buf = Buffer.from(m[2], "base64");
134
+ }
135
+ else if (isLocalPath(entry)) {
136
+ if (!localAllowed)
137
+ throw new Error("Local file paths are only supported when the server runs locally (stdio). Send a public URL or data: URI instead.");
138
+ ({ buf, contentType } = await readLocalImage(entry));
139
+ }
140
+ else {
141
+ ({ buf, contentType } = await fetchBuffer(entry));
142
+ }
143
+ const norm = await toAllowedImage(buf, contentType);
144
+ const dataUri = `data:${norm.contentType};base64,${norm.buf.toString("base64")}`;
145
+ const res = await api.uploadImageBase64({ base64: dataUri, content_type: norm.contentType });
146
+ const hosted = (res && res.data) || (res && res.url) || null;
147
+ if (!hosted)
148
+ throw new Error("Upload returned no URL.");
149
+ images[entry] = hosted;
150
+ }
151
+ catch (e) {
152
+ errors.push({ url: entry, error: e?.message ?? String(e) });
153
+ }
154
+ }));
155
+ return {
156
+ uploaded: Object.keys(images).length,
157
+ failed: errors.length,
158
+ images,
159
+ ...(errors.length ? { errors } : {}),
160
+ };
78
161
  }));
79
162
  // ── Publish the site ────────────────────────────────────────────────────────
80
163
  server.tool("publish_site", `Publish the whole site live — snapshots all current page sources into the live (published) version.
@@ -143,7 +226,7 @@ Two-step safety: dry_run=true (default) describes what will happen; dry_run=fals
143
226
  images,
144
227
  buttons,
145
228
  palette: [...colors].slice(0, 24),
146
- hint: "Rebuild this as BuilderX sections: map each heading group + its text/image/button into a new_section call. Generate fresh copy where useful; re-host external images with upload_image if you want them on the site CDN. This is a structural blueprint, not a 1:1 clone.",
229
+ hint: "Rebuild this as BuilderX sections: map each heading group + its text/image/button into a new_section call. Generate fresh copy where useful; re-host external images with upload_images if you want them on the site CDN. This is a structural blueprint, not a 1:1 clone.",
147
230
  };
148
231
  }
149
232
  server.tool("ingest_html", "Parse reference HTML into a structural blueprint (title, headings, paragraphs, images, buttons, colour palette) you can rebuild as BuilderX sections with new_section. Not a 1:1 clone.", {
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { BUILD_GUIDE } from "../builder/guide.js";
3
3
  import { listElements, getElement, buildElement } from "../builder/catalog.js";
4
- import { buildSection, newPageSkeleton, validatePage, reassignIds, } from "../builder/page.js";
4
+ import { buildSection, newPageSkeleton, validatePage, finalizeForRender, reassignIds, } from "../builder/page.js";
5
5
  // Recursive spec for new_section / build_page children.
6
6
  const elementSpec = z.object({
7
7
  type: z.string().describe("Element type (see list_elements)"),
@@ -16,6 +16,18 @@ function parseSource(src) {
16
16
  function newPageId(res) {
17
17
  return (res && res.data && res.data.id) || (res && res.id) || null;
18
18
  }
19
+ // BuilderX page kinds. The numeric `type` is what the backend stores (PAGE_TYPE in
20
+ // builderx_spa); SPECIAL kinds also require a site-level data-source flag enabled on
21
+ // site.settings, otherwise components that bind to store/customer/blog data render
22
+ // with null bindings. build_page sets both for you.
23
+ const PAGE_TYPE_NUM = {
24
+ main: 1, store: 2, member: 3, blog: 4, custom: 5, error: 6, maintain: 7,
25
+ };
26
+ const PAGE_TYPE_FLAG = {
27
+ store: "use_store", member: "use_member", blog: "use_blog",
28
+ error: "use_error", maintain: "use_maintain",
29
+ };
30
+ const PAGE_KINDS = ["main", "store", "member", "blog", "custom", "error", "maintain"];
19
31
  export function registerBuilderTools(server, api, handle) {
20
32
  server.tool("get_build_guide", "Get the BuilderX page authoring guide: page shape, the grid layout model, styling, breakpoints, forms/data, and the build workflow. Read this before building or heavily editing a page.", {}, () => handle(async () => ({ guide: BUILD_GUIDE })));
21
33
  server.tool("list_elements", "List all BuilderX element/component types you can place on a page, grouped by category with a one-line summary and whether each is a container.", {}, () => handle(async () => listElements()));
@@ -45,36 +57,60 @@ The source must be { sections: [...] } — build sections with new_section. Vali
45
57
  name: z.string().describe("Page name"),
46
58
  slug: z.string().describe("URL slug, e.g. '/landing' or '/about'"),
47
59
  source: z.any().describe("Full page source { sections: [...] } (object or JSON string)"),
48
- type: z.string().optional().describe("Page type (optional)"),
60
+ type: z
61
+ .enum(PAGE_KINDS)
62
+ .optional()
63
+ .describe("Page kind. SPECIAL pages need a site data-source enabled — build_page does this automatically: store→use_store (product/cart bindings), member→use_member (customer/order bindings), blog→use_blog, error→use_error, maintain→use_maintain. 'main'/'custom' need nothing. Omit for a normal content page (defaults to 'main' for the homepage)."),
49
64
  is_homepage: z.boolean().default(false).describe("Set as the site homepage"),
50
65
  dry_run: z.boolean().default(true).describe("Preview+validate only (true) or create+save (false)"),
51
66
  }, ({ name, slug, source, type, is_homepage, dry_run }) => handle(async () => {
52
67
  const parsed = parseSource(source);
53
68
  const validation = validatePage(parsed);
69
+ // Resolve numeric page type + the site data-source flag a special page needs.
70
+ const kind = type || (is_homepage ? "main" : undefined);
71
+ const typeNum = kind ? PAGE_TYPE_NUM[kind] : undefined;
72
+ const requiredFlag = kind ? PAGE_TYPE_FLAG[kind] : undefined;
54
73
  if (dry_run) {
55
74
  return {
56
75
  dry_run: true,
57
76
  validation,
58
- request: { name, slug, type, is_homepage, sections: (parsed && parsed.sections || []).length },
77
+ request: { name, slug, type: kind ?? null, page_type_num: typeNum ?? null, is_homepage, sections: (parsed && parsed.sections || []).length },
78
+ will_enable_feature: requiredFlag ?? null,
79
+ renders_at_breakpoints: ["bp1", "bp2", "bp3", "bp4"],
59
80
  hint: validation.valid
60
- ? "Looks valid. Call again with dry_run=false to create and save the page."
81
+ ? `Looks valid. On save, every node's runtime is expanded into the bp1..bp4 keys the storefront renders. Call again with dry_run=false to create and save the page.${requiredFlag ? ` Will also enable site.settings.${requiredFlag} so its data bindings resolve.` : ""}`
61
82
  : "Fix the errors above before saving.",
62
83
  };
63
84
  }
64
85
  if (!validation.valid) {
65
86
  return { error: "Validation failed — not saving.", validation };
66
87
  }
67
- const created = await api.createPage({ name, slug, type, is_homepage });
88
+ // A special page is useless if its site data-source flag is off (bindings
89
+ // return null). Enable it BEFORE creating the page so the page works on first load.
90
+ let feature = null;
91
+ if (requiredFlag) {
92
+ try {
93
+ feature = await api.enableSiteFeature(requiredFlag);
94
+ }
95
+ catch (e) {
96
+ return { error: `Could not enable site.settings.${requiredFlag} (needed for a '${kind}' page). ${e?.message ?? e}` };
97
+ }
98
+ }
99
+ const created = await api.createPage({ name, slug, ...(typeNum != null ? { type: typeNum } : {}), is_homepage });
68
100
  const pageId = newPageId(created);
69
101
  if (!pageId) {
70
102
  return { error: "Page created but no id was returned; cannot save source.", created };
71
103
  }
104
+ // Expand runtime -> bp1..bp4 so the saved source actually renders on the storefront.
105
+ finalizeForRender(parsed);
72
106
  const saved = await api.updatePageSource(pageId, { source: parsed });
73
107
  return {
74
108
  success: true,
75
109
  page_id: pageId,
76
110
  name,
77
111
  slug,
112
+ page_type: kind ?? null,
113
+ ...(feature ? { data_source: { flag: feature.flag, newly_enabled: feature.changed } } : {}),
78
114
  page_source_id: saved && saved.data && saved.data.id,
79
115
  stats: validation.stats,
80
116
  };
@@ -112,6 +148,9 @@ Two-step safety: dry_run=true (default) previews; dry_run=false saves.`, {
112
148
  }
113
149
  if (!validation.valid)
114
150
  return { error: "Validation failed — not saving.", validation };
151
+ // Expand the newly-added section's runtime -> bp1..bp4 (existing sections are
152
+ // already in breakpoint shape and are left untouched).
153
+ finalizeForRender(source);
115
154
  const saved = await api.updatePageSource(page_id, { source });
116
155
  return {
117
156
  success: true,
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { getConfig, setConfig } from "../db.js";
3
+ import { resolvePreviewUrl } from "../config.js";
3
4
  /** Read all saved credentials from the local config file for startup */
4
5
  export function getSavedConfig() {
5
6
  return {
@@ -62,6 +63,58 @@ export function registerContextTools(server, api, handle) {
62
63
  page,
63
64
  };
64
65
  }));
66
+ server.tool("create_site", `Create a brand-new storefront site for the current account, then (by default) switch to it.
67
+ The backend seeds sample categories, products and a blog, but creates NO pages — so after this,
68
+ build a homepage: get_build_guide → new_section → build_page (type:'main', is_homepage:true).
69
+ Note: free accounts are limited to 4 sites (creation fails with a quota error past that).`, {
70
+ name: z.string().describe("Display name of the new site, e.g. 'My Coffee Shop'"),
71
+ slug: z
72
+ .string()
73
+ .describe("URL-safe site slug (lowercase letters, digits, hyphens), e.g. 'my-coffee-shop'. Becomes the preview subdomain and must be unique."),
74
+ switch_to: z
75
+ .boolean()
76
+ .default(true)
77
+ .describe("Switch the session to the new site after creating it (saved for next session). Default true."),
78
+ }, ({ name, slug, switch_to }) => handle(async () => {
79
+ let res;
80
+ try {
81
+ res = await api.createSite({ name, slug });
82
+ }
83
+ catch (e) {
84
+ const msg = e instanceof Error ? e.message : String(e);
85
+ if (msg.includes("403")) {
86
+ throw new Error("Cannot create site: your account's site quota is reached (free plan allows up to 4 sites). Delete an unused site or upgrade your plan, then retry.");
87
+ }
88
+ throw new Error(`Site creation failed: ${msg}. Check the slug is unique and URL-safe (lowercase, hyphens).`);
89
+ }
90
+ const site = res?.data?.site || res?.data || res?.site || res;
91
+ const newId = site?.id;
92
+ if (!newId) {
93
+ throw new Error("Site was not created (no id returned by the backend).");
94
+ }
95
+ const createdSlug = site?.site_slug?.slug || slug;
96
+ let switched = false;
97
+ let previewUrl = null;
98
+ const previousSiteId = api.siteId;
99
+ if (switch_to) {
100
+ api.switchSite(newId);
101
+ setConfig("site_id", newId);
102
+ setConfig("site_name", site?.name || name);
103
+ setConfig("site_domain", createdSlug || "");
104
+ switched = true;
105
+ previewUrl = await resolvePreviewUrl(api).catch(() => null);
106
+ }
107
+ return {
108
+ success: true,
109
+ site_id: newId,
110
+ name: site?.name || name,
111
+ slug: createdSlug,
112
+ switched,
113
+ ...(switched ? { current_site_id: api.siteId, previous_site_id: previousSiteId } : {}),
114
+ preview_url: previewUrl,
115
+ next_step: "New site has sample products/categories/blog but NO pages. Create a homepage with build_page (type:'main', is_homepage:true), then add store/member/blog pages as needed. Publish site-level with publish_site.",
116
+ };
117
+ }));
65
118
  server.tool("switch_site", `Switch to a different site by site_id. All subsequent tool calls will target the new site.
66
119
  The choice is saved to local database — next session will auto-connect to this site.
67
120
  Use list_my_sites first to find the site_id`, {
@@ -1,6 +1,4 @@
1
1
  import { z } from "zod";
2
- import { getImageAlt, setImageAlts as dbSetImageAlts, listImageAlts, countImageAlts } from "../db.js";
3
- import { isMongoEnabled, mongoUpsertAlts, mongoFindAlts, mongoListAlts } from "../mongo.js";
4
2
  const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico)(\?[^"')\s]*)?$/i;
5
3
  const URL_IN_CSS_RE = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g;
6
4
  const HTTP_URL_RE = /https?:\/\/[^\s"'<>)]+/g;
@@ -405,7 +403,6 @@ Note: global_sections are read-only via the API and are not included.`, {
405
403
  const { src, alt, src_path, alt_path } = probeImagePaths(node);
406
404
  if (only_missing_alt && alt && alt.trim())
407
405
  return;
408
- const cached = src ? getImageAlt(normalizeUrl(src)) : null;
409
406
  out.push({
410
407
  source_type: meta.source_type,
411
408
  source_id: meta.source_id,
@@ -416,7 +413,6 @@ Note: global_sections are read-only via the API and are not included.`, {
416
413
  alt: alt || "",
417
414
  src_path,
418
415
  alt_path,
419
- ...(cached && { cached_alt: cached.alt, cached_source: cached.source, cached_at: cached.updated_at }),
420
416
  });
421
417
  });
422
418
  }
@@ -571,30 +567,11 @@ If alt_path is omitted, it is auto-detected via the same probe used by list_imag
571
567
  }
572
568
  try {
573
569
  await saver(source);
574
- // Auto-cache: save alt per src URL so re-runs can skip OCR
575
- const cacheBatch = [];
576
- for (const u of perItem) {
577
- if (u.error || !u._src)
578
- continue;
579
- if (!/^https?:\/\//i.test(u._src))
580
- continue;
581
- cacheBatch.push({ url_key: normalizeUrl(u._src), url: u._src, alt: u.after, source: "ai" });
582
- }
583
- if (cacheBatch.length) {
584
- try {
585
- dbSetImageAlts(cacheBatch);
586
- }
587
- catch { /* cache best-effort */ }
588
- if (isMongoEnabled()) {
589
- mongoUpsertAlts(cacheBatch).catch(() => { });
590
- }
591
- }
592
570
  results.push({
593
571
  source_type,
594
572
  source_id,
595
573
  success: true,
596
574
  updated: perItem.filter((u) => !u.error).length,
597
- cached: cacheBatch.length,
598
575
  updates: perItem.map(({ _src, ...rest }) => rest),
599
576
  });
600
577
  }
@@ -604,137 +581,21 @@ If alt_path is omitted, it is auto-detected via the same probe used by list_imag
604
581
  }
605
582
  return { dry_run, sources: results.length, results };
606
583
  }));
607
- // ── Alt cache tools ──
608
- server.tool("get_cached_image_alts", `Look up cached alt descriptions for image URLs. URLs are matched by normalized form (query string stripped, lowercase). Use BEFORE running read_image/OCR — skip already-described URLs.
609
- When MONGO_URI is set, misses are then checked against MongoDB and successful hits are backfilled into the local cache for fast re-lookup.`, {
610
- urls: z.array(z.string()).min(1).describe("Image URLs to look up"),
611
- }, ({ urls }) => handle(async () => {
612
- const hits = [];
613
- let misses = [];
614
- const keyToUrl = new Map();
615
- for (const u of urls) {
616
- if (!/^https?:\/\//i.test(u)) {
617
- misses.push(u);
618
- continue;
619
- }
620
- const key = normalizeUrl(u);
621
- keyToUrl.set(key, u);
622
- const row = getImageAlt(key);
623
- if (row)
624
- hits.push({ url: u, url_key: key, alt: row.alt, source: row.source, updated_at: row.updated_at });
625
- else
626
- misses.push(u);
627
- }
628
- let mongo_hits = 0;
629
- if (isMongoEnabled() && misses.length) {
630
- const missKeys = misses
631
- .filter((u) => /^https?:\/\//i.test(u))
632
- .map((u) => normalizeUrl(u));
633
- try {
634
- const found = await mongoFindAlts(missKeys);
635
- if (found.size) {
636
- const backfill = [];
637
- const stillMissing = [];
638
- for (const u of misses) {
639
- const k = /^https?:\/\//i.test(u) ? normalizeUrl(u) : null;
640
- if (k && found.has(k)) {
641
- const doc = found.get(k);
642
- hits.push({ url: u, url_key: k, alt: doc.alt, source: doc.source || "mongo", updated_at: doc.updated_at, origin: "mongo" });
643
- backfill.push({ url_key: k, url: doc.url || u, alt: doc.alt, source: doc.source || "mongo" });
644
- mongo_hits++;
645
- }
646
- else {
647
- stillMissing.push(u);
648
- }
649
- }
650
- if (backfill.length) {
651
- try {
652
- dbSetImageAlts(backfill);
653
- }
654
- catch { /* best-effort */ }
655
- }
656
- misses = stillMissing;
657
- }
658
- }
659
- catch { /* fall through with original misses */ }
660
- }
661
- return { hits_count: hits.length, miss_count: misses.length, mongo_hits, hits, misses };
662
- }));
663
- server.tool("save_image_alts_cache", `Manually save image URL → alt entries to the local cache. Useful for bulk import or saving descriptions generated outside the set_image_alts flow.`, {
664
- items: z.array(z.object({
665
- url: z.string().describe("Image URL"),
666
- alt: z.string().describe("Alt/description text"),
667
- source: z.string().optional().describe("Origin tag (e.g. 'ai', 'manual', 'imported'). Default 'manual'"),
668
- })).min(1),
669
- }, ({ items }) => handle(async () => {
670
- const batch = [];
671
- const skipped = [];
672
- for (const it of items) {
673
- if (!/^https?:\/\//i.test(it.url)) {
674
- skipped.push({ url: it.url, reason: "non-http URL" });
675
- continue;
676
- }
677
- batch.push({ url_key: normalizeUrl(it.url), url: it.url, alt: it.alt, source: it.source || "manual" });
678
- }
679
- if (batch.length) {
680
- dbSetImageAlts(batch);
681
- if (isMongoEnabled()) {
682
- mongoUpsertAlts(batch).catch(() => { });
683
- }
684
- }
685
- return { saved: batch.length, skipped, mongo: isMongoEnabled() ? "queued" : "disabled" };
686
- }));
687
- server.tool("list_image_alts_cache", `List entries in the alt cache, most recently updated first.`, {
688
- limit: z.number().default(100).describe("Max entries (default 100)"),
689
- offset: z.number().default(0).describe("Pagination offset"),
690
- }, ({ limit, offset }) => handle(async () => {
691
- const total = countImageAlts();
692
- const rows = listImageAlts(limit, offset);
693
- return { total, count: rows.length, entries: rows };
694
- }));
695
- // ── Mongo sync (active when MONGO_URI is set) ──
696
- server.tool("sync_image_alts_to_mongo", `Push local alt cache entries up to MongoDB. Bulk upsert keyed by url_key. Use when you want to back up local-only entries to the shared central store, or after a session of heavy AI describes.
697
- Requires MONGO_URI env var.`, {
698
- limit: z.number().default(1000).describe("Max entries to push per call"),
699
- offset: z.number().default(0).describe("Offset into local cache"),
700
- }, ({ limit, offset }) => handle(async () => {
701
- if (!isMongoEnabled())
702
- return { error: "MONGO_URI not configured" };
703
- const rows = listImageAlts(limit, offset);
704
- if (!rows.length)
705
- return { pushed: 0, total_local: countImageAlts() };
706
- const res = await mongoUpsertAlts(rows.map((r) => ({ url_key: r.url_key, url: r.url, alt: r.alt, source: r.source })));
707
- return { pushed: rows.length, ...res, total_local: countImageAlts() };
708
- }));
709
- server.tool("sync_image_alts_from_mongo", `Pull MongoDB alt entries down into local cache. Useful when starting on a new machine/site to warm the local cache from the central store.
710
- Requires MONGO_URI env var.`, {
711
- limit: z.number().default(1000).describe("Max entries to pull"),
712
- offset: z.number().default(0).describe("Offset into Mongo collection"),
713
- }, ({ limit, offset }) => handle(async () => {
714
- if (!isMongoEnabled())
715
- return { error: "MONGO_URI not configured" };
716
- const { total, entries } = await mongoListAlts(limit, offset);
717
- if (entries.length) {
718
- dbSetImageAlts(entries.map((e) => ({ url_key: e.url_key, url: e.url, alt: e.alt, source: e.source || "mongo" })));
719
- }
720
- return { pulled: entries.length, total_remote: total, total_local: countImageAlts() };
721
- }));
722
584
  // ── Combo: fetch images + metadata in one call so Claude can describe + call set_image_alts once ──
723
585
  server.tool("fetch_images_for_alt_fill", `One-shot helper for filling image_alt across the site. Returns image bytes + element metadata in a single response so Claude can describe everything in one pass, then call set_image_alts once.
724
586
 
725
587
  Workflow:
726
588
  1. Call this tool with scope/limit.
727
- 2. Tool returns each image inline with its element_id + source_type + source_id (and skips URLs already in cache).
589
+ 2. Tool returns each image inline with its element_id + source_type + source_id.
728
590
  3. Claude reads images, drafts an alt for each, then calls set_image_alts(items) once with the template at the end of the response.
729
591
 
730
592
  The pre-built "items" template at the end contains placeholders — fill in "alt" and call set_image_alts.`, {
731
593
  scope: z.enum(["all", "pages", "global_sources"]).default("all"),
732
594
  page_id: z.string().optional(),
733
595
  only_missing_alt: z.boolean().default(true).describe("Default true — skip elements that already have alt"),
734
- skip_cached: z.boolean().default(true).describe("Skip URLs already in alt cache (Claude doesn't need to describe again)"),
735
596
  limit: z.number().default(10).describe("Max images per call (cap 20)"),
736
597
  max_size_mb: z.number().default(8),
737
- }, async ({ scope, page_id, only_missing_alt, skip_cached, limit, max_size_mb }) => {
598
+ }, async ({ scope, page_id, only_missing_alt, limit, max_size_mb }) => {
738
599
  try {
739
600
  const cap = Math.min(Math.max(limit, 1), 20);
740
601
  // 1. Collect candidate elements
@@ -790,17 +651,9 @@ The pre-built "items" template at the end contains placeholders — fill in "alt
790
651
  });
791
652
  }
792
653
  }
793
- // 2. Resolve cache hits auto-prepare items; misses need vision
794
- const autoItems = [];
654
+ // 2. Take up to `cap` candidates that need a vision-generated description
795
655
  const needVision = [];
796
656
  for (const c of candidates) {
797
- if (skip_cached) {
798
- const cached = getImageAlt(normalizeUrl(c.src));
799
- if (cached && cached.alt) {
800
- autoItems.push({ source_type: c.source_type, source_id: c.source_id, element_id: c.element_id, alt: cached.alt });
801
- continue;
802
- }
803
- }
804
657
  needVision.push(c);
805
658
  if (needVision.length >= cap)
806
659
  break;
@@ -812,7 +665,7 @@ The pre-built "items" template at the end contains placeholders — fill in "alt
812
665
  const content = [];
813
666
  content.push({
814
667
  type: "text",
815
- text: `Fetched ${needVision.length} image(s) needing description. ${autoItems.length} auto-filled from cache. ${candidates.length - needVision.length - autoItems.length} skipped.
668
+ text: `Fetched ${needVision.length} image(s) needing description. ${Math.max(0, candidates.length - needVision.length)} more candidate(s) not included in this batch.
816
669
 
817
670
  For each image below, write a short alt description in the language of the site (Vietnamese unless content suggests otherwise). Focus on the SUBJECT visible — avoid generic phrases like "image of...".
818
671
 
@@ -838,9 +691,8 @@ When done, call set_image_alts with the items array. The template is at the bott
838
691
  }
839
692
  }
840
693
  const template = {
841
- auto_from_cache: autoItems,
842
694
  to_describe: visionItems,
843
- next_step: "Replace each <FILL_ALT_FOR_#N> with your description, then call set_image_alts with items = [...auto_from_cache, ...to_describe].",
695
+ next_step: "Replace each <FILL_ALT_FOR_#N> with your description, then call set_image_alts with items = to_describe.",
844
696
  };
845
697
  content.push({ type: "text", text: JSON.stringify(template, null, 2) });
846
698
  return { content };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-storefront-mcp",
3
- "version": "1.1.4",
3
+ "version": "1.3.0",
4
4
  "description": "MCP server for the WebCake/StoreCake storefront builder — page CRUD, page authoring, products, orders, and more",
5
5
  "mcpName": "io.github.vuluu2k/webcake-storefront-mcp",
6
6
  "license": "MIT",
@@ -35,7 +35,6 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@modelcontextprotocol/sdk": "^1.12.1",
38
- "mongodb": "^6.21.0",
39
38
  "node-html-parser": "^8.0.2",
40
39
  "sharp": "^0.34.5",
41
40
  "zod": "^3.25.0"
package/dist/mongo.js DELETED
@@ -1,102 +0,0 @@
1
- // Optional MongoDB sync layer for the image alt cache.
2
- // Activates when MONGO_URI env var is set. Silently no-op when absent.
3
- const MONGO_URI = process.env.MONGO_URI || "";
4
- const MONGO_DB = process.env.MONGO_DB || "webcake_mcp";
5
- const MONGO_COLLECTION = process.env.MONGO_COLLECTION || "image_alt_cache";
6
- let _client = null;
7
- let _collection = null;
8
- let _connecting = null;
9
- async function connect() {
10
- if (!MONGO_URI)
11
- return null;
12
- if (_collection)
13
- return _collection;
14
- if (_connecting)
15
- return _connecting;
16
- _connecting = (async () => {
17
- try {
18
- const { MongoClient } = await import("mongodb");
19
- _client = new MongoClient(MONGO_URI, { serverSelectionTimeoutMS: 5000 });
20
- await _client.connect();
21
- const db = _client.db(MONGO_DB);
22
- _collection = db.collection(MONGO_COLLECTION);
23
- await _collection.createIndex({ url_key: 1 }, { unique: true });
24
- return _collection;
25
- }
26
- catch (e) {
27
- _connecting = null;
28
- throw e;
29
- }
30
- })();
31
- return _connecting;
32
- }
33
- export function isMongoEnabled() {
34
- return !!MONGO_URI;
35
- }
36
- export async function mongoUpsertAlts(items) {
37
- if (!isMongoEnabled())
38
- return { ok: false, reason: "MONGO_URI not set" };
39
- const col = await connect();
40
- if (!col)
41
- return { ok: false, reason: "no collection" };
42
- if (!items.length)
43
- return { ok: true, upserted: 0 };
44
- const now = Date.now();
45
- const ops = items.map((it) => ({
46
- updateOne: {
47
- filter: { url_key: it.url_key },
48
- update: {
49
- $set: {
50
- url_key: it.url_key,
51
- url: it.url,
52
- alt: it.alt,
53
- source: it.source || "ai",
54
- updated_at: now,
55
- },
56
- $setOnInsert: { created_at: now },
57
- },
58
- upsert: true,
59
- },
60
- }));
61
- const res = await col.bulkWrite(ops, { ordered: false });
62
- return { ok: true, upserted: res.upsertedCount, modified: res.modifiedCount };
63
- }
64
- export async function mongoFindAlts(urlKeys) {
65
- if (!isMongoEnabled() || !urlKeys.length)
66
- return new Map();
67
- const col = await connect();
68
- if (!col)
69
- return new Map();
70
- const cursor = col.find({ url_key: { $in: urlKeys } });
71
- const map = new Map();
72
- for await (const doc of cursor) {
73
- map.set(doc.url_key, doc);
74
- }
75
- return map;
76
- }
77
- export async function mongoListAlts(limit = 100, offset = 0) {
78
- if (!isMongoEnabled())
79
- return { total: 0, entries: [] };
80
- const col = await connect();
81
- if (!col)
82
- return { total: 0, entries: [] };
83
- const total = await col.countDocuments();
84
- const entries = await col
85
- .find({}, { projection: { _id: 0 } })
86
- .sort({ updated_at: -1 })
87
- .skip(offset)
88
- .limit(limit)
89
- .toArray();
90
- return { total, entries };
91
- }
92
- export async function mongoCloseQuietly() {
93
- if (_client) {
94
- try {
95
- await _client.close();
96
- }
97
- catch { /* ignore */ }
98
- _client = null;
99
- _collection = null;
100
- _connecting = null;
101
- }
102
- }