@noleemits/vision-builder-control-mcp 4.18.1 → 4.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +563 -75
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -3,8 +3,8 @@
3
3
  * Noleemits Vision Builder Control MCP Server
4
4
  *
5
5
  * Provides 65 tools for building and managing WordPress/Elementor sites.
6
- * v4.15.0: Injection engine v2 — icon-list injection, unfilled slot clearing, plus_tooltip boilerplate fix.
7
- * v4.14.0: Pipeline quality fixes — smart counter parsing, boilerplate post-processing, quality scores in browse, injection completeness.
6
+ * v4.15.0: Injection engine v2 — icon-list injection, unfilled slot clearing.
7
+ * v4.14.0: Pipeline quality fixes — smart counter parsing, post-processing, quality scores in browse, injection completeness.
8
8
  * v4.13.0: Phase 3 DX — display_conditions, null filter fix, garbage HTML cleanup, icon key docs, auto-inject convenience docs.
9
9
  * v4.12.0: Phase 2 tools — move_element, clean_globals, find_replace URL search, set_front_page.
10
10
  * v4.11.0: Phase 1 fixes — token patch semantics, trigger key auto-injection (background/typography), enhanced cache clear, flex-grow/grid convenience.
@@ -104,7 +104,7 @@ process.on('SIGINT', () => {
104
104
  // CONFIG
105
105
  // ================================================================
106
106
 
107
- const VERSION = '4.18.1';
107
+ const VERSION = '4.31.0';
108
108
  const MIN_PLUGIN_VERSION = '4.13.0'; // Minimum WP plugin version required by this MCP server
109
109
 
110
110
  // ================================================================
@@ -944,6 +944,39 @@ function rebrandSection(section, colorMap, fontMap, globalsMap, globalsResolver)
944
944
 
945
945
  // ── Content Injection ──
946
946
 
947
+ // ───── Elementor v4 atomic-widget helpers ─────
948
+ // Atomic widgets (e-heading, e-paragraph, e-button) wrap their content in
949
+ // Html_V3_Prop_Type / Link_Prop_Type envelopes. These helpers read/write
950
+ // values into those envelopes without disturbing v3 (legacy) widgets.
951
+
952
+ function setAtomicHtmlV3(widget, propKey, value) {
953
+ if (!widget.settings) widget.settings = {};
954
+ widget.settings[propKey] = {
955
+ $$type: 'html-v3',
956
+ value: {
957
+ content: { $$type: 'string', value: String(value) },
958
+ children: []
959
+ }
960
+ };
961
+ }
962
+
963
+ function setAtomicLinkUrl(widget, url) {
964
+ if (!widget.settings) widget.settings = {};
965
+ if (!widget.settings.link || widget.settings.link.$$type !== 'link') {
966
+ widget.settings.link = { $$type: 'link', value: {} };
967
+ }
968
+ if (!widget.settings.link.value) widget.settings.link.value = {};
969
+ widget.settings.link.value.destination = { $$type: 'url', value: String(url) };
970
+ }
971
+
972
+ function getAtomicHtmlV3(widget, propKey) {
973
+ return widget?.settings?.[propKey]?.value?.content?.value || '';
974
+ }
975
+
976
+ function getAtomicLinkUrl(widget) {
977
+ return widget?.settings?.link?.value?.destination?.value || '';
978
+ }
979
+
947
980
  /**
948
981
  * Extract an ordered list of content-bearing widgets from a section tree.
949
982
  * Each entry: { widget, path, type, role }
@@ -976,6 +1009,12 @@ function extractContentSlots(element, path = []) {
976
1009
  slots.push({ widget: element, path: [...path], type: 'icon-list', items: s.icon_list || [] });
977
1010
  } else if (wt === 'icon') {
978
1011
  slots.push({ widget: element, path: [...path], type: 'icon', icon: s.selected_icon || {} });
1012
+ } else if (wt === 'e-heading') {
1013
+ slots.push({ widget: element, path: [...path], type: 'heading', text: getAtomicHtmlV3(element, 'title'), atomic: true });
1014
+ } else if (wt === 'e-paragraph') {
1015
+ slots.push({ widget: element, path: [...path], type: 'text', text: getAtomicHtmlV3(element, 'paragraph'), atomic: true });
1016
+ } else if (wt === 'e-button') {
1017
+ slots.push({ widget: element, path: [...path], type: 'button', text: getAtomicHtmlV3(element, 'text'), url: getAtomicLinkUrl(element), atomic: true });
979
1018
  }
980
1019
  }
981
1020
 
@@ -1127,10 +1166,9 @@ function structureContentBlock(block) {
1127
1166
  * Deep clones the section first to avoid mutation.
1128
1167
  */
1129
1168
  /**
1130
- * Add site-specific boilerplate fields to a template section.
1131
- * - jet_parallax_layout_list: [] on all containers
1132
- * - plus_tooltip_content_desc on all widgets
1169
+ * Enforce standard settings on a template section.
1133
1170
  * - layout: full_width on root container
1171
+ * - css_classes / _css_classes duality
1134
1172
  */
1135
1173
  function addBoilerplate(section, isRoot = true) {
1136
1174
  if (!section) return section;
@@ -1138,25 +1176,71 @@ function addBoilerplate(section, isRoot = true) {
1138
1176
  if (!section.settings) section.settings = {};
1139
1177
  section.settings.layout = 'full_width';
1140
1178
  }
1141
- if (section.elType === 'container') {
1142
- if (!section.settings) section.settings = {};
1143
- if (!section.settings.jet_parallax_layout_list) {
1144
- section.settings.jet_parallax_layout_list = [];
1145
- }
1146
- }
1147
- if (section.widgetType) {
1148
- if (!section.settings) section.settings = {};
1149
- if (!section.settings.plus_tooltip_content_desc) {
1150
- // PHP validator uses empty() which treats '' as empty — needs non-empty value
1151
- section.settings.plus_tooltip_content_desc = 'Luctus nec ullamcorper mattis';
1152
- }
1153
- }
1154
1179
  if (Array.isArray(section.elements)) {
1155
1180
  section.elements.forEach(child => addBoilerplate(child, false));
1156
1181
  }
1157
1182
  return section;
1158
1183
  }
1159
1184
 
1185
+ /**
1186
+ * Detect repeating "card" containers — sibling containers where each contains
1187
+ * exactly 1 heading + ≤1 text-editor in its subtree. Many template kits use this
1188
+ * pattern for services/features/pricing grids. Returns the largest card group
1189
+ * found (array of {container, headingWidget, textWidget}).
1190
+ */
1191
+ function findCardPairs(element, excludeWidgets = new Set()) {
1192
+ function countWidgets(el, types) {
1193
+ const counts = {};
1194
+ types.forEach(t => counts[t] = 0);
1195
+ (function walk(e) {
1196
+ if (!e) return;
1197
+ if (e.widgetType && types.includes(e.widgetType) && !excludeWidgets.has(e)) {
1198
+ counts[e.widgetType] = (counts[e.widgetType] || 0) + 1;
1199
+ }
1200
+ if (Array.isArray(e.elements)) e.elements.forEach(walk);
1201
+ })(el);
1202
+ return counts;
1203
+ }
1204
+ function findFirst(el, type) {
1205
+ if (!el) return null;
1206
+ if (el.widgetType === type && !excludeWidgets.has(el)) return el;
1207
+ if (Array.isArray(el.elements)) {
1208
+ for (const child of el.elements) {
1209
+ const found = findFirst(child, type);
1210
+ if (found) return found;
1211
+ }
1212
+ }
1213
+ return null;
1214
+ }
1215
+
1216
+ // Global scan: collect all card-like containers anywhere in the tree
1217
+ const allCards = [];
1218
+ (function walk(el) {
1219
+ if (!el) return;
1220
+ if (el.elType === 'container') {
1221
+ const counts = countWidgets(el, ['heading', 'text-editor', 'icon-box', 'testimonial', 'counter', 'toggle']);
1222
+ // Card pattern: exactly 1 heading, ≤1 text-editor, no icon-box/testimonial/counter/toggle
1223
+ // (those have their own injection paths)
1224
+ if (counts['heading'] === 1 &&
1225
+ counts['text-editor'] <= 1 &&
1226
+ counts['icon-box'] === 0 &&
1227
+ counts['testimonial'] === 0 &&
1228
+ counts['counter'] === 0 &&
1229
+ counts['toggle'] === 0) {
1230
+ allCards.push({
1231
+ container: el,
1232
+ headingWidget: findFirst(el, 'heading'),
1233
+ textWidget: findFirst(el, 'text-editor'),
1234
+ });
1235
+ return; // don't recurse into a matched card — its subtree is consumed
1236
+ }
1237
+ }
1238
+ if (Array.isArray(el.elements)) el.elements.forEach(walk);
1239
+ })(element);
1240
+
1241
+ return allCards;
1242
+ }
1243
+
1160
1244
  function injectContent(section, content) {
1161
1245
  const injected = JSON.parse(JSON.stringify(section));
1162
1246
  const slots = extractContentSlots(injected);
@@ -1175,21 +1259,78 @@ function injectContent(section, content) {
1175
1259
  const paragraphs = content.paragraphs || [];
1176
1260
  const quotes = content.quotes || [];
1177
1261
  const faq = content.faq || [];
1262
+ const items = (content.items || []);
1178
1263
 
1179
- // Inject headings: first heading = main heading, rest = subheading or paragraphs
1180
- if (headings.length > 0 && content.heading) {
1181
- // Find the "main" heading (usually largest or first H1/H2)
1182
- const mainIdx = headings.findIndex(h => {
1264
+ // Track widgets already consumed by card-pair injection so later loops skip them.
1265
+ const consumed = new Set();
1266
+
1267
+ // Identify the main heading slot (h1/h2 or first) so card detection can skip it.
1268
+ let mainHeadingIdx = -1;
1269
+ if (headings.length > 0) {
1270
+ mainHeadingIdx = headings.findIndex(h => {
1183
1271
  const size = h.widget.settings?.header_size;
1184
1272
  return size === 'h1' || size === 'h2' || !size;
1185
1273
  });
1186
- const mi = mainIdx >= 0 ? mainIdx : 0;
1187
- headings[mi].widget.settings.title = content.heading;
1188
- changes++;
1274
+ if (mainHeadingIdx < 0) mainHeadingIdx = 0;
1275
+ }
1276
+ const mainHeadingWidget = mainHeadingIdx >= 0 ? headings[mainHeadingIdx].widget : null;
1277
+
1278
+ // ── Card-pair injection ──
1279
+ // When a section uses the "card grid" pattern (sibling containers each with
1280
+ // 1 heading + ≤1 text-editor), distribute content.items into those cards.
1281
+ // This runs before icon-list/icon-box injection to claim the proper slots.
1282
+ const excludeFromCards = new Set();
1283
+ if (mainHeadingWidget) excludeFromCards.add(mainHeadingWidget);
1284
+ const cardPairs = items.length > 0 ? findCardPairs(injected, excludeFromCards) : [];
1285
+ const useCards = cardPairs.length >= items.length &&
1286
+ iconBoxes.length === 0 &&
1287
+ imageBoxes.length === 0;
1288
+ if (useCards) {
1289
+ const HIDDEN_CLASSES = 'elementor-hidden-desktop elementor-hidden-tablet elementor-hidden-mobile';
1290
+ for (let i = 0; i < cardPairs.length; i++) {
1291
+ const { container, headingWidget, textWidget } = cardPairs[i];
1292
+ if (i < items.length) {
1293
+ if (headingWidget) {
1294
+ headingWidget.settings.title = items[i].title;
1295
+ consumed.add(headingWidget);
1296
+ changes++;
1297
+ }
1298
+ if (textWidget) {
1299
+ textWidget.settings.editor = items[i].description
1300
+ ? `<p>${items[i].description}</p>`
1301
+ : '';
1302
+ consumed.add(textWidget);
1303
+ changes++;
1304
+ }
1305
+ } else {
1306
+ // Excess card — hide via CSS class
1307
+ if (!container.settings) container.settings = {};
1308
+ container.settings._css_classes = HIDDEN_CLASSES;
1309
+ container.settings.css_classes = HIDDEN_CLASSES;
1310
+ changes++;
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ // Inject headings: first heading = main heading, rest = subheading or paragraphs
1316
+ if (headings.length > 0 && content.heading) {
1317
+ const mi = mainHeadingIdx;
1318
+ if (!consumed.has(headings[mi].widget)) {
1319
+ if (headings[mi].atomic) {
1320
+ setAtomicHtmlV3(headings[mi].widget, 'title', content.heading);
1321
+ } else {
1322
+ headings[mi].widget.settings.title = content.heading;
1323
+ }
1324
+ changes++;
1325
+ }
1189
1326
 
1190
1327
  // If there's a subheading slot before the main heading (common: label above title)
1191
- if (mi > 0 && content.subheading) {
1192
- headings[0].widget.settings.title = content.subheading;
1328
+ if (mi > 0 && content.subheading && !consumed.has(headings[0].widget)) {
1329
+ if (headings[0].atomic) {
1330
+ setAtomicHtmlV3(headings[0].widget, 'title', content.subheading);
1331
+ } else {
1332
+ headings[0].widget.settings.title = content.subheading;
1333
+ }
1193
1334
  changes++;
1194
1335
  }
1195
1336
 
@@ -1198,8 +1339,13 @@ function injectContent(section, content) {
1198
1339
  for (let i = 0; i < headings.length; i++) {
1199
1340
  if (i === mi) continue;
1200
1341
  if (i < mi) continue; // already handled
1342
+ if (consumed.has(headings[i].widget)) continue;
1201
1343
  if (paragraphs.length && paraIdx < paragraphs.length) {
1202
- headings[i].widget.settings.title = paragraphs[paraIdx++];
1344
+ if (headings[i].atomic) {
1345
+ setAtomicHtmlV3(headings[i].widget, 'title', paragraphs[paraIdx++]);
1346
+ } else {
1347
+ headings[i].widget.settings.title = paragraphs[paraIdx++];
1348
+ }
1203
1349
  changes++;
1204
1350
  }
1205
1351
  }
@@ -1208,8 +1354,15 @@ function injectContent(section, content) {
1208
1354
  // Inject text-editor widgets with paragraphs
1209
1355
  let pIdx = 0;
1210
1356
  for (const slot of texts) {
1357
+ if (consumed.has(slot.widget)) continue;
1211
1358
  if (pIdx < paragraphs.length) {
1212
- slot.widget.settings.editor = `<p>${paragraphs.slice(pIdx).join('</p><p>')}</p>`;
1359
+ const html = `<p>${paragraphs.slice(pIdx).join('</p><p>')}</p>`;
1360
+ if (slot.atomic) {
1361
+ // v4 e-paragraph stores in html-v3 wrapper at settings.paragraph
1362
+ setAtomicHtmlV3(slot.widget, 'paragraph', html);
1363
+ } else {
1364
+ slot.widget.settings.editor = html;
1365
+ }
1213
1366
  pIdx = paragraphs.length;
1214
1367
  changes++;
1215
1368
  }
@@ -1221,7 +1374,6 @@ function injectContent(section, content) {
1221
1374
  'fas fa-bolt', 'fas fa-heart', 'fas fa-cog', 'fas fa-gem',
1222
1375
  'fas fa-rocket', 'fas fa-thumbs-up', 'fas fa-lightbulb', 'fas fa-bullseye',
1223
1376
  ];
1224
- const items = (content.items || []);
1225
1377
  for (let i = 0; i < iconBoxes.length && i < items.length; i++) {
1226
1378
  iconBoxes[i].widget.settings.title_text = items[i].title;
1227
1379
  if (items[i].description) {
@@ -1261,9 +1413,14 @@ function injectContent(section, content) {
1261
1413
  // Inject icon-list widgets with items (bullet lists in about, pricing features, etc.)
1262
1414
  const iconLists = slots.filter(s => s.type === 'icon-list');
1263
1415
  // Items for icon-lists come from content.items that overflow past icon-boxes + image-boxes,
1264
- // OR from content.items directly if there are no icon-boxes/image-boxes
1416
+ // OR from content.items directly if there are no icon-boxes/image-boxes.
1417
+ // Skip entirely if items were already consumed by card-pair injection.
1265
1418
  const iconListItemStart = iconBoxes.length + imageBoxes.length;
1266
- const iconListItems = items.length > iconListItemStart ? items.slice(iconListItemStart) : (iconBoxes.length === 0 && imageBoxes.length === 0 ? items : []);
1419
+ const iconListItems = useCards
1420
+ ? []
1421
+ : (items.length > iconListItemStart
1422
+ ? items.slice(iconListItemStart)
1423
+ : (iconBoxes.length === 0 && imageBoxes.length === 0 ? items : []));
1267
1424
  let iconListItemIdx = 0;
1268
1425
  for (const il of iconLists) {
1269
1426
  const existing = il.widget.settings.icon_list || [];
@@ -1291,9 +1448,15 @@ function injectContent(section, content) {
1291
1448
  // Inject buttons — fill all available button slots, always set both text and URL
1292
1449
  const contentButtons = content.buttons || [];
1293
1450
  for (let i = 0; i < buttons.length && i < contentButtons.length; i++) {
1294
- buttons[i].widget.settings.text = contentButtons[i].text;
1295
- if (!buttons[i].widget.settings.link) buttons[i].widget.settings.link = {};
1296
- buttons[i].widget.settings.link.url = contentButtons[i].url || '#';
1451
+ const url = contentButtons[i].url || '#';
1452
+ if (buttons[i].atomic) {
1453
+ setAtomicHtmlV3(buttons[i].widget, 'text', contentButtons[i].text);
1454
+ setAtomicLinkUrl(buttons[i].widget, url);
1455
+ } else {
1456
+ buttons[i].widget.settings.text = contentButtons[i].text;
1457
+ if (!buttons[i].widget.settings.link) buttons[i].widget.settings.link = {};
1458
+ buttons[i].widget.settings.link.url = url;
1459
+ }
1297
1460
  changes++;
1298
1461
  }
1299
1462
 
@@ -1432,23 +1595,43 @@ function injectContent(section, content) {
1432
1595
  const HIDDEN_CLASSES = 'elementor-hidden-desktop elementor-hidden-tablet elementor-hidden-mobile';
1433
1596
 
1434
1597
  // Headings: clear unfilled ones (beyond main + subheading + paragraph-filled ones)
1598
+ // Skip widgets consumed by card-pair injection (their titles are real content).
1435
1599
  const filledHeadingCount = (content.heading ? 1 : 0) + (content.subheading ? 1 : 0) + Math.min(paragraphs.length, Math.max(0, headings.length - 2));
1436
1600
  for (let i = filledHeadingCount; i < headings.length; i++) {
1437
- if (headings[i].widget.settings.title && headings[i].widget.settings.title !== content.heading && headings[i].widget.settings.title !== content.subheading) {
1438
- headings[i].widget.settings.title = '';
1439
- changes++;
1601
+ if (consumed.has(headings[i].widget)) continue;
1602
+ if (headings[i].atomic) {
1603
+ const current = getAtomicHtmlV3(headings[i].widget, 'title');
1604
+ if (current && current !== content.heading && current !== content.subheading) {
1605
+ setAtomicHtmlV3(headings[i].widget, 'title', '');
1606
+ changes++;
1607
+ }
1608
+ } else {
1609
+ if (headings[i].widget.settings.title && headings[i].widget.settings.title !== content.heading && headings[i].widget.settings.title !== content.subheading) {
1610
+ headings[i].widget.settings.title = '';
1611
+ changes++;
1612
+ }
1440
1613
  }
1441
1614
  }
1442
1615
 
1443
- // Text-editors: clear unfilled ones
1444
- if (paragraphs.length === 0) {
1616
+ // Text-editors: clear unfilled ones (skip card-consumed widgets)
1617
+ if (paragraphs.length === 0 && !useCards) {
1445
1618
  // No paragraphs at all — don't clear, the template text may be structural
1446
1619
  } else {
1447
1620
  // First text-editor got content; clear the rest
1448
- for (let i = 1; i < texts.length; i++) {
1449
- if (texts[i].widget.settings.editor) {
1450
- texts[i].widget.settings.editor = '';
1451
- changes++;
1621
+ for (let i = 0; i < texts.length; i++) {
1622
+ if (consumed.has(texts[i].widget)) continue;
1623
+ // Skip the first un-consumed text-editor (it got paragraphs)
1624
+ if (paragraphs.length > 0 && i === 0 && !consumed.has(texts[0].widget)) continue;
1625
+ if (texts[i].atomic) {
1626
+ if (getAtomicHtmlV3(texts[i].widget, 'paragraph')) {
1627
+ setAtomicHtmlV3(texts[i].widget, 'paragraph', '');
1628
+ changes++;
1629
+ }
1630
+ } else {
1631
+ if (texts[i].widget.settings.editor) {
1632
+ texts[i].widget.settings.editor = '';
1633
+ changes++;
1634
+ }
1452
1635
  }
1453
1636
  }
1454
1637
  }
@@ -1559,27 +1742,24 @@ const tokenCache = {
1559
1742
 
1560
1743
  const DEFAULT_TOKENS = {
1561
1744
  colors: {
1562
- primary: '#0961AD', primary_dark: '#154168', primary_hover: '#043F72',
1563
- primary_gradient_end: '#074A8A', secondary: '#FFC32A', secondary_hover: '#ECB21D',
1564
- dark_bg: '#084D8F', light_bg: '#E8F4FA', text: '#191919', white: '#FFFFFF',
1565
- muted_text: '#5C6B7F', light_gray_bg: '#F8F9FA',
1745
+ primary: '#2563EB', primary_dark: '#1E40AF', primary_hover: '#1D4ED8',
1746
+ primary_gradient_end: '#1E3A8A', secondary: '#F59E0B', secondary_hover: '#D97706',
1747
+ dark_bg: '#1E293B', light_bg: '#F1F5F9', text: '#1E293B', white: '#FFFFFF',
1748
+ muted_text: '#64748B', light_gray_bg: '#F8FAFC',
1566
1749
  },
1567
1750
  typography: {
1568
- heading_font: 'Poppins', body_font: 'Poppins',
1751
+ heading_font: 'Inter', body_font: 'Inter',
1569
1752
  button_weight: '600', button_transform: 'uppercase',
1570
1753
  },
1571
1754
  spacing: {
1572
- section_class: 'space-block-lg', button_padding: '15px 30px', border_radius: '100px',
1755
+ section_class: 'space-block-lg', button_padding: '15px 30px', border_radius: '8px',
1573
1756
  },
1574
1757
  urls: {
1575
- site_base: '', free_mri_review: '/free-mri-review', patient_experience: '/patient-experience',
1758
+ site_base: '', cta_path: '/contact', about_path: '/about',
1576
1759
  },
1577
1760
  buttons: {
1578
1761
  primary_class: 'primary-btn', secondary_class: 'secondary-btn',
1579
1762
  },
1580
- boilerplate: {
1581
- jet_parallax: true, plus_tooltip: true, tooltip_text: 'Luctus nec ullamcorper mattis',
1582
- },
1583
1763
  globals_map: {
1584
1764
  text: 'globals/colors?id=text', primary: 'globals/colors?id=primary', secondary: 'globals/colors?id=secondary',
1585
1765
  },
@@ -1620,10 +1800,11 @@ async function getDesignTokens(forceRefresh = false) {
1620
1800
  * Build Elementor button settings from tokens
1621
1801
  */
1622
1802
  function buildButtonSettings(tokens, type) {
1623
- const c = tokens.colors;
1624
- const t = tokens.typography;
1625
- const b = tokens.buttons;
1626
- const bp = tokens.boilerplate;
1803
+ const c = tokens.colors || {};
1804
+ const t = tokens.typography || {};
1805
+ const b = tokens.buttons || {};
1806
+ const s = tokens.spacing || {};
1807
+ const r = parseInt(s.border_radius) || 8;
1627
1808
 
1628
1809
  if (type === 'primary') {
1629
1810
  return {
@@ -1638,12 +1819,11 @@ function buildButtonSettings(tokens, type) {
1638
1819
  button_background_hover_background: 'gradient',
1639
1820
  button_background_hover_color: c.primary,
1640
1821
  button_background_hover_color_b: c.primary_hover,
1641
- border_radius: { unit: 'px', top: '100', right: '100', bottom: '100', left: '100', isLinked: true },
1822
+ border_radius: { unit: 'px', top: String(r), right: String(r), bottom: String(r), left: String(r), isLinked: true },
1642
1823
  button_padding: { unit: 'px', top: 15, right: 30, bottom: 15, left: 30, isLinked: false },
1643
1824
  typography_typography: 'custom', typography_font_family: t.heading_font,
1644
1825
  typography_font_weight: t.button_weight, typography_text_transform: t.button_transform,
1645
1826
  _css_classes: b.primary_class,
1646
- plus_tooltip_content_desc: bp.tooltip_text,
1647
1827
  __globals__: { background_color: '', button_background_hover_color: tokens.globals_map?.primary || '', button_background_hover_color_b: '', hover_color: '' },
1648
1828
  };
1649
1829
  } else {
@@ -1654,12 +1834,11 @@ function buildButtonSettings(tokens, type) {
1654
1834
  background_color: c.secondary,
1655
1835
  button_background_hover_color: c.secondary_hover,
1656
1836
  hover_color: '#000000',
1657
- border_radius: { unit: 'px', top: '100', right: '100', bottom: '100', left: '100', isLinked: true },
1837
+ border_radius: { unit: 'px', top: String(r), right: String(r), bottom: String(r), left: String(r), isLinked: true },
1658
1838
  button_padding: { unit: 'px', top: 15, right: 30, bottom: 15, left: 30, isLinked: false },
1659
1839
  typography_typography: 'custom', typography_font_family: t.heading_font,
1660
1840
  typography_font_weight: t.button_weight, typography_text_transform: t.button_transform,
1661
1841
  _css_classes: b.secondary_class,
1662
- plus_tooltip_content_desc: bp.tooltip_text,
1663
1842
  __globals__: { background_color: tokens.globals_map?.secondary || '', button_text_color: '', hover_color: tokens.globals_map?.text || '', button_background_hover_color: '' },
1664
1843
  };
1665
1844
  }
@@ -1678,7 +1857,7 @@ function getToolDefinitions() {
1678
1857
  },
1679
1858
  {
1680
1859
  name: 'get_design_tokens',
1681
- description: 'Get all design tokens (colors, typography, spacing, URLs, buttons, boilerplate). Tokens are cached for 5 minutes.',
1860
+ description: 'Get all design tokens (colors, typography, spacing, URLs, buttons, globals_map). Tokens are cached for 5 minutes.',
1682
1861
  inputSchema: { type: 'object', properties: {} }
1683
1862
  },
1684
1863
  {
@@ -1700,7 +1879,7 @@ function getToolDefinitions() {
1700
1879
  },
1701
1880
  {
1702
1881
  name: 'get_design_rules',
1703
- 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.',
1882
+ description: 'Get ALL design conventions: buttons, colors, container/widget rules, URLs, section types. Generated from current design tokens. ALWAYS call this before generating or editing pages.',
1704
1883
  inputSchema: { type: 'object', properties: {} }
1705
1884
  },
1706
1885
  {
@@ -1737,7 +1916,7 @@ function getToolDefinitions() {
1737
1916
  },
1738
1917
  {
1739
1918
  name: 'validate_page',
1740
- description: 'Run design validation on an Elementor page (CSS duals, boilerplate, duplicate IDs, etc.)',
1919
+ description: 'Run design validation on an Elementor page (CSS duals, root layout, duplicate IDs, etc.)',
1741
1920
  inputSchema: {
1742
1921
  type: 'object',
1743
1922
  properties: {
@@ -2058,6 +2237,7 @@ function getToolDefinitions() {
2058
2237
  search: { type: 'string', description: 'Text to find (literal string or regex pattern)' },
2059
2238
  replace: { type: 'string', description: 'Replacement text' },
2060
2239
  regex: { type: 'boolean', description: 'Treat search as regex pattern. Default: false (literal).' },
2240
+ case_insensitive: { type: 'boolean', description: 'Ignore case when matching. Default: false.' },
2061
2241
  page_id: { type: 'number', description: 'Optional: only search this page' },
2062
2242
  dry_run: { type: 'boolean', description: 'Preview changes without saving. Default: true (SAFE). Set to false to apply changes.' },
2063
2243
  force: { type: 'boolean', description: 'Override edit locks (default: false)' }
@@ -2117,6 +2297,8 @@ function getToolDefinitions() {
2117
2297
  status: { type: 'string', description: 'Comma-separated: publish,draft,private (default: all three)' },
2118
2298
  search: { type: 'string', description: 'Search by keyword' },
2119
2299
  category: { type: 'string', description: 'Filter by category: ID (number) or slug (string). Only applies to post types with categories.' },
2300
+ date_after: { type: 'string', description: 'Filter posts after this date (YYYY-MM-DD)' },
2301
+ date_before: { type: 'string', description: 'Filter posts before this date (YYYY-MM-DD)' },
2120
2302
  per_page: { type: 'number', description: 'Results per page, max 100 (default: 50)' },
2121
2303
  page: { type: 'number', description: 'Page number (default: 1)' }
2122
2304
  }
@@ -2367,6 +2549,198 @@ function getToolDefinitions() {
2367
2549
  required: ['template_id']
2368
2550
  }
2369
2551
  },
2552
+
2553
+ // ── Nav menus (v4.29.0) ──
2554
+ {
2555
+ name: 'list_nav_menus',
2556
+ description: 'List all WordPress nav menus with their IDs, names, slugs, and item counts.',
2557
+ inputSchema: { type: 'object', properties: {} }
2558
+ },
2559
+ {
2560
+ name: 'create_nav_menu',
2561
+ description: 'Create a new WordPress nav menu. If a menu with the same name exists, returns its existing ID.',
2562
+ inputSchema: {
2563
+ type: 'object',
2564
+ properties: {
2565
+ name: { type: 'string', description: 'Menu name (e.g. "Primary", "Footer")' }
2566
+ },
2567
+ required: ['name']
2568
+ }
2569
+ },
2570
+ {
2571
+ name: 'set_menu_items',
2572
+ description: 'Bulk-replace all items in a nav menu. Each item: {title, url (for type=custom), type ("custom"|"post_type"|"taxonomy"), object ("page"|"post"|"category"), object_id, parent_index (for nesting — 0-based index of parent in this same array), target ("_blank")}. Items are inserted in order; nested items must come AFTER their parent in the array.',
2573
+ inputSchema: {
2574
+ type: 'object',
2575
+ properties: {
2576
+ menu_id: { type: 'number', description: 'Menu ID from list_nav_menus or create_nav_menu' },
2577
+ items: {
2578
+ type: 'array',
2579
+ description: 'Ordered list of menu items (replaces all existing items in the menu)',
2580
+ items: {
2581
+ type: 'object',
2582
+ properties: {
2583
+ title: { type: 'string' },
2584
+ url: { type: 'string', description: 'Required when type="custom"' },
2585
+ type: { type: 'string', enum: ['custom', 'post_type', 'taxonomy'], description: 'Default: "custom"' },
2586
+ object: { type: 'string', description: 'e.g. "page", "post", "category" — required when type !== "custom"' },
2587
+ object_id: { type: 'number', description: 'WP object ID — required when type !== "custom"' },
2588
+ parent_index: { type: 'number', description: 'Index in this array of the parent item (for nesting). Omit for top-level.' },
2589
+ target: { type: 'string', enum: ['_blank', ''], description: 'Optional. "_blank" opens in new tab.' }
2590
+ }
2591
+ }
2592
+ }
2593
+ },
2594
+ required: ['menu_id', 'items']
2595
+ }
2596
+ },
2597
+ {
2598
+ name: 'list_menu_locations',
2599
+ description: 'List the theme\'s registered nav menu locations and which menu (if any) is currently assigned to each.',
2600
+ inputSchema: { type: 'object', properties: {} }
2601
+ },
2602
+ {
2603
+ name: 'assign_menu_to_location',
2604
+ description: 'Assign a nav menu to a theme location (e.g. "menu-1" / "primary"). Use list_menu_locations first to see available locations.',
2605
+ inputSchema: {
2606
+ type: 'object',
2607
+ properties: {
2608
+ location: { type: 'string', description: 'Theme location key (e.g. "menu-1")' },
2609
+ menu_id: { type: 'number', description: 'Menu ID to assign' }
2610
+ },
2611
+ required: ['location', 'menu_id']
2612
+ }
2613
+ },
2614
+
2615
+ // ── Custom post types & taxonomies (v4.31.0) ──
2616
+ {
2617
+ name: 'register_cpt',
2618
+ description: 'Register a custom post type. Stored in `nvbc_post_types` WP option and registered on every init. Pass standard register_post_type args in snake_case. Required: slug. Common: label, plural_label, hierarchical (bool), supports (array), rewrite_slug, has_archive, taxonomies (array of taxonomy slugs), menu_icon. Pass `rewrite_slug: ""` for root-level URLs (locations pattern).',
2619
+ inputSchema: {
2620
+ type: 'object',
2621
+ properties: {
2622
+ slug: { type: 'string', description: 'CPT slug (e.g. "practice-area")' },
2623
+ label: { type: 'string', description: 'Singular label (e.g. "Practice Area")' },
2624
+ plural_label: { type: 'string', description: 'Plural label (e.g. "Practice Areas")' },
2625
+ public: { type: 'boolean' },
2626
+ show_in_rest: { type: 'boolean', description: 'Default: true (required for Gutenberg + REST API)' },
2627
+ hierarchical: { type: 'boolean', description: 'Allow parent/child URLs (default: false)' },
2628
+ supports: { type: 'array', items: { type: 'string' }, description: 'Default: ["title","editor","thumbnail","revisions","page-attributes"]' },
2629
+ has_archive: { type: 'boolean' },
2630
+ rewrite_slug: { type: 'string', description: 'URL slug. Pass empty string for root-level URLs.' },
2631
+ rewrite_with_front: { type: 'boolean' },
2632
+ menu_icon: { type: 'string', description: 'Dashicon class (e.g. "dashicons-businessperson")' },
2633
+ menu_position: { type: 'number' },
2634
+ taxonomies: { type: 'array', items: { type: 'string' }, description: 'Built-in or custom taxonomy slugs to attach' },
2635
+ show_in_menu: { type: 'boolean' },
2636
+ show_in_nav_menus: { type: 'boolean' },
2637
+ exclude_from_search:{ type: 'boolean' }
2638
+ },
2639
+ required: ['slug']
2640
+ }
2641
+ },
2642
+ {
2643
+ name: 'list_post_types',
2644
+ description: 'List all registered post types (built-in + NVBC-managed). Returns slug, labels, hierarchical, has_archive, rewrite_slug, and a `managed` flag indicating which are NVBC-managed (deletable via delete_cpt).',
2645
+ inputSchema: { type: 'object', properties: {} }
2646
+ },
2647
+ {
2648
+ name: 'delete_cpt',
2649
+ description: 'Unregister an NVBC-managed CPT. Existing posts of that type are NOT deleted; they just become unrenderable until you re-register the CPT. Built-in post types cannot be deleted.',
2650
+ inputSchema: {
2651
+ type: 'object',
2652
+ properties: { slug: { type: 'string' } },
2653
+ required: ['slug']
2654
+ }
2655
+ },
2656
+ {
2657
+ name: 'register_taxonomy',
2658
+ description: 'Register a custom taxonomy. Stored in `nvbc_taxonomies` WP option. Pass post_types as array of CPT slugs to attach the taxonomy to.',
2659
+ inputSchema: {
2660
+ type: 'object',
2661
+ properties: {
2662
+ slug: { type: 'string' },
2663
+ label: { type: 'string' },
2664
+ plural_label: { type: 'string' },
2665
+ post_types: { type: 'array', items: { type: 'string' } },
2666
+ hierarchical: { type: 'boolean', description: 'true = category-style, false = tag-style' },
2667
+ rewrite_slug: { type: 'string' },
2668
+ public: { type: 'boolean' },
2669
+ show_in_rest: { type: 'boolean' },
2670
+ show_admin_column: { type: 'boolean' }
2671
+ },
2672
+ required: ['slug']
2673
+ }
2674
+ },
2675
+ {
2676
+ name: 'delete_taxonomy',
2677
+ description: 'Unregister an NVBC-managed taxonomy. Term assignments persist in the database; just the taxonomy itself stops being registered.',
2678
+ inputSchema: {
2679
+ type: 'object',
2680
+ properties: { slug: { type: 'string' } },
2681
+ required: ['slug']
2682
+ }
2683
+ },
2684
+ {
2685
+ name: 'flush_rewrites',
2686
+ description: 'Force a flush of WordPress rewrite rules. Normally not needed — register_cpt/register_taxonomy auto-flush. Use this if archive/single URLs return 404 after a manual change.',
2687
+ inputSchema: { type: 'object', properties: {} }
2688
+ },
2689
+ {
2690
+ name: 'import_acf_groups_native',
2691
+ description: 'Import ACF Pro field groups in their native JSON export format (output of Tools → Export Field Groups). Preserves repeaters, flexible content, conditional logic, sub_fields, field keys exactly as in the source export. Use this when you have a pre-prepared ACF JSON file. The simpler create_acf_field_group flattens schema; this one is bit-for-bit faithful.',
2692
+ inputSchema: {
2693
+ type: 'object',
2694
+ properties: {
2695
+ groups: {
2696
+ type: 'array',
2697
+ description: 'Array of ACF field group export objects (each must have title + fields)',
2698
+ items: { type: 'object' }
2699
+ }
2700
+ },
2701
+ required: ['groups']
2702
+ }
2703
+ },
2704
+
2705
+ // ── Media import (v4.30.0) ──
2706
+ {
2707
+ name: 'import_image',
2708
+ description: 'Sideload a single image into the WordPress media library from a public URL (preferred) or base64. Idempotent: if the same source URL was imported before, returns the existing attachment without re-fetching. Returns {attachment_id, url, was_existing, alt, title}.',
2709
+ inputSchema: {
2710
+ type: 'object',
2711
+ properties: {
2712
+ url: { type: 'string', description: 'Public URL the WP server can fetch (e.g. https://example.com/image.jpg)' },
2713
+ base64: { type: 'string', description: 'Alternative to url — raw base64 or data: URI. Requires filename.' },
2714
+ filename: { type: 'string', description: 'Filename to save as. Required when uploading via base64; inferred from URL otherwise.' },
2715
+ alt: { type: 'string', description: 'Alt text (saved as _wp_attachment_image_alt)' },
2716
+ title: { type: 'string', description: 'Attachment title (defaults to filename without extension)' }
2717
+ }
2718
+ }
2719
+ },
2720
+ {
2721
+ name: 'import_images_bulk',
2722
+ description: 'Sideload many images in one call. Same dedup-by-source-URL behavior as import_image. Partial failures do not fail the batch — every item gets a per-item result. Returns {total, imported, existing, failed, results: [{index, success, attachment_id?, url?, was_existing?, error?}]}.',
2723
+ inputSchema: {
2724
+ type: 'object',
2725
+ properties: {
2726
+ items: {
2727
+ type: 'array',
2728
+ description: 'List of images to import. Each item supports the same fields as import_image.',
2729
+ items: {
2730
+ type: 'object',
2731
+ properties: {
2732
+ url: { type: 'string' },
2733
+ base64: { type: 'string' },
2734
+ filename: { type: 'string' },
2735
+ alt: { type: 'string' },
2736
+ title: { type: 'string' }
2737
+ }
2738
+ }
2739
+ }
2740
+ },
2741
+ required: ['items']
2742
+ }
2743
+ },
2370
2744
  {
2371
2745
  name: 'audit_placeholders',
2372
2746
  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.',
@@ -2953,7 +3327,7 @@ function getToolDefinitions() {
2953
3327
  },
2954
3328
  {
2955
3329
  name: 'validate_all_pages',
2956
- description: 'Validate all Elementor pages against design patterns (CSS class duality, boilerplate, root layout, duplicate IDs). Returns per-page scores and aggregate summary.',
3330
+ description: 'Validate all Elementor pages against design patterns (CSS class duality, root layout, duplicate IDs). Returns per-page scores and aggregate summary.',
2957
3331
  inputSchema: {
2958
3332
  type: 'object',
2959
3333
  properties: {
@@ -3073,7 +3447,6 @@ async function handleToolCall(name, args) {
3073
3447
  const t = tokens.typography || {};
3074
3448
  const s = tokens.spacing || {};
3075
3449
  const u = tokens.urls || {};
3076
- const bp = tokens.boilerplate || {};
3077
3450
  const gm = tokens.globals_map || {};
3078
3451
 
3079
3452
  return ok(
@@ -3081,17 +3454,16 @@ async function handleToolCall(name, args) {
3081
3454
  `--- BUTTONS ---\n` +
3082
3455
  `Primary: gradient ${c.primary} -> ${c.primary_dark}, text ${c.white}, class "${tokens.buttons?.primary_class}"\n` +
3083
3456
  `Secondary: solid ${c.secondary}, text ${c.text}, class "${tokens.buttons?.secondary_class}"\n` +
3457
+ `Border radius: ${s.border_radius}\n` +
3084
3458
  `Rules:\n - Hero: ALWAYS secondary\n - Light bg: primary\n - Dark bg (1 btn): secondary\n - Dark bg (2 btn): BOTH secondary\n\n` +
3085
3459
  `--- COLORS (hex) ---\n${Object.entries(c).map(([k, v]) => ` ${k}: ${v}`).join('\n')}\n\n` +
3086
3460
  `--- COLORS (__globals__) ---\n${Object.entries(gm).map(([k, v]) => ` ${k}: ${v}`).join('\n')}\n\n` +
3087
3461
  `--- CONTAINER RULES ---\n` +
3088
3462
  ` Root: layout = "full_width"\n` +
3089
- ` ALL containers: jet_parallax_layout_list = []\n` +
3090
3463
  ` css_classes MUST match _css_classes\n` +
3091
3464
  ` Section spacing: "${s.section_class}"\n\n` +
3092
3465
  `--- WIDGET RULES ---\n` +
3093
- ` ALL widgets: elements = []\n` +
3094
- ` ALL widgets: plus_tooltip_content_desc = "${bp.tooltip_text}"\n\n` +
3466
+ ` ALL widgets: elements = []\n\n` +
3095
3467
  `--- TYPOGRAPHY ---\n Heading: ${t.heading_font}\n Body: ${t.body_font}\n Button: ${t.button_weight} ${t.button_transform}\n\n` +
3096
3468
  `--- URLS ---\n${Object.entries(u).map(([k, v]) => ` ${k}: ${v}`).join('\n')}`
3097
3469
  );
@@ -3222,7 +3594,7 @@ async function handleToolCall(name, args) {
3222
3594
  if (!r.success || !r.components) {
3223
3595
  return ok(`Failed to list components: ${r.message || 'Unknown error'}`);
3224
3596
  }
3225
- let out = `${r.count} Design Components Available\n\n`;
3597
+ let out = `${r.count} Design Components Available (v3 unmarked, v4 atomic marked [v4])\n\n`;
3226
3598
  const grouped = {};
3227
3599
  r.components.forEach(c => {
3228
3600
  if (!grouped[c.category]) grouped[c.category] = [];
@@ -3231,7 +3603,9 @@ async function handleToolCall(name, args) {
3231
3603
  Object.entries(grouped).forEach(([category, items]) => {
3232
3604
  out += `--- ${category.toUpperCase()} (${items.length}) ---\n`;
3233
3605
  items.forEach(item => {
3234
- out += ` - ${item.name}\n ${item.meta.description}\n`;
3606
+ const engine = item.meta?.engine || 'v3';
3607
+ const mark = engine === 'v4' ? ' [v4]' : '';
3608
+ out += ` - ${item.name}${mark}\n ${item.meta.description}\n`;
3235
3609
  if (item.meta.variables?.length) {
3236
3610
  out += ` Variables: ${item.meta.variables.map(v => v.name).join(', ')}\n`;
3237
3611
  }
@@ -3618,6 +3992,7 @@ async function handleToolCall(name, args) {
3618
3992
  search: stripCDATA(args.search),
3619
3993
  replace: stripCDATA(args.replace),
3620
3994
  regex: parseBool(args.regex, false),
3995
+ case_insensitive: parseBool(args.case_insensitive, false),
3621
3996
  dry_run: parseBool(args.dry_run, true), // default TRUE (safe)
3622
3997
  };
3623
3998
  if (args.page_id) body.page_id = args.page_id;
@@ -3732,6 +4107,8 @@ async function handleToolCall(name, args) {
3732
4107
  if (args.status) params.set('status', args.status);
3733
4108
  if (args.search) params.set('search', args.search);
3734
4109
  if (args.category) params.set('category', args.category);
4110
+ if (args.date_after) params.set('date_after', args.date_after);
4111
+ if (args.date_before) params.set('date_before', args.date_before);
3735
4112
  if (args.per_page) params.set('per_page', args.per_page);
3736
4113
  if (args.page) params.set('page', args.page);
3737
4114
  const qs = params.toString() ? `?${params.toString()}` : '';
@@ -4060,6 +4437,117 @@ async function handleToolCall(name, args) {
4060
4437
  }
4061
4438
  }
4062
4439
 
4440
+ case 'list_nav_menus': {
4441
+ const r = await apiCall('/menus');
4442
+ if (!r.menus || !r.menus.length) return ok('No nav menus found.');
4443
+ const lines = r.menus.map(m => `[${m.id}] ${m.name} (slug: ${m.slug}, ${m.count} items)`);
4444
+ return ok(`Nav menus (${r.menus.length}):\n${lines.join('\n')}`);
4445
+ }
4446
+
4447
+ case 'create_nav_menu': {
4448
+ const r = await apiCall('/menus', 'POST', { name: args.name });
4449
+ return ok(r.created
4450
+ ? `Menu created: "${r.name}" (ID ${r.id})`
4451
+ : `Menu already existed: "${r.name}" (ID ${r.id})`);
4452
+ }
4453
+
4454
+ case 'set_menu_items': {
4455
+ const r = await apiCall(`/menus/${args.menu_id}/items`, 'POST', { items: args.items });
4456
+ const errLines = (r.errors || []).map(e => ` • #${e.index}: ${e.message}`);
4457
+ return ok(
4458
+ `Menu ${r.menu_id}: ${r.created.length} item(s) inserted.` +
4459
+ (errLines.length ? `\n${r.errors.length} error(s):\n${errLines.join('\n')}` : '')
4460
+ );
4461
+ }
4462
+
4463
+ case 'list_menu_locations': {
4464
+ const r = await apiCall('/menu-locations');
4465
+ if (!r.locations || !r.locations.length) return ok('Theme has no registered nav menu locations.');
4466
+ const lines = r.locations.map(l => `${l.location} (${l.label}) → ${l.menu_id ? 'menu #' + l.menu_id : '(none assigned)'}`);
4467
+ return ok(`Theme nav menu locations:\n${lines.join('\n')}`);
4468
+ }
4469
+
4470
+ case 'assign_menu_to_location': {
4471
+ const r = await apiCall('/menu-locations', 'POST', { location: args.location, menu_id: args.menu_id });
4472
+ return ok(`Assigned menu #${r.menu_id} to location "${r.location}".`);
4473
+ }
4474
+
4475
+ case 'register_cpt': {
4476
+ const r = await apiCall('/post-types', 'POST', args);
4477
+ return ok(`${r.created ? 'Registered' : 'Updated'} CPT "${r.slug}"\n hierarchical: ${r.def.hierarchical}\n rewrite_slug: ${r.def.rewrite_slug || '(default)'}\n has_archive: ${r.def.has_archive}\n supports: ${(r.def.supports || []).join(', ')}`);
4478
+ }
4479
+
4480
+ case 'list_post_types': {
4481
+ const r = await apiCall('/post-types');
4482
+ const lines = (r.post_types || []).map(pt => {
4483
+ const flags = [];
4484
+ if (pt.hierarchical) flags.push('hierarchical');
4485
+ if (pt.has_archive) flags.push('archive');
4486
+ if (pt.managed) flags.push('NVBC-managed');
4487
+ return ` ${pt.slug.padEnd(20)} ${pt.plural_label}${flags.length ? ' [' + flags.join(', ') + ']' : ''}`;
4488
+ });
4489
+ return ok(`Post types (${(r.post_types || []).length}):\n${lines.join('\n')}`);
4490
+ }
4491
+
4492
+ case 'delete_cpt': {
4493
+ const r = await apiCall(`/post-types/${encodeURIComponent(args.slug)}`, 'DELETE');
4494
+ return ok(`Removed CPT "${r.removed}" from NVBC-managed list. Existing posts of this type are still in the DB.`);
4495
+ }
4496
+
4497
+ case 'register_taxonomy': {
4498
+ const r = await apiCall('/taxonomies-managed', 'POST', args);
4499
+ return ok(`${r.created ? 'Registered' : 'Updated'} taxonomy "${r.slug}"\n hierarchical: ${r.def.hierarchical}\n attached to: ${(r.def.post_types || []).join(', ') || '(none)'}`);
4500
+ }
4501
+
4502
+ case 'delete_taxonomy': {
4503
+ const r = await apiCall(`/taxonomies-managed/${encodeURIComponent(args.slug)}`, 'DELETE');
4504
+ return ok(`Removed taxonomy "${r.removed}" from NVBC-managed list.`);
4505
+ }
4506
+
4507
+ case 'flush_rewrites': {
4508
+ await apiCall('/flush-rewrites', 'POST');
4509
+ return ok('Rewrite rules flushed.');
4510
+ }
4511
+
4512
+ case 'import_acf_groups_native': {
4513
+ const r = await apiCall('/acf/import-groups-native', 'POST', { groups: args.groups });
4514
+ let out = `ACF native import: ${r.total} group(s) → ${r.imported.length} imported, ${r.errors.length} errors\n`;
4515
+ out += r.imported.map(g => ` ✓ ${g.title} (id: ${g.id}, key: ${g.key})`).join('\n');
4516
+ if (r.errors.length) {
4517
+ out += '\n' + r.errors.map(e => ` ✗ #${e.index} ${e.title || ''}: ${e.error}`).join('\n');
4518
+ }
4519
+ return ok(out);
4520
+ }
4521
+
4522
+ case 'import_image': {
4523
+ const body = {};
4524
+ if (args.url) body.url = args.url;
4525
+ if (args.base64) body.base64 = args.base64;
4526
+ if (args.filename) body.filename = args.filename;
4527
+ if (args.alt) body.alt = args.alt;
4528
+ if (args.title) body.title = args.title;
4529
+ const r = await apiCall('/media/import', 'POST', body);
4530
+ return ok(
4531
+ `${r.was_existing ? 'Existing' : 'Imported'} attachment #${r.attachment_id}\n` +
4532
+ ` url: ${r.url}\n` +
4533
+ (r.alt ? ` alt: ${r.alt}\n` : '') +
4534
+ (r.title ? ` title: ${r.title}` : '')
4535
+ );
4536
+ }
4537
+
4538
+ case 'import_images_bulk': {
4539
+ const r = await apiCall('/media/import-bulk', 'POST', { items: args.items });
4540
+ let out = `Bulk import: ${r.total} requested → ${r.imported} new, ${r.existing} existing, ${r.failed} failed\n`;
4541
+ // Always include the per-item attachment IDs so the caller can wire them into pages
4542
+ // without a second round-trip. Failures are flagged inline.
4543
+ const lines = r.results.map(item =>
4544
+ item.success
4545
+ ? ` #${item.index} → att ${item.attachment_id}${item.was_existing ? ' (existing)' : ''} [${item.source_url || '(base64)'}]`
4546
+ : ` #${item.index} FAILED ${item.error}: ${item.message || ''} [${item.source_url || '(base64)'}]`
4547
+ );
4548
+ return ok(out + lines.join('\n'));
4549
+ }
4550
+
4063
4551
  case 'audit_placeholders': {
4064
4552
  const qs = args.include_templates ? '?include_templates=1' : '';
4065
4553
  const r = await apiCall(`/audit-placeholders${qs}`);
@@ -4791,7 +5279,7 @@ async function handleToolCall(name, args) {
4791
5279
  }
4792
5280
 
4793
5281
  case 'append_section': {
4794
- // Auto-add boilerplate to all appended sections
5282
+ // Enforce root settings on appended sections
4795
5283
  if (args.section) addBoilerplate(args.section);
4796
5284
  const body = {
4797
5285
  target_id: args.target_id,
@@ -5222,7 +5710,7 @@ async function handleToolCall(name, args) {
5222
5710
  }
5223
5711
  }
5224
5712
 
5225
- // Add site-specific boilerplate (jet_parallax, plus_tooltip, full_width)
5713
+ // Enforce standard settings (root layout: full_width)
5226
5714
  addBoilerplate(final);
5227
5715
 
5228
5716
  // Append to page
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noleemits/vision-builder-control-mcp",
3
- "version": "4.18.1",
3
+ "version": "4.31.0",
4
4
  "description": "Vision Builder Control MCP server - design token-driven page builder tools for WordPress/Elementor websites",
5
5
  "type": "module",
6
6
  "main": "index.js",