webcake-storefront-mcp 1.14.0 → 1.15.1
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 +24 -0
- package/dist/builder/bindings.js +2 -0
- package/dist/builder/catalog.js +1 -1
- package/dist/builder/events.js +26 -6
- package/dist/changelog.json +14 -14
- package/dist/server.js +6 -0
- package/dist/tools/global-section-write.js +156 -0
- package/dist/tools/global-sections.js +150 -0
- package/dist/tools/global-sources.js +14 -10
- package/dist/tools/pages.js +11 -4
- package/dist/tools/result-cache.js +77 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -178,6 +178,30 @@ export class WebcakeCmsApi {
|
|
|
178
178
|
listGlobalSections() {
|
|
179
179
|
return this.request("GET", `/api/v1/site/${this.siteId}/global_sections`);
|
|
180
180
|
}
|
|
181
|
+
/** Upsert/delete global sections (Header/Footer/reusable blocks) via the site /save
|
|
182
|
+
* pipeline — the SAME endpoint the builder uses. Each entry carries a `status`
|
|
183
|
+
* ("new"|"update"|"delete") and is matched by (site_id, section_id). `section` is sent
|
|
184
|
+
* as an OBJECT (backend Jason.encode!s it). Optionally pass `pages` (each { id, source }
|
|
185
|
+
* with source a JSON STRING) to embed the section node into page sources in the same
|
|
186
|
+
* atomic save — required for a header/footer to actually render. `settings` MUST be the
|
|
187
|
+
* current site settings (string) or /save would null them; we fetch them when omitted. */
|
|
188
|
+
async saveGlobalSections({ global_sections = [], pages = [], settings, changes } = {}) {
|
|
189
|
+
let s = settings;
|
|
190
|
+
if (s === undefined)
|
|
191
|
+
s = await this.getSiteSettings().catch(() => ({}));
|
|
192
|
+
const settingsStr = typeof s === "string" ? s : JSON.stringify(s || {});
|
|
193
|
+
return this.request("POST", `/api/v1/site/${this.siteId}/save`, {
|
|
194
|
+
body: {
|
|
195
|
+
settings: settingsStr,
|
|
196
|
+
global_sources: [],
|
|
197
|
+
page_contents: [],
|
|
198
|
+
changes: changes || {},
|
|
199
|
+
pages,
|
|
200
|
+
global_sections,
|
|
201
|
+
},
|
|
202
|
+
timeout: 120000,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
181
205
|
getSite() {
|
|
182
206
|
return this.request("GET", `/api/v1/site/${this.siteId}/`);
|
|
183
207
|
}
|
package/dist/builder/bindings.js
CHANGED
|
@@ -202,6 +202,8 @@ export function normalizeBindings(bindings) {
|
|
|
202
202
|
if (!Array.isArray(bindings))
|
|
203
203
|
return bindings;
|
|
204
204
|
return bindings.map((b) => {
|
|
205
|
+
if (typeof b === "string")
|
|
206
|
+
return makeBinding(b); // bare "name::field" target
|
|
205
207
|
if (!b || typeof b !== "object")
|
|
206
208
|
return b;
|
|
207
209
|
if (b.id && b.name)
|
package/dist/builder/catalog.js
CHANGED
|
@@ -166,7 +166,7 @@ export function buildElement(type, opts = {}) {
|
|
|
166
166
|
if (Array.isArray(node.bindings) && node.bindings.length)
|
|
167
167
|
node.bindings = normalizeBindings(node.bindings);
|
|
168
168
|
if (Array.isArray(node.events) && node.events.length)
|
|
169
|
-
node.events = normalizeEvents(node.events);
|
|
169
|
+
node.events = normalizeEvents(node.events, node.type);
|
|
170
170
|
return node;
|
|
171
171
|
}
|
|
172
172
|
export function isKnownType(type) {
|
package/dist/builder/events.js
CHANGED
|
@@ -80,25 +80,45 @@ export function isKnownTrigger(name) {
|
|
|
80
80
|
export function getEventAction(action) {
|
|
81
81
|
return ACTION_BY_NAME[action];
|
|
82
82
|
}
|
|
83
|
+
// Element types whose events only fire on a specific trigger — mirrors builderx_spa
|
|
84
|
+
// components/editor/traits/Events.vue `addEvent()`. For these the element-type trigger
|
|
85
|
+
// is the right default (a click handler on a form never fires); for everything else the
|
|
86
|
+
// action's usual trigger (or "click") wins.
|
|
87
|
+
const SPECIAL_EVENT_TRIGGER = {
|
|
88
|
+
form: "success",
|
|
89
|
+
swiper: "tab",
|
|
90
|
+
popup: "hide",
|
|
91
|
+
"input-search": "onenter",
|
|
92
|
+
"submit-button": "hover",
|
|
93
|
+
};
|
|
94
|
+
/** The trigger the builder defaults to for a given element type, if it is a special one. */
|
|
95
|
+
export function defaultTriggerForType(type) {
|
|
96
|
+
return type ? SPECIAL_EVENT_TRIGGER[type] : undefined;
|
|
97
|
+
}
|
|
83
98
|
/**
|
|
84
99
|
* Mint a structurally-valid event object. Pass at least `action`; extra action-specific
|
|
85
100
|
* fields are merged verbatim. The id and a sensible eventName are filled in.
|
|
101
|
+
* eventName precedence (mirrors the editor): explicit > element-type default
|
|
102
|
+
* (form→success, swiper→tab, popup→hide, input-search→onenter, submit-button→hover) >
|
|
103
|
+
* the action's usual trigger > "click".
|
|
86
104
|
* makeEvent({ action: "scroll_to", scroll_to_id: "SECTION-x" })
|
|
87
|
-
* makeEvent({ action: "
|
|
105
|
+
* makeEvent({ action: "open_page", open_page_id: "…" }, "form") // eventName → "success"
|
|
88
106
|
*/
|
|
89
|
-
export function makeEvent(spec) {
|
|
107
|
+
export function makeEvent(spec, elementType) {
|
|
90
108
|
if (!spec || typeof spec !== "object")
|
|
91
109
|
throw new Error("Event spec must be an object with at least an `action`.");
|
|
92
110
|
const def = spec.action ? ACTION_BY_NAME[spec.action] : undefined;
|
|
93
|
-
const eventName = spec.eventName || (def ? def.trigger : "click");
|
|
111
|
+
const eventName = spec.eventName || defaultTriggerForType(elementType) || (def ? def.trigger : "click");
|
|
94
112
|
const { eventName: _e, action: _a, id: _id, ...rest } = spec;
|
|
95
113
|
return { id: spec.id || `EVENT-${randomString(6)}`, eventName, ...(spec.action ? { action: spec.action } : {}), ...rest };
|
|
96
114
|
}
|
|
97
|
-
/** Ensure every event in an array has an id + an eventName (mint where missing).
|
|
98
|
-
|
|
115
|
+
/** Ensure every event in an array has an id + an eventName (mint where missing).
|
|
116
|
+
* Pass the owning element's type so special types get the right default trigger.
|
|
117
|
+
* Round-trip safe: events that already carry id/eventName are preserved verbatim. */
|
|
118
|
+
export function normalizeEvents(events, elementType) {
|
|
99
119
|
if (!Array.isArray(events))
|
|
100
120
|
return events;
|
|
101
|
-
return events.map((e) => (e && typeof e === "object" ? makeEvent(e) : e));
|
|
121
|
+
return events.map((e) => (e && typeof e === "object" ? makeEvent(e, elementType) : e));
|
|
102
122
|
}
|
|
103
123
|
/** Validate one element's events against the catalog + the set of ids present in the page. */
|
|
104
124
|
export function validateEvents(node, allIds) {
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.15.1",
|
|
4
|
+
"d": "24/06/2026",
|
|
5
|
+
"type": "Fixed",
|
|
6
|
+
"en": "update_page_element, update_page_elements, update_global_source_element, and update_global_source_elements now normalize events and bindings arrays…",
|
|
7
|
+
"vi": "update_page_element, update_page_elements, update_global_source_element và update_global_source_elements nay chuẩn hóa mảng events và bindings khi…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.15.0",
|
|
11
|
+
"d": "24/06/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "New get_global_section tool returns a compact tree representation of a single global section (Header/Footer/block), listing each element's id, type,…",
|
|
14
|
+
"vi": "Tool mới get_global_section trả về cây phần tử dạng compact của một global section (Header/Footer/block), liệt kê id, type, text, class, binding và…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.14.0",
|
|
4
18
|
"d": "24/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Added",
|
|
27
41
|
"en": "New update_product tool updates an existing product's name, description, images, category_ids, is_published flag, or variations (price/stock/SKU per…",
|
|
28
42
|
"vi": "Tool mới update_product cập nhật name, description, images, category_ids, cờ is_published hoặc variations (giá/tồn kho/SKU theo từng biến thể) của…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.11.1",
|
|
32
|
-
"d": "24/06/2026",
|
|
33
|
-
"type": "Fixed",
|
|
34
|
-
"en": "create_site_from_template now calls the dedicated import_store_to_theme API instead of the generic site-duplicate endpoint, correctly cloning the…",
|
|
35
|
-
"vi": "create_site_from_template nay gọi đúng API import_store_to_theme thay vì endpoint nhân bản site thông thường, giúp clone đầy đủ các trang, global…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.11.0",
|
|
39
|
-
"d": "24/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "New create_site_from_template tool clones a marketplace template (all its pages, page-sources, and settings) into a new account-owned site, switches…",
|
|
42
|
-
"vi": "Tool mới create_site_from_template nhân bản một template marketplace (toàn bộ trang, page-source và cài đặt) thành một site mới thuộc tài khoản hiện…"
|
|
43
43
|
}
|
|
44
44
|
]
|
package/dist/server.js
CHANGED
|
@@ -14,6 +14,9 @@ import { registerAppTools } from "./tools/apps.js";
|
|
|
14
14
|
import { registerPromotionTools } from "./tools/promotions.js";
|
|
15
15
|
import { registerComboTools } from "./tools/combos.js";
|
|
16
16
|
import { registerGlobalSourceTools } from "./tools/global-sources.js";
|
|
17
|
+
import { registerGlobalSectionTools } from "./tools/global-sections.js";
|
|
18
|
+
import { registerGlobalSectionWriteTools } from "./tools/global-section-write.js";
|
|
19
|
+
import { registerResultCacheTools } from "./tools/result-cache.js";
|
|
17
20
|
import { registerImageTools } from "./tools/images.js";
|
|
18
21
|
import { registerBuilderTools } from "./tools/builder.js";
|
|
19
22
|
import { registerBuilderExtraTools } from "./tools/builder-extras.js";
|
|
@@ -60,6 +63,9 @@ export function createServer(api, opts = {}) {
|
|
|
60
63
|
registerPromotionTools(server, api, handle);
|
|
61
64
|
registerComboTools(server, api, handle);
|
|
62
65
|
registerGlobalSourceTools(server, api, handle);
|
|
66
|
+
registerGlobalSectionTools(server, api, handle);
|
|
67
|
+
registerGlobalSectionWriteTools(server, api, handle);
|
|
68
|
+
registerResultCacheTools(server, api, handle);
|
|
63
69
|
registerImageTools(server, api, handle);
|
|
64
70
|
registerBuilderTools(server, api, handle);
|
|
65
71
|
registerBuilderExtraTools(server, api, handle, { allowLocalFiles: opts.allowLocalFiles === true });
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { validatePage, finalizeForRender } from "../builder/page.js";
|
|
3
|
+
/**
|
|
4
|
+
* Write tools for global SECTIONS (Header / Footer / reusable blocks).
|
|
5
|
+
*
|
|
6
|
+
* Mirrors exactly what builderx_spa does on save (see PagePublish.vue + globalSection.js):
|
|
7
|
+
* a global section is persisted through the site `/save` pipeline as
|
|
8
|
+
* { section_id, name, type(1=header,2=section,3=footer), pages:[pageId...],
|
|
9
|
+
* status:"new"|"update"|"delete", section:<node>, contents:[] }
|
|
10
|
+
* matched by (site_id, section_id). The node is marked with specials.global =
|
|
11
|
+
* "header"|"section"|"footer" and carries the SAME id on every page that embeds it.
|
|
12
|
+
*
|
|
13
|
+
* For a header/footer to actually RENDER, the same node must also be injected into each
|
|
14
|
+
* page's source.sections (header → top, footer → bottom). We do both in ONE atomic /save:
|
|
15
|
+
* the global_sections upsert AND the updated page sources (which also rebuilds each page's
|
|
16
|
+
* app_css). api.saveGlobalSections sends the current site.settings so /save can't null them.
|
|
17
|
+
*/
|
|
18
|
+
const TYPE_NUM = { header: 1, section: 2, footer: 3 };
|
|
19
|
+
function parseSource(src) {
|
|
20
|
+
if (src == null)
|
|
21
|
+
return null;
|
|
22
|
+
return typeof src === "string" ? JSON.parse(src) : src;
|
|
23
|
+
}
|
|
24
|
+
/** All pages with their parsed source. listPages returns each page's source under
|
|
25
|
+
* page.source.source (a JSON string), same as the add_section tool relies on. */
|
|
26
|
+
async function loadPages(api) {
|
|
27
|
+
const res = await api.listPages();
|
|
28
|
+
const pages = (res && res.data) || res || [];
|
|
29
|
+
return (Array.isArray(pages) ? pages : []).map((p) => ({
|
|
30
|
+
id: p.id,
|
|
31
|
+
name: p.name,
|
|
32
|
+
source: parseSource(p.source && p.source.source) || { sections: [] },
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
/** Insert `node` into a page source at the right slot for its global type, replacing any
|
|
36
|
+
* existing section with the same id (idempotent). Returns true if the source changed. */
|
|
37
|
+
function injectNode(source, node, type) {
|
|
38
|
+
if (!source || !Array.isArray(source.sections))
|
|
39
|
+
source.sections = [];
|
|
40
|
+
const existingIdx = source.sections.findIndex((s) => s && s.id === node.id);
|
|
41
|
+
if (existingIdx !== -1)
|
|
42
|
+
source.sections.splice(existingIdx, 1);
|
|
43
|
+
if (type === "footer") {
|
|
44
|
+
source.sections.push(node);
|
|
45
|
+
}
|
|
46
|
+
else if (type === "header") {
|
|
47
|
+
source.sections.unshift(node);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// reusable section → after the last header, else at top
|
|
51
|
+
const lastHeader = source.sections.map((s) => s?.specials?.global).lastIndexOf("header");
|
|
52
|
+
source.sections.splice(lastHeader + 1, 0, node);
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
export function registerGlobalSectionWriteTools(server, api, handle) {
|
|
57
|
+
server.tool("create_global_section", `Create a reusable global section (Header / Footer / shared block) the way the builder does:
|
|
58
|
+
persists a global_section record AND embeds the same section node into page sources so it
|
|
59
|
+
actually renders across the site (header → top of every page, footer → bottom).
|
|
60
|
+
Build the section first with new_section (give it a real bg/padding + logo/menu/links), then pass it here.
|
|
61
|
+
Two-step safety: dry_run=true (default) previews which pages change; dry_run=false performs the atomic save.`, {
|
|
62
|
+
type: z.enum(["header", "section", "footer"]).describe("header = top chrome (logo/nav/cart), footer = bottom chrome, section = reusable content block"),
|
|
63
|
+
name: z.string().describe("Display name in the editor (e.g. 'Header', 'Footer')"),
|
|
64
|
+
section: z.any().describe("A section node from new_section (object or JSON string) — the content of the header/footer."),
|
|
65
|
+
page_ids: z.array(z.string()).optional().describe("Pages to embed into. Omit to apply to ALL pages of the site (typical for header/footer)."),
|
|
66
|
+
dry_run: z.boolean().default(true).describe("Preview which pages would change (true) or perform the atomic save (false)."),
|
|
67
|
+
}, ({ type, name, section, page_ids, dry_run }) => handle(async () => {
|
|
68
|
+
const node = parseSource(section);
|
|
69
|
+
if (!node || node.type !== "section") {
|
|
70
|
+
return { error: "`section` must be a section node (type:'section') built with new_section." };
|
|
71
|
+
}
|
|
72
|
+
if (!node.id)
|
|
73
|
+
return { error: "section node has no id." };
|
|
74
|
+
// Mark it as a global section and expand runtime → bp1..bp4 (renderer reads bpN).
|
|
75
|
+
node.specials = { ...(node.specials || {}), global: type };
|
|
76
|
+
const wrap = { sections: [node] };
|
|
77
|
+
const validation = validatePage(wrap);
|
|
78
|
+
if (!validation.valid)
|
|
79
|
+
return { error: "Section failed validation.", validation };
|
|
80
|
+
finalizeForRender(wrap);
|
|
81
|
+
const finalNode = wrap.sections[0];
|
|
82
|
+
// Resolve target pages + inject the node into each one's source.
|
|
83
|
+
const allPages = await loadPages(api);
|
|
84
|
+
const targets = page_ids && page_ids.length
|
|
85
|
+
? allPages.filter((p) => page_ids.includes(p.id))
|
|
86
|
+
: allPages;
|
|
87
|
+
if (!targets.length)
|
|
88
|
+
return { error: "No matching pages to attach the global section to." };
|
|
89
|
+
const changedPages = targets.map((p) => {
|
|
90
|
+
injectNode(p.source, finalNode, type);
|
|
91
|
+
return { id: p.id, name: p.name, source: JSON.stringify(p.source) };
|
|
92
|
+
});
|
|
93
|
+
const targetIds = targets.map((p) => p.id);
|
|
94
|
+
const globalSection = {
|
|
95
|
+
section_id: finalNode.id,
|
|
96
|
+
name,
|
|
97
|
+
type: TYPE_NUM[type],
|
|
98
|
+
pages: targetIds,
|
|
99
|
+
section: finalNode,
|
|
100
|
+
status: "new",
|
|
101
|
+
contents: [],
|
|
102
|
+
};
|
|
103
|
+
if (dry_run) {
|
|
104
|
+
return {
|
|
105
|
+
dry_run: true,
|
|
106
|
+
section_id: finalNode.id,
|
|
107
|
+
type,
|
|
108
|
+
name,
|
|
109
|
+
embeds_into_pages: targets.map((p) => ({ id: p.id, name: p.name })),
|
|
110
|
+
page_count: targets.length,
|
|
111
|
+
hint: "Call again with dry_run=false to create the global section and embed it. Then publish_site to take it live.",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const changes = targetIds.reduce((o, id) => { o[id] = 1; return o; }, {});
|
|
115
|
+
const res = await api.saveGlobalSections({ global_sections: [globalSection], pages: changedPages.map(({ id, source }) => ({ id, source })), changes });
|
|
116
|
+
const ok = !!(res && (res.success || res.data));
|
|
117
|
+
return {
|
|
118
|
+
success: ok,
|
|
119
|
+
section_id: finalNode.id,
|
|
120
|
+
type,
|
|
121
|
+
name,
|
|
122
|
+
embedded_pages: targetIds.length,
|
|
123
|
+
note: "Publish the site (publish_site) to take the new header/footer live.",
|
|
124
|
+
raw: ok ? undefined : res,
|
|
125
|
+
};
|
|
126
|
+
}));
|
|
127
|
+
server.tool("delete_global_section", `Delete a global section (Header/Footer/block) and remove its node from every page source.
|
|
128
|
+
Two-step safety: dry_run=true (default) shows which pages would change; dry_run=false performs the atomic save.`, {
|
|
129
|
+
section_id: z.string().describe("The global section's section_id (the section node id) — from list_global_sections."),
|
|
130
|
+
dry_run: z.boolean().default(true).describe("Preview (true) or perform the delete + page cleanup (false)."),
|
|
131
|
+
}, ({ section_id, dry_run }) => handle(async () => {
|
|
132
|
+
const allPages = await loadPages(api);
|
|
133
|
+
const affected = allPages.filter((p) => Array.isArray(p.source.sections) && p.source.sections.some((s) => s && s.id === section_id));
|
|
134
|
+
if (dry_run) {
|
|
135
|
+
return {
|
|
136
|
+
dry_run: true,
|
|
137
|
+
section_id,
|
|
138
|
+
removes_from_pages: affected.map((p) => ({ id: p.id, name: p.name })),
|
|
139
|
+
page_count: affected.length,
|
|
140
|
+
hint: "Call again with dry_run=false to delete the global section and clean it out of those pages.",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const changedPages = affected.map((p) => {
|
|
144
|
+
p.source.sections = p.source.sections.filter((s) => !(s && s.id === section_id));
|
|
145
|
+
return { id: p.id, source: JSON.stringify(p.source) };
|
|
146
|
+
});
|
|
147
|
+
const changes = affected.reduce((o, p) => { o[p.id] = 1; return o; }, {});
|
|
148
|
+
const res = await api.saveGlobalSections({
|
|
149
|
+
global_sections: [{ section_id, status: "delete", section: null }],
|
|
150
|
+
pages: changedPages,
|
|
151
|
+
changes,
|
|
152
|
+
});
|
|
153
|
+
const ok = !!(res && (res.success || res.data));
|
|
154
|
+
return { success: ok, section_id, cleaned_pages: changedPages.length, note: "Publish the site to apply.", raw: ok ? undefined : res };
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { buildOverview, buildTreeText, searchElements, nodeToDetail, findNodeById, } from "./global-sources.js";
|
|
3
|
+
import { cacheLarge } from "./result-cache.js";
|
|
4
|
+
/**
|
|
5
|
+
* Global SECTIONS tools — reusable page chrome (Header, Footer) + shared content
|
|
6
|
+
* blocks (breadcrumb, "about", product strips) that pages embed by reference.
|
|
7
|
+
*
|
|
8
|
+
* Why this module exists: the raw `GET /global_sections` response is HUGE (a real
|
|
9
|
+
* site's Header+Footer trees alone are >1MB), which overflows the tool-result token
|
|
10
|
+
* budget. So instead of dumping the whole tree, we:
|
|
11
|
+
* - cache the fetched list for the session (30s TTL) so drill-down is free, and
|
|
12
|
+
* - expose SLIM tools: a summary list, a compact per-section tree, and
|
|
13
|
+
* element search / element detail — mirroring the global_sources tools.
|
|
14
|
+
*
|
|
15
|
+
* Shape note: each global section's element tree lives under `gs.section` (a single
|
|
16
|
+
* section node with `children`), NOT under `gs.source`. `gs.contents` is the
|
|
17
|
+
* (usually empty) multilingual override list. `gs.type` is the chrome slot:
|
|
18
|
+
* 1 = header, 3 = footer, 2 = reusable content block.
|
|
19
|
+
*/
|
|
20
|
+
const TYPE_LABEL = { 1: "header", 2: "block", 3: "footer" };
|
|
21
|
+
const CACHE_TTL = 30000;
|
|
22
|
+
let _cache = null;
|
|
23
|
+
/** Pull the array of global sections out of the various response envelopes. */
|
|
24
|
+
function extractList(res) {
|
|
25
|
+
if (!res)
|
|
26
|
+
return [];
|
|
27
|
+
if (Array.isArray(res))
|
|
28
|
+
return res;
|
|
29
|
+
if (Array.isArray(res.global_sections))
|
|
30
|
+
return res.global_sections;
|
|
31
|
+
if (res.data) {
|
|
32
|
+
if (Array.isArray(res.data))
|
|
33
|
+
return res.data;
|
|
34
|
+
if (Array.isArray(res.data.global_sections))
|
|
35
|
+
return res.data.global_sections;
|
|
36
|
+
}
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
async function fetchSections(api, force = false) {
|
|
40
|
+
if (!force && _cache && Date.now() - _cache.time < CACHE_TTL)
|
|
41
|
+
return _cache.items;
|
|
42
|
+
const res = await api.listGlobalSections();
|
|
43
|
+
const items = extractList(res);
|
|
44
|
+
_cache = { items, time: Date.now() };
|
|
45
|
+
return items;
|
|
46
|
+
}
|
|
47
|
+
/** The element-tree root of a global section is its `section` node. */
|
|
48
|
+
function rootOf(gs) {
|
|
49
|
+
return gs && gs.section ? gs.section : null;
|
|
50
|
+
}
|
|
51
|
+
function summarize(gs) {
|
|
52
|
+
const root = rootOf(gs);
|
|
53
|
+
const ov = root ? buildOverview(root) : null;
|
|
54
|
+
const langs = Array.isArray(gs.contents) ? gs.contents.map((c) => c.language_code).filter(Boolean) : [];
|
|
55
|
+
return {
|
|
56
|
+
id: gs.id,
|
|
57
|
+
name: gs.name,
|
|
58
|
+
type: gs.type,
|
|
59
|
+
slot: TYPE_LABEL[gs.type] || "block",
|
|
60
|
+
elements: ov ? ov.elements : 0,
|
|
61
|
+
types: ov ? ov.types : {},
|
|
62
|
+
classes: ov ? ov.classes : [],
|
|
63
|
+
contents_langs: langs,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async function resolveSection(api, id) {
|
|
67
|
+
let items = await fetchSections(api);
|
|
68
|
+
let found = items.find((g) => String(g.id) === String(id));
|
|
69
|
+
if (!found) {
|
|
70
|
+
items = await fetchSections(api, true); // force refresh once
|
|
71
|
+
found = items.find((g) => String(g.id) === String(id));
|
|
72
|
+
}
|
|
73
|
+
return found || null;
|
|
74
|
+
}
|
|
75
|
+
export function registerGlobalSectionTools(server, api, handle) {
|
|
76
|
+
server.tool("list_global_sections", `List reusable global sections (Header, Footer, shared content blocks) — SLIM summary only.
|
|
77
|
+
Each entry: id, name, slot (header/footer/block), element count + type histogram + custom classes.
|
|
78
|
+
The full element tree is large, so it is NOT returned here — drill in with get_global_section
|
|
79
|
+
(compact tree), search_global_section_elements, or get_global_section_element.`, {}, () => handle(async () => {
|
|
80
|
+
const items = await fetchSections(api);
|
|
81
|
+
return {
|
|
82
|
+
count: items.length,
|
|
83
|
+
global_sections: items.map(summarize),
|
|
84
|
+
hint: "Use get_global_section(global_section_id) for a compact element tree of one section.",
|
|
85
|
+
};
|
|
86
|
+
}));
|
|
87
|
+
server.tool("get_global_section", `Get one global section as a COMPACT tree (3-5x fewer tokens than raw JSON).
|
|
88
|
+
Each line: ID [type] "text" .class [Nbind] [Nev] (children_count).
|
|
89
|
+
Use this to learn how a real Header/Footer/block is composed before building your own.`, {
|
|
90
|
+
global_section_id: z.string().describe("Global section ID (from list_global_sections)"),
|
|
91
|
+
raw: z.boolean().default(false).describe("Return the FULL raw section JSON (large) instead of the compact tree — delivered via the large-result cache so you can split-read it with read_cached_result."),
|
|
92
|
+
}, ({ global_section_id, raw }) => handle(async () => {
|
|
93
|
+
const gs = await resolveSection(api, global_section_id);
|
|
94
|
+
if (!gs)
|
|
95
|
+
return { error: `Global section "${global_section_id}" not found. Call list_global_sections first.` };
|
|
96
|
+
const root = rootOf(gs);
|
|
97
|
+
if (raw) {
|
|
98
|
+
// Full fidelity — cache-then-split-read so even a >1MB tree is reachable.
|
|
99
|
+
return cacheLarge(`global_section:${gs.name || gs.id}`, root || {});
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
id: gs.id,
|
|
103
|
+
name: gs.name,
|
|
104
|
+
slot: TYPE_LABEL[gs.type] || "block",
|
|
105
|
+
overview: root ? buildOverview(root) : null,
|
|
106
|
+
tree: root ? buildTreeText(root) : "(empty)",
|
|
107
|
+
hint: "Use get_global_section_element(global_section_id, element_id) for full style/config of one node. Pass raw=true for the full JSON via the cache.",
|
|
108
|
+
};
|
|
109
|
+
}));
|
|
110
|
+
server.tool("search_global_section_elements", `Search/filter elements within a global section (Header/Footer/block) without dumping the whole tree.
|
|
111
|
+
Filter by type, id substring, custom_class, text, or has_bind / has_events / has_custom_class.`, {
|
|
112
|
+
global_section_id: z.string().describe("Global section ID"),
|
|
113
|
+
type: z.string().optional().describe("Filter by element type (e.g. 'menu', 'menu-item', 'container', 'image', 'text')"),
|
|
114
|
+
id: z.string().optional().describe("Filter by element ID substring"),
|
|
115
|
+
custom_class: z.string().optional().describe("Filter by custom class substring"),
|
|
116
|
+
text: z.string().optional().describe("Filter by text content substring"),
|
|
117
|
+
has_custom_class: z.boolean().optional().describe("Only elements with a custom class"),
|
|
118
|
+
has_bind: z.boolean().optional().describe("Only elements with data bindings"),
|
|
119
|
+
has_events: z.boolean().optional().describe("Only elements with events"),
|
|
120
|
+
limit: z.number().default(50).describe("Max results (default 50)"),
|
|
121
|
+
}, ({ global_section_id, ...filters }) => handle(async () => {
|
|
122
|
+
const gs = await resolveSection(api, global_section_id);
|
|
123
|
+
if (!gs)
|
|
124
|
+
return { error: `Global section "${global_section_id}" not found.` };
|
|
125
|
+
const root = rootOf(gs);
|
|
126
|
+
if (!root)
|
|
127
|
+
return { error: "Global section has no element tree." };
|
|
128
|
+
const results = searchElements(root, filters);
|
|
129
|
+
return { global_section_id, matched: results.length, elements: results };
|
|
130
|
+
}));
|
|
131
|
+
server.tool("get_global_section_element", "Get full detail (style, config, specials, events, bindings, responsive bp1..bp4, children IDs) of a single element inside a global section.", {
|
|
132
|
+
global_section_id: z.string().describe("Global section ID"),
|
|
133
|
+
element_id: z.string().describe("Element ID (e.g. 'MENU-1', 'TEXT-3')"),
|
|
134
|
+
}, ({ global_section_id, element_id }) => handle(async () => {
|
|
135
|
+
const gs = await resolveSection(api, global_section_id);
|
|
136
|
+
if (!gs)
|
|
137
|
+
return { error: `Global section "${global_section_id}" not found.` };
|
|
138
|
+
const root = rootOf(gs);
|
|
139
|
+
if (!root)
|
|
140
|
+
return { error: "Global section has no element tree." };
|
|
141
|
+
const node = findNodeById(root, element_id);
|
|
142
|
+
if (!node)
|
|
143
|
+
return { error: `Element "${element_id}" not found in global section.` };
|
|
144
|
+
const detail = nodeToDetail(node);
|
|
145
|
+
if (node.children && node.children.length) {
|
|
146
|
+
detail.children = node.children.map((c) => ({ id: c.id, type: c.type }));
|
|
147
|
+
}
|
|
148
|
+
return detail;
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { getConfirmMode } from "./context.js";
|
|
3
|
+
import { normalizeEvents } from "../builder/events.js";
|
|
4
|
+
import { normalizeBindings } from "../builder/bindings.js";
|
|
3
5
|
/**
|
|
4
6
|
* Global Sources tools — manage site-wide components: cart, popup, overview, etc.
|
|
5
7
|
*
|
|
@@ -13,7 +15,7 @@ import { getConfirmMode } from "./context.js";
|
|
|
13
15
|
* - Safeguard on full source update (block if data shrinks >50%)
|
|
14
16
|
*/
|
|
15
17
|
// ── Source tree helpers ──
|
|
16
|
-
function parseSource(sourceJson) {
|
|
18
|
+
export function parseSource(sourceJson) {
|
|
17
19
|
try {
|
|
18
20
|
return typeof sourceJson === "string" ? JSON.parse(sourceJson) : sourceJson;
|
|
19
21
|
}
|
|
@@ -25,7 +27,7 @@ function parseSource(sourceJson) {
|
|
|
25
27
|
* - Page format: { sections: [...] } → returns sections array
|
|
26
28
|
* - Global source format: { id, type, children: [...] } → returns [rootNode]
|
|
27
29
|
*/
|
|
28
|
-
function getRoots(source) {
|
|
30
|
+
export function getRoots(source) {
|
|
29
31
|
if (!source)
|
|
30
32
|
return [];
|
|
31
33
|
if (source.sections)
|
|
@@ -52,7 +54,7 @@ function walkSource(source, fn) {
|
|
|
52
54
|
return;
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
|
-
function buildOverview(source) {
|
|
57
|
+
export function buildOverview(source) {
|
|
56
58
|
const typeCounts = {};
|
|
57
59
|
const customClasses = new Set();
|
|
58
60
|
let total = 0;
|
|
@@ -72,7 +74,7 @@ function buildOverview(source) {
|
|
|
72
74
|
classes: [...customClasses].sort(),
|
|
73
75
|
};
|
|
74
76
|
}
|
|
75
|
-
function nodeToDetail(node) {
|
|
77
|
+
export function nodeToDetail(node) {
|
|
76
78
|
const entry = { id: node.id || "", type: node.type || "unknown" };
|
|
77
79
|
if (node.style && Object.keys(node.style).length)
|
|
78
80
|
entry.style = node.style;
|
|
@@ -95,7 +97,7 @@ function nodeToDetail(node) {
|
|
|
95
97
|
entry.children_count = node.children.length;
|
|
96
98
|
return entry;
|
|
97
99
|
}
|
|
98
|
-
function findNodeById(source, elementId) {
|
|
100
|
+
export function findNodeById(source, elementId) {
|
|
99
101
|
let found = null;
|
|
100
102
|
walkSource(source, (node) => {
|
|
101
103
|
if (node.id === elementId) {
|
|
@@ -151,10 +153,11 @@ function applyNodeUpdates(node, updates) {
|
|
|
151
153
|
node.config = { ...(node.config || {}), ...updates.config };
|
|
152
154
|
if (updates.specials)
|
|
153
155
|
node.specials = { ...(node.specials || {}), ...updates.specials };
|
|
156
|
+
// Normalize so updated events/bindings get a valid id + eventName (renderer needs it).
|
|
154
157
|
if (updates.events !== undefined)
|
|
155
|
-
node.events = updates.events;
|
|
158
|
+
node.events = normalizeEvents(updates.events, node.type);
|
|
156
159
|
if (updates.bindings !== undefined)
|
|
157
|
-
node.bindings = updates.bindings;
|
|
160
|
+
node.bindings = normalizeBindings(updates.bindings);
|
|
158
161
|
if (updates.responsive) {
|
|
159
162
|
for (const [bp, val] of Object.entries(updates.responsive)) {
|
|
160
163
|
if (/^bp\d+$/.test(bp)) {
|
|
@@ -165,7 +168,7 @@ function applyNodeUpdates(node, updates) {
|
|
|
165
168
|
}
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
|
-
function searchElements(source, filters) {
|
|
171
|
+
export function searchElements(source, filters) {
|
|
169
172
|
const results = [];
|
|
170
173
|
const limit = filters.limit || 50;
|
|
171
174
|
walkSource(source, (node) => {
|
|
@@ -206,7 +209,7 @@ function searchElements(source, filters) {
|
|
|
206
209
|
* │ └─ TEXT-2 [text] "Product name"
|
|
207
210
|
* └─ BUTTON-1 [button] "Thanh toán" .checkout-btn [2ev]
|
|
208
211
|
*/
|
|
209
|
-
function buildTreeText(source) {
|
|
212
|
+
export function buildTreeText(source) {
|
|
210
213
|
const roots = getRoots(source);
|
|
211
214
|
if (!roots.length)
|
|
212
215
|
return "(empty)";
|
|
@@ -432,7 +435,8 @@ STEP 1: Call with dry_run=true (default) → returns diff of what will change.
|
|
|
432
435
|
STEP 2: Show the diff to the user and ask for confirmation. NEVER proceed without explicit user approval.
|
|
433
436
|
STEP 3: Only after user confirms, call again with dry_run=false to apply.
|
|
434
437
|
IMPORTANT: You MUST show the diff to the user and get explicit "yes/ok/confirm" before calling with dry_run=false. Skipping confirmation risks data loss.
|
|
435
|
-
Merge rules: style/config/specials = shallow merge,
|
|
438
|
+
Merge rules: style/config/specials = shallow merge, responsive = merge by bp key.
|
|
439
|
+
events/bindings = REPLACE the whole array — pass the COMPLETE list (read it first); entries are auto-normalized (id + eventName filled in).`, {
|
|
436
440
|
global_source_id: z.string().describe("Global source ID"),
|
|
437
441
|
element_id: z.string().describe("Element ID to update (e.g. 'TEXT-3', 'BUTTON-1')"),
|
|
438
442
|
component: z.string().optional().describe('Component hint for faster lookup'),
|
package/dist/tools/pages.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { CUSTOM_CODE_GUIDE } from "../guides.js";
|
|
3
3
|
import { getConfirmMode } from "./context.js";
|
|
4
|
+
import { normalizeEvents } from "../builder/events.js";
|
|
5
|
+
import { normalizeBindings } from "../builder/bindings.js";
|
|
4
6
|
/**
|
|
5
7
|
* Page source utilities.
|
|
6
8
|
*
|
|
@@ -145,10 +147,13 @@ function applyNodeUpdates(node, updates) {
|
|
|
145
147
|
node.config = { ...(node.config || {}), ...updates.config };
|
|
146
148
|
if (updates.specials)
|
|
147
149
|
node.specials = { ...(node.specials || {}), ...updates.specials };
|
|
150
|
+
// Normalize so updated events/bindings carry a valid id + eventName (and the right
|
|
151
|
+
// element-type default trigger) — the storefront renderer won't dispatch an event with
|
|
152
|
+
// no eventName. Round-trip safe: already-formed entries pass through unchanged.
|
|
148
153
|
if (updates.events !== undefined)
|
|
149
|
-
node.events = updates.events;
|
|
154
|
+
node.events = normalizeEvents(updates.events, node.type);
|
|
150
155
|
if (updates.bindings !== undefined)
|
|
151
|
-
node.bindings = updates.bindings;
|
|
156
|
+
node.bindings = normalizeBindings(updates.bindings);
|
|
152
157
|
// Responsive breakpoints (bp1, bp2, ...)
|
|
153
158
|
if (updates.responsive) {
|
|
154
159
|
for (const [bp, val] of Object.entries(updates.responsive)) {
|
|
@@ -434,7 +439,8 @@ IMPORTANT: Before calling, you MUST read existing content with list_page_content
|
|
|
434
439
|
}
|
|
435
440
|
return api.updatePageContent({ page_id, language_code, content, meta_tags });
|
|
436
441
|
}));
|
|
437
|
-
|
|
442
|
+
// list_global_sections (+ get/search/element drill-downs) live in tools/global-sections.ts
|
|
443
|
+
// — they return SLIM summaries instead of the raw multi-MB tree this endpoint produces.
|
|
438
444
|
// ── Element interaction tools ──
|
|
439
445
|
server.tool("get_page_element", "Get full detail of a single element by its ID (e.g. 'TEXT-3', 'BUTTON-1', 'SECTION-2'). Returns style, config, specials, events, bindings, responsive, and children IDs", {
|
|
440
446
|
page_id: z.string().describe("Page ID"),
|
|
@@ -459,7 +465,8 @@ STEP 1: Call with dry_run=true (default) → returns diff of what will change.
|
|
|
459
465
|
STEP 2: Show the diff to the user and ask for confirmation. NEVER proceed without explicit user approval.
|
|
460
466
|
STEP 3: Only after user confirms, call again with dry_run=false to apply.
|
|
461
467
|
IMPORTANT: You MUST show the diff to the user and get explicit "yes/ok/confirm" before calling with dry_run=false. Skipping confirmation risks data loss.
|
|
462
|
-
Merge rules: style/config/specials = shallow merge,
|
|
468
|
+
Merge rules: style/config/specials = shallow merge, responsive = merge by bp key.
|
|
469
|
+
events/bindings = REPLACE the whole array — pass the COMPLETE list (read it first with get_page_element so you don't drop the others); entries are auto-normalized (id + eventName filled in, so you can pass just { action, ...fields } / { target }).`, {
|
|
463
470
|
page_id: z.string().describe("Page ID"),
|
|
464
471
|
element_id: z.string().describe("Element ID to update (e.g. 'TEXT-3', 'BUTTON-1')"),
|
|
465
472
|
dry_run: z.boolean().optional().describe("Preview only (true) or apply changes (false). Defaults to confirm_mode setting. Use toggle_confirm_mode to change default."),
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const _cache = new Map();
|
|
3
|
+
let _seq = 0;
|
|
4
|
+
const TTL_MS = 10 * 60 * 1000;
|
|
5
|
+
const DEFAULT_THRESHOLD = 20000; // chars — comfortably under the tool-result token budget
|
|
6
|
+
const DEFAULT_CHUNK = 12000;
|
|
7
|
+
function prune(now) {
|
|
8
|
+
for (const [k, v] of _cache)
|
|
9
|
+
if (now - v.created > TTL_MS)
|
|
10
|
+
_cache.delete(k);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pass-through if small, otherwise cache and return a read handle.
|
|
14
|
+
* @returns `{ cached:false, data }` or `{ cached:true, cache_id, total_chars, ... }`
|
|
15
|
+
*/
|
|
16
|
+
export function cacheLarge(label, data, threshold = DEFAULT_THRESHOLD) {
|
|
17
|
+
const text = typeof data === "string" ? data : JSON.stringify(data);
|
|
18
|
+
if (text.length <= threshold)
|
|
19
|
+
return { cached: false, data };
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
prune(now);
|
|
22
|
+
const id = `cache-${++_seq}`;
|
|
23
|
+
_cache.set(id, { text, label, created: now });
|
|
24
|
+
return {
|
|
25
|
+
cached: true,
|
|
26
|
+
cache_id: id,
|
|
27
|
+
label,
|
|
28
|
+
total_chars: text.length,
|
|
29
|
+
total_lines: text.split("\n").length,
|
|
30
|
+
preview: text.slice(0, 1500),
|
|
31
|
+
hint: `Large result cached (${text.length} chars). Read it in chunks with read_cached_result(cache_id="${id}", offset:0, length:${DEFAULT_CHUNK}). Expires in ${TTL_MS / 60000} min.`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Direct accessor for other modules (e.g. to peek a cached entry). */
|
|
35
|
+
export function getCached(id) {
|
|
36
|
+
return _cache.get(id);
|
|
37
|
+
}
|
|
38
|
+
export function registerResultCacheTools(server, _api, handle) {
|
|
39
|
+
server.tool("read_cached_result", `Read a slice of a large cached result produced by another tool (look for "cached":true + a cache_id in its output).
|
|
40
|
+
Page through with offset/length; the response reports next_offset + remaining_chars until done.`, {
|
|
41
|
+
cache_id: z.string().describe('The cache_id returned by the producing tool (e.g. "cache-3")'),
|
|
42
|
+
offset: z.number().default(0).describe("Start character offset (default 0)"),
|
|
43
|
+
length: z.number().default(DEFAULT_CHUNK).describe(`Number of characters to return (default ${DEFAULT_CHUNK})`),
|
|
44
|
+
}, ({ cache_id, offset, length }) => handle(async () => {
|
|
45
|
+
const entry = _cache.get(cache_id);
|
|
46
|
+
if (!entry)
|
|
47
|
+
return { error: `Cache "${cache_id}" not found or expired. Re-run the producing tool.` };
|
|
48
|
+
const start = Math.max(0, offset);
|
|
49
|
+
const end = Math.min(entry.text.length, start + Math.max(1, length));
|
|
50
|
+
const slice = entry.text.slice(start, end);
|
|
51
|
+
const done = end >= entry.text.length;
|
|
52
|
+
return {
|
|
53
|
+
cache_id,
|
|
54
|
+
label: entry.label,
|
|
55
|
+
offset: start,
|
|
56
|
+
returned_chars: slice.length,
|
|
57
|
+
total_chars: entry.text.length,
|
|
58
|
+
next_offset: done ? null : end,
|
|
59
|
+
remaining_chars: entry.text.length - end,
|
|
60
|
+
done,
|
|
61
|
+
chunk: slice,
|
|
62
|
+
};
|
|
63
|
+
}));
|
|
64
|
+
server.tool("list_cached_results", "List the large results currently held in the session cache (id, label, size, age).", {}, () => handle(async () => {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
prune(now);
|
|
67
|
+
return {
|
|
68
|
+
count: _cache.size,
|
|
69
|
+
cached: [..._cache.entries()].map(([id, e]) => ({
|
|
70
|
+
cache_id: id,
|
|
71
|
+
label: e.label,
|
|
72
|
+
total_chars: e.text.length,
|
|
73
|
+
age_seconds: Math.round((now - e.created) / 1000),
|
|
74
|
+
})),
|
|
75
|
+
};
|
|
76
|
+
}));
|
|
77
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-storefront-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.1",
|
|
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",
|