webcake-storefront-mcp 1.0.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/README.md +1166 -0
- package/dist/api.js +346 -0
- package/dist/auth/login.js +87 -0
- package/dist/builder/catalog.js +186 -0
- package/dist/builder/factory.js +1677 -0
- package/dist/builder/guide.js +64 -0
- package/dist/builder/page.js +149 -0
- package/dist/config.js +97 -0
- package/dist/db.js +96 -0
- package/dist/guides.js +93 -0
- package/dist/http.js +120 -0
- package/dist/index.js +73 -0
- package/dist/install.js +140 -0
- package/dist/mongo.js +102 -0
- package/dist/server.js +63 -0
- package/dist/smoke.js +81 -0
- package/dist/tools/apps.js +7 -0
- package/dist/tools/articles.js +53 -0
- package/dist/tools/automation.js +8 -0
- package/dist/tools/builder-extras.js +165 -0
- package/dist/tools/builder.js +124 -0
- package/dist/tools/cms-files.js +255 -0
- package/dist/tools/collections.js +31 -0
- package/dist/tools/combos.js +72 -0
- package/dist/tools/context.js +158 -0
- package/dist/tools/customers.js +13 -0
- package/dist/tools/global-sources.js +662 -0
- package/dist/tools/images.js +875 -0
- package/dist/tools/orders.js +32 -0
- package/dist/tools/pages.js +621 -0
- package/dist/tools/products.js +38 -0
- package/dist/tools/promotions.js +131 -0
- package/dist/tools/site-style.js +157 -0
- package/package.json +38 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getConfirmMode } from "./context.js";
|
|
3
|
+
/**
|
|
4
|
+
* Global Sources tools — manage site-wide components: cart, popup, overview, etc.
|
|
5
|
+
*
|
|
6
|
+
* Source structure: same as page source — { sections: [{ id, type, style, config, specials, children }] }
|
|
7
|
+
*
|
|
8
|
+
* Improvements over page tools:
|
|
9
|
+
* - 30s cache (fetch once, reuse across tools)
|
|
10
|
+
* - No `component` needed for detail/search/element tools — auto-resolved from cache
|
|
11
|
+
* - Compact tree text output (3-5x token savings vs JSON)
|
|
12
|
+
* - Element-level update tools (shallow merge, same as pages)
|
|
13
|
+
* - Safeguard on full source update (block if data shrinks >50%)
|
|
14
|
+
*/
|
|
15
|
+
// ── Source tree helpers ──
|
|
16
|
+
function parseSource(sourceJson) {
|
|
17
|
+
try {
|
|
18
|
+
return typeof sourceJson === "string" ? JSON.parse(sourceJson) : sourceJson;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Get root nodes from source — handles both formats:
|
|
25
|
+
* - Page format: { sections: [...] } → returns sections array
|
|
26
|
+
* - Global source format: { id, type, children: [...] } → returns [rootNode]
|
|
27
|
+
*/
|
|
28
|
+
function getRoots(source) {
|
|
29
|
+
if (!source)
|
|
30
|
+
return [];
|
|
31
|
+
if (source.sections)
|
|
32
|
+
return source.sections;
|
|
33
|
+
if (source.id)
|
|
34
|
+
return [source];
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
/** Walk all nodes in source tree — handles both page and global source format */
|
|
38
|
+
function walkSource(source, fn) {
|
|
39
|
+
function walk(node) {
|
|
40
|
+
if (!node)
|
|
41
|
+
return true;
|
|
42
|
+
if (fn(node) === false)
|
|
43
|
+
return false;
|
|
44
|
+
for (const child of node.children || []) {
|
|
45
|
+
if (walk(child) === false)
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
for (const root of getRoots(source)) {
|
|
51
|
+
if (walk(root) === false)
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function buildOverview(source) {
|
|
56
|
+
const typeCounts = {};
|
|
57
|
+
const customClasses = new Set();
|
|
58
|
+
let total = 0;
|
|
59
|
+
walkSource(source, (node) => {
|
|
60
|
+
total++;
|
|
61
|
+
const t = node.type || "unknown";
|
|
62
|
+
typeCounts[t] = (typeCounts[t] || 0) + 1;
|
|
63
|
+
const cc = node.specials && node.specials.custom_class;
|
|
64
|
+
if (cc)
|
|
65
|
+
cc.split(",").map((s) => s.trim()).filter(Boolean).forEach((c) => customClasses.add(c));
|
|
66
|
+
});
|
|
67
|
+
const roots = getRoots(source);
|
|
68
|
+
return {
|
|
69
|
+
sections: roots.length,
|
|
70
|
+
elements: total,
|
|
71
|
+
types: typeCounts,
|
|
72
|
+
classes: [...customClasses].sort(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function nodeToDetail(node) {
|
|
76
|
+
const entry = { id: node.id || "", type: node.type || "unknown" };
|
|
77
|
+
if (node.style && Object.keys(node.style).length)
|
|
78
|
+
entry.style = node.style;
|
|
79
|
+
if (node.config && Object.keys(node.config).length)
|
|
80
|
+
entry.config = node.config;
|
|
81
|
+
if (node.specials && Object.keys(node.specials).length)
|
|
82
|
+
entry.specials = node.specials;
|
|
83
|
+
if (node.events && node.events.length)
|
|
84
|
+
entry.events = node.events;
|
|
85
|
+
if (node.bindings && node.bindings.length)
|
|
86
|
+
entry.bindings = node.bindings;
|
|
87
|
+
for (const key of Object.keys(node)) {
|
|
88
|
+
if (/^bp\d+$/.test(key) && node[key] && typeof node[key] === "object") {
|
|
89
|
+
if (!entry.responsive)
|
|
90
|
+
entry.responsive = {};
|
|
91
|
+
entry.responsive[key] = node[key];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (node.children && node.children.length)
|
|
95
|
+
entry.children_count = node.children.length;
|
|
96
|
+
return entry;
|
|
97
|
+
}
|
|
98
|
+
function findNodeById(source, elementId) {
|
|
99
|
+
let found = null;
|
|
100
|
+
walkSource(source, (node) => {
|
|
101
|
+
if (node.id === elementId) {
|
|
102
|
+
found = node;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
108
|
+
/** Deep-diff two nodes — returns only changed fields with before/after.
|
|
109
|
+
* Objects (style, config, specials) are diffed at key-level.
|
|
110
|
+
* Arrays (events, bindings) show full before/after.
|
|
111
|
+
* Responsive breakpoints diffed individually.
|
|
112
|
+
*/
|
|
113
|
+
function computeNodeDiff(beforeNode, afterNode) {
|
|
114
|
+
const diff = {};
|
|
115
|
+
const objFields = ["style", "config", "specials"];
|
|
116
|
+
const arrFields = ["events", "bindings"];
|
|
117
|
+
for (const field of objFields) {
|
|
118
|
+
const b = beforeNode[field] || {};
|
|
119
|
+
const a = afterNode[field] || {};
|
|
120
|
+
if (JSON.stringify(b) === JSON.stringify(a))
|
|
121
|
+
continue;
|
|
122
|
+
const fieldDiff = {};
|
|
123
|
+
for (const k of new Set([...Object.keys(b), ...Object.keys(a)])) {
|
|
124
|
+
if (JSON.stringify(b[k]) !== JSON.stringify(a[k])) {
|
|
125
|
+
fieldDiff[k] = { before: b[k] ?? null, after: a[k] ?? null };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (Object.keys(fieldDiff).length)
|
|
129
|
+
diff[field] = fieldDiff;
|
|
130
|
+
}
|
|
131
|
+
for (const field of arrFields) {
|
|
132
|
+
if (JSON.stringify(beforeNode[field] || null) !== JSON.stringify(afterNode[field] || null)) {
|
|
133
|
+
diff[field] = { before: beforeNode[field] || null, after: afterNode[field] || null };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const key of new Set([...Object.keys(beforeNode), ...Object.keys(afterNode)])) {
|
|
137
|
+
if (!/^bp\d+$/.test(key))
|
|
138
|
+
continue;
|
|
139
|
+
if (JSON.stringify(beforeNode[key] || null) !== JSON.stringify(afterNode[key] || null)) {
|
|
140
|
+
if (!diff.responsive)
|
|
141
|
+
diff.responsive = {};
|
|
142
|
+
diff.responsive[key] = { before: beforeNode[key] || null, after: afterNode[key] || null };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return Object.keys(diff).length ? diff : null;
|
|
146
|
+
}
|
|
147
|
+
function applyNodeUpdates(node, updates) {
|
|
148
|
+
if (updates.style)
|
|
149
|
+
node.style = { ...(node.style || {}), ...updates.style };
|
|
150
|
+
if (updates.config)
|
|
151
|
+
node.config = { ...(node.config || {}), ...updates.config };
|
|
152
|
+
if (updates.specials)
|
|
153
|
+
node.specials = { ...(node.specials || {}), ...updates.specials };
|
|
154
|
+
if (updates.events !== undefined)
|
|
155
|
+
node.events = updates.events;
|
|
156
|
+
if (updates.bindings !== undefined)
|
|
157
|
+
node.bindings = updates.bindings;
|
|
158
|
+
if (updates.responsive) {
|
|
159
|
+
for (const [bp, val] of Object.entries(updates.responsive)) {
|
|
160
|
+
if (/^bp\d+$/.test(bp)) {
|
|
161
|
+
node[bp] = node[bp]
|
|
162
|
+
? { style: { ...node[bp].style, ...val.style }, config: { ...node[bp].config, ...val.config } }
|
|
163
|
+
: val;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function searchElements(source, filters) {
|
|
169
|
+
const results = [];
|
|
170
|
+
const limit = filters.limit || 50;
|
|
171
|
+
walkSource(source, (node) => {
|
|
172
|
+
if (results.length >= limit)
|
|
173
|
+
return false;
|
|
174
|
+
if (filters.type && node.type !== filters.type)
|
|
175
|
+
return;
|
|
176
|
+
if (filters.id && !(node.id || "").toLowerCase().includes(filters.id.toLowerCase()))
|
|
177
|
+
return;
|
|
178
|
+
if (filters.custom_class) {
|
|
179
|
+
const cc = (node.specials && node.specials.custom_class) || "";
|
|
180
|
+
if (!cc.toLowerCase().includes(filters.custom_class.toLowerCase()))
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (filters.text) {
|
|
184
|
+
const text = (node.specials && node.specials.text) || "";
|
|
185
|
+
if (!text.toLowerCase().includes(filters.text.toLowerCase()))
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (filters.has_custom_class && !(node.specials && node.specials.custom_class))
|
|
189
|
+
return;
|
|
190
|
+
if (filters.has_bind && !(node.bindings && node.bindings.length) && !(node.specials && node.specials.bind))
|
|
191
|
+
return;
|
|
192
|
+
if (filters.has_events && !(node.events && node.events.length))
|
|
193
|
+
return;
|
|
194
|
+
results.push(nodeToDetail(node));
|
|
195
|
+
});
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Build compact tree text — 3-5x fewer tokens than JSON.
|
|
200
|
+
*
|
|
201
|
+
* Output example:
|
|
202
|
+
* SECTION-1 [section] (3)
|
|
203
|
+
* ├─ TEXT-1 [text] "Giỏ hàng" .cart-title
|
|
204
|
+
* ├─ CONTAINER-1 [container] .cart-items [bind] (2)
|
|
205
|
+
* │ ├─ IMAGE-1 [image] .product-img
|
|
206
|
+
* │ └─ TEXT-2 [text] "Product name"
|
|
207
|
+
* └─ BUTTON-1 [button] "Thanh toán" .checkout-btn [2ev]
|
|
208
|
+
*/
|
|
209
|
+
function buildTreeText(source) {
|
|
210
|
+
const roots = getRoots(source);
|
|
211
|
+
if (!roots.length)
|
|
212
|
+
return "(empty)";
|
|
213
|
+
const lines = [];
|
|
214
|
+
function walk(node, prefix, isLast) {
|
|
215
|
+
const connector = prefix === "" ? "" : (isLast ? "└─ " : "├─ ");
|
|
216
|
+
const nextPrefix = prefix === "" ? "" : prefix + (isLast ? " " : "│ ");
|
|
217
|
+
let line = `${prefix}${connector}${node.id || "?"} [${node.type || "?"}]`;
|
|
218
|
+
// Compact annotations
|
|
219
|
+
const tags = [];
|
|
220
|
+
if (node.specials) {
|
|
221
|
+
if (node.specials.text) {
|
|
222
|
+
const t = node.specials.text.replace(/[\n\r]+/g, " ").substring(0, 50);
|
|
223
|
+
tags.push(`"${t}"`);
|
|
224
|
+
}
|
|
225
|
+
if (node.specials.custom_class)
|
|
226
|
+
tags.push(`.${node.specials.custom_class.split(",")[0].trim()}`);
|
|
227
|
+
if (node.specials.custom_css)
|
|
228
|
+
tags.push("{css}");
|
|
229
|
+
if (node.specials.bind)
|
|
230
|
+
tags.push(`[bind:${node.specials.bind}]`);
|
|
231
|
+
}
|
|
232
|
+
if (node.bindings && node.bindings.length)
|
|
233
|
+
tags.push(`[${node.bindings.length}bind]`);
|
|
234
|
+
if (node.events && node.events.length)
|
|
235
|
+
tags.push(`[${node.events.length}ev]`);
|
|
236
|
+
if (tags.length)
|
|
237
|
+
line += " " + tags.join(" ");
|
|
238
|
+
const children = node.children || [];
|
|
239
|
+
if (children.length)
|
|
240
|
+
line += ` (${children.length})`;
|
|
241
|
+
lines.push(line);
|
|
242
|
+
for (let i = 0; i < children.length; i++) {
|
|
243
|
+
walk(children[i], nextPrefix, i === children.length - 1);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (let i = 0; i < roots.length; i++) {
|
|
247
|
+
walk(roots[i], "", i === roots.length - 1);
|
|
248
|
+
}
|
|
249
|
+
return lines.join("\n");
|
|
250
|
+
}
|
|
251
|
+
// ── Incremental cache — populated as tools are used, keyed by ID ──
|
|
252
|
+
//
|
|
253
|
+
// Why not fetch-all-at-once: the API may require `component` filter to return
|
|
254
|
+
// certain types (e.g. popup). So we cache incrementally: list/get tools fetch
|
|
255
|
+
// from API with the correct filter and feed results into the cache. Subsequent
|
|
256
|
+
// detail/search/element tools can resolve by ID from cache without re-fetching.
|
|
257
|
+
const _gsById = new Map(); // id → { gs, time }
|
|
258
|
+
const CACHE_TTL = 30000;
|
|
259
|
+
/** Feed API results into cache */
|
|
260
|
+
function cacheItems(items) {
|
|
261
|
+
if (!Array.isArray(items))
|
|
262
|
+
return;
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
for (const item of items) {
|
|
265
|
+
if (item?.id)
|
|
266
|
+
_gsById.set(String(item.id), { gs: item, time: now });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function invalidateGsCache() {
|
|
270
|
+
_gsById.clear();
|
|
271
|
+
}
|
|
272
|
+
/** Fetch global sources by component from API and cache results */
|
|
273
|
+
async function fetchByComponent(api, component) {
|
|
274
|
+
const res = component === "cart-droppable"
|
|
275
|
+
? await api.getSourceCart()
|
|
276
|
+
: await api.getGlobalSources({ component });
|
|
277
|
+
const items = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
|
|
278
|
+
cacheItems(items);
|
|
279
|
+
return items;
|
|
280
|
+
}
|
|
281
|
+
/** Fetch ALL global sources (general + cart) and cache */
|
|
282
|
+
async function fetchAll(api) {
|
|
283
|
+
const [gsRes, cartRes] = await Promise.all([
|
|
284
|
+
api.getGlobalSources({}).catch(() => null),
|
|
285
|
+
api.getSourceCart().catch(() => null),
|
|
286
|
+
]);
|
|
287
|
+
const gsList = Array.isArray(gsRes?.data) ? gsRes.data : (Array.isArray(gsRes) ? gsRes : []);
|
|
288
|
+
const cartList = Array.isArray(cartRes?.data) ? cartRes.data : (Array.isArray(cartRes) ? cartRes : []);
|
|
289
|
+
const merged = [...gsList, ...cartList];
|
|
290
|
+
cacheItems(merged);
|
|
291
|
+
return merged;
|
|
292
|
+
}
|
|
293
|
+
/** Resolve global source by ID. Source is the original object — no wrapping. */
|
|
294
|
+
async function getGsWithSource(api, globalSourceId, componentHint) {
|
|
295
|
+
const id = String(globalSourceId);
|
|
296
|
+
// 1. Cache hit?
|
|
297
|
+
const cached = _gsById.get(id);
|
|
298
|
+
if (cached && Date.now() - cached.time < CACHE_TTL) {
|
|
299
|
+
return { gs: cached.gs, source: parseSource(cached.gs.source) };
|
|
300
|
+
}
|
|
301
|
+
// 2. Cache miss — fetch and retry
|
|
302
|
+
if (componentHint) {
|
|
303
|
+
await fetchByComponent(api, componentHint);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
await fetchAll(api);
|
|
307
|
+
}
|
|
308
|
+
const entry = _gsById.get(id);
|
|
309
|
+
if (!entry)
|
|
310
|
+
return { error: `Global source "${globalSourceId}" not found. Provide component param or call list_global_sources first.` };
|
|
311
|
+
return { gs: entry.gs, source: parseSource(entry.gs.source) };
|
|
312
|
+
}
|
|
313
|
+
// ── Zod schemas (reused across tools) ──
|
|
314
|
+
const elementUpdateShape = z.object({
|
|
315
|
+
element_id: z.string().describe("Element ID"),
|
|
316
|
+
style: z.record(z.any()).optional(),
|
|
317
|
+
config: z.record(z.any()).optional(),
|
|
318
|
+
specials: z.record(z.any()).optional(),
|
|
319
|
+
events: z.array(z.record(z.any())).optional(),
|
|
320
|
+
bindings: z.array(z.record(z.any())).optional(),
|
|
321
|
+
responsive: z.record(z.any()).optional(),
|
|
322
|
+
});
|
|
323
|
+
// ── Tool registration ──
|
|
324
|
+
export function registerGlobalSourceTools(server, api, handle) {
|
|
325
|
+
// ── List ──
|
|
326
|
+
server.tool("list_global_sources", `List global sources (cart, popup, etc.). Returns compact summary per source.
|
|
327
|
+
Always provide component to filter by type — the API may not return all types without a filter.`, {
|
|
328
|
+
component: z.string().optional().describe('Filter by component type (e.g. "cart-droppable", "popup"). Recommended to always provide'),
|
|
329
|
+
}, ({ component }) => handle(async () => {
|
|
330
|
+
// Always call API with component filter — API may require it for certain types
|
|
331
|
+
const items = component ? await fetchByComponent(api, component) : await fetchAll(api);
|
|
332
|
+
if (!Array.isArray(items))
|
|
333
|
+
return items;
|
|
334
|
+
return {
|
|
335
|
+
count: items.length,
|
|
336
|
+
global_sources: items.map((gs) => {
|
|
337
|
+
const source = parseSource(gs.source);
|
|
338
|
+
const ov = source ? buildOverview(source) : null;
|
|
339
|
+
return { id: gs.id, component: gs.component, type: gs.type, elements: ov ? ov.elements : 0, sections: ov ? ov.sections : 0 };
|
|
340
|
+
}),
|
|
341
|
+
hint: "Use get_global_source_detail with global_source_id for full element tree.",
|
|
342
|
+
};
|
|
343
|
+
}));
|
|
344
|
+
server.tool("get_source_cart", `Get all cart global sources with compact tree view.
|
|
345
|
+
Shows full element hierarchy — no need to call get_global_source_detail separately.`, {}, () => handle(async () => {
|
|
346
|
+
const items = await fetchByComponent(api, "cart-droppable");
|
|
347
|
+
if (!Array.isArray(items))
|
|
348
|
+
return items;
|
|
349
|
+
return {
|
|
350
|
+
count: items.length,
|
|
351
|
+
carts: items.map((gs) => {
|
|
352
|
+
const source = parseSource(gs.source);
|
|
353
|
+
return {
|
|
354
|
+
id: gs.id,
|
|
355
|
+
type: gs.type,
|
|
356
|
+
overview: source ? buildOverview(source) : null,
|
|
357
|
+
tree: source ? buildTreeText(source) : "(empty)",
|
|
358
|
+
};
|
|
359
|
+
}),
|
|
360
|
+
hint: "Use get_global_source_element for full style/config. Use update_global_source_element to modify.",
|
|
361
|
+
};
|
|
362
|
+
}));
|
|
363
|
+
// ── Detail ──
|
|
364
|
+
server.tool("get_global_source_detail", `Get full detail of a global source — compact tree view showing all elements.
|
|
365
|
+
Each line: ID [type] "text" .class [events] [bindings] (children_count).
|
|
366
|
+
Provide component for faster lookup; omit if you already called list_global_sources.`, {
|
|
367
|
+
global_source_id: z.string().describe("Global source ID"),
|
|
368
|
+
component: z.string().optional().describe('Component hint for faster lookup (e.g. "popup", "cart-droppable")'),
|
|
369
|
+
}, ({ global_source_id, component }) => handle(async () => {
|
|
370
|
+
const { gs, source, error } = await getGsWithSource(api, global_source_id, component);
|
|
371
|
+
if (error)
|
|
372
|
+
return { error };
|
|
373
|
+
return {
|
|
374
|
+
id: gs.id,
|
|
375
|
+
component: gs.component,
|
|
376
|
+
type: gs.type,
|
|
377
|
+
overview: source ? buildOverview(source) : null,
|
|
378
|
+
tree: source ? buildTreeText(source) : "(empty)",
|
|
379
|
+
hint: "Use get_global_source_element for full style/config of a specific element. Use search_global_source_elements to filter.",
|
|
380
|
+
};
|
|
381
|
+
}));
|
|
382
|
+
// ── Element search & detail ──
|
|
383
|
+
server.tool("search_global_source_elements", `Search/filter elements within a global source. No component param needed.
|
|
384
|
+
Examples:
|
|
385
|
+
- Find all buttons: type="button"
|
|
386
|
+
- Find by class: custom_class="hero"
|
|
387
|
+
- Find text: text="subscribe"
|
|
388
|
+
- Data-bound only: has_bind=true
|
|
389
|
+
- With events: has_events=true
|
|
390
|
+
- With custom class: has_custom_class=true`, {
|
|
391
|
+
global_source_id: z.string().describe("Global source ID"),
|
|
392
|
+
component: z.string().optional().describe('Component hint for faster lookup (e.g. "popup", "cart-droppable")'),
|
|
393
|
+
type: z.string().optional().describe("Filter by element type (e.g. 'text', 'button', 'image', 'container', 'section')"),
|
|
394
|
+
id: z.string().optional().describe("Filter by element ID substring (e.g. 'TEXT', 'BUTTON-3')"),
|
|
395
|
+
custom_class: z.string().optional().describe("Filter by custom class substring"),
|
|
396
|
+
text: z.string().optional().describe("Filter by text content substring"),
|
|
397
|
+
has_custom_class: z.boolean().optional().describe("Only elements with custom class"),
|
|
398
|
+
has_bind: z.boolean().optional().describe("Only elements with data bindings"),
|
|
399
|
+
has_events: z.boolean().optional().describe("Only elements with events"),
|
|
400
|
+
limit: z.number().default(50).describe("Max results (default 50)"),
|
|
401
|
+
}, ({ global_source_id, component, ...filters }) => handle(async () => {
|
|
402
|
+
const { source, error } = await getGsWithSource(api, global_source_id, component);
|
|
403
|
+
if (error)
|
|
404
|
+
return { error };
|
|
405
|
+
if (!source)
|
|
406
|
+
return { error: "Global source has no source data" };
|
|
407
|
+
const results = searchElements(source, filters);
|
|
408
|
+
return { global_source_id, matched: results.length, elements: results };
|
|
409
|
+
}));
|
|
410
|
+
server.tool("get_global_source_element", "Get full detail of a single element (style, config, specials, events, bindings, responsive, children).", {
|
|
411
|
+
global_source_id: z.string().describe("Global source ID"),
|
|
412
|
+
element_id: z.string().describe("Element ID (e.g. 'TEXT-3', 'BUTTON-1')"),
|
|
413
|
+
component: z.string().optional().describe('Component hint for faster lookup'),
|
|
414
|
+
}, ({ global_source_id, element_id, component }) => handle(async () => {
|
|
415
|
+
const { source, error } = await getGsWithSource(api, global_source_id, component);
|
|
416
|
+
if (error)
|
|
417
|
+
return { error };
|
|
418
|
+
if (!source)
|
|
419
|
+
return { error: "Global source has no source data" };
|
|
420
|
+
const node = findNodeById(source, element_id);
|
|
421
|
+
if (!node)
|
|
422
|
+
return { error: `Element "${element_id}" not found` };
|
|
423
|
+
const detail = nodeToDetail(node);
|
|
424
|
+
if (node.children && node.children.length) {
|
|
425
|
+
detail.children = node.children.map((c) => ({ id: c.id, type: c.type }));
|
|
426
|
+
}
|
|
427
|
+
return detail;
|
|
428
|
+
}));
|
|
429
|
+
// ── Element-level updates (shallow merge, same as page tools) ──
|
|
430
|
+
server.tool("update_global_source_element", `Update a single element within a global source. Two-step process:
|
|
431
|
+
STEP 1: Call with dry_run=true (default) → returns diff of what will change.
|
|
432
|
+
STEP 2: Show the diff to the user and ask for confirmation. NEVER proceed without explicit user approval.
|
|
433
|
+
STEP 3: Only after user confirms, call again with dry_run=false to apply.
|
|
434
|
+
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, events/bindings = replace array, responsive = merge by bp key.`, {
|
|
436
|
+
global_source_id: z.string().describe("Global source ID"),
|
|
437
|
+
element_id: z.string().describe("Element ID to update (e.g. 'TEXT-3', 'BUTTON-1')"),
|
|
438
|
+
component: z.string().optional().describe('Component hint for faster lookup'),
|
|
439
|
+
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."),
|
|
440
|
+
style: z.record(z.any()).optional().describe("CSS style properties to merge"),
|
|
441
|
+
config: z.record(z.any()).optional().describe("Config properties to merge"),
|
|
442
|
+
specials: z.record(z.any()).optional().describe("Specials to merge (text, custom_class, custom_css)"),
|
|
443
|
+
events: z.array(z.record(z.any())).optional().describe("Complete events array (replaces existing)"),
|
|
444
|
+
bindings: z.array(z.record(z.any())).optional().describe("Complete bindings array (replaces existing)"),
|
|
445
|
+
responsive: z.record(z.any()).optional().describe("Responsive overrides (e.g. {bp1: {style: {...}}})"),
|
|
446
|
+
}, ({ global_source_id, element_id, component, dry_run: dryRunParam, ...updates }) => handle(async () => {
|
|
447
|
+
// Resolve dry_run: explicit param wins, otherwise follow confirm_mode setting
|
|
448
|
+
const dry_run = dryRunParam !== undefined ? dryRunParam : getConfirmMode() === "always_confirm";
|
|
449
|
+
// Force fresh read before write to avoid stale cache overwriting newer data
|
|
450
|
+
invalidateGsCache();
|
|
451
|
+
const { gs, source, error } = await getGsWithSource(api, global_source_id, component);
|
|
452
|
+
if (error)
|
|
453
|
+
return { error };
|
|
454
|
+
if (!source)
|
|
455
|
+
return { error: "Global source has no source data" };
|
|
456
|
+
const node = findNodeById(source, element_id);
|
|
457
|
+
if (!node)
|
|
458
|
+
return { error: `Element "${element_id}" not found` };
|
|
459
|
+
// Clone node before mutation for diff
|
|
460
|
+
const beforeNode = JSON.parse(JSON.stringify(node));
|
|
461
|
+
applyNodeUpdates(node, updates);
|
|
462
|
+
const diff = computeNodeDiff(beforeNode, node);
|
|
463
|
+
if (!diff)
|
|
464
|
+
return { info: "No changes detected", element_id };
|
|
465
|
+
// Dry run: return diff without saving
|
|
466
|
+
if (dry_run) {
|
|
467
|
+
return {
|
|
468
|
+
dry_run: true,
|
|
469
|
+
element_id,
|
|
470
|
+
diff,
|
|
471
|
+
hint: "Review the changes above. Call again with dry_run=false to apply.",
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
// Actual save — send source as OBJECT (not string) because backend does Jason.encode!
|
|
475
|
+
const existingLen = JSON.stringify(parseSource(gs.source)).length;
|
|
476
|
+
const newLen = JSON.stringify(source).length;
|
|
477
|
+
// Safeguard: block if source shrinks significantly
|
|
478
|
+
if (existingLen > 200 && newLen < existingLen * 0.5) {
|
|
479
|
+
return {
|
|
480
|
+
error: `BLOCKED: Source would shrink from ${existingLen} to ${newLen} chars. This indicates data loss.`,
|
|
481
|
+
existing_length: existingLen,
|
|
482
|
+
new_length: newLen,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const isCart = gs.component === "cart-droppable";
|
|
486
|
+
let res;
|
|
487
|
+
if (isCart) {
|
|
488
|
+
res = await api.updateSourceCart({ source, type: gs.type, site_id: api.siteId });
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
res = await api.updateGlobalSource({ global_source_id, source, type: gs.component, site_id: api.siteId });
|
|
492
|
+
}
|
|
493
|
+
invalidateGsCache();
|
|
494
|
+
const saved = res && res.data;
|
|
495
|
+
if (!saved)
|
|
496
|
+
return { error: "Backend returned empty response — update may not have persisted", sent_length: newLen };
|
|
497
|
+
return { success: true, element_id, diff, source_length: newLen };
|
|
498
|
+
}));
|
|
499
|
+
server.tool("update_global_source_elements", `Batch update multiple elements in one global source. Two-step process:
|
|
500
|
+
STEP 1: Call with dry_run=true (default) → returns per-element diff.
|
|
501
|
+
STEP 2: Show all diffs to the user and ask for confirmation. NEVER proceed without explicit user approval.
|
|
502
|
+
STEP 3: Only after user confirms, call again with dry_run=false to apply.
|
|
503
|
+
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.
|
|
504
|
+
Same merge rules: style/config/specials = shallow merge, events/bindings = replace.`, {
|
|
505
|
+
global_source_id: z.string().describe("Global source ID"),
|
|
506
|
+
component: z.string().optional().describe('Component hint for faster lookup'),
|
|
507
|
+
dry_run: z.boolean().optional().describe("Preview only (true) or apply changes (false). Defaults to confirm_mode setting."),
|
|
508
|
+
updates: z.array(elementUpdateShape).describe("Array of element updates"),
|
|
509
|
+
}, ({ global_source_id, component, dry_run: dryRunParam, updates: elementUpdates }) => handle(async () => {
|
|
510
|
+
const dry_run = dryRunParam !== undefined ? dryRunParam : getConfirmMode() === "always_confirm";
|
|
511
|
+
invalidateGsCache();
|
|
512
|
+
const { gs, source, error } = await getGsWithSource(api, global_source_id, component);
|
|
513
|
+
if (error)
|
|
514
|
+
return { error };
|
|
515
|
+
if (!source)
|
|
516
|
+
return { error: "Global source has no source data" };
|
|
517
|
+
const results = [];
|
|
518
|
+
for (const upd of elementUpdates) {
|
|
519
|
+
const node = findNodeById(source, upd.element_id);
|
|
520
|
+
if (!node) {
|
|
521
|
+
results.push({ element_id: upd.element_id, error: "Not found" });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const beforeNode = JSON.parse(JSON.stringify(node));
|
|
525
|
+
const { element_id, ...updates } = upd;
|
|
526
|
+
applyNodeUpdates(node, updates);
|
|
527
|
+
const diff = computeNodeDiff(beforeNode, node);
|
|
528
|
+
results.push({ element_id, diff: diff || "no changes" });
|
|
529
|
+
}
|
|
530
|
+
if (dry_run) {
|
|
531
|
+
return {
|
|
532
|
+
dry_run: true,
|
|
533
|
+
elements: results,
|
|
534
|
+
hint: "Review the changes above. Call again with dry_run=false to apply.",
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
// Actual save — send source as OBJECT (not string) because backend does Jason.encode!
|
|
538
|
+
const existingLen = JSON.stringify(parseSource(gs.source)).length;
|
|
539
|
+
const newLen = JSON.stringify(source).length;
|
|
540
|
+
if (existingLen > 200 && newLen < existingLen * 0.5) {
|
|
541
|
+
return {
|
|
542
|
+
error: `BLOCKED: Source would shrink from ${existingLen} to ${newLen} chars. This indicates data loss.`,
|
|
543
|
+
existing_length: existingLen,
|
|
544
|
+
new_length: newLen,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const isCart = gs.component === "cart-droppable";
|
|
548
|
+
let res;
|
|
549
|
+
if (isCart) {
|
|
550
|
+
res = await api.updateSourceCart({ source, type: gs.type, site_id: api.siteId });
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
res = await api.updateGlobalSource({ global_source_id, source, type: gs.component, site_id: api.siteId });
|
|
554
|
+
}
|
|
555
|
+
invalidateGsCache();
|
|
556
|
+
const saved = res && res.data;
|
|
557
|
+
if (!saved)
|
|
558
|
+
return { error: "Backend returned empty response — update may not have persisted", sent_length: newLen };
|
|
559
|
+
return { success: true, elements: results, source_length: newLen };
|
|
560
|
+
}));
|
|
561
|
+
// ── CRUD ──
|
|
562
|
+
server.tool("create_global_source", `Create a new global source component. Component types: "cart-droppable" (cart), "popup", or any custom type.`, {
|
|
563
|
+
component: z.string().describe('Component type (e.g. "cart-droppable", "popup")'),
|
|
564
|
+
source: z.any().describe("Source configuration (sections/elements JSON)"),
|
|
565
|
+
type: z.string().default("default").describe('Type variant (default: "default")'),
|
|
566
|
+
}, ({ component, source, type }) => handle(async () => {
|
|
567
|
+
const params = { component, source, type, site_id: api.siteId };
|
|
568
|
+
const res = component === "cart-droppable"
|
|
569
|
+
? await api.createSourceCart(params)
|
|
570
|
+
: await api.createGlobalSource(params);
|
|
571
|
+
invalidateGsCache();
|
|
572
|
+
return res;
|
|
573
|
+
}));
|
|
574
|
+
server.tool("update_global_source", `Replace full source of a global source.
|
|
575
|
+
IMPORTANT: Before calling this tool, you MUST:
|
|
576
|
+
1. Read existing source with get_global_source_detail first
|
|
577
|
+
2. Show the user what will change and get explicit confirmation
|
|
578
|
+
3. NEVER call without user approval — this replaces the ENTIRE source
|
|
579
|
+
Safeguarded: blocks if new source is <50% of existing size. For element-level changes, prefer update_global_source_element instead.`, {
|
|
580
|
+
global_source_id: z.string().describe("Global source ID"),
|
|
581
|
+
source: z.any().describe("New source configuration (JSON object)"),
|
|
582
|
+
}, ({ global_source_id, source: newSourceInput }) => handle(async () => {
|
|
583
|
+
// Force fresh read before write to avoid stale cache
|
|
584
|
+
invalidateGsCache();
|
|
585
|
+
const { gs, source: existingSource, error } = await getGsWithSource(api, global_source_id);
|
|
586
|
+
if (error)
|
|
587
|
+
return { error };
|
|
588
|
+
// Ensure source is an object (not string) — backend does Jason.encode! so we must send object
|
|
589
|
+
const newSourceObj = typeof newSourceInput === "string" ? JSON.parse(newSourceInput) : newSourceInput;
|
|
590
|
+
const newLen = JSON.stringify(newSourceObj).length;
|
|
591
|
+
// Safeguard: block if new source is suspiciously smaller
|
|
592
|
+
if (existingSource) {
|
|
593
|
+
const existingLen = JSON.stringify(existingSource).length;
|
|
594
|
+
if (existingLen > 200 && newLen < existingLen * 0.5) {
|
|
595
|
+
return {
|
|
596
|
+
error: `BLOCKED: New source (${newLen} chars) is much smaller than existing (${existingLen} chars) — ${Math.round((newLen / existingLen) * 100)}% of original. Use get_global_source_detail to read existing data first, or use update_global_source_element for targeted changes.`,
|
|
597
|
+
existing_length: existingLen,
|
|
598
|
+
new_length: newLen,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const isCart = gs.component === "cart-droppable";
|
|
603
|
+
if (isCart) {
|
|
604
|
+
await api.updateSourceCart({ source: newSourceObj, type: gs.type, site_id: api.siteId });
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
await api.updateGlobalSource({ global_source_id, source: newSourceObj, type: gs.component, site_id: api.siteId });
|
|
608
|
+
}
|
|
609
|
+
invalidateGsCache();
|
|
610
|
+
return { success: true, global_source_id, new_length: newLen };
|
|
611
|
+
}));
|
|
612
|
+
server.tool("delete_global_source", "Delete a global source and its published version", {
|
|
613
|
+
global_source_id: z.string().describe("Global source ID to delete"),
|
|
614
|
+
}, ({ global_source_id }) => handle(async () => {
|
|
615
|
+
const res = await api.deleteGlobalSource({ global_source_id, site_id: api.siteId });
|
|
616
|
+
invalidateGsCache();
|
|
617
|
+
return res;
|
|
618
|
+
}));
|
|
619
|
+
// ── Multilingual contents ──
|
|
620
|
+
server.tool("get_global_source_contents", "Get multilingual contents for global sources by component type", {
|
|
621
|
+
component: z.string().describe('Component type (e.g. "cart-droppable", "popup")'),
|
|
622
|
+
}, ({ component }) => handle(() => api.getGlobalSourceContents({ site_id: api.siteId, component })));
|
|
623
|
+
server.tool("update_global_source_contents", `Update multilingual contents (upsert). Each entry: global_source_id, language_code, content.
|
|
624
|
+
IMPORTANT: Before calling, you MUST read existing contents with get_global_source_contents first, then show the user what will change and get explicit confirmation. NEVER update without user approval.`, {
|
|
625
|
+
contents: z.array(z.object({
|
|
626
|
+
global_source_id: z.string().describe("Global source ID"),
|
|
627
|
+
language_code: z.string().describe("Language code (e.g. 'en', 'vi')"),
|
|
628
|
+
content: z.any().describe("Translation content (JSON object)"),
|
|
629
|
+
type_component: z.string().optional().describe("Component type"),
|
|
630
|
+
})).describe("Array of content entries to upsert"),
|
|
631
|
+
}, ({ contents }) => handle(async () => {
|
|
632
|
+
// Safeguard: for each entry, check existing content size
|
|
633
|
+
for (const entry of contents) {
|
|
634
|
+
if (!entry.content)
|
|
635
|
+
continue;
|
|
636
|
+
const component = entry.type_component;
|
|
637
|
+
if (!component)
|
|
638
|
+
continue;
|
|
639
|
+
const existingRes = await api.getGlobalSourceContents({ site_id: api.siteId, component });
|
|
640
|
+
const existingList = (existingRes && existingRes.data) || existingRes || [];
|
|
641
|
+
if (!Array.isArray(existingList))
|
|
642
|
+
continue;
|
|
643
|
+
const existing = existingList.find((c) => String(c.global_source_id) === String(entry.global_source_id) && c.language_code === entry.language_code);
|
|
644
|
+
if (existing && existing.content) {
|
|
645
|
+
const existingStr = JSON.stringify(existing.content);
|
|
646
|
+
const newStr = JSON.stringify(entry.content);
|
|
647
|
+
if (existingStr.length > 200 && newStr.length < existingStr.length * 0.5) {
|
|
648
|
+
return {
|
|
649
|
+
error: `BLOCKED: Content for ${entry.global_source_id}/${entry.language_code} would shrink from ${existingStr.length} to ${newStr.length} chars (${Math.round((newStr.length / existingStr.length) * 100)}% of original). Read existing with get_global_source_contents first.`,
|
|
650
|
+
existing_length: existingStr.length,
|
|
651
|
+
new_length: newStr.length,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
break; // Only check first entry to avoid excessive API calls
|
|
656
|
+
}
|
|
657
|
+
return api.updateGlobalSourceContents({
|
|
658
|
+
site_id: api.siteId,
|
|
659
|
+
contents: contents.map((c) => ({ ...c, site_id: api.siteId })),
|
|
660
|
+
});
|
|
661
|
+
}));
|
|
662
|
+
}
|