@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.
Files changed (3) hide show
  1. package/README.md +34 -0
  2. package/index.js +4361 -0
  3. 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
+ });