@noleemits/vision-builder-control-mcp 4.5.5
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 +34 -0
- package/index.js +4361 -0
- package/package.json +45 -0
package/index.js
ADDED
|
@@ -0,0 +1,4361 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Noleemits Vision Builder Control MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Provides 62 tools for building and managing WordPress/Elementor sites.
|
|
6
|
+
* v4.3.0: Add 4 nvbc_* shortcodes (pill badges, category list, excerpt, category cards).
|
|
7
|
+
* v4.2.1: Fix audit_clickable scanner (WP_Post object vs ID bug).
|
|
8
|
+
* - Design tokens (get, set, refresh)
|
|
9
|
+
* - Design rules (generated from tokens)
|
|
10
|
+
* - Button generator (context-aware, token-driven)
|
|
11
|
+
* - Page management (list, create)
|
|
12
|
+
* - Page validation, content extraction, template import
|
|
13
|
+
* - Section management (list, remove, reorder/move/swap)
|
|
14
|
+
* - Element-level operations (list tree, remove, update settings by ID)
|
|
15
|
+
* - Link audit & fix (scan all pages, ensure correct internal/external targets)
|
|
16
|
+
* - Page export/snapshot, image audit, global find & replace
|
|
17
|
+
* - Cross-page element copy, broken link checker
|
|
18
|
+
* - Component presets (get, set style overrides)
|
|
19
|
+
* - Component library, add component, build page
|
|
20
|
+
* - Posts CRUD (list, get, create, update, delete any post type)
|
|
21
|
+
* - RankMath SEO (get, update, audit across all content)
|
|
22
|
+
* - Taxonomies (list all, assign terms to posts)
|
|
23
|
+
* - Health check
|
|
24
|
+
* - Template library (list kits, browse sections, get section JSON, get tokens, rebrand, inject content, assemble page)
|
|
25
|
+
*
|
|
26
|
+
* Transport modes:
|
|
27
|
+
* stdio (default) — for Claude Desktop / Claude Code
|
|
28
|
+
* --http — HTTP/SSE server for Claude.ai web connectors
|
|
29
|
+
* --port=N — HTTP port (default: 3100)
|
|
30
|
+
*
|
|
31
|
+
* WordPress plugin: Noleemits Vision Builder Control (nvbc/v1 REST endpoints)
|
|
32
|
+
*
|
|
33
|
+
* Version: 4.4.0
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
37
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
38
|
+
import {
|
|
39
|
+
CallToolRequestSchema,
|
|
40
|
+
ListToolsRequestSchema,
|
|
41
|
+
ErrorCode,
|
|
42
|
+
McpError,
|
|
43
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
44
|
+
import { readFileSync, existsSync } from 'fs';
|
|
45
|
+
import { join, dirname } from 'path';
|
|
46
|
+
import { fileURLToPath } from 'url';
|
|
47
|
+
|
|
48
|
+
// ================================================================
|
|
49
|
+
// CLI ARGS
|
|
50
|
+
// ================================================================
|
|
51
|
+
|
|
52
|
+
const cliArgs = process.argv.slice(2);
|
|
53
|
+
const httpMode = cliArgs.includes('--http');
|
|
54
|
+
const portArg = cliArgs.find(a => a.startsWith('--port='));
|
|
55
|
+
const PORT = portArg ? parseInt(portArg.split('=')[1]) : 3100;
|
|
56
|
+
const HEARTBEAT_MS = 25000;
|
|
57
|
+
const FETCH_TIMEOUT_MS = 120000;
|
|
58
|
+
const TOKEN_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
59
|
+
|
|
60
|
+
// ================================================================
|
|
61
|
+
// GLOBAL ERROR HANDLERS & LIFECYCLE LOGGING
|
|
62
|
+
// ================================================================
|
|
63
|
+
|
|
64
|
+
process.on('uncaughtException', (err) => {
|
|
65
|
+
console.error('[FATAL] Uncaught exception (kept alive):', err.message);
|
|
66
|
+
console.error(err.stack);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
process.on('unhandledRejection', (reason) => {
|
|
70
|
+
console.error('[WARN] Unhandled rejection (kept alive):', reason);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
process.on('exit', (code) => {
|
|
74
|
+
console.error(`[LIFECYCLE] Process exiting with code ${code}`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
process.on('beforeExit', (code) => {
|
|
78
|
+
console.error(`[LIFECYCLE] beforeExit fired (code ${code}) — event loop empty, keeping alive`);
|
|
79
|
+
// Prevent exit by scheduling work if event loop is empty
|
|
80
|
+
if (!global._mcpKeepAlive) {
|
|
81
|
+
global._mcpKeepAlive = setInterval(() => {}, 30000);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
process.on('SIGTERM', () => {
|
|
86
|
+
console.error('[LIFECYCLE] SIGTERM received');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
process.on('SIGINT', () => {
|
|
90
|
+
console.error('[LIFECYCLE] SIGINT received');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ================================================================
|
|
94
|
+
// CONFIG
|
|
95
|
+
// ================================================================
|
|
96
|
+
|
|
97
|
+
const VERSION = '4.5.5';
|
|
98
|
+
|
|
99
|
+
// ================================================================
|
|
100
|
+
// PARAMETER HELPERS
|
|
101
|
+
// ================================================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse a boolean parameter that may arrive as a string from MCP protocol.
|
|
105
|
+
* @param {*} value - The parameter value
|
|
106
|
+
* @param {boolean} defaultValue - Default if undefined/null
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
function parseBool(value, defaultValue = false) {
|
|
110
|
+
if (value === undefined || value === null) return defaultValue;
|
|
111
|
+
if (typeof value === 'boolean') return value;
|
|
112
|
+
if (typeof value === 'string') return value.toLowerCase() === 'true';
|
|
113
|
+
return Boolean(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Strip CDATA wrappers that MCP XML transport may inject into string values.
|
|
118
|
+
* Handles: <![CDATA[content]]> → content
|
|
119
|
+
*/
|
|
120
|
+
function stripCDATA(str) {
|
|
121
|
+
if (typeof str !== 'string') return str;
|
|
122
|
+
// Full CDATA wrapper
|
|
123
|
+
str = str.replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '');
|
|
124
|
+
return str;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Recursively strip CDATA from all string values in an object/array.
|
|
129
|
+
*/
|
|
130
|
+
function deepStripCDATA(obj) {
|
|
131
|
+
if (typeof obj === 'string') return stripCDATA(obj);
|
|
132
|
+
if (Array.isArray(obj)) return obj.map(deepStripCDATA);
|
|
133
|
+
if (obj && typeof obj === 'object') {
|
|
134
|
+
const result = {};
|
|
135
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
136
|
+
result[k] = deepStripCDATA(v);
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
return obj;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const CONFIG = {
|
|
144
|
+
wordpressUrl: process.env.WP_URL || 'http://localhost',
|
|
145
|
+
username: process.env.WP_USER || '',
|
|
146
|
+
applicationPassword: process.env.WP_APP_PASSWORD || '',
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ================================================================
|
|
150
|
+
// TEMPLATE LIBRARY
|
|
151
|
+
// ================================================================
|
|
152
|
+
|
|
153
|
+
const __filename_tl = fileURLToPath(import.meta.url);
|
|
154
|
+
const __dirname_tl = dirname(__filename_tl);
|
|
155
|
+
|
|
156
|
+
const TEMPLATE_LIBRARY_DIR = process.env.TEMPLATE_LIBRARY_DIR || '';
|
|
157
|
+
let _templateCatalog = null;
|
|
158
|
+
|
|
159
|
+
function getTemplateCatalog() {
|
|
160
|
+
if (_templateCatalog) return _templateCatalog;
|
|
161
|
+
const catalogPath = join(TEMPLATE_LIBRARY_DIR, 'section-catalog.json');
|
|
162
|
+
if (!TEMPLATE_LIBRARY_DIR || !existsSync(catalogPath)) return null;
|
|
163
|
+
try {
|
|
164
|
+
_templateCatalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
|
|
165
|
+
return _templateCatalog;
|
|
166
|
+
} catch (e) {
|
|
167
|
+
console.error(`[TEMPLATE] Failed to load catalog: ${e.message}`);
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function loadTemplateSection(kitId, pageSlug, sectionIndex) {
|
|
173
|
+
const templatePath = join(TEMPLATE_LIBRARY_DIR, 'templates', kitId, 'templates', `${pageSlug}.json`);
|
|
174
|
+
if (!existsSync(templatePath)) return null;
|
|
175
|
+
try {
|
|
176
|
+
const data = JSON.parse(readFileSync(templatePath, 'utf8'));
|
|
177
|
+
const content = data.content || [];
|
|
178
|
+
if (sectionIndex < 0 || sectionIndex >= content.length) return null;
|
|
179
|
+
return content[sectionIndex];
|
|
180
|
+
} catch (e) {
|
|
181
|
+
console.error(`[TEMPLATE] Failed to load section: ${e.message}`);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Content Analysis & Section Mapping ──
|
|
187
|
+
|
|
188
|
+
const CONTENT_BLOCK_PATTERNS = {
|
|
189
|
+
hero: {
|
|
190
|
+
description: 'Hero section with main headline and intro',
|
|
191
|
+
detect: (block) => {
|
|
192
|
+
if (block.index !== 0) return 0;
|
|
193
|
+
let score = 0.5; // first block bonus
|
|
194
|
+
if (block.title) score += 0.3; // has a # title within the block
|
|
195
|
+
if (block.has_cta) score += 0.2;
|
|
196
|
+
if (block.paragraphs <= 2) score += 0.1;
|
|
197
|
+
const text = (block.heading + ' ' + (block.title || '')).toLowerCase();
|
|
198
|
+
if (text.includes('hero') || text.includes('welcome') || text.includes('home')) score += 0.1;
|
|
199
|
+
return Math.min(score, 1);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
'stats-bar': {
|
|
203
|
+
description: 'Key metrics or numbers',
|
|
204
|
+
detect: (block) => {
|
|
205
|
+
let score = 0;
|
|
206
|
+
if (block.stat_count >= 3) score += 0.6;
|
|
207
|
+
else if (block.stat_count >= 2) score += 0.4;
|
|
208
|
+
if (block.paragraphs === 0 && !block.has_numbered_items) score += 0.2;
|
|
209
|
+
if (block.has_numbered_items) score -= 0.3; // numbered items are steps, not stats
|
|
210
|
+
if (block.items.length <= 6) score += 0.1;
|
|
211
|
+
return Math.max(0, Math.min(score, 1));
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
'features-grid': {
|
|
215
|
+
description: 'Feature or benefit cards',
|
|
216
|
+
detect: (block) => {
|
|
217
|
+
let score = 0;
|
|
218
|
+
if (block.item_count >= 3 && block.item_count <= 8) score += 0.5;
|
|
219
|
+
if (block.items_have_descriptions) score += 0.3;
|
|
220
|
+
if (block.heading_level >= 3) score += 0.1;
|
|
221
|
+
return Math.min(score, 1);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
'services-grid': {
|
|
225
|
+
description: 'Service offerings with descriptions',
|
|
226
|
+
detect: (block) => {
|
|
227
|
+
let score = 0;
|
|
228
|
+
const text = block.raw_text.toLowerCase();
|
|
229
|
+
if (text.includes('service') || text.includes('offer') || text.includes('solution')) score += 0.3;
|
|
230
|
+
if (block.item_count >= 3 && block.items_have_descriptions) score += 0.4;
|
|
231
|
+
if (block.has_cta) score += 0.1;
|
|
232
|
+
return Math.min(score, 1);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
'about-content': {
|
|
236
|
+
description: 'About section with text and image placeholder',
|
|
237
|
+
detect: (block) => {
|
|
238
|
+
let score = 0;
|
|
239
|
+
if (block.paragraphs >= 2) score += 0.3;
|
|
240
|
+
if (block.heading_level === 2) score += 0.1;
|
|
241
|
+
const text = block.raw_text.toLowerCase();
|
|
242
|
+
if (text.includes('about') || text.includes('mission') || text.includes('story') || text.includes('who we')) score += 0.3;
|
|
243
|
+
if (block.has_image_hint) score += 0.2;
|
|
244
|
+
return Math.min(score, 1);
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
testimonials: {
|
|
248
|
+
description: 'Customer testimonials or reviews',
|
|
249
|
+
detect: (block) => {
|
|
250
|
+
let score = 0;
|
|
251
|
+
if (block.has_quotes) score += 0.6;
|
|
252
|
+
const text = block.raw_text.toLowerCase();
|
|
253
|
+
if (text.includes('testimonial') || text.includes('review') || text.includes('client') ||
|
|
254
|
+
text.includes('patient') || text.includes('say') || text.includes('said')) score += 0.3;
|
|
255
|
+
if (block.item_count >= 2) score += 0.1;
|
|
256
|
+
return Math.min(score, 1);
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
'team-grid': {
|
|
260
|
+
description: 'Team member profiles',
|
|
261
|
+
detect: (block) => {
|
|
262
|
+
let score = 0;
|
|
263
|
+
const text = block.raw_text.toLowerCase();
|
|
264
|
+
if (text.includes('team') || text.includes('staff') || text.includes('doctor') || text.includes('physician') || text.includes('surgeon')) score += 0.4;
|
|
265
|
+
if (block.has_people_names && !block.has_quotes) score += 0.4;
|
|
266
|
+
if (block.item_count >= 2) score += 0.2;
|
|
267
|
+
// Penalize when content is clearly Q/A (false positive from name pattern)
|
|
268
|
+
if (block.qa_count >= 2) score -= 0.5;
|
|
269
|
+
return Math.max(0, Math.min(score, 1));
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
'pricing-table': {
|
|
273
|
+
description: 'Pricing plans or packages',
|
|
274
|
+
detect: (block) => {
|
|
275
|
+
let score = 0;
|
|
276
|
+
const text = block.raw_text.toLowerCase();
|
|
277
|
+
if (text.includes('$') || text.includes('price') || text.includes('/mo') || text.includes('plan')) score += 0.5;
|
|
278
|
+
if (block.item_count >= 2 && block.item_count <= 4) score += 0.3;
|
|
279
|
+
if (block.has_cta) score += 0.1;
|
|
280
|
+
return Math.min(score, 1);
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
faq: {
|
|
284
|
+
description: 'Frequently asked questions',
|
|
285
|
+
detect: (block) => {
|
|
286
|
+
let score = 0;
|
|
287
|
+
if (block.question_count >= 2) score += 0.6;
|
|
288
|
+
if (block.qa_count >= 2) score += 0.7; // Q: ... A: ... bullet pattern
|
|
289
|
+
const text = block.raw_text.toLowerCase();
|
|
290
|
+
if (text.includes('faq') || text.includes('frequently') || text.includes('question')) score += 0.4;
|
|
291
|
+
// Detect Q&A pattern: short lines alternating with longer explanations
|
|
292
|
+
const lines = block.lines.filter(l => l.trim().length > 0);
|
|
293
|
+
const shortLong = lines.filter((l, i) => i < lines.length - 1 && l.trim().length < 80 && lines[i+1].trim().length > 40);
|
|
294
|
+
if (shortLong.length >= 2) score += 0.3;
|
|
295
|
+
return Math.min(score, 1);
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
'contact-form': {
|
|
299
|
+
description: 'Contact form or contact info',
|
|
300
|
+
detect: (block) => {
|
|
301
|
+
let score = 0;
|
|
302
|
+
const text = block.raw_text.toLowerCase();
|
|
303
|
+
const heading = (block.heading + ' ' + (block.title || '')).toLowerCase();
|
|
304
|
+
if (heading.includes('contact') || heading.includes('get in touch') || heading.includes('reach us')) score += 0.5;
|
|
305
|
+
else if (text.includes('contact') || text.includes('get in touch') || text.includes('reach us')) score += 0.3;
|
|
306
|
+
if (text.includes('email') || text.includes('phone') || text.includes('address')) score += 0.4;
|
|
307
|
+
if (block.has_cta) score += 0.1;
|
|
308
|
+
return Math.min(score, 1);
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
'process-steps': {
|
|
312
|
+
description: 'Numbered steps or process flow',
|
|
313
|
+
detect: (block) => {
|
|
314
|
+
let score = 0;
|
|
315
|
+
if (block.has_numbered_items) score += 0.6;
|
|
316
|
+
const text = block.raw_text.toLowerCase();
|
|
317
|
+
if (text.includes('step') || text.includes('process') || text.includes('how it works') || text.includes('how to')) score += 0.3;
|
|
318
|
+
if (block.item_count >= 3 && block.item_count <= 6) score += 0.2;
|
|
319
|
+
return Math.min(score, 1);
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
cta: {
|
|
323
|
+
description: 'Call-to-action section',
|
|
324
|
+
detect: (block) => {
|
|
325
|
+
let score = 0;
|
|
326
|
+
if (block.has_cta) score += 0.3;
|
|
327
|
+
if (block.paragraphs <= 2 && block.items.length <= 3) score += 0.2;
|
|
328
|
+
const text = block.raw_text.toLowerCase();
|
|
329
|
+
if (text.includes('get started') || text.includes('sign up') || text.includes('schedule') ||
|
|
330
|
+
text.includes('book') || text.includes('free') || text.includes('contact us') ||
|
|
331
|
+
text.includes('call now') || text.includes('start your')) score += 0.4;
|
|
332
|
+
if (block.index === block.total_blocks - 1) score += 0.2;
|
|
333
|
+
return Math.min(score, 1);
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
'text-content': {
|
|
337
|
+
description: 'Rich text content block',
|
|
338
|
+
detect: (block) => {
|
|
339
|
+
let score = 0;
|
|
340
|
+
if (block.paragraphs >= 3) score += 0.4;
|
|
341
|
+
if (block.item_count === 0) score += 0.2;
|
|
342
|
+
if (!block.has_cta && !block.has_quotes) score += 0.1;
|
|
343
|
+
return Math.min(score, 1);
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
'logo-strip': {
|
|
347
|
+
description: 'Partner or client logos',
|
|
348
|
+
detect: (block) => {
|
|
349
|
+
let score = 0;
|
|
350
|
+
const text = block.raw_text.toLowerCase();
|
|
351
|
+
if (text.includes('partner') || text.includes('client') || text.includes('trusted by') ||
|
|
352
|
+
text.includes('featured in') || text.includes('as seen')) score += 0.6;
|
|
353
|
+
if (block.paragraphs === 0 && block.item_count === 0) score += 0.2;
|
|
354
|
+
return Math.min(score, 1);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
gallery: {
|
|
358
|
+
description: 'Image gallery or portfolio',
|
|
359
|
+
detect: (block) => {
|
|
360
|
+
let score = 0;
|
|
361
|
+
const text = block.raw_text.toLowerCase();
|
|
362
|
+
if (text.includes('gallery') || text.includes('portfolio') || text.includes('project') || text.includes('work')) score += 0.4;
|
|
363
|
+
if (block.image_count >= 3) score += 0.4;
|
|
364
|
+
return Math.min(score, 1);
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
'comparison-table': {
|
|
368
|
+
description: 'Feature comparison or vs table',
|
|
369
|
+
detect: (block) => {
|
|
370
|
+
let score = 0;
|
|
371
|
+
const text = block.raw_text.toLowerCase();
|
|
372
|
+
if (text.includes('vs') || text.includes('compare') || text.includes('comparison') || text.includes('versus')) score += 0.5;
|
|
373
|
+
if (block.has_table) score += 0.4;
|
|
374
|
+
return Math.min(score, 1);
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
function parseContentBlocks(markdown) {
|
|
380
|
+
const lines = markdown.split('\n');
|
|
381
|
+
const blocks = [];
|
|
382
|
+
let currentBlock = null;
|
|
383
|
+
|
|
384
|
+
function finalizeBlock() {
|
|
385
|
+
if (!currentBlock) return;
|
|
386
|
+
// Analyze the accumulated block
|
|
387
|
+
const text = currentBlock.lines.join('\n');
|
|
388
|
+
const raw = text.toLowerCase();
|
|
389
|
+
|
|
390
|
+
// Count stats (numbers with labels like "99% something" or "2,732+ procedures")
|
|
391
|
+
// Matches: 99%, 500+, $50M+, 2,732+, 25+ Years, 0.01% — handles bullet prefix
|
|
392
|
+
const statPattern = /(?:^|\n)\s*(?:[-*•]\s+)?(?:\$?[\d,]+[%+]|[\d,.]+[kKmM]\+?|\$[\d,.]+[kKmM]?\+?)\s+\w/gm;
|
|
393
|
+
const stats = text.match(statPattern) || [];
|
|
394
|
+
|
|
395
|
+
// Count questions
|
|
396
|
+
const questions = currentBlock.lines.filter(l => l.trim().endsWith('?'));
|
|
397
|
+
|
|
398
|
+
// Count Q/A patterns in bullets (e.g. "- Q: ... A: ...")
|
|
399
|
+
const qaItems = currentBlock.lines.filter(l => /^\s*[-*•]\s+Q:\s*.+\s*A:\s*.+/i.test(l));
|
|
400
|
+
|
|
401
|
+
// Count list items
|
|
402
|
+
const listItems = currentBlock.lines.filter(l => /^\s*[-*•]\s/.test(l) || /^\s*\d+[.)]\s/.test(l));
|
|
403
|
+
const numberedItems = currentBlock.lines.filter(l => /^\s*\d+[.)]\s/.test(l));
|
|
404
|
+
|
|
405
|
+
// Count sub-headings (items with titles)
|
|
406
|
+
const subHeadings = currentBlock.lines.filter(l => /^#{3,6}\s/.test(l));
|
|
407
|
+
|
|
408
|
+
// Detect items with descriptions (sub-heading followed by text, OR colon-separated bullet items)
|
|
409
|
+
const colonItems = currentBlock.lines.filter(l => /^\s*[-*•]\s+[^:]+:\s+.{10,}/.test(l));
|
|
410
|
+
const itemsWithDesc = (subHeadings.length > 0 && currentBlock.lines.some(l => !l.startsWith('#') && l.trim().length > 30))
|
|
411
|
+
|| colonItems.length >= 2;
|
|
412
|
+
|
|
413
|
+
// Count paragraphs (non-empty, non-heading, non-list lines)
|
|
414
|
+
const paragraphs = currentBlock.lines.filter(l =>
|
|
415
|
+
l.trim().length > 0 && !l.startsWith('#') && !/^\s*[-*•]\s/.test(l) && !/^\s*\d+[.)]\s/.test(l)
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// Detect image hints
|
|
419
|
+
const imageHints = text.match(/!\[.*?\]|image:|photo:|picture:|illustration:|icon:/gi) || [];
|
|
420
|
+
|
|
421
|
+
// Detect quotes
|
|
422
|
+
const quotes = currentBlock.lines.filter(l => l.trim().startsWith('>') || l.trim().startsWith('"'));
|
|
423
|
+
|
|
424
|
+
// Detect CTA-like phrases
|
|
425
|
+
const ctaPhrases = raw.match(/get started|sign up|schedule|book now|learn more|contact us|call now|free.*review|start your|download|subscribe/g) || [];
|
|
426
|
+
|
|
427
|
+
// Detect people names (capitalized two-word patterns)
|
|
428
|
+
const namePattern = /(?:Dr\.|Mr\.|Mrs\.|Ms\.)?\s?[A-Z][a-z]+ [A-Z][a-z]+/g;
|
|
429
|
+
const names = text.match(namePattern) || [];
|
|
430
|
+
|
|
431
|
+
// Detect table
|
|
432
|
+
const hasTable = currentBlock.lines.some(l => l.includes('|') && l.trim().startsWith('|'));
|
|
433
|
+
|
|
434
|
+
// Count images
|
|
435
|
+
const images = text.match(/!\[.*?\]\(.*?\)/g) || [];
|
|
436
|
+
|
|
437
|
+
blocks.push({
|
|
438
|
+
index: blocks.length,
|
|
439
|
+
total_blocks: 0, // filled later
|
|
440
|
+
heading: currentBlock.heading,
|
|
441
|
+
title: currentBlock.title || currentBlock.heading,
|
|
442
|
+
heading_level: currentBlock.heading_level,
|
|
443
|
+
lines: currentBlock.lines,
|
|
444
|
+
raw_text: text,
|
|
445
|
+
items: listItems,
|
|
446
|
+
item_count: Math.max(listItems.length, subHeadings.length),
|
|
447
|
+
items_have_descriptions: itemsWithDesc,
|
|
448
|
+
stat_count: stats.length,
|
|
449
|
+
question_count: questions.length,
|
|
450
|
+
qa_count: qaItems.length,
|
|
451
|
+
paragraphs: paragraphs.length,
|
|
452
|
+
has_cta: ctaPhrases.length > 0,
|
|
453
|
+
has_quotes: quotes.length > 0,
|
|
454
|
+
has_image_hint: imageHints.length > 0,
|
|
455
|
+
has_people_names: names.length >= 2,
|
|
456
|
+
has_numbered_items: numberedItems.length >= 2,
|
|
457
|
+
has_table: hasTable,
|
|
458
|
+
image_count: images.length,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
currentBlock = null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for (const line of lines) {
|
|
465
|
+
const sectionMatch = line.match(/^##\s+(.+)/);
|
|
466
|
+
if (sectionMatch) {
|
|
467
|
+
// ## creates a new content block (section divider)
|
|
468
|
+
finalizeBlock();
|
|
469
|
+
currentBlock = {
|
|
470
|
+
heading: sectionMatch[1].trim(),
|
|
471
|
+
heading_level: 2,
|
|
472
|
+
lines: []
|
|
473
|
+
};
|
|
474
|
+
} else {
|
|
475
|
+
if (!currentBlock) {
|
|
476
|
+
currentBlock = { heading: '', heading_level: 0, lines: [] };
|
|
477
|
+
}
|
|
478
|
+
// # within a block becomes the block's title (not a new block)
|
|
479
|
+
const titleMatch = line.match(/^#\s+(.+)/);
|
|
480
|
+
if (titleMatch && currentBlock.lines.length === 0 && !currentBlock.title) {
|
|
481
|
+
currentBlock.title = titleMatch[1].trim();
|
|
482
|
+
}
|
|
483
|
+
currentBlock.lines.push(line);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
finalizeBlock();
|
|
487
|
+
|
|
488
|
+
// Fill total_blocks
|
|
489
|
+
blocks.forEach(b => b.total_blocks = blocks.length);
|
|
490
|
+
|
|
491
|
+
return blocks;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function mapContentToSections(blocks, catalog, kitId) {
|
|
495
|
+
const results = [];
|
|
496
|
+
|
|
497
|
+
for (const block of blocks) {
|
|
498
|
+
// Score against all content block patterns
|
|
499
|
+
const scores = {};
|
|
500
|
+
for (const [type, pattern] of Object.entries(CONTENT_BLOCK_PATTERNS)) {
|
|
501
|
+
const score = pattern.detect(block);
|
|
502
|
+
if (score > 0.2) scores[type] = score;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
|
506
|
+
const bestType = sorted[0]?.[0] || 'text-content';
|
|
507
|
+
const bestScore = sorted[0]?.[1] || 0;
|
|
508
|
+
|
|
509
|
+
// Debug: log block scores
|
|
510
|
+
console.error(`[MAPPER] Block ${block.index} "${block.heading}" → items:${block.item_count} stats:${block.stat_count} qa:${block.qa_count || 0} q:${block.question_count} names:${block.has_people_names} desc:${block.items_have_descriptions}`);
|
|
511
|
+
console.error(`[MAPPER] Scores: ${sorted.map(([t,s]) => `${t}:${s.toFixed(2)}`).join(', ')}`);
|
|
512
|
+
|
|
513
|
+
// Find matching sections from catalog
|
|
514
|
+
let candidates = catalog.sections.filter(s => s.type === bestType);
|
|
515
|
+
if (kitId) candidates = candidates.filter(s => s.kit_id === kitId);
|
|
516
|
+
|
|
517
|
+
// If no candidates for this type in kit, try all kits
|
|
518
|
+
if (candidates.length === 0 && kitId) {
|
|
519
|
+
candidates = catalog.sections.filter(s => s.type === bestType);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Rank candidates by confidence and element count appropriateness
|
|
523
|
+
candidates.sort((a, b) => {
|
|
524
|
+
// Prefer high confidence
|
|
525
|
+
let diff = b.confidence - a.confidence;
|
|
526
|
+
// Break ties by column count match
|
|
527
|
+
if (Math.abs(diff) < 0.1 && block.item_count > 0) {
|
|
528
|
+
const aColDiff = Math.abs(a.features.column_count - block.item_count);
|
|
529
|
+
const bColDiff = Math.abs(b.features.column_count - block.item_count);
|
|
530
|
+
diff = aColDiff - bColDiff; // prefer closer column match
|
|
531
|
+
}
|
|
532
|
+
return diff;
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
results.push({
|
|
536
|
+
block_index: block.index,
|
|
537
|
+
heading: block.heading,
|
|
538
|
+
detected_type: bestType,
|
|
539
|
+
confidence: Math.round(bestScore * 100) / 100,
|
|
540
|
+
alternatives: sorted.slice(1, 3).map(([t, s]) => ({ type: t, score: Math.round(s * 100) / 100 })),
|
|
541
|
+
recommended_sections: candidates.slice(0, 3).map(s => ({
|
|
542
|
+
id: s.id,
|
|
543
|
+
kit: s.kit_title,
|
|
544
|
+
page: s.page_name,
|
|
545
|
+
section_index: s.section_index,
|
|
546
|
+
confidence: s.confidence,
|
|
547
|
+
columns: s.features.column_count,
|
|
548
|
+
elements: s.features.total_elements,
|
|
549
|
+
preview: s.features.text_preview[0] || ''
|
|
550
|
+
})),
|
|
551
|
+
content_summary: {
|
|
552
|
+
items: block.item_count,
|
|
553
|
+
paragraphs: block.paragraphs,
|
|
554
|
+
stats: block.stat_count,
|
|
555
|
+
questions: block.question_count,
|
|
556
|
+
has_cta: block.has_cta,
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return results;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function loadDesignTokens(kitId) {
|
|
565
|
+
const globalPath = join(TEMPLATE_LIBRARY_DIR, 'templates', kitId, 'templates', 'global.json');
|
|
566
|
+
if (!existsSync(globalPath)) return null;
|
|
567
|
+
try {
|
|
568
|
+
const data = JSON.parse(readFileSync(globalPath, 'utf8'));
|
|
569
|
+
return data.page_settings || null;
|
|
570
|
+
} catch (e) {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ── Token Swapper (Rebranding) ──
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Build a globals resolver from kit tokens.
|
|
579
|
+
* Returns { colorMap: { id → hex }, typoMap: { id → { family, weight, size } } }
|
|
580
|
+
*/
|
|
581
|
+
function buildGlobalsResolver(sourceTokens) {
|
|
582
|
+
const colorMap = {};
|
|
583
|
+
const typoMap = {};
|
|
584
|
+
if (!sourceTokens) return { colorMap, typoMap };
|
|
585
|
+
|
|
586
|
+
for (const c of sourceTokens.system_colors || []) {
|
|
587
|
+
if (c._id && c.color) colorMap[c._id] = normalizeHex(c.color) || c.color;
|
|
588
|
+
}
|
|
589
|
+
for (const c of sourceTokens.custom_colors || []) {
|
|
590
|
+
if (c._id && c.color) colorMap[c._id] = normalizeHex(c.color) || c.color;
|
|
591
|
+
}
|
|
592
|
+
for (const t of sourceTokens.system_typography || []) {
|
|
593
|
+
if (t._id && t.typography_typography === 'custom') {
|
|
594
|
+
typoMap[t._id] = {
|
|
595
|
+
family: t.typography_font_family || '',
|
|
596
|
+
weight: t.typography_font_weight || '',
|
|
597
|
+
size: t.typography_font_size || null,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
for (const t of sourceTokens.custom_typography || []) {
|
|
602
|
+
if (t._id && t.typography_typography === 'custom') {
|
|
603
|
+
typoMap[t._id] = {
|
|
604
|
+
family: t.typography_font_family || '',
|
|
605
|
+
weight: t.typography_font_weight || '',
|
|
606
|
+
size: t.typography_font_size || null,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return { colorMap, typoMap };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Normalize a hex color to uppercase 6-digit form for consistent matching.
|
|
615
|
+
* Handles #RGB, #RRGGBB, #RRGGBBAA formats. Returns null for non-hex.
|
|
616
|
+
*/
|
|
617
|
+
function normalizeHex(color) {
|
|
618
|
+
if (!color || typeof color !== 'string') return null;
|
|
619
|
+
const m = color.match(/^#([0-9A-Fa-f]{3,8})$/);
|
|
620
|
+
if (!m) return null;
|
|
621
|
+
const hex = m[1].toUpperCase();
|
|
622
|
+
if (hex.length === 3) return '#' + hex[0]+hex[0] + hex[1]+hex[1] + hex[2]+hex[2];
|
|
623
|
+
if (hex.length === 4) return '#' + hex[0]+hex[0] + hex[1]+hex[1] + hex[2]+hex[2] + hex[3]+hex[3];
|
|
624
|
+
if (hex.length === 6 || hex.length === 8) return '#' + hex;
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Build color replacement map from source kit tokens → target brand tokens.
|
|
630
|
+
* colorOverrides: { source_hex: target_hex } or { system_color_id: target_hex }
|
|
631
|
+
* sourceTokens: the source kit's page_settings (from loadDesignTokens)
|
|
632
|
+
*/
|
|
633
|
+
function buildColorMap(sourceTokens, colorOverrides) {
|
|
634
|
+
const map = {}; // normalized source hex → target hex
|
|
635
|
+
|
|
636
|
+
if (!colorOverrides || !sourceTokens) return map;
|
|
637
|
+
|
|
638
|
+
// Map system color IDs to their hex values
|
|
639
|
+
const systemColorMap = {};
|
|
640
|
+
for (const c of sourceTokens.system_colors || []) {
|
|
641
|
+
systemColorMap[c._id] = normalizeHex(c.color);
|
|
642
|
+
}
|
|
643
|
+
// Also map custom colors
|
|
644
|
+
for (const c of sourceTokens.custom_colors || []) {
|
|
645
|
+
systemColorMap[c._id] = normalizeHex(c.color);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
for (const [src, tgt] of Object.entries(colorOverrides)) {
|
|
649
|
+
const targetNorm = normalizeHex(tgt);
|
|
650
|
+
if (!targetNorm) continue;
|
|
651
|
+
|
|
652
|
+
// If src is a system color ID, resolve to hex
|
|
653
|
+
if (systemColorMap[src]) {
|
|
654
|
+
map[systemColorMap[src]] = targetNorm;
|
|
655
|
+
} else {
|
|
656
|
+
const srcNorm = normalizeHex(src);
|
|
657
|
+
if (srcNorm) map[srcNorm] = targetNorm;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return map;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Replace hex colors in a string value using the color map.
|
|
665
|
+
* Handles both #RRGGBB and #RRGGBBAA (preserving alpha).
|
|
666
|
+
*/
|
|
667
|
+
function replaceColorsInString(str, colorMap) {
|
|
668
|
+
if (!str || typeof str !== 'string') return str;
|
|
669
|
+
return str.replace(/#[0-9A-Fa-f]{3,8}\b/g, (match) => {
|
|
670
|
+
const norm = normalizeHex(match);
|
|
671
|
+
if (!norm) return match;
|
|
672
|
+
// For 8-digit hex (with alpha), check if the base 6-digit color matches
|
|
673
|
+
const base6 = norm.substring(0, 7);
|
|
674
|
+
const alpha = norm.length > 7 ? norm.substring(7) : '';
|
|
675
|
+
if (colorMap[base6]) {
|
|
676
|
+
return colorMap[base6] + alpha;
|
|
677
|
+
}
|
|
678
|
+
if (colorMap[norm]) {
|
|
679
|
+
return colorMap[norm];
|
|
680
|
+
}
|
|
681
|
+
return match;
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Recursively rebrand an Elementor element tree.
|
|
687
|
+
* - Replaces hardcoded hex colors in string settings
|
|
688
|
+
* - Replaces hex colors inside object settings (e.g., box_shadow.color)
|
|
689
|
+
* - Updates __globals__ color references if globalsMap is provided
|
|
690
|
+
* - Replaces font families
|
|
691
|
+
*/
|
|
692
|
+
// Unsplash placeholder images by context keyword
|
|
693
|
+
const UNSPLASH_PLACEHOLDERS = [
|
|
694
|
+
'https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&h=800&fit=crop', // office
|
|
695
|
+
'https://images.unsplash.com/photo-1497215842964-222b430dc094?w=1200&h=800&fit=crop', // workspace
|
|
696
|
+
'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?w=1200&h=800&fit=crop', // team
|
|
697
|
+
'https://images.unsplash.com/photo-1560472354-b33ff0c44a43?w=1200&h=800&fit=crop', // business
|
|
698
|
+
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=600&h=600&fit=crop', // portrait
|
|
699
|
+
'https://images.unsplash.com/photo-1573497019940-1c28c88b4f3e?w=600&h=600&fit=crop', // professional
|
|
700
|
+
];
|
|
701
|
+
let _unsplashIdx = 0;
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Replace external kit image URLs with Unsplash placeholders.
|
|
705
|
+
* Only replaces URLs from known kit domains (newkit.creativemox.com, etc.)
|
|
706
|
+
*/
|
|
707
|
+
function replaceExternalImages(settings) {
|
|
708
|
+
if (!settings || typeof settings !== 'object') return;
|
|
709
|
+
|
|
710
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
711
|
+
// Image object: { url: "http://...", id: 123, ... }
|
|
712
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && 'url' in value && typeof value.url === 'string') {
|
|
713
|
+
if (value.url && (value.url.includes('newkit.creativemox.com') || value.url.includes('creativemox.com'))) {
|
|
714
|
+
value.url = UNSPLASH_PLACEHOLDERS[_unsplashIdx % UNSPLASH_PLACEHOLDERS.length];
|
|
715
|
+
value.id = '';
|
|
716
|
+
_unsplashIdx++;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// Slideshow gallery array
|
|
720
|
+
if (Array.isArray(value)) {
|
|
721
|
+
for (const item of value) {
|
|
722
|
+
if (item && typeof item === 'object') {
|
|
723
|
+
if ('url' in item && typeof item.url === 'string' &&
|
|
724
|
+
(item.url.includes('newkit.creativemox.com') || item.url.includes('creativemox.com'))) {
|
|
725
|
+
item.url = UNSPLASH_PLACEHOLDERS[_unsplashIdx % UNSPLASH_PLACEHOLDERS.length];
|
|
726
|
+
item.id = '';
|
|
727
|
+
_unsplashIdx++;
|
|
728
|
+
}
|
|
729
|
+
replaceExternalImages(item);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
// Recurse into sub-objects (but skip arrays already handled)
|
|
734
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && !('url' in value)) {
|
|
735
|
+
replaceExternalImages(value);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Replace all fa-circle placeholder icons with contextual FA icons
|
|
741
|
+
const REPLACEMENT_ICONS = [
|
|
742
|
+
'fas fa-check-circle', 'fas fa-star', 'fas fa-shield-alt', 'fas fa-award',
|
|
743
|
+
'fas fa-bolt', 'fas fa-heart', 'fas fa-cog', 'fas fa-gem',
|
|
744
|
+
'fas fa-rocket', 'fas fa-thumbs-up', 'fas fa-lightbulb', 'fas fa-bullseye',
|
|
745
|
+
'fas fa-chart-line', 'fas fa-users', 'fas fa-clock', 'fas fa-medal',
|
|
746
|
+
];
|
|
747
|
+
let _iconReplacementCounter = 0;
|
|
748
|
+
|
|
749
|
+
function replaceCircleIcons(settings) {
|
|
750
|
+
if (!settings || typeof settings !== 'object') return;
|
|
751
|
+
for (const key of Object.keys(settings)) {
|
|
752
|
+
const val = settings[key];
|
|
753
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
754
|
+
if (val.value === 'fas fa-circle' && val.library === 'fa-solid') {
|
|
755
|
+
val.value = REPLACEMENT_ICONS[_iconReplacementCounter % REPLACEMENT_ICONS.length];
|
|
756
|
+
_iconReplacementCounter++;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
// Also check icon arrays (e.g. icon-list items)
|
|
760
|
+
if (Array.isArray(val)) {
|
|
761
|
+
for (const item of val) {
|
|
762
|
+
if (item && typeof item === 'object') {
|
|
763
|
+
replaceCircleIcons(item);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function rebrandElement(element, colorMap, fontMap, globalsMap, globalsResolver) {
|
|
771
|
+
if (!element || typeof element !== 'object') return element;
|
|
772
|
+
|
|
773
|
+
// Deep clone to avoid mutating original
|
|
774
|
+
const el = JSON.parse(JSON.stringify(element));
|
|
775
|
+
|
|
776
|
+
if (el.settings && typeof el.settings === 'object') {
|
|
777
|
+
rebrandSettings(el.settings, colorMap, fontMap, globalsMap, globalsResolver);
|
|
778
|
+
// Replace external kit images with Unsplash placeholders
|
|
779
|
+
replaceExternalImages(el.settings);
|
|
780
|
+
// Replace fa-circle placeholder icons with contextual alternatives
|
|
781
|
+
replaceCircleIcons(el.settings);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (Array.isArray(el.elements)) {
|
|
785
|
+
el.elements = el.elements.map(child => rebrandElement(child, colorMap, fontMap, globalsMap, globalsResolver));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return el;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function rebrandSettings(settings, colorMap, fontMap, globalsMap, globalsResolver) {
|
|
792
|
+
// Handle __globals__ references — resolve to hardcoded values
|
|
793
|
+
if (settings.__globals__) {
|
|
794
|
+
const resolver = globalsResolver || {};
|
|
795
|
+
const resolvedColors = resolver.colorMap || {};
|
|
796
|
+
const resolvedTypo = resolver.typoMap || {};
|
|
797
|
+
const keysToDelete = [];
|
|
798
|
+
|
|
799
|
+
for (const [key, ref] of Object.entries(settings.__globals__)) {
|
|
800
|
+
if (typeof ref !== 'string') continue;
|
|
801
|
+
|
|
802
|
+
// Try explicit globalsMap first (user-provided ref-to-ref mapping)
|
|
803
|
+
if (globalsMap && globalsMap[ref]) {
|
|
804
|
+
settings.__globals__[key] = globalsMap[ref];
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Resolve color globals → hardcoded hex
|
|
809
|
+
const colorMatch = ref.match(/^globals\/colors\?id=(.+)$/);
|
|
810
|
+
if (colorMatch) {
|
|
811
|
+
const colorId = colorMatch[1];
|
|
812
|
+
const sourceHex = resolvedColors[colorId];
|
|
813
|
+
if (sourceHex) {
|
|
814
|
+
// Apply color mapping if available, otherwise use source hex
|
|
815
|
+
const targetHex = colorMap[sourceHex] || sourceHex;
|
|
816
|
+
// Set the hardcoded value directly in settings
|
|
817
|
+
// Map __globals__ key to the corresponding settings key
|
|
818
|
+
// e.g., __globals__.title_color → settings.title_color
|
|
819
|
+
settings[key] = targetHex;
|
|
820
|
+
keysToDelete.push(key);
|
|
821
|
+
}
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Resolve typography globals → hardcoded font properties
|
|
826
|
+
const typoMatch = ref.match(/^globals\/typography\?id=(.+)$/);
|
|
827
|
+
if (typoMatch) {
|
|
828
|
+
const typoId = typoMatch[1];
|
|
829
|
+
const sourceTypo = resolvedTypo[typoId];
|
|
830
|
+
if (sourceTypo) {
|
|
831
|
+
// For typography globals, the key pattern is like "typography_typography"
|
|
832
|
+
// We need to set the individual typography sub-properties
|
|
833
|
+
// The key in __globals__ is usually "<prefix>_typography" (e.g., "typography_typography", "title_typography_typography")
|
|
834
|
+
const prefix = key.replace(/_typography$/, '');
|
|
835
|
+
settings[key] = 'custom';
|
|
836
|
+
if (sourceTypo.family) {
|
|
837
|
+
const targetFont = (fontMap && fontMap[sourceTypo.family]) || sourceTypo.family;
|
|
838
|
+
settings[prefix + '_font_family'] = targetFont;
|
|
839
|
+
}
|
|
840
|
+
if (sourceTypo.weight) {
|
|
841
|
+
settings[prefix + '_font_weight'] = sourceTypo.weight;
|
|
842
|
+
}
|
|
843
|
+
if (sourceTypo.size) {
|
|
844
|
+
settings[prefix + '_font_size'] = sourceTypo.size;
|
|
845
|
+
}
|
|
846
|
+
keysToDelete.push(key);
|
|
847
|
+
}
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Remove resolved __globals__ entries
|
|
853
|
+
for (const k of keysToDelete) {
|
|
854
|
+
delete settings.__globals__[k];
|
|
855
|
+
}
|
|
856
|
+
// If __globals__ is now empty, remove it entirely
|
|
857
|
+
if (Object.keys(settings.__globals__).length === 0) {
|
|
858
|
+
delete settings.__globals__;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
863
|
+
if (key === '__globals__') continue;
|
|
864
|
+
|
|
865
|
+
// String values: replace colors and fonts
|
|
866
|
+
if (typeof value === 'string') {
|
|
867
|
+
// Color replacement
|
|
868
|
+
settings[key] = replaceColorsInString(value, colorMap);
|
|
869
|
+
// Font replacement
|
|
870
|
+
if (fontMap && key.includes('font_family') && fontMap[value]) {
|
|
871
|
+
settings[key] = fontMap[value];
|
|
872
|
+
}
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Object values: recurse into sub-objects (box_shadow, border, etc.)
|
|
877
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
878
|
+
for (const [subKey, subVal] of Object.entries(value)) {
|
|
879
|
+
if (typeof subVal === 'string') {
|
|
880
|
+
value[subKey] = replaceColorsInString(subVal, colorMap);
|
|
881
|
+
if (fontMap && subKey.includes('font_family') && fontMap[subVal]) {
|
|
882
|
+
value[subKey] = fontMap[subVal];
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Array values: recurse into each item (e.g., repeater fields)
|
|
890
|
+
if (Array.isArray(value)) {
|
|
891
|
+
for (const item of value) {
|
|
892
|
+
if (item && typeof item === 'object') {
|
|
893
|
+
rebrandSettings(item, colorMap, fontMap, globalsMap, globalsResolver);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Rebrand a full section JSON with color, font, and globals replacements.
|
|
902
|
+
* Returns the rebranded section (deep cloned).
|
|
903
|
+
*/
|
|
904
|
+
function rebrandSection(section, colorMap, fontMap, globalsMap, globalsResolver) {
|
|
905
|
+
return rebrandElement(section, colorMap, fontMap, globalsMap || {}, globalsResolver || null);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ── Content Injection ──
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Extract an ordered list of content-bearing widgets from a section tree.
|
|
912
|
+
* Each entry: { widget, path, type, role }
|
|
913
|
+
*/
|
|
914
|
+
function extractContentSlots(element, path = []) {
|
|
915
|
+
const slots = [];
|
|
916
|
+
if (!element) return slots;
|
|
917
|
+
|
|
918
|
+
if (element.widgetType) {
|
|
919
|
+
const wt = element.widgetType;
|
|
920
|
+
const s = element.settings || {};
|
|
921
|
+
|
|
922
|
+
if (wt === 'heading') {
|
|
923
|
+
slots.push({ widget: element, path: [...path], type: 'heading', text: s.title || '' });
|
|
924
|
+
} else if (wt === 'text-editor') {
|
|
925
|
+
slots.push({ widget: element, path: [...path], type: 'text', text: s.editor || '' });
|
|
926
|
+
} else if (wt === 'button') {
|
|
927
|
+
slots.push({ widget: element, path: [...path], type: 'button', text: s.text || '', url: s.link?.url || '' });
|
|
928
|
+
} else if (wt === 'icon-box') {
|
|
929
|
+
slots.push({ widget: element, path: [...path], type: 'icon-box', title: s.title_text || '', description: s.description_text || '' });
|
|
930
|
+
} else if (wt === 'counter') {
|
|
931
|
+
slots.push({ widget: element, path: [...path], type: 'counter', value: s.ending_number || '', suffix: s.suffix || '', prefix: s.prefix || '' });
|
|
932
|
+
} else if (wt === 'testimonial') {
|
|
933
|
+
slots.push({ widget: element, path: [...path], type: 'testimonial', text: s.testimonial_content || '', name: s.testimonial_name || '', job: s.testimonial_job || '' });
|
|
934
|
+
} else if (wt === 'toggle' || wt === 'nested-accordion') {
|
|
935
|
+
slots.push({ widget: element, path: [...path], type: 'accordion', items: s.tabs || s.items || [] });
|
|
936
|
+
} else if (wt === 'image-box') {
|
|
937
|
+
slots.push({ widget: element, path: [...path], type: 'image-box', title: s.title_text || '', description: s.description_text || '' });
|
|
938
|
+
} else if (wt === 'icon') {
|
|
939
|
+
slots.push({ widget: element, path: [...path], type: 'icon', icon: s.selected_icon || {} });
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (Array.isArray(element.elements)) {
|
|
944
|
+
element.elements.forEach((child, i) => {
|
|
945
|
+
slots.push(...extractContentSlots(child, [...path, i]));
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return slots;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Convert a markdown content block to structured content for injection.
|
|
954
|
+
* content: { heading, subheading, paragraphs[], items[], stats[], buttons[], quotes[], faq[] }
|
|
955
|
+
*/
|
|
956
|
+
function structureContentBlock(block) {
|
|
957
|
+
const result = {
|
|
958
|
+
heading: block.title || block.heading || '',
|
|
959
|
+
subheading: block.title ? block.heading : '',
|
|
960
|
+
paragraphs: [],
|
|
961
|
+
items: [],
|
|
962
|
+
stats: [],
|
|
963
|
+
buttons: [],
|
|
964
|
+
quotes: [],
|
|
965
|
+
faq: [],
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const lines = block.lines || [];
|
|
969
|
+
|
|
970
|
+
// Extract subheadings (### level) as items
|
|
971
|
+
let currentItem = null;
|
|
972
|
+
for (const line of lines) {
|
|
973
|
+
// Skip # title line (already captured as heading)
|
|
974
|
+
if (/^#\s+/.test(line)) continue;
|
|
975
|
+
|
|
976
|
+
const h3 = line.match(/^###\s+(.+)/);
|
|
977
|
+
if (h3) {
|
|
978
|
+
if (currentItem) result.items.push(currentItem);
|
|
979
|
+
currentItem = { title: h3[1].trim(), description: '' };
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (currentItem) {
|
|
983
|
+
if (line.trim()) {
|
|
984
|
+
currentItem.description += (currentItem.description ? ' ' : '') + line.trim();
|
|
985
|
+
}
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Numbered items (process steps, FAQ)
|
|
990
|
+
const numbered = line.match(/^\s*\d+[.)]\s+(.+)/);
|
|
991
|
+
if (numbered) {
|
|
992
|
+
result.items.push({ title: numbered[1].trim(), description: '' });
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Bullet items
|
|
997
|
+
const bullet = line.match(/^\s*[-*•]\s+(.+)/);
|
|
998
|
+
if (bullet) {
|
|
999
|
+
const content = bullet[1].trim();
|
|
1000
|
+
// Check for markdown link: [text](url)
|
|
1001
|
+
const linkMatch = content.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
|
1002
|
+
if (linkMatch) {
|
|
1003
|
+
result.buttons.push({ text: linkMatch[1], url: linkMatch[2] });
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
// Check for FAQ pattern "Q: question A: answer" (MUST be before colon check)
|
|
1007
|
+
const qaMatch = content.match(/^Q:\s*(.+?)\s*A:\s*(.+)$/i);
|
|
1008
|
+
if (qaMatch) {
|
|
1009
|
+
result.faq.push({ question: qaMatch[1].trim(), answer: qaMatch[2].trim() });
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
// Check for "Title: Description" pattern
|
|
1013
|
+
const colonMatch = content.match(/^([^:]+):\s+(.+)/);
|
|
1014
|
+
if (colonMatch) {
|
|
1015
|
+
result.items.push({ title: colonMatch[1].trim(), description: colonMatch[2].trim() });
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
// Check for stat pattern "2500+ Cases Won"
|
|
1019
|
+
const statMatch = content.match(/^(\$?[\d,]+[%+kKmM]*\+?)\s+(.+)/);
|
|
1020
|
+
if (statMatch) {
|
|
1021
|
+
result.stats.push({ value: statMatch[1], label: statMatch[2].trim() });
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
result.items.push({ title: content, description: '' });
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Quotes
|
|
1029
|
+
if (line.trim().startsWith('>') || line.trim().startsWith('"')) {
|
|
1030
|
+
const quoteText = line.trim().replace(/^>\s*/, '').replace(/^"/, '').replace(/"$/, '');
|
|
1031
|
+
const authorMatch = quoteText.match(/[–—-]\s*(.+)$/);
|
|
1032
|
+
if (authorMatch) {
|
|
1033
|
+
result.quotes.push({ text: quoteText.replace(/\s*[–—-]\s*.+$/, ''), author: authorMatch[1].trim() });
|
|
1034
|
+
} else {
|
|
1035
|
+
result.quotes.push({ text: quoteText, author: '' });
|
|
1036
|
+
}
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Stats (numbers with labels)
|
|
1041
|
+
const stat = line.match(/^\s*(?:\$?[\d,]+[%+]|[\d,.]+[kKmM]\+?|\$[\d,.]+[kKmM]?\+?)\s+(.+)/);
|
|
1042
|
+
if (stat) {
|
|
1043
|
+
const valMatch = line.match(/^\s*((?:\$?[\d,]+[%+]|[\d,.]+[kKmM]\+?|\$[\d,.]+[kKmM]?\+?))/);
|
|
1044
|
+
result.stats.push({ value: valMatch ? valMatch[1] : '', label: stat[1].trim() });
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// FAQ pattern: short line followed by longer line
|
|
1049
|
+
if (line.trim().endsWith('?')) {
|
|
1050
|
+
result.faq.push({ question: line.trim(), answer: '' });
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
// If previous was a question, this is the answer
|
|
1054
|
+
if (result.faq.length > 0 && !result.faq[result.faq.length - 1].answer && line.trim()) {
|
|
1055
|
+
result.faq[result.faq.length - 1].answer = line.trim();
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Regular paragraphs
|
|
1060
|
+
if (line.trim()) {
|
|
1061
|
+
result.paragraphs.push(line.trim());
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (currentItem) result.items.push(currentItem);
|
|
1065
|
+
|
|
1066
|
+
// Extract CTA phrases as buttons (only if no explicit link buttons and no FAQ content)
|
|
1067
|
+
if (result.buttons.length === 0 && result.faq.length === 0) {
|
|
1068
|
+
const ctaPatterns = [
|
|
1069
|
+
/(?:get started|sign up|schedule|book now|learn more|contact us|call now|free.*review|start your|discover more)[^.\n]*/gi
|
|
1070
|
+
];
|
|
1071
|
+
for (const p of ctaPatterns) {
|
|
1072
|
+
const matches = lines.join('\n').match(p) || [];
|
|
1073
|
+
for (const m of matches) {
|
|
1074
|
+
result.buttons.push({ text: m.trim(), url: '#' });
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
// If no explicit CTA found but block has_cta flag, use heading as button
|
|
1078
|
+
if (result.buttons.length === 0 && block.has_cta && result.heading) {
|
|
1079
|
+
result.buttons.push({ text: 'Get Started', url: '#' });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return result;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Inject structured content into a section's widgets.
|
|
1088
|
+
* Deep clones the section first to avoid mutation.
|
|
1089
|
+
*/
|
|
1090
|
+
function injectContent(section, content) {
|
|
1091
|
+
const injected = JSON.parse(JSON.stringify(section));
|
|
1092
|
+
const slots = extractContentSlots(injected);
|
|
1093
|
+
|
|
1094
|
+
// Categorize slots
|
|
1095
|
+
const headings = slots.filter(s => s.type === 'heading');
|
|
1096
|
+
const texts = slots.filter(s => s.type === 'text');
|
|
1097
|
+
const buttons = slots.filter(s => s.type === 'button');
|
|
1098
|
+
const iconBoxes = slots.filter(s => s.type === 'icon-box');
|
|
1099
|
+
const counters = slots.filter(s => s.type === 'counter');
|
|
1100
|
+
const testimonials = slots.filter(s => s.type === 'testimonial');
|
|
1101
|
+
const accordions = slots.filter(s => s.type === 'accordion');
|
|
1102
|
+
const imageBoxes = slots.filter(s => s.type === 'image-box');
|
|
1103
|
+
|
|
1104
|
+
let changes = 0;
|
|
1105
|
+
const paragraphs = content.paragraphs || [];
|
|
1106
|
+
const quotes = content.quotes || [];
|
|
1107
|
+
const faq = content.faq || [];
|
|
1108
|
+
|
|
1109
|
+
// Inject headings: first heading = main heading, rest = subheading or paragraphs
|
|
1110
|
+
if (headings.length > 0 && content.heading) {
|
|
1111
|
+
// Find the "main" heading (usually largest or first H1/H2)
|
|
1112
|
+
const mainIdx = headings.findIndex(h => {
|
|
1113
|
+
const size = h.widget.settings?.header_size;
|
|
1114
|
+
return size === 'h1' || size === 'h2' || !size;
|
|
1115
|
+
});
|
|
1116
|
+
const mi = mainIdx >= 0 ? mainIdx : 0;
|
|
1117
|
+
headings[mi].widget.settings.title = content.heading;
|
|
1118
|
+
changes++;
|
|
1119
|
+
|
|
1120
|
+
// If there's a subheading slot before the main heading (common: label above title)
|
|
1121
|
+
if (mi > 0 && content.subheading) {
|
|
1122
|
+
headings[0].widget.settings.title = content.subheading;
|
|
1123
|
+
changes++;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Remaining headings after main: fill with paragraphs or leave
|
|
1127
|
+
let paraIdx = 0;
|
|
1128
|
+
for (let i = 0; i < headings.length; i++) {
|
|
1129
|
+
if (i === mi) continue;
|
|
1130
|
+
if (i < mi) continue; // already handled
|
|
1131
|
+
if (paragraphs.length && paraIdx < paragraphs.length) {
|
|
1132
|
+
headings[i].widget.settings.title = paragraphs[paraIdx++];
|
|
1133
|
+
changes++;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Inject text-editor widgets with paragraphs
|
|
1139
|
+
let pIdx = 0;
|
|
1140
|
+
for (const slot of texts) {
|
|
1141
|
+
if (pIdx < paragraphs.length) {
|
|
1142
|
+
slot.widget.settings.editor = `<p>${paragraphs.slice(pIdx).join('</p><p>')}</p>`;
|
|
1143
|
+
pIdx = paragraphs.length;
|
|
1144
|
+
changes++;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Inject icon-boxes with items + assign contextual FA icons
|
|
1149
|
+
const SERVICE_ICONS = [
|
|
1150
|
+
'fas fa-check-circle', 'fas fa-star', 'fas fa-shield-alt', 'fas fa-award',
|
|
1151
|
+
'fas fa-bolt', 'fas fa-heart', 'fas fa-cog', 'fas fa-gem',
|
|
1152
|
+
'fas fa-rocket', 'fas fa-thumbs-up', 'fas fa-lightbulb', 'fas fa-bullseye',
|
|
1153
|
+
];
|
|
1154
|
+
const items = (content.items || []);
|
|
1155
|
+
for (let i = 0; i < iconBoxes.length && i < items.length; i++) {
|
|
1156
|
+
iconBoxes[i].widget.settings.title_text = items[i].title;
|
|
1157
|
+
if (items[i].description) {
|
|
1158
|
+
iconBoxes[i].widget.settings.description_text = items[i].description;
|
|
1159
|
+
}
|
|
1160
|
+
// Replace placeholder circle icons with contextual icons
|
|
1161
|
+
const currentIcon = iconBoxes[i].widget.settings.selected_icon;
|
|
1162
|
+
if (currentIcon && (currentIcon.value === 'fas fa-circle' || !currentIcon.value)) {
|
|
1163
|
+
iconBoxes[i].widget.settings.selected_icon = {
|
|
1164
|
+
value: SERVICE_ICONS[i % SERVICE_ICONS.length],
|
|
1165
|
+
library: 'fa-solid',
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
changes++;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Also fill image-boxes
|
|
1172
|
+
for (let i = 0; i < imageBoxes.length; i++) {
|
|
1173
|
+
const itemIdx = iconBoxes.length + i;
|
|
1174
|
+
if (itemIdx < items.length) {
|
|
1175
|
+
imageBoxes[i].widget.settings.title_text = items[itemIdx].title;
|
|
1176
|
+
if (items[itemIdx].description) {
|
|
1177
|
+
imageBoxes[i].widget.settings.description_text = items[itemIdx].description;
|
|
1178
|
+
}
|
|
1179
|
+
changes++;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Inject buttons
|
|
1184
|
+
const contentButtons = content.buttons || [];
|
|
1185
|
+
for (let i = 0; i < buttons.length && i < contentButtons.length; i++) {
|
|
1186
|
+
buttons[i].widget.settings.text = contentButtons[i].text;
|
|
1187
|
+
if (contentButtons[i].url && contentButtons[i].url !== '#') {
|
|
1188
|
+
if (!buttons[i].widget.settings.link) buttons[i].widget.settings.link = {};
|
|
1189
|
+
buttons[i].widget.settings.link.url = contentButtons[i].url;
|
|
1190
|
+
}
|
|
1191
|
+
changes++;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Inject counters with stats
|
|
1195
|
+
const contentStats = content.stats || [];
|
|
1196
|
+
for (let i = 0; i < counters.length && i < contentStats.length; i++) {
|
|
1197
|
+
const val = contentStats[i].value.replace(/[^0-9.]/g, '');
|
|
1198
|
+
const suffix = contentStats[i].value.replace(/[\d,.]/g, '');
|
|
1199
|
+
counters[i].widget.settings.ending_number = val || counters[i].widget.settings.ending_number;
|
|
1200
|
+
if (suffix) counters[i].widget.settings.suffix = suffix;
|
|
1201
|
+
if (contentStats[i].label && counters[i].widget.settings.title) {
|
|
1202
|
+
counters[i].widget.settings.title = contentStats[i].label;
|
|
1203
|
+
}
|
|
1204
|
+
changes++;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Replace placeholder circle icons (standalone icon widgets)
|
|
1208
|
+
const STAT_ICONS = [
|
|
1209
|
+
'fas fa-chart-line', 'fas fa-users', 'fas fa-clock', 'fas fa-medal',
|
|
1210
|
+
'fas fa-trophy', 'fas fa-clipboard-check', 'fas fa-hand-holding-heart', 'fas fa-stethoscope',
|
|
1211
|
+
];
|
|
1212
|
+
const icons = slots.filter(s => s.type === 'icon');
|
|
1213
|
+
for (let i = 0; i < icons.length; i++) {
|
|
1214
|
+
const currentIcon = icons[i].widget.settings.selected_icon;
|
|
1215
|
+
if (currentIcon && (currentIcon.value === 'fas fa-circle' || !currentIcon.value)) {
|
|
1216
|
+
icons[i].widget.settings.selected_icon = {
|
|
1217
|
+
value: STAT_ICONS[i % STAT_ICONS.length],
|
|
1218
|
+
library: 'fa-solid',
|
|
1219
|
+
};
|
|
1220
|
+
changes++;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Inject testimonials
|
|
1225
|
+
for (let i = 0; i < testimonials.length && i < quotes.length; i++) {
|
|
1226
|
+
testimonials[i].widget.settings.testimonial_content = quotes[i].text;
|
|
1227
|
+
if (quotes[i].author) {
|
|
1228
|
+
testimonials[i].widget.settings.testimonial_name = quotes[i].author;
|
|
1229
|
+
}
|
|
1230
|
+
changes++;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Inject FAQ into accordion/toggle widgets
|
|
1234
|
+
if (accordions.length > 0 && faq.length > 0) {
|
|
1235
|
+
for (const acc of accordions) {
|
|
1236
|
+
const wt = acc.widget.widgetType;
|
|
1237
|
+
|
|
1238
|
+
if (wt === 'nested-accordion') {
|
|
1239
|
+
// nested-accordion: items[].item_title for questions, child elements for answers
|
|
1240
|
+
const existingItems = acc.widget.settings.items || [];
|
|
1241
|
+
if (existingItems.length === 0) continue;
|
|
1242
|
+
|
|
1243
|
+
const templateItem = JSON.parse(JSON.stringify(existingItems[0]));
|
|
1244
|
+
const templateChild = acc.widget.elements?.[0] ? JSON.parse(JSON.stringify(acc.widget.elements[0])) : null;
|
|
1245
|
+
const newItems = [];
|
|
1246
|
+
const newChildren = [];
|
|
1247
|
+
|
|
1248
|
+
for (const qa of faq) {
|
|
1249
|
+
const item = JSON.parse(JSON.stringify(templateItem));
|
|
1250
|
+
item.item_title = qa.question;
|
|
1251
|
+
item._id = Math.random().toString(36).substr(2, 7);
|
|
1252
|
+
newItems.push(item);
|
|
1253
|
+
|
|
1254
|
+
if (templateChild) {
|
|
1255
|
+
const child = JSON.parse(JSON.stringify(templateChild));
|
|
1256
|
+
// Update the text-editor widget inside the child container
|
|
1257
|
+
const textWidget = (child.elements || []).find(e => e.widgetType === 'text-editor');
|
|
1258
|
+
if (textWidget) {
|
|
1259
|
+
textWidget.settings.editor = `<p>${qa.answer}</p>`;
|
|
1260
|
+
}
|
|
1261
|
+
// Regenerate IDs for the child
|
|
1262
|
+
child.id = Math.random().toString(36).substr(2, 7);
|
|
1263
|
+
(child.elements || []).forEach(e => { e.id = Math.random().toString(36).substr(2, 7); });
|
|
1264
|
+
newChildren.push(child);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
acc.widget.settings.items = newItems;
|
|
1268
|
+
if (newChildren.length > 0) acc.widget.elements = newChildren;
|
|
1269
|
+
changes++;
|
|
1270
|
+
} else {
|
|
1271
|
+
// Toggle/accordion: tabs[].tab_title + tab_content
|
|
1272
|
+
const tabsKey = acc.widget.settings.tabs ? 'tabs' : 'items';
|
|
1273
|
+
const existingTabs = acc.widget.settings[tabsKey] || [];
|
|
1274
|
+
if (existingTabs.length === 0) continue;
|
|
1275
|
+
|
|
1276
|
+
const template = JSON.parse(JSON.stringify(existingTabs[0]));
|
|
1277
|
+
const newTabs = [];
|
|
1278
|
+
for (const qa of faq) {
|
|
1279
|
+
const tab = JSON.parse(JSON.stringify(template));
|
|
1280
|
+
if ('tab_title' in tab) {
|
|
1281
|
+
tab.tab_title = qa.question;
|
|
1282
|
+
tab.tab_content = qa.answer;
|
|
1283
|
+
} else if ('item_title' in tab) {
|
|
1284
|
+
tab.item_title = qa.question;
|
|
1285
|
+
} else if ('title' in tab) {
|
|
1286
|
+
tab.title = qa.question;
|
|
1287
|
+
tab.content = qa.answer;
|
|
1288
|
+
}
|
|
1289
|
+
newTabs.push(tab);
|
|
1290
|
+
}
|
|
1291
|
+
acc.widget.settings[tabsKey] = newTabs;
|
|
1292
|
+
changes++;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
return { section: injected, changes };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// ================================================================
|
|
1301
|
+
// API CALL
|
|
1302
|
+
// ================================================================
|
|
1303
|
+
|
|
1304
|
+
async function apiCall(endpoint, method = 'GET', body = null) {
|
|
1305
|
+
const url = `${CONFIG.wordpressUrl}/wp-json/nvbc/v1${endpoint}`;
|
|
1306
|
+
const auth = Buffer.from(`${CONFIG.username}:${CONFIG.applicationPassword}`).toString('base64');
|
|
1307
|
+
|
|
1308
|
+
console.error(`[API] ${method} ${url}`);
|
|
1309
|
+
|
|
1310
|
+
const controller = new AbortController();
|
|
1311
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
1312
|
+
|
|
1313
|
+
try {
|
|
1314
|
+
const options = {
|
|
1315
|
+
method,
|
|
1316
|
+
headers: {
|
|
1317
|
+
'Authorization': `Basic ${auth}`,
|
|
1318
|
+
'Content-Type': 'application/json',
|
|
1319
|
+
'Accept': 'application/json',
|
|
1320
|
+
'User-Agent': `Vision-Builder-Control/${VERSION}`,
|
|
1321
|
+
},
|
|
1322
|
+
signal: controller.signal,
|
|
1323
|
+
};
|
|
1324
|
+
if (body) options.body = JSON.stringify(body);
|
|
1325
|
+
|
|
1326
|
+
const response = await fetch(url, options);
|
|
1327
|
+
const data = await response.json();
|
|
1328
|
+
if (!response.ok) throw new Error(data.message || `HTTP ${response.status}`);
|
|
1329
|
+
return data;
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
if (err.name === 'AbortError') {
|
|
1332
|
+
throw new Error(`Request timed out after ${FETCH_TIMEOUT_MS / 1000}s: ${method} ${endpoint}`);
|
|
1333
|
+
}
|
|
1334
|
+
throw err;
|
|
1335
|
+
} finally {
|
|
1336
|
+
clearTimeout(timer);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// ================================================================
|
|
1341
|
+
// TOKEN CACHE — fetches from REST API, caches 5 min, falls back to defaults
|
|
1342
|
+
// ================================================================
|
|
1343
|
+
|
|
1344
|
+
const tokenCache = {
|
|
1345
|
+
data: null,
|
|
1346
|
+
timestamp: 0,
|
|
1347
|
+
stale: null, // previous good data kept as fallback
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
const DEFAULT_TOKENS = {
|
|
1351
|
+
colors: {
|
|
1352
|
+
primary: '#0961AD', primary_dark: '#154168', primary_hover: '#043F72',
|
|
1353
|
+
primary_gradient_end: '#074A8A', secondary: '#FFC32A', secondary_hover: '#ECB21D',
|
|
1354
|
+
dark_bg: '#084D8F', light_bg: '#E8F4FA', text: '#191919', white: '#FFFFFF',
|
|
1355
|
+
muted_text: '#5C6B7F', light_gray_bg: '#F8F9FA',
|
|
1356
|
+
},
|
|
1357
|
+
typography: {
|
|
1358
|
+
heading_font: 'Poppins', body_font: 'Poppins',
|
|
1359
|
+
button_weight: '600', button_transform: 'uppercase',
|
|
1360
|
+
},
|
|
1361
|
+
spacing: {
|
|
1362
|
+
section_class: 'space-block-lg', button_padding: '15px 30px', border_radius: '100px',
|
|
1363
|
+
},
|
|
1364
|
+
urls: {
|
|
1365
|
+
site_base: '', free_mri_review: '/free-mri-review', patient_experience: '/patient-experience',
|
|
1366
|
+
},
|
|
1367
|
+
buttons: {
|
|
1368
|
+
primary_class: 'primary-btn', secondary_class: 'secondary-btn',
|
|
1369
|
+
},
|
|
1370
|
+
boilerplate: {
|
|
1371
|
+
jet_parallax: true, plus_tooltip: true, tooltip_text: 'Luctus nec ullamcorper mattis',
|
|
1372
|
+
},
|
|
1373
|
+
globals_map: {
|
|
1374
|
+
text: 'globals/colors?id=text', primary: 'globals/colors?id=primary', secondary: 'globals/colors?id=secondary',
|
|
1375
|
+
},
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
async function getDesignTokens(forceRefresh = false) {
|
|
1379
|
+
const now = Date.now();
|
|
1380
|
+
|
|
1381
|
+
// Return cache if fresh
|
|
1382
|
+
if (!forceRefresh && tokenCache.data && (now - tokenCache.timestamp) < TOKEN_CACHE_TTL_MS) {
|
|
1383
|
+
return tokenCache.data;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
try {
|
|
1387
|
+
const r = await apiCall('/design-tokens');
|
|
1388
|
+
if (r.success && r.tokens) {
|
|
1389
|
+
tokenCache.data = r.tokens;
|
|
1390
|
+
tokenCache.stale = r.tokens;
|
|
1391
|
+
tokenCache.timestamp = now;
|
|
1392
|
+
console.error('[TOKENS] Refreshed from WordPress');
|
|
1393
|
+
return r.tokens;
|
|
1394
|
+
}
|
|
1395
|
+
} catch (err) {
|
|
1396
|
+
console.error(`[TOKENS] Fetch failed: ${err.message}`);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Fallback chain: stale cache → hardcoded defaults
|
|
1400
|
+
if (tokenCache.stale) {
|
|
1401
|
+
console.error('[TOKENS] Using stale cache');
|
|
1402
|
+
return tokenCache.stale;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
console.error('[TOKENS] Using hardcoded defaults');
|
|
1406
|
+
return DEFAULT_TOKENS;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Build Elementor button settings from tokens
|
|
1411
|
+
*/
|
|
1412
|
+
function buildButtonSettings(tokens, type) {
|
|
1413
|
+
const c = tokens.colors;
|
|
1414
|
+
const t = tokens.typography;
|
|
1415
|
+
const b = tokens.buttons;
|
|
1416
|
+
const bp = tokens.boilerplate;
|
|
1417
|
+
|
|
1418
|
+
if (type === 'primary') {
|
|
1419
|
+
return {
|
|
1420
|
+
text: '', link: { url: '', is_external: false, nofollow: false },
|
|
1421
|
+
align: 'left', size: 'lg', icon_align: 'row-reverse',
|
|
1422
|
+
button_text_color: c.white,
|
|
1423
|
+
background_background: 'gradient',
|
|
1424
|
+
background_color: c.primary,
|
|
1425
|
+
background_color_b: c.primary_dark,
|
|
1426
|
+
background_color_b_stop: { unit: '%', size: 98, sizes: [] },
|
|
1427
|
+
background_gradient_angle: { unit: 'deg', size: 90, sizes: [] },
|
|
1428
|
+
button_background_hover_background: 'gradient',
|
|
1429
|
+
button_background_hover_color: c.primary,
|
|
1430
|
+
button_background_hover_color_b: c.primary_hover,
|
|
1431
|
+
border_radius: { unit: 'px', top: '100', right: '100', bottom: '100', left: '100', isLinked: true },
|
|
1432
|
+
button_padding: { unit: 'px', top: 15, right: 30, bottom: 15, left: 30, isLinked: false },
|
|
1433
|
+
typography_typography: 'custom', typography_font_family: t.heading_font,
|
|
1434
|
+
typography_font_weight: t.button_weight, typography_text_transform: t.button_transform,
|
|
1435
|
+
_css_classes: b.primary_class,
|
|
1436
|
+
plus_tooltip_content_desc: bp.tooltip_text,
|
|
1437
|
+
__globals__: { background_color: '', button_background_hover_color: tokens.globals_map?.primary || '', button_background_hover_color_b: '', hover_color: '' },
|
|
1438
|
+
};
|
|
1439
|
+
} else {
|
|
1440
|
+
return {
|
|
1441
|
+
text: '', link: { url: '', is_external: false, nofollow: false },
|
|
1442
|
+
align: 'left', size: 'lg', icon_align: 'row-reverse',
|
|
1443
|
+
button_text_color: c.text,
|
|
1444
|
+
background_color: c.secondary,
|
|
1445
|
+
button_background_hover_color: c.secondary_hover,
|
|
1446
|
+
hover_color: '#000000',
|
|
1447
|
+
border_radius: { unit: 'px', top: '100', right: '100', bottom: '100', left: '100', isLinked: true },
|
|
1448
|
+
button_padding: { unit: 'px', top: 15, right: 30, bottom: 15, left: 30, isLinked: false },
|
|
1449
|
+
typography_typography: 'custom', typography_font_family: t.heading_font,
|
|
1450
|
+
typography_font_weight: t.button_weight, typography_text_transform: t.button_transform,
|
|
1451
|
+
_css_classes: b.secondary_class,
|
|
1452
|
+
plus_tooltip_content_desc: bp.tooltip_text,
|
|
1453
|
+
__globals__: { background_color: tokens.globals_map?.secondary || '', button_text_color: '', hover_color: tokens.globals_map?.text || '', button_background_hover_color: '' },
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// ================================================================
|
|
1459
|
+
// TOOL DEFINITIONS
|
|
1460
|
+
// ================================================================
|
|
1461
|
+
|
|
1462
|
+
function getToolDefinitions() {
|
|
1463
|
+
return [
|
|
1464
|
+
{
|
|
1465
|
+
name: 'health_check',
|
|
1466
|
+
description: 'Check connection to Vision Builder Control WordPress plugin',
|
|
1467
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1468
|
+
},
|
|
1469
|
+
{
|
|
1470
|
+
name: 'get_design_tokens',
|
|
1471
|
+
description: 'Get all design tokens (colors, typography, spacing, URLs, buttons, boilerplate). Tokens are cached for 5 minutes.',
|
|
1472
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1473
|
+
},
|
|
1474
|
+
{
|
|
1475
|
+
name: 'set_design_tokens',
|
|
1476
|
+
description: 'Save design tokens to WordPress. Requires manage_options capability.',
|
|
1477
|
+
inputSchema: {
|
|
1478
|
+
type: 'object',
|
|
1479
|
+
properties: {
|
|
1480
|
+
tokens: { type: 'object', description: 'Full or partial token object to save' }
|
|
1481
|
+
},
|
|
1482
|
+
required: ['tokens']
|
|
1483
|
+
}
|
|
1484
|
+
},
|
|
1485
|
+
{
|
|
1486
|
+
name: 'refresh_tokens',
|
|
1487
|
+
description: 'Force re-fetch design tokens from WordPress, bypassing the cache.',
|
|
1488
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1489
|
+
},
|
|
1490
|
+
{
|
|
1491
|
+
name: 'get_design_rules',
|
|
1492
|
+
description: 'Get ALL design conventions: buttons, colors, container/widget boilerplate, URLs, section types. Generated from current design tokens. ALWAYS call this before generating or editing pages.',
|
|
1493
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1494
|
+
},
|
|
1495
|
+
{
|
|
1496
|
+
name: 'get_button',
|
|
1497
|
+
description: 'Get the correct Elementor button JSON for a given context. Returns ready-to-use widget JSON built from design tokens.',
|
|
1498
|
+
inputSchema: {
|
|
1499
|
+
type: 'object',
|
|
1500
|
+
properties: {
|
|
1501
|
+
text: { type: 'string', description: 'Button label' },
|
|
1502
|
+
url: { type: 'string', description: 'Button URL' },
|
|
1503
|
+
context: { type: 'string', enum: ['hero', 'light_bg', 'dark_bg'], description: 'Where the button appears' }
|
|
1504
|
+
},
|
|
1505
|
+
required: ['text', 'url', 'context']
|
|
1506
|
+
}
|
|
1507
|
+
},
|
|
1508
|
+
{
|
|
1509
|
+
name: 'list_pages',
|
|
1510
|
+
description: 'List all WordPress pages with their Elementor status, section count, and edit URLs.',
|
|
1511
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1512
|
+
},
|
|
1513
|
+
{
|
|
1514
|
+
name: 'create_page',
|
|
1515
|
+
description: 'Create a new WordPress page, optionally set up for Elementor editing.',
|
|
1516
|
+
inputSchema: {
|
|
1517
|
+
type: 'object',
|
|
1518
|
+
properties: {
|
|
1519
|
+
title: { type: 'string', description: 'Page title' },
|
|
1520
|
+
slug: { type: 'string', description: 'URL slug (optional, auto-generated from title)' },
|
|
1521
|
+
status: { type: 'string', enum: ['draft', 'publish', 'private'], description: 'Page status (default: draft)' },
|
|
1522
|
+
elementor: { type: 'boolean', description: 'Set up for Elementor editing (default: true)' }
|
|
1523
|
+
},
|
|
1524
|
+
required: ['title']
|
|
1525
|
+
}
|
|
1526
|
+
},
|
|
1527
|
+
{
|
|
1528
|
+
name: 'validate_page',
|
|
1529
|
+
description: 'Run design validation on an Elementor page (CSS duals, boilerplate, duplicate IDs, etc.)',
|
|
1530
|
+
inputSchema: {
|
|
1531
|
+
type: 'object',
|
|
1532
|
+
properties: {
|
|
1533
|
+
page_id: { type: 'number', description: 'WordPress page ID' }
|
|
1534
|
+
},
|
|
1535
|
+
required: ['page_id']
|
|
1536
|
+
}
|
|
1537
|
+
},
|
|
1538
|
+
{
|
|
1539
|
+
name: 'extract_content',
|
|
1540
|
+
description: 'Extract all text, headings, images, links, buttons, and placeholders from a page. Detects placeholder text, dynamic tags, and icon-box content.',
|
|
1541
|
+
inputSchema: {
|
|
1542
|
+
type: 'object',
|
|
1543
|
+
properties: {
|
|
1544
|
+
page_id: { type: 'number', description: 'WordPress page ID' }
|
|
1545
|
+
},
|
|
1546
|
+
required: ['page_id']
|
|
1547
|
+
}
|
|
1548
|
+
},
|
|
1549
|
+
{
|
|
1550
|
+
name: 'import_template',
|
|
1551
|
+
description: 'Import a full Elementor JSON template onto a page. Mode: replace (default) or append.',
|
|
1552
|
+
inputSchema: {
|
|
1553
|
+
type: 'object',
|
|
1554
|
+
properties: {
|
|
1555
|
+
page_id: { type: 'number', description: 'WordPress page ID' },
|
|
1556
|
+
template: { type: 'object', description: 'Elementor JSON: {content:[...]} or array of sections' },
|
|
1557
|
+
mode: { type: 'string', enum: ['replace', 'append'], description: 'Import mode (default: replace)' }
|
|
1558
|
+
},
|
|
1559
|
+
required: ['page_id', 'template']
|
|
1560
|
+
}
|
|
1561
|
+
},
|
|
1562
|
+
{
|
|
1563
|
+
name: 'get_component_presets',
|
|
1564
|
+
description: 'Get all component style presets (saved overrides + resolved values with fallbacks). Shows which components have customizable styles.',
|
|
1565
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1566
|
+
},
|
|
1567
|
+
{
|
|
1568
|
+
name: 'set_component_presets',
|
|
1569
|
+
description: 'Save style presets for a specific component. Empty values reset to defaults. Requires manage_options capability.',
|
|
1570
|
+
inputSchema: {
|
|
1571
|
+
type: 'object',
|
|
1572
|
+
properties: {
|
|
1573
|
+
component_id: { type: 'string', description: 'Component ID: "category/name" (e.g., "stats/stats-bar-4col-dark")' },
|
|
1574
|
+
presets: { type: 'object', description: 'Style overrides (e.g., {"stat_number_color": "#FFD700", "divider_width": "2"})' }
|
|
1575
|
+
},
|
|
1576
|
+
required: ['component_id', 'presets']
|
|
1577
|
+
}
|
|
1578
|
+
},
|
|
1579
|
+
{
|
|
1580
|
+
name: 'list_components',
|
|
1581
|
+
description: 'List all available design components (heroes, stats bars, CTAs, etc.). Each component has metadata showing what variables can be customized.',
|
|
1582
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
name: 'add_component',
|
|
1586
|
+
description: 'Add a single component to a page. Components are pre-built, proven sections (hero, stats bar, conditions grid, CTA, etc.). Variables populate content.',
|
|
1587
|
+
inputSchema: {
|
|
1588
|
+
type: 'object',
|
|
1589
|
+
properties: {
|
|
1590
|
+
page_id: { type: 'number', description: 'WordPress page ID' },
|
|
1591
|
+
category: { type: 'string', description: 'Component category: heroes, intros, stats, conditions, comparisons, videos, ctas, teams, tabs, blocks, cards' },
|
|
1592
|
+
name: { type: 'string', description: 'Component name (e.g., "hero-two-col-soft-blue", "stats-bar-4col-dark")' },
|
|
1593
|
+
variables: { type: 'object', description: 'Variable values to populate (heading, description, buttons, etc.)' },
|
|
1594
|
+
position: { type: 'string', description: 'Where to add: "append" (default), "prepend", or numeric index' }
|
|
1595
|
+
},
|
|
1596
|
+
required: ['page_id', 'category', 'name']
|
|
1597
|
+
}
|
|
1598
|
+
},
|
|
1599
|
+
{
|
|
1600
|
+
name: 'build_page',
|
|
1601
|
+
description: 'Build entire page from component list. Pass array of components with variables. Uses proven designs with token-driven styling.',
|
|
1602
|
+
inputSchema: {
|
|
1603
|
+
type: 'object',
|
|
1604
|
+
properties: {
|
|
1605
|
+
page_id: { type: 'number', description: 'WordPress page ID' },
|
|
1606
|
+
sections: {
|
|
1607
|
+
type: 'array',
|
|
1608
|
+
description: 'Array of components: [{category, name, variables}, ...]',
|
|
1609
|
+
items: {
|
|
1610
|
+
type: 'object',
|
|
1611
|
+
properties: {
|
|
1612
|
+
category: { type: 'string' },
|
|
1613
|
+
name: { type: 'string' },
|
|
1614
|
+
variables: { type: 'object' }
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
},
|
|
1618
|
+
mode: { type: 'string', enum: ['replace', 'append'], description: 'Build mode (default: replace)' }
|
|
1619
|
+
},
|
|
1620
|
+
required: ['page_id', 'sections']
|
|
1621
|
+
}
|
|
1622
|
+
},
|
|
1623
|
+
{
|
|
1624
|
+
name: 'list_sections',
|
|
1625
|
+
description: 'List all top-level sections on a page with index, ID, CSS classes, and heading text. Use before remove/reorder operations.',
|
|
1626
|
+
inputSchema: {
|
|
1627
|
+
type: 'object',
|
|
1628
|
+
properties: {
|
|
1629
|
+
page_id: { type: 'number', description: 'WordPress page ID' }
|
|
1630
|
+
},
|
|
1631
|
+
required: ['page_id']
|
|
1632
|
+
}
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
name: 'remove_section',
|
|
1636
|
+
description: 'Remove a single section from a page by its index (0-based) or Elementor section ID.',
|
|
1637
|
+
inputSchema: {
|
|
1638
|
+
type: 'object',
|
|
1639
|
+
properties: {
|
|
1640
|
+
page_id: { type: 'number', description: 'WordPress page ID' },
|
|
1641
|
+
index: { type: 'number', description: 'Section index (0-based). Use list_sections to find the right index.' },
|
|
1642
|
+
section_id: { type: 'string', description: 'Elementor section ID (alternative to index)' }
|
|
1643
|
+
},
|
|
1644
|
+
required: ['page_id']
|
|
1645
|
+
}
|
|
1646
|
+
},
|
|
1647
|
+
{
|
|
1648
|
+
name: 'reorder_sections',
|
|
1649
|
+
description: 'Reorder, move, or swap sections on a page. Three modes: "move" (move one section to a new position), "swap" (swap two sections), "order" (provide full reordering array).',
|
|
1650
|
+
inputSchema: {
|
|
1651
|
+
type: 'object',
|
|
1652
|
+
properties: {
|
|
1653
|
+
page_id: { type: 'number', description: 'WordPress page ID' },
|
|
1654
|
+
move: {
|
|
1655
|
+
type: 'object',
|
|
1656
|
+
description: 'Move a section: {from: index, to: index}',
|
|
1657
|
+
properties: { from: { type: 'number' }, to: { type: 'number' } }
|
|
1658
|
+
},
|
|
1659
|
+
swap: {
|
|
1660
|
+
type: 'array',
|
|
1661
|
+
description: 'Swap two sections: [indexA, indexB]',
|
|
1662
|
+
items: { type: 'number' }
|
|
1663
|
+
},
|
|
1664
|
+
order: {
|
|
1665
|
+
type: 'array',
|
|
1666
|
+
description: 'Full reorder: array of current indices in desired order, e.g. [2,0,1,3]',
|
|
1667
|
+
items: { type: 'number' }
|
|
1668
|
+
}
|
|
1669
|
+
},
|
|
1670
|
+
required: ['page_id']
|
|
1671
|
+
}
|
|
1672
|
+
},
|
|
1673
|
+
{
|
|
1674
|
+
name: 'list_elements',
|
|
1675
|
+
description: 'List the element tree inside a page or specific section. Shows IDs, types, widget names, and text previews for every container and widget. Use to find element IDs before remove_element.',
|
|
1676
|
+
inputSchema: {
|
|
1677
|
+
type: 'object',
|
|
1678
|
+
properties: {
|
|
1679
|
+
page_id: { type: 'number', description: 'WordPress page ID' },
|
|
1680
|
+
section_index: { type: 'number', description: 'Only show this section (0-based). Omit to show all sections.' },
|
|
1681
|
+
depth: { type: 'number', description: 'Max nesting depth (default: 3)' }
|
|
1682
|
+
},
|
|
1683
|
+
required: ['page_id']
|
|
1684
|
+
}
|
|
1685
|
+
},
|
|
1686
|
+
{
|
|
1687
|
+
name: 'remove_element',
|
|
1688
|
+
description: 'Remove any element (widget, inner container, or section) by its Elementor ID. Works at any depth in the page tree. Use list_elements first to find the ID.',
|
|
1689
|
+
inputSchema: {
|
|
1690
|
+
type: 'object',
|
|
1691
|
+
properties: {
|
|
1692
|
+
page_id: { type: 'number', description: 'WordPress page ID' },
|
|
1693
|
+
element_id: { type: 'string', description: 'Elementor element ID to remove' }
|
|
1694
|
+
},
|
|
1695
|
+
required: ['page_id', 'element_id']
|
|
1696
|
+
}
|
|
1697
|
+
},
|
|
1698
|
+
{
|
|
1699
|
+
name: 'update_element',
|
|
1700
|
+
description: 'Update any element\'s settings by its Elementor ID. Patch widget properties like hover_color, background_color, __globals__ overrides, text, link URLs, etc. Supports dot-notation for nested keys (e.g. "__globals__.hover_color"). Use list_elements or export_page first to find the element ID and current settings. IMPORTANT: Prefer settings_json (JSON string) over settings (object) for reliable MCP transport.',
|
|
1701
|
+
inputSchema: {
|
|
1702
|
+
type: 'object',
|
|
1703
|
+
properties: {
|
|
1704
|
+
page_id: { type: 'number', description: 'WordPress page ID' },
|
|
1705
|
+
element_id: { type: 'string', description: 'Elementor element ID to update' },
|
|
1706
|
+
settings: {
|
|
1707
|
+
type: 'object',
|
|
1708
|
+
description: 'Key-value pairs of settings to set. May not work reliably via MCP — use settings_json instead.',
|
|
1709
|
+
additionalProperties: true
|
|
1710
|
+
},
|
|
1711
|
+
settings_json: {
|
|
1712
|
+
type: 'string',
|
|
1713
|
+
description: 'Settings as a JSON string (preferred over settings object for reliable MCP transport). Example: \'{"hover_color":"#FFFFFF","__globals__":{"hover_color":""}}\''
|
|
1714
|
+
}
|
|
1715
|
+
},
|
|
1716
|
+
required: ['page_id', 'element_id']
|
|
1717
|
+
}
|
|
1718
|
+
},
|
|
1719
|
+
{
|
|
1720
|
+
name: 'audit_links',
|
|
1721
|
+
description: 'Scan all Elementor pages and audit link targets. Reports internal links that open in new tab and external links that open in same tab. Use include_templates to also scan library templates (menus, headers, footers).',
|
|
1722
|
+
inputSchema: {
|
|
1723
|
+
type: 'object',
|
|
1724
|
+
properties: {
|
|
1725
|
+
page_id: { type: 'number', description: 'Optional: scan only this page. Omit to scan ALL Elementor pages.' },
|
|
1726
|
+
include_templates: { type: 'boolean', description: 'If true, also scan Elementor library templates (menus, headers, footers). Default: false.' }
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
name: 'fix_links',
|
|
1732
|
+
description: 'Auto-fix link targets across all Elementor pages. Internal links → same tab, external → new tab. Supports dry_run mode. Use include_templates to also fix library templates.',
|
|
1733
|
+
inputSchema: {
|
|
1734
|
+
type: 'object',
|
|
1735
|
+
properties: {
|
|
1736
|
+
page_id: { type: 'number', description: 'Optional: fix only this page. Omit to fix ALL Elementor pages.' },
|
|
1737
|
+
dry_run: { type: 'boolean', description: 'If true, report what would be fixed without making changes. Default: false.' },
|
|
1738
|
+
include_templates: { type: 'boolean', description: 'If true, also fix Elementor library templates (menus, headers, footers). Default: false.' }
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
},
|
|
1742
|
+
{
|
|
1743
|
+
name: 'export_page',
|
|
1744
|
+
description: 'Export raw Elementor JSON data for a page. Returns full template JSON plus page metadata (title, slug, status, template). Use for backups before destructive operations or to copy templates between pages.',
|
|
1745
|
+
inputSchema: {
|
|
1746
|
+
type: 'object',
|
|
1747
|
+
properties: {
|
|
1748
|
+
page_id: { type: 'number', description: 'WordPress page ID' }
|
|
1749
|
+
},
|
|
1750
|
+
required: ['page_id']
|
|
1751
|
+
}
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
name: 'audit_images',
|
|
1755
|
+
description: 'Scan Elementor pages for image issues: missing alt text, empty image URLs. With deep=true, also checks image format (non-WebP), file size (>200KB), oversized dimensions (>2000px), and provides a format breakdown summary. Optional fix mode adds placeholder alt text based on nearby headings.',
|
|
1756
|
+
inputSchema: {
|
|
1757
|
+
type: 'object',
|
|
1758
|
+
properties: {
|
|
1759
|
+
page_id: { type: 'number', description: 'Optional: scan only this page' },
|
|
1760
|
+
deep: { type: 'boolean', description: 'If true, also check format (non-WebP), file size, dimensions. Slower but comprehensive. Default: false.' },
|
|
1761
|
+
fix: { type: 'boolean', description: 'If true, auto-add placeholder alt text to images missing it. Default: false (audit only).' },
|
|
1762
|
+
dry_run: { type: 'boolean', description: 'If fix=true and dry_run=true, preview fixes without saving. Default: false.' }
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
},
|
|
1766
|
+
{
|
|
1767
|
+
name: 'find_replace',
|
|
1768
|
+
description: 'Global find & replace across all Elementor pages. Searches heading titles, text-editor HTML, button text, image-box titles/descriptions, icon-list items, toggle titles/content, and image alt text. Supports literal text or regex. Always preview with dry_run=true first.',
|
|
1769
|
+
inputSchema: {
|
|
1770
|
+
type: 'object',
|
|
1771
|
+
properties: {
|
|
1772
|
+
search: { type: 'string', description: 'Text to find (literal string or regex pattern)' },
|
|
1773
|
+
replace: { type: 'string', description: 'Replacement text' },
|
|
1774
|
+
regex: { type: 'boolean', description: 'Treat search as regex pattern. Default: false (literal).' },
|
|
1775
|
+
page_id: { type: 'number', description: 'Optional: only search this page' },
|
|
1776
|
+
dry_run: { type: 'boolean', description: 'Preview changes without saving. Default: true (SAFE). Set to false to apply changes.' }
|
|
1777
|
+
},
|
|
1778
|
+
required: ['search', 'replace']
|
|
1779
|
+
}
|
|
1780
|
+
},
|
|
1781
|
+
{
|
|
1782
|
+
name: 'copy_element',
|
|
1783
|
+
description: 'Copy an element (section, container, or widget) from one page to another. Deep-clones with all descendants and regenerates all Elementor IDs. Use list_elements to find the source element ID first.',
|
|
1784
|
+
inputSchema: {
|
|
1785
|
+
type: 'object',
|
|
1786
|
+
properties: {
|
|
1787
|
+
source_page_id: { type: 'number', description: 'Page ID to copy FROM' },
|
|
1788
|
+
element_id: { type: 'string', description: 'Elementor element ID to copy' },
|
|
1789
|
+
target_page_id: { type: 'number', description: 'Page ID to copy TO' },
|
|
1790
|
+
position: { type: 'string', description: 'Where to place: "append" (default), "prepend", or section index number' }
|
|
1791
|
+
},
|
|
1792
|
+
required: ['source_page_id', 'element_id', 'target_page_id']
|
|
1793
|
+
}
|
|
1794
|
+
},
|
|
1795
|
+
{
|
|
1796
|
+
name: 'check_links',
|
|
1797
|
+
description: 'Check all URLs on Elementor pages for broken links (404, 500, timeout). Performs HTTP HEAD requests to each unique URL. May take 30-60 seconds for sites with many links.',
|
|
1798
|
+
inputSchema: {
|
|
1799
|
+
type: 'object',
|
|
1800
|
+
properties: {
|
|
1801
|
+
page_id: { type: 'number', description: 'Optional: check only this page. Recommended for speed.' }
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
},
|
|
1805
|
+
// ── Posts CRUD ──
|
|
1806
|
+
{
|
|
1807
|
+
name: 'list_posts',
|
|
1808
|
+
description: 'List WordPress posts, pages, or custom post types with filtering. Supports search, status filter, pagination. Returns metadata, editor type, and featured image.',
|
|
1809
|
+
inputSchema: {
|
|
1810
|
+
type: 'object',
|
|
1811
|
+
properties: {
|
|
1812
|
+
post_type: { type: 'string', description: '"post", "page", "any" (default), or CPT slug' },
|
|
1813
|
+
status: { type: 'string', description: 'Comma-separated: publish,draft,private (default: all three)' },
|
|
1814
|
+
search: { type: 'string', description: 'Search by keyword' },
|
|
1815
|
+
per_page: { type: 'number', description: 'Results per page, max 100 (default: 50)' },
|
|
1816
|
+
page: { type: 'number', description: 'Page number (default: 1)' }
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
},
|
|
1820
|
+
{
|
|
1821
|
+
name: 'get_post',
|
|
1822
|
+
description: 'Get a single WordPress post/page with ALL details: content, excerpt, Elementor status, RankMath SEO data (title, description, keywords, score), taxonomies (categories, tags), and featured image.',
|
|
1823
|
+
inputSchema: {
|
|
1824
|
+
type: 'object',
|
|
1825
|
+
properties: {
|
|
1826
|
+
post_id: { type: 'number', description: 'WordPress post/page ID' }
|
|
1827
|
+
},
|
|
1828
|
+
required: ['post_id']
|
|
1829
|
+
}
|
|
1830
|
+
},
|
|
1831
|
+
{
|
|
1832
|
+
name: 'create_post',
|
|
1833
|
+
description: 'Create a WordPress post, page, or CPT. Supports content, excerpt, taxonomies, featured image, Elementor setup, and initial RankMath SEO data.',
|
|
1834
|
+
inputSchema: {
|
|
1835
|
+
type: 'object',
|
|
1836
|
+
properties: {
|
|
1837
|
+
title: { type: 'string', description: 'Post title' },
|
|
1838
|
+
post_type: { type: 'string', description: '"post" (default), "page", or CPT slug' },
|
|
1839
|
+
content: { type: 'string', description: 'Post content (block HTML or plain text)' },
|
|
1840
|
+
excerpt: { type: 'string', description: 'Post excerpt' },
|
|
1841
|
+
status: { type: 'string', enum: ['draft', 'publish', 'private', 'pending'], description: 'Post status (default: draft)' },
|
|
1842
|
+
slug: { type: 'string', description: 'URL slug (auto-generated if omitted)' },
|
|
1843
|
+
taxonomies: { type: 'object', description: 'Object: {"category": [1,2], "post_tag": ["seo","health"]}' },
|
|
1844
|
+
featured_image_id: { type: 'number', description: 'Media library attachment ID' },
|
|
1845
|
+
elementor: { type: 'boolean', description: 'Set up for Elementor editing' },
|
|
1846
|
+
seo: { type: 'object', description: 'RankMath SEO: {title, description, focus_keyword, robots, ...}' }
|
|
1847
|
+
},
|
|
1848
|
+
required: ['title']
|
|
1849
|
+
}
|
|
1850
|
+
},
|
|
1851
|
+
{
|
|
1852
|
+
name: 'update_post',
|
|
1853
|
+
description: 'Update any field on a WordPress post/page. Only provided fields are changed. Supports title, content, status, slug, excerpt, taxonomies, featured image, and RankMath SEO.',
|
|
1854
|
+
inputSchema: {
|
|
1855
|
+
type: 'object',
|
|
1856
|
+
properties: {
|
|
1857
|
+
post_id: { type: 'number', description: 'WordPress post/page ID' },
|
|
1858
|
+
title: { type: 'string' },
|
|
1859
|
+
content: { type: 'string' },
|
|
1860
|
+
excerpt: { type: 'string' },
|
|
1861
|
+
status: { type: 'string', enum: ['draft', 'publish', 'private', 'pending', 'trash'] },
|
|
1862
|
+
slug: { type: 'string' },
|
|
1863
|
+
parent: { type: 'number', description: 'Parent post/page ID (for hierarchical types like pages). Changes URL structure.' },
|
|
1864
|
+
taxonomies: { type: 'object', description: '{"category": [1,2]}' },
|
|
1865
|
+
featured_image_id: { type: 'number', description: 'Attachment ID (omit to keep, pass null to remove)' },
|
|
1866
|
+
seo: { type: 'object', description: 'RankMath SEO fields to update' }
|
|
1867
|
+
},
|
|
1868
|
+
required: ['post_id']
|
|
1869
|
+
}
|
|
1870
|
+
},
|
|
1871
|
+
{
|
|
1872
|
+
name: 'delete_post',
|
|
1873
|
+
description: 'Trash or permanently delete a WordPress post/page. Default: moves to trash (recoverable). Use force=true for permanent deletion.',
|
|
1874
|
+
inputSchema: {
|
|
1875
|
+
type: 'object',
|
|
1876
|
+
properties: {
|
|
1877
|
+
post_id: { type: 'number', description: 'WordPress post/page ID' },
|
|
1878
|
+
force: { type: 'boolean', description: 'If true, permanently delete (not recoverable). Default: false (trash).' }
|
|
1879
|
+
},
|
|
1880
|
+
required: ['post_id']
|
|
1881
|
+
}
|
|
1882
|
+
},
|
|
1883
|
+
// ── RankMath SEO ──
|
|
1884
|
+
{
|
|
1885
|
+
name: 'get_seo',
|
|
1886
|
+
description: 'Get all RankMath SEO data for a post: title, description, focus keyword, score, robots, canonical URL, Open Graph, Twitter card, primary category.',
|
|
1887
|
+
inputSchema: {
|
|
1888
|
+
type: 'object',
|
|
1889
|
+
properties: {
|
|
1890
|
+
post_id: { type: 'number', description: 'WordPress post/page ID' }
|
|
1891
|
+
},
|
|
1892
|
+
required: ['post_id']
|
|
1893
|
+
}
|
|
1894
|
+
},
|
|
1895
|
+
{
|
|
1896
|
+
name: 'update_seo',
|
|
1897
|
+
description: 'Update RankMath SEO fields on a post. Accepts any subset: title, description, focus_keyword, robots (array), canonical_url, facebook_title/description/image, twitter_title/description/image, primary_category.',
|
|
1898
|
+
inputSchema: {
|
|
1899
|
+
type: 'object',
|
|
1900
|
+
properties: {
|
|
1901
|
+
post_id: { type: 'number', description: 'WordPress post/page ID' },
|
|
1902
|
+
title: { type: 'string', description: 'SEO meta title' },
|
|
1903
|
+
description: { type: 'string', description: 'SEO meta description' },
|
|
1904
|
+
focus_keyword: { type: 'string', description: 'Primary focus keyword' },
|
|
1905
|
+
robots: { type: 'array', items: { type: 'string' }, description: '["index","follow"] or ["noindex","nofollow"]' },
|
|
1906
|
+
canonical_url: { type: 'string' },
|
|
1907
|
+
facebook_title: { type: 'string' },
|
|
1908
|
+
facebook_description: { type: 'string' },
|
|
1909
|
+
facebook_image: { type: 'string' },
|
|
1910
|
+
twitter_title: { type: 'string' },
|
|
1911
|
+
twitter_description: { type: 'string' },
|
|
1912
|
+
twitter_image: { type: 'string' },
|
|
1913
|
+
primary_category: { type: 'number' }
|
|
1914
|
+
},
|
|
1915
|
+
required: ['post_id']
|
|
1916
|
+
}
|
|
1917
|
+
},
|
|
1918
|
+
{
|
|
1919
|
+
name: 'audit_seo',
|
|
1920
|
+
description: 'Scan all published posts/pages for SEO issues: missing title, missing description, missing focus keyword, low score (<50), noindex flags.',
|
|
1921
|
+
inputSchema: {
|
|
1922
|
+
type: 'object',
|
|
1923
|
+
properties: {
|
|
1924
|
+
post_type: { type: 'string', description: 'Optional: filter by post type (e.g., "page" or "post")' }
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
},
|
|
1928
|
+
// ── Taxonomies ──
|
|
1929
|
+
{
|
|
1930
|
+
name: 'list_taxonomies',
|
|
1931
|
+
description: 'List all public WordPress taxonomies (categories, tags, custom) with their terms. Optionally filter by post type.',
|
|
1932
|
+
inputSchema: {
|
|
1933
|
+
type: 'object',
|
|
1934
|
+
properties: {
|
|
1935
|
+
post_type: { type: 'string', description: 'Optional: only show taxonomies for this post type' }
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
},
|
|
1939
|
+
{
|
|
1940
|
+
name: 'set_post_terms',
|
|
1941
|
+
description: 'Set taxonomy terms on a post. For categories: pass term IDs. For tags: pass strings (auto-created if new). Use append=true to add without replacing.',
|
|
1942
|
+
inputSchema: {
|
|
1943
|
+
type: 'object',
|
|
1944
|
+
properties: {
|
|
1945
|
+
post_id: { type: 'number', description: 'WordPress post/page ID' },
|
|
1946
|
+
taxonomy: { type: 'string', description: 'Taxonomy slug: "category", "post_tag", or custom' },
|
|
1947
|
+
terms: { type: 'array', description: 'Array of term IDs (numbers) or names (strings)' },
|
|
1948
|
+
append: { type: 'boolean', description: 'If true, add without removing existing. Default: false (replace).' }
|
|
1949
|
+
},
|
|
1950
|
+
required: ['post_id', 'taxonomy', 'terms']
|
|
1951
|
+
}
|
|
1952
|
+
},
|
|
1953
|
+
|
|
1954
|
+
// ── Revisions ──
|
|
1955
|
+
{
|
|
1956
|
+
name: 'list_revisions',
|
|
1957
|
+
description: 'List all revisions for a post or template with dates, authors, and Elementor widget/section counts. Use this to find a revision to restore from.',
|
|
1958
|
+
inputSchema: {
|
|
1959
|
+
type: 'object',
|
|
1960
|
+
properties: {
|
|
1961
|
+
post_id: { type: 'number', description: 'WordPress post/page/template ID' }
|
|
1962
|
+
},
|
|
1963
|
+
required: ['post_id']
|
|
1964
|
+
}
|
|
1965
|
+
},
|
|
1966
|
+
{
|
|
1967
|
+
name: 'restore_revision',
|
|
1968
|
+
description: 'Restore _elementor_data from a specific revision to the parent post/template. Safe for both pages and library templates (menus, headers, footers). Shows before/after widget counts.',
|
|
1969
|
+
inputSchema: {
|
|
1970
|
+
type: 'object',
|
|
1971
|
+
properties: {
|
|
1972
|
+
post_id: { type: 'number', description: 'WordPress post/page/template ID to restore' },
|
|
1973
|
+
revision_id: { type: 'number', description: 'Revision ID to restore from (get from list_revisions)' }
|
|
1974
|
+
},
|
|
1975
|
+
required: ['post_id', 'revision_id']
|
|
1976
|
+
}
|
|
1977
|
+
},
|
|
1978
|
+
|
|
1979
|
+
// ── Elementor Library Templates ──
|
|
1980
|
+
{
|
|
1981
|
+
name: 'list_templates',
|
|
1982
|
+
description: 'List all Elementor library templates (mega menus, headers, footers, popups, sections). Shows template type, widget count, and display conditions. Optionally filter by type.',
|
|
1983
|
+
inputSchema: {
|
|
1984
|
+
type: 'object',
|
|
1985
|
+
properties: {
|
|
1986
|
+
type: { type: 'string', description: 'Optional filter: "header", "footer", "popup", "section", "page", etc.' }
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
},
|
|
1990
|
+
{
|
|
1991
|
+
name: 'get_template',
|
|
1992
|
+
description: 'Get a library template\'s full element tree and Elementor data. Use this to read mega menus, headers, footers, and popups. Returns both raw elementor_data and a simplified tree summary.',
|
|
1993
|
+
inputSchema: {
|
|
1994
|
+
type: 'object',
|
|
1995
|
+
properties: {
|
|
1996
|
+
template_id: { type: 'number', description: 'Elementor library template ID' }
|
|
1997
|
+
},
|
|
1998
|
+
required: ['template_id']
|
|
1999
|
+
}
|
|
2000
|
+
},
|
|
2001
|
+
{
|
|
2002
|
+
name: 'update_template',
|
|
2003
|
+
description: 'Safely update a library template\'s _elementor_data. MUST use this instead of import_template for menus, headers, footers, and popups — import_template corrupts library templates. Accepts modified elementor_data array.',
|
|
2004
|
+
inputSchema: {
|
|
2005
|
+
type: 'object',
|
|
2006
|
+
properties: {
|
|
2007
|
+
template_id: { type: 'number', description: 'Elementor library template ID' },
|
|
2008
|
+
elementor_data: { type: 'array', description: 'The full Elementor data array to write' }
|
|
2009
|
+
},
|
|
2010
|
+
required: ['template_id', 'elementor_data']
|
|
2011
|
+
}
|
|
2012
|
+
},
|
|
2013
|
+
{
|
|
2014
|
+
name: 'audit_placeholders',
|
|
2015
|
+
description: 'Scan all published Elementor pages for placeholder text (e.g. "Add Your Heading Text Here", "Lorem ipsum"), empty sections with no content, and broken anchor links (#). Optionally include library templates.',
|
|
2016
|
+
inputSchema: {
|
|
2017
|
+
type: 'object',
|
|
2018
|
+
properties: {
|
|
2019
|
+
include_templates: { type: 'boolean', description: 'Also scan library templates (default: false)' }
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
},
|
|
2023
|
+
{
|
|
2024
|
+
name: 'audit_ctas',
|
|
2025
|
+
description: 'Audit all published pages for CTA button coverage. Reports button count per page, flags pages below minimum threshold, detects buttons with empty/# URLs, and identifies over-repeated URLs.',
|
|
2026
|
+
inputSchema: {
|
|
2027
|
+
type: 'object',
|
|
2028
|
+
properties: {
|
|
2029
|
+
min_buttons: { type: 'number', description: 'Minimum buttons expected per page (default: 2)' },
|
|
2030
|
+
include_templates: { type: 'boolean', description: 'Also scan library templates (default: false)' }
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
},
|
|
2034
|
+
|
|
2035
|
+
{
|
|
2036
|
+
name: 'audit_clickable',
|
|
2037
|
+
description: 'Scan all published Elementor pages for plain-text phone numbers, email addresses, and URLs that are NOT wrapped in <a> tags. These should be clickable links but are just text. Returns issues with element IDs, matched values, and suggested fix HTML.',
|
|
2038
|
+
inputSchema: {
|
|
2039
|
+
type: 'object',
|
|
2040
|
+
properties: {
|
|
2041
|
+
target_id: { type: 'number', description: 'Scan a single page by ID. Omit to scan all published pages.' },
|
|
2042
|
+
include_templates: { type: 'boolean', description: 'Also scan library templates (default: false)' }
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
},
|
|
2046
|
+
|
|
2047
|
+
// ── Element Operations (v3.6.0) ──
|
|
2048
|
+
{
|
|
2049
|
+
name: 'add_element',
|
|
2050
|
+
description: 'Add a widget or container into a parent container on a page. Pass the full Elementor element JSON (elType, widgetType, settings, etc.). If no ID is provided, one will be auto-generated. Use position to insert at a specific index.',
|
|
2051
|
+
inputSchema: {
|
|
2052
|
+
type: 'object',
|
|
2053
|
+
properties: {
|
|
2054
|
+
page_id: { type: 'number', description: 'WordPress page ID' },
|
|
2055
|
+
parent_id: { type: 'string', description: 'Elementor ID of the parent container to insert into' },
|
|
2056
|
+
element: { type: 'object', description: 'Full Elementor element JSON: {elType, widgetType, settings, elements, ...}', additionalProperties: true },
|
|
2057
|
+
position: { type: 'number', description: 'Optional: insert at this index (0-based). Omit to append at end.' }
|
|
2058
|
+
},
|
|
2059
|
+
required: ['page_id', 'parent_id', 'element']
|
|
2060
|
+
}
|
|
2061
|
+
},
|
|
2062
|
+
{
|
|
2063
|
+
name: 'get_element',
|
|
2064
|
+
description: 'Get a single element\'s full settings and metadata by its Elementor ID. Returns elType, widgetType, all settings, and a summary of children (for containers). Use this to inspect an element before updating it.',
|
|
2065
|
+
inputSchema: {
|
|
2066
|
+
type: 'object',
|
|
2067
|
+
properties: {
|
|
2068
|
+
page_id: { type: 'number', description: 'WordPress page ID' },
|
|
2069
|
+
element_id: { type: 'string', description: 'Elementor element ID to retrieve' }
|
|
2070
|
+
},
|
|
2071
|
+
required: ['page_id', 'element_id']
|
|
2072
|
+
}
|
|
2073
|
+
},
|
|
2074
|
+
{
|
|
2075
|
+
name: 'batch_update_elements',
|
|
2076
|
+
description: 'Batch update element settings across multiple pages in one call. Groups updates by page to minimize DB reads/writes. IMPORTANT: Prefer updates_json (JSON string) over updates (array) for reliable MCP transport.',
|
|
2077
|
+
inputSchema: {
|
|
2078
|
+
type: 'object',
|
|
2079
|
+
properties: {
|
|
2080
|
+
updates: {
|
|
2081
|
+
type: 'array',
|
|
2082
|
+
description: 'Array of updates: [{page_id, element_id, settings}, ...]. May not work reliably via MCP — use updates_json instead.',
|
|
2083
|
+
items: {
|
|
2084
|
+
type: 'object',
|
|
2085
|
+
properties: {
|
|
2086
|
+
page_id: { type: 'number' },
|
|
2087
|
+
element_id: { type: 'string' },
|
|
2088
|
+
settings: { type: 'object', additionalProperties: true }
|
|
2089
|
+
},
|
|
2090
|
+
required: ['page_id', 'element_id', 'settings']
|
|
2091
|
+
}
|
|
2092
|
+
},
|
|
2093
|
+
updates_json: {
|
|
2094
|
+
type: 'string',
|
|
2095
|
+
description: 'Updates as a JSON string (preferred). Example: \'[{"page_id":123,"element_id":"abc","settings":{"title":"New"}}]\''
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
},
|
|
2100
|
+
{
|
|
2101
|
+
name: 'manage_redirects',
|
|
2102
|
+
description: 'Manage RankMath 301 redirects. Actions: "create" (add redirects), "list" (show all), "delete" (remove by ID). Source paths should NOT include the site subdirectory prefix (e.g. /treatment-options/old-slug/), but destination paths SHOULD include it (e.g. /deuk/treatment-options/new-slug/).',
|
|
2103
|
+
inputSchema: {
|
|
2104
|
+
type: 'object',
|
|
2105
|
+
properties: {
|
|
2106
|
+
action: { type: 'string', enum: ['create', 'list', 'delete'], description: 'Action: create, list, or delete' },
|
|
2107
|
+
redirects: {
|
|
2108
|
+
type: 'array',
|
|
2109
|
+
description: 'For "create": array of {source, destination, code}. code defaults to 301.',
|
|
2110
|
+
items: {
|
|
2111
|
+
type: 'object',
|
|
2112
|
+
properties: {
|
|
2113
|
+
source: { type: 'string', description: 'Source URL path (without site prefix)' },
|
|
2114
|
+
destination: { type: 'string', description: 'Destination URL path (with site prefix)' },
|
|
2115
|
+
code: { type: 'number', description: 'HTTP redirect code (default: 301)' }
|
|
2116
|
+
},
|
|
2117
|
+
required: ['source', 'destination']
|
|
2118
|
+
}
|
|
2119
|
+
},
|
|
2120
|
+
ids: { type: 'array', items: { type: 'number' }, description: 'For "delete": array of redirect IDs to remove' }
|
|
2121
|
+
},
|
|
2122
|
+
required: ['action']
|
|
2123
|
+
}
|
|
2124
|
+
},
|
|
2125
|
+
|
|
2126
|
+
// ── Performance Audit (v3.7.0) ──
|
|
2127
|
+
{
|
|
2128
|
+
name: 'audit_page_weight',
|
|
2129
|
+
description: 'Audit Elementor page complexity and weight. Reports total elements, containers, widgets, images, videos, max nesting depth, estimated DOM nodes, JSON size, and widget type breakdown. Flags heavy pages exceeding threshold. Use to identify pages that need optimization.',
|
|
2130
|
+
inputSchema: {
|
|
2131
|
+
type: 'object',
|
|
2132
|
+
properties: {
|
|
2133
|
+
page_id: { type: 'number', description: 'Optional: audit only this page' },
|
|
2134
|
+
max_elements: { type: 'number', description: 'Element count threshold for flagging heavy pages (default: 300)' }
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
},
|
|
2138
|
+
{
|
|
2139
|
+
name: 'audit_page_speed',
|
|
2140
|
+
description: 'Run Google PageSpeed Insights on a URL and return Core Web Vitals (LCP, FCP, CLS, TBT, SI, TTI), performance score, and top optimization opportunities with estimated savings. Works without API key (rate-limited) or with optional key stored in WP options.',
|
|
2141
|
+
inputSchema: {
|
|
2142
|
+
type: 'object',
|
|
2143
|
+
properties: {
|
|
2144
|
+
url: { type: 'string', description: 'Full URL to test (e.g. https://example.com/page)' },
|
|
2145
|
+
page_id: { type: 'number', description: 'Alternative: WordPress page ID (will use its permalink)' },
|
|
2146
|
+
strategy: { type: 'string', enum: ['mobile', 'desktop'], description: 'Test strategy (default: mobile)' }
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
},
|
|
2150
|
+
// ── Section Operations ──
|
|
2151
|
+
{
|
|
2152
|
+
name: 'copy_section',
|
|
2153
|
+
description: 'Copy a section from one page/template to another. Deep-clones with regenerated IDs. Works with both pages and library templates. Use list_sections to find the section index first.',
|
|
2154
|
+
inputSchema: {
|
|
2155
|
+
type: 'object',
|
|
2156
|
+
properties: {
|
|
2157
|
+
source_id: { type: 'number', description: 'Page or template ID to copy FROM' },
|
|
2158
|
+
section_index: { type: 'number', description: 'Zero-based index of the section to copy (use list_sections to find it)' },
|
|
2159
|
+
target_id: { type: 'number', description: 'Page or template ID to copy TO' },
|
|
2160
|
+
position: { type: 'string', description: 'Where to place: "end" (default), "start", or numeric index' }
|
|
2161
|
+
},
|
|
2162
|
+
required: ['source_id', 'section_index', 'target_id']
|
|
2163
|
+
}
|
|
2164
|
+
},
|
|
2165
|
+
{
|
|
2166
|
+
name: 'append_section',
|
|
2167
|
+
description: 'Append a section to an existing page or template without downloading the full template. Pass the section JSON directly — no need to export, merge, and re-upload. Useful for adding CTA sections, footers, or any reusable block.',
|
|
2168
|
+
inputSchema: {
|
|
2169
|
+
type: 'object',
|
|
2170
|
+
properties: {
|
|
2171
|
+
target_id: { type: 'number', description: 'Page or template ID to append to' },
|
|
2172
|
+
section: { type: 'object', description: 'Full Elementor section object (must have elType and elements)' },
|
|
2173
|
+
position: { type: 'string', description: 'Where to place: "end" (default), "start", or numeric index' },
|
|
2174
|
+
regenerate_ids: { type: 'boolean', description: 'Regenerate all element IDs to avoid collisions (default: true)' }
|
|
2175
|
+
},
|
|
2176
|
+
required: ['target_id', 'section']
|
|
2177
|
+
}
|
|
2178
|
+
},
|
|
2179
|
+
|
|
2180
|
+
// ── Template Library Tools ──
|
|
2181
|
+
|
|
2182
|
+
{
|
|
2183
|
+
name: 'template_list_kits',
|
|
2184
|
+
description: 'List all available template kits in the template library. Shows kit name, industry, page count, section count, and design token summary (colors, fonts). Use this to discover what templates are available before selecting one.',
|
|
2185
|
+
inputSchema: {
|
|
2186
|
+
type: 'object',
|
|
2187
|
+
properties: {},
|
|
2188
|
+
}
|
|
2189
|
+
},
|
|
2190
|
+
{
|
|
2191
|
+
name: 'template_browse_sections',
|
|
2192
|
+
description: 'Browse sections from the template library catalog. Filter by kit, section type, or page name. Returns section metadata with widget composition, layout info, and text preview — useful for finding the right section to use.',
|
|
2193
|
+
inputSchema: {
|
|
2194
|
+
type: 'object',
|
|
2195
|
+
properties: {
|
|
2196
|
+
kit_id: { type: 'string', description: 'Filter by kit ID (folder name). Omit to search all kits.' },
|
|
2197
|
+
type: { type: 'string', description: 'Filter by section type: hero, cta, features-grid, stats-bar, testimonials, team-grid, pricing-table, faq, contact-form, about-content, services-grid, gallery, blog-grid, logo-strip, text-content, comparison-table, process-steps, tabs-content, map-section' },
|
|
2198
|
+
page_name: { type: 'string', description: 'Filter by page name (partial match, case-insensitive)' },
|
|
2199
|
+
limit: { type: 'number', description: 'Max results to return (default: 20)' }
|
|
2200
|
+
},
|
|
2201
|
+
}
|
|
2202
|
+
},
|
|
2203
|
+
{
|
|
2204
|
+
name: 'template_get_section',
|
|
2205
|
+
description: 'Get the full Elementor JSON for a specific section from the template library. Returns the complete section object ready to be used with append_section or for content injection. Use template_browse_sections first to find the section ID.',
|
|
2206
|
+
inputSchema: {
|
|
2207
|
+
type: 'object',
|
|
2208
|
+
properties: {
|
|
2209
|
+
section_id: { type: 'string', description: 'Section ID from the catalog (format: kit-id/page-slug/section-index)' }
|
|
2210
|
+
},
|
|
2211
|
+
required: ['section_id']
|
|
2212
|
+
}
|
|
2213
|
+
},
|
|
2214
|
+
{
|
|
2215
|
+
name: 'template_map_content',
|
|
2216
|
+
description: 'Analyze page content (markdown) and recommend the best template sections from the library for each content block. Parses headings, detects content types (hero, features, FAQ, pricing, testimonials, CTA, etc.), and returns ranked section recommendations from the selected kit. Use this to plan a page layout before assembling sections.',
|
|
2217
|
+
inputSchema: {
|
|
2218
|
+
type: 'object',
|
|
2219
|
+
properties: {
|
|
2220
|
+
content: { type: 'string', description: 'Page content in markdown format. Use ## headings to separate content blocks.' },
|
|
2221
|
+
kit_id: { type: 'string', description: 'Preferred kit ID to pull sections from. Omit to search all kits.' },
|
|
2222
|
+
page_type: { type: 'string', description: 'Optional page type hint: home, about, services, contact, faq, pricing, team, blog' }
|
|
2223
|
+
},
|
|
2224
|
+
required: ['content']
|
|
2225
|
+
}
|
|
2226
|
+
},
|
|
2227
|
+
{
|
|
2228
|
+
name: 'template_get_tokens',
|
|
2229
|
+
description: 'Get the design tokens (colors, typography, button styles) from a template kit. Use this to understand a kit\'s visual identity before rebranding or to compare with your project\'s tokens.',
|
|
2230
|
+
inputSchema: {
|
|
2231
|
+
type: 'object',
|
|
2232
|
+
properties: {
|
|
2233
|
+
kit_id: { type: 'string', description: 'Kit ID (folder name) to get tokens from' }
|
|
2234
|
+
},
|
|
2235
|
+
required: ['kit_id']
|
|
2236
|
+
}
|
|
2237
|
+
},
|
|
2238
|
+
{
|
|
2239
|
+
name: 'template_rebrand',
|
|
2240
|
+
description: 'Rebrand a template section by replacing colors, fonts, and global token references. Takes a section (from template_get_section) and applies your brand\'s colors and fonts. Handles hardcoded hex colors, __globals__ references, font families, and nested objects (shadows, borders). Returns the rebranded section JSON ready for append_section.',
|
|
2241
|
+
inputSchema: {
|
|
2242
|
+
type: 'object',
|
|
2243
|
+
properties: {
|
|
2244
|
+
section_id: { type: 'string', description: 'Section ID from the catalog (format: kit-id/page-slug/section-index). The section JSON will be loaded automatically.' },
|
|
2245
|
+
section_json: { type: 'object', description: 'Alternatively, pass section JSON directly instead of section_id.' },
|
|
2246
|
+
colors: {
|
|
2247
|
+
type: 'object',
|
|
2248
|
+
description: 'Color replacement map. Keys can be hex colors (#RRGGBB) or system color IDs (primary, secondary, text, accent, or custom IDs). Values are target hex colors. Example: {"#F79996": "#084D8F", "secondary": "#1A1A1A"}',
|
|
2249
|
+
additionalProperties: { type: 'string' }
|
|
2250
|
+
},
|
|
2251
|
+
fonts: {
|
|
2252
|
+
type: 'object',
|
|
2253
|
+
description: 'Font replacement map. Keys are source font families, values are target font families. Example: {"Bricolage Grotesque": "Montserrat", "Inter Tight": "Open Sans"}',
|
|
2254
|
+
additionalProperties: { type: 'string' }
|
|
2255
|
+
},
|
|
2256
|
+
globals: {
|
|
2257
|
+
type: 'object',
|
|
2258
|
+
description: 'Global reference replacement map. Keys are source __globals__ references (e.g., "globals/colors?id=secondary"), values are target references. Example: {"globals/colors?id=secondary": "globals/colors?id=primary"}',
|
|
2259
|
+
additionalProperties: { type: 'string' }
|
|
2260
|
+
},
|
|
2261
|
+
auto_map_from_kit: { type: 'string', description: 'Source kit ID. If provided, automatically loads the kit\'s tokens to resolve system color IDs in the colors map.' }
|
|
2262
|
+
},
|
|
2263
|
+
}
|
|
2264
|
+
},
|
|
2265
|
+
{
|
|
2266
|
+
name: 'template_inject_content',
|
|
2267
|
+
description: 'Inject structured content into a template section\'s widgets. Replaces heading titles, text-editor paragraphs, icon-box items, button labels/URLs, counter stats, testimonial quotes, and FAQ accordion items. Use after template_rebrand to fill a section with your actual content before deploying.',
|
|
2268
|
+
inputSchema: {
|
|
2269
|
+
type: 'object',
|
|
2270
|
+
properties: {
|
|
2271
|
+
section_id: { type: 'string', description: 'Section ID from catalog (format: kit-id/page-slug/section-index). Loads the section automatically.' },
|
|
2272
|
+
section_json: { type: 'object', description: 'Alternatively, pass section JSON directly (e.g., output from template_rebrand).' },
|
|
2273
|
+
content: {
|
|
2274
|
+
type: 'object',
|
|
2275
|
+
description: 'Structured content to inject into the section.',
|
|
2276
|
+
properties: {
|
|
2277
|
+
heading: { type: 'string', description: 'Main heading text' },
|
|
2278
|
+
subheading: { type: 'string', description: 'Optional subtitle/label above main heading' },
|
|
2279
|
+
paragraphs: { type: 'array', items: { type: 'string' }, description: 'Paragraph texts' },
|
|
2280
|
+
items: {
|
|
2281
|
+
type: 'array',
|
|
2282
|
+
items: { type: 'object', properties: { title: { type: 'string' }, description: { type: 'string' } } },
|
|
2283
|
+
description: 'Feature/service items with title + description (for icon-boxes)'
|
|
2284
|
+
},
|
|
2285
|
+
stats: {
|
|
2286
|
+
type: 'array',
|
|
2287
|
+
items: { type: 'object', properties: { value: { type: 'string' }, label: { type: 'string' } } },
|
|
2288
|
+
description: 'Stat values for counter widgets (e.g., {value: "500+", label: "Cases Won"})'
|
|
2289
|
+
},
|
|
2290
|
+
buttons: {
|
|
2291
|
+
type: 'array',
|
|
2292
|
+
items: { type: 'object', properties: { text: { type: 'string' }, url: { type: 'string' } } },
|
|
2293
|
+
description: 'CTA button labels and URLs'
|
|
2294
|
+
},
|
|
2295
|
+
quotes: {
|
|
2296
|
+
type: 'array',
|
|
2297
|
+
items: { type: 'object', properties: { text: { type: 'string' }, author: { type: 'string' } } },
|
|
2298
|
+
description: 'Testimonial quotes with author names'
|
|
2299
|
+
},
|
|
2300
|
+
faq: {
|
|
2301
|
+
type: 'array',
|
|
2302
|
+
items: { type: 'object', properties: { question: { type: 'string' }, answer: { type: 'string' } } },
|
|
2303
|
+
description: 'FAQ question/answer pairs (for accordion/toggle widgets)'
|
|
2304
|
+
}
|
|
2305
|
+
},
|
|
2306
|
+
required: ['heading']
|
|
2307
|
+
}
|
|
2308
|
+
},
|
|
2309
|
+
required: ['content']
|
|
2310
|
+
}
|
|
2311
|
+
},
|
|
2312
|
+
{
|
|
2313
|
+
name: 'template_assemble_page',
|
|
2314
|
+
description: 'End-to-end page assembly: parse markdown content → map to template sections → rebrand with your colors/fonts → inject content → create WordPress page → append all sections. This is the complete text-to-design pipeline in one tool call.',
|
|
2315
|
+
inputSchema: {
|
|
2316
|
+
type: 'object',
|
|
2317
|
+
properties: {
|
|
2318
|
+
title: { type: 'string', description: 'Page title for WordPress' },
|
|
2319
|
+
slug: { type: 'string', description: 'URL slug (optional, auto-generated from title if omitted)' },
|
|
2320
|
+
content: { type: 'string', description: 'Page content in markdown format. Use ## headings to separate content blocks.' },
|
|
2321
|
+
kit_id: { type: 'string', description: 'Template kit ID to use for section selection' },
|
|
2322
|
+
colors: { type: 'object', description: 'Brand color map: source hex/ID → target hex. Passed to template_rebrand.', additionalProperties: { type: 'string' } },
|
|
2323
|
+
fonts: { type: 'object', description: 'Brand font map: source family → target family.', additionalProperties: { type: 'string' } },
|
|
2324
|
+
globals: { type: 'object', description: 'Global reference replacement map.', additionalProperties: { type: 'string' } },
|
|
2325
|
+
status: { type: 'string', enum: ['draft', 'publish', 'private'], description: 'Page status (default: draft)' },
|
|
2326
|
+
page_type: { type: 'string', description: 'Page type hint for section mapper: home, about, services, contact, faq, pricing, team, blog' }
|
|
2327
|
+
},
|
|
2328
|
+
required: ['title', 'content', 'kit_id']
|
|
2329
|
+
}
|
|
2330
|
+
},
|
|
2331
|
+
{
|
|
2332
|
+
name: 'get_shortcode_status',
|
|
2333
|
+
description: 'Check if NVBC shortcodes are enabled and list available shortcodes. Use this before attempting to use shortcodes in templates.',
|
|
2334
|
+
inputSchema: { type: 'object', properties: {} }
|
|
2335
|
+
},
|
|
2336
|
+
{
|
|
2337
|
+
name: 'toggle_shortcodes',
|
|
2338
|
+
description: 'Enable or disable NVBC shortcodes. When enabled, shortcodes like [nvbc_categories] become available for solving Elementor rendering limitations.',
|
|
2339
|
+
inputSchema: {
|
|
2340
|
+
type: 'object',
|
|
2341
|
+
properties: {
|
|
2342
|
+
enabled: { type: 'boolean', description: 'true to enable, false to disable' }
|
|
2343
|
+
},
|
|
2344
|
+
required: ['enabled']
|
|
2345
|
+
}
|
|
2346
|
+
},
|
|
2347
|
+
{
|
|
2348
|
+
name: 'get_site_profile',
|
|
2349
|
+
description: 'Get the site profile — stored preferences, URLs, design conventions, and team info. Use this at the start of a session to understand site context.',
|
|
2350
|
+
inputSchema: { type: 'object', properties: {} }
|
|
2351
|
+
},
|
|
2352
|
+
{
|
|
2353
|
+
name: 'update_site_profile',
|
|
2354
|
+
description: 'Update site profile values. Only known keys are accepted (site_name, staging_url, production_url, primary_color, secondary_color, font_heading, font_body, cta_phone, cta_text, team_timezone, etc). Unknown keys are rejected. Merge semantics — only provided keys are updated.',
|
|
2355
|
+
inputSchema: {
|
|
2356
|
+
type: 'object',
|
|
2357
|
+
properties: {
|
|
2358
|
+
profile: {
|
|
2359
|
+
type: 'object',
|
|
2360
|
+
description: 'Key-value pairs to update. Example: {"site_name": "Deuk Spine", "primary_color": "#084D8F"}'
|
|
2361
|
+
}
|
|
2362
|
+
},
|
|
2363
|
+
required: ['profile']
|
|
2364
|
+
}
|
|
2365
|
+
},
|
|
2366
|
+
{
|
|
2367
|
+
name: 'list_changes',
|
|
2368
|
+
description: 'List recent changes made to the site. Shows who made each change (user + IP), when, what was changed, and whether it was via MCP or manual. Use to audit recent activity.',
|
|
2369
|
+
inputSchema: {
|
|
2370
|
+
type: 'object',
|
|
2371
|
+
properties: {
|
|
2372
|
+
post_id: { type: 'number', description: 'Filter by page/post ID' },
|
|
2373
|
+
source: { type: 'string', enum: ['mcp', 'manual', 'api'], description: 'Filter by source' },
|
|
2374
|
+
limit: { type: 'number', description: 'Max entries (default 20)' }
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
},
|
|
2378
|
+
{
|
|
2379
|
+
name: 'rollback_change',
|
|
2380
|
+
description: 'Revert a specific change by its changelog ID. Use list_changes first to find the ID. Currently supports rollback for update_element actions.',
|
|
2381
|
+
inputSchema: {
|
|
2382
|
+
type: 'object',
|
|
2383
|
+
properties: {
|
|
2384
|
+
changelog_id: { type: 'number', description: 'Changelog entry ID to rollback' },
|
|
2385
|
+
preview: { type: 'boolean', description: 'If true, show what would be rolled back without applying (default: false)' }
|
|
2386
|
+
},
|
|
2387
|
+
required: ['changelog_id']
|
|
2388
|
+
}
|
|
2389
|
+
},
|
|
2390
|
+
{
|
|
2391
|
+
name: 'validate_all_pages',
|
|
2392
|
+
description: 'Validate all Elementor pages against design patterns (CSS class duality, boilerplate, root layout, duplicate IDs). Returns per-page scores and aggregate summary.',
|
|
2393
|
+
inputSchema: {
|
|
2394
|
+
type: 'object',
|
|
2395
|
+
properties: {
|
|
2396
|
+
verbose: { type: 'boolean', description: 'Include detailed issues per page (default: false)' }
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
},
|
|
2400
|
+
{
|
|
2401
|
+
name: 'get_audit_report',
|
|
2402
|
+
description: 'Get the latest weekly audit report. Shows summary of link, image, and SEO issues across all pages from the most recent scheduled audit run.',
|
|
2403
|
+
inputSchema: {
|
|
2404
|
+
type: 'object',
|
|
2405
|
+
properties: {}
|
|
2406
|
+
}
|
|
2407
|
+
},
|
|
2408
|
+
{
|
|
2409
|
+
name: 'run_audit_now',
|
|
2410
|
+
description: 'Manually trigger a full site audit immediately — bypasses the scheduled cron and the audit_enabled flag. Runs link, image, and SEO checks across all pages in batches. After triggering, wait ~30–60s then call get_audit_report for results.',
|
|
2411
|
+
inputSchema: {
|
|
2412
|
+
type: 'object',
|
|
2413
|
+
properties: {}
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
];
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// ================================================================
|
|
2420
|
+
// TOOL HANDLER
|
|
2421
|
+
// ================================================================
|
|
2422
|
+
|
|
2423
|
+
async function handleToolCall(name, args) {
|
|
2424
|
+
console.error(`[MCP] ${name}`);
|
|
2425
|
+
|
|
2426
|
+
switch (name) {
|
|
2427
|
+
|
|
2428
|
+
case 'health_check': {
|
|
2429
|
+
try {
|
|
2430
|
+
const r = await apiCall('/health');
|
|
2431
|
+
return ok(
|
|
2432
|
+
`Vision Builder Control: ACTIVE\n` +
|
|
2433
|
+
`Plugin: v${r.version}\n` +
|
|
2434
|
+
`Elementor: ${r.elementor ? 'Active' : 'NOT ACTIVE'}\n` +
|
|
2435
|
+
`Components: ${r.components}\n` +
|
|
2436
|
+
`Shortcodes: ${r.shortcodes ? 'Enabled' : 'Disabled'}\n` +
|
|
2437
|
+
`MCP Server: v${VERSION} (${httpMode ? 'HTTP/SSE' : 'stdio'})`
|
|
2438
|
+
);
|
|
2439
|
+
} catch (e) {
|
|
2440
|
+
return ok(`Vision Builder Control: NOT RESPONDING\nError: ${e.message}\n\nMake sure:\n1. Noleemits Vision Builder Control plugin is activated\n2. Elementor is installed and activated\n3. WP_URL, WP_USER, WP_APP_PASSWORD are correct`);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
case 'get_design_tokens': {
|
|
2445
|
+
const tokens = await getDesignTokens();
|
|
2446
|
+
return ok(`=== DESIGN TOKENS ===\n\n${JSON.stringify(tokens, null, 2)}`);
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
case 'set_design_tokens': {
|
|
2450
|
+
const r = await apiCall('/design-tokens', 'POST', { tokens: args.tokens });
|
|
2451
|
+
if (r.success) {
|
|
2452
|
+
tokenCache.data = null; // Invalidate cache
|
|
2453
|
+
return ok(`Tokens saved successfully.\n\nUpdated tokens:\n${JSON.stringify(r.tokens, null, 2)}`);
|
|
2454
|
+
}
|
|
2455
|
+
return ok(`Failed to save tokens: ${r.message || 'Unknown error'}`);
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
case 'refresh_tokens': {
|
|
2459
|
+
const tokens = await getDesignTokens(true);
|
|
2460
|
+
return ok(`Tokens refreshed from WordPress.\n\n${JSON.stringify(tokens, null, 2)}`);
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
case 'get_design_rules': {
|
|
2464
|
+
const tokens = await getDesignTokens();
|
|
2465
|
+
const c = tokens.colors || {};
|
|
2466
|
+
const t = tokens.typography || {};
|
|
2467
|
+
const s = tokens.spacing || {};
|
|
2468
|
+
const u = tokens.urls || {};
|
|
2469
|
+
const bp = tokens.boilerplate || {};
|
|
2470
|
+
const gm = tokens.globals_map || {};
|
|
2471
|
+
|
|
2472
|
+
return ok(
|
|
2473
|
+
`=== DESIGN RULES ===\n\n` +
|
|
2474
|
+
`--- BUTTONS ---\n` +
|
|
2475
|
+
`Primary: gradient ${c.primary} -> ${c.primary_dark}, text ${c.white}, class "${tokens.buttons?.primary_class}"\n` +
|
|
2476
|
+
`Secondary: solid ${c.secondary}, text ${c.text}, class "${tokens.buttons?.secondary_class}"\n` +
|
|
2477
|
+
`Rules:\n - Hero: ALWAYS secondary\n - Light bg: primary\n - Dark bg (1 btn): secondary\n - Dark bg (2 btn): BOTH secondary\n\n` +
|
|
2478
|
+
`--- COLORS (hex) ---\n${Object.entries(c).map(([k, v]) => ` ${k}: ${v}`).join('\n')}\n\n` +
|
|
2479
|
+
`--- COLORS (__globals__) ---\n${Object.entries(gm).map(([k, v]) => ` ${k}: ${v}`).join('\n')}\n\n` +
|
|
2480
|
+
`--- CONTAINER RULES ---\n` +
|
|
2481
|
+
` Root: layout = "full_width"\n` +
|
|
2482
|
+
` ALL containers: jet_parallax_layout_list = []\n` +
|
|
2483
|
+
` css_classes MUST match _css_classes\n` +
|
|
2484
|
+
` Section spacing: "${s.section_class}"\n\n` +
|
|
2485
|
+
`--- WIDGET RULES ---\n` +
|
|
2486
|
+
` ALL widgets: elements = []\n` +
|
|
2487
|
+
` ALL widgets: plus_tooltip_content_desc = "${bp.tooltip_text}"\n\n` +
|
|
2488
|
+
`--- TYPOGRAPHY ---\n Heading: ${t.heading_font}\n Body: ${t.body_font}\n Button: ${t.button_weight} ${t.button_transform}\n\n` +
|
|
2489
|
+
`--- URLS ---\n${Object.entries(u).map(([k, v]) => ` ${k}: ${v}`).join('\n')}`
|
|
2490
|
+
);
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
case 'get_button': {
|
|
2494
|
+
const tokens = await getDesignTokens();
|
|
2495
|
+
const isSecondary = args.context === 'hero' || args.context === 'dark_bg';
|
|
2496
|
+
const type = isSecondary ? 'secondary' : 'primary';
|
|
2497
|
+
const settings = buildButtonSettings(tokens, type);
|
|
2498
|
+
settings.text = args.text;
|
|
2499
|
+
settings.link = { url: args.url, is_external: false, nofollow: false };
|
|
2500
|
+
|
|
2501
|
+
const widget = {
|
|
2502
|
+
id: rndId(),
|
|
2503
|
+
elType: 'widget',
|
|
2504
|
+
widgetType: 'button',
|
|
2505
|
+
settings,
|
|
2506
|
+
elements: [],
|
|
2507
|
+
isInner: false,
|
|
2508
|
+
};
|
|
2509
|
+
return ok(`${type.toUpperCase()} button for ${args.context}:\n\n${JSON.stringify(widget, null, 2)}`);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
case 'list_pages': {
|
|
2513
|
+
const r = await apiCall('/pages');
|
|
2514
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
2515
|
+
let out = `${r.count} Pages\n\n`;
|
|
2516
|
+
r.pages.forEach(p => {
|
|
2517
|
+
out += ` [${p.id}] ${p.title} (${p.status}, ${p.editor}, ${p.sections} sections)\n`;
|
|
2518
|
+
out += ` URL: ${p.url}\n Edit: ${p.edit_url}\n`;
|
|
2519
|
+
});
|
|
2520
|
+
return ok(out);
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
case 'create_page': {
|
|
2524
|
+
const r = await apiCall('/pages', 'POST', {
|
|
2525
|
+
title: args.title,
|
|
2526
|
+
slug: args.slug,
|
|
2527
|
+
status: args.status || 'draft',
|
|
2528
|
+
elementor: parseBool(args.elementor, true),
|
|
2529
|
+
});
|
|
2530
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
2531
|
+
return ok(
|
|
2532
|
+
`Page Created!\n` +
|
|
2533
|
+
`ID: ${r.page_id}\n` +
|
|
2534
|
+
`Title: ${r.title}\n` +
|
|
2535
|
+
`Slug: ${r.slug}\n` +
|
|
2536
|
+
`Status: ${r.status}\n` +
|
|
2537
|
+
`Editor: ${r.editor}\n` +
|
|
2538
|
+
`URL: ${r.url}\n` +
|
|
2539
|
+
`Edit: ${r.edit_url}`
|
|
2540
|
+
);
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
case 'validate_page': {
|
|
2544
|
+
const r = await apiCall(`/pages/${args.page_id}/validate`);
|
|
2545
|
+
if (r.valid) {
|
|
2546
|
+
const sectionOrder = (r.section_order || []).map((s, i) => ` ${i + 1}. ${s}`).join('\n');
|
|
2547
|
+
return ok(`ALL CHECKS PASSED!\nSections: ${r.stats?.sections || 0} | Containers: ${r.stats?.containers || 0} | Widgets: ${r.stats?.widgets || 0} | IDs: ${r.unique_ids || 0}\n\nOrder:\n${sectionOrder}`);
|
|
2548
|
+
}
|
|
2549
|
+
const issueCount = r.issues?.length || 0;
|
|
2550
|
+
const issueList = (r.issues || []).map(i => ` - ${i}`).join('\n');
|
|
2551
|
+
return ok(`ISSUES FOUND (${issueCount}):\n${issueList}\n\nStats: ${r.stats?.sections || 0}s / ${r.stats?.containers || 0}c / ${r.stats?.widgets || 0}w`);
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
case 'extract_content': {
|
|
2555
|
+
const r = await apiCall(`/pages/${args.page_id}/extract`);
|
|
2556
|
+
let out = `=== "${r.title}" ===\nID: ${r.page_id} | Editor: ${r.editor} | Words: ${r.word_count} | Buttons: ${r.buttons?.length || 0}\n`;
|
|
2557
|
+
if (r.placeholders?.length) { out += `⚠ PLACEHOLDERS (${r.placeholders.length}):\n${r.placeholders.map(p => ` [${p.id}] ${p.widget}: "${p.text}"`).join('\n')}\n\n`; }
|
|
2558
|
+
else { out += '\n'; }
|
|
2559
|
+
if (r.headings?.length) { out += `--- HEADINGS ---\n${r.headings.map(h => ` ${h.level}: ${h.text}`).join('\n')}\n\n`; }
|
|
2560
|
+
if (r.buttons?.length) {
|
|
2561
|
+
out += `--- BUTTONS (${r.buttons.length}) ---\n`;
|
|
2562
|
+
r.buttons.forEach(b => { out += ` [${b.id}] "${b.text}" -> ${b.url || '(no url)'}${b.css_class ? ` .${b.css_class}` : ''}\n`; });
|
|
2563
|
+
out += '\n';
|
|
2564
|
+
}
|
|
2565
|
+
if (r.sections?.length) {
|
|
2566
|
+
out += `--- SECTIONS (${r.sections.length}) ---\n`;
|
|
2567
|
+
r.sections.forEach((s, i) => {
|
|
2568
|
+
out += `\n[${i + 1}] ${s.title || s.id}${s.css_class ? ` .${s.css_class}` : ''}\n`;
|
|
2569
|
+
(s.text_blocks || []).forEach(t => { const tr = t.trim(); if (tr) out += ` ${tr.slice(0, 200)}${tr.length > 200 ? '...' : ''}\n`; });
|
|
2570
|
+
});
|
|
2571
|
+
out += '\n';
|
|
2572
|
+
}
|
|
2573
|
+
if (r.images?.length) { out += `--- IMAGES ---\n${r.images.map(i => ` ${i.url} (alt: ${i.alt || '-'})`).join('\n')}\n\n`; }
|
|
2574
|
+
if (r.links?.length) { out += `--- LINKS ---\n${r.links.map(l => ` "${l.text}" -> ${l.url}${l.type ? ` [${l.type}]` : ''}`).join('\n')}\n\n`; }
|
|
2575
|
+
out += `--- FULL TEXT ---\n${r.all_text}`;
|
|
2576
|
+
return ok(out);
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
case 'import_template': {
|
|
2580
|
+
const r = await apiCall(`/pages/${args.page_id}/import`, 'POST', { template: args.template, mode: args.mode || 'replace' });
|
|
2581
|
+
return ok(r.success ? `Imported!\n${r.sections} sections, ${r.total_elements} elements (${r.mode})\n\n${r.preview_url}` : `Error: ${r.message}`);
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
case 'get_component_presets': {
|
|
2585
|
+
const r = await apiCall('/component-presets');
|
|
2586
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
2587
|
+
if (!r.presets?.length) return ok('No components with style schemas found.');
|
|
2588
|
+
let out = `=== COMPONENT STYLE PRESETS (${r.count}) ===\n\n`;
|
|
2589
|
+
r.presets.forEach(p => {
|
|
2590
|
+
out += `--- ${p.name} (${p.component_id}) ---\n`;
|
|
2591
|
+
Object.entries(p.style_schema).forEach(([key, def]) => {
|
|
2592
|
+
const saved = p.saved[key] || '';
|
|
2593
|
+
const resolved = p.resolved[key] || '';
|
|
2594
|
+
const source = saved ? 'custom' : (def.default_token ? `token:${def.default_token}` : 'default');
|
|
2595
|
+
out += ` ${key}: ${resolved} [${source}]\n`;
|
|
2596
|
+
});
|
|
2597
|
+
out += '\n';
|
|
2598
|
+
});
|
|
2599
|
+
return ok(out);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
case 'set_component_presets': {
|
|
2603
|
+
const r = await apiCall('/component-presets', 'POST', {
|
|
2604
|
+
component_id: args.component_id,
|
|
2605
|
+
presets: args.presets
|
|
2606
|
+
});
|
|
2607
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
2608
|
+
return ok(r.message);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
case 'list_components': {
|
|
2612
|
+
const r = await apiCall('/components');
|
|
2613
|
+
if (!r.success || !r.components) {
|
|
2614
|
+
return ok(`Failed to list components: ${r.message || 'Unknown error'}`);
|
|
2615
|
+
}
|
|
2616
|
+
let out = `${r.count} Design Components Available\n\n`;
|
|
2617
|
+
const grouped = {};
|
|
2618
|
+
r.components.forEach(c => {
|
|
2619
|
+
if (!grouped[c.category]) grouped[c.category] = [];
|
|
2620
|
+
grouped[c.category].push(c);
|
|
2621
|
+
});
|
|
2622
|
+
Object.entries(grouped).forEach(([category, items]) => {
|
|
2623
|
+
out += `--- ${category.toUpperCase()} (${items.length}) ---\n`;
|
|
2624
|
+
items.forEach(item => {
|
|
2625
|
+
out += ` - ${item.name}\n ${item.meta.description}\n`;
|
|
2626
|
+
if (item.meta.variables?.length) {
|
|
2627
|
+
out += ` Variables: ${item.meta.variables.map(v => v.name).join(', ')}\n`;
|
|
2628
|
+
}
|
|
2629
|
+
if (item.meta.style_schema) {
|
|
2630
|
+
out += ` Customizable styles: ${Object.keys(item.meta.style_schema).join(', ')}\n`;
|
|
2631
|
+
}
|
|
2632
|
+
if (item.meta.post_placement_instructions) {
|
|
2633
|
+
out += ` NOTE: ${item.meta.post_placement_instructions}\n`;
|
|
2634
|
+
}
|
|
2635
|
+
});
|
|
2636
|
+
out += '\n';
|
|
2637
|
+
});
|
|
2638
|
+
return ok(out);
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
case 'add_component': {
|
|
2642
|
+
const r = await apiCall(`/pages/${args.page_id}/add-component`, 'POST', {
|
|
2643
|
+
category: args.category,
|
|
2644
|
+
name: args.name,
|
|
2645
|
+
variables: args.variables || {},
|
|
2646
|
+
position: args.position || 'append'
|
|
2647
|
+
});
|
|
2648
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
2649
|
+
let msg = `Added ${r.component.category}/${r.component.name}!\n` +
|
|
2650
|
+
`ID: ${r.component.id}\nTotal sections: ${r.total_sections}\nPreview: ${r.preview_url}`;
|
|
2651
|
+
if (r.post_placement_instructions) msg += `\n\nACTION REQUIRED: ${r.post_placement_instructions}`;
|
|
2652
|
+
return ok(msg);
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
case 'build_page': {
|
|
2656
|
+
const r = await apiCall(`/pages/${args.page_id}/build`, 'POST', {
|
|
2657
|
+
sections: args.sections,
|
|
2658
|
+
mode: args.mode || 'replace'
|
|
2659
|
+
});
|
|
2660
|
+
if (!r.success) return ok(`Build failed: ${r.message || 'Unknown error'}`);
|
|
2661
|
+
let buildMsg = `Page Built!\nSections: ${r.sections_built}\nElements: ${r.total_elements}\nMode: ${r.mode}\n\nPreview: ${r.preview_url}\nEdit: ${r.edit_url}`;
|
|
2662
|
+
if (r.post_placement_instructions?.length) {
|
|
2663
|
+
buildMsg += '\n\nACTION REQUIRED:\n' + r.post_placement_instructions.map(i => `- ${i}`).join('\n');
|
|
2664
|
+
}
|
|
2665
|
+
return ok(buildMsg);
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
case 'list_sections': {
|
|
2669
|
+
const r = await apiCall(`/pages/${args.page_id}/extract`);
|
|
2670
|
+
if (r.error) return ok(`Failed: ${r.message || r.error}`);
|
|
2671
|
+
const sections = r.sections || [];
|
|
2672
|
+
if (!sections.length) return ok('Page has no sections.');
|
|
2673
|
+
let msg = `Page ${args.page_id}: ${sections.length} sections\n${'─'.repeat(40)}\n`;
|
|
2674
|
+
sections.forEach((s, i) => {
|
|
2675
|
+
const cls = s.css_class || s.css_classes || '';
|
|
2676
|
+
const heading = s.first_heading || s.heading || '';
|
|
2677
|
+
const widgets = s.widget_count || s.widgets || '?';
|
|
2678
|
+
msg += `[${i}] ${cls || s.id || '(no class)'} — ${heading || '(no heading)'} (${widgets} widgets)\n`;
|
|
2679
|
+
});
|
|
2680
|
+
return ok(msg);
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
case 'remove_section': {
|
|
2684
|
+
const body = {};
|
|
2685
|
+
if (args.index !== undefined) body.index = args.index;
|
|
2686
|
+
if (args.section_id) body.section_id = args.section_id;
|
|
2687
|
+
const r = await apiCall(`/pages/${args.page_id}/remove-section`, 'POST', body);
|
|
2688
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2689
|
+
return ok(`Removed section${r.removed?.id ? ` (ID: ${r.removed.id})` : ''}.\nRemaining sections: ${r.remaining_sections}`);
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
case 'reorder_sections': {
|
|
2693
|
+
const body = {};
|
|
2694
|
+
if (args.move) body.move = args.move;
|
|
2695
|
+
else if (args.swap) body.swap = args.swap;
|
|
2696
|
+
else if (args.order) body.order = args.order;
|
|
2697
|
+
const r = await apiCall(`/pages/${args.page_id}/reorder`, 'POST', body);
|
|
2698
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2699
|
+
let msg = `Sections reordered! (${r.sections} total)\n`;
|
|
2700
|
+
if (r.section_order) {
|
|
2701
|
+
r.section_order.forEach((s, i) => {
|
|
2702
|
+
msg += `[${i}] ${s.css_class || s.id || '(no class)'}\n`;
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
return ok(msg);
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
case 'list_elements': {
|
|
2709
|
+
const params = new URLSearchParams();
|
|
2710
|
+
if (args.section_index !== undefined) params.set('section_index', args.section_index);
|
|
2711
|
+
if (args.depth !== undefined) params.set('depth', args.depth);
|
|
2712
|
+
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
2713
|
+
const r = await apiCall(`/pages/${args.page_id}/elements${qs}`);
|
|
2714
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2715
|
+
|
|
2716
|
+
function formatTree(el, indent) {
|
|
2717
|
+
let line = ' '.repeat(indent);
|
|
2718
|
+
if (el.type === 'widget') {
|
|
2719
|
+
line += `[${el.widget}] id:${el.id}`;
|
|
2720
|
+
if (el.text) line += ` — "${el.text}"`;
|
|
2721
|
+
if (el.image) line += ` — img:${el.image}`;
|
|
2722
|
+
if (el.items) line += ` — ${el.items} items`;
|
|
2723
|
+
} else {
|
|
2724
|
+
line += `<${el.type}> id:${el.id}`;
|
|
2725
|
+
}
|
|
2726
|
+
if (el.css_class) line += ` .${el.css_class}`;
|
|
2727
|
+
if (el.label) line += ` "${el.label}"`;
|
|
2728
|
+
let result = line + '\n';
|
|
2729
|
+
if (el.children) {
|
|
2730
|
+
el.children.forEach(c => { result += formatTree(c, indent + 1); });
|
|
2731
|
+
} else if (el.children_count) {
|
|
2732
|
+
result += ' '.repeat(indent + 1) + `(${el.children_count} children, increase depth to see)\n`;
|
|
2733
|
+
}
|
|
2734
|
+
return result;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
let msg = `Page ${args.page_id}: ${r.total_sections} sections\n${'─'.repeat(50)}\n`;
|
|
2738
|
+
r.elements.forEach((el, i) => {
|
|
2739
|
+
if (args.section_index === undefined) msg += `\n── Section [${i}] ──\n`;
|
|
2740
|
+
msg += formatTree(el, 0);
|
|
2741
|
+
});
|
|
2742
|
+
return ok(msg);
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
case 'remove_element': {
|
|
2746
|
+
const r = await apiCall(`/pages/${args.page_id}/remove-element`, 'POST', {
|
|
2747
|
+
element_id: args.element_id
|
|
2748
|
+
});
|
|
2749
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2750
|
+
return ok(`Removed ${r.removed.type} (ID: ${r.removed.id}).\nRemaining sections: ${r.remaining_sections}`);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
case 'update_element': {
|
|
2754
|
+
// Resolve settings: prefer settings_json (string) for reliable MCP transport, fall back to settings (object)
|
|
2755
|
+
let settings = null;
|
|
2756
|
+
if (args.settings_json) {
|
|
2757
|
+
try { settings = JSON.parse(stripCDATA(args.settings_json)); } catch (e) {
|
|
2758
|
+
return ok(`Failed: settings_json is not valid JSON: ${e.message}`);
|
|
2759
|
+
}
|
|
2760
|
+
} else if (args.settings && typeof args.settings === 'object' && Object.keys(args.settings).length > 0) {
|
|
2761
|
+
settings = args.settings;
|
|
2762
|
+
} else if (args.settings && typeof args.settings === 'string') {
|
|
2763
|
+
try { settings = JSON.parse(stripCDATA(args.settings)); } catch (e) {
|
|
2764
|
+
return ok(`Failed: settings string is not valid JSON: ${e.message}`);
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
if (!settings || Object.keys(settings).length === 0) {
|
|
2768
|
+
return ok(`Failed: No settings provided. Use settings_json (preferred) or settings parameter.\nReceived args keys: ${Object.keys(args).join(', ')}`);
|
|
2769
|
+
}
|
|
2770
|
+
const cleanSettings = deepStripCDATA(settings);
|
|
2771
|
+
try {
|
|
2772
|
+
const r = await apiCall(`/pages/${args.page_id}/update-element`, 'POST', {
|
|
2773
|
+
element_id: args.element_id,
|
|
2774
|
+
settings: cleanSettings
|
|
2775
|
+
});
|
|
2776
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2777
|
+
return ok(`Element ${r.element_id} updated on page ${r.page_id}.\nUpdated keys: ${r.updated_keys.join(', ')}`);
|
|
2778
|
+
} catch (err) {
|
|
2779
|
+
return ok(`Failed: ${err.message}`);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
case 'audit_links': {
|
|
2784
|
+
const params = new URLSearchParams();
|
|
2785
|
+
if (args.page_id) params.set('page_id', args.page_id);
|
|
2786
|
+
if (args.include_templates) params.set('include_templates', '1');
|
|
2787
|
+
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
2788
|
+
const r = await apiCall(`/audit-links${qs}`);
|
|
2789
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2790
|
+
|
|
2791
|
+
let msg = `=== LINK AUDIT ===\nPages scanned: ${r.pages_scanned}\nPages with issues: ${r.pages_with_issues}\nTotal issues: ${r.total_issues}\n`;
|
|
2792
|
+
|
|
2793
|
+
if (r.total_issues === 0) {
|
|
2794
|
+
msg += '\nAll links have correct targets!';
|
|
2795
|
+
} else {
|
|
2796
|
+
r.results.forEach(page => {
|
|
2797
|
+
msg += `\n--- ${page.title} (ID: ${page.page_id}, ${page.status}) — ${page.issue_count} issues ---\n`;
|
|
2798
|
+
page.issues.forEach(issue => {
|
|
2799
|
+
msg += ` [${issue.widget}] ${issue.field}: ${issue.url}\n ${issue.problem}\n`;
|
|
2800
|
+
});
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
return ok(msg);
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
case 'fix_links': {
|
|
2807
|
+
const body = {};
|
|
2808
|
+
if (args.page_id) body.page_id = args.page_id;
|
|
2809
|
+
if (parseBool(args.dry_run)) body.dry_run = true;
|
|
2810
|
+
if (parseBool(args.include_templates)) body.include_templates = true;
|
|
2811
|
+
const r = await apiCall('/fix-links', 'POST', body);
|
|
2812
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2813
|
+
|
|
2814
|
+
const mode = r.dry_run ? 'DRY RUN' : 'APPLIED';
|
|
2815
|
+
let msg = `=== LINK FIX (${mode}) ===\nPages scanned: ${r.pages_scanned}\nPages fixed: ${r.pages_fixed}\nTotal fixes: ${r.total_fixes}\n`;
|
|
2816
|
+
|
|
2817
|
+
if (r.total_fixes === 0) {
|
|
2818
|
+
msg += '\nNo fixes needed — all links are correct!';
|
|
2819
|
+
} else {
|
|
2820
|
+
r.results.forEach(page => {
|
|
2821
|
+
msg += `\n--- ${page.title} (ID: ${page.page_id}) — ${page.fix_count} fixes ---\n`;
|
|
2822
|
+
page.fixes.forEach(fix => {
|
|
2823
|
+
msg += ` [${fix.widget}] ${fix.field}: ${fix.url}\n ${fix.action}\n`;
|
|
2824
|
+
});
|
|
2825
|
+
});
|
|
2826
|
+
if (r.dry_run) msg += '\n(Dry run — no changes were made. Run again with dry_run=false to apply.)';
|
|
2827
|
+
}
|
|
2828
|
+
return ok(msg);
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
case 'export_page': {
|
|
2832
|
+
const r = await apiCall(`/pages/${args.page_id}/export`);
|
|
2833
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2834
|
+
let msg = `=== PAGE EXPORT ===\n`;
|
|
2835
|
+
msg += `ID: ${r.page_id} | Title: ${r.title} | Slug: ${r.slug}\n`;
|
|
2836
|
+
msg += `Status: ${r.status} | Template: ${r.template}\n`;
|
|
2837
|
+
msg += `Sections: ${r.section_count} | Elements: ${r.element_count}\n\n`;
|
|
2838
|
+
const jsonStr = JSON.stringify(r.elementor_data, null, 2);
|
|
2839
|
+
if (jsonStr.length > 200000) {
|
|
2840
|
+
msg += `--- ELEMENTOR DATA (truncated, ${Math.round(jsonStr.length / 1024)}KB total) ---\n`;
|
|
2841
|
+
msg += jsonStr.substring(0, 200000) + '\n\n... (truncated — use REST API directly for full data)';
|
|
2842
|
+
} else {
|
|
2843
|
+
msg += `--- ELEMENTOR DATA (JSON) ---\n` + jsonStr;
|
|
2844
|
+
}
|
|
2845
|
+
return ok(msg);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
case 'audit_images': {
|
|
2849
|
+
if (args.fix) {
|
|
2850
|
+
// POST mode: fix missing alt text
|
|
2851
|
+
const body = { dry_run: parseBool(args.dry_run, false) };
|
|
2852
|
+
if (args.page_id) body.page_id = args.page_id;
|
|
2853
|
+
const r = await apiCall('/audit-images', 'POST', body);
|
|
2854
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2855
|
+
|
|
2856
|
+
const mode = r.dry_run ? 'DRY RUN' : 'APPLIED';
|
|
2857
|
+
let msg = `=== IMAGE FIX (${mode}) ===\nPages scanned: ${r.pages_scanned}\nPages fixed: ${r.pages_fixed}\nTotal fixes: ${r.total_fixes}\n`;
|
|
2858
|
+
|
|
2859
|
+
if (r.total_fixes === 0) {
|
|
2860
|
+
msg += '\nNo fixes needed — all images have alt text!';
|
|
2861
|
+
} else {
|
|
2862
|
+
r.results.forEach(page => {
|
|
2863
|
+
msg += `\n--- ${page.title} (ID: ${page.page_id}) — ${page.fix_count} fixes ---\n`;
|
|
2864
|
+
page.fixes.forEach(fix => {
|
|
2865
|
+
msg += ` [${fix.widget}] ${fix.url}\n ${fix.action}\n`;
|
|
2866
|
+
});
|
|
2867
|
+
});
|
|
2868
|
+
if (r.dry_run) msg += '\n(Dry run — no changes saved. Set dry_run=false to apply.)';
|
|
2869
|
+
}
|
|
2870
|
+
return ok(msg);
|
|
2871
|
+
} else {
|
|
2872
|
+
// GET mode: audit only
|
|
2873
|
+
const params = new URLSearchParams();
|
|
2874
|
+
if (args.page_id) params.set('page_id', args.page_id);
|
|
2875
|
+
if (args.deep) params.set('deep', '1');
|
|
2876
|
+
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
2877
|
+
const r = await apiCall(`/audit-images${qs}`);
|
|
2878
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2879
|
+
|
|
2880
|
+
let msg = `=== IMAGE AUDIT${args.deep ? ' (DEEP)' : ''} ===\nPages scanned: ${r.pages_scanned}\nPages with issues: ${r.pages_with_issues}\nTotal issues: ${r.total_issues}\n`;
|
|
2881
|
+
|
|
2882
|
+
if (r.image_summary) {
|
|
2883
|
+
const s = r.image_summary;
|
|
2884
|
+
msg += `\n--- Image Summary ---\nTotal images: ${s.total_images}\nTotal size: ${s.total_size_kb} KB\nNon-WebP: ${s.non_webp_count}\nFormats: ${Object.entries(s.formats).map(([k,v]) => `${k}=${v}`).join(', ')}\n`;
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
if (r.total_issues === 0) {
|
|
2888
|
+
msg += '\nAll images pass checks!';
|
|
2889
|
+
} else {
|
|
2890
|
+
r.results.forEach(page => {
|
|
2891
|
+
msg += `\n--- ${page.title} (ID: ${page.page_id}) — ${page.issue_count} issues ---\n`;
|
|
2892
|
+
page.issues.forEach(issue => {
|
|
2893
|
+
if (issue.issue === 'non_webp_format') {
|
|
2894
|
+
msg += ` [${issue.widget}] non-WebP (${issue.format}): ${issue.url}\n`;
|
|
2895
|
+
} else if (issue.issue === 'large_file') {
|
|
2896
|
+
msg += ` [${issue.widget}] large file (${issue.size_kb} KB): ${issue.url}\n`;
|
|
2897
|
+
} else if (issue.issue === 'oversized_dimensions') {
|
|
2898
|
+
msg += ` [${issue.widget}] oversized (${issue.width}x${issue.height}): ${issue.url}\n`;
|
|
2899
|
+
} else {
|
|
2900
|
+
msg += ` [${issue.widget}] ${issue.issue}: ${issue.url || '(empty URL)'}\n`;
|
|
2901
|
+
if (issue.suggested_alt) msg += ` Suggested alt: "${issue.suggested_alt}"\n`;
|
|
2902
|
+
}
|
|
2903
|
+
});
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2906
|
+
return ok(msg);
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
case 'find_replace': {
|
|
2911
|
+
const body = {
|
|
2912
|
+
search: stripCDATA(args.search),
|
|
2913
|
+
replace: stripCDATA(args.replace),
|
|
2914
|
+
regex: parseBool(args.regex, false),
|
|
2915
|
+
dry_run: parseBool(args.dry_run, true), // default TRUE (safe)
|
|
2916
|
+
};
|
|
2917
|
+
if (args.page_id) body.page_id = args.page_id;
|
|
2918
|
+
const r = await apiCall('/find-replace', 'POST', body);
|
|
2919
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2920
|
+
|
|
2921
|
+
const mode = r.dry_run ? 'DRY RUN' : 'APPLIED';
|
|
2922
|
+
let msg = `=== FIND & REPLACE (${mode}) ===\n`;
|
|
2923
|
+
msg += `Search: "${r.search}" → Replace: "${r.replace}"${r.regex ? ' (regex)' : ''}\n`;
|
|
2924
|
+
msg += `Pages scanned: ${r.pages_scanned} | Pages changed: ${r.pages_changed} | Total replacements: ${r.total_changes}\n`;
|
|
2925
|
+
|
|
2926
|
+
if (r.total_changes === 0) {
|
|
2927
|
+
msg += '\nNo matches found.';
|
|
2928
|
+
} else {
|
|
2929
|
+
r.results.forEach(page => {
|
|
2930
|
+
msg += `\n--- ${page.title} (ID: ${page.page_id}) — ${page.change_count} changes ---\n`;
|
|
2931
|
+
page.changes.forEach(c => {
|
|
2932
|
+
msg += ` [${c.widget}] ${c.field}: "${c.old}" → "${c.new}"\n`;
|
|
2933
|
+
});
|
|
2934
|
+
});
|
|
2935
|
+
if (r.dry_run) msg += '\n(Dry run — no changes saved. Set dry_run=false to apply.)';
|
|
2936
|
+
}
|
|
2937
|
+
return ok(msg);
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
case 'copy_element': {
|
|
2941
|
+
const r = await apiCall('/copy-element', 'POST', {
|
|
2942
|
+
source_page_id: args.source_page_id,
|
|
2943
|
+
element_id: args.element_id,
|
|
2944
|
+
target_page_id: args.target_page_id,
|
|
2945
|
+
position: args.position || 'append',
|
|
2946
|
+
});
|
|
2947
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2948
|
+
let msg = `Element copied!\n`;
|
|
2949
|
+
msg += `Source: page ${r.source_page_id}, element ${r.original_id} (${r.element_type}`;
|
|
2950
|
+
if (r.widget_type) msg += ` / ${r.widget_type}`;
|
|
2951
|
+
msg += `)\n`;
|
|
2952
|
+
msg += `Target: page ${r.target_page_id}, new ID: ${r.new_id}\n`;
|
|
2953
|
+
msg += `Position: ${r.position}\n`;
|
|
2954
|
+
msg += `Target now has ${r.target_sections} sections`;
|
|
2955
|
+
return ok(msg);
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
case 'check_links': {
|
|
2959
|
+
const params = new URLSearchParams();
|
|
2960
|
+
if (args.page_id) params.set('page_id', args.page_id);
|
|
2961
|
+
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
2962
|
+
const r = await apiCall(`/check-links${qs}`);
|
|
2963
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
2964
|
+
|
|
2965
|
+
let msg = `=== BROKEN LINK CHECK ===\n`;
|
|
2966
|
+
msg += `URLs checked: ${r.urls_checked} | Broken: ${r.broken_count} | Pages scanned: ${r.pages_scanned}\n`;
|
|
2967
|
+
|
|
2968
|
+
if (r.broken_count === 0) {
|
|
2969
|
+
msg += '\nAll links are working!';
|
|
2970
|
+
} else {
|
|
2971
|
+
r.broken_links.forEach(link => {
|
|
2972
|
+
msg += `\n${link.status_code || 'ERR'} ${link.url}\n`;
|
|
2973
|
+
msg += ` Status: ${link.message}\n`;
|
|
2974
|
+
msg += ` Found on:\n`;
|
|
2975
|
+
link.found_on.forEach(loc => {
|
|
2976
|
+
msg += ` - "${loc.page_title}" (ID: ${loc.page_id}), [${loc.widget}] ${loc.field}\n`;
|
|
2977
|
+
});
|
|
2978
|
+
});
|
|
2979
|
+
}
|
|
2980
|
+
return ok(msg);
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
// ── Posts CRUD ──
|
|
2984
|
+
|
|
2985
|
+
case 'list_posts': {
|
|
2986
|
+
const params = new URLSearchParams();
|
|
2987
|
+
if (args.post_type) params.set('post_type', args.post_type);
|
|
2988
|
+
if (args.status) params.set('status', args.status);
|
|
2989
|
+
if (args.search) params.set('search', args.search);
|
|
2990
|
+
if (args.per_page) params.set('per_page', args.per_page);
|
|
2991
|
+
if (args.page) params.set('page', args.page);
|
|
2992
|
+
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
2993
|
+
const r = await apiCall(`/posts${qs}`);
|
|
2994
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
2995
|
+
let out = `${r.total} posts found (page ${r.page}/${r.pages})\n${'─'.repeat(50)}\n`;
|
|
2996
|
+
r.posts.forEach(p => {
|
|
2997
|
+
out += `[${p.id}] ${p.title}\n`;
|
|
2998
|
+
out += ` Type: ${p.type} | Status: ${p.status} | Editor: ${p.editor}\n`;
|
|
2999
|
+
out += ` Modified: ${p.modified} | Author: ${p.author}\n`;
|
|
3000
|
+
if (p.featured_image) out += ` Image: ${p.featured_image}\n`;
|
|
3001
|
+
out += ` URL: ${p.url}\n\n`;
|
|
3002
|
+
});
|
|
3003
|
+
return ok(out);
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
case 'get_post': {
|
|
3007
|
+
const r = await apiCall(`/posts/${args.post_id}`);
|
|
3008
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Not found'}`);
|
|
3009
|
+
let out = `=== ${r.title} ===\n`;
|
|
3010
|
+
out += `ID: ${r.id} | Type: ${r.type} | Status: ${r.status} | Editor: ${r.editor}\n`;
|
|
3011
|
+
out += `Slug: ${r.slug}\nURL: ${r.url}\n`;
|
|
3012
|
+
out += `Author: ${r.author} | Created: ${r.date} | Modified: ${r.modified}\n`;
|
|
3013
|
+
if (r.elementor_sections) out += `Elementor sections: ${r.elementor_sections}\n`;
|
|
3014
|
+
if (r.featured_image) out += `Featured image: ${r.featured_image.url} (alt: "${r.featured_image.alt || ''}")\n`;
|
|
3015
|
+
if (r.excerpt) out += `\nExcerpt: ${r.excerpt}\n`;
|
|
3016
|
+
if (r.taxonomies && Object.keys(r.taxonomies).length) {
|
|
3017
|
+
out += '\n--- TAXONOMIES ---\n';
|
|
3018
|
+
for (const [tax, terms] of Object.entries(r.taxonomies)) {
|
|
3019
|
+
out += ` ${tax}: ${terms.map(t => `${t.name} (${t.id})`).join(', ')}\n`;
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
if (r.seo) {
|
|
3023
|
+
out += '\n--- SEO (RankMath) ---\n';
|
|
3024
|
+
for (const [k, v] of Object.entries(r.seo)) {
|
|
3025
|
+
out += ` ${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}\n`;
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
if (r.content) {
|
|
3029
|
+
const preview = r.content.length > 500 ? r.content.slice(0, 500) + '...' : r.content;
|
|
3030
|
+
out += `\n--- CONTENT (${r.content_length} chars) ---\n${preview}\n`;
|
|
3031
|
+
}
|
|
3032
|
+
return ok(out);
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
case 'create_post': {
|
|
3036
|
+
const body = { title: args.title };
|
|
3037
|
+
if (args.post_type) body.post_type = args.post_type;
|
|
3038
|
+
if (args.content) body.content = args.content;
|
|
3039
|
+
if (args.excerpt) body.excerpt = args.excerpt;
|
|
3040
|
+
if (args.status) body.status = args.status;
|
|
3041
|
+
if (args.slug) body.slug = args.slug;
|
|
3042
|
+
if (args.taxonomies) body.taxonomies = args.taxonomies;
|
|
3043
|
+
if (args.featured_image_id) body.featured_image_id = args.featured_image_id;
|
|
3044
|
+
if (args.elementor) body.elementor = args.elementor;
|
|
3045
|
+
if (args.seo) body.seo = args.seo;
|
|
3046
|
+
const r = await apiCall('/posts', 'POST', body);
|
|
3047
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3048
|
+
return ok(`Post created!\nID: ${r.id} | Type: ${r.type} | Status: ${r.status}\nTitle: ${r.title}\nSlug: ${r.slug}\nURL: ${r.url}\nEdit: ${r.edit_url}`);
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
case 'update_post': {
|
|
3052
|
+
const { post_id, ...fields } = args;
|
|
3053
|
+
const r = await apiCall(`/posts/${post_id}`, 'PUT', fields);
|
|
3054
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3055
|
+
return ok(`Post updated!\nID: ${r.id} | Status: ${r.status}\nTitle: ${r.title}\nUpdated: ${r.updated_fields.join(', ')}\nURL: ${r.url}`);
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
case 'delete_post': {
|
|
3059
|
+
const qs = args.force ? '?force=true' : '';
|
|
3060
|
+
const r = await apiCall(`/posts/${args.post_id}${qs}`, 'DELETE');
|
|
3061
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3062
|
+
return ok(`${r.action === 'permanently_deleted' ? 'PERMANENTLY DELETED' : 'Trashed'}: [${r.id}] ${r.title}`);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
// ── RankMath SEO ──
|
|
3066
|
+
|
|
3067
|
+
case 'get_seo': {
|
|
3068
|
+
const r = await apiCall(`/posts/${args.post_id}/seo`);
|
|
3069
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Not found'}`);
|
|
3070
|
+
let out = `=== SEO: ${r.title} ===\nID: ${r.id} | URL: ${r.url}\n\n`;
|
|
3071
|
+
if (!r.seo) {
|
|
3072
|
+
out += 'No RankMath SEO data found for this post.';
|
|
3073
|
+
} else {
|
|
3074
|
+
for (const [k, v] of Object.entries(r.seo)) {
|
|
3075
|
+
out += ` ${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}\n`;
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
return ok(out);
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
case 'update_seo': {
|
|
3082
|
+
const { post_id, ...seoFields } = args;
|
|
3083
|
+
const r = await apiCall(`/posts/${post_id}/seo`, 'POST', seoFields);
|
|
3084
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3085
|
+
let out = `SEO updated for post ${r.id}!\nFields changed: ${r.updated_fields.join(', ')}\n\n--- Current SEO ---\n`;
|
|
3086
|
+
if (r.seo) {
|
|
3087
|
+
for (const [k, v] of Object.entries(r.seo)) {
|
|
3088
|
+
out += ` ${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}\n`;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
return ok(out);
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
case 'audit_seo': {
|
|
3095
|
+
const params = new URLSearchParams();
|
|
3096
|
+
if (args.post_type) params.set('post_type', args.post_type);
|
|
3097
|
+
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
3098
|
+
const r = await apiCall(`/audit-seo${qs}`);
|
|
3099
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3100
|
+
let out = `=== SEO AUDIT ===\n`;
|
|
3101
|
+
out += `Total published: ${r.summary.total_posts}\n`;
|
|
3102
|
+
out += `Missing SEO title: ${r.summary.missing_seo_title}\n`;
|
|
3103
|
+
out += `Missing description: ${r.summary.missing_description}\n`;
|
|
3104
|
+
out += `Missing focus keyword: ${r.summary.missing_focus_kw}\n`;
|
|
3105
|
+
out += `Low score (<50): ${r.summary.low_score}\n`;
|
|
3106
|
+
out += `Noindex pages: ${r.summary.noindex_pages}\n`;
|
|
3107
|
+
if (r.issues.length) {
|
|
3108
|
+
out += `\n--- ISSUES (${r.issues.length} posts) ---\n`;
|
|
3109
|
+
r.issues.forEach(p => {
|
|
3110
|
+
out += ` [${p.id}] ${p.title} (${p.type})`;
|
|
3111
|
+
if (p.score) out += ` — score: ${p.score}`;
|
|
3112
|
+
out += `\n Issues: ${p.issues.join(', ')}\n`;
|
|
3113
|
+
});
|
|
3114
|
+
} else {
|
|
3115
|
+
out += '\nNo issues found — all content has complete SEO data!';
|
|
3116
|
+
}
|
|
3117
|
+
return ok(out);
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
// ── Taxonomies ──
|
|
3121
|
+
|
|
3122
|
+
case 'list_taxonomies': {
|
|
3123
|
+
const params = new URLSearchParams();
|
|
3124
|
+
if (args.post_type) params.set('post_type', args.post_type);
|
|
3125
|
+
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
3126
|
+
const r = await apiCall(`/taxonomies${qs}`);
|
|
3127
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3128
|
+
let out = `${r.count} Taxonomies\n${'─'.repeat(40)}\n`;
|
|
3129
|
+
r.taxonomies.forEach(tax => {
|
|
3130
|
+
out += `\n${tax.name} (${tax.slug}) — ${tax.term_count} terms`;
|
|
3131
|
+
out += tax.hierarchical ? ' [hierarchical]' : ' [flat]';
|
|
3132
|
+
out += `\n Post types: ${tax.post_types.join(', ')}\n`;
|
|
3133
|
+
if (tax.terms.length) {
|
|
3134
|
+
tax.terms.forEach(t => {
|
|
3135
|
+
out += ` [${t.id}] ${t.name} (${t.count} posts)${t.parent ? ` ← parent:${t.parent}` : ''}\n`;
|
|
3136
|
+
});
|
|
3137
|
+
}
|
|
3138
|
+
});
|
|
3139
|
+
return ok(out);
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
case 'set_post_terms': {
|
|
3143
|
+
const r = await apiCall(`/posts/${args.post_id}/terms`, 'POST', {
|
|
3144
|
+
taxonomy: args.taxonomy,
|
|
3145
|
+
terms: args.terms,
|
|
3146
|
+
append: args.append || false,
|
|
3147
|
+
});
|
|
3148
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3149
|
+
const termNames = r.terms.map(t => `${t.name} (${t.id})`).join(', ');
|
|
3150
|
+
return ok(`Terms set on post ${r.id}!\nTaxonomy: ${r.taxonomy}\nMode: ${r.append ? 'append' : 'replace'}\nCurrent terms: ${termNames}`);
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
// ── Revisions ──
|
|
3154
|
+
|
|
3155
|
+
case 'list_revisions': {
|
|
3156
|
+
const r = await apiCall(`/posts/${args.post_id}/revisions`);
|
|
3157
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3158
|
+
let out = `Revisions for "${r.post_title}" (${r.post_type} #${r.post_id})\n`;
|
|
3159
|
+
out += `Total: ${r.total} revisions\n\n`;
|
|
3160
|
+
if (r.revisions.length === 0) {
|
|
3161
|
+
out += 'No revisions found.';
|
|
3162
|
+
} else {
|
|
3163
|
+
out += 'ID | Date | Author | Elementor | Sections | Widgets\n';
|
|
3164
|
+
out += '--------|---------------------|-----------------|-----------|----------|--------\n';
|
|
3165
|
+
for (const rev of r.revisions) {
|
|
3166
|
+
const date = rev.date.replace('T', ' ').substring(0, 19);
|
|
3167
|
+
out += `${String(rev.id).padEnd(8)}| ${date} | ${rev.author.padEnd(16)}| ${rev.has_elementor ? 'Yes' : 'No '.padEnd(10)}| ${String(rev.sections).padEnd(9)}| ${rev.widgets}\n`;
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
return ok(out);
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
case 'restore_revision': {
|
|
3174
|
+
const r = await apiCall(`/posts/${args.post_id}/revisions/${args.revision_id}/restore`, 'POST');
|
|
3175
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3176
|
+
return ok(
|
|
3177
|
+
`Revision restored!\n\n` +
|
|
3178
|
+
`Post: #${r.post_id}\n` +
|
|
3179
|
+
`Revision: #${r.revision_id} (${r.revision_date})\n` +
|
|
3180
|
+
`Widgets: ${r.widgets_before} → ${r.widgets_after}\n` +
|
|
3181
|
+
`Sections: ${r.sections_after}`
|
|
3182
|
+
);
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
// ── Elementor Library Templates ──
|
|
3186
|
+
|
|
3187
|
+
case 'list_templates': {
|
|
3188
|
+
const qs = args.type ? `?type=${encodeURIComponent(args.type)}` : '';
|
|
3189
|
+
const r = await apiCall(`/templates${qs}`);
|
|
3190
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3191
|
+
let out = `Elementor Library Templates (${r.total} total)\n\n`;
|
|
3192
|
+
if (r.templates.length === 0) {
|
|
3193
|
+
out += 'No templates found.';
|
|
3194
|
+
} else {
|
|
3195
|
+
out += 'ID | Type | Widgets | Title\n';
|
|
3196
|
+
out += '------|-----------|---------|------\n';
|
|
3197
|
+
for (const t of r.templates) {
|
|
3198
|
+
out += `${String(t.id).padEnd(6)}| ${(t.template_type || '').padEnd(10)}| ${String(t.widgets).padEnd(8)}| ${t.title}\n`;
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
return ok(out);
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
case 'get_template': {
|
|
3205
|
+
const r = await apiCall(`/templates/${args.template_id}`);
|
|
3206
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3207
|
+
let out = `Template: "${r.title}" (#${r.id})\n`;
|
|
3208
|
+
out += `Type: ${r.template_type}\n`;
|
|
3209
|
+
out += `Sections: ${r.sections} | Widgets: ${r.widgets}\n`;
|
|
3210
|
+
if (r.conditions && r.conditions.length) {
|
|
3211
|
+
out += `Conditions: ${JSON.stringify(r.conditions)}\n`;
|
|
3212
|
+
}
|
|
3213
|
+
out += `\n--- Element Tree ---\n`;
|
|
3214
|
+
for (const node of r.tree) {
|
|
3215
|
+
const indent = ' '.repeat(node.depth);
|
|
3216
|
+
let label = `${indent}${node.type}:${node.id}`;
|
|
3217
|
+
if (node.title) label += ` title="${node.title}"`;
|
|
3218
|
+
if (node.text) label += ` text="${node.text}"`;
|
|
3219
|
+
if (node.editor) label += ` editor="${node.editor}"`;
|
|
3220
|
+
if (node.link) label += ` link=${node.link}`;
|
|
3221
|
+
if (node.items) label += ` items=${node.items}`;
|
|
3222
|
+
if (node.menu_items) label += ` menu_items=${node.menu_items}`;
|
|
3223
|
+
if (node.css_classes) label += ` class="${node.css_classes}"`;
|
|
3224
|
+
out += label + '\n';
|
|
3225
|
+
}
|
|
3226
|
+
return ok(out);
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
case 'update_template': {
|
|
3230
|
+
const r = await apiCall(`/templates/${args.template_id}`, 'POST', {
|
|
3231
|
+
elementor_data: args.elementor_data,
|
|
3232
|
+
});
|
|
3233
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
3234
|
+
return ok(
|
|
3235
|
+
`Template updated!\n\n` +
|
|
3236
|
+
`Template: "${r.title}" (#${r.id})\n` +
|
|
3237
|
+
`Widgets: ${r.widgets_before} → ${r.widgets_after}\n` +
|
|
3238
|
+
`Sections: ${r.sections}`
|
|
3239
|
+
);
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
case 'audit_placeholders': {
|
|
3243
|
+
const qs = args.include_templates ? '?include_templates=1' : '';
|
|
3244
|
+
const r = await apiCall(`/audit-placeholders${qs}`);
|
|
3245
|
+
const s = r.summary;
|
|
3246
|
+
let out = `=== PLACEHOLDER AUDIT ===\nScanned: ${s.pages_scanned} | Issues: ${s.pages_with_issues} | Placeholders: ${s.total_placeholders} | Empty sections: ${s.total_empty_sections}\n\n`;
|
|
3247
|
+
if (!r.pages?.length) { out += 'No issues found!'; return ok(out); }
|
|
3248
|
+
for (const p of r.pages) {
|
|
3249
|
+
out += `--- [${p.id}] "${p.title}" (${p.type}) ---\n`;
|
|
3250
|
+
if (p.placeholders?.length) {
|
|
3251
|
+
out += ` Placeholders:\n`;
|
|
3252
|
+
p.placeholders.forEach(ph => { out += ` [${ph.id}] ${ph.widget}: "${ph.text}"\n`; });
|
|
3253
|
+
}
|
|
3254
|
+
if (p.empty_sections?.length) {
|
|
3255
|
+
out += ` Empty sections:\n`;
|
|
3256
|
+
p.empty_sections.forEach(es => { out += ` [${es.id}] ${es.title || '(untitled)'}\n`; });
|
|
3257
|
+
}
|
|
3258
|
+
if (p.broken_anchors?.length) {
|
|
3259
|
+
out += ` Broken anchors:\n`;
|
|
3260
|
+
p.broken_anchors.forEach(ba => { out += ` "${ba.text}" -> ${ba.url || '(empty)'}\n`; });
|
|
3261
|
+
}
|
|
3262
|
+
out += '\n';
|
|
3263
|
+
}
|
|
3264
|
+
return ok(out);
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
case 'audit_ctas': {
|
|
3268
|
+
const params = new URLSearchParams();
|
|
3269
|
+
if (args.min_buttons) params.set('min_buttons', args.min_buttons);
|
|
3270
|
+
if (args.include_templates) params.set('include_templates', '1');
|
|
3271
|
+
const qs = params.toString() ? `?${params}` : '';
|
|
3272
|
+
const r = await apiCall(`/audit-ctas${qs}`);
|
|
3273
|
+
const s = r.summary;
|
|
3274
|
+
let out = `=== CTA AUDIT ===\nScanned: ${s.pages_scanned} | Total buttons: ${s.total_buttons} | Below minimum (${s.min_threshold}): ${s.pages_below_min}\n\n`;
|
|
3275
|
+
for (const p of r.pages) {
|
|
3276
|
+
const flag = p.issues?.length ? '⚠' : '✓';
|
|
3277
|
+
out += `${flag} [${p.id}] "${p.title}" — ${p.button_count} buttons, ${p.sections} sections\n`;
|
|
3278
|
+
if (p.issues?.length) {
|
|
3279
|
+
p.issues.forEach(issue => { out += ` ⚠ ${issue}\n`; });
|
|
3280
|
+
}
|
|
3281
|
+
if (p.buttons?.length) {
|
|
3282
|
+
p.buttons.forEach(b => { out += ` "${b.text}" -> ${b.url || '(no url)'}${b.css_class ? ` .${b.css_class}` : ''}\n`; });
|
|
3283
|
+
}
|
|
3284
|
+
out += '\n';
|
|
3285
|
+
}
|
|
3286
|
+
return ok(out);
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
case 'audit_clickable': {
|
|
3290
|
+
const params = new URLSearchParams();
|
|
3291
|
+
if (args.target_id) params.set('page_id', args.target_id);
|
|
3292
|
+
if (parseBool(args.include_templates)) params.set('include_templates', '1');
|
|
3293
|
+
const qs = params.toString() ? `?${params}` : '';
|
|
3294
|
+
const r = await apiCall(`/audit-clickable${qs}`);
|
|
3295
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
3296
|
+
let out = `=== CLICKABLE CONTENT AUDIT ===\nPages scanned: ${r.pages_scanned} | Pages with issues: ${r.pages_with_issues} | Total issues: ${r.total_issues}\n\n`;
|
|
3297
|
+
if (r.results) {
|
|
3298
|
+
for (const p of r.results) {
|
|
3299
|
+
out += `── [${p.id}] "${p.title}" — ${p.issues.length} issue(s) ──\n`;
|
|
3300
|
+
out += ` ${p.url}\n`;
|
|
3301
|
+
for (const issue of p.issues) {
|
|
3302
|
+
out += ` ${issue.type.toUpperCase()}: "${issue.value}" in ${issue.widget} [${issue.element_id}]\n`;
|
|
3303
|
+
if (issue.fix) out += ` Fix: ${issue.fix}\n`;
|
|
3304
|
+
}
|
|
3305
|
+
out += '\n';
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
if (r.total_issues === 0) out += 'No unlinked phone numbers, emails, or URLs found.\n';
|
|
3309
|
+
return ok(out);
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
// ── Element Operations (v3.6.0) ──
|
|
3313
|
+
|
|
3314
|
+
case 'add_element': {
|
|
3315
|
+
const r = await apiCall(`/pages/${args.page_id}/add-element`, 'POST', {
|
|
3316
|
+
parent_id: args.parent_id,
|
|
3317
|
+
element: args.element,
|
|
3318
|
+
position: args.position
|
|
3319
|
+
});
|
|
3320
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
3321
|
+
return ok(`Element added!\nPage: ${r.page_id}\nParent: ${r.parent_id}\nNew element ID: ${r.element_id}${r.position !== null && r.position !== undefined ? `\nPosition: ${r.position}` : ' (appended)'}`);
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
case 'get_element': {
|
|
3325
|
+
const r = await apiCall(`/pages/${args.page_id}/get-element`, 'POST', {
|
|
3326
|
+
element_id: args.element_id
|
|
3327
|
+
});
|
|
3328
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
3329
|
+
let out = `=== Element: ${r.id} ===\nType: ${r.elType}`;
|
|
3330
|
+
if (r.widgetType) out += ` (${r.widgetType})`;
|
|
3331
|
+
out += '\n';
|
|
3332
|
+
if (r.children_count !== undefined) {
|
|
3333
|
+
out += `Children: ${r.children_count}\n`;
|
|
3334
|
+
if (r.children?.length) {
|
|
3335
|
+
r.children.forEach(c => {
|
|
3336
|
+
out += ` - ${c.id}: ${c.elType}${c.widgetType ? '/' + c.widgetType : ''}${c.title ? ' "' + c.title + '"' : ''}\n`;
|
|
3337
|
+
});
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
out += `\nSettings:\n${JSON.stringify(r.settings, null, 2)}`;
|
|
3341
|
+
return ok(out);
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
case 'batch_update_elements': {
|
|
3345
|
+
// Resolve updates: prefer updates_json (string) for reliable MCP transport
|
|
3346
|
+
let updates = [];
|
|
3347
|
+
if (args.updates_json) {
|
|
3348
|
+
try { updates = JSON.parse(stripCDATA(args.updates_json)); } catch (e) {
|
|
3349
|
+
return ok(`Failed: updates_json is not valid JSON: ${e.message}`);
|
|
3350
|
+
}
|
|
3351
|
+
} else if (args.updates && Array.isArray(args.updates) && args.updates.length > 0) {
|
|
3352
|
+
updates = args.updates;
|
|
3353
|
+
} else if (args.updates && typeof args.updates === 'string') {
|
|
3354
|
+
try { updates = JSON.parse(stripCDATA(args.updates)); } catch (e) {
|
|
3355
|
+
return ok(`Failed: updates string is not valid JSON: ${e.message}`);
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
if (!Array.isArray(updates) || updates.length === 0) {
|
|
3359
|
+
return ok(`Failed: No updates provided. Use updates_json (preferred) or updates parameter.\nReceived args keys: ${Object.keys(args).join(', ')}`);
|
|
3360
|
+
}
|
|
3361
|
+
// Strip CDATA from all settings values and ensure proper structure
|
|
3362
|
+
const cleanUpdates = updates.map(u => {
|
|
3363
|
+
let settings = u.settings || {};
|
|
3364
|
+
if (typeof settings === 'string') {
|
|
3365
|
+
try { settings = JSON.parse(settings); } catch (e) { settings = {}; }
|
|
3366
|
+
}
|
|
3367
|
+
return {
|
|
3368
|
+
page_id: u.page_id,
|
|
3369
|
+
element_id: u.element_id,
|
|
3370
|
+
settings: deepStripCDATA(settings)
|
|
3371
|
+
};
|
|
3372
|
+
});
|
|
3373
|
+
const r = await apiCall('/batch-update-elements', 'POST', {
|
|
3374
|
+
updates: cleanUpdates
|
|
3375
|
+
});
|
|
3376
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
3377
|
+
let out = `=== Batch Update ===\nTotal: ${r.total} | Succeeded: ${r.succeeded} | Failed: ${r.failed}\n`;
|
|
3378
|
+
if (r.failed > 0) {
|
|
3379
|
+
out += '\nFailed:\n';
|
|
3380
|
+
r.results.filter(x => !x.success).forEach(x => {
|
|
3381
|
+
out += ` [${x.index}] ${x.error}\n`;
|
|
3382
|
+
});
|
|
3383
|
+
}
|
|
3384
|
+
return ok(out);
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
case 'manage_redirects': {
|
|
3388
|
+
const r = await apiCall('/add-redirects', 'POST', {
|
|
3389
|
+
action: args.action,
|
|
3390
|
+
redirects: args.redirects,
|
|
3391
|
+
ids: args.ids
|
|
3392
|
+
});
|
|
3393
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
3394
|
+
|
|
3395
|
+
if (args.action === 'create') {
|
|
3396
|
+
let out = `=== Redirects Created ===\n`;
|
|
3397
|
+
if (r.results) {
|
|
3398
|
+
r.results.forEach(rr => {
|
|
3399
|
+
out += rr.success
|
|
3400
|
+
? ` [OK] ${rr.source} -> ${rr.destination} (${rr.code})\n`
|
|
3401
|
+
: ` [ERR] ${rr.source}: ${rr.error}\n`;
|
|
3402
|
+
});
|
|
3403
|
+
}
|
|
3404
|
+
out += `\nCreated: ${r.created || 0}`;
|
|
3405
|
+
return ok(out);
|
|
3406
|
+
} else if (args.action === 'list') {
|
|
3407
|
+
let out = `=== RankMath Redirects (${r.total || 0}) ===\n`;
|
|
3408
|
+
if (r.redirects?.length) {
|
|
3409
|
+
r.redirects.forEach(rr => {
|
|
3410
|
+
out += ` [${rr.id}] ${rr.source} -> ${rr.destination} (${rr.code}) ${rr.status}\n`;
|
|
3411
|
+
});
|
|
3412
|
+
}
|
|
3413
|
+
return ok(out);
|
|
3414
|
+
} else if (args.action === 'delete') {
|
|
3415
|
+
return ok(`Deleted ${r.deleted || 0} redirect(s).`);
|
|
3416
|
+
}
|
|
3417
|
+
return ok(JSON.stringify(r, null, 2));
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
// ── Performance Audit (v3.7.0) ──
|
|
3421
|
+
|
|
3422
|
+
case 'audit_page_weight': {
|
|
3423
|
+
const params = new URLSearchParams();
|
|
3424
|
+
if (args.page_id) params.set('page_id', args.page_id);
|
|
3425
|
+
if (args.max_elements) params.set('max_elements', args.max_elements);
|
|
3426
|
+
const qs = params.toString() ? `?${params}` : '';
|
|
3427
|
+
const r = await apiCall(`/audit-page-weight${qs}`);
|
|
3428
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
3429
|
+
|
|
3430
|
+
const s = r.summary;
|
|
3431
|
+
let out = `=== PAGE WEIGHT AUDIT ===\nPages scanned: ${s.pages_scanned}\nHeavy pages (>${s.threshold} elements): ${s.heavy_pages}\nTotal elements: ${s.total_elements}\nTotal images: ${s.total_images}\nAvg elements/page: ${s.avg_elements}\n`;
|
|
3432
|
+
|
|
3433
|
+
for (const p of r.pages) {
|
|
3434
|
+
const flag = p.issues?.length ? '!!' : 'OK';
|
|
3435
|
+
out += `\n[${flag}] "${p.title}" (ID: ${p.page_id})\n`;
|
|
3436
|
+
out += ` Elements: ${p.total_elements} (${p.containers} containers, ${p.widgets} widgets)\n`;
|
|
3437
|
+
out += ` Images: ${p.images} | Videos: ${p.videos} | Max depth: ${p.max_depth}\n`;
|
|
3438
|
+
out += ` Est. DOM: ${p.estimated_dom} | JSON: ${p.json_size_kb} KB\n`;
|
|
3439
|
+
if (Object.keys(p.widget_types).length) {
|
|
3440
|
+
const top5 = Object.entries(p.widget_types).slice(0, 5).map(([k,v]) => `${k}(${v})`).join(', ');
|
|
3441
|
+
out += ` Top widgets: ${top5}\n`;
|
|
3442
|
+
}
|
|
3443
|
+
if (p.issues?.length) {
|
|
3444
|
+
p.issues.forEach(i => { out += ` ⚠ ${i}\n`; });
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
return ok(out);
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
case 'audit_page_speed': {
|
|
3451
|
+
// Resolve URL: if page_id given, get permalink from WP
|
|
3452
|
+
let testUrl = args.url;
|
|
3453
|
+
if (!testUrl && args.page_id) {
|
|
3454
|
+
const post = await apiCall(`/posts/${args.page_id}`);
|
|
3455
|
+
testUrl = post?.url;
|
|
3456
|
+
}
|
|
3457
|
+
if (!testUrl) return ok('Failed: Provide url or page_id parameter');
|
|
3458
|
+
|
|
3459
|
+
const strategy = args.strategy || 'mobile';
|
|
3460
|
+
|
|
3461
|
+
// Call PageSpeed API directly from Node.js (avoids WP hosting timeout)
|
|
3462
|
+
const psiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?` +
|
|
3463
|
+
`url=${encodeURIComponent(testUrl)}&strategy=${strategy}&category=performance`;
|
|
3464
|
+
|
|
3465
|
+
console.error(`[PSI] Fetching ${strategy} score for ${testUrl}`);
|
|
3466
|
+
const controller = new AbortController();
|
|
3467
|
+
const timeout = setTimeout(() => controller.abort(), 90000);
|
|
3468
|
+
|
|
3469
|
+
try {
|
|
3470
|
+
const resp = await fetch(psiUrl, { signal: controller.signal });
|
|
3471
|
+
clearTimeout(timeout);
|
|
3472
|
+
if (!resp.ok) {
|
|
3473
|
+
const err = await resp.json().catch(() => ({}));
|
|
3474
|
+
return ok(`PageSpeed API error ${resp.status}: ${err?.error?.message || 'Unknown'}`);
|
|
3475
|
+
}
|
|
3476
|
+
const body = await resp.json();
|
|
3477
|
+
|
|
3478
|
+
const lighthouse = body.lighthouseResult || {};
|
|
3479
|
+
const audits = lighthouse.audits || {};
|
|
3480
|
+
const categories = lighthouse.categories || {};
|
|
3481
|
+
const score = categories.performance?.score != null ? Math.round(categories.performance.score * 100) : null;
|
|
3482
|
+
|
|
3483
|
+
let out = `=== PAGESPEED INSIGHTS ===\nURL: ${testUrl}\nStrategy: ${strategy}\nScore: ${score}/100\n`;
|
|
3484
|
+
|
|
3485
|
+
// Core Web Vitals
|
|
3486
|
+
const metricKeys = {
|
|
3487
|
+
'first-contentful-paint': 'FCP',
|
|
3488
|
+
'largest-contentful-paint': 'LCP',
|
|
3489
|
+
'total-blocking-time': 'TBT',
|
|
3490
|
+
'cumulative-layout-shift': 'CLS',
|
|
3491
|
+
'speed-index': 'SI',
|
|
3492
|
+
'interactive': 'TTI',
|
|
3493
|
+
};
|
|
3494
|
+
out += '\n--- Core Web Vitals ---\n';
|
|
3495
|
+
for (const [key, label] of Object.entries(metricKeys)) {
|
|
3496
|
+
if (audits[key]) {
|
|
3497
|
+
const a = audits[key];
|
|
3498
|
+
const s = a.score != null ? Math.round(a.score * 100) : null;
|
|
3499
|
+
const icon = s >= 90 ? 'GOOD' : s >= 50 ? 'NEEDS WORK' : 'POOR';
|
|
3500
|
+
out += ` ${label}: ${a.displayValue || 'N/A'} (${icon})\n`;
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
// Opportunities
|
|
3505
|
+
const oppKeys = [
|
|
3506
|
+
'render-blocking-resources', 'unused-css-rules', 'unused-javascript',
|
|
3507
|
+
'modern-image-formats', 'uses-optimized-images', 'uses-responsive-images',
|
|
3508
|
+
'offscreen-images', 'unminified-css', 'unminified-javascript',
|
|
3509
|
+
'uses-text-compression', 'uses-long-cache-ttl', 'dom-size',
|
|
3510
|
+
'redirects', 'server-response-time',
|
|
3511
|
+
];
|
|
3512
|
+
const opps = [];
|
|
3513
|
+
for (const key of oppKeys) {
|
|
3514
|
+
if (audits[key] && (audits[key].score ?? 1) < 1) {
|
|
3515
|
+
const a = audits[key];
|
|
3516
|
+
const opp = { title: a.title || key, value: a.displayValue || '' };
|
|
3517
|
+
if (a.details?.overallSavingsMs) opp.savings_ms = Math.round(a.details.overallSavingsMs);
|
|
3518
|
+
if (a.details?.overallSavingsBytes) opp.savings_kb = Math.round(a.details.overallSavingsBytes / 1024);
|
|
3519
|
+
opps.push(opp);
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
opps.sort((a, b) => (b.savings_ms || 0) - (a.savings_ms || 0));
|
|
3523
|
+
|
|
3524
|
+
if (opps.length) {
|
|
3525
|
+
out += '\n--- Opportunities ---\n';
|
|
3526
|
+
opps.forEach(o => {
|
|
3527
|
+
out += ` ${o.title}`;
|
|
3528
|
+
if (o.savings_ms) out += ` — save ~${o.savings_ms}ms`;
|
|
3529
|
+
if (o.savings_kb) out += ` — save ~${o.savings_kb} KB`;
|
|
3530
|
+
out += '\n';
|
|
3531
|
+
});
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
out += `\nFetched: ${new Date().toISOString()}`;
|
|
3535
|
+
return ok(out);
|
|
3536
|
+
} catch (e) {
|
|
3537
|
+
clearTimeout(timeout);
|
|
3538
|
+
return ok(`PageSpeed API failed: ${e.message}`);
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
case 'copy_section': {
|
|
3543
|
+
const r = await apiCall('/copy-section', 'POST', {
|
|
3544
|
+
source_id: args.source_id,
|
|
3545
|
+
section_index: args.section_index,
|
|
3546
|
+
target_id: args.target_id,
|
|
3547
|
+
position: args.position || 'end',
|
|
3548
|
+
});
|
|
3549
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
3550
|
+
let msg = `Section copied!\n`;
|
|
3551
|
+
msg += `Source: ID ${r.source_id}, section index ${r.section_index}\n`;
|
|
3552
|
+
msg += `Target: ID ${r.target_id}, new section ID: ${r.new_section_id}\n`;
|
|
3553
|
+
msg += `Elements copied: ${r.elements_copied}\n`;
|
|
3554
|
+
msg += `Target now has ${r.target_sections} sections\n`;
|
|
3555
|
+
if (r.section_css_class) msg += `CSS class: ${r.section_css_class}\n`;
|
|
3556
|
+
if (r.section_title) msg += `Title: ${r.section_title}`;
|
|
3557
|
+
return ok(msg);
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
case 'append_section': {
|
|
3561
|
+
const body = {
|
|
3562
|
+
target_id: args.target_id,
|
|
3563
|
+
section: args.section,
|
|
3564
|
+
position: args.position || 'end',
|
|
3565
|
+
};
|
|
3566
|
+
if (args.regenerate_ids !== undefined) body.regenerate_ids = args.regenerate_ids;
|
|
3567
|
+
const r = await apiCall('/append-section', 'POST', body);
|
|
3568
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
3569
|
+
let msg = `Section appended!\n`;
|
|
3570
|
+
msg += `Target: ID ${r.target_id}\n`;
|
|
3571
|
+
msg += `New section ID: ${r.new_section_id}\n`;
|
|
3572
|
+
msg += `Elements added: ${r.elements_added}\n`;
|
|
3573
|
+
msg += `Target now has ${r.target_sections} sections\n`;
|
|
3574
|
+
if (r.section_css_class) msg += `CSS class: ${r.section_css_class}\n`;
|
|
3575
|
+
if (r.section_title) msg += `Title: ${r.section_title}`;
|
|
3576
|
+
return ok(msg);
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
// ── Template Library Tools ──
|
|
3580
|
+
|
|
3581
|
+
case 'template_list_kits': {
|
|
3582
|
+
const catalog = getTemplateCatalog();
|
|
3583
|
+
if (!catalog) return ok('Template library not configured.\nSet TEMPLATE_LIBRARY_DIR env var to the path containing section-catalog.json and the templates/ folder.');
|
|
3584
|
+
let msg = `Template Library: ${catalog.stats.total_kits} kits, ${catalog.stats.total_pages} pages, ${catalog.stats.total_sections} sections\n\n`;
|
|
3585
|
+
for (const [kitId, kit] of Object.entries(catalog.kits)) {
|
|
3586
|
+
msg += `── ${kit.title} ──\n`;
|
|
3587
|
+
msg += ` ID: ${kitId}\n`;
|
|
3588
|
+
msg += ` Pages: ${kit.pages.length} | Sections: ${kit.section_count}\n`;
|
|
3589
|
+
if (kit.design_tokens) {
|
|
3590
|
+
const colors = (kit.design_tokens.colors || []).map(c => `${c.title || c._id}: ${c.color}`).join(', ');
|
|
3591
|
+
const fonts = (kit.design_tokens.typography || []).map(t => t.typography_font_family).filter(Boolean).join(', ');
|
|
3592
|
+
if (colors) msg += ` Colors: ${colors}\n`;
|
|
3593
|
+
if (fonts) msg += ` Fonts: ${[...new Set(fonts.split(', '))].join(', ')}\n`;
|
|
3594
|
+
}
|
|
3595
|
+
msg += ` Pages: ${kit.pages.map(p => p.name).join(', ')}\n\n`;
|
|
3596
|
+
}
|
|
3597
|
+
msg += `Section types: ${Object.entries(catalog.stats.sections_by_type).sort((a,b) => b[1]-a[1]).map(([t,c]) => `${t}(${c})`).join(', ')}`;
|
|
3598
|
+
return ok(msg);
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
case 'template_browse_sections': {
|
|
3602
|
+
const catalog = getTemplateCatalog();
|
|
3603
|
+
if (!catalog) return ok('Template library not configured. Set TEMPLATE_LIBRARY_DIR env var.');
|
|
3604
|
+
let sections = catalog.sections;
|
|
3605
|
+
if (args.kit_id) sections = sections.filter(s => s.kit_id === args.kit_id);
|
|
3606
|
+
if (args.type) sections = sections.filter(s => s.type === args.type);
|
|
3607
|
+
if (args.page_name) {
|
|
3608
|
+
const q = args.page_name.toLowerCase();
|
|
3609
|
+
sections = sections.filter(s => s.page_name.toLowerCase().includes(q));
|
|
3610
|
+
}
|
|
3611
|
+
const limit = args.limit || 20;
|
|
3612
|
+
const total = sections.length;
|
|
3613
|
+
sections = sections.slice(0, limit);
|
|
3614
|
+
let msg = `Found ${total} sections`;
|
|
3615
|
+
if (total > limit) msg += ` (showing first ${limit})`;
|
|
3616
|
+
msg += '\n\n';
|
|
3617
|
+
for (const s of sections) {
|
|
3618
|
+
msg += `── ${s.id} ──\n`;
|
|
3619
|
+
msg += ` Kit: ${s.kit_title}\n`;
|
|
3620
|
+
msg += ` Page: ${s.page_name} [section #${s.section_index}]\n`;
|
|
3621
|
+
msg += ` Type: ${s.type} (confidence: ${s.confidence})`;
|
|
3622
|
+
if (s.secondary_type) msg += ` | also: ${s.secondary_type}`;
|
|
3623
|
+
msg += '\n';
|
|
3624
|
+
const widgetSummary = Object.entries(s.features.widgets).map(([w,c]) => `${w}:${c}`).join(', ');
|
|
3625
|
+
msg += ` Widgets: ${widgetSummary || '(none)'}\n`;
|
|
3626
|
+
msg += ` Elements: ${s.features.total_elements} | Columns: ${s.features.column_count} | Depth: ${s.features.max_depth}\n`;
|
|
3627
|
+
if (s.features.background_color) msg += ` Background: ${s.features.background_color}\n`;
|
|
3628
|
+
if (s.features.text_preview.length > 0) {
|
|
3629
|
+
msg += ` Preview: ${s.features.text_preview[0]}\n`;
|
|
3630
|
+
}
|
|
3631
|
+
msg += '\n';
|
|
3632
|
+
}
|
|
3633
|
+
return ok(msg);
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
case 'template_get_section': {
|
|
3637
|
+
const catalog = getTemplateCatalog();
|
|
3638
|
+
if (!catalog) return ok('Template library not configured. Set TEMPLATE_LIBRARY_DIR env var.');
|
|
3639
|
+
const parts = args.section_id.split('/');
|
|
3640
|
+
if (parts.length !== 3) return ok('Invalid section_id format. Expected: kit-id/page-slug/section-index');
|
|
3641
|
+
const [kitId, pageSlug, indexStr] = parts;
|
|
3642
|
+
const sectionIndex = parseInt(indexStr);
|
|
3643
|
+
// Find catalog entry for metadata
|
|
3644
|
+
const catalogEntry = catalog.sections.find(s => s.id === args.section_id);
|
|
3645
|
+
if (!catalogEntry) return ok(`Section not found in catalog: ${args.section_id}`);
|
|
3646
|
+
// Load the actual section JSON
|
|
3647
|
+
const section = loadTemplateSection(kitId, pageSlug, sectionIndex);
|
|
3648
|
+
if (!section) return ok(`Could not load section JSON for: ${args.section_id}`);
|
|
3649
|
+
let msg = `Section: ${args.section_id}\n`;
|
|
3650
|
+
msg += `Type: ${catalogEntry.type} | Page: ${catalogEntry.page_name}\n`;
|
|
3651
|
+
msg += `Elements: ${catalogEntry.features.total_elements}\n\n`;
|
|
3652
|
+
msg += `--- SECTION JSON ---\n`;
|
|
3653
|
+
msg += JSON.stringify(section, null, 2);
|
|
3654
|
+
return ok(msg);
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
case 'template_map_content': {
|
|
3658
|
+
const catalog = getTemplateCatalog();
|
|
3659
|
+
if (!catalog) return ok('Template library not configured. Set TEMPLATE_LIBRARY_DIR env var.');
|
|
3660
|
+
const blocks = parseContentBlocks(args.content);
|
|
3661
|
+
if (blocks.length === 0) return ok('No content blocks found. Use ## headings to separate content blocks.');
|
|
3662
|
+
const mapping = mapContentToSections(blocks, catalog, args.kit_id || null);
|
|
3663
|
+
let msg = `Content Analysis: ${blocks.length} blocks detected\n`;
|
|
3664
|
+
if (args.kit_id) msg += `Preferred kit: ${args.kit_id}\n`;
|
|
3665
|
+
msg += '\n';
|
|
3666
|
+
for (const m of mapping) {
|
|
3667
|
+
msg += `═══ Block ${m.block_index}: "${m.heading || '(intro)'}"\n`;
|
|
3668
|
+
msg += ` Detected type: ${m.detected_type} (confidence: ${m.confidence})\n`;
|
|
3669
|
+
if (m.alternatives.length > 0) {
|
|
3670
|
+
msg += ` Also possible: ${m.alternatives.map(a => `${a.type}(${a.score})`).join(', ')}\n`;
|
|
3671
|
+
}
|
|
3672
|
+
msg += ` Content: ${m.content_summary.items} items, ${m.content_summary.paragraphs} paragraphs`;
|
|
3673
|
+
if (m.content_summary.stats > 0) msg += `, ${m.content_summary.stats} stats`;
|
|
3674
|
+
if (m.content_summary.questions > 0) msg += `, ${m.content_summary.questions} questions`;
|
|
3675
|
+
if (m.content_summary.has_cta) msg += `, has CTA`;
|
|
3676
|
+
msg += '\n';
|
|
3677
|
+
if (m.recommended_sections.length > 0) {
|
|
3678
|
+
msg += ` Recommended sections:\n`;
|
|
3679
|
+
for (let i = 0; i < m.recommended_sections.length; i++) {
|
|
3680
|
+
const r = m.recommended_sections[i];
|
|
3681
|
+
msg += ` ${i + 1}. ${r.id}\n`;
|
|
3682
|
+
msg += ` Kit: ${r.kit} | Page: ${r.page} | ${r.elements} elements, ${r.columns} cols\n`;
|
|
3683
|
+
if (r.preview) msg += ` Preview: ${r.preview.substring(0, 70)}\n`;
|
|
3684
|
+
}
|
|
3685
|
+
} else {
|
|
3686
|
+
msg += ` No matching sections found in catalog.\n`;
|
|
3687
|
+
}
|
|
3688
|
+
msg += '\n';
|
|
3689
|
+
}
|
|
3690
|
+
// Summary table
|
|
3691
|
+
msg += `═══ MAPPING SUMMARY ═══\n`;
|
|
3692
|
+
msg += `Block | Type | Section ID\n`;
|
|
3693
|
+
msg += `──────┼──────┼──────────\n`;
|
|
3694
|
+
for (const m of mapping) {
|
|
3695
|
+
const secId = m.recommended_sections[0]?.id || '(none)';
|
|
3696
|
+
msg += `${String(m.block_index).padEnd(6)}| ${m.detected_type.padEnd(18)} | ${secId}\n`;
|
|
3697
|
+
}
|
|
3698
|
+
return ok(msg);
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
case 'template_get_tokens': {
|
|
3702
|
+
const tokens = loadDesignTokens(args.kit_id);
|
|
3703
|
+
if (!tokens) return ok(`Could not load design tokens for kit: ${args.kit_id}`);
|
|
3704
|
+
let msg = `Design Tokens: ${args.kit_id}\n\n`;
|
|
3705
|
+
msg += `── System Colors ──\n`;
|
|
3706
|
+
for (const c of tokens.system_colors || []) {
|
|
3707
|
+
msg += ` ${c._id} (${c.title || ''}): ${c.color}\n`;
|
|
3708
|
+
}
|
|
3709
|
+
msg += `\n── System Typography ──\n`;
|
|
3710
|
+
for (const t of tokens.system_typography || []) {
|
|
3711
|
+
msg += ` ${t._id} (${t.title || ''}): ${t.typography_font_family || ''} ${t.typography_font_weight || ''} ${t.typography_font_size?.size || ''}${t.typography_font_size?.unit || ''}\n`;
|
|
3712
|
+
}
|
|
3713
|
+
msg += `\n── Button Styles ──\n`;
|
|
3714
|
+
for (const [k, v] of Object.entries(tokens)) {
|
|
3715
|
+
if (k.startsWith('button_')) {
|
|
3716
|
+
const val = typeof v === 'object' ? JSON.stringify(v) : v;
|
|
3717
|
+
msg += ` ${k}: ${val}\n`;
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
// Also show custom colors/typography if present
|
|
3721
|
+
if (tokens.custom_colors?.length) {
|
|
3722
|
+
msg += `\n── Custom Colors (${tokens.custom_colors.length}) ──\n`;
|
|
3723
|
+
for (const c of tokens.custom_colors) {
|
|
3724
|
+
msg += ` ${c._id} (${c.title || ''}): ${c.color}\n`;
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
return ok(msg);
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
case 'template_rebrand': {
|
|
3731
|
+
// Load the section JSON
|
|
3732
|
+
let section = args.section_json || null;
|
|
3733
|
+
if (!section && args.section_id) {
|
|
3734
|
+
const parts = args.section_id.split('/');
|
|
3735
|
+
if (parts.length !== 3) return ok('Invalid section_id format. Expected: kit-id/page-slug/section-index');
|
|
3736
|
+
const [kitId, pageSlug, indexStr] = parts;
|
|
3737
|
+
section = loadTemplateSection(kitId, pageSlug, parseInt(indexStr, 10));
|
|
3738
|
+
if (!section) return ok(`Could not load section: ${args.section_id}`);
|
|
3739
|
+
}
|
|
3740
|
+
if (!section) return ok('Provide either section_id or section_json.');
|
|
3741
|
+
|
|
3742
|
+
// Load source kit tokens if auto_map_from_kit is provided
|
|
3743
|
+
const sourceKitId = args.auto_map_from_kit || (args.section_id ? args.section_id.split('/')[0] : null);
|
|
3744
|
+
const sourceTokens = sourceKitId ? loadDesignTokens(sourceKitId) : null;
|
|
3745
|
+
|
|
3746
|
+
// Build color map
|
|
3747
|
+
const colorMap = buildColorMap(sourceTokens || { system_colors: [], custom_colors: [] }, args.colors || {});
|
|
3748
|
+
|
|
3749
|
+
// Build font map (direct key→value, no normalization needed)
|
|
3750
|
+
const fontMap = args.fonts || {};
|
|
3751
|
+
|
|
3752
|
+
// Build globals map (direct key→value)
|
|
3753
|
+
const globalsMap = args.globals || {};
|
|
3754
|
+
|
|
3755
|
+
// Build globals resolver from source kit tokens
|
|
3756
|
+
const globalsResolver = buildGlobalsResolver(sourceTokens);
|
|
3757
|
+
|
|
3758
|
+
// Apply rebrand
|
|
3759
|
+
const rebranded = rebrandSection(section, colorMap, fontMap, globalsMap, globalsResolver);
|
|
3760
|
+
|
|
3761
|
+
// Count replacements for summary
|
|
3762
|
+
const origStr = JSON.stringify(section);
|
|
3763
|
+
const rebStr = JSON.stringify(rebranded);
|
|
3764
|
+
let colorChanges = 0;
|
|
3765
|
+
for (const src of Object.keys(colorMap)) {
|
|
3766
|
+
const regex = new RegExp(src.replace('#', '#'), 'gi');
|
|
3767
|
+
const origMatches = origStr.match(regex) || [];
|
|
3768
|
+
const rebMatches = rebStr.match(regex) || [];
|
|
3769
|
+
colorChanges += origMatches.length - rebMatches.length;
|
|
3770
|
+
}
|
|
3771
|
+
let fontChanges = 0;
|
|
3772
|
+
for (const src of Object.keys(fontMap)) {
|
|
3773
|
+
const regex = new RegExp(src.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
3774
|
+
const origMatches = origStr.match(regex) || [];
|
|
3775
|
+
const rebMatches = rebStr.match(regex) || [];
|
|
3776
|
+
fontChanges += origMatches.length - rebMatches.length;
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
// Count globals resolved (compare __globals__ refs before and after)
|
|
3780
|
+
const origGlobals = (origStr.match(/"__globals__"/g) || []).length;
|
|
3781
|
+
const rebGlobals = (rebStr.match(/globals\/colors\?id=|globals\/typography\?id=/g) || []).length;
|
|
3782
|
+
const origGlobalRefs = (origStr.match(/globals\/colors\?id=|globals\/typography\?id=/g) || []).length;
|
|
3783
|
+
const globalsResolved = origGlobalRefs - rebGlobals;
|
|
3784
|
+
|
|
3785
|
+
let msg = `Rebranded section successfully.\n\n`;
|
|
3786
|
+
msg += `── Replacements ──\n`;
|
|
3787
|
+
msg += ` Color mappings applied: ${Object.keys(colorMap).length}\n`;
|
|
3788
|
+
msg += ` Font mappings applied: ${Object.keys(fontMap).length}\n`;
|
|
3789
|
+
msg += ` Globals resolved to hardcoded: ${globalsResolved}\n`;
|
|
3790
|
+
msg += ` Globals remaining (unresolved): ${rebGlobals}\n`;
|
|
3791
|
+
msg += ` Estimated color replacements: ~${Math.abs(colorChanges)}\n`;
|
|
3792
|
+
msg += ` Estimated font replacements: ~${Math.abs(fontChanges)}\n`;
|
|
3793
|
+
msg += ` Output size: ${rebStr.length} chars\n\n`;
|
|
3794
|
+
msg += `── Color Map ──\n`;
|
|
3795
|
+
for (const [src, tgt] of Object.entries(colorMap)) {
|
|
3796
|
+
msg += ` ${src} → ${tgt}\n`;
|
|
3797
|
+
}
|
|
3798
|
+
if (Object.keys(fontMap).length) {
|
|
3799
|
+
msg += `\n── Font Map ──\n`;
|
|
3800
|
+
for (const [src, tgt] of Object.entries(fontMap)) {
|
|
3801
|
+
msg += ` ${src} → ${tgt}\n`;
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
return {
|
|
3806
|
+
content: [
|
|
3807
|
+
{ type: 'text', text: msg },
|
|
3808
|
+
{ type: 'text', text: JSON.stringify(rebranded) }
|
|
3809
|
+
]
|
|
3810
|
+
};
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
case 'template_inject_content': {
|
|
3814
|
+
// Load section
|
|
3815
|
+
let section = args.section_json || null;
|
|
3816
|
+
if (!section && args.section_id) {
|
|
3817
|
+
const parts = args.section_id.split('/');
|
|
3818
|
+
if (parts.length !== 3) return ok('Invalid section_id format. Expected: kit-id/page-slug/section-index');
|
|
3819
|
+
const [kitId, pageSlug, indexStr] = parts;
|
|
3820
|
+
section = loadTemplateSection(kitId, pageSlug, parseInt(indexStr, 10));
|
|
3821
|
+
if (!section) return ok(`Could not load section: ${args.section_id}`);
|
|
3822
|
+
}
|
|
3823
|
+
if (!section) return ok('Provide either section_id or section_json.');
|
|
3824
|
+
|
|
3825
|
+
const content = args.content || {};
|
|
3826
|
+
const { section: injected, changes } = injectContent(section, content);
|
|
3827
|
+
|
|
3828
|
+
// Summarize what was injected
|
|
3829
|
+
const slots = extractContentSlots(section);
|
|
3830
|
+
let msg = `Content injected: ${changes} changes applied.\n\n`;
|
|
3831
|
+
msg += `── Section Slots ──\n`;
|
|
3832
|
+
const slotCounts = {};
|
|
3833
|
+
for (const s of slots) { slotCounts[s.type] = (slotCounts[s.type] || 0) + 1; }
|
|
3834
|
+
for (const [t, c] of Object.entries(slotCounts)) { msg += ` ${t}: ${c}\n`; }
|
|
3835
|
+
msg += `\n── Content Provided ──\n`;
|
|
3836
|
+
if (content.heading) msg += ` Heading: "${content.heading}"\n`;
|
|
3837
|
+
if (content.subheading) msg += ` Subheading: "${content.subheading}"\n`;
|
|
3838
|
+
if (content.paragraphs?.length) msg += ` Paragraphs: ${content.paragraphs.length}\n`;
|
|
3839
|
+
if (content.items?.length) msg += ` Items: ${content.items.length}\n`;
|
|
3840
|
+
if (content.stats?.length) msg += ` Stats: ${content.stats.length}\n`;
|
|
3841
|
+
if (content.buttons?.length) msg += ` Buttons: ${content.buttons.length}\n`;
|
|
3842
|
+
if (content.quotes?.length) msg += ` Quotes: ${content.quotes.length}\n`;
|
|
3843
|
+
if (content.faq?.length) msg += ` FAQ: ${content.faq.length}\n`;
|
|
3844
|
+
|
|
3845
|
+
return {
|
|
3846
|
+
content: [
|
|
3847
|
+
{ type: 'text', text: msg },
|
|
3848
|
+
{ type: 'text', text: JSON.stringify(injected) }
|
|
3849
|
+
]
|
|
3850
|
+
};
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
case 'template_assemble_page': {
|
|
3854
|
+
const catalog = getTemplateCatalog();
|
|
3855
|
+
if (!catalog) return ok('Template library not configured. Set TEMPLATE_LIBRARY_DIR env var.');
|
|
3856
|
+
|
|
3857
|
+
const kitId = args.kit_id;
|
|
3858
|
+
const sourceTokens = loadDesignTokens(kitId);
|
|
3859
|
+
|
|
3860
|
+
// Step 1: Parse content into blocks
|
|
3861
|
+
const blocks = parseContentBlocks(args.content);
|
|
3862
|
+
if (blocks.length === 0) return ok('No content blocks found. Use ## headings to separate sections.');
|
|
3863
|
+
|
|
3864
|
+
// Step 2: Map content to template sections
|
|
3865
|
+
const mapping = mapContentToSections(blocks, catalog, kitId);
|
|
3866
|
+
|
|
3867
|
+
// Step 3: Build color/font maps
|
|
3868
|
+
const colorMap = buildColorMap(sourceTokens || { system_colors: [], custom_colors: [] }, args.colors || {});
|
|
3869
|
+
const fontMap = args.fonts || {};
|
|
3870
|
+
const globalsMap = args.globals || {};
|
|
3871
|
+
const globalsResolver = buildGlobalsResolver(sourceTokens);
|
|
3872
|
+
|
|
3873
|
+
// Step 4: Create the page
|
|
3874
|
+
let pageId;
|
|
3875
|
+
try {
|
|
3876
|
+
const createBody = {
|
|
3877
|
+
title: args.title,
|
|
3878
|
+
status: args.status || 'draft',
|
|
3879
|
+
};
|
|
3880
|
+
if (args.slug) createBody.slug = args.slug;
|
|
3881
|
+
const pageResult = await apiCall('/pages', 'POST', createBody);
|
|
3882
|
+
pageId = pageResult.page_id || pageResult.id;
|
|
3883
|
+
if (!pageId) return ok(`Failed to create page: ${JSON.stringify(pageResult)}`);
|
|
3884
|
+
} catch (err) {
|
|
3885
|
+
return ok(`Failed to create page: ${err.message}`);
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
// Reset icon replacement counter for each new page
|
|
3889
|
+
_iconReplacementCounter = 0;
|
|
3890
|
+
|
|
3891
|
+
// Step 5: For each mapped block, load → rebrand → inject → append
|
|
3892
|
+
const results = [];
|
|
3893
|
+
let totalElements = 0;
|
|
3894
|
+
|
|
3895
|
+
for (const m of mapping) {
|
|
3896
|
+
const bestSection = m.recommended_sections[0];
|
|
3897
|
+
if (!bestSection) {
|
|
3898
|
+
results.push({ block: m.block_index, heading: m.heading, status: 'skipped', reason: 'No matching template section' });
|
|
3899
|
+
continue;
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
// Load section JSON
|
|
3903
|
+
const parts = bestSection.id.split('/');
|
|
3904
|
+
const sectionJson = loadTemplateSection(parts[0], parts[1], parseInt(parts[2], 10));
|
|
3905
|
+
if (!sectionJson) {
|
|
3906
|
+
results.push({ block: m.block_index, heading: m.heading, status: 'skipped', reason: `Could not load section: ${bestSection.id}` });
|
|
3907
|
+
continue;
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
// Build per-section globals resolver from the section's actual source kit
|
|
3911
|
+
const sectionKitId = parts[0];
|
|
3912
|
+
const sectionTokens = sectionKitId === kitId ? sourceTokens : loadDesignTokens(sectionKitId);
|
|
3913
|
+
const sectionGlobalsResolver = buildGlobalsResolver(sectionTokens);
|
|
3914
|
+
|
|
3915
|
+
// Build color map: start with user overrides, then auto-map source kit system colors
|
|
3916
|
+
const sectionColorMap = buildColorMap(sectionTokens || { system_colors: [], custom_colors: [] }, args.colors || {});
|
|
3917
|
+
// Auto-map cross-kit system colors → user's brand targets
|
|
3918
|
+
if (sectionKitId !== kitId && sectionTokens) {
|
|
3919
|
+
const userTargets = Object.values(args.colors || {}).map(normalizeHex).filter(Boolean);
|
|
3920
|
+
const srcSystemColors = (sectionTokens.system_colors || [])
|
|
3921
|
+
.filter(c => c._id && c.color && c._id !== 'text')
|
|
3922
|
+
.map(c => normalizeHex(c.color)).filter(Boolean);
|
|
3923
|
+
for (let ci = 0; ci < srcSystemColors.length && ci < userTargets.length; ci++) {
|
|
3924
|
+
if (!sectionColorMap[srcSystemColors[ci]]) {
|
|
3925
|
+
sectionColorMap[srcSystemColors[ci]] = userTargets[ci];
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
// Also map custom colors (kit accent/highlight colors)
|
|
3929
|
+
const srcCustomColors = (sectionTokens.custom_colors || [])
|
|
3930
|
+
.map(c => normalizeHex(c.color)).filter(Boolean);
|
|
3931
|
+
for (let ci = 0; ci < srcCustomColors.length; ci++) {
|
|
3932
|
+
const targetIdx = Math.min(ci, userTargets.length - 1);
|
|
3933
|
+
if (targetIdx >= 0 && !sectionColorMap[srcCustomColors[ci]]) {
|
|
3934
|
+
sectionColorMap[srcCustomColors[ci]] = userTargets[targetIdx];
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
// Rebrand
|
|
3940
|
+
const rebranded = rebrandSection(sectionJson, sectionColorMap, fontMap, globalsMap, sectionGlobalsResolver);
|
|
3941
|
+
|
|
3942
|
+
// Structure content for injection
|
|
3943
|
+
const block = blocks[m.block_index];
|
|
3944
|
+
const structured = structureContentBlock(block);
|
|
3945
|
+
|
|
3946
|
+
// Debug: log structured content for FAQ blocks
|
|
3947
|
+
if (m.detected_type === 'faq') {
|
|
3948
|
+
console.error(`[FAQ-DEBUG] Block ${m.block_index} structured: faq=${structured.faq.length}, items=${structured.items.length}, buttons=${structured.buttons.length}`);
|
|
3949
|
+
if (structured.faq.length > 0) console.error(`[FAQ-DEBUG] First Q: "${structured.faq[0].question}"`);
|
|
3950
|
+
if (structured.items.length > 0) console.error(`[FAQ-DEBUG] First item: "${structured.items[0].title}"`);
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3953
|
+
// Inject content
|
|
3954
|
+
const { section: final, changes } = injectContent(rebranded, structured);
|
|
3955
|
+
if (m.detected_type === 'faq') {
|
|
3956
|
+
console.error(`[FAQ-DEBUG] Injection changes: ${changes}`);
|
|
3957
|
+
// Check if accordion items were modified
|
|
3958
|
+
const accWidgets = [];
|
|
3959
|
+
function findAccordions(el) {
|
|
3960
|
+
if (el.widgetType === 'nested-accordion') accWidgets.push(el);
|
|
3961
|
+
if (el.elements) el.elements.forEach(findAccordions);
|
|
3962
|
+
}
|
|
3963
|
+
findAccordions(final);
|
|
3964
|
+
for (const aw of accWidgets) {
|
|
3965
|
+
console.error(`[FAQ-DEBUG] Accordion items: ${(aw.settings.items||[]).map(i=>i.item_title).join(' | ')}`);
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
|
|
3969
|
+
// Append to page
|
|
3970
|
+
try {
|
|
3971
|
+
const appendResult = await apiCall('/append-section', 'POST', {
|
|
3972
|
+
target_id: pageId,
|
|
3973
|
+
section: final,
|
|
3974
|
+
position: 'end',
|
|
3975
|
+
regenerate_ids: true,
|
|
3976
|
+
});
|
|
3977
|
+
totalElements += appendResult.elements_added || 0;
|
|
3978
|
+
results.push({
|
|
3979
|
+
block: m.block_index,
|
|
3980
|
+
heading: m.heading || '(intro)',
|
|
3981
|
+
type: m.detected_type,
|
|
3982
|
+
section_id: bestSection.id,
|
|
3983
|
+
status: 'appended',
|
|
3984
|
+
elements: appendResult.elements_added || 0,
|
|
3985
|
+
});
|
|
3986
|
+
} catch (err) {
|
|
3987
|
+
results.push({
|
|
3988
|
+
block: m.block_index,
|
|
3989
|
+
heading: m.heading || '(intro)',
|
|
3990
|
+
status: 'failed',
|
|
3991
|
+
reason: err.message,
|
|
3992
|
+
});
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3996
|
+
// Build summary
|
|
3997
|
+
let msg = `Page assembled: "${args.title}"\n`;
|
|
3998
|
+
msg += `Page ID: ${pageId}\n`;
|
|
3999
|
+
msg += `Sections: ${results.filter(r => r.status === 'appended').length} / ${mapping.length}\n`;
|
|
4000
|
+
msg += `Total elements: ${totalElements}\n`;
|
|
4001
|
+
msg += `Status: ${args.status || 'draft'}\n\n`;
|
|
4002
|
+
|
|
4003
|
+
msg += `── Section Results ──\n`;
|
|
4004
|
+
for (const r of results) {
|
|
4005
|
+
if (r.status === 'appended') {
|
|
4006
|
+
msg += ` ✓ Block ${r.block} "${r.heading}" → ${r.type} → ${r.section_id} (${r.elements} elements)\n`;
|
|
4007
|
+
} else if (r.status === 'skipped') {
|
|
4008
|
+
msg += ` ○ Block ${r.block} "${r.heading}" → SKIPPED: ${r.reason}\n`;
|
|
4009
|
+
} else {
|
|
4010
|
+
msg += ` ✗ Block ${r.block} "${r.heading}" → FAILED: ${r.reason}\n`;
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
if (Object.keys(colorMap).length) {
|
|
4015
|
+
msg += `\n── Rebrand Applied ──\n`;
|
|
4016
|
+
msg += ` Colors: ${Object.keys(colorMap).length} mappings\n`;
|
|
4017
|
+
msg += ` Fonts: ${Object.keys(fontMap).length} mappings\n`;
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
// Get page URLs
|
|
4021
|
+
try {
|
|
4022
|
+
const pageInfo = await apiCall(`/pages`);
|
|
4023
|
+
const page = (pageInfo.pages || pageInfo || []).find(p => p.id === pageId);
|
|
4024
|
+
if (page) {
|
|
4025
|
+
msg += `\nPreview: ${page.url || ''}\n`;
|
|
4026
|
+
msg += `Edit: ${page.edit_url || ''}\n`;
|
|
4027
|
+
}
|
|
4028
|
+
} catch (e) {
|
|
4029
|
+
msg += `\nPage ID: ${pageId} (check WordPress admin)\n`;
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
return ok(msg);
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
case 'get_shortcode_status': {
|
|
4036
|
+
const r = await apiCall('/settings/shortcodes');
|
|
4037
|
+
let msg = `Shortcodes: ${r.enabled ? 'ENABLED' : 'DISABLED'}\n\n`;
|
|
4038
|
+
if (r.enabled) {
|
|
4039
|
+
msg += 'Available shortcodes:\n';
|
|
4040
|
+
for (const [code, desc] of Object.entries(r.shortcodes)) {
|
|
4041
|
+
msg += ` [${code}] — ${desc}\n`;
|
|
4042
|
+
}
|
|
4043
|
+
} else {
|
|
4044
|
+
msg += 'Shortcodes are disabled. To enable them, use toggle_shortcodes or go to Settings > Vision Builder Control > Features.\n\n';
|
|
4045
|
+
msg += 'When enabled, these shortcodes solve Elementor rendering limitations:\n';
|
|
4046
|
+
for (const [code, desc] of Object.entries(r.shortcodes)) {
|
|
4047
|
+
msg += ` [${code}] — ${desc}\n`;
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
return ok(msg);
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
case 'toggle_shortcodes': {
|
|
4054
|
+
const r = await apiCall('/settings/shortcodes', 'POST', { enabled: args.enabled });
|
|
4055
|
+
return ok(r.message);
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
case 'get_site_profile': {
|
|
4059
|
+
const r = await apiCall('/site-profile');
|
|
4060
|
+
let msg = '=== SITE PROFILE ===\n\n';
|
|
4061
|
+
for (const [key, value] of Object.entries(r)) {
|
|
4062
|
+
if (value !== '' && value !== null) {
|
|
4063
|
+
msg += `${key}: ${value}\n`;
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
const empty = Object.entries(r).filter(([k, v]) => v === '' || v === null).map(([k]) => k);
|
|
4067
|
+
if (empty.length > 0) {
|
|
4068
|
+
msg += `\nUnset keys (${empty.length}): ${empty.join(', ')}`;
|
|
4069
|
+
}
|
|
4070
|
+
return ok(msg);
|
|
4071
|
+
}
|
|
4072
|
+
|
|
4073
|
+
case 'update_site_profile': {
|
|
4074
|
+
const r = await apiCall('/site-profile', 'POST', args.profile);
|
|
4075
|
+
let msg = '';
|
|
4076
|
+
if (r.updated.length > 0) {
|
|
4077
|
+
msg += `Updated: ${r.updated.join(', ')}\n`;
|
|
4078
|
+
}
|
|
4079
|
+
if (r.rejected.length > 0) {
|
|
4080
|
+
msg += `Rejected (unknown keys): ${r.rejected.join(', ')}\n`;
|
|
4081
|
+
}
|
|
4082
|
+
return ok(msg || 'No changes made.');
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
case 'list_changes': {
|
|
4086
|
+
const params = new URLSearchParams();
|
|
4087
|
+
if (args.post_id) params.set('post_id', args.post_id);
|
|
4088
|
+
if (args.source) params.set('source', args.source);
|
|
4089
|
+
params.set('limit', args.limit || 20);
|
|
4090
|
+
|
|
4091
|
+
const r = await apiCall(`/changelog?${params}`);
|
|
4092
|
+
let msg = `=== CHANGELOG (${r.total} total) ===\n\n`;
|
|
4093
|
+
|
|
4094
|
+
if (r.entries.length === 0) {
|
|
4095
|
+
msg += 'No changes recorded yet.';
|
|
4096
|
+
} else {
|
|
4097
|
+
for (const e of r.entries) {
|
|
4098
|
+
msg += `#${e.id} | ${e.created_at} | ${e.source.toUpperCase()}\n`;
|
|
4099
|
+
msg += ` Page: ${e.post_title || 'N/A'} (${e.post_id})\n`;
|
|
4100
|
+
msg += ` Action: ${e.action}\n`;
|
|
4101
|
+
msg += ` Summary: ${e.summary}\n`;
|
|
4102
|
+
msg += ` User: ${e.user_login} | IP: ${e.ip_address}\n\n`;
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
return ok(msg);
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
case 'rollback_change': {
|
|
4109
|
+
const id = args.changelog_id;
|
|
4110
|
+
const preview = args.preview || false;
|
|
4111
|
+
|
|
4112
|
+
if (preview) {
|
|
4113
|
+
const r = await apiCall(`/changelog/${id}/rollback`);
|
|
4114
|
+
let msg = `=== ROLLBACK PREVIEW #${r.id} ===\n\n`;
|
|
4115
|
+
msg += `Action: ${r.action}\n`;
|
|
4116
|
+
msg += `Summary: ${r.summary}\n\n`;
|
|
4117
|
+
msg += `Before (would restore to):\n${JSON.stringify(r.before, null, 2)}\n\n`;
|
|
4118
|
+
msg += `After (current state):\n${JSON.stringify(r.after, null, 2)}\n\n`;
|
|
4119
|
+
msg += r.message;
|
|
4120
|
+
return ok(msg);
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
const r = await apiCall(`/changelog/${id}/rollback`, 'POST');
|
|
4124
|
+
return ok(r.message);
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
case 'validate_all_pages': {
|
|
4128
|
+
const verbose = args.verbose ? '&verbose=1' : '';
|
|
4129
|
+
const r = await apiCall(`/validate-all?${verbose}`);
|
|
4130
|
+
let msg = `=== BULK VALIDATION ===\n\n`;
|
|
4131
|
+
msg += `Pages scanned: ${r.pages_scanned}\n`;
|
|
4132
|
+
msg += `Pages valid: ${r.pages_valid}\n`;
|
|
4133
|
+
msg += `Pages with issues: ${r.pages_with_issues}\n`;
|
|
4134
|
+
msg += `Total issues: ${r.total_issues}\n\n`;
|
|
4135
|
+
|
|
4136
|
+
if (r.results && r.results.length > 0) {
|
|
4137
|
+
for (const p of r.results) {
|
|
4138
|
+
const status = p.valid ? '✓' : '✗';
|
|
4139
|
+
msg += `${status} ${p.title} (#${p.page_id}) — ${p.issue_count} issues`;
|
|
4140
|
+
if (p.stats) msg += ` | ${p.stats.sections}s/${p.stats.containers}c/${p.stats.widgets}w`;
|
|
4141
|
+
msg += '\n';
|
|
4142
|
+
if (p.issues && p.issues.length > 0) {
|
|
4143
|
+
for (const issue of p.issues) {
|
|
4144
|
+
msg += ` - ${issue}\n`;
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
4149
|
+
return ok(msg);
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
case 'get_audit_report': {
|
|
4153
|
+
const r = await apiCall('/audit-report');
|
|
4154
|
+
if (r.status === 'no_data') {
|
|
4155
|
+
return ok(r.message);
|
|
4156
|
+
}
|
|
4157
|
+
let msg = `=== WEEKLY AUDIT REPORT ===\n\n`;
|
|
4158
|
+
msg += `Last run: ${r.completed_at || 'Unknown'}\n`;
|
|
4159
|
+
msg += `Duration: ${r.duration_seconds || 0}s\n`;
|
|
4160
|
+
msg += `Audit enabled: ${r.audit_enabled ? 'Yes' : 'No'}\n`;
|
|
4161
|
+
msg += `Scheduled hour: ${r.audit_start_hour || 3}:00\n\n`;
|
|
4162
|
+
|
|
4163
|
+
if (r.summary) {
|
|
4164
|
+
msg += `--- Summary ---\n`;
|
|
4165
|
+
msg += `Pages scanned: ${r.summary.pages_scanned || 0}\n`;
|
|
4166
|
+
msg += `Link issues: ${r.summary.link_issues || 0}\n`;
|
|
4167
|
+
msg += `Image issues: ${r.summary.image_issues || 0}\n`;
|
|
4168
|
+
msg += `SEO issues: ${r.summary.seo_issues || 0}\n`;
|
|
4169
|
+
}
|
|
4170
|
+
return ok(msg);
|
|
4171
|
+
}
|
|
4172
|
+
|
|
4173
|
+
case 'run_audit_now': {
|
|
4174
|
+
const r = await apiCall('/audit/run', 'POST', {});
|
|
4175
|
+
return ok(r.message || 'Audit started. Call get_audit_report in ~60s for results.');
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
default:
|
|
4179
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
|
|
4183
|
+
// ================================================================
|
|
4184
|
+
// MCP SERVER FACTORY
|
|
4185
|
+
// ================================================================
|
|
4186
|
+
|
|
4187
|
+
function createMcpServer() {
|
|
4188
|
+
const server = new Server(
|
|
4189
|
+
{ name: 'vision-builder-control', version: VERSION },
|
|
4190
|
+
{ capabilities: { tools: {} } },
|
|
4191
|
+
);
|
|
4192
|
+
|
|
4193
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
4194
|
+
const tools = getToolDefinitions();
|
|
4195
|
+
console.error(`[MCP] ${tools.length} tools registered`);
|
|
4196
|
+
return { tools };
|
|
4197
|
+
});
|
|
4198
|
+
|
|
4199
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
4200
|
+
const { name, arguments: args } = request.params;
|
|
4201
|
+
try {
|
|
4202
|
+
return await handleToolCall(name, args);
|
|
4203
|
+
} catch (error) {
|
|
4204
|
+
console.error(`[MCP] Error in ${name}: ${error.message}`);
|
|
4205
|
+
return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
|
|
4206
|
+
}
|
|
4207
|
+
});
|
|
4208
|
+
|
|
4209
|
+
return server;
|
|
4210
|
+
}
|
|
4211
|
+
|
|
4212
|
+
// ================================================================
|
|
4213
|
+
// HELPERS
|
|
4214
|
+
// ================================================================
|
|
4215
|
+
|
|
4216
|
+
function ok(msg) { return { content: [{ type: 'text', text: msg }] }; }
|
|
4217
|
+
function rndId() { return [...Array(8)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); }
|
|
4218
|
+
|
|
4219
|
+
// ================================================================
|
|
4220
|
+
// STDIO MODE
|
|
4221
|
+
// ================================================================
|
|
4222
|
+
|
|
4223
|
+
// Module-level references to prevent GC
|
|
4224
|
+
let _server = null;
|
|
4225
|
+
let _transport = null;
|
|
4226
|
+
|
|
4227
|
+
async function startStdio() {
|
|
4228
|
+
// Keepalive FIRST — ensures event loop never empties, even if stdin closes
|
|
4229
|
+
global._mcpKeepAlive = setInterval(() => {}, 30000);
|
|
4230
|
+
|
|
4231
|
+
// Monitor stdin/stdout lifecycle (diagnostic)
|
|
4232
|
+
process.stdin.on('end', () => {
|
|
4233
|
+
console.error('[LIFECYCLE] stdin ended');
|
|
4234
|
+
});
|
|
4235
|
+
process.stdin.on('close', () => {
|
|
4236
|
+
console.error('[LIFECYCLE] stdin closed');
|
|
4237
|
+
});
|
|
4238
|
+
process.stdin.on('error', (err) => {
|
|
4239
|
+
console.error('[LIFECYCLE] stdin error:', err.message);
|
|
4240
|
+
});
|
|
4241
|
+
process.stdout.on('error', (err) => {
|
|
4242
|
+
console.error('[LIFECYCLE] stdout error:', err.message);
|
|
4243
|
+
});
|
|
4244
|
+
|
|
4245
|
+
_server = createMcpServer();
|
|
4246
|
+
_transport = new StdioServerTransport();
|
|
4247
|
+
await _server.connect(_transport);
|
|
4248
|
+
|
|
4249
|
+
console.error(`Vision Builder Control MCP v${VERSION} ready (stdio)`);
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4252
|
+
// ================================================================
|
|
4253
|
+
// HTTP/SSE MODE
|
|
4254
|
+
// ================================================================
|
|
4255
|
+
|
|
4256
|
+
async function startHttp(port) {
|
|
4257
|
+
const { SSEServerTransport } = await import("@modelcontextprotocol/sdk/server/sse.js");
|
|
4258
|
+
const { createServer: createHttpServer } = await import("node:http");
|
|
4259
|
+
const { URL } = await import("node:url");
|
|
4260
|
+
|
|
4261
|
+
const sessions = new Map();
|
|
4262
|
+
|
|
4263
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
4264
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
4265
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
4266
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
4267
|
+
|
|
4268
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
4269
|
+
|
|
4270
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
4271
|
+
|
|
4272
|
+
if (req.method === 'GET' && url.pathname === '/sse') {
|
|
4273
|
+
console.error('[HTTP] New SSE connection');
|
|
4274
|
+
try {
|
|
4275
|
+
const transport = new SSEServerTransport('/message', res);
|
|
4276
|
+
const sessionId = transport.sessionId;
|
|
4277
|
+
const server = createMcpServer();
|
|
4278
|
+
|
|
4279
|
+
const heartbeat = setInterval(() => {
|
|
4280
|
+
if (!res.writableEnded) { res.write(': keepalive\n\n'); }
|
|
4281
|
+
else { clearInterval(heartbeat); }
|
|
4282
|
+
}, HEARTBEAT_MS);
|
|
4283
|
+
|
|
4284
|
+
transport.onclose = () => { clearInterval(heartbeat); sessions.delete(sessionId); };
|
|
4285
|
+
res.on('close', () => { clearInterval(heartbeat); sessions.delete(sessionId); });
|
|
4286
|
+
|
|
4287
|
+
sessions.set(sessionId, { server, transport, heartbeat });
|
|
4288
|
+
await server.connect(transport);
|
|
4289
|
+
console.error(`[HTTP] Session established (${sessions.size} active)`);
|
|
4290
|
+
} catch (err) {
|
|
4291
|
+
console.error('[HTTP] SSE error:', err.message);
|
|
4292
|
+
if (!res.headersSent) { res.writeHead(500); res.end('SSE failed'); }
|
|
4293
|
+
}
|
|
4294
|
+
return;
|
|
4295
|
+
}
|
|
4296
|
+
|
|
4297
|
+
if (req.method === 'POST' && url.pathname === '/message') {
|
|
4298
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
4299
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
4300
|
+
res.writeHead(404); res.end('Session not found'); return;
|
|
4301
|
+
}
|
|
4302
|
+
try {
|
|
4303
|
+
await sessions.get(sessionId).transport.handlePostMessage(req, res);
|
|
4304
|
+
} catch (err) {
|
|
4305
|
+
if (!res.headersSent) { res.writeHead(500); res.end('Message failed'); }
|
|
4306
|
+
}
|
|
4307
|
+
return;
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
if (req.method === 'GET' && (url.pathname === '/health' || url.pathname === '/')) {
|
|
4311
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4312
|
+
res.end(JSON.stringify({
|
|
4313
|
+
status: 'ok', plugin: 'vision-builder-control-mcp', version: VERSION,
|
|
4314
|
+
transport: 'sse', sessions: sessions.size, uptime: Math.round(process.uptime()),
|
|
4315
|
+
}));
|
|
4316
|
+
return;
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4319
|
+
res.writeHead(404); res.end('Not Found');
|
|
4320
|
+
});
|
|
4321
|
+
|
|
4322
|
+
httpServer.listen(port, () => {
|
|
4323
|
+
console.error(`Vision Builder Control MCP v${VERSION} ready (HTTP/SSE)`);
|
|
4324
|
+
console.error(` SSE: http://localhost:${port}/sse`);
|
|
4325
|
+
console.error(` Health: http://localhost:${port}/health`);
|
|
4326
|
+
});
|
|
4327
|
+
|
|
4328
|
+
const shutdown = async () => {
|
|
4329
|
+
for (const [, session] of sessions) {
|
|
4330
|
+
clearInterval(session.heartbeat);
|
|
4331
|
+
try { await session.transport.close(); } catch {}
|
|
4332
|
+
}
|
|
4333
|
+
sessions.clear();
|
|
4334
|
+
httpServer.close();
|
|
4335
|
+
process.exit(0);
|
|
4336
|
+
};
|
|
4337
|
+
|
|
4338
|
+
process.on('SIGINT', shutdown);
|
|
4339
|
+
process.on('SIGTERM', shutdown);
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
// ================================================================
|
|
4343
|
+
// MAIN
|
|
4344
|
+
// ================================================================
|
|
4345
|
+
|
|
4346
|
+
async function main() {
|
|
4347
|
+
if (!CONFIG.username || !CONFIG.applicationPassword) {
|
|
4348
|
+
console.error('Warning: WP_USER and WP_APP_PASSWORD not set');
|
|
4349
|
+
}
|
|
4350
|
+
|
|
4351
|
+
if (httpMode) {
|
|
4352
|
+
await startHttp(PORT);
|
|
4353
|
+
} else {
|
|
4354
|
+
await startStdio();
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
main().catch((err) => {
|
|
4359
|
+
console.error('[FATAL] Startup failed:', err.message);
|
|
4360
|
+
process.exit(1);
|
|
4361
|
+
});
|