webcake-storefront-mcp 1.31.6 → 1.31.8
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/dist/builder/catalog.js +35 -0
- package/dist/builder/factory.js +28 -4
- package/dist/builder/guide.js +47 -0
- package/dist/builder/page.js +101 -15
- package/dist/changelog.json +14 -14
- package/dist/smoke.js +6 -2
- package/dist/tools/builder.js +20 -6
- package/dist/tools/catalog-write.js +47 -9
- package/dist/tools/page-draft.js +6 -4
- package/dist/tools/pages.js +15 -6
- package/package.json +1 -1
package/dist/builder/catalog.js
CHANGED
|
@@ -157,6 +157,41 @@ export function buildElement(type, opts = {}) {
|
|
|
157
157
|
}
|
|
158
158
|
// Guarantee children-using factories never throw on a missing children array.
|
|
159
159
|
const node = fn({ children: [], ...opts });
|
|
160
|
+
// Some factories (container, menu, menu-droppable, …) IGNORE opts.config / opts.style — they
|
|
161
|
+
// only wire children/specials. That silently dropped base layout keys like config.isHidden
|
|
162
|
+
// (e.g. a "hidden on desktop" mobile bar stayed visible). Backfill them into runtime so EVERY
|
|
163
|
+
// element honours opts.config/opts.style. Existing values from factories that DO handle them are
|
|
164
|
+
// kept (opts wins only where the factory left it unset) — idempotent for those.
|
|
165
|
+
if (opts.config && typeof opts.config === "object") {
|
|
166
|
+
node.runtime = node.runtime || {};
|
|
167
|
+
node.runtime.config = { ...opts.config, ...(node.runtime.config || {}) };
|
|
168
|
+
}
|
|
169
|
+
if (opts.style && typeof opts.style === "object") {
|
|
170
|
+
node.runtime = node.runtime || {};
|
|
171
|
+
node.runtime.style = { ...opts.style, ...(node.runtime.style || {}) };
|
|
172
|
+
}
|
|
173
|
+
// Generic horizontal ALIGNMENT control: opts.align maps to the renderer's constraintX so
|
|
174
|
+
// ANY element (button, text, image, container…) can be placed left / centre / right, or
|
|
175
|
+
// told to FILL its grid cell. Without this a content-sized element (e.g. a button) lands
|
|
176
|
+
// wherever the default constraint puts it; authors had no clean knob to centre or pin it.
|
|
177
|
+
// 'fill' also forces a 100%-width cell so the element truly spans the column.
|
|
178
|
+
if (opts.align) {
|
|
179
|
+
const ALIGN = {
|
|
180
|
+
left: ["left"], center: ["centerLeft"], centre: ["centerLeft"],
|
|
181
|
+
right: ["right"], fill: ["left", "right"], stretch: ["left", "right"],
|
|
182
|
+
};
|
|
183
|
+
const cx = ALIGN[String(opts.align).toLowerCase()];
|
|
184
|
+
if (cx) {
|
|
185
|
+
node.runtime = node.runtime || {};
|
|
186
|
+
const rc = (node.runtime.config = node.runtime.config || {});
|
|
187
|
+
rc.constraintX = cx;
|
|
188
|
+
if (cx.length === 2) {
|
|
189
|
+
rc.widthUnit = "%";
|
|
190
|
+
if (rc.relWidth == null)
|
|
191
|
+
rc.relWidth = 100;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
160
195
|
// Some factories ignore opts.bindings/events — attach them so any element the AI passes
|
|
161
196
|
// them to gets them — then normalize so each binding/event has a valid id (+ name/eventName).
|
|
162
197
|
if (opts.bindings && !node.bindings)
|
package/dist/builder/factory.js
CHANGED
|
@@ -135,12 +135,26 @@ export const createButton = (opts = {}) => {
|
|
|
135
135
|
button.id = 'BUTTON-' + randomString(8);
|
|
136
136
|
button.type = 'button';
|
|
137
137
|
button.runtime.style = {
|
|
138
|
-
|
|
139
|
-
height: 46,
|
|
138
|
+
height: 48,
|
|
140
139
|
fontSize: '16px',
|
|
140
|
+
// Generous horizontal padding so the label never sits edge-to-edge — the #1 "ugly
|
|
141
|
+
// button" complaint when a button stretched full width. Centred text keeps it tidy
|
|
142
|
+
// whatever the width. Override any of these via opts.style.
|
|
143
|
+
paddingTop: '0px',
|
|
144
|
+
paddingBottom: '0px',
|
|
145
|
+
paddingLeft: '28px',
|
|
146
|
+
paddingRight: '28px',
|
|
147
|
+
textAlign: 'center',
|
|
141
148
|
...(opts.style || {}),
|
|
142
149
|
};
|
|
143
150
|
button.runtime.config = {
|
|
151
|
+
// A button sizes to its CONTENT (label + padding), NOT the full grid cell. Without
|
|
152
|
+
// this, stackChildren's sizeDefaults stretches it to widthUnit:'%'/relWidth:100 — i.e.
|
|
153
|
+
// an edge-to-edge bar with cramped text. Content-width + the default centred
|
|
154
|
+
// constraintX means a standalone CTA sits centred with breathing room instead of being
|
|
155
|
+
// slammed flush-left. To make a button FILL its container, pass opts.align:'fill' (or
|
|
156
|
+
// opts.config.widthUnit:'%').
|
|
157
|
+
widthUnit: 'auto',
|
|
144
158
|
...(opts.config || {})
|
|
145
159
|
};
|
|
146
160
|
button.specials.text = opts.text || 'Button';
|
|
@@ -725,9 +739,19 @@ export const createSubmitButton = (opts = {}) => {
|
|
|
725
739
|
const button = cloneDeep(SKELETON);
|
|
726
740
|
button.id = 'SUBMIT-BUTTON-' + randomString(8);
|
|
727
741
|
button.type = 'submit-button';
|
|
728
|
-
button
|
|
729
|
-
|
|
742
|
+
// A form submit button stays full-width (the norm inside a form column) but gets a real
|
|
743
|
+
// height + side padding + centred label so it never renders as a cramped sliver.
|
|
744
|
+
button.runtime.style = {
|
|
745
|
+
height: 48,
|
|
746
|
+
fontSize: '16px',
|
|
747
|
+
paddingLeft: '24px',
|
|
748
|
+
paddingRight: '24px',
|
|
749
|
+
textAlign: 'center',
|
|
750
|
+
...(opts.style || {}),
|
|
751
|
+
};
|
|
752
|
+
button.runtime.config = { ...(opts.config || {}) };
|
|
730
753
|
button.specials = {
|
|
754
|
+
...(opts.specials || {}),
|
|
731
755
|
text: opts.specials?.text || 'Submit'
|
|
732
756
|
};
|
|
733
757
|
return button;
|
package/dist/builder/guide.js
CHANGED
|
@@ -94,6 +94,20 @@ children with a grid:
|
|
|
94
94
|
new_section does ALL of this for you: pass children and they are stacked one row each in
|
|
95
95
|
the centre column.
|
|
96
96
|
|
|
97
|
+
### ALIGNMENT inside a cell — how children line up (avoid the "snapped to a corner" look)
|
|
98
|
+
\`constraintX\`/\`constraintY\` decide where a child sits in its grid cell. The renderer maps:
|
|
99
|
+
- \`["left","right"]\` → justify-self: STRETCH — the element FILLS the cell width (this is what makes
|
|
100
|
+
columns, cards, and images line up edge-to-edge). This is the DEFAULT new_section/new_row give to
|
|
101
|
+
every width-filling element (text, image, container, repeaters), so siblings stay in straight rows.
|
|
102
|
+
- \`["centerLeft"]\` → justify-self: CENTER (shrinks a content-width element to its content and centres it),
|
|
103
|
+
\`["left"]\` → left, \`["right"]\` → right. \`constraintY\`: \`["top"]\` (default) / \`["bottom"]\` / \`["centerTop"]\` (middle).
|
|
104
|
+
DON'T hand-set \`["centerLeft"]\` on a full-width element (text/container/card) — that snaps it to its
|
|
105
|
+
content width and floats it, so it no longer lines up with its neighbours (the classic "cắn/lệch" bug).
|
|
106
|
+
Instead: leave the default stretch and control the LOOK with \`textAlign\` (for text) or, for a
|
|
107
|
+
content-width element like a button, with \`opts.align\` ('left'|'center'|'right'|'fill'). A button is
|
|
108
|
+
content-width + centred by DEFAULT; pass \`align:"left"\` so it lines up with left-aligned text above it,
|
|
109
|
+
or \`align:"fill"\` to span the column.
|
|
110
|
+
|
|
97
111
|
## Multi-column rows (cards side by side) — USE THIS, real pages are full of them
|
|
98
112
|
A plain vertical stack looks like a blog post, not a designed page. Feature cards,
|
|
99
113
|
category tiles, footer columns, a text+image hero — all are HORIZONTAL rows. Two ways:
|
|
@@ -147,6 +161,13 @@ A bare stack of default elements looks unfinished. Apply real styling:
|
|
|
147
161
|
- BUTTONS HAVE NO DEFAULT COLOUR — you MUST style them or they look like plain text:
|
|
148
162
|
\`{ type:"button", opts:{ text:"Mua ngay", style:{ background:"var(--color_24)", color:"var(--color_00)",
|
|
149
163
|
borderRadius:"8px", fontWeight:"600", height:48 } } }\` (DARK brand background, white label — always readable).
|
|
164
|
+
- BUTTON WIDTH & ALIGNMENT (don't fight it): a \`button\` is CONTENT-SIZED by default (label + built-in
|
|
165
|
+
28px side padding) and CENTRED in its cell — so a standalone CTA looks like a real button, never a
|
|
166
|
+
full-width bar with cramped text or one slammed flush-left. To place/size it, pass \`opts.align\`:
|
|
167
|
+
\`"left"\` | \`"center"\` (default) | \`"right"\` | \`"fill"\` (span the whole column, e.g. two side-by-side
|
|
168
|
+
Add-to-cart / Buy-now buttons, or a button inside a narrow summary card). \`align\` works on ANY element
|
|
169
|
+
(text/image/container too). A form \`submit-button\` stays full-width by design. Do NOT try to force a
|
|
170
|
+
button's width via \`style.width\` — the renderer ignores it (use \`align:"fill"\` instead).
|
|
150
171
|
- HERO: build it as a section whose FIRST child is a full-width \`image\` element (the background photo,
|
|
151
172
|
width:"100%", height ~480), then overlay the heading/sub/button on top. ⚠️ Do NOT rely on a CSS
|
|
152
173
|
\`background:"linear-gradient(...), url(...)"\` SHORTHAND on the section — the storefront renderer
|
|
@@ -176,6 +197,11 @@ The four breakpoints (largest → smallest), keyed bp1..bp4, are:
|
|
|
176
197
|
For NEW pages you author once in \`runtime\` (the bp1/desktop base); on save the build expands
|
|
177
198
|
it into bp1..bp4. By default all four are the same (renders identically across devices), PLUS
|
|
178
199
|
sections re-centre their grid per breakpoint and multi-column rows auto-collapse (4→2→1 cols).
|
|
200
|
+
AUTO-RESPONSIVE DEFAULTS (you get these for free, no diffs needed): on tablet/mobile the build
|
|
201
|
+
also shrinks oversized TYPOGRAPHY (any fontSize ≥22px scales ~0.86× on tablet / ~0.72× on mobile,
|
|
202
|
+
floored at 15px) and TALL images (height >320px shrinks on mobile) so hero headlines and big media
|
|
203
|
+
don't blow out small screens. Body text (<22px) is left alone. Pass \`opts.responsive\` only to
|
|
204
|
+
OVERRIDE this default for a specific node/breakpoint (your explicit diff always wins).
|
|
179
205
|
|
|
180
206
|
RESPONSIVE CASCADE (reason about each breakpoint, don't hand-copy): to make a node look
|
|
181
207
|
different on smaller screens, pass \`opts.responsive\` = SPARSE per-breakpoint diffs and the
|
|
@@ -313,6 +339,27 @@ create the globals — they embed into each page's source. If you later overwrit
|
|
|
313
339
|
(delete_global_section by the section NODE id, then create once) to re-embed cleanly. Edit a global
|
|
314
340
|
later with update_global_section_element(s) and it updates on every page at once.
|
|
315
341
|
|
|
342
|
+
### HEADER must be RESPONSIVE — add a MOBILE MENUBAR, don't just let nav stack
|
|
343
|
+
A header built as one horizontal row of nav links looks fine on desktop but on a phone the links
|
|
344
|
+
squash or collapse into an ugly vertical pile. Build a header that carries TWO navs and swap them
|
|
345
|
+
per breakpoint with the \`isHidden\` config (it cascades bp1→bp4 like any config key):
|
|
346
|
+
- DESKTOP nav — the inline row of links in the top bar. Keep it on desktop+laptop, hide from tablet
|
|
347
|
+
down: \`responsive:{ bp3:{ config:{ isHidden:true } } }\` (base visible).
|
|
348
|
+
- MOBILE MENUBAR — a SEPARATE container, a centred horizontal row of the same links placed as a
|
|
349
|
+
second row of the header section (below the logo/cart bar). Hide it on desktop+laptop, show from
|
|
350
|
+
tablet down: base \`config:{ isHidden:true }\` + \`responsive:{ bp3:{ config:{ isHidden:false } } }\`.
|
|
351
|
+
RELIABILITY NOTE (storefront-verified): this two-nav \`isHidden\`-SWAP is the dependable mobile
|
|
352
|
+
menubar. AVOID these — they do NOT render dependably from raw MCP data: the native \`menu\`
|
|
353
|
+
\`type:"hamburger"\` (its ☰ trigger paints as a 0-width empty box; an inline-SVG \`mask\` is ignored);
|
|
354
|
+
a \`toggle\`-revealed panel (the storefront only makes a toggle target's hidden state reactive when
|
|
355
|
+
it's wired in the builder UI, so a click can't show an \`isHidden\` element); and \`open_popup\` drawers
|
|
356
|
+
(popups are global_sources and the publish pipeline doesn't always emit them, so the popup isn't on
|
|
357
|
+
the page). A plain visible mobile nav row, shown via \`isHidden\` swap, always works.
|
|
358
|
+
Lay the bar as a 2-column row [logo | (desktop-nav + cart, aligned right)] and add the mobile nav row
|
|
359
|
+
as the section's 2nd child. Keep the logo + cart-icon visible on every breakpoint.
|
|
360
|
+
(If you do want a real collapsible hamburger, finish it in the WebCake builder UI, which wires the
|
|
361
|
+
toggle/menu reactively — the MCP can't reproduce that wiring from data alone.)
|
|
362
|
+
|
|
316
363
|
## Popups (newsletter / promo / age-gate)
|
|
317
364
|
A popup is a GLOBAL SOURCE, not a page section. Compose it from elements (there is no scaffold
|
|
318
365
|
shortcut), then save it as a "popup" global source:
|
package/dist/builder/page.js
CHANGED
|
@@ -10,6 +10,42 @@ import { validateEvents } from "./events.js";
|
|
|
10
10
|
import { validateBindings } from "./bindings.js";
|
|
11
11
|
import { BREAKPOINTS, genGridByBp, SECTION_CONTENT_COL_START, SECTION_CONTENT_COL_END, } from "./grid.js";
|
|
12
12
|
const clone = (o) => structuredClone(o);
|
|
13
|
+
/** Parse a CSS px length ("52px") or a bare number (52) → number, else null. */
|
|
14
|
+
function pxToNum(v) {
|
|
15
|
+
if (typeof v === "number")
|
|
16
|
+
return Number.isFinite(v) ? v : null;
|
|
17
|
+
const m = /^(-?\d+(?:\.\d+)?)px$/.exec(String(v ?? "").trim());
|
|
18
|
+
return m ? parseFloat(m[1]) : null;
|
|
19
|
+
}
|
|
20
|
+
/** AUTO-RESPONSIVE down-scaling per breakpoint. Headings/large text and very tall media
|
|
21
|
+
* keep their desktop size on phones unless the author hand-writes a responsive diff —
|
|
22
|
+
* which makes generated pages look broken on mobile. These factors shrink oversized
|
|
23
|
+
* typography (and tall images) on tablet/mobile by DEFAULT. Applied only when the author
|
|
24
|
+
* did NOT already override the property at this breakpoint (resolved value === base). */
|
|
25
|
+
const AUTO_FONT_FACTOR = { bp1: 1, bp2: 1, bp3: 0.86, bp4: 0.72 };
|
|
26
|
+
const AUTO_IMG_HEIGHT_FACTOR = { bp1: 1, bp2: 1, bp3: 0.82, bp4: 0.58 };
|
|
27
|
+
/** Mutate a resolved per-breakpoint `style` in place: shrink big fonts (≥22px) and tall
|
|
28
|
+
* images (>320px) for smaller breakpoints. `baseStyle` is the bp1/authored style — we only
|
|
29
|
+
* auto-scale a property the author left untouched at this bp (resolved === base). */
|
|
30
|
+
function applyAutoResponsive(node, bp, baseStyle, style) {
|
|
31
|
+
const ff = AUTO_FONT_FACTOR[bp];
|
|
32
|
+
if (ff != null && ff < 1) {
|
|
33
|
+
const baseF = pxToNum(baseStyle.fontSize);
|
|
34
|
+
const curF = pxToNum(style.fontSize);
|
|
35
|
+
if (baseF != null && curF != null && curF === baseF && baseF >= 22) {
|
|
36
|
+
style.fontSize = Math.max(15, Math.round(baseF * ff)) + "px";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const hf = AUTO_IMG_HEIGHT_FACTOR[bp];
|
|
40
|
+
if (hf != null && hf < 1 && (node.type === "image" || node.type === "image-dataset")) {
|
|
41
|
+
const baseH = pxToNum(baseStyle.height);
|
|
42
|
+
const curH = pxToNum(style.height);
|
|
43
|
+
if (baseH != null && curH != null && curH === baseH && baseH > 320) {
|
|
44
|
+
const nh = Math.round(baseH * hf);
|
|
45
|
+
style.height = typeof baseStyle.height === "number" ? nh : nh + "px";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
13
49
|
/** Walk every node in a source tree (depth-first). Return false from fn to stop. */
|
|
14
50
|
export function walk(source, fn) {
|
|
15
51
|
const sections = source && Array.isArray(source.sections) ? source.sections : [];
|
|
@@ -64,11 +100,28 @@ const FILL_WIDTH_TYPES = new Set([
|
|
|
64
100
|
"grid-category", "grid-blog", "product-gallery", "product-image-carousel",
|
|
65
101
|
"custom-layout", "layout-dataset", "form",
|
|
66
102
|
]);
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
103
|
+
/**
|
|
104
|
+
* Default horizontal constraint for a child in its grid cell. THE ALIGNMENT RULE:
|
|
105
|
+
* an element that FILLS its cell width (widthUnit "%", relWidth 100 — the default for
|
|
106
|
+
* text/image/container/repeaters) must STRETCH (`["left","right"]` → justify-self:stretch),
|
|
107
|
+
* NOT centre. The old default `["centerLeft"]` (justify-self:center) snapped such elements
|
|
108
|
+
* to their content width and floated them — so sibling columns/cards/images did NOT line up
|
|
109
|
+
* (the "cắn left-top / không thẳng hàng" bug). Stretch makes every full-width child span its
|
|
110
|
+
* cell edge-to-edge, so its OWN textAlign / inner stacking controls layout and rows align.
|
|
111
|
+
* Only a genuinely CONTENT-WIDTH element (widthUnit "auto", e.g. a button) gets a point
|
|
112
|
+
* constraint, defaulting to centre. An explicit constraintX (e.g. set by opts.align) always wins.
|
|
113
|
+
*/
|
|
69
114
|
function defaultConstraintX(child) {
|
|
70
|
-
|
|
71
|
-
|
|
115
|
+
const cfg = (child.runtime && child.runtime.config) || {};
|
|
116
|
+
if (cfg.constraintX)
|
|
117
|
+
return cfg.constraintX; // explicit / from opts.align
|
|
118
|
+
if (FILL_WIDTH_TYPES.has(child.type))
|
|
119
|
+
return ["left", "right"];
|
|
120
|
+
// Content-sized elements (a button opts into widthUnit:"auto") centre by default; everything
|
|
121
|
+
// else fills its cell, so it must stretch to line up with its neighbours.
|
|
122
|
+
if (cfg.widthUnit === "auto")
|
|
123
|
+
return ["centerLeft"];
|
|
124
|
+
return ["left", "right"];
|
|
72
125
|
}
|
|
73
126
|
export function stackChildren(container, children, opts = {}) {
|
|
74
127
|
const gridCols = opts.gridCols || 1;
|
|
@@ -89,20 +142,36 @@ export function stackChildren(container, children, opts = {}) {
|
|
|
89
142
|
};
|
|
90
143
|
children.forEach((child, i) => {
|
|
91
144
|
child.runtime = child.runtime || {};
|
|
145
|
+
const cc = child.runtime.config || {};
|
|
92
146
|
child.runtime.config = {
|
|
93
|
-
...
|
|
147
|
+
...cc,
|
|
94
148
|
columnStart: colStart,
|
|
95
149
|
columnEnd: colEnd,
|
|
96
150
|
rowStart: i + 1,
|
|
97
151
|
rowEnd: i + 2,
|
|
98
152
|
constraintX: defaultConstraintX(child),
|
|
99
|
-
constraintY:
|
|
153
|
+
constraintY: cc.constraintY || ["top"],
|
|
154
|
+
// Width the template-native way: every element declares a width % of its cell
|
|
155
|
+
// (config.widthUnit + relWidth). Without these the renderer emits an invalid
|
|
156
|
+
// `width: %;` (the "width is 0" bug). Default to filling the cell; respect an
|
|
157
|
+
// element that opted into "auto" (content-sized, e.g. buttons) or "px".
|
|
158
|
+
...sizeDefaults(cc),
|
|
100
159
|
loaded: true,
|
|
101
160
|
};
|
|
102
161
|
});
|
|
103
162
|
container.children = children;
|
|
104
163
|
return container;
|
|
105
164
|
}
|
|
165
|
+
/** Template-native width defaults: fill the grid cell (widthUnit "%", relWidth 100) unless
|
|
166
|
+
* the element already chose a unit. The storefront's build_position reads these (NOT
|
|
167
|
+
* style.width, except when widthUnit==="px"). */
|
|
168
|
+
function sizeDefaults(cfg) {
|
|
169
|
+
const widthUnit = cfg.widthUnit || "%";
|
|
170
|
+
const out = { widthUnit };
|
|
171
|
+
if (widthUnit !== "auto")
|
|
172
|
+
out.relWidth = cfg.relWidth != null ? cfg.relWidth : 100;
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
106
175
|
const ROW_PLACEHOLDER_ROW = () => ({ unit: "min/max", min: { unit: "px", absValue: 50 }, max: { unit: "max-c" } });
|
|
107
176
|
/** Default responsive collapse for a multi-column row: full columns on desktop/laptop,
|
|
108
177
|
* 2 columns on tablet, 1 column on mobile. `null` = keep the full column count. */
|
|
@@ -137,14 +206,16 @@ export function rowChildren(container, children, opts = {}) {
|
|
|
137
206
|
};
|
|
138
207
|
children.forEach((child, i) => {
|
|
139
208
|
child.runtime = child.runtime || {};
|
|
209
|
+
const cc = child.runtime.config || {};
|
|
140
210
|
child.runtime.config = {
|
|
141
|
-
...
|
|
211
|
+
...cc,
|
|
142
212
|
columnStart: i + 1,
|
|
143
213
|
columnEnd: i + 2,
|
|
144
214
|
rowStart: 1,
|
|
145
215
|
rowEnd: 2,
|
|
146
216
|
constraintX: defaultConstraintX(child),
|
|
147
|
-
constraintY:
|
|
217
|
+
constraintY: cc.constraintY || ["top"],
|
|
218
|
+
...sizeDefaults(cc),
|
|
148
219
|
loaded: true,
|
|
149
220
|
__cell: { index: i, ...meta },
|
|
150
221
|
};
|
|
@@ -182,14 +253,27 @@ export function buildSection(childSpecs = [], sectionOpts = {}) {
|
|
|
182
253
|
contentColEnd: SECTION_CONTENT_COL_END,
|
|
183
254
|
rowGap: sectionOpts.rowGap,
|
|
184
255
|
});
|
|
185
|
-
//
|
|
186
|
-
//
|
|
256
|
+
// Section vertical padding the TEMPLATE-NATIVE way: a fixed-height spacer GRID ROW at the
|
|
257
|
+
// top and bottom, content rows in between (real templates do exactly this — the storefront
|
|
258
|
+
// ignores CSS `padding` on a section, build_section only emits the grid). Default 64px;
|
|
259
|
+
// pass sectionOpts.padY (0 disables, e.g. a full-bleed hero).
|
|
260
|
+
const padY = sectionOpts.padY != null ? sectionOpts.padY : 64;
|
|
187
261
|
section.runtime = section.runtime || {};
|
|
188
|
-
section.runtime.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
262
|
+
const cfg = section.runtime.config || (section.runtime.config = {});
|
|
263
|
+
if (padY > 0 && Array.isArray(cfg.rows)) {
|
|
264
|
+
const spacer = () => ({ unit: "min/max", min: { unit: "px", absValue: padY }, max: { unit: "max-c" } });
|
|
265
|
+
cfg.rows = [spacer(), ...cfg.rows, spacer()];
|
|
266
|
+
cfg.grid = `3x${cfg.rows.length}`;
|
|
267
|
+
for (const c of children) {
|
|
268
|
+
const cc = c.runtime && c.runtime.config;
|
|
269
|
+
if (cc) {
|
|
270
|
+
cc.rowStart = (cc.rowStart || 1) + 1;
|
|
271
|
+
cc.rowEnd = (cc.rowEnd || 2) + 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Section style carries background only (padding is done via the spacer rows above).
|
|
276
|
+
section.runtime.style = { ...(sectionOpts.style || {}) };
|
|
193
277
|
return section;
|
|
194
278
|
}
|
|
195
279
|
export function buildFromSpec(spec) {
|
|
@@ -309,6 +393,8 @@ function expandNodeToBreakpoints(node) {
|
|
|
309
393
|
delete config.__row;
|
|
310
394
|
delete config.__cell;
|
|
311
395
|
delete config.responsive;
|
|
396
|
+
// Shrink oversized fonts / tall images on tablet & mobile by default (author diffs win).
|
|
397
|
+
applyAutoResponsive(node, bp, rt.style || {}, style);
|
|
312
398
|
if (isSection) {
|
|
313
399
|
const g = genGridByBp(minW);
|
|
314
400
|
const sectionRows = baseConfig.rows && baseConfig.rows.length ? clone(baseConfig.rows) : clone(g.rows);
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.31.8",
|
|
4
|
+
"d": "29/06/2026",
|
|
5
|
+
"type": "Fixed",
|
|
6
|
+
"en": "Elements placed by new_section, new_row, and build_page that fill their grid cell (text, image, container, columns, repeaters) now receive…",
|
|
7
|
+
"vi": "Các phần tử được đặt bởi new_section, new_row và build_page mà lấp đầy ô lưới (text, image, container, columns, repeaters) nay nhận constraintX:…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.31.7",
|
|
11
|
+
"d": "26/06/2026",
|
|
12
|
+
"type": "Fixed",
|
|
13
|
+
"en": "build_page, create_page, update_page, and commit_page_draft now strip a leading / from slug before saving; a slug like /cart would previously 404 on…",
|
|
14
|
+
"vi": "build_page, create_page, update_page và commit_page_draft nay tự động loại bỏ dấu / đầu dòng khỏi slug trước khi lưu; các slug kiểu /cart trước đây…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.31.6",
|
|
4
18
|
"d": "26/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Added",
|
|
27
41
|
"en": "New update_collection_columns tool reads the current collection schema and PATCHes it with the system columns plus the provided custom columns,…",
|
|
28
42
|
"vi": "Tool mới update_collection_columns đọc schema hiện tại của collection rồi PATCH lại với các cột hệ thống cộng các cột tùy chỉnh được cung cấp, cho…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.31.2",
|
|
32
|
-
"d": "26/06/2026",
|
|
33
|
-
"type": "Changed",
|
|
34
|
-
"en": "The HTTP_FUNCTION_GUIDE embedded in get_http_function and get_site_custom_code now includes a \"Common patterns\" section with battle-tested…",
|
|
35
|
-
"vi": "HTTP_FUNCTION_GUIDE được nhúng trong get_http_function và get_site_custom_code nay bổ sung phần \"Common patterns\" với các recipe đã được kiểm chứng…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.31.1",
|
|
39
|
-
"d": "26/06/2026",
|
|
40
|
-
"type": "Fixed",
|
|
41
|
-
"en": "The webcake-data SDK reference embedded in get_http_function and get_site_custom_code now documents the correct Mongoose-document API: filters are…",
|
|
42
|
-
"vi": "Tài liệu tham chiếu SDK webcake-data được nhúng trong get_http_function và get_site_custom_code nay ghi lại đúng API kiểu Mongoose-document: bộ lọc…"
|
|
43
43
|
}
|
|
44
44
|
]
|
package/dist/smoke.js
CHANGED
|
@@ -50,8 +50,12 @@ console.log("== page: grid composition + validation ==");
|
|
|
50
50
|
{ type: "button", opts: { text: "Buy" } },
|
|
51
51
|
]);
|
|
52
52
|
// A section uses the builder's centred 3-column grid; children sit in the centre column.
|
|
53
|
-
|
|
53
|
+
// padY (default 64) adds a top + bottom SPACER ROW, so a 2-child section is 3x4 and the
|
|
54
|
+
// children start at row 2 (past the top spacer) — template-native section padding.
|
|
55
|
+
check("section grid is 3x(N+2) with spacer rows", hero.runtime.config.grid === "3x4", hero.runtime.config.grid);
|
|
56
|
+
check("top row is a padY spacer", hero.runtime.config.rows[0].min.absValue === 64, hero.runtime.config.rows[0]);
|
|
54
57
|
check("children placed in centre column", hero.children.every((c) => c.runtime.config.columnStart === 2));
|
|
58
|
+
check("children shifted past top spacer", hero.children[0].runtime.config.rowStart === 2, hero.children[0].runtime.config.rowStart);
|
|
55
59
|
const src = newPageSkeleton();
|
|
56
60
|
src.sections.push(hero);
|
|
57
61
|
const v = validatePage(src);
|
|
@@ -62,7 +66,7 @@ console.log("== page: grid composition + validation ==");
|
|
|
62
66
|
const sec0 = src.sections[0];
|
|
63
67
|
check("finalize removes runtime", !("runtime" in sec0), Object.keys(sec0));
|
|
64
68
|
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 === "
|
|
69
|
+
check("section bp4 is mobile grid", sec0.bp4.config.grid === "3x4" && sec0.bp4.config.columns[0].absValue === 5, sec0.bp4.config.columns?.[0]);
|
|
66
70
|
check("child bp1 keeps centre column", sec0.children[0].bp1.config.columnStart === 2, sec0.children[0].bp1?.config);
|
|
67
71
|
check("finalize is idempotent", (finalizeForRender(src), !("runtime" in sec0)));
|
|
68
72
|
// duplicate ids must fail validation
|
package/dist/tools/builder.js
CHANGED
|
@@ -61,6 +61,17 @@ export function buildPageSeo(seo = {}) {
|
|
|
61
61
|
out.og_image = seo.thumbnail;
|
|
62
62
|
return out;
|
|
63
63
|
}
|
|
64
|
+
/** Normalize a page slug to what the storefront matches on. The storefront routes by the
|
|
65
|
+
* RAW path segment (e.g. "/cart" → path ["cart"]) and looks up `page.slug == "cart"`, so a
|
|
66
|
+
* stored slug WITH a leading "/" (e.g. "/cart") never matches and the page 404s. The homepage
|
|
67
|
+
* is matched by `is_nil(slug)`, so an empty/"/" slug must become "no slug" (undefined) — never
|
|
68
|
+
* stored as "". Strips leading/trailing slashes; returns undefined for the homepage/blank case. */
|
|
69
|
+
export function normalizeSlug(slug) {
|
|
70
|
+
if (!slug)
|
|
71
|
+
return undefined;
|
|
72
|
+
const s = String(slug).trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
73
|
+
return s.length ? s : undefined;
|
|
74
|
+
}
|
|
64
75
|
export function registerBuilderTools(server, api, handle) {
|
|
65
76
|
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 })));
|
|
66
77
|
server.tool("get_page_schema", "Get the authoritative JSON Schema (Draft 2020-12) for a page source `{ sections: [...] }` — the structural contract for every node (id/type/specials/runtime{style,config}/children/events/bindings) in the CSS-grid model. Use it as the shape to emit; validate_page enforces the semantic rules.", {}, () => handle(async () => PAGE_SCHEMA));
|
|
@@ -72,7 +83,7 @@ export function registerBuilderTools(server, api, handle) {
|
|
|
72
83
|
server.tool("list_bindings", "List every dynamic-data BINDING target: the datasets (product, cart_item, order, order_item, post, category, customer, customer_address, …) and their exact field names ('product::product_price', …), which page type each needs (store/member/blog), and how repeater children (grid-product, cart-items, post-list) bind per-item. Attach via new_element opts.bindings (ids auto-minted, e.g. opts.bindings=[{ target:'product::product_price' }]).", {}, () => handle(async () => describeBindingsCatalog()));
|
|
73
84
|
server.tool("new_element", "Build a single structurally-valid element node from the real builder factory. Returns the node — edit its specials/style, then place it in a section's children.", {
|
|
74
85
|
type: z.string().describe("Element type (see list_elements)"),
|
|
75
|
-
opts: z.record(z.any()).optional().describe("Factory opts: { text, src, width, height, style, config, specials, events }"),
|
|
86
|
+
opts: z.record(z.any()).optional().describe("Factory opts: { text, src, width, height, style, config, specials, events, bindings, responsive, align }. align = horizontal placement in the grid cell: 'left'|'center'|'right'|'fill' (works on any element; buttons are content-width+centred by default — use align:'fill' to span the column)."),
|
|
76
87
|
}, ({ type, opts }) => handle(async () => buildElement(type, opts || {})));
|
|
77
88
|
server.tool("new_section", `Build a complete section node with children laid out in the builder's vertical grid.
|
|
78
89
|
Pass an array of element specs; each child is stacked top-to-bottom. Nest containers via the child's own 'children'.
|
|
@@ -109,7 +120,7 @@ Example children: [{ "type":"container", "children":[{"type":"image","opts":{...
|
|
|
109
120
|
Two-step safety: call with dry_run=true (default) to validate and preview, then dry_run=false to actually create + save.
|
|
110
121
|
The source must be { sections: [...] } — build sections with new_section. Validation errors block the real save.`, {
|
|
111
122
|
name: z.string().describe("Page name"),
|
|
112
|
-
slug: z.string().describe("URL slug, e.g. '/
|
|
123
|
+
slug: z.string().describe("URL slug WITHOUT a leading slash, e.g. 'about', 'collections', 'cart'. A leading '/' is stripped automatically (the storefront matches the bare path segment, so '/cart' would 404). Store pages MUST use the conventional slugs: category='collections', product detail='products', cart='cart', checkout='checkout', thank-you='complete'. The homepage needs no slug (pass is_homepage:true)."),
|
|
113
124
|
source: z.any().describe("Full page source { sections: [...] } (object or JSON string)"),
|
|
114
125
|
type: z
|
|
115
126
|
.enum(PAGE_KINDS)
|
|
@@ -134,11 +145,14 @@ The source must be { sections: [...] } — build sections with new_section. Vali
|
|
|
134
145
|
const kind = type || (is_homepage ? "main" : undefined);
|
|
135
146
|
const typeNum = kind ? PAGE_TYPE_NUM[kind] : undefined;
|
|
136
147
|
const requiredFlag = kind ? PAGE_TYPE_FLAG[kind] : undefined;
|
|
148
|
+
// Strip a leading "/" — the storefront matches `page.slug == "<segment>"` (no slash),
|
|
149
|
+
// so "/cart" would 404. Homepage (blank/"/") → undefined (matched by is_nil(slug)).
|
|
150
|
+
const cleanSlug = normalizeSlug(slug);
|
|
137
151
|
if (dry_run) {
|
|
138
152
|
return {
|
|
139
153
|
dry_run: true,
|
|
140
154
|
validation,
|
|
141
|
-
request: { name, slug, type: kind ?? null, page_type_num: typeNum ?? null, is_homepage, sections: (parsed && parsed.sections || []).length },
|
|
155
|
+
request: { name, slug: cleanSlug ?? null, type: kind ?? null, page_type_num: typeNum ?? null, is_homepage, sections: (parsed && parsed.sections || []).length },
|
|
142
156
|
will_enable_feature: requiredFlag ?? null,
|
|
143
157
|
renders_at_breakpoints: ["bp1", "bp2", "bp3", "bp4"],
|
|
144
158
|
hint: validation.valid
|
|
@@ -170,10 +184,10 @@ The source must be { sections: [...] } — build sections with new_section. Vali
|
|
|
170
184
|
}
|
|
171
185
|
// slug / homepage / SEO are not applied at create — set them via update_page.
|
|
172
186
|
const seoBlock = seo ? buildPageSeo(seo) : null;
|
|
173
|
-
if (
|
|
187
|
+
if (cleanSlug || is_homepage || (seoBlock && Object.keys(seoBlock).length)) {
|
|
174
188
|
await api
|
|
175
189
|
.updatePage(pageId, {
|
|
176
|
-
...(
|
|
190
|
+
...(cleanSlug ? { slug: cleanSlug } : {}),
|
|
177
191
|
...(is_homepage ? { is_homepage: true } : {}),
|
|
178
192
|
...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
|
|
179
193
|
})
|
|
@@ -183,7 +197,7 @@ The source must be { sections: [...] } — build sections with new_section. Vali
|
|
|
183
197
|
success: true,
|
|
184
198
|
page_id: pageId,
|
|
185
199
|
name,
|
|
186
|
-
slug,
|
|
200
|
+
slug: cleanSlug ?? null,
|
|
187
201
|
page_type: kind ?? null,
|
|
188
202
|
...(feature ? { data_source: { flag: feature.flag, newly_enabled: feature.changed } } : {}),
|
|
189
203
|
stats: validation.stats,
|
|
@@ -18,6 +18,18 @@ const variationSpec = z.object({
|
|
|
18
18
|
.optional()
|
|
19
19
|
.describe("Attribute values for this variation, e.g. [{name:'Color',value:'Đen'},{name:'Size',value:'M'}]"),
|
|
20
20
|
});
|
|
21
|
+
/** Normalize an attribute value to its keyword key (DEN for "Đen", TRANG for "Trắng", S for "S") —
|
|
22
|
+
* the form the storefront variation selector uses. Strips Vietnamese diacritics + uppercases. */
|
|
23
|
+
function attrKeyValue(v) {
|
|
24
|
+
return String(v)
|
|
25
|
+
.normalize("NFD").replace(/[̀-ͯ]/g, "")
|
|
26
|
+
.replace(/đ/g, "d").replace(/Đ/g, "D")
|
|
27
|
+
.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
28
|
+
}
|
|
29
|
+
/** Cartesian product of N lists — used to expand attribute axes into one variation per combo. */
|
|
30
|
+
function cartesian(lists) {
|
|
31
|
+
return lists.reduce((acc, cur) => acc.flatMap((a) => cur.map((b) => [...a, b])), [[]]);
|
|
32
|
+
}
|
|
21
33
|
export function registerCatalogWriteTools(server, api, handle) {
|
|
22
34
|
server.tool("create_product", `Create a product so the storefront has real merchandise (grid-product / slider-product bindings need this).
|
|
23
35
|
Simple use: pass name + price (+ images, category_ids). One default variation with the price/stock is created for you.
|
|
@@ -39,23 +51,49 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
|
|
|
39
51
|
variations: z.array(variationSpec).optional().describe("Explicit per-SKU variations. Omit to auto-build one from price/stock/sku."),
|
|
40
52
|
}, ({ name, price, original_price, stock, sku, images, description, short_description, category_ids, attributes, variations }) => handle(async () => {
|
|
41
53
|
let vars = variations;
|
|
54
|
+
// Enrich attribute axes with the id + keyword shape the storefront variation selector
|
|
55
|
+
// needs (a raw {name,values} declares the axis but attaches it to no variation).
|
|
56
|
+
const attrDefs = (attributes || []).map((a) => ({
|
|
57
|
+
id: randomUUID(),
|
|
58
|
+
name: a.name,
|
|
59
|
+
values: a.values,
|
|
60
|
+
keyword: (a.values || []).map((v) => ({ keyValue: attrKeyValue(v), value: v })),
|
|
61
|
+
}));
|
|
42
62
|
if (!vars || !vars.length) {
|
|
43
63
|
if (price == null)
|
|
44
64
|
throw new Error("Provide `price` (or explicit `variations`) to create a product.");
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
65
|
+
if (attrDefs.length) {
|
|
66
|
+
// Expand the attribute axes into ONE variation per value-combination, each carrying
|
|
67
|
+
// `fields` — otherwise the axis is declared but bound to nothing (not selectable).
|
|
68
|
+
const axisLists = attrDefs.map((a) => a.values.map((v) => ({ name: a.name, value: v })));
|
|
69
|
+
const combos = cartesian(axisLists);
|
|
70
|
+
vars = combos.map((combo) => ({
|
|
71
|
+
custom_id: `SKU-${randomUUID().slice(0, 8)}`,
|
|
48
72
|
retail_price: price,
|
|
49
73
|
original_price: original_price ?? price,
|
|
50
74
|
remain_quantity: stock ?? 100,
|
|
51
75
|
images: images || [],
|
|
52
76
|
weight: 0,
|
|
53
|
-
fields:
|
|
54
|
-
|
|
55
|
-
|
|
77
|
+
fields: combo.map((c) => ({ id: randomUUID(), name: c.name, value: c.value })),
|
|
78
|
+
is_hidden: false,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
vars = [
|
|
83
|
+
{
|
|
84
|
+
custom_id: sku || `SKU-${randomUUID().slice(0, 8)}`,
|
|
85
|
+
retail_price: price,
|
|
86
|
+
original_price: original_price ?? price,
|
|
87
|
+
remain_quantity: stock ?? 100,
|
|
88
|
+
images: images || [],
|
|
89
|
+
weight: 0,
|
|
90
|
+
fields: [],
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|
|
56
94
|
}
|
|
57
95
|
else {
|
|
58
|
-
// Normalise: fill SKU / original_price / stock defaults per variation.
|
|
96
|
+
// Normalise: fill SKU / original_price / stock defaults per variation; mint field ids.
|
|
59
97
|
vars = vars.map((v) => ({
|
|
60
98
|
custom_id: v.custom_id || `SKU-${randomUUID().slice(0, 8)}`,
|
|
61
99
|
retail_price: v.retail_price,
|
|
@@ -63,7 +101,7 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
|
|
|
63
101
|
remain_quantity: v.remain_quantity ?? 100,
|
|
64
102
|
images: v.images || [],
|
|
65
103
|
weight: v.weight ?? 0,
|
|
66
|
-
fields: v.fields || [],
|
|
104
|
+
fields: (v.fields || []).map((f) => ({ id: f.id || randomUUID(), name: f.name, value: f.value })),
|
|
67
105
|
is_hidden: false,
|
|
68
106
|
}));
|
|
69
107
|
}
|
|
@@ -75,7 +113,7 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
|
|
|
75
113
|
// endpoint, so it must be [] — never omitted — even for a no-variation product.)
|
|
76
114
|
categories: category_ids || [],
|
|
77
115
|
ribbons: [],
|
|
78
|
-
product_attributes:
|
|
116
|
+
product_attributes: attrDefs,
|
|
79
117
|
...(description ? { description } : {}),
|
|
80
118
|
// short_description is an ARRAY of {description} blocks on the real product shape.
|
|
81
119
|
...(short_description ? { short_description: [{ description: short_description }] } : {}),
|
package/dist/tools/page-draft.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { PAGE_TYPE_NUM, PAGE_TYPE_FLAG, PAGE_KINDS, buildPageSeo } from "./builder.js";
|
|
2
|
+
import { PAGE_TYPE_NUM, PAGE_TYPE_FLAG, PAGE_KINDS, buildPageSeo, normalizeSlug } from "./builder.js";
|
|
3
3
|
import { validatePage, finalizeForRender, reassignIds } from "../builder/page.js";
|
|
4
4
|
import { createDraft, getDraft, setDraft, appendDraftSection, listDrafts, delDraft, } from "../persistence/draft-cache.js";
|
|
5
5
|
// Friendly result when a draft is gone (disposable cache: expired ~2h or restart).
|
|
@@ -27,7 +27,7 @@ function newPageId(res) {
|
|
|
27
27
|
export function registerPageDraftTools(server, api, handle) {
|
|
28
28
|
server.tool("start_page_draft", `Start a page draft (no network). Build a multi-section page safely: cache each section with add_draft_section, then commit_page_draft persists it to the backend INCREMENTALLY (resumable on timeout). Use this instead of build_page for large/multi-section pages. The draft cache is DISPOSABLE (Redis on the remote server when REDIS_URL is set, in-memory otherwise; sliding ~2h TTL) — if a draft is ever lost, just re-send the sections, never a failure.`, {
|
|
29
29
|
name: z.string().describe("Page name"),
|
|
30
|
-
slug: z.string().describe("URL slug, e.g. '/
|
|
30
|
+
slug: z.string().describe("URL slug WITHOUT a leading slash, e.g. 'about', 'collections', 'cart'. A leading '/' is stripped automatically (the storefront matches the bare path segment, so '/cart' would 404). Store pages MUST use: category='collections', product='products', cart='cart', checkout='checkout', thank-you='complete'. Homepage needs no slug (is_homepage:true)."),
|
|
31
31
|
type: z
|
|
32
32
|
.enum(PAGE_KINDS)
|
|
33
33
|
.optional()
|
|
@@ -140,11 +140,13 @@ RESUMABLE: if a request fails mid-commit, the draft keeps its page_id + committe
|
|
|
140
140
|
await setDraft(draft);
|
|
141
141
|
}
|
|
142
142
|
// All sections committed → apply slug / homepage / SEO, then drop the draft.
|
|
143
|
+
// Strip a leading "/" so the storefront's bare-segment match resolves (else 404).
|
|
144
|
+
const cleanSlug = normalizeSlug(draft.meta.slug);
|
|
143
145
|
const seoBlock = draft.meta.seo ? buildPageSeo(draft.meta.seo) : null;
|
|
144
|
-
if (
|
|
146
|
+
if (cleanSlug || draft.meta.is_homepage || (seoBlock && Object.keys(seoBlock).length)) {
|
|
145
147
|
await api
|
|
146
148
|
.updatePage(draft.page_id, {
|
|
147
|
-
...(
|
|
149
|
+
...(cleanSlug ? { slug: cleanSlug } : {}),
|
|
148
150
|
...(draft.meta.is_homepage ? { is_homepage: true } : {}),
|
|
149
151
|
...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
|
|
150
152
|
})
|
package/dist/tools/pages.js
CHANGED
|
@@ -3,7 +3,7 @@ import { CUSTOM_CODE_GUIDE } from "../guides.js";
|
|
|
3
3
|
import { getConfirmMode } from "./context.js";
|
|
4
4
|
import { normalizeEvents } from "../builder/events.js";
|
|
5
5
|
import { normalizeBindings } from "../builder/bindings.js";
|
|
6
|
-
import { PAGE_TYPE_NUM, PAGE_KINDS, buildPageSeo } from "./builder.js";
|
|
6
|
+
import { PAGE_TYPE_NUM, PAGE_KINDS, buildPageSeo, normalizeSlug } from "./builder.js";
|
|
7
7
|
/**
|
|
8
8
|
* Page source utilities.
|
|
9
9
|
*
|
|
@@ -296,7 +296,7 @@ Examples:
|
|
|
296
296
|
}));
|
|
297
297
|
server.tool("create_page", "Create a new (empty) page. For a page with content use build_page instead. type is a KIND (main/store/member/blog/custom/error/maintain) mapped to the numeric backend type; pass seo so it doesn't publish with an empty title.", {
|
|
298
298
|
name: z.string().describe("Page name"),
|
|
299
|
-
slug: z.string().describe("URL slug
|
|
299
|
+
slug: z.string().describe("URL slug WITHOUT a leading slash, e.g. 'about', 'collections', 'cart'. A leading '/' is stripped automatically (the storefront matches the bare path segment, so '/cart' would 404). Homepage needs no slug (pass is_homepage:true)."),
|
|
300
300
|
type: z.enum(PAGE_KINDS).optional().describe("Page kind (main/store/member/blog/custom/error/maintain). store/member/blog need their data-source flag enabled — prefer build_page which auto-enables it."),
|
|
301
301
|
is_homepage: z.boolean().default(false).describe("Set as homepage"),
|
|
302
302
|
seo: z
|
|
@@ -304,30 +304,39 @@ Examples:
|
|
|
304
304
|
.optional()
|
|
305
305
|
.describe("SEO → settings.seo (title/description/keyword/favicon/thumbnail). Tokens {{name_page}}/{{name_site}} allowed."),
|
|
306
306
|
}, ({ name, slug, type, is_homepage, seo }) => handle(async () => {
|
|
307
|
+
const cleanSlug = normalizeSlug(slug);
|
|
307
308
|
const typeNum = type ? PAGE_TYPE_NUM[type] : undefined;
|
|
308
309
|
const created = await api.createPage({ name, ...(typeNum != null ? { type: typeNum } : {}) });
|
|
309
310
|
invalidatePageCache();
|
|
310
311
|
const pageId = (created && (created.id || created.data?.id || created.page?.id)) || null;
|
|
311
312
|
const seoBlock = seo ? buildPageSeo(seo) : null;
|
|
312
|
-
if (pageId && (
|
|
313
|
+
if (pageId && (cleanSlug || is_homepage || (seoBlock && Object.keys(seoBlock).length))) {
|
|
313
314
|
await api
|
|
314
315
|
.updatePage(pageId, {
|
|
315
|
-
...(
|
|
316
|
+
...(cleanSlug ? { slug: cleanSlug } : {}),
|
|
316
317
|
...(is_homepage ? { is_homepage: true } : {}),
|
|
317
318
|
...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
|
|
318
319
|
})
|
|
319
320
|
.catch(() => { });
|
|
320
321
|
invalidatePageCache();
|
|
321
322
|
}
|
|
322
|
-
return { success: true, page_id: pageId, name, slug, type: type ?? null, raw: pageId ? undefined : created };
|
|
323
|
+
return { success: true, page_id: pageId, name, slug: cleanSlug ?? null, type: type ?? null, raw: pageId ? undefined : created };
|
|
323
324
|
}));
|
|
324
325
|
server.tool("update_page", "Update page properties (name, slug, settings, custom code)", {
|
|
325
326
|
page_id: z.string().describe("Page ID"),
|
|
326
327
|
name: z.string().optional().describe("New name"),
|
|
327
|
-
slug: z.string().optional().describe("New slug"),
|
|
328
|
+
slug: z.string().optional().describe("New slug WITHOUT a leading slash (e.g. 'about', 'cart'). A leading '/' is stripped automatically — '/cart' would 404 on the storefront."),
|
|
328
329
|
is_homepage: z.boolean().optional().describe("Set as homepage"),
|
|
329
330
|
settings: z.record(z.any()).optional().describe("Page settings"),
|
|
330
331
|
}, ({ page_id, ...params }) => handle(async () => {
|
|
332
|
+
// Normalize slug (strip leading "/") so the storefront's bare-segment match resolves.
|
|
333
|
+
if (params.slug !== undefined) {
|
|
334
|
+
const clean = normalizeSlug(params.slug);
|
|
335
|
+
if (clean)
|
|
336
|
+
params.slug = clean;
|
|
337
|
+
else
|
|
338
|
+
delete params.slug;
|
|
339
|
+
}
|
|
331
340
|
const res = await api.updatePage(page_id, params);
|
|
332
341
|
invalidatePageCache();
|
|
333
342
|
return res;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-storefront-mcp",
|
|
3
|
-
"version": "1.31.
|
|
3
|
+
"version": "1.31.8",
|
|
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",
|