webcake-storefront-mcp 1.33.2 → 1.34.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/dist/api.js +23 -0
- package/dist/builder/attributes.js +8 -5
- package/dist/builder/bindings.js +11 -1
- package/dist/builder/catalog.js +6 -2
- package/dist/builder/events.js +18 -3
- package/dist/builder/factory.js +37 -0
- package/dist/builder/guide.js +21 -2
- package/dist/builder/page.js +60 -14
- package/dist/changelog.json +7 -7
- package/dist/server.js +1 -1
- package/dist/tools/builder-extras.js +7 -1
- package/dist/tools/builder.js +56 -5
- package/dist/tools/global-section-write.js +1 -1
- package/dist/tools/page-draft.js +1 -1
- package/dist/tools/site-style.js +2 -1
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -9,6 +9,7 @@ export class WebcakeCmsApi {
|
|
|
9
9
|
sessionId;
|
|
10
10
|
_adminToken;
|
|
11
11
|
_cmsApiKey;
|
|
12
|
+
_siteBps = null;
|
|
12
13
|
constructor({ baseUrl, token, siteId, sessionId }) {
|
|
13
14
|
this.baseUrl = (baseUrl || "").replace(/\/$/, "");
|
|
14
15
|
this.token = token;
|
|
@@ -101,6 +102,28 @@ export class WebcakeCmsApi {
|
|
|
101
102
|
this.siteId = siteId;
|
|
102
103
|
this._adminToken = null;
|
|
103
104
|
this._cmsApiKey = null;
|
|
105
|
+
this._siteBps = null;
|
|
106
|
+
}
|
|
107
|
+
// ── Section template library (designer-made blocks behind the editor's template picker) ──
|
|
108
|
+
listTemplateCategories() {
|
|
109
|
+
return this.request("GET", `/api/v1/templates/categories`);
|
|
110
|
+
}
|
|
111
|
+
/** Templates (each { id, name, thumbnail, source: JSON string of ONE node, usually a SECTION }).
|
|
112
|
+
* Query: id = category id (omit for all), term, page, limit. */
|
|
113
|
+
listTemplates(query = {}) {
|
|
114
|
+
return this.request("GET", `/api/v1/templates`, { query });
|
|
115
|
+
}
|
|
116
|
+
/** The site's breakpoints ({ bpN: [minW, maxW] } from settings, which may be a JSON string),
|
|
117
|
+
* or null when unset/unreadable — callers then use the standard four. Cached per site. */
|
|
118
|
+
getSiteBreakpoints() {
|
|
119
|
+
this._siteBps ??= this.getSite()
|
|
120
|
+
.then((res) => {
|
|
121
|
+
const site = res?.data ?? res;
|
|
122
|
+
const settings = typeof site?.settings === "string" ? JSON.parse(site.settings || "{}") : site?.settings;
|
|
123
|
+
return settings?.breakpoints || null;
|
|
124
|
+
})
|
|
125
|
+
.catch(() => null);
|
|
126
|
+
return this._siteBps;
|
|
104
127
|
}
|
|
105
128
|
/** Update auth token (in-memory) */
|
|
106
129
|
switchToken(token) {
|
|
@@ -23,7 +23,7 @@ export const SHARED_SPECIALS = {
|
|
|
23
23
|
element_async: "Mark the element async/lazy (advanced — usually leave unset).",
|
|
24
24
|
event_name_custom: "Custom analytics event name fired on interaction.",
|
|
25
25
|
tabIndex: "Bind this element to a tab item (TAB-ITEM-…) so a tabs element shows/hides it.",
|
|
26
|
-
text_animation_type: "
|
|
26
|
+
text_animation_type: "Animated words in text/button: set to the type used by the <span class=\"animationText\" data-type …> inside specials.text (typed|typed2|clip|rotate3|animate.css name). See get_build_guide → Motion.",
|
|
27
27
|
};
|
|
28
28
|
// Generic layout keys every node's runtime.config may carry (documented in the build
|
|
29
29
|
// guide); listed here so describeAttributes can flag them as 'generic, not element-specific'.
|
|
@@ -48,7 +48,7 @@ export const SHARED_KEY_DICT = {
|
|
|
48
48
|
src: "Media URL (hosted).",
|
|
49
49
|
image_alt: "Alt text (SEO / accessibility).",
|
|
50
50
|
typographyTheme: "Apply a site typography preset.",
|
|
51
|
-
text_animation_type: "
|
|
51
|
+
text_animation_type: "Animated-words type (typed|typed2|clip|rotate3|…), paired with an animationText span in the text.",
|
|
52
52
|
marker: "List marker style (none/dotList/numberList/imageList).",
|
|
53
53
|
tabIndex: "Tab/focus order.",
|
|
54
54
|
shapeType: "Shape kind (rectangle/etc.).",
|
|
@@ -157,7 +157,7 @@ export const ELEMENT_ATTRS = {
|
|
|
157
157
|
"video-dataset": { specials: { autoRotateInMobile: "Rotate to landscape on mobile (boolean).", show_list_video_lesson: "Show the lesson playlist (boolean).", show_next_video_lesson: "Show the next-lesson button (boolean)." }, events: "none", bindings: ["lesson::lesson_video"], notes: "Video bound to a dataset field (course lesson video)." },
|
|
158
158
|
rectangle: {
|
|
159
159
|
specials: { shapeType: "Shape kind.", custom_css: "Raw CSS for the shape." },
|
|
160
|
-
config: { mask: "Mask SVG/shape (clips the box into an icon).", maskId: "Mask reference.", custom_mask: "Custom mask SVG markup.", fixedPosition: "
|
|
160
|
+
config: { mask: "Mask SVG/shape (clips the box into an icon).", maskId: "Mask reference.", custom_mask: "Custom mask SVG markup.", fixedPosition: "With position:'pinned' (+ style.position:'fixed'): where it floats — bottom_right|bottom_left|top_right|… — e.g. a floating badge. See get_build_guide → Sticky & pinned.", animation: "Entrance/attention animation { type, delay, duration, repeat }: type = animate.css name (fadeInUp, zoomIn, bounceIn, pulse…), delay/duration in s, repeat 1|2|3|'infinite'. See get_build_guide → Motion." },
|
|
161
161
|
events: "click, hover — often used as a clickable card/background.",
|
|
162
162
|
notes: "Coloured box / divider / background. Style via runtime.style (background, border*, boxShadow).",
|
|
163
163
|
},
|
|
@@ -178,7 +178,7 @@ export const ELEMENT_ATTRS = {
|
|
|
178
178
|
events: "none",
|
|
179
179
|
notes: "Top-level band; centred 3-column grid. Children go in the centre column (new_section handles this). Make a header section sticky with config.stickyPosition; add a video/pattern background via config.",
|
|
180
180
|
},
|
|
181
|
-
container: { specials: { tabIndex: "Tab order.", custom_class: "CSS class.", custom_css: "Raw CSS." }, config: { maxWidth: "Max width.", maxWidthUnit: "Max-width unit.", fixedPosition: "Float/pin the box (e.g. bottom_right) — for back-to-top / chat bubbles.", fixedRight: "Pinned offset from right (px).", fixedBottom: "Pinned offset from bottom (px).", fixedLeft: "Pinned offset from left (px).", stickyPosition: "Sticky mode.", stickyTop: "Sticky offset from top (px).", stickyBottom: "Sticky offset from bottom (px).", pinnedPoint: "Section id where pinning starts.", unpinnedPoint: "Section id where pinning ends.", useAutoScroll: "Auto-scroll the box (boolean).", initMaxHeight: "Collapsed max-height (px).", isUseInitMaxHeight: "Animate from initMaxHeight (boolean).", maxHeight: "Expanded max-height (px).", animation: "Entrance animation." }, events: "click, hover — clickable groups are common.", notes: "Generic grid box; nest children with their own grid. Pin a floating button/sidebar via config.fixedPosition; sticky filters via stickyTop + pinnedPoint/unpinnedPoint." },
|
|
181
|
+
container: { specials: { tabIndex: "Tab order.", custom_class: "CSS class.", custom_css: "Raw CSS." }, config: { maxWidth: "Max width.", maxWidthUnit: "Max-width unit.", fixedPosition: "Float/pin the box (e.g. bottom_right) — for back-to-top / chat bubbles.", fixedRight: "Pinned offset from right (px).", fixedBottom: "Pinned offset from bottom (px).", fixedLeft: "Pinned offset from left (px).", stickyPosition: "Sticky mode.", stickyTop: "Sticky offset from top (px).", stickyBottom: "Sticky offset from bottom (px).", pinnedPoint: "Section id where pinning starts.", unpinnedPoint: "Section id where pinning ends.", useAutoScroll: "Auto-scroll the box (boolean).", initMaxHeight: "Collapsed max-height (px).", isUseInitMaxHeight: "Animate from initMaxHeight (boolean).", maxHeight: "Expanded max-height (px).", animation: "Entrance/attention animation { type, delay, duration, repeat }: type = animate.css name (fadeInUp, zoomIn, bounceIn, pulse…), delay/duration in s, repeat 1|2|3|'infinite'. See get_build_guide → Motion." }, events: "click, hover — clickable groups are common.", notes: "Generic grid box; nest children with their own grid. Pin a floating button/sidebar via config.fixedPosition; sticky filters via stickyTop + pinnedPoint/unpinnedPoint." },
|
|
182
182
|
row: { events: "none", notes: "Simple horizontal wrapper." },
|
|
183
183
|
tabs: { specials: { activeTab: "Default active tab index.", tabs: "Tab definitions [{id,name}].", navMode: "How tabs switch: toggle | …", showNav: "Show the tab nav (boolean).", showBullet: "Show bullets (boolean).", showDivider: "Show a divider (boolean)." }, config: { navPosition: "top|left|right|bottom.", alignNav: "Nav alignment.", nav_display_style: "Nav style." }, events: "tab — fires when the active tab changes.", notes: "Tabbed container; each tab maps to a tab item the content reacts to (e.g. account panels, product detail/description/reviews)." },
|
|
184
184
|
collapse: { events: "none", notes: "Accordion container (collapse-item children)." },
|
|
@@ -288,7 +288,10 @@ export const ELEMENT_ATTRS = {
|
|
|
288
288
|
"grid-category": { specials: { showCategories: "Categories to show [{id,...}] (else all).", isAutoCatchMenuLevelFirst: "Auto-pick top-level categories (boolean)." }, config: { columns: "Categories per row.", minimum_card_width: "Min card width (autofit, px).", sizeThumbnail: "{width,height} of the category image.", responsive: "fixed | autofit.", imageBorderRadius: "Image radius (px).", cardBackground: "Card background.", cardBorderRadius: "Card radius.", name_padding_y: "Padding around the name." }, events: "none", notes: "Lists product categories. Create categories first (create_product_category)." },
|
|
289
289
|
"slider-category": { specials: { showCategories: "Categories to show [{id,...}].", on_hover: "Card hover effect (zoom-in|…)." }, config: { columns: "Visible categories.", minimum_card_width: "Min card width (px).", sizeThumbnail: "{width,height} of the image.", imageBorderRadius: "Image radius.", navSize: "Arrow size (px).", arrowColor: "Arrow colour." }, events: "none" },
|
|
290
290
|
"customer-address": { events: "none", bindings: ["customer_address::full_name", "customer_address::address", "customer_address::phone_number", "customer_address::pdc", "customer_address::is_default"], notes: "Member saved addresses." },
|
|
291
|
-
wishlist: { specials: { svg: "Icon SVG.", itemCount: "Badge count." }, config: { itemCountColor: "Badge text colour.", itemBackgroundColor: "Badge background.", itemCountFontSize: "Badge size.", itemCountFontWeight: "Badge weight.", itemCountBorderRadius: "Badge radius.", itemCountFontFamily: "Badge font." }, events: "click
|
|
291
|
+
wishlist: { specials: { svg: "Icon SVG.", itemCount: "Badge count." }, config: { itemCountColor: "Badge text colour.", itemBackgroundColor: "Badge background.", itemCountFontSize: "Badge size.", itemCountFontWeight: "Badge weight.", itemCountBorderRadius: "Badge radius.", itemCountFontFamily: "Badge font." }, events: "No built-in click: add an open_page event to your wishlist page (one holding a wishlist-container)." },
|
|
292
|
+
"wishlist-container": { notes: "Wishlist page block (store page). Children: ONE wishlist-item (template, repeated per saved product) + ONE wishlist-empty (shown when none). Editor default item = row of image-dataset (wishlist_item::product_image) | text-dataset name + price | button with event wishlist_remove_item; empty = text + button open_page back to the shop." },
|
|
293
|
+
"wishlist-item": { notes: "Per-product template inside wishlist-container — bind children to wishlist_item::product_name / product_price / product_original_price / product_image / product_short_description; a button with action wishlist_remove_item removes the product." },
|
|
294
|
+
"wishlist-empty": { notes: "Empty state inside wishlist-container (e.g. 'Chưa có sản phẩm yêu thích' + a button open_page to the shop)." },
|
|
292
295
|
"favorite-icon": { config: { mask: "Heart icon SVG mask.", colorActive: "Colour when favourited." }, events: "click — toggle favourite (add_wishlist_product / wishlist_remove_item).", notes: "Heart toggle on a product card; pairs with the wishlist element in the header." },
|
|
293
296
|
"bonus-items": { specials: { typeBonus: "combo_product | other." }, events: "none" },
|
|
294
297
|
// ── navigation ──
|
package/dist/builder/bindings.js
CHANGED
|
@@ -41,6 +41,16 @@ export const BINDING_DATASETS = {
|
|
|
41
41
|
summary: "Sale/discount badge overlaid on a product card.",
|
|
42
42
|
fields: ["product-overlay::product_sale", "product-overlay::product_discount", "product-overlay::product_total_sold", "product-overlay::product_url"],
|
|
43
43
|
},
|
|
44
|
+
wishlist_item: {
|
|
45
|
+
name: "wishlist_item",
|
|
46
|
+
page_type: "store",
|
|
47
|
+
repeater_parents: ["wishlist-item"],
|
|
48
|
+
summary: "A product the shopper saved to their wishlist (children of wishlist-item).",
|
|
49
|
+
fields: [
|
|
50
|
+
"wishlist_item::product_name", "wishlist_item::product_price", "wishlist_item::product_original_price",
|
|
51
|
+
"wishlist_item::product_image", "wishlist_item::product_short_description",
|
|
52
|
+
],
|
|
53
|
+
},
|
|
44
54
|
cart_item: {
|
|
45
55
|
name: "cart_item",
|
|
46
56
|
page_type: "store",
|
|
@@ -292,7 +302,7 @@ export function describeBindingsCatalog() {
|
|
|
292
302
|
return {
|
|
293
303
|
note: "Dataset elements (text-dataset/image-dataset/…) and children of a repeater (grid-product, cart-items, post-list…) carry bindings:[{ id, name, target, ...meta }]. Build via new_element opts.bindings (ids auto-minted) — e.g. new_element('text-dataset',{ bindings:[{ target:'product::product_price', show_tax:true }] }). A target only resolves on a page whose type enables its dataset (store→use_store, member→use_member, blog→use_blog) — build_page handles the flag. The optional meta keys (name_style, prefix_content, value, attr_id, …) control HOW the value renders/combines — see meta_keys.",
|
|
294
304
|
meta_keys: BINDING_META_KEYS,
|
|
295
|
-
page_type_required: { store: ["product", "product-overlay", "cart_item", "order", "order_item", "category", "bonus_item", "promotion_item", "attr", "search_result"], member: ["customer", "customer_address"], blog: ["post", "blog"], any: ["form", "general_info", "agency"] },
|
|
305
|
+
page_type_required: { store: ["product", "product-overlay", "cart_item", "wishlist_item", "order", "order_item", "category", "bonus_item", "promotion_item", "attr", "search_result"], member: ["customer", "customer_address"], blog: ["post", "blog"], any: ["form", "general_info", "agency"] },
|
|
296
306
|
repeater_context: REPEATER_CONTEXT,
|
|
297
307
|
datasets: Object.values(BINDING_DATASETS).map((ds) => ({
|
|
298
308
|
name: ds.name,
|
package/dist/builder/catalog.js
CHANGED
|
@@ -59,7 +59,7 @@ const CATEGORY_MEMBERS = {
|
|
|
59
59
|
"detect-address", "postal-code", "country", "input-file", "input-number",
|
|
60
60
|
"input-date", "input-search", "input-product-note", "otp-input", "rating-input",
|
|
61
61
|
"two-point-range", "identity", "search-form", "search-droppable", "color-group",
|
|
62
|
-
"switch",
|
|
62
|
+
"switch", "otp-method",
|
|
63
63
|
],
|
|
64
64
|
commerce: [
|
|
65
65
|
"grid-product", "slider-product", "product-gallery", "product-image-carousel",
|
|
@@ -69,7 +69,7 @@ const CATEGORY_MEMBERS = {
|
|
|
69
69
|
"bonus-items", "flash-sale", "promotions", "promotions-short", "grid-category",
|
|
70
70
|
"slider-category", "number-step", "warehouse", "warehouse-dataset",
|
|
71
71
|
"customer-address", "reward-point", "referral-code", "lucky-wheel", "tee-form",
|
|
72
|
-
"wishlist", "favorite-icon",
|
|
72
|
+
"wishlist", "favorite-icon", "wishlist-container", "wishlist-item", "wishlist-empty",
|
|
73
73
|
],
|
|
74
74
|
navigation: [
|
|
75
75
|
"menu", "menu-item", "menu-anchor-item", "submenu", "menu-droppable", "member-bar",
|
|
@@ -84,6 +84,7 @@ const CATEGORY_MEMBERS = {
|
|
|
84
84
|
],
|
|
85
85
|
course: [
|
|
86
86
|
"lesson-sidebar", "lesson-items", "next-lesson-droppable", "list-lesson-droppable",
|
|
87
|
+
"list-courses",
|
|
87
88
|
],
|
|
88
89
|
};
|
|
89
90
|
const CATEGORY_BY_TYPE = {};
|
|
@@ -122,6 +123,9 @@ const SUMMARY = {
|
|
|
122
123
|
"grid-product": "Product grid (lists products from the store).",
|
|
123
124
|
"slider-product": "Product slider/carousel.",
|
|
124
125
|
"cart-items": "Shopping cart line items.",
|
|
126
|
+
"wishlist-container": "Wishlist page list: holds a wishlist-item template + a wishlist-empty state.",
|
|
127
|
+
"wishlist-item": "Template repeated per saved product (bind children to wishlist_item::…).",
|
|
128
|
+
"wishlist-empty": "Shown instead of the items when the wishlist is empty.",
|
|
125
129
|
"cart-icon": "Cart icon with item count.",
|
|
126
130
|
"quantity-input": "Quantity stepper for products.",
|
|
127
131
|
payment: "Payment methods block.",
|
package/dist/builder/events.js
CHANGED
|
@@ -92,6 +92,21 @@ export const EVENT_ACTIONS = [
|
|
|
92
92
|
{ action: "lightbox", trigger: "click", category: "media", summary: "Open an image in a lightbox.", optional: ["lightboxLink"] },
|
|
93
93
|
{ action: "set_address_default", trigger: "click", category: "account", summary: "Mark a saved address as the default." },
|
|
94
94
|
{ action: "change_avatar", trigger: "click", category: "account", summary: "Change the member avatar." },
|
|
95
|
+
// From builderx_spa EventSetting.vue actions(); handled in the renderer (events.js, or
|
|
96
|
+
// common.ex / swiper.js / form.js / lesson-sidebar.js where noted).
|
|
97
|
+
{ action: "filter", trigger: "click", category: "ui", summary: "Apply the product filters listed in `filters` (filter element ids; their selections become URL params) — e.g. an 'Apply' button on a filter panel. and_filters must all be chosen, or_filters at least one. redirect_type page|category (+ page_id / category_id) sends the shopper to that listing.", required: ["filters"], optional: ["and_filters", "or_filters", "or_filter_is_select_only_one", "product_id_auto_redirect", "redirect_type", "page_id", "category_id"], target_in_page: ["filters", "and_filters", "or_filters"] },
|
|
98
|
+
{ action: "change_language", trigger: "click", category: "navigation", summary: "Language switcher: reopen the current page/product/category in another language. change_language = target language code (e.g. 'en').", required: ["change_language"] },
|
|
99
|
+
{ action: "change_text_style", trigger: "click", category: "content", summary: "Restyle another text element (e.g. highlight the active option). text_style = style object.", required: ["target_id", "text_style"], target_in_page: ["target_id"] },
|
|
100
|
+
{ action: "open_product_link", trigger: "click", category: "navigation", summary: "Open one of the product's custom links (target_custom_links = 1-based index), else default_url. target_href _self|_blank.", optional: ["target_custom_links", "default_url", "target_href"] },
|
|
101
|
+
{ action: "add_bonus_product_to_cart", trigger: "click", category: "cart", summary: "Add a promotion's bonus/gift product to the cart (button inside a promotion bonus item).", optional: ["open_page", "productNoteElId"] },
|
|
102
|
+
{ action: "send_botcake_mess", trigger: "click", category: "contact", summary: "Open a Botcake chatbot conversation.", required: ["botcake_ref_url"] },
|
|
103
|
+
{ action: "delete_customer_address", trigger: "click", category: "account", summary: "Delete this saved address (inside a customer-address list on the member profile page)." },
|
|
104
|
+
{ action: "course_submit", trigger: "click", category: "form", summary: "Submit a course assignment (uploads files). The customer must be logged in and have bought the course." },
|
|
105
|
+
{ action: "auto_next_track", trigger: "click", category: "media", summary: "Course lesson sidebar switch: when on, auto-play the next lesson after a video ends (lesson-sidebar.js)." },
|
|
106
|
+
{ action: "play_video", trigger: "tab", category: "media", summary: "When a slider reaches slide target_slide (1-based), play video element target_video (swiper.js).", required: ["target_slide", "target_video"], target_in_page: ["target_video"] },
|
|
107
|
+
{ action: "close_search_droppable", trigger: "onenter", category: "ui", summary: "Close the search suggestions dropdown when Enter is pressed in the search box." },
|
|
108
|
+
{ action: "bold", trigger: "hover", category: "hover", summary: "Make the text bold on hover (common.ex)." },
|
|
109
|
+
{ action: "create_article", trigger: "submit", category: "form", summary: "Form submit creates a blog article from the form fields — user-submitted posts (form.js)." },
|
|
95
110
|
];
|
|
96
111
|
const ACTION_BY_NAME = {};
|
|
97
112
|
for (const a of EVENT_ACTIONS)
|
|
@@ -169,9 +184,9 @@ export function validateEvents(node, allIds) {
|
|
|
169
184
|
warnings.push(`Event "${ev.action}" on "${where}" is missing required field "${f}".`);
|
|
170
185
|
}
|
|
171
186
|
for (const f of def.target_in_page || []) {
|
|
172
|
-
const v
|
|
173
|
-
|
|
174
|
-
|
|
187
|
+
for (const v of [].concat(ev[f] ?? [])) // a field may hold one id or a list (filter's filters)
|
|
188
|
+
if (v && !allIds.has(v))
|
|
189
|
+
warnings.push(`Event "${ev.action}" on "${where}" targets missing element "${v}" (field ${f}).`);
|
|
175
190
|
}
|
|
176
191
|
}
|
|
177
192
|
return { errors, warnings };
|
package/dist/builder/factory.js
CHANGED
|
@@ -528,6 +528,22 @@ export const createWishlist = ({ svg, itemCount, config, children, style } = {})
|
|
|
528
528
|
wistlist.children = children || [];
|
|
529
529
|
return wistlist;
|
|
530
530
|
};
|
|
531
|
+
// Wishlist page blocks. The editor has no factory for these (it seeds them from
|
|
532
|
+
// common/wishlist-container.json); the renderer clones the wishlist-item <template> once per
|
|
533
|
+
// saved product (assets/render/wishlist-container.js) and shows wishlist-empty when there are none.
|
|
534
|
+
const plainContainer = (prefix, type) => (opts = {}) => {
|
|
535
|
+
const el = cloneDeep(SKELETON);
|
|
536
|
+
el.id = prefix + randomString(8);
|
|
537
|
+
el.type = type;
|
|
538
|
+
el.runtime.style = { ...opts.style };
|
|
539
|
+
el.runtime.config = { ...opts.config };
|
|
540
|
+
el.children = opts.children || [];
|
|
541
|
+
el.specials = { ...opts.specials };
|
|
542
|
+
return el;
|
|
543
|
+
};
|
|
544
|
+
export const createWishlistContainer = plainContainer('WISHLIST-CONTAINER-', 'wishlist-container');
|
|
545
|
+
export const createWishlistItem = plainContainer('WISHLIST-ITEM-', 'wishlist-item');
|
|
546
|
+
export const createWishlistEmpty = plainContainer('WISHLIST-EMPTY-', 'wishlist-empty');
|
|
531
547
|
export const createFavoriteIcon = (opts = {}) => {
|
|
532
548
|
const favorite = cloneDeep(SKELETON);
|
|
533
549
|
favorite.id = 'FAVORITE-ICON-' + randomString(8);
|
|
@@ -1763,3 +1779,24 @@ export const createLessonItems = (opts = {}) => {
|
|
|
1763
1779
|
nextlesson.specials = { ...opts.specials };
|
|
1764
1780
|
return nextlesson;
|
|
1765
1781
|
};
|
|
1782
|
+
// Ported from builderx_spa factory.js (4ea6dd53e, e107a53e5).
|
|
1783
|
+
export const createOtpMethod = (opts = {}) => {
|
|
1784
|
+
const otpMethod = cloneDeep(SKELETON);
|
|
1785
|
+
otpMethod.id = 'OTP-METHOD-' + randomString(8);
|
|
1786
|
+
otpMethod.type = 'otp-method';
|
|
1787
|
+
otpMethod.children = opts.children || [];
|
|
1788
|
+
otpMethod.runtime.style = { ...opts.style };
|
|
1789
|
+
otpMethod.runtime.config = { ...opts.config };
|
|
1790
|
+
otpMethod.specials = { ...opts.specials };
|
|
1791
|
+
return otpMethod;
|
|
1792
|
+
};
|
|
1793
|
+
export const createListCourses = (opts = {}) => {
|
|
1794
|
+
const el = cloneDeep(SKELETON);
|
|
1795
|
+
el.id = 'LIST-COURSES-' + randomString(8);
|
|
1796
|
+
el.type = 'list-courses';
|
|
1797
|
+
el.runtime.style = { ...opts.style };
|
|
1798
|
+
el.runtime.config = { ...opts.config };
|
|
1799
|
+
el.children = opts.children || [];
|
|
1800
|
+
el.specials = { ...opts.specials };
|
|
1801
|
+
return el;
|
|
1802
|
+
};
|
package/dist/builder/guide.js
CHANGED
|
@@ -4,7 +4,9 @@ export const BUILD_GUIDE = `# BuilderX page authoring guide
|
|
|
4
4
|
|
|
5
5
|
## DESIGN SYSTEM — lock this FIRST, every build (this is what makes free composition look DESIGNED)
|
|
6
6
|
There are NO page templates here and NO seeded data — you COMPOSE every page from elements
|
|
7
|
-
based on the user's actual goal.
|
|
7
|
+
based on the user's actual goal. (One optional shortcut: a single section may start from the
|
|
8
|
+
designer section library — list_section_templates → add_section({ template }) — then restyle it
|
|
9
|
+
to the locked system below.) To make that composition look intentional (not a random pile
|
|
8
10
|
of default elements), LOCK a small design system before you place anything, and reuse it on
|
|
9
11
|
EVERY section and EVERY page:
|
|
10
12
|
- PALETTE — from the site THEME matrix vars \`var(--color_RC)\` (see Colours below). Pick: page
|
|
@@ -347,7 +349,7 @@ create the globals — they embed into each page's source. If you later overwrit
|
|
|
347
349
|
(delete_global_section by the section NODE id, then create once) to re-embed cleanly. Edit a global
|
|
348
350
|
later with update_global_section_element(s) and it updates on every page at once.
|
|
349
351
|
|
|
350
|
-
STICKY & PINNED (runtime.config, on a section or
|
|
352
|
+
STICKY & PINNED (runtime.config, on a section or a text/image/button/shape/container element):
|
|
351
353
|
position:"sticky" sticks while scrolling — the usual sticky header: the header section gets
|
|
352
354
|
{ position:"sticky", stickyPosition:"autotop", stickyTop:0 }. stickyPosition:
|
|
353
355
|
autotop (default) | autobottom | default (both); offsets stickyTop / stickyBottom (px).
|
|
@@ -357,6 +359,23 @@ STICKY & PINNED (runtime.config, on a section or any element):
|
|
|
357
359
|
fixedTop / fixedLeft / fixedRight / fixedBottom (px, default 20).
|
|
358
360
|
Anything else (or unset) = normal flow. stickyPosition alone does nothing without position:"sticky".
|
|
359
361
|
|
|
362
|
+
MOTION (text, image, button, shape, container) — tasteful: one entrance per block, short delays:
|
|
363
|
+
runtime.config.animation = { type:"fadeInUp", delay:0.2, duration:1, repeat:1 } // seconds; repeat 1|2|3|"infinite"
|
|
364
|
+
type = an animate.css name: fadeIn / fadeInUp / fadeInLeft / zoomIn / bounceIn / backInUp / slideInUp /
|
|
365
|
+
flipInX … (attention seekers pulse / heartBeat / tada / shakeX — "infinite" only on one small CTA).
|
|
366
|
+
Stagger a row of cards with delay 0, 0.15, 0.3 … so they cascade in.
|
|
367
|
+
ANIMATED WORDS (typing / rotating headline, text or button): wrap the words in specials.text with
|
|
368
|
+
<span class="animationText" data-type="typed" data-word="Cà phê\\nBánh ngọt\\nTrà sữa"
|
|
369
|
+
data-delay="0" data-duration="1" data-repeat="infinite">Cà phê</span>
|
|
370
|
+
and set specials.text_animation_type to the same type. data-type: typed | typed2 | clip | rotate3 (or an
|
|
371
|
+
animate.css name); data-word = the words to cycle, one per line.
|
|
372
|
+
HOVER feedback is an event: { eventName:"hover", action:"scale" | "transform_y" | "change_background" |
|
|
373
|
+
"change_text_color" | "bold" …, duration:0.2 } (duration = transition seconds, default 0.1).
|
|
374
|
+
|
|
375
|
+
TYPOGRAPHY PRESETS: the theme defines 9 text styles — use them so text follows the site theme:
|
|
376
|
+
style:{ font:"var(--font_N)", color:"var(--font_color_N)" } N: 0-5 = H1-H6, 6-8 = paragraph large/normal/small.
|
|
377
|
+
Don't also set fontSize/fontWeight/fontFamily on that node (they'd override the preset).
|
|
378
|
+
|
|
360
379
|
### HEADER must be RESPONSIVE — add a MOBILE MENUBAR, don't just let nav stack
|
|
361
380
|
A header built as one horizontal row of nav links looks fine on desktop but on a phone the links
|
|
362
381
|
squash or collapse into an ugly vertical pile. Build a header that carries TWO navs and swap them
|
package/dist/builder/page.js
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
// their children in a CSS grid driven by runtime.config (grid / columns / rows) and each
|
|
5
5
|
// child's runtime.config (columnStart/End, rowStart/End, constraintX/Y). The helpers here
|
|
6
6
|
// produce that structure the same way the builder does, so generated pages render.
|
|
7
|
-
import { buildElement, isKnownType, ELEMENT_TYPES } from "./catalog.js";
|
|
7
|
+
import { buildElement, isKnownType, ELEMENT_TYPES, CONTAINER_TYPE_SET } from "./catalog.js";
|
|
8
8
|
import { randomString } from "./factory.js";
|
|
9
9
|
import { validateEvents } from "./events.js";
|
|
10
10
|
import { validateBindings } from "./bindings.js";
|
|
11
|
-
import { BREAKPOINTS, genGridByBp, SECTION_CONTENT_COL_START, SECTION_CONTENT_COL_END, } from "./grid.js";
|
|
11
|
+
import { BREAKPOINTS, BREAKPOINT_KEYS, genGridByBp, SECTION_CONTENT_COL_START, SECTION_CONTENT_COL_END, } from "./grid.js";
|
|
12
12
|
const clone = (o) => structuredClone(o);
|
|
13
13
|
/** Parse a CSS px length ("52px") or a bare number (52) → number, else null. */
|
|
14
14
|
function pxToNum(v) {
|
|
@@ -349,6 +349,25 @@ export function validatePage(source) {
|
|
|
349
349
|
warnings.push(...r.warnings);
|
|
350
350
|
}
|
|
351
351
|
});
|
|
352
|
+
// Third pass (needs ancestry): structures the renderer silently mishandles. Warnings only.
|
|
353
|
+
const checkTree = (node, ancestors) => {
|
|
354
|
+
if (!node || typeof node !== "object")
|
|
355
|
+
return;
|
|
356
|
+
const type = node.type;
|
|
357
|
+
const kids = Array.isArray(node.children) ? node.children : [];
|
|
358
|
+
if (!ancestors.length && type && type !== "section" && type !== "popup")
|
|
359
|
+
warnings.push(`Top-level node "${node.id}" is a ${type}: pages render only sections at the top level — wrap it in a section.`);
|
|
360
|
+
if (kids.length && type && isKnownType(type) && !CONTAINER_TYPE_SET.has(type))
|
|
361
|
+
warnings.push(`"${node.id}" (${type}) has ${kids.length} child node(s), but ${type} is not a container — they won't render.`);
|
|
362
|
+
if (type === "submit-button" && !ancestors.some((t) => /form/.test(t)))
|
|
363
|
+
warnings.push(`Submit button "${node.id}" is not inside a form — it has nothing to submit.`);
|
|
364
|
+
if ((node.bindings || []).some((b) => b?.name === "wishlist_item") && !ancestors.includes("wishlist-item"))
|
|
365
|
+
warnings.push(`"${node.id}" binds wishlist_item::… outside a wishlist-item — those fields only resolve per saved product.`);
|
|
366
|
+
for (const c of kids)
|
|
367
|
+
checkTree(c, [...ancestors, type]);
|
|
368
|
+
};
|
|
369
|
+
for (const s of source.sections)
|
|
370
|
+
checkTree(s, []);
|
|
352
371
|
return {
|
|
353
372
|
valid: errors.length === 0,
|
|
354
373
|
...(errors.length ? { errors } : {}),
|
|
@@ -356,15 +375,39 @@ export function validatePage(source) {
|
|
|
356
375
|
stats: { sections: source.sections.length, total_elements: total, element_types: typeCounts },
|
|
357
376
|
};
|
|
358
377
|
}
|
|
359
|
-
/**
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
378
|
+
/** The site's breakpoints if they're a usable map that differs from the standard four, else null. */
|
|
379
|
+
function customBreakpoints(site) {
|
|
380
|
+
if (!site || typeof site !== "object")
|
|
381
|
+
return null;
|
|
382
|
+
const entries = Object.entries(site);
|
|
383
|
+
if (!entries.length || !entries.every(([, r]) => Array.isArray(r) && typeof r[0] === "number" && typeof r[1] === "number"))
|
|
384
|
+
return null;
|
|
385
|
+
const standard = entries.length === BREAKPOINT_KEYS.length &&
|
|
386
|
+
entries.every(([k, [lo, hi]]) => BREAKPOINTS[k] && BREAKPOINTS[k][0] === lo && BREAKPOINTS[k][1] === hi);
|
|
387
|
+
return standard ? null : site;
|
|
388
|
+
}
|
|
389
|
+
/** Re-key a freshly expanded node from bp1..bp4 onto the site's breakpoints. The renderer emits
|
|
390
|
+
* CSS only for the site's keys, and users can split/delete breakpoints in the editor (a split
|
|
391
|
+
* of bp1 at 1600 gives bp1:[1320,1600] + bp5:[1601,1e9]; a deleted name can be reused for a
|
|
392
|
+
* different range) — so match by WIDTH, never by name: each site breakpoint takes the standard
|
|
393
|
+
* breakpoint containing its min width; sections regenerate their grid for that width. */
|
|
394
|
+
function remapToSiteBreakpoints(node, site) {
|
|
395
|
+
const std = Object.entries(BREAKPOINTS);
|
|
396
|
+
const pick = (minW) => (std.find(([, [lo, hi]]) => minW >= lo && minW <= hi) || std[std.length - 1])[0];
|
|
397
|
+
const out = {};
|
|
398
|
+
for (const [key, [minW]] of Object.entries(site)) {
|
|
399
|
+
const from = node[pick(minW)];
|
|
400
|
+
if (!from)
|
|
401
|
+
continue;
|
|
402
|
+
out[key] = clone(from);
|
|
403
|
+
if (node.type === "section")
|
|
404
|
+
out[key].config.columns = clone(genGridByBp(minW).columns);
|
|
405
|
+
}
|
|
406
|
+
for (const k of BREAKPOINT_KEYS)
|
|
407
|
+
delete node[k];
|
|
408
|
+
Object.assign(node, out);
|
|
409
|
+
}
|
|
410
|
+
function expandNodeToBreakpoints(node, site) {
|
|
368
411
|
const rt = node && node.runtime;
|
|
369
412
|
if (rt && (rt.style || rt.config || rt.responsive)) {
|
|
370
413
|
const baseConfig = { ...(rt.config || {}), loaded: true };
|
|
@@ -429,9 +472,11 @@ function expandNodeToBreakpoints(node) {
|
|
|
429
472
|
node[bp] = { style, config };
|
|
430
473
|
}
|
|
431
474
|
delete node.runtime;
|
|
475
|
+
if (site)
|
|
476
|
+
remapToSiteBreakpoints(node, site);
|
|
432
477
|
}
|
|
433
478
|
for (const child of node.children || [])
|
|
434
|
-
expandNodeToBreakpoints(child);
|
|
479
|
+
expandNodeToBreakpoints(child, site);
|
|
435
480
|
return node;
|
|
436
481
|
}
|
|
437
482
|
/**
|
|
@@ -440,10 +485,11 @@ function expandNodeToBreakpoints(node) {
|
|
|
440
485
|
* is removed. MUST be called before saving a page built with new_section/new_element —
|
|
441
486
|
* otherwise the page renders with no styling or grid placement.
|
|
442
487
|
*/
|
|
443
|
-
export function finalizeForRender(source) {
|
|
488
|
+
export function finalizeForRender(source, siteBreakpoints) {
|
|
444
489
|
const sections = source && Array.isArray(source.sections) ? source.sections : [];
|
|
490
|
+
const custom = customBreakpoints(siteBreakpoints);
|
|
445
491
|
for (const s of sections)
|
|
446
|
-
expandNodeToBreakpoints(s);
|
|
492
|
+
expandNodeToBreakpoints(s, custom);
|
|
447
493
|
return source;
|
|
448
494
|
}
|
|
449
495
|
export { ELEMENT_TYPES };
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.34.0",
|
|
4
|
+
"d": "14/10/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "New list_section_templates tool browses the designer-made section template library (categories, search by term, thumbnails) for starting a section…",
|
|
7
|
+
"vi": "Tool mới list_section_templates để duyệt thư viện mẫu section do designer tạo (theo danh mục, tìm kiếm bằng term, có thumbnail) làm điểm khởi đầu…"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"v": "1.33.2",
|
|
4
11
|
"d": "14/10/2026",
|
|
@@ -33,12 +40,5 @@
|
|
|
33
40
|
"type": "Added",
|
|
34
41
|
"en": "create_product, update_product, create_product_category, update_product_category, and create_blog_category accept a new seo object to write…",
|
|
35
42
|
"vi": "create_product, update_product, create_product_category, update_product_category và create_blog_category nay nhận thêm đối tượng seo để ghi…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.31.12",
|
|
39
|
-
"d": "09/10/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "create_article and update_article accept a new seo object (page_title, meta_description, page_keywords, og_title, og_description, og_image) that…",
|
|
42
|
-
"vi": "create_article và update_article nay nhận thêm đối tượng seo (page_title, meta_description, page_keywords, og_title, og_description, og_image) để…"
|
|
43
43
|
}
|
|
44
44
|
]
|
package/dist/server.js
CHANGED
|
@@ -20,7 +20,7 @@ Tool discovery: the common tools (pages, builder, products, orders, blog, custom
|
|
|
20
20
|
|
|
21
21
|
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.
|
|
22
22
|
|
|
23
|
-
Build the WHOLE site by FREE COMPOSITION FROM ELEMENTS — there are NO page templates and NO seeded data. You compose every page from elements (new_section/new_element/new_row → build_page) based on the user's actual goal. Enrich EVERY page (home, category, product detail, cart, checkout, thank-you, …) to the same e-commerce standard with the same palette/header/footer. Make the Header and Footer GLOBAL (create_global_section type:"header"/"footer") so they're consistent on every page — don't inline them per page. For newsletter/promo offers, build a popup node (new_element "popup") and save it via create_global_source({component:"popup"}), then open it with an open_popup event. See get_build_guide for the design system, the per-page section archetypes, the global-section order rules, and the popup workflow.
|
|
23
|
+
Build the WHOLE site by FREE COMPOSITION FROM ELEMENTS — there are NO page templates and NO seeded data (a single section MAY start from the designer section library: list_section_templates → add_section({ template }), restyled to the brand). You compose every page from elements (new_section/new_element/new_row → build_page) based on the user's actual goal. Enrich EVERY page (home, category, product detail, cart, checkout, thank-you, …) to the same e-commerce standard with the same palette/header/footer. Make the Header and Footer GLOBAL (create_global_section type:"header"/"footer") so they're consistent on every page — don't inline them per page. For newsletter/promo offers, build a popup node (new_element "popup") and save it via create_global_source({component:"popup"}), then open it with an open_popup event. See get_build_guide for the design system, the per-page section archetypes, the global-section order rules, and the popup workflow.
|
|
24
24
|
|
|
25
25
|
To make a generated site look real, also CREATE DATA so dataset bindings resolve: create_product_category + create_product (storefront), create_blog_category + create_article (blog). Get image URLs from search_images / upload_images first, then reference them. A good flow for a fresh site: create_site → create a few categories → create products in them → build_page (home + store/blog pages) → publish_site.
|
|
26
26
|
|
|
@@ -231,7 +231,13 @@ Two-step safety: dry_run=true (default) describes what will happen; dry_run=fals
|
|
|
231
231
|
// is exactly that link (resolvePreviewUrl == link(): primary_domain wins, else subdomain).
|
|
232
232
|
const res = await api.publishSite(preview_url ? { domain: preview_url } : {});
|
|
233
233
|
const data = (res && res.data) || res;
|
|
234
|
-
return {
|
|
234
|
+
return {
|
|
235
|
+
success: true,
|
|
236
|
+
published_at: data && data.published_at,
|
|
237
|
+
site: data && data.name,
|
|
238
|
+
preview_url,
|
|
239
|
+
screenshots: "Homepage screenshots (desktop 1920_1080 + mobile 430_932) are captured in the background after a publish (at most once an hour). About a minute later get_site_info returns them in preview_img — open one with invoke_tool('read_image', { url }) to check the live result visually.",
|
|
240
|
+
};
|
|
235
241
|
}));
|
|
236
242
|
// ── Ingest reference HTML / URL into a blueprint ────────────────────────────
|
|
237
243
|
function blueprintFromHtml(html) {
|
package/dist/tools/builder.js
CHANGED
|
@@ -275,7 +275,7 @@ ONE page per site for type main (homepage) / error / maintain, and slugs are uni
|
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
277
|
// Expand runtime -> bp1..bp4 so the saved source actually renders on the storefront.
|
|
278
|
-
finalizeForRender(parsed);
|
|
278
|
+
finalizeForRender(parsed, await api.getSiteBreakpoints());
|
|
279
279
|
// createPage saves the page AND its source in one call (source is required there).
|
|
280
280
|
const created = await api.createPage({ name, source: parsed, ...(typeNum != null ? { type: typeNum } : {}) });
|
|
281
281
|
const pageId = newPageId(created);
|
|
@@ -295,13 +295,64 @@ ONE page per site for type main (homepage) / error / maintain, and slugs are uni
|
|
|
295
295
|
...(metaWarning ? { warning: metaWarning } : {}),
|
|
296
296
|
};
|
|
297
297
|
}));
|
|
298
|
+
const templateRoot = (t) => {
|
|
299
|
+
try {
|
|
300
|
+
return JSON.parse(t.source || "null");
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
server.tool("list_section_templates", `Browse the designer-made section library (the editor's template picker): ready, styled blocks (hero, product showcase, testimonials, footer…) to start from instead of composing from scratch.
|
|
307
|
+
No args → categories. With category_id and/or term → templates { id, name, thumbnail, kind }. Look at a thumbnail with invoke_tool('read_image', { url }), then insert a kind:"section" template with add_section({ page_id, template:{ category_id, id } }) and restyle it to the brand.`, {
|
|
308
|
+
category_id: z.string().optional().describe("Category id (from the no-arg call)"),
|
|
309
|
+
term: z.string().optional().describe("Search template names"),
|
|
310
|
+
page: z.number().optional().describe("Page (default 1)"),
|
|
311
|
+
limit: z.number().optional().describe("Per page (default 12)"),
|
|
312
|
+
}, ({ category_id, term, page, limit }) => handle(async () => {
|
|
313
|
+
if (!category_id && !term) {
|
|
314
|
+
const res = await api.listTemplateCategories();
|
|
315
|
+
const cats = res?.templates ?? res?.data?.templates ?? res?.data ?? [];
|
|
316
|
+
return { categories: (Array.isArray(cats) ? cats : []).map((c) => ({ id: c.id, name: c.name })) };
|
|
317
|
+
}
|
|
318
|
+
const res = await api.listTemplates({ id: category_id, term, page: page || 1, limit: limit || 12 });
|
|
319
|
+
const data = res?.data ?? res;
|
|
320
|
+
return {
|
|
321
|
+
templates: (data?.templates || []).map((t) => {
|
|
322
|
+
const root = templateRoot(t);
|
|
323
|
+
return { id: t.id, name: t.name, thumbnail: t.thumbnail, kind: root?.type === "section" ? "section" : root?.type || "unknown" };
|
|
324
|
+
}),
|
|
325
|
+
page: data?.page,
|
|
326
|
+
total: data?.total_entries,
|
|
327
|
+
};
|
|
328
|
+
}));
|
|
298
329
|
server.tool("add_section", `Append a section to an EXISTING page's source. Reads the current source, appends your section, validates, and (when dry_run=false) saves.
|
|
299
|
-
The section is re-id'd to avoid collisions.
|
|
330
|
+
The section is re-id'd to avoid collisions. Pass a section built with new_section, OR template:{ category_id, id } to insert a library section (list_section_templates).
|
|
300
331
|
Two-step safety: dry_run=true (default) previews; dry_run=false saves.`, {
|
|
301
332
|
page_id: z.string().describe("Target page id"),
|
|
302
|
-
section: z.any().describe("A section node (from new_section) — object or JSON string"),
|
|
333
|
+
section: z.any().optional().describe("A section node (from new_section) — object or JSON string"),
|
|
334
|
+
template: z
|
|
335
|
+
.object({ category_id: z.string().optional(), id: z.string() })
|
|
336
|
+
.optional()
|
|
337
|
+
.describe("Insert a library section instead (ids from list_section_templates)"),
|
|
303
338
|
dry_run: z.boolean().default(true).describe("Preview only (true) or save (false)"),
|
|
304
|
-
}, ({ page_id, section, dry_run }) => handle(async () => {
|
|
339
|
+
}, ({ page_id, section, template, dry_run }) => handle(async () => {
|
|
340
|
+
if (template) {
|
|
341
|
+
// No get-by-id route: page through the category until the template turns up.
|
|
342
|
+
let found = null;
|
|
343
|
+
for (let p = 1; p <= 10 && !found; p++) {
|
|
344
|
+
const res = await api.listTemplates({ id: template.category_id, page: p, limit: 50 });
|
|
345
|
+
const list = (res?.data ?? res)?.templates || [];
|
|
346
|
+
found = list.find((t) => t.id === template.id);
|
|
347
|
+
if (list.length < 50)
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
if (!found)
|
|
351
|
+
return { error: `Template "${template.id}" not found${template.category_id ? " in that category" : ""}.` };
|
|
352
|
+
section = templateRoot(found);
|
|
353
|
+
}
|
|
354
|
+
if (!section)
|
|
355
|
+
return { error: "Pass a section (from new_section) or a template { category_id, id }." };
|
|
305
356
|
const pagesRes = await api.listPages();
|
|
306
357
|
const pages = (pagesRes && pagesRes.data) || pagesRes || [];
|
|
307
358
|
const page = Array.isArray(pages) ? pages.find((p) => p.id === page_id) : null;
|
|
@@ -330,7 +381,7 @@ Two-step safety: dry_run=true (default) previews; dry_run=false saves.`, {
|
|
|
330
381
|
return { error: "Validation failed — not saving.", validation };
|
|
331
382
|
// Expand the newly-added section's runtime -> bp1..bp4 (existing sections are
|
|
332
383
|
// already in breakpoint shape and are left untouched).
|
|
333
|
-
finalizeForRender(source);
|
|
384
|
+
finalizeForRender(source, await api.getSiteBreakpoints());
|
|
334
385
|
const saved = await api.updatePageSource(page_id, { source });
|
|
335
386
|
return {
|
|
336
387
|
success: true,
|
|
@@ -77,7 +77,7 @@ Two-step safety: dry_run=true (default) previews which pages change; dry_run=fal
|
|
|
77
77
|
const validation = validatePage(wrap);
|
|
78
78
|
if (!validation.valid)
|
|
79
79
|
return { error: "Section failed validation.", validation };
|
|
80
|
-
finalizeForRender(wrap);
|
|
80
|
+
finalizeForRender(wrap, await api.getSiteBreakpoints());
|
|
81
81
|
const finalNode = wrap.sections[0];
|
|
82
82
|
// Resolve target pages + inject the node into each one's source.
|
|
83
83
|
const allPages = await loadPages(api);
|
package/dist/tools/page-draft.js
CHANGED
|
@@ -134,7 +134,7 @@ RESUMABLE: if a request fails mid-commit, the draft keeps its page_id + committe
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
// Expand runtime -> bp1..bp4 so the saved nodes actually render.
|
|
137
|
-
finalizeForRender(full);
|
|
137
|
+
finalizeForRender(full, await api.getSiteBreakpoints());
|
|
138
138
|
const finalizedSections = full.sections;
|
|
139
139
|
try {
|
|
140
140
|
// Create the page with only the FIRST section (a SMALL first request) if not already created.
|
package/dist/tools/site-style.js
CHANGED
|
@@ -76,7 +76,7 @@ function parseThemeDescription(d) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
export function registerSiteStyleTools(server, api, handle) {
|
|
79
|
-
server.tool("get_site_info", "Get full site information: name, domain, settings (colors, typography, layout, language, payment methods, etc.)", {}, () => handle(async () => {
|
|
79
|
+
server.tool("get_site_info", "Get full site information: name, domain, settings (colors, typography, layout, language, payment methods, etc.) and preview_img — the latest homepage screenshots (1920_1080 desktop / 430_932 mobile), refreshed after publish_site.", {}, () => handle(async () => {
|
|
80
80
|
const res = await api.getSite();
|
|
81
81
|
const site = (res && res.data) || res;
|
|
82
82
|
if (!site)
|
|
@@ -89,6 +89,7 @@ export function registerSiteStyleTools(server, api, handle) {
|
|
|
89
89
|
logo: site.logo || undefined,
|
|
90
90
|
favicon: site.favicon || undefined,
|
|
91
91
|
settings: site.settings || {},
|
|
92
|
+
preview_img: site.preview_img || undefined,
|
|
92
93
|
created_at: site.created_at,
|
|
93
94
|
};
|
|
94
95
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-storefront-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.34.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",
|