PluginProbe
Page Builder: Pagelayer – Drag and Drop website builder / 2.2.0
Page Builder: Pagelayer – Drag and Drop website builder v2.2.0
2.2.1 2.2.0 2.1.9 2.1.8 2.1.7 2.1.6 2.1.5 2.1.4 2.1.3 trunk 0.9.0 0.9.1 0.9.2 0.9.3 0.9.4 0.9.5 0.9.6 0.9.7 0.9.8 0.9.9 1.0.0 1.0.2 1.0.3 1.0.4 1.0.5 All 129 releases
pagelayer / main / ai-layout-engine.php

ai-layout-engine.php in Page Builder: Pagelayer – Drag and Drop website builder 2.2.0, at main/ai-layout-engine.php

5,944 lines 209.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 //////////////////////////////////////////////////////////////
4 //===========================================================
5 // ai-layout-engine.php
6 //===========================================================
7 // PAGELAYER
8 // Build-with-AI: live widget catalog + schemas for the model.
9 // The AI invents structure, copy, and visuals — this file does not
10 // ship canned layouts. Independent of the MCP abilities register.
11 //===========================================================
12 //////////////////////////////////////////////////////////////
13
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 class Pagelayer_AI_Layout_Engine {
19
20 private static $instance = null;
21
22 const SCHEMA_CACHE_TTL = 21600;
23 const MAX_SCHEMA_WIDGETS = 64;
24
25 private static $skip_tags = array(
26 'pl_missing',
27 'pl_customizer',
28 'pl_wp_widgets',
29 'pl_block',
30 'pl_post_props',
31 );
32
33 public static function instance() {
34 if (self::$instance === null) {
35 self::$instance = new self();
36 }
37 return self::$instance;
38 }
39
40 private function ensure_shortcodes() {
41 if (function_exists('pagelayer_load_shortcodes')) {
42 pagelayer_load_shortcodes();
43 }
44 }
45
46 private function include_pro_settings() {
47 $default = defined('PAGELAYER_PRO_VERSION');
48 return (bool) apply_filters('pagelayer_ai_include_pro_settings', $default);
49 }
50
51 /**
52 * Full widget schema from the live shortcode registry.
53 * Used only by Build-with-AI — not the MCP abilities layer.
54 */
55 private function extract_widget_schema($tag, $data) {
56 global $pagelayer;
57
58 $schema = array(
59 'id' => $tag,
60 'name' => isset($data['name']) ? $data['name'] : $tag,
61 'group' => isset($data['group']) ? $data['group'] : 'misc',
62 'html' => isset($data['html']) ? $data['html'] : '',
63 'holder' => isset($data['holder']) ? $data['holder'] : '',
64 'innerHTML' => isset($data['innerHTML']) ? $data['innerHTML'] : '',
65 'parent' => isset($data['parent']) ? $data['parent'] : array(),
66 'has_group' => isset($data['has_group']) ? $data['has_group'] : array(),
67 'skip_props_cat' => isset($data['skip_props_cat']) ? $data['skip_props_cat'] : array(),
68 'skip_props' => isset($data['skip_props']) ? $data['skip_props'] : array(),
69 'sections' => array(),
70 );
71
72 $settings_tabs = isset($data['settings']) ? $data['settings'] : array();
73 $options = isset($data['options']) ? $data['options'] : array();
74 $section_keys = array();
75
76 if (!empty($pagelayer->tabs) && is_array($pagelayer->tabs)) {
77 foreach ($pagelayer->tabs as $tab) {
78 if (empty($data[$tab]) || !is_array($data[$tab])) {
79 continue;
80 }
81 foreach ($data[$tab] as $section_key => $section_label) {
82 $section_keys[] = $section_key;
83 }
84 }
85 }
86
87 foreach ($section_keys as $section_key) {
88 $props = array();
89 if (isset($data[$section_key]) && is_array($data[$section_key])) {
90 $props = $data[$section_key];
91 } elseif (isset($pagelayer->styles[$section_key]) && is_array($pagelayer->styles[$section_key])) {
92 $props = $pagelayer->styles[$section_key];
93 }
94
95 if (empty($props)) {
96 continue;
97 }
98
99 $clean_props = array();
100 foreach ($props as $prop_key => $prop_def) {
101 if (!is_array($prop_def)) {
102 $clean_props[$prop_key] = array('label' => $prop_def);
103 continue;
104 }
105
106 $clean_prop = array(
107 'type' => isset($prop_def['type']) ? $prop_def['type'] : '',
108 'label' => isset($prop_def['label']) ? $prop_def['label'] : '',
109 'default' => isset($prop_def['default']) ? $prop_def['default'] : null,
110 );
111
112 if (isset($prop_def['list']) && is_array($prop_def['list'])) {
113 $clean_prop['allowed_values'] = $prop_def['list'];
114 }
115 if (isset($prop_def['min'])) {
116 $clean_prop['min'] = $prop_def['min'];
117 }
118 if (isset($prop_def['max'])) {
119 $clean_prop['max'] = $prop_def['max'];
120 }
121 if (isset($prop_def['step'])) {
122 $clean_prop['step'] = $prop_def['step'];
123 }
124 if (isset($prop_def['units'])) {
125 $clean_prop['units'] = $prop_def['units'];
126 }
127 if (isset($prop_def['screen'])) {
128 $clean_prop['responsive'] = (bool) $prop_def['screen'];
129 }
130 if (isset($prop_def['req'])) {
131 $clean_prop['requires'] = $prop_def['req'];
132 }
133 if (isset($prop_def['show'])) {
134 $clean_prop['show_when'] = $prop_def['show'];
135 }
136 if (isset($prop_def['edit'])) {
137 $clean_prop['edit_selector'] = $prop_def['edit'];
138 }
139 if (isset($prop_def['desc'])) {
140 $clean_prop['desc'] = $prop_def['desc'];
141 }
142 if (!empty($prop_def['pro'])) {
143 $clean_prop['pro'] = 1;
144 }
145
146 $clean_props[$prop_key] = $clean_prop;
147 }
148
149 $schema['sections'][$section_key] = array(
150 'label' => isset($settings_tabs[$section_key]) ? $settings_tabs[$section_key] : (isset($options[$section_key]) ? $options[$section_key] : ucfirst($section_key)),
151 'properties' => $clean_props,
152 );
153 }
154
155 if (isset($pagelayer->default_params[$tag])) {
156 $schema['default_attrs'] = $pagelayer->default_params[$tag];
157 }
158
159 return $schema;
160 }
161
162 private function compact_prop($prop) {
163 $type = !empty($prop['type']) ? $prop['type'] : 'text';
164 $parts = array($type);
165
166 if (!empty($prop['label']) && is_string($prop['label'])) {
167 $lbl = trim(preg_replace('/\s+/', ' ', wp_strip_all_tags($prop['label'])));
168 if ($lbl !== '') {
169 if (function_exists('mb_substr')) {
170 $lbl = mb_substr($lbl, 0, 40);
171 } else {
172 $lbl = substr($lbl, 0, 40);
173 }
174 $parts[] = 'lbl:' . str_replace(array('|', ':'), array('/', '-'), $lbl);
175 }
176 }
177
178 if (isset($prop['default']) && $prop['default'] !== '' && $prop['default'] !== null) {
179 $def = is_array($prop['default']) ? wp_json_encode($prop['default']) : (string) $prop['default'];
180 if (strlen($def) > 40) {
181 $def = substr($def, 0, 40) . '';
182 }
183 $parts[] = 'def:' . $def;
184 }
185
186 if (!empty($prop['allowed_values']) && is_array($prop['allowed_values'])) {
187 $vals = array_values(array_filter(array_keys($prop['allowed_values']), 'strlen'));
188 if (empty($vals) || $vals === range(0, count($prop['allowed_values']) - 1)) {
189 $vals = array_values($prop['allowed_values']);
190 }
191 $vals = array_map(function ($v) {
192 return is_scalar($v) ? (string) $v : '';
193 }, $vals);
194 $parts[] = 'opts:' . implode(',', array_filter($vals, 'strlen'));
195 }
196
197 if (isset($prop['min']) || isset($prop['max'])) {
198 $range = (isset($prop['min']) ? $prop['min'] : '') . '-' . (isset($prop['max']) ? $prop['max'] : '');
199 if (!empty($prop['units'])) {
200 $units = is_array($prop['units']) ? implode('/', $prop['units']) : $prop['units'];
201 $range .= $units;
202 }
203 $parts[] = $range;
204 }
205
206 if (!empty($prop['requires']) && is_array($prop['requires'])) {
207 $req = array();
208 foreach ($prop['requires'] as $k => $v) {
209 $req[] = $k . '=' . (is_array($v) ? implode('/', $v) : $v);
210 }
211 $parts[] = 'req:' . implode('&', $req);
212 }
213
214 if (!empty($prop['responsive'])) {
215 $parts[] = 'resp';
216 }
217
218 if ($type === 'typography') {
219 $parts[] = 'fields:family,size,style,weight,variant,deco-line,deco-style,line-height,transform,letter,word';
220 } elseif ($type === 'image') {
221 $parts[] = 'val:url-or-id';
222 } elseif ($type === 'padding') {
223 $parts[] = 'val:t,r,b,l+unit';
224 } elseif ($type === 'icon') {
225 $parts[] = 'val:fa5-class';
226 }
227
228 return implode('|', $parts);
229 }
230
231 private function own_section_keys($tag) {
232 global $pagelayer;
233 $this->ensure_shortcodes();
234 $data = isset($pagelayer->shortcodes[$tag]) ? $pagelayer->shortcodes[$tag] : array();
235 return isset($data['settings']) && is_array($data['settings']) ? array_keys($data['settings']) : array();
236 }
237
238 private function compact_widget_schema($schema, $mode = 'own', $only = array()) {
239 $tag = $schema['id'];
240 $own = $this->own_section_keys($tag);
241 $out = array(
242 'id' => $tag,
243 'name' => $schema['name'],
244 'group' => $schema['group'],
245 );
246
247 if (!empty($schema['parent'])) {
248 $out['must_be_inside'] = $schema['parent'];
249 }
250 if (!empty($schema['holder']) || !empty($schema['has_group'])) {
251 $out['accepts_children'] = true;
252 }
253 if (!empty($schema['innerHTML'])) {
254 $out['content_attr'] = $schema['innerHTML'];
255 $out['content_note'] = 'Main text goes in the node "content" field, not as attrs[' . $schema['innerHTML'] . '].';
256 }
257 if (!empty($schema['html']) && is_string($schema['html'])) {
258 preg_match_all('/\{\{\{?([a-zA-Z0-9_\-]+)\}?\}\}/', $schema['html'], $markup_m);
259 if (!empty($markup_m[1])) {
260 $renders = array();
261 foreach (array_unique($markup_m[1]) as $name) {
262 if (strpos($name, 'pagelayer') === 0 || strpos($name, 'func_') === 0) {
263 continue;
264 }
265 $renders[] = $name;
266 }
267 if (!empty($renders)) {
268 $out['renders'] = array_values($renders);
269 }
270 }
271 }
272 if (!empty($schema['skip_props'])) {
273 $out['unsupported_props'] = $schema['skip_props'];
274 }
275
276 $sections = array();
277 $include_pro = $this->include_pro_settings();
278 foreach ($schema['sections'] as $key => $section) {
279 $is_own = in_array($key, $own, true);
280
281 if (!empty($only)) {
282 if (!in_array($key, $only, true)) {
283 continue;
284 }
285 } elseif ($mode === 'own' && !$is_own) {
286 continue;
287 } elseif ($mode === 'shared' && $is_own) {
288 continue;
289 }
290
291 $props = array();
292 foreach ($section['properties'] as $prop_key => $prop) {
293 if ($mode === 'own' && substr($prop_key, -6) === '_hover') {
294 $base = substr($prop_key, 0, -6);
295 if ($base !== '' && isset($section['properties'][$base])) {
296 continue;
297 }
298 }
299 if (!$include_pro && !empty($prop['pro'])) {
300 continue;
301 }
302 $props[$prop_key] = $this->compact_prop($prop);
303 }
304 if (empty($props)) {
305 continue;
306 }
307 $sections[$key] = $props;
308 }
309
310 $out['props'] = $sections;
311 return $out;
312 }
313
314 private function widget_attr_rules($tag) {
315 global $pagelayer;
316 static $cache = array();
317
318 $lookup = str_replace(array('pl_inner_row', 'pl_inner_col'), array('pl_row', 'pl_col'), $tag);
319 if (array_key_exists($lookup, $cache)) {
320 return $cache[$lookup];
321 }
322
323 $this->ensure_shortcodes();
324 if (empty($pagelayer->shortcodes[$lookup])) {
325 return $cache[$lookup] = null;
326 }
327
328 $schema = $this->extract_widget_schema($lookup, $pagelayer->shortcodes[$lookup]);
329 $rules = array('allowed' => array(), 'req' => array());
330
331 foreach ($schema['sections'] as $section) {
332 foreach ($section['properties'] as $key => $prop) {
333 $rules['allowed'][$key] = isset($prop['type']) ? $prop['type'] : '';
334 if (!empty($prop['requires']) && is_array($prop['requires'])) {
335 $rules['req'][$key] = $prop['requires'];
336 }
337 if (!empty($prop['responsive'])) {
338 $rules['allowed'][$key . '_tablet'] = $rules['allowed'][$key];
339 $rules['allowed'][$key . '_mobile'] = $rules['allowed'][$key];
340 }
341 }
342 }
343
344 return $cache[$lookup] = $rules;
345 }
346
347 private function build_widget_example($tag, $data) {
348 $schema = $this->extract_widget_schema($tag, $data);
349 $inner_key = isset($data['innerHTML']) ? $data['innerHTML'] : '';
350 $own_sections = isset($data['settings']) && is_array($data['settings']) ? $data['settings'] : array();
351
352 $attrs = array();
353 foreach ($schema['sections'] as $section_key => $section) {
354 if (!isset($own_sections[$section_key])) {
355 continue;
356 }
357 foreach ($section['properties'] as $key => $prop) {
358 $type = isset($prop['type']) ? $prop['type'] : '';
359 if (strpos($key, '_hover') !== false || !empty($prop['requires'])) {
360 continue;
361 }
362 if (in_array($type, array('text', 'textarea', 'editor'), true)) {
363 $label = !empty($prop['label']) ? $prop['label'] : $key;
364 $attrs[$key] = '<on-topic ' . $label . ' — never the widget default>';
365 } elseif ($type === 'color') {
366 $attrs[$key] = '$primary';
367 } elseif ($type === 'icon') {
368 $attrs[$key] = !empty($prop['default']) ? $prop['default'] : 'fas fa-star';
369 } elseif ($type === 'image') {
370 $attrs[$key] = 'https://images.unsplash.com/photo-1557804506-669a67965ba0?w=1200';
371 } elseif ($type === 'link') {
372 $attrs[$key] = '#';
373 }
374 }
375 }
376
377 $example = array('tag' => $tag, 'attrs' => $attrs);
378 if ($inner_key && isset($attrs[$inner_key])) {
379 $example['content'] = $attrs[$inner_key];
380 unset($example['attrs'][$inner_key]);
381 }
382 return $example;
383 }
384
385 /**
386 * Compact catalog of every registered widget the builder may emit.
387 * Format: tag => "Name|group[|children][|in:parent][|content:innerHTML]"
388 */
389 public function get_widget_catalog() {
390 global $pagelayer;
391 $this->ensure_shortcodes();
392
393 $catalog = array();
394 if (empty($pagelayer->shortcodes) || !is_array($pagelayer->shortcodes)) {
395 return $catalog;
396 }
397
398 foreach ($pagelayer->shortcodes as $tag => $data) {
399 if (in_array($tag, self::$skip_tags, true)) {
400 continue;
401 }
402 if (strpos($tag, 'pl_wp_') === 0 && $tag !== 'pl_wp_menu') {
403 continue;
404 }
405
406 $name = isset($data['name']) ? $data['name'] : $tag;
407 $group = isset($data['group']) ? $data['group'] : 'misc';
408 $line = $name . '|' . $group;
409
410 if (!empty($data['holder']) || !empty($data['has_group'])) {
411 $line .= '|children';
412 }
413 if (!empty($data['parent'])) {
414 $line .= '|in:' . implode(',', (array) $data['parent']);
415 }
416 if (!empty($data['innerHTML'])) {
417 $line .= '|content:' . $data['innerHTML'];
418 }
419
420 $catalog[$tag] = $line;
421 }
422
423 return apply_filters('pagelayer_ai_widget_catalog', $catalog);
424 }
425
426 public function get_compact_schemas($tags = array()) {
427 global $pagelayer;
428 $this->ensure_shortcodes();
429
430 $catalog = $this->get_widget_catalog();
431 if (empty($tags)) {
432 $tags = array_keys($catalog);
433 }
434
435 $pro_flag = $this->include_pro_settings() ? '1' : '0';
436 $pro_ver = defined('PAGELAYER_PRO_VERSION') ? PAGELAYER_PRO_VERSION : '0';
437 $cache_key = 'pagelayer_ai_own_schemas_v5_' . PAGELAYER_VERSION . '_' . $pro_ver . '_' . $pro_flag . '_' . md5(implode(',', $tags) . count($pagelayer->shortcodes));
438 $cached = get_transient($cache_key);
439 if (is_array($cached)) {
440 return $cached;
441 }
442
443 $out = array();
444 foreach ($tags as $tag) {
445 if (!isset($pagelayer->shortcodes[$tag])) {
446 continue;
447 }
448 $schema = $this->extract_widget_schema($tag, $pagelayer->shortcodes[$tag]);
449 $compact = $this->compact_widget_schema($schema, 'own');
450 unset($compact['legend'], $compact['shared_note'], $compact['shared_style_sections']);
451 $out[$tag] = $compact;
452 }
453
454 set_transient($cache_key, $out, self::SCHEMA_CACHE_TTL);
455 return $out;
456 }
457
458 public function get_common_styles() {
459 global $pagelayer;
460 $this->ensure_shortcodes();
461
462 $probe = isset($pagelayer->shortcodes['pl_heading']) ? 'pl_heading' : (is_array($pagelayer->shortcodes) ? key($pagelayer->shortcodes) : '');
463 if (!$probe || empty($pagelayer->shortcodes[$probe])) {
464 return array();
465 }
466
467 $sections = apply_filters('pagelayer_ai_common_style_sections', array(
468 'ele_bg_styles', 'ele_styles', 'border_styles', 'font_style', 'custom_styles',
469 ));
470
471 $schema = $this->extract_widget_schema($probe, $pagelayer->shortcodes[$probe]);
472 $compact = $this->compact_widget_schema($schema, 'shared', $sections);
473 $styles = !empty($compact['props']) && is_array($compact['props']) ? $compact['props'] : array();
474
475 foreach ($styles as $section => $props) {
476 if (!is_array($props)) {
477 continue;
478 }
479 foreach (array_keys($props) as $key) {
480 if (strpos($key, '_hover') !== false || strpos($key, '_state') !== false) {
481 unset($styles[$section][$key]);
482 }
483 }
484 }
485
486 return $styles;
487 }
488
489 public function get_global_color_keys() {
490 $snap = $this->get_globals_snapshot();
491 $keys = array_keys($snap['colors']);
492 return !empty($keys) ? $keys : array('primary', 'secondary', 'text', 'accent');
493 }
494
495 /**
496 * Current Pagelayer global colors + fonts as stored (title/value maps).
497 */
498 public function get_globals_snapshot() {
499 global $pagelayer;
500 if (function_exists('pagelayer_load_global_palette')) {
501 pagelayer_load_global_palette();
502 }
503
504 $colors = (isset($pagelayer->global_colors) && is_array($pagelayer->global_colors))
505 ? $pagelayer->global_colors
506 : array();
507 $fonts = (isset($pagelayer->global_fonts) && is_array($pagelayer->global_fonts))
508 ? $pagelayer->global_fonts
509 : array();
510
511 if (empty($colors)) {
512 $colors = array(
513 'primary' => array('title' => 'Primary', 'value' => '#2563eb'),
514 'secondary' => array('title' => 'Secondary', 'value' => '#0f172a'),
515 'text' => array('title' => 'Text', 'value' => '#0f172a'),
516 'accent' => array('title' => 'Accent', 'value' => '#f59e0b'),
517 'bg' => array('title' => 'Background', 'value' => '#ffffff'),
518 'surface' => array('title' => 'Surface', 'value' => '#f8fafc'),
519 );
520 }
521
522 return array(
523 'colors' => $colors,
524 'fonts' => $fonts,
525 );
526 }
527
528 private function sanitize_hex($hex) {
529 $hex = $this->coerce_hex($hex);
530 return $hex;
531 }
532
533 private function coerce_hex($val) {
534 $val = trim((string) $val);
535 if ($val === '') {
536 return '';
537 }
538 if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $val)) {
539 return strtolower($val);
540 }
541 if (preg_match('/^([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $val)) {
542 return '#' . strtolower($val);
543 }
544 if (preg_match('/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i', $val, $m)) {
545 return sprintf('#%02x%02x%02x', min(255, (int) $m[1]), min(255, (int) $m[2]), min(255, (int) $m[3]));
546 }
547 $named = array(
548 'dark' => '#0f172a',
549 'light' => '#f8fafc',
550 'black' => '#111827',
551 'white' => '#ffffff',
552 'red' => '#ef4444',
553 'blue' => '#2563eb',
554 'green' => '#10b981',
555 'yellow' => '#f59e0b',
556 'orange' => '#f97316',
557 'purple' => '#8b5cf6',
558 'pink' => '#ec4899',
559 'navy' => '#0f172a',
560 'gold' => '#d97706',
561 'teal' => '#14b8a6',
562 'gray' => '#64748b',
563 'grey' => '#64748b',
564 'charcoal' => '#1e293b',
565 'cream' => '#fdf8f4',
566 'indigo' => '#6366f1',
567 'cyan' => '#06b6d4',
568 );
569 $low = strtolower($val);
570 if (isset($named[$low])) {
571 return $named[$low];
572 }
573 return '';
574 }
575
576 private function sanitize_font_family($name) {
577 $name = $this->first_font_name($name);
578 $name = preg_replace('/[^a-zA-Z0-9 \-]/', '', $name);
579 $name = trim(preg_replace('/\s+/', ' ', $name));
580 if ($name === '' || strlen($name) > 60) {
581 return '';
582 }
583 return $name;
584 }
585
586 private function first_font_name($name) {
587 $name = trim((string) $name);
588 $name = trim($name, "\"'`");
589 if (strpos($name, ',') !== false) {
590 $name = trim(explode(',', $name)[0], "\"'` ");
591 }
592 return $name;
593 }
594
595 private function mix_hex($a, $b, $t) {
596 $a = ltrim($this->coerce_hex($a), '#');
597 $b = ltrim($this->coerce_hex($b), '#');
598 if (strlen($a) === 3) {
599 $a = $a[0] . $a[0] . $a[1] . $a[1] . $a[2] . $a[2];
600 }
601 if (strlen($b) === 3) {
602 $b = $b[0] . $b[0] . $b[1] . $b[1] . $b[2] . $b[2];
603 }
604 if (strlen($a) < 6 || strlen($b) < 6) {
605 return '#' . $a;
606 }
607 $t = max(0, min(1, (float) $t));
608 $out = '#';
609 for ($i = 0; $i < 3; $i++) {
610 $av = hexdec(substr($a, $i * 2, 2));
611 $bv = hexdec(substr($b, $i * 2, 2));
612 $out .= sprintf('%02x', (int) round($av + ($bv - $av) * $t));
613 }
614 return $out;
615 }
616
617 private function complete_palette($palette) {
618 $snap = $this->get_globals_snapshot();
619 $keys = array(
620 'primary' => '#2563eb',
621 'secondary' => '',
622 'text' => '#0f172a',
623 'accent' => '',
624 'bg' => '#ffffff',
625 'surface' => '',
626 );
627 $out = array();
628 foreach ($keys as $key => $fallback) {
629 $hex = '';
630 if (is_array($palette) && !empty($palette[$key])) {
631 $hex = $this->coerce_hex($palette[$key]);
632 }
633 if ($hex === '' && !empty($snap['colors'][$key]['value'])) {
634 $hex = $this->coerce_hex($snap['colors'][$key]['value']);
635 }
636 $out[$key] = $hex !== '' ? $hex : $fallback;
637 }
638 if ($out['primary'] === '') {
639 $out['primary'] = '#2563eb';
640 }
641 if ($out['secondary'] === '') {
642 $out['secondary'] = $this->mix_hex($out['primary'], '#0f172a', 0.45);
643 }
644 if ($out['text'] === '') {
645 $out['text'] = '#0f172a';
646 }
647 if ($out['accent'] === '') {
648 $out['accent'] = $this->mix_hex($out['primary'], '#f59e0b', 0.4);
649 }
650 if ($out['bg'] === '') {
651 $out['bg'] = '#ffffff';
652 }
653 if ($out['surface'] === '') {
654 $out['surface'] = $this->mix_hex($out['primary'], '#ffffff', 0.92);
655 }
656 return $out;
657 }
658
659 private function complete_fonts($fonts) {
660 $snap = $this->get_globals_snapshot();
661 $read = function ($key, $fallback) use ($fonts, $snap) {
662 $raw = '';
663 if (is_array($fonts) && !empty($fonts[$key])) {
664 if (is_string($fonts[$key])) {
665 $raw = $fonts[$key];
666 } elseif (is_array($fonts[$key])) {
667 if (!empty($fonts[$key]['font-family']) && is_string($fonts[$key]['font-family'])) {
668 $raw = $fonts[$key]['font-family'];
669 } elseif (!empty($fonts[$key]['value']['font-family'])) {
670 $raw = $fonts[$key]['value']['font-family'];
671 }
672 }
673 }
674 if ($raw === '' && !empty($snap['fonts'][$key]['value']['font-family'])) {
675 $raw = $snap['fonts'][$key]['value']['font-family'];
676 }
677 $family = $this->sanitize_font_family($raw);
678 return $family !== '' ? $family : $fallback;
679 };
680
681 return array(
682 'primary' => $read('primary', 'Poppins'),
683 'secondary' => $read('secondary', 'Poppins'),
684 'text' => $read('text', 'Inter'),
685 'accent' => $read('accent', 'Poppins'),
686 );
687 }
688
689 /**
690 * Write AI-chosen palette/fonts into Pagelayer global settings and memory
691 * so $primary / $text tokens resolve during render. Always completes bg + fonts.
692 */
693 public function apply_ai_globals($palette = array(), $fonts = array(), $persist = true) {
694 global $pagelayer;
695
696 $palette = $this->complete_palette(is_array($palette) ? $palette : array());
697 $fonts = $this->complete_fonts(is_array($fonts) ? $fonts : array());
698
699 $snap = $this->get_globals_snapshot();
700 $colors = $snap['colors'];
701 $gfonts = $snap['fonts'];
702
703 $titles = array(
704 'primary' => 'Primary',
705 'secondary' => 'Secondary',
706 'text' => 'Text',
707 'accent' => 'Accent',
708 'bg' => 'Background',
709 'surface' => 'Surface',
710 );
711 foreach ($titles as $key => $title) {
712 if (empty($palette[$key])) {
713 continue;
714 }
715 $hex = $this->coerce_hex($palette[$key]);
716 if ($hex === '') {
717 continue;
718 }
719 if (!isset($colors[$key]) || !is_array($colors[$key])) {
720 $colors[$key] = array('title' => $title, 'value' => $hex);
721 } else {
722 $colors[$key]['value'] = $hex;
723 if (empty($colors[$key]['title'])) {
724 $colors[$key]['title'] = $title;
725 }
726 }
727 }
728
729 foreach (array('primary' => 'Primary', 'secondary' => 'Secondary', 'text' => 'Text', 'accent' => 'Accent') as $key => $title) {
730 if (empty($fonts[$key])) {
731 continue;
732 }
733 $family = $this->sanitize_font_family($fonts[$key]);
734 if ($family === '') {
735 continue;
736 }
737 $base = (isset($pagelayer) && is_object($pagelayer) && method_exists($pagelayer, 'default_font_styles'))
738 ? $pagelayer->default_font_styles(array('font-family' => $family))
739 : array('font-family' => $family);
740 if (!isset($gfonts[$key]) || !is_array($gfonts[$key])) {
741 $gfonts[$key] = array('title' => $title, 'value' => $base);
742 } else {
743 if (empty($gfonts[$key]['value']) || !is_array($gfonts[$key]['value'])) {
744 $gfonts[$key]['value'] = $base;
745 } else {
746 $gfonts[$key]['value']['font-family'] = $family;
747 }
748 if (empty($gfonts[$key]['title'])) {
749 $gfonts[$key]['title'] = $title;
750 }
751 }
752 }
753
754 if (isset($pagelayer) && is_object($pagelayer)) {
755 $pagelayer->global_colors = $colors;
756 $pagelayer->global_fonts = $gfonts;
757 }
758
759 if ($persist) {
760 update_option('pagelayer_global_colors', wp_json_encode($colors));
761 update_option('pagelayer_global_fonts', wp_json_encode($gfonts));
762 }
763
764 return array(
765 'colors' => $colors,
766 'fonts' => $gfonts,
767 );
768 }
769
770 /**
771 * Detect if user prompt is asking to update/change global colors
772 */
773 public function is_global_color_request($prompt) {
774 $hay = strtolower(trim((string) $prompt));
775 if ($hay === '') {
776 return false;
777 }
778 // If build/create/design command, it is a build request, not a pure color request
779 if (preg_match('/^\s*(build|create|design|generate)\b/i', $hay) || preg_match('/\b(build|create|design)\s+(other|another|a|an|new|this|page|section)\b/i', $hay)) {
780 return false;
781 }
782 if (preg_match('/\b(global\s+colou?rs?|global\s+palette|brand\s+colou?rs?|site\s+colou?rs?|theme\s+colou?r\s+global)\b/i', $hay)) {
783 return true;
784 }
785 if (preg_match('/\b(change|set|update|modify|turn|make)\b.{0,30}\b(global\s+colou?r|global\s+palette|brand\s+colou?r|theme\s+colou?r\s+global)\b/i', $hay)) {
786 return true;
787 }
788 if (preg_match('/\b(give\s+)?set\s+this\s+global\s+colou?r\b/i', $hay)) {
789 return true;
790 }
791 return false;
792 }
793
794 /**
795 * Detect if user prompt is asking to change/set background color or canvas dark/light mode
796 */
797 public function is_background_color_request($prompt) {
798 $hay = strtolower(trim((string) $prompt));
799 if ($hay === '') {
800 return false;
801 }
802 // If build/create/design command, it is a build request, not a pure background request
803 if (preg_match('/^\s*(build|create|design|generate)\b/i', $hay) || preg_match('/\b(build|create|design)\s+(other|another|a|an|new|this|page|section)\b/i', $hay)) {
804 return false;
805 }
806 if (preg_match('/\b(make|set|change|turn|switch)\b.{0,25}\b(all\s+)?(background|bg|theme)\b/i', $hay)) {
807 return true;
808 }
809 if (preg_match('/\b(change|set|update)\b.{0,20}\b(background\s+colou?r|bg\s+colou?r|canvas\s+colou?r)\b/i', $hay)) {
810 return true;
811 }
812 if (preg_match('/\b(make|set)\b.{0,20}\b(all\s+)?(background|bg)\b.{0,20}\b(dark|light|white|black|#[0-9a-f]{3,6}|[a-z]+)\b/i', $hay)) {
813 return true;
814 }
815 if (preg_match('/\b(background|bg)\b.{0,20}\b(dark|light|white|black|#[0-9a-f]{3,6}|[a-z]+)\b/i', $hay) && !preg_match('/\b(make|build|create)\s+(a|an|new)\s+(section|page|hero|pricing)\b/i', $hay)) {
816 return true;
817 }
818 if (preg_match('/\b(dark\s*mode|light\s*mode)\b/i', $hay) && !preg_match('/\b(make|build|create)\s+(a|an|new)\s+(section|page|hero|pricing)\b/i', $hay)) {
819 return true;
820 }
821 if (preg_match('/\b(give\s+)?set\s+this\s+colou?r\b/i', $hay)) {
822 return true;
823 }
824 return false;
825 }
826
827 /**
828 * Process a global color instruction, persist to DB, and return snapshot
829 */
830 public function handle_global_color_instruction($prompt) {
831 $hay = strtolower(trim((string) $prompt));
832 $snap = $this->get_globals_snapshot();
833 $palette = array();
834 foreach ($snap['colors'] as $k => $c) {
835 $palette[$k] = !empty($c['value']) ? $c['value'] : '#ffffff';
836 }
837
838 $extracted_color = $this->extract_color_from_string($hay);
839 if ($extracted_color === '') {
840 $extracted_color = '#2563eb';
841 }
842
843 $target_key = 'primary';
844 if (preg_match('/\b(secondary)\b/i', $hay)) {
845 $target_key = 'secondary';
846 } elseif (preg_match('/\b(text|copy|font\s*colou?r)\b/i', $hay)) {
847 $target_key = 'text';
848 } elseif (preg_match('/\b(accent)\b/i', $hay)) {
849 $target_key = 'accent';
850 } elseif (preg_match('/\b(background|bg)\b/i', $hay)) {
851 $target_key = 'bg';
852 } elseif (preg_match('/\b(surface)\b/i', $hay)) {
853 $target_key = 'surface';
854 }
855
856 $palette[$target_key] = $extracted_color;
857 if ($target_key === 'bg') {
858 $is_dark = $this->is_dark_color($extracted_color);
859 $palette['surface'] = $is_dark ? '#1e293b' : '#f8fafc';
860 $palette['text'] = $is_dark ? '#f8fafc' : '#0f172a';
861 }
862
863 $updated = $this->apply_ai_globals($palette, array(), true);
864
865 return array(
866 'success' => true,
867 'action' => 'update_globals',
868 'target_key' => $target_key,
869 'color' => $extracted_color,
870 'palette' => $palette,
871 'globals' => $this->get_globals_snapshot(),
872 'globals_updated' => true,
873 'message' => sprintf(__('Global %s color updated to %s.', 'pagelayer'), ucfirst($target_key), $extracted_color),
874 );
875 }
876
877 /**
878 * Process a background color or dark mode instruction, persist to DB, and return status
879 */
880 public function handle_background_color_instruction($prompt) {
881 $hay = strtolower(trim((string) $prompt));
882
883 $is_dark = false;
884 $bg_color = '';
885
886 if (preg_match('/\b(dark|black|navy|charcoal|night|dark\s*mode)\b/i', $hay)) {
887 $is_dark = true;
888 $bg_color = '#0f172a';
889 } elseif (preg_match('/\b(light|white|bright|light\s*mode)\b/i', $hay)) {
890 $is_dark = false;
891 $bg_color = '#ffffff';
892 } else {
893 $extracted = $this->extract_color_from_string($hay);
894 if ($extracted !== '') {
895 $bg_color = $extracted;
896 $is_dark = $this->is_dark_color($bg_color);
897 } else {
898 $is_dark = true;
899 $bg_color = '#0f172a';
900 }
901 }
902
903 $snap = $this->get_globals_snapshot();
904 $palette = array();
905 foreach ($snap['colors'] as $k => $c) {
906 $palette[$k] = !empty($c['value']) ? $c['value'] : '#ffffff';
907 }
908
909 $palette['bg'] = $bg_color;
910 $palette['surface'] = $is_dark ? '#1e293b' : '#f8fafc';
911 $palette['text'] = $is_dark ? '#f8fafc' : '#0f172a';
912
913 $updated = $this->apply_ai_globals($palette, array(), true);
914
915 return array(
916 'success' => true,
917 'action' => 'update_background',
918 'bg_color' => $bg_color,
919 'is_dark' => $is_dark,
920 'palette' => $palette,
921 'globals' => $this->get_globals_snapshot(),
922 'globals_updated' => true,
923 'message' => $is_dark
924 ? __('Canvas background and section rows set to dark mode.', 'pagelayer')
925 : sprintf(__('Canvas background color set to %s.', 'pagelayer'), $bg_color),
926 );
927 }
928
929 /**
930 * Extract a hex or named color from any text string
931 */
932 public function extract_color_from_string($str) {
933 if (preg_match('/#([0-9a-f]{6}|[0-9a-f]{3})\b/i', $str, $m)) {
934 return strtolower($m[0]);
935 }
936 if (preg_match('/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i', $str, $m)) {
937 return sprintf('#%02x%02x%02x', min(255, (int) $m[1]), min(255, (int) $m[2]), min(255, (int) $m[3]));
938 }
939 $named = array(
940 'dark' => '#0f172a',
941 'light' => '#f8fafc',
942 'black' => '#111827',
943 'white' => '#ffffff',
944 'red' => '#ef4444',
945 'blue' => '#2563eb',
946 'green' => '#10b981',
947 'yellow' => '#f59e0b',
948 'orange' => '#f97316',
949 'purple' => '#8b5cf6',
950 'pink' => '#ec4899',
951 'navy' => '#0f172a',
952 'gold' => '#d97706',
953 'teal' => '#14b8a6',
954 'gray' => '#64748b',
955 'grey' => '#64748b',
956 'charcoal' => '#1e293b',
957 'cream' => '#fdf8f4',
958 'indigo' => '#6366f1',
959 'cyan' => '#06b6d4',
960 );
961 foreach ($named as $name => $hex) {
962 if (preg_match('/\b' . $name . '\b/i', $str)) {
963 return $hex;
964 }
965 }
966 return '';
967 }
968
969 /**
970 * Check if custom site globals already exist
971 */
972 public function has_custom_globals() {
973 $colors_opt = get_option('pagelayer_global_colors', '');
974 $fonts_opt = get_option('pagelayer_global_fonts', '');
975 return (!empty($colors_opt) && $colors_opt !== '[]' && $colors_opt !== '""') || (!empty($fonts_opt) && $fonts_opt !== '[]' && $fonts_opt !== '""');
976 }
977
978 /**
979 * Extract flat color map and font map from current site globals snapshot
980 */
981 public function get_existing_tokens() {
982 $snap = $this->get_globals_snapshot();
983 $palette = array();
984 if (!empty($snap['colors']) && is_array($snap['colors'])) {
985 foreach ($snap['colors'] as $k => $c) {
986 $palette[$k] = !empty($c['value']) ? $c['value'] : (is_string($c) ? $c : '');
987 }
988 }
989 $fonts = array();
990 if (!empty($snap['fonts']) && is_array($snap['fonts'])) {
991 foreach ($snap['fonts'] as $k => $f) {
992 if (!empty($f['value']['font-family'])) {
993 $fonts[$k] = $f['value']['font-family'];
994 } elseif (is_string($f)) {
995 $fonts[$k] = $f;
996 }
997 }
998 }
999 return array(
1000 'palette' => $palette,
1001 'fonts' => $fonts,
1002 );
1003 }
1004
1005 public function global_font_family($key = 'primary') {
1006 $snap = $this->get_globals_snapshot();
1007 if (!empty($snap['fonts'][$key]['value']['font-family'])) {
1008 $family = $this->sanitize_font_family($snap['fonts'][$key]['value']['font-family']);
1009 if ($family !== '') {
1010 return $family;
1011 }
1012 }
1013 if ($key !== 'primary' && !empty($snap['fonts']['primary']['value']['font-family'])) {
1014 $family = $this->sanitize_font_family($snap['fonts']['primary']['value']['font-family']);
1015 if ($family !== '') {
1016 return $family;
1017 }
1018 }
1019 return ($key === 'text') ? 'Inter' : 'Poppins';
1020 }
1021
1022 /**
1023 * Live widget groups from the shortcode registry (tag => "Name").
1024 */
1025 public function get_widget_groups() {
1026 global $pagelayer;
1027 $this->ensure_shortcodes();
1028
1029 $groups = array();
1030 $catalog = $this->get_widget_catalog();
1031 if (empty($pagelayer->groups) || !is_array($pagelayer->groups)) {
1032 foreach ($catalog as $tag => $line) {
1033 $parts = explode('|', $line);
1034 $name = isset($parts[0]) ? $parts[0] : $tag;
1035 $group = isset($parts[1]) ? $parts[1] : 'misc';
1036 $groups[$group][] = $tag . ':' . $name;
1037 }
1038 return $groups;
1039 }
1040
1041 foreach ($pagelayer->groups as $group => $tags) {
1042 if (!is_array($tags)) {
1043 continue;
1044 }
1045 foreach ($tags as $tag) {
1046 if (!isset($catalog[$tag])) {
1047 continue;
1048 }
1049 $parts = explode('|', $catalog[$tag]);
1050 $groups[$group][] = $tag . ':' . $parts[0];
1051 }
1052 }
1053
1054 return $groups;
1055 }
1056
1057 /**
1058 * Pick widgets whose live name/tag/group matches the user's request.
1059 * The catalog is the source of truth — no hardcoded widget field names.
1060 */
1061 public function widgets_for_request($prompt) {
1062 $catalog = $this->get_widget_catalog();
1063 $matched = array();
1064
1065 foreach (array('pl_row', 'pl_col') as $required) {
1066 if (isset($catalog[$required])) {
1067 $matched[] = $required;
1068 }
1069 }
1070
1071 $hay = strtolower((string) $prompt);
1072 $words = preg_split('/[^a-z0-9]+/i', $hay, -1, PREG_SPLIT_NO_EMPTY);
1073 $stop = array(
1074 'a' => 1, 'an' => 1, 'the' => 1, 'and' => 1, 'or' => 1, 'for' => 1, 'with' => 1,
1075 'from' => 1, 'this' => 1, 'that' => 1, 'page' => 1, 'section' => 1, 'layout' => 1,
1076 'make' => 1, 'create' => 1, 'build' => 1, 'want' => 1, 'please' => 1, 'add' => 1,
1077 'using' => 1, 'into' => 1, 'onto' => 1, 'your' => 1, 'our' => 1, 'my' => 1,
1078 'modern' => 1, 'nice' => 1, 'good' => 1, 'best' => 1, 'new' => 1, 'some' => 1,
1079 'need' => 1, 'have' => 1, 'just' => 1, 'like' => 1, 'also' => 1,
1080 'theme' => 1, 'template' => 1, 'templates' => 1, 'header' => 1, 'footer' => 1,
1081 'error' => 1, 'found' => 1, 'design' => 1, 'generate' => 1,
1082 );
1083
1084 $meaningful = array();
1085 foreach ($words as $w) {
1086 $w = strtolower($w);
1087 if (strlen($w) < 3 || isset($stop[$w])) {
1088 continue;
1089 }
1090 $meaningful[] = $w;
1091 if (strlen($w) > 4 && substr($w, -1) === 's') {
1092 $meaningful[] = substr($w, 0, -1);
1093 }
1094 }
1095
1096 foreach ($catalog as $tag => $line) {
1097 $parts = explode('|', $line);
1098 $name = strtolower(isset($parts[0]) ? $parts[0] : '');
1099 $group = strtolower(isset($parts[1]) ? $parts[1] : '');
1100 $slug = strtolower(str_replace(array('pl_', '_'), array('', ' '), $tag));
1101 $blob = $name . ' ' . $group . ' ' . $slug . ' ' . $tag;
1102
1103 if ($name !== '' && strpos($hay, $name) !== false) {
1104 $matched[] = $tag;
1105 continue;
1106 }
1107
1108 foreach ($meaningful as $w) {
1109 if (strpos($blob, $w) !== false) {
1110 $matched[] = $tag;
1111 break;
1112 }
1113 }
1114 }
1115
1116 $matched = array_values(array_unique($matched));
1117
1118 foreach (array('pl_heading', 'pl_text', 'pl_btn', 'pl_image') as $primitive) {
1119 if (isset($catalog[$primitive]) && !in_array($primitive, $matched, true)) {
1120 $matched[] = $primitive;
1121 }
1122 }
1123
1124 // Expand holders/children of prompt matches first so purpose-built
1125 // widgets (including Pro) are not sliced off by the fallback pack.
1126 $matched = $this->expand_related_widgets($matched);
1127
1128 $kind = $this->request_kind($prompt);
1129
1130 $kind_pack = $this->widgets_for_kind($kind);
1131 if (!empty($kind_pack)) {
1132 $restricted = array();
1133 foreach ($kind_pack as $t) {
1134 if (isset($catalog[$t])) {
1135 $restricted[] = $t;
1136 }
1137 }
1138 return $this->expand_related_widgets($restricted);
1139 }
1140
1141 $extra = array();
1142 if ($kind === 'blog' || $kind === 'single') {
1143 $extra = array('pl_archive_posts', 'pl_posts', 'pl_featured_image', 'pl_post_title', 'pl_post_excerpt', 'pl_post_info', 'pl_post_content');
1144 } elseif ($kind === 'contact') {
1145 $extra = array('pl_heading', 'pl_text', 'pl_btn', 'pl_address', 'pl_phone', 'pl_email');
1146 } elseif ($kind === 'faq') {
1147 $extra = array('pl_accordion', 'pl_accordion_item', 'pl_heading', 'pl_text');
1148 }
1149
1150 foreach ($extra as $tag) {
1151 if (isset($catalog[$tag]) && !in_array($tag, $matched, true)) {
1152 $matched[] = $tag;
1153 }
1154 }
1155
1156 $requested_sections = $this->extract_requested_sections($prompt);
1157 $is_full_page = ($kind === 'home' || (!$this->is_scoped_kind($kind) && count($requested_sections) >= 2));
1158
1159 if ($is_full_page) {
1160 foreach ($this->default_widget_set() as $tag) {
1161 if (!in_array($tag, $matched, true)) {
1162 $matched[] = $tag;
1163 }
1164 }
1165 foreach (array_keys($catalog) as $tag) {
1166 if (!in_array($tag, $matched, true)) {
1167 $matched[] = $tag;
1168 }
1169 }
1170 } else {
1171 foreach (array('pl_row', 'pl_col', 'pl_heading', 'pl_text', 'pl_btn', 'pl_image') as $tag) {
1172 if (isset($catalog[$tag]) && !in_array($tag, $matched, true)) {
1173 $matched[] = $tag;
1174 }
1175 }
1176 }
1177
1178 return $this->expand_related_widgets($matched);
1179 }
1180
1181 /**
1182 * Widget allow-list for theme-builder chrome and 404 so the model cannot
1183 * reach for icon boxes / services / testimonials.
1184 */
1185 public function widgets_for_kind($kind) {
1186 $packs = array(
1187 'header' => array(
1188 'pl_row', 'pl_col', 'pl_heading', 'pl_image', 'pl_btn', 'pl_icon',
1189 'pl_wp_menu', 'pl_search', 'pl_social_grp', 'pl_social',
1190 ),
1191 'footer' => array(
1192 'pl_row', 'pl_col', 'pl_heading', 'pl_text', 'pl_btn', 'pl_image', 'pl_icon',
1193 'pl_list', 'pl_list_item', 'pl_social_grp', 'pl_social',
1194 'pl_address', 'pl_phone', 'pl_email', 'pl_divider', 'pl_space',
1195 ),
1196 '404' => array(
1197 'pl_row', 'pl_col', 'pl_heading', 'pl_text', 'pl_btn', 'pl_search',
1198 'pl_icon', 'pl_image', 'pl_list', 'pl_list_item',
1199 ),
1200 );
1201 return isset($packs[$kind]) ? $packs[$kind] : array();
1202 }
1203
1204 /**
1205 * If a holder is selected, include its children; if a child is selected, include its parent.
1206 */
1207 public function expand_related_widgets($tags) {
1208 $catalog = $this->get_widget_catalog();
1209 $set = array();
1210 foreach ((array) $tags as $tag) {
1211 $tag = $this->normalize_tag((string) $tag);
1212 if (empty($catalog) || isset($catalog[$tag])) {
1213 $set[$tag] = true;
1214 }
1215 }
1216
1217 foreach ($catalog as $tag => $line) {
1218 $parts = explode('|', $line);
1219 foreach ($parts as $part) {
1220 if (strpos($part, 'in:') !== 0) {
1221 continue;
1222 }
1223 $parents = explode(',', substr($part, 3));
1224 if (isset($set[$tag])) {
1225 foreach ($parents as $parent) {
1226 $parent = trim($parent);
1227 if (isset($catalog[$parent])) {
1228 $set[$parent] = true;
1229 }
1230 }
1231 }
1232 foreach ($parents as $parent) {
1233 $parent = trim($parent);
1234 if (isset($set[$parent]) && isset($catalog[$tag])) {
1235 $set[$tag] = true;
1236 }
1237 }
1238 }
1239 }
1240
1241 $out = array_keys($set);
1242 $max = $this->max_schema_widgets();
1243 if (count($out) > $max) {
1244 $priority = array();
1245 foreach ((array) $tags as $tag) {
1246 $tag = $this->normalize_tag((string) $tag);
1247 if (isset($set[$tag])) {
1248 $priority[$tag] = true;
1249 }
1250 }
1251 $rest = array();
1252 foreach ($out as $tag) {
1253 if (!isset($priority[$tag])) {
1254 $rest[] = $tag;
1255 }
1256 }
1257 $out = array_merge(array_keys($priority), $rest);
1258 $out = array_slice($out, 0, $max);
1259 }
1260 return $out;
1261 }
1262
1263 public function max_schema_widgets() {
1264 $max = (int) apply_filters('pagelayer_ai_max_schema_widgets', self::MAX_SCHEMA_WIDGETS);
1265 return $max > 0 ? $max : self::MAX_SCHEMA_WIDGETS;
1266 }
1267
1268 public function get_compact_examples($tags) {
1269 global $pagelayer;
1270 $this->ensure_shortcodes();
1271
1272 $out = array();
1273 foreach ((array) $tags as $tag) {
1274 $tag = $this->normalize_tag((string) $tag);
1275 if (empty($pagelayer->shortcodes[$tag])) {
1276 continue;
1277 }
1278 $example = $this->build_widget_example($tag, $pagelayer->shortcodes[$tag]);
1279 if (empty($example) || !is_array($example)) {
1280 continue;
1281 }
1282 if (!empty($example['attrs']) && is_array($example['attrs'])) {
1283 foreach ($example['attrs'] as $k => $v) {
1284 if (is_string($v) && strlen($v) > 80) {
1285 $example['attrs'][$k] = substr($v, 0, 77) . '...';
1286 }
1287 }
1288 }
1289 if (!empty($example['content']) && is_string($example['content']) && strlen($example['content']) > 80) {
1290 $example['content'] = substr($example['content'], 0, 77) . '...';
1291 }
1292 $out[$tag] = $example;
1293 }
1294 return $out;
1295 }
1296
1297 private function schema_legend() {
1298 return 'prop format "type|lbl:Label|def:X|opts:a,b|min-maxunit|req:attr=val|resp". '
1299 . 'Use the property KEY as the attrs key and lbl as the meaning — never invent a shorter alias. '
1300 . 'req = companion attr that must ALSO be set on the same node or Pagelayer discards the property at render with no error. '
1301 . '"a/b" = any one of those values; "&" = all conditions; leading "!" negates. '
1302 . 'resp = also accepts _tablet and _mobile suffixed siblings. '
1303 . 'content_attr = that field is the node "content" string, not an attrs key. '
1304 . 'renders = attrs the widget markup actually interpolates.';
1305 }
1306
1307 public function get_plan_prompt() {
1308 $catalog = $this->get_widget_catalog();
1309 $groups = $this->get_widget_groups();
1310
1311 $prompt = "You are a senior product/brand designer planning a Pagelayer page or Theme Builder template.\n";
1312 $prompt .= "Infer what the named brand, domain, or product actually is (industry, audience, offer) from the prompt. ";
1313 $prompt .= "SiteSEO is an SEO product, not a restaurant; a pizzeria is not a gym. Design for THAT business.\n";
1314 $prompt .= "SCOPE: Plan ONLY what was asked. A header, footer, 404, blog, or inner page is NOT a homepage — do not add extra marketing bands.\n";
1315 $prompt .= "NO HEADER / NO FOOTER RULE: Do NOT include a website header (navbar, site menu, logo bar) or website footer (copyright bar, site-wide footer links) in the plan UNLESS the user explicitly asks to build a header or footer. In Pagelayer, headers and footers are managed separately via Pagelayer Theme Builder templates.\n";
1316 $prompt .= "Choose REAL widgets from the live catalog. Do not invent tags. Include holder children.\n";
1317 $prompt .= "Invent a complete, original information architecture and column map for THIS request. ";
1318 $prompt .= "Do not reuse a canned skeleton and do not shrink a homepage to 2–3 generic bands.\n";
1319 $prompt .= "A homepage/site must feel like a finished marketing theme: distinctive opening, the product explained, proof or specifics, conversion, plus only the extra bands THIS product needs.\n";
1320 $prompt .= "A header/footer/404 plan must be a finished Theme Builder template of that type only — never a photo-hero with one line of text, and never extra features/services sections.\n\n";
1321 $prompt .= "OUTPUT a JSON object only:\n";
1322 $prompt .= '{"widgets":["pl_row","pl_col",...],"layout":[{"name":"block-name","cols":[7,5],"widgets":["pl_heading","pl_btn","pl_image"]}],"palette":{"primary":"#hex","secondary":"#hex","text":"#hex","accent":"#hex","bg":"#hex","surface":"#hex"},"fonts":{"primary":"Heading Font","text":"Body Font"},"mood":"short visual direction","summary":"one sentence"}\n';
1323 $prompt .= "layout[] is the architecture you invented (column splits + widgets per band) for THIS brand. Always include pl_row and pl_col.\n";
1324 $prompt .= "palette MUST include bg and surface. fonts.primary is the heading family, fonts.text is the body family (real Google fonts).\n";
1325 $prompt .= "widgets[] should list every catalog tag this page will use — prefer purpose-built widgets, not only heading/text/button/image.\n";
1326 $prompt .= "\nWIDGET GROUPS (live on this install):\n";
1327 $prompt .= wp_json_encode($groups, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n\n";
1328 $prompt .= "CATALOG (tag => Name|group[|children][|in:parent][|content:innerHTML]):\n";
1329 $prompt .= wp_json_encode($catalog, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
1330
1331 return apply_filters('pagelayer_ai_plan_prompt', $prompt, $this);
1332 }
1333
1334 public function get_build_prompt($widget_tags = array(), $opts = array()) {
1335 $catalog = $this->get_widget_catalog();
1336 $groups = $this->get_widget_groups();
1337 $kind = !empty($opts['kind']) ? $opts['kind'] : 'section';
1338
1339 if (empty($widget_tags)) {
1340 $widget_tags = array_keys($catalog);
1341 }
1342
1343 $widget_tags = $this->expand_related_widgets(array_merge(
1344 array('pl_row', 'pl_col'),
1345 $widget_tags
1346 ));
1347
1348 $schemas = $this->get_compact_schemas($widget_tags);
1349 $styles = $this->get_common_styles();
1350 $example_tags = array_values(array_diff($widget_tags, array('pl_row', 'pl_col', 'pl_inner_row', 'pl_inner_col')));
1351 $examples = $this->get_compact_examples(array_slice($example_tags, 0, 20));
1352
1353 $scoped = $this->is_scoped_kind($kind);
1354 $chrome = in_array($kind, array('header', 'footer', '404'), true);
1355
1356 if ($chrome) {
1357 $allowed = $this->widgets_for_kind($kind);
1358 if (!empty($allowed)) {
1359 $widget_tags = $this->expand_related_widgets(array_merge(array('pl_row', 'pl_col'), $allowed));
1360 }
1361 $filtered = array();
1362 foreach ($widget_tags as $t) {
1363 if (isset($catalog[$t])) {
1364 $filtered[$t] = $catalog[$t];
1365 }
1366 }
1367 if (!empty($filtered)) {
1368 $catalog = $filtered;
1369 }
1370 $schemas = $this->get_compact_schemas($widget_tags);
1371 $example_tags = array_values(array_diff($widget_tags, array('pl_row', 'pl_col', 'pl_inner_row', 'pl_inner_col')));
1372 $examples = $this->get_compact_examples(array_slice($example_tags, 0, 20));
1373 }
1374
1375 $prompt = "You are a senior product designer and Pagelayer layout engine. Convert the user's request into a production-ready, visually finished Pagelayer JSON theme — not a wireframe sketch.\n";
1376 $prompt .= "SCOPE: Build ONLY what they asked for. A header, footer, 404, blog template, service page, about page, or contact page is NOT a homepage. Do not add extra marketing bands they did not request.\n";
1377 if ($kind !== 'header' && $kind !== 'footer') {
1378 $prompt .= "NO HEADER / NO FOOTER RULE: Do NOT generate a website navigation header (logo + nav menu bar) or website footer (copyright bar / footer links) UNLESS the user explicitly asked to create a header or footer. Pagelayer handles site headers and footers globally via Theme Builder templates.\n";
1379 }
1380 $prompt .= "BRAND: Infer the real product/industry from the brand, domain, or topic in the prompt and design for that. Do not invent a generic layout and swap the name in.\n";
1381 $prompt .= "STRUCTURE: YOU invent the information architecture, column map, widget mix, and visual composition for THIS request. Do not reuse a canned skeleton. Do not emit section-preset shorthand ({\"section\":\"hero\"}). Always emit real pl_row / pl_col trees with widget settings.\n";
1382 $prompt .= "BADGES: A section badge is its own widget/row above the heading, wrapped in <span class=\"pagelayer-badge\"> with margin-bottom. Never overlay raw '(ribbon text)' or badge strings on a CTA heading. If ribbon_text is empty or a placeholder, omit the ribbon (show_ribbon unset).\n";
1383 $prompt .= "SOCIAL: Use pl_social_grp > pl_social children in a horizontal inline-flex row with gap. Never stack social icons as full-width vertical buttons. icon_color and icon_bg_color MUST contrast (white glyph on \$primary circle). Never set them to the same color.\n";
1384 if (!empty($opts['structure_brief'])) {
1385 $prompt .= $opts['structure_brief'] . "\n\n";
1386 } elseif ($kind !== 'section') {
1387 $prompt .= $this->structure_brief($kind, !empty($opts['prompt']) ? $opts['prompt'] : '') . "\n\n";
1388 }
1389 if ($kind === 'home') {
1390 $prompt .= "This request is a HOMEPAGE / FULL SITE landing. Invent a complete branded marketing theme for this product — opening hero, offer, proof/specifics, conversion, plus only bands that belong to THIS product. (Do NOT include site header or site footer).\n\n";
1391 } elseif ($kind === 'header') {
1392 $prompt .= "STRICT SCOPE FOR HEADER THEME TEMPLATE:\n";
1393 $prompt .= "- This is a Pagelayer Theme Builder HEADER, not a page. Generate ONE slim [pl_row]: brand/logo + pl_wp_menu (horizontal) + optional CTA.\n";
1394 $prompt .= "- Color bar background (ele_bg_type=color). Do NOT use a full-bleed photo as the design.\n";
1395 $prompt .= "- ABSOLUTELY DO NOT add features, services, icon boxes, testimonials, heroes, or any extra page sections.\n\n";
1396 } elseif ($kind === 'footer') {
1397 $prompt .= "STRICT SCOPE FOR FOOTER THEME TEMPLATE:\n";
1398 $prompt .= "- This is a Pagelayer Theme Builder FOOTER, not a page. Generate TWO [pl_row]s: (1) 3–4 columns — Brand/about, Useful links (pl_list), Contact, Social; (2) copyright bar.\n";
1399 $prompt .= "- Color background (ele_bg_type=color). A footer that is only a background image plus one line of text is a FAILED response.\n";
1400 $prompt .= "- ABSOLUTELY DO NOT add features, services, icon boxes, testimonials, pricing, hero, or any extra marketing sections.\n\n";
1401 } elseif ($kind === '404') {
1402 $prompt .= "STRICT SCOPE FOR 404 ERROR THEME TEMPLATE:\n";
1403 $prompt .= "- This is a Pagelayer Theme Builder 404 template, not a homepage. Build a finished error screen: large 404 h1, headline, explanation, pl_search, Back to Home pl_btn. Optional 3–4 text links.\n";
1404 $prompt .= "- Centered on a color background. A 404 that is only a background image plus one heading is a FAILED response.\n";
1405 $prompt .= "- ABSOLUTELY DO NOT add feature cards, icon boxes, services, counters, testimonials, pricing, or marketing bands.\n\n";
1406 } elseif ($kind === 'blog') {
1407 $prompt .= "This is a BLOG LISTING / ARCHIVE theme template only. Invent the composition. Use catalog post widgets if available. No marketing homepage.\n\n";
1408 } elseif ($kind === 'single') {
1409 $prompt .= "This is a SINGLE POST theme template only. Invent the composition. No homepage.\n\n";
1410 } elseif ($kind === 'service') {
1411 $prompt .= "This is a SERVICES page only. Invent a layout that fits this domain. Do not wrap it in a homepage.\n\n";
1412 } elseif (in_array($kind, array('about', 'contact', 'pricing', 'faq', 'search'), true)) {
1413 $prompt .= "This is a dedicated {$kind} page only. Invent the composition. Do not expand it into a homepage. Do not add extra unrequested sections.\n\n";
1414 } else {
1415 $prompt .= "STRICT SCOPE: Follow the user's request. If they asked for one section, build ONLY that section. Do NOT add extra unrequested sections (such as feature icon boxes, services, or testimonials) unless explicitly requested in the prompt — but still finish it as designed UI, not unstyled defaults.\n\n";
1416 }
1417 $prompt .= "Use ONLY tags from the catalog and ONLY attribute keys from the widget schemas / common styles below. Inventing a field name makes the style silently vanish.\n\n";
1418
1419 $prompt .= "OUTPUT a JSON object only:\n";
1420 $prompt .= '{"palette":{"primary":"#hex","secondary":"#hex","text":"#hex","accent":"#hex","bg":"#hex"},"fonts":{"primary":"Font Name","text":"Font Name"},"nodes":[ ... ]}' . "\n";
1421 $prompt .= "palette/fonts are written to Pagelayer global settings. Each node: {\"tag\":\"pl_...\",\"attrs\":{...},\"content\": ...}\n";
1422 $prompt .= "- content is a STRING when the schema has content_attr (that text is innerHTML).\n";
1423 $prompt .= "- content is an ARRAY of child nodes for containers (pl_row, pl_col, and any widget marked |children or accepts_children).\n";
1424 $prompt .= "- No markdown. No greeting. JSON only.\n\n";
1425
1426 $prompt .= "HIERARCHY:\n";
1427 $prompt .= "1. Root nodes are pl_row.\n";
1428 $prompt .= "2. pl_row children are ONLY pl_col. Column widths (attrs.col 1-12) in a row MUST sum to 12.\n";
1429 $prompt .= "3. Widgets live inside pl_col. Nested rows inside a column are still pl_row/pl_col.\n";
1430 $prompt .= "4. Widgets marked |in:parent MUST be nested under that parent.\n";
1431 $prompt .= "5. Never emit empty columns. Never put a leaf widget directly in a row.\n\n";
1432
1433 if (!$chrome) {
1434 $prompt .= "CARD GRID LAYOUT (only when you actually use repeating cards):\n";
1435 $prompt .= "- You are NOT required to use icon boxes. Use them only if this page needs that content.\n";
1436 $prompt .= "- When you DO use Icon Box, Feature, Service, Testimonial, Counter, Pricing, or similar cards: one widget per column, never stacked in a single column.\n";
1437 $prompt .= "- 1 item = one col=12. 2 items = one row of two col=6. 3 items = one row of three col=4: [Item1][Item2][Item3].\n";
1438 $prompt .= "- 4+ items: fill a row of 3 columns, then start a NEW sibling pl_row underneath for the rest. Example 6 items:\n";
1439 $prompt .= " Row A: [Item1 col=4] [Item2 col=4] [Item3 col=4]\n";
1440 $prompt .= " Row B: [Item4 col=4] [Item5 col=4] [Item6 col=4]\n";
1441 $prompt .= "- Never put 4+ card columns in the same pl_row. Never wrap a card grid in a nested row inside a col=12. Never dump all cards into one column.\n";
1442 $prompt .= "- Section headings/subcopy go in their own full-width col=12 row above the card grid, not inside the first card column.\n";
1443 $prompt .= "- UNIFORM IMAGE & ICON SIZING: When using Image Box (pl_service) or card images, all images across columns MUST have the exact same height (220px, object-fit: cover) so card headings and buttons align across columns. For Icon Box (pl_iconbox), all icons must use identical 64px by 64px sizing. For Image Slider (pl_image_slider), do NOT leave images unconstrained or excessively tall; keep slider image height well-proportioned (400px–440px height, object-fit: cover, max-width: 1000px, border-radius: 12px) so it fits elegantly on the screen.\n\n";
1444 }
1445
1446 if ($kind === 'home' || (!empty($opts['full_page']) && !$scoped)) {
1447 $prompt .= "WIDGET MIX (required for homepage / full page):\n";
1448 $prompt .= "- Use the catalog. A homepage built only from heading+text+button+image is a failed response.\n";
1449 $prompt .= "- Pick purpose-built widgets that match the content (icon box, counter, testimonial, accordion, list, image, video, pricing, stars, etc.) and fill THAT widget's own schema fields.\n";
1450 $prompt .= "- A homepage should typically use 8+ different widget tags besides pl_row/pl_col.\n\n";
1451 } elseif ($kind === 'header') {
1452 $prompt .= "WIDGET MIX (header theme template):\n";
1453 $prompt .= "- Use pl_heading or pl_image for the brand, pl_wp_menu for navigation, optional pl_btn CTA.\n";
1454 $prompt .= "- Do NOT use icon boxes, services, testimonials, or page sections.\n\n";
1455 } elseif ($kind === 'footer') {
1456 $prompt .= "WIDGET MIX (footer theme template):\n";
1457 $prompt .= "- Use pl_heading, pl_text, pl_list + pl_list_item, pl_address/pl_phone/pl_email, pl_social_grp + pl_social, optional pl_btn.\n";
1458 $prompt .= "- Do NOT use icon boxes, services, testimonials, counters, or pricing.\n\n";
1459 } elseif ($kind === '404') {
1460 $prompt .= "WIDGET MIX (for 404 error theme template):\n";
1461 $prompt .= "- Use pl_heading (404 number + title), pl_text (explanation), pl_btn (Back to Home), pl_search, optional pl_icon and link buttons.\n";
1462 $prompt .= "- Absolutely DO NOT use icon boxes, testimonials, counters, or feature/service cards.\n\n";
1463 } else {
1464 $prompt .= "WIDGET MIX:\n";
1465 $prompt .= "- Use purpose-built widgets that strictly match what the user requested.\n";
1466 $prompt .= "- Do NOT add feature cards, icon boxes, services, or testimonials unless the user specifically asked for them.\n\n";
1467 }
1468
1469 $prompt .= "FONTS:\n";
1470 $prompt .= "- palette/fonts in the JSON are saved as Pagelayer globals. Always send fonts.primary (headings) and fonts.text (body) as real Google font names (e.g. Poppins, Inter, Outfit).\n";
1471 $prompt .= "- Typography props (heading_typo, service_heading_typo, btn_typo, ...) are 11 comma fields: family,size,style,weight,variant,deco-line,deco-style,line-height,transform,letter,word.\n";
1472 $prompt .= "- ALWAYS put the heading font name in field 0, e.g. \"Poppins,48,,800,,,,1.15,,,\". An empty family means the theme font and looks unfinished.\n";
1473 $prompt .= "- Body copy (pl_text): set font_family to the body font name AND font_size (16) and line_height (1.7). Do not use font_size on widgets that have their own typography prop.\n\n";
1474
1475 $prompt .= "COLORS AND BACKGROUNDS:\n";
1476 $prompt .= "- Invent a NEW palette for THIS brand. JSON palette MUST include primary, secondary, text, accent, bg, surface as #hex.\n";
1477 $prompt .= "- After you output palette, PHP writes it to the site. Reference colors as \$primary, \$secondary, \$text, \$accent, \$bg, \$surface.\n";
1478 $prompt .= "- EVERY pl_row needs ele_bg_type=color AND ele_bg_color (\$primary / \$bg / \$surface / a hex). Alternate dark and light bands.\n";
1479 $prompt .= "- Dark backgrounds MUST use light text (#ffffff). Light / white backgrounds MUST use dark text (\$text / \$primary / #111827). Never put black/#000/\$text on a dark ele_bg_color, and never put white/#fff on a white/\$bg/\$surface band — both are unreadable.\n";
1480 $prompt .= "- Social icons: bg_shape=pagelayer-social-shape-circle, icon_color=#ffffff, icon_bg_color=\$primary (never the same value).\n";
1481 $prompt .= "- Image sliders: controls=arrows, nav_size ~28, arraow_bg_size ~48, arraow_color=#ffffff, arrows_bg=rgba(15,23,42,0.72). Arrow glyph and arrow button MUST be different colors.\n";
1482 $prompt .= "- Accordion / Tabs: tabs_color and tabs_bg_color MUST contrast (e.g. #111827 on #f1f5f9). Active tab is #ffffff on \$primary. Panel text is dark on a light panel. Never set tab text, tab background, and section background to the same color.\n";
1483 $prompt .= "- Counter: counter_start_number must be 1 or greater (never 0 — 0 hides the widget). counter_end_number greater than start.\n";
1484 $prompt .= "- EVERY widget: never use the same color for a foreground (text/icon/arrow) and its background.\n";
1485 $prompt .= "- Set each widget's OWN color fields from its schema (icon color, heading color, text color, button colors). Empty own colors render as widget defaults.\n";
1486 $prompt .= "- ALWAYS set companion attrs: ele_bg_color needs ele_bg_type=color; btn_bg_color needs type=pagelayer-btn-custom; border_width/border_color need border_type=solid.\n\n";
1487
1488 $prompt .= "SETTINGS (read the schema — do not guess names; unstyled widgets are a failed response):\n";
1489 $prompt .= "- The property KEY is the attrs key. lbl: is the meaning. renders: lists attrs the widget markup actually uses.\n";
1490 $prompt .= "- A prop with req:X=Y is discarded at render unless X is also set on the SAME node. Widget defaults do NOT count.\n";
1491 $prompt .= "- NEVER write style=\"\" or <style> in content. Styling is attrs only. Use the exact keys from WIDGET SCHEMAS and COMMON STYLES.\n";
1492 $prompt .= "- Heading/title content is HTML such as <h1>, <h2>, <h3> inside content. One h1 per layout.\n";
1493 $prompt .= "- Set each widget's OWN visual fields from its schema (colors, icons, images, alignment, typography, spacing) AND common styles (ele_padding, ele_bg_type+ele_bg_color, border_radius, ele_shadow).\n";
1494 $prompt .= "- Images: real topical Unsplash https URLs in the schema's image key (photo of THIS subject). Do not invent fake domains.\n";
1495 if ($chrome) {
1496 $prompt .= "- For header/footer/404: prefer ele_bg_type=color. Do NOT make the whole template a single background-image hero with one line of text.\n";
1497 }
1498 $prompt .= "\n";
1499
1500 $prompt .= "DUMMY DATA (required):\n";
1501 if ($kind === 'footer') {
1502 $prompt .= "- Invent realistic footer copy: brand blurb, 4–6 useful links, address/phone/email, social labels, © year copyright. No feature/service/testimonial copy.\n";
1503 } elseif ($kind === 'header') {
1504 $prompt .= "- Invent a short brand name and a real CTA label. Navigation comes from pl_wp_menu, not fake Home/About/Services text links unless no menu widget exists.\n";
1505 } elseif ($kind === '404') {
1506 $prompt .= "- Invent a short on-brand 404 headline and explanation, a search placeholder, and a Back to Home label. No feature/service marketing copy.\n";
1507 } else {
1508 $prompt .= "- Invent realistic dummy content that matches the user's prompt topic (names, prices, quotes, features, FAQs, stats, addresses, emails, phones, button labels).\n";
1509 }
1510 $prompt .= "- Fill every heading, text, button, and list item. Never leave schema defaults like \"Icon Box\", \"Title\", \"Lorem ipsum\", empty strings, or placeholders.\n";
1511 $prompt .= "- Copy is on-topic for THIS brand/product.\n\n";
1512
1513 if ($chrome) {
1514 $prompt .= "DESIGN THIS TEMPLATE:\n";
1515 $prompt .= "- Follow the STRICT SCOPE above. Column widths in a row MUST sum to 12.\n";
1516 $prompt .= "- Header padding ~16–28px vertical. Footer main row ~48–64px vertical, copyright row ~16–24px. 404 content row min-height via ele_padding (80px+ vertical) and centered align.\n";
1517 $prompt .= "- Buttons: type=pagelayer-btn-custom (required for colors), plus btn_bg_color and btn_color.\n";
1518 $prompt .= "- High contrast. No '{ribbon_text}' tokens. No Lorem ipsum.\n\n";
1519 } else {
1520 $prompt .= "DESIGN THE THEME (you invent the structure — nothing is prescribed):\n";
1521 $prompt .= "- Decide rows, column splits (they must sum to 12), which catalog widgets belong in each band, and whether a band is image-led, product-led, list-led, or quote-led — based on THIS brand.\n";
1522 $prompt .= "- Copy, photos, palette, widgets, AND the wireframe are all invented for this prompt.\n";
1523 $prompt .= "- Alternate light/dark or tinted rows via ele_bg_type+ele_bg_color. Strong type. EVERY section pl_row must keep its OWN padding-top AND padding-bottom (50px-60px, 20px left/right). Never 0 top on a following row — that makes it touch the columns above.\n";
1524 $prompt .= "- Padding, radius, shadow via attrs. High contrast (WCAG-ish): light text on dark bands, dark text on light bands.\n";
1525 $prompt .= "- Buttons: type=pagelayer-btn-custom (required for colors), size=pagelayer-btn-large, plus btn_bg_color and btn_color.\n";
1526 $prompt .= "- One h1 per page. High contrast. No '{ribbon_text}' tokens. No Lorem ipsum.\n\n";
1527 }
1528
1529 $prompt .= "SPACING (schema defaults do NOT render unless the attr is set):\n";
1530 $prompt .= "- Column gaps MUST be the row setting col_gap (use 20). PHP emits padding on .pagelayer-col-holder — that is what the editor AND the frontend both show.\n";
1531 $prompt .= "- NEVER fake a column gap with ele_css (gap / grid-gap / column-gap), CSS grid, widget margins, or a transparent border on the column. Those render on the frontend only and disappear in the Pagelayer editor.\n";
1532 $prompt .= "- Every pl_col MUST set widget_space (use 20). That is the gap between stacked widgets in a column.\n";
1533 if (!$chrome) {
1534 $prompt .= "- Card widgets MUST set ele_padding e.g. 32px,28px,32px,28px so the card chrome sits inside the col_gap, visible in both editor and frontend.\n";
1535 $prompt .= "- Icon Box (pl_iconbox): service_alignment=top, service_icon_alignment=center (Horizontal Position), heading_alignment=center, service_text_alignment=center. service_heading_typo like \"Poppins,20,,700,,,,1.3,,,\". Body copy ~15px / 1.7.\n";
1536 $prompt .= "- Do not put ele_bg_color / ele_shadow / border on pl_col for a card grid. Style the widget; leave the column transparent so col_gap is the visible gutter.\n";
1537 }
1538 $prompt .= "\n";
1539
1540 $prompt .= "SCHEMA LEGEND: " . $this->schema_legend() . "\n\n";
1541 if (!$chrome) {
1542 $prompt .= "WIDGET GROUPS:\n";
1543 $prompt .= wp_json_encode($groups, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n\n";
1544 }
1545 $prompt .= "CATALOG (every widget you may use — do not invent tags outside this list):\n";
1546 $prompt .= wp_json_encode($catalog, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n\n";
1547 $prompt .= "WIDGET SCHEMAS (own settings for the widgets this layout needs):\n";
1548 $prompt .= wp_json_encode($schemas, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n\n";
1549 if (!empty($examples)) {
1550 $prompt .= "EXAMPLE NODES (live schema — copy these attr names, not this layout):\n";
1551 $prompt .= wp_json_encode($examples, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n\n";
1552 }
1553 $prompt .= "COMMON STYLES (accepted by every widget/row/column — set on attrs):\n";
1554 $prompt .= wp_json_encode($styles, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
1555
1556 return apply_filters('pagelayer_ai_build_prompt', $prompt, $widget_tags, $this, $opts);
1557 }
1558
1559 public function should_two_pass() {
1560 return apply_filters('pagelayer_ai_should_two_pass', false);
1561 }
1562
1563 /**
1564 * Page-level requests get a planner pass so the builder designs a theme
1565 * instead of a single-shot sketch. Tiny "add a button" prompts stay one pass.
1566 */
1567 public function should_plan($kind) {
1568 $page = in_array($kind, array('home', 'header', 'footer', '404', 'blog', 'single', 'search', 'about', 'contact', 'service', 'pricing', 'faq'), true);
1569 return (bool) apply_filters('pagelayer_ai_should_plan', $page || $this->should_two_pass(), $kind);
1570 }
1571
1572 public function get_plan_user_message($prompt, $context = array()) {
1573 $kind = $this->request_kind($prompt);
1574 $topic = $this->infer_page_topic($prompt);
1575 $msg = "Plan a designed theme for this request. Brand/topic: {$topic}.\n";
1576 $msg .= $this->inner_page_brief($kind, $prompt) . "\n";
1577 $msg .= $this->structure_brief($kind, $prompt) . "\n";
1578 if ($kind === 'header' || $kind === 'footer' || $kind === '404') {
1579 $msg .= "This is a Theme Builder {$kind} template. Plan ONLY that template. Do not plan a homepage or extra marketing sections.\n";
1580 } elseif ($kind !== 'home') {
1581 $msg .= "Do not plan a homepage. Do not add extra unrelated sections (features, services, testimonials) unless the user asked for them.\n";
1582 } else {
1583 $msg .= "Plan a complete branded homepage/site for this industry — not a 2–3 band sketch and not a generic SaaS clone.\n";
1584 }
1585 if (!empty($context['use_existing_globals']) && !empty($context['existing_palette'])) {
1586 $msg .= "\nCRITICAL: The user has chosen to use the existing site palette (" . wp_json_encode($context['existing_palette']) . ") and typography (" . (!empty($context['existing_fonts']) ? wp_json_encode($context['existing_fonts']) : '') . "). You MUST preserve and reuse these exact colors and fonts in your plan and layout.\n";
1587 }
1588
1589 $requested_sections = !empty($context['requested_sections']) ? $context['requested_sections'] : $this->extract_requested_sections($prompt);
1590 if ($this->is_scoped_kind($kind)) {
1591 $requested_sections = array();
1592 }
1593 if (!empty($requested_sections)) {
1594 $msg .= "\nCRITICAL REQUIREMENT — USER EXPLICITLY SPECIFIED THESE " . count($requested_sections) . " SECTIONS:\n";
1595 $msg .= "Your plan MUST include an entry in layout[] for every single one of these sections in order:\n";
1596 foreach ($requested_sections as $i => $sec) {
1597 $num = $i + 1;
1598 $desc = !empty($sec['desc']) ? "" . $sec['desc'] : '';
1599 $msg .= " {$num}. \"{$sec['name']}\"{$desc}\n";
1600 }
1601 $msg .= "Do NOT omit, skip, or merge any requested section.\n";
1602 }
1603
1604 $msg .= $prompt;
1605 if (!empty($context['page_title'])) {
1606 $msg .= "\nTarget page title: " . $context['page_title'];
1607 }
1608 if (!empty($context['section_type'])) {
1609 $msg .= "\nSection type: " . $context['section_type'];
1610 }
1611 return $msg;
1612 }
1613
1614 public function get_build_user_message($prompt, $context = array(), $plan = array()) {
1615 $kind = $this->request_kind($prompt);
1616 $topic = $this->infer_page_topic($prompt);
1617 $msg = "Brand/topic to design for: {$topic}. Infer what this product/business is and design a finished theme for it.\n";
1618 $msg .= $this->inner_page_brief($kind, $prompt) . "\n";
1619 $msg .= $this->structure_brief($kind, $prompt) . "\n";
1620 $msg .= $prompt;
1621 if (!empty($context['page_title'])) {
1622 $msg .= "\nTarget page title: " . $context['page_title'];
1623 }
1624 if (!empty($context['section_type'])) {
1625 $msg .= "\nSection type: " . $context['section_type'];
1626 }
1627 if (!empty($plan['mood'])) {
1628 $msg .= "\nBrand mood from the plan: " . $plan['mood'];
1629 }
1630 if (!empty($plan['summary'])) {
1631 $msg .= "\nPlan summary: " . $plan['summary'];
1632 }
1633 if (!empty($plan['use_existing_globals'])) {
1634 $msg .= "\nIMPORTANT: The user wants to preserve existing site global colors & fonts. Reuse existing global tokens (\$primary, \$secondary, \$text, \$accent, \$bg, \$surface) and typography.";
1635 }
1636 if (!empty($plan['palette']) && is_array($plan['palette'])) {
1637 $msg .= "\nPalette to apply: " . wp_json_encode($plan['palette']);
1638 }
1639 if (!empty($plan['fonts']) && is_array($plan['fonts'])) {
1640 $msg .= "\nFonts to apply: " . wp_json_encode($plan['fonts']);
1641 }
1642 if (!empty($plan['layout']) && is_array($plan['layout'])) {
1643 $msg .= "\nUse this invented architecture as a starting point and enrich it (do not collapse it into a thin generic template): " . wp_json_encode($plan['layout']);
1644 }
1645
1646 $requested_sections = !empty($plan['requested_sections']) ? $plan['requested_sections'] : (!empty($context['requested_sections']) ? $context['requested_sections'] : $this->extract_requested_sections($prompt));
1647 if ($this->is_scoped_kind($kind)) {
1648 $requested_sections = array();
1649 }
1650 if (!empty($requested_sections)) {
1651 $msg .= "\n\n" . str_repeat('=', 70) . "\n";
1652 $msg .= "CRITICAL REQUIREMENT — ALL USER-SPECIFIED SECTIONS MUST BE INCLUDED:\n";
1653 $msg .= "The user explicitly listed the following " . count($requested_sections) . " sections. You MUST generate ALL of them in order in your \"nodes\" array. Every requested section must be a complete [pl_row] with realistic dummy copy and widget settings. DO NOT skip, omit, or truncate ANY section:\n";
1654 foreach ($requested_sections as $i => $sec) {
1655 $num = $i + 1;
1656 $desc = !empty($sec['desc']) ? "" . $sec['desc'] : '';
1657 $msg .= " {$num}. [pl_row] \"{$sec['name']}\"{$desc}\n";
1658 }
1659 $msg .= "\nTOKEN BUDGETING: Ensure you balance widget detail across all " . count($requested_sections) . " sections so you never run out of tokens before completing the final section. Every single section from 1 to " . count($requested_sections) . " MUST be in your JSON response.\n";
1660 $msg .= str_repeat('=', 70) . "\n\n";
1661 }
1662
1663 $msg .= "\nSet real widget settings from the schemas (own colors/typography/icons/images/alignment AND common ele_padding, ele_bg_type+ele_bg_color, border_radius, ele_shadow). Companion req attrs must be set on the same node.";
1664 $msg .= "\nPalette must include primary, secondary, text, accent, bg, surface. Fonts must include primary (headings) and text (body) as Google font names.";
1665 $msg .= "\nEvery heading_typo must start with the heading font name (Family,size,,weight,...). Every pl_text needs font_family. Every pl_row needs ele_bg_type+ele_bg_color.";
1666 if ($kind === 'home' || !empty($requested_sections)) {
1667 $msg .= "\nUse many catalog widgets (icon box, counter, testimonial, accordion, list, image, ...), not only heading/text/button.";
1668 } elseif ($kind === 'header') {
1669 $msg .= "\nSTRICT HEADER SCOPE: ONE slim header row (logo + pl_wp_menu + optional CTA). Color bar, not a photo hero. NO features, services, or page sections.";
1670 } elseif ($kind === 'footer') {
1671 $msg .= "\nSTRICT FOOTER SCOPE: TWO rows — a 3–4 column footer (brand, links, contact, social) plus a copyright bar. Color background. A background-image + one line of text is a failed footer. NO features, services, icon boxes, or testimonials.";
1672 } elseif ($kind === '404') {
1673 $msg .= "\nSTRICT 404 SCOPE: Finished error template — large 404 heading, explanation, search, Back to Home button. Color background, not a photo hero with only text. DO NOT add features, icon boxes, testimonials, services, or any other extra sections.";
1674 } else {
1675 $msg .= "\nSTRICT SCOPE: Build ONLY the requested {$kind} layout. Do NOT add extra sections like features, icon boxes, services, or testimonials unless specifically requested in the prompt.";
1676 }
1677 $msg .= "\nUse Pagelayer global color tokens (\$primary, \$secondary, \$text, \$accent, \$bg, \$surface).";
1678 $msg .= "\nCard grids: one widget per column. 3 items = 3 columns in one row. 4+ items = a new sibling row of up to 3 columns. Never stack cards in one column and never nest a grid row inside a column.";
1679 $msg .= "\nColumn spacing must be the row col_gap attribute (20). Do not use ele_css gap or transparent column borders.";
1680 $msg .= "\nFill every text field with realistic dummy data related to this prompt — never leave widget defaults or empty copy.";
1681 $msg .= "\nReturn JSON {\"palette\":{...},\"fonts\":{...},\"nodes\":[...]} only.";
1682 return $msg;
1683 }
1684
1685 public function parse_json($raw) {
1686 if (!is_string($raw) || $raw === '') {
1687 return null;
1688 }
1689
1690 $text = trim($raw);
1691 $text = preg_replace('/^```(?:json|javascript|js)?\s*/i', '', $text);
1692 $text = preg_replace('/\s*```$/', '', $text);
1693 $text = trim($text);
1694
1695 $decoded = json_decode($text, true);
1696 if (is_array($decoded)) {
1697 return $decoded;
1698 }
1699
1700 if (preg_match('/(\{.*\}|\[.*\])/s', $text, $m)) {
1701 $decoded = json_decode($m[1], true);
1702 if (is_array($decoded)) {
1703 return $decoded;
1704 }
1705 $repaired = $this->repair_truncated_json($m[1]);
1706 if (is_array($repaired)) {
1707 return $repaired;
1708 }
1709 }
1710
1711 return $this->repair_truncated_json($text);
1712 }
1713
1714 /**
1715 * Salvage complete node/section objects from a truncated LLM JSON payload.
1716 */
1717 private function repair_truncated_json($text) {
1718 if (!is_string($text) || $text === '') {
1719 return null;
1720 }
1721
1722 $nodes = array();
1723 if (preg_match('/"nodes"\s*:\s*\[/s', $text, $m, PREG_OFFSET_CAPTURE)) {
1724 $chunk = substr($text, $m[0][1] + strlen($m[0][0]));
1725 $offset = 0;
1726 $len = strlen($chunk);
1727 while ($offset < $len) {
1728 while ($offset < $len && strpos(", \n\r\t", $chunk[$offset]) !== false) {
1729 $offset++;
1730 }
1731 if ($offset >= $len || $chunk[$offset] === ']') {
1732 break;
1733 }
1734 if ($chunk[$offset] !== '{') {
1735 break;
1736 }
1737 $depth = 0;
1738 $in_str = false;
1739 $esc = false;
1740 $end = null;
1741 for ($j = $offset; $j < $len; $j++) {
1742 $ch = $chunk[$j];
1743 if ($in_str) {
1744 if ($esc) {
1745 $esc = false;
1746 } elseif ($ch === '\\') {
1747 $esc = true;
1748 } elseif ($ch === '"') {
1749 $in_str = false;
1750 }
1751 continue;
1752 }
1753 if ($ch === '"') {
1754 $in_str = true;
1755 continue;
1756 }
1757 if ($ch === '{') {
1758 $depth++;
1759 } elseif ($ch === '}') {
1760 $depth--;
1761 if ($depth === 0) {
1762 $end = $j;
1763 break;
1764 }
1765 }
1766 }
1767 if ($end === null) {
1768 break;
1769 }
1770 $obj = json_decode(substr($chunk, $offset, $end - $offset + 1), true);
1771 if (is_array($obj) && !empty($obj['tag'])) {
1772 $nodes[] = $obj;
1773 }
1774 $offset = $end + 1;
1775 }
1776 }
1777
1778 if (empty($nodes)) {
1779 return null;
1780 }
1781
1782 $out = array('nodes' => $nodes);
1783 if (preg_match('/"palette"\s*:\s*(\{(?:[^{}]|\{[^{}]*\})*\})/s', $text, $pm)) {
1784 $palette = json_decode($pm[1], true);
1785 if (is_array($palette)) {
1786 $out['palette'] = $palette;
1787 }
1788 }
1789 if (preg_match('/"fonts"\s*:\s*(\{(?:[^{}]|\{[^{}]*\})*\})/s', $text, $fm)) {
1790 $fonts = json_decode($fm[1], true);
1791 if (is_array($fonts)) {
1792 $out['fonts'] = $fonts;
1793 }
1794 }
1795 return $out;
1796 }
1797
1798 /**
1799 /**
1800 * Extract user-requested sections from prompt when specified as a bulleted/numbered/dashed list.
1801 *
1802 * @param string $prompt
1803 * @return array Array of array('name' => string, 'desc' => string, 'key' => string)
1804 */
1805 public function extract_requested_sections($prompt) {
1806 if (!is_string($prompt) || trim($prompt) === '') {
1807 return array();
1808 }
1809
1810 $hay = strtolower(trim($prompt));
1811 // A header/footer/404 request is never a multi-section homepage list.
1812 if ($this->wants_header($prompt) || $this->wants_footer($prompt)
1813 || strpos($hay, '404') !== false
1814 || preg_match('/\b(not\s*found|error\s*page|error\s*template)\b/i', $hay)) {
1815 return array();
1816 }
1817
1818 $sections = array();
1819 $lines = preg_split('/[\r\n]+/', (string) $prompt);
1820
1821 // 1. Line-by-line check for section items
1822 foreach ($lines as $line) {
1823 $line = trim($line);
1824 if ($line === '') {
1825 continue;
1826 }
1827 // Strip leading bullets, numbers, dashes, asterisks
1828 $clean = preg_replace('/^[\s*\-•#\d\.\)\(\]]+/', '', $line);
1829 $clean = trim($clean);
1830
1831 // Match: Name [– or — or - or : or |] Description
1832 if (preg_match('/^([^–—:\-|]+?)\s*[–—:\-|]\s*(.+)$/u', $clean, $m)) {
1833 $name = trim($m[1]);
1834 $desc = trim($m[2]);
1835 $words = preg_split('/\s+/', $name);
1836 if (count($words) >= 1 && count($words) <= 6 && strlen($name) <= 50) {
1837 if (!preg_match('/^(check\s*this|note|example|p\.s|please|make\s*sure|remember|important)\b/i', $name)) {
1838 $sections[] = array(
1839 'name' => $name,
1840 'desc' => $desc,
1841 'key' => sanitize_key($name),
1842 );
1843 }
1844 }
1845 } elseif (preg_match('/^(hero|popular\s*[\w]+|why\s*choose\s*us|features|services|special\s*offer|promotions?|about\s*us|our\s*story|customer\s*reviews|testimonials|reviews|location\s*&\s*hours|contact\s*us|contact|hours\s*&\s*location|final\s*cta|cta|call\s*to\s*action|faq|pricing|stats|team|gallery)\b(.*)$/ui', $clean, $m)) {
1846 $name = trim($m[1]);
1847 $desc = trim($m[2]);
1848 $sections[] = array(
1849 'name' => ucwords($name),
1850 'desc' => trim($desc, " \t\n\r\0\x0B–—:-|"),
1851 'key' => sanitize_key($name),
1852 );
1853 }
1854 }
1855
1856 // 2. If line-by-line didn't find multiple sections, check inline numbered or bulleted list in prompt
1857 if (count($sections) < 2) {
1858 if (preg_match_all('/(?:\d+[\.\)]|\([0-9a-z]\)|[-•])\s*([^–—:\-\n,;]+?)\s*[–—:\-]\s*([^–—\n;]+?)(?=(?:\d+[\.\)]|\([0-9a-z]\)|[-•]|$))/u', $prompt, $matches, PREG_SET_ORDER)) {
1859 $inline_sections = array();
1860 foreach ($matches as $m) {
1861 $name = trim($m[1]);
1862 $desc = trim($m[2]);
1863 $words = preg_split('/\s+/', $name);
1864 if (count($words) >= 1 && count($words) <= 6 && strlen($name) <= 50) {
1865 $inline_sections[] = array(
1866 'name' => $name,
1867 'desc' => $desc,
1868 'key' => sanitize_key($name),
1869 );
1870 }
1871 }
1872 if (count($inline_sections) >= 2) {
1873 $sections = $inline_sections;
1874 }
1875 }
1876 }
1877
1878 return $sections;
1879 }
1880
1881 /**
1882 * Extract summary text and widget tags from a row node tree
1883 */
1884 public function get_row_summary_text($row_node) {
1885 $text = '';
1886 $tags = array();
1887 $walk = function ($node) use (&$walk, &$text, &$tags) {
1888 if (!is_array($node)) return;
1889 if (!empty($node['tag'])) {
1890 $tags[] = strtolower($node['tag']);
1891 }
1892 if (!empty($node['section']) && is_string($node['section'])) {
1893 $text .= ' ' . $node['section'];
1894 }
1895 if (!empty($node['attrs']) && is_array($node['attrs'])) {
1896 foreach ($node['attrs'] as $k => $v) {
1897 if (is_string($v) && preg_match('/(heading|title|text|caption|desc|label|name|quote)/i', $k)) {
1898 $text .= ' ' . $v;
1899 }
1900 }
1901 }
1902 if (isset($node['content'])) {
1903 if (is_string($node['content'])) {
1904 $text .= ' ' . wp_strip_all_tags($node['content']);
1905 } elseif (is_array($node['content'])) {
1906 foreach ($node['content'] as $c) {
1907 $walk($c);
1908 }
1909 }
1910 }
1911 };
1912 $walk($row_node);
1913 return array('text' => strtolower($text), 'tags' => array_unique($tags));
1914 }
1915
1916 /**
1917 * Find which of the user's requested sections are missing from generated nodes
1918 */
1919 public function find_missing_sections($nodes, $requested_sections) {
1920 if (empty($requested_sections) || !is_array($requested_sections)) {
1921 return array();
1922 }
1923 if (empty($nodes) || !is_array($nodes)) {
1924 return $requested_sections;
1925 }
1926
1927 $row_summaries = array();
1928 foreach ($nodes as $row) {
1929 if (is_array($row) && !empty($row['tag']) && $this->is_row_tag($row['tag'])) {
1930 $row_summaries[] = $this->get_row_summary_text($row);
1931 }
1932 }
1933
1934 $missing = array();
1935 foreach ($requested_sections as $sec) {
1936 $sec_name = strtolower(trim($sec['name']));
1937 $found = false;
1938
1939 $raw_words = preg_split('/[^a-z0-9]+/i', $sec_name);
1940 $keywords = array();
1941 foreach ($raw_words as $w) {
1942 if (strlen($w) >= 3 && !in_array($w, array('the', 'and', 'for', 'our', 'with', 'your'), true)) {
1943 $keywords[] = $w;
1944 }
1945 }
1946
1947 foreach ($row_summaries as $summary) {
1948 $text = $summary['text'];
1949 $tags = $summary['tags'];
1950
1951 if (strpos($text, $sec_name) !== false) {
1952 if (preg_match('/(review|testimonial)/i', $sec_name)) {
1953 $has_rev_widget = in_array('pl_testimonial', $tags, true) || in_array('pl_review', $tags, true) || in_array('pl_stars', $tags, true);
1954 if ($has_rev_widget || strlen($text) > 120) {
1955 $found = true;
1956 break;
1957 }
1958 } else {
1959 $found = true;
1960 break;
1961 }
1962 }
1963
1964 $matches_count = 0;
1965 foreach ($keywords as $kw) {
1966 if (strpos($text, $kw) !== false) {
1967 $matches_count++;
1968 }
1969 }
1970 if (!empty($keywords) && $matches_count >= max(1, ceil(count($keywords) * 0.6))) {
1971 if (preg_match('/(review|testimonial)/i', $sec_name)) {
1972 $has_rev_widget = in_array('pl_testimonial', $tags, true) || in_array('pl_review', $tags, true) || in_array('pl_stars', $tags, true);
1973 if ($has_rev_widget || strlen($text) > 120) {
1974 $found = true;
1975 break;
1976 }
1977 } else {
1978 $found = true;
1979 break;
1980 }
1981 }
1982
1983 if (preg_match('/(popular|menu|pizza|items?|product|catalog)/i', $sec_name)) {
1984 if (in_array('pl_service', $tags, true) && (strpos($text, 'pizza') !== false || strpos($text, 'cart') !== false || strpos($text, '$') !== false)) {
1985 $found = true;
1986 break;
1987 }
1988 }
1989 if (preg_match('/(why\s*choose|feature|benefit)/i', $sec_name)) {
1990 if (in_array('pl_iconbox', $tags, true) && (strpos($text, 'ingredient') !== false || strpos($text, 'fresh') !== false || strpos($text, 'fast') !== false || strpos($text, 'delivery') !== false || strpos($text, 'oven') !== false)) {
1991 $found = true;
1992 break;
1993 }
1994 }
1995 if (preg_match('/(offer|promo|deal|discount|banner)/i', $sec_name)) {
1996 if (strpos($text, 'offer') !== false || strpos($text, 'discount') !== false || strpos($text, 'free') !== false || strpos($text, 'price of one') !== false || strpos($text, '% off') !== false) {
1997 $found = true;
1998 break;
1999 }
2000 }
2001 if (preg_match('/(location|hour|address|map|find\s*us)/i', $sec_name)) {
2002 if (in_array('pl_address', $tags, true) || in_array('pl_phone', $tags, true) || in_array('pl_google_maps', $tags, true) || strpos($text, 'opening hours') !== false || strpos($text, 'mon-') !== false || strpos($text, 'pm') !== false || strpos($text, 'am') !== false) {
2003 $found = true;
2004 break;
2005 }
2006 }
2007 if (preg_match('/(cta|final|order\s*now|hungry)/i', $sec_name)) {
2008 if (in_array('pl_btn', $tags, true) && (strpos($text, 'order') !== false || strpos($text, 'hungry') !== false || strpos($text, 'now') !== false)) {
2009 $found = true;
2010 break;
2011 }
2012 }
2013 }
2014
2015 if (!$found) {
2016 $missing[] = $sec;
2017 }
2018 }
2019
2020 return $missing;
2021 }
2022
2023 /**
2024 * Theme-builder kinds that must never expand into a marketing homepage.
2025 */
2026 public function is_scoped_kind($kind) {
2027 return in_array($kind, array('header', 'footer', '404', 'blog', 'single', 'search', 'about', 'contact', 'service', 'pricing', 'faq', 'section'), true);
2028 }
2029
2030 /**
2031 * Header / footer / 404 / archive / single — saved as Theme Builder templates.
2032 */
2033 public function is_theme_template_kind($kind) {
2034 return in_array($kind, array('header', 'footer', '404', 'blog', 'single', 'search'), true);
2035 }
2036
2037 /**
2038 * Map a request kind onto a Pagelayer Theme Builder template type + display conditions.
2039 *
2040 * @return array{type:string,title:string,conditions:array}|null
2041 */
2042 public function theme_template_spec($kind, $prompt = '') {
2043 $site = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
2044 if ($site === '') {
2045 $site = 'Site';
2046 }
2047
2048 $specs = array(
2049 'header' => array(
2050 'type' => 'header',
2051 'title' => $site . ' Header',
2052 'conditions' => array(array('type' => 'include', 'template' => '', 'sub_template' => '', 'id' => '')),
2053 ),
2054 'footer' => array(
2055 'type' => 'footer',
2056 'title' => $site . ' Footer',
2057 'conditions' => array(array('type' => 'include', 'template' => '', 'sub_template' => '', 'id' => '')),
2058 ),
2059 '404' => array(
2060 'type' => 'single',
2061 'title' => $site . ' 404',
2062 'conditions' => array(array('type' => 'include', 'template' => 'singular', 'sub_template' => '404', 'id' => '')),
2063 ),
2064 'blog' => array(
2065 'type' => 'archive',
2066 'title' => $site . ' Blog Archive',
2067 'conditions' => array(array('type' => 'include', 'template' => 'archives', 'sub_template' => '', 'id' => '')),
2068 ),
2069 'single' => array(
2070 'type' => 'single',
2071 'title' => $site . ' Single Post',
2072 'conditions' => array(array('type' => 'include', 'template' => 'singular', 'sub_template' => '', 'id' => '')),
2073 ),
2074 'search' => array(
2075 'type' => 'archive',
2076 'title' => $site . ' Search Results',
2077 'conditions' => array(array('type' => 'include', 'template' => 'archives', 'sub_template' => 'search', 'id' => '')),
2078 ),
2079 );
2080
2081 if (!isset($specs[$kind])) {
2082 return null;
2083 }
2084
2085 $spec = $specs[$kind];
2086 $topic = $this->infer_page_topic($prompt);
2087 if ($topic !== '' && $topic !== 'this business' && strlen($topic) < 60) {
2088 $spec['title'] = $site . ' ' . ucwords($kind === '404' ? '404' : str_replace('_', ' ', $kind));
2089 }
2090
2091 return $spec;
2092 }
2093
2094 /**
2095 * What the user actually asked to build. Theme templates (header, footer, 404)
2096 * and inner pages must not be treated as marketing homepages.
2097 *
2098 * @return string home|header|footer|404|blog|single|search|about|contact|service|pricing|faq|section
2099 */
2100 public function request_kind($prompt) {
2101 $hay = strtolower(trim((string) $prompt));
2102 if ($hay === '') {
2103 return 'section';
2104 }
2105
2106 // Theme-builder chrome first — never let a footer/header request become a homepage.
2107 if ($this->wants_header($prompt)) {
2108 return 'header';
2109 }
2110 if ($this->wants_footer($prompt)) {
2111 return 'footer';
2112 }
2113
2114 if (strpos($hay, '404') !== false || preg_match('/\b(not\s*found|error\s*page|error\s*template)\b/i', $hay)) {
2115 return '404';
2116 }
2117 if (preg_match('/\b(single\s+post|single\s+blog|blog\s+post\s+template|post\s+template|single\s+template)\b/i', $hay)) {
2118 return 'single';
2119 }
2120 if (preg_match('/\b(blog\s*page|blog\s*template|blog\s*archive|posts?\s+listing|archive\s*(page|template)|news\s*page)\b/i', $hay)
2121 || preg_match('/\b(make|create|build|design)\b.{0,50}\bblog\b/i', $hay)) {
2122 return 'blog';
2123 }
2124 if (preg_match('/\bsearch\s*(page|template|results)\b/i', $hay)) {
2125 return 'search';
2126 }
2127 if (preg_match('/\b(contact\s*(page|template|us)|get\s+in\s+touch\s+page)\b/i', $hay)) {
2128 return 'contact';
2129 }
2130 if (preg_match('/\b(about\s*(page|template|us)|our\s+story\s+page)\b/i', $hay)) {
2131 return 'about';
2132 }
2133 if (preg_match('/\b(pricing\s*(page|template)|plans?\s+page)\b/i', $hay)) {
2134 return 'pricing';
2135 }
2136 if (preg_match('/\b(faq\s*(page|template)|frequently\s+asked)\b/i', $hay)) {
2137 return 'faq';
2138 }
2139 if (preg_match('/\b(service\s*page|services\s*page|service\s*template|services\s*template)\b/i', $hay)
2140 || preg_match('/\b(make|create|build|design)\b.{0,40}\bservices?\s+page\b/i', $hay)) {
2141 return 'service';
2142 }
2143
2144 // Numbered multi-section lists are homepage requests — but never after a scoped kind above.
2145 $requested_sections = $this->extract_requested_sections($prompt);
2146 if (count($requested_sections) >= 2) {
2147 return 'home';
2148 }
2149
2150 if (preg_match('/\b(home\s*page|homepage|landing\s*page|front\s*page)\b/i', $hay)) {
2151 return 'home';
2152 }
2153 if (preg_match('/\b(full\s*site|entire\s*site|whole\s*site|web\s*site|website)\b/i', $hay)
2154 && !preg_match('/\b(404|blog|service|about|contact|pricing|faq|header|footer|template)\b/i', $hay)) {
2155 return 'home';
2156 }
2157 if (preg_match('/\b(make|create|build|design|generate)\b.{0,40}\b(site|homepage|landing)\b/i', $hay)
2158 && !preg_match('/\b(404|blog|service|about|contact|pricing|faq|header|footer|template)\b/i', $hay)) {
2159 return 'home';
2160 }
2161 // "make siteseo.com" / "build https://loginizer.com" / a bare domain.
2162 if (preg_match('/\b(?:https?:\/\/)?(?:www\.)?[a-z0-9][a-z0-9-]{1,61}\.(com|io|net|org|app|dev|co|ai|seo|in|us)\b/i', $hay)
2163 && !preg_match('/\b(404|blog|service|about|contact|pricing|faq|header|footer|template)\b/i', $hay)) {
2164 return 'home';
2165 }
2166
2167 return 'section';
2168 }
2169
2170 public function is_full_page_request($prompt) {
2171 return $this->request_kind($prompt) === 'home' || count($this->extract_requested_sections($prompt)) >= 2;
2172 }
2173
2174 public function inner_page_brief($kind, $prompt) {
2175 $topic = $this->infer_page_topic($prompt);
2176 $briefs = array(
2177 'header' => "Build ONLY a site HEADER theme template for: {$topic}. One slim full-width bar: brand/logo + primary navigation (pl_wp_menu) + optional CTA. This is Theme Builder chrome, not a page. No hero, no features, no services, no footer.",
2178 'footer' => "Build ONLY a site FOOTER theme template for: {$topic}. A real multi-column footer (brand/about, useful links, contact, social) plus a copyright bar. This is Theme Builder chrome, not a page. No hero photo, no features, no services, no testimonials, no pricing.",
2179 '404' => "Build ONLY a 404 Not Found theme template for: {$topic}. A finished error screen: large 404 code, headline, short explanation, search, and Back to Home button. Optional 3–4 text links (Home, Blog, Contact). Absolutely NO feature boxes, services, testimonials, or marketing sections. Do NOT make a photo-hero with only a background image and one line of text.",
2180 'blog' => "Build ONLY a blog listing / archive theme template for: {$topic}. Invent the composition. Use catalog post widgets if available. Do NOT build a marketing homepage.",
2181 'single' => "Build ONLY a single blog-post theme template for: {$topic}. Invent the composition. Do NOT build a homepage.",
2182 'search' => "Build ONLY a search-results theme template. Invent the composition. No marketing homepage sections.",
2183 'about' => "Build ONLY an About page/section for: {$topic}. Invent the composition. Do NOT add a full homepage or extra unrequested sections.",
2184 'contact' => "Build ONLY a Contact page/section for: {$topic}. Invent the composition. No extra marketing sections unless asked.",
2185 'service' => "Build ONLY a Services page/section for: {$topic}. Invent a layout that fits this domain. No extra marketing bands unless the user asked.",
2186 'pricing' => "Build ONLY a Pricing page/section for: {$topic}. Invent the composition. Not a full homepage.",
2187 'faq' => "Build ONLY an FAQ page/section for: {$topic}. Invent the composition. Not a full homepage.",
2188 'section' => "Build exactly the section or layout the user described. Do not add extra unrequested sections (features, services, testimonials, pricing).",
2189 'home' => "Build a complete branded homepage/theme for: {$topic}. Invent original architecture for THIS business — do not reuse a canned skeleton.",
2190 );
2191 return isset($briefs[$kind]) ? $briefs[$kind] : $briefs['section'];
2192 }
2193
2194 public function infer_page_topic($prompt) {
2195 $t = strtolower((string) $prompt);
2196 $t = preg_replace('/\b(please|make|create|build|design|generate|want|need|a|an|the|my|our|for|with|using|home\s*page|homepage|landing\s*page|full\s*page|web\s*site|website|web\s*page|webpage|site|page)\b/i', ' ', $t);
2197 $t = trim(preg_replace('/\s+/', ' ', $t));
2198 if ($t === '' || strlen($t) < 3) {
2199 $t = trim((string) $prompt);
2200 }
2201 return $t !== '' ? $t : 'this business';
2202 }
2203
2204 public function niche_from_prompt($prompt) {
2205 return $this->industry_from_prompt($prompt);
2206 }
2207
2208 /**
2209 * Lightweight industry guess kept for compatibility. Build-with-AI no longer
2210 * uses this to pick a canned layout — the model invents architecture.
2211 *
2212 * @return string restaurant|saas|service|generic
2213 */
2214 public function industry_from_prompt($prompt) {
2215 $hay = strtolower((string) $prompt);
2216 if (preg_match('/pizza|pizzeria|restaurant|trattoria|osteria|cafe|caf[]|bakery|burger|food|kitchen|diner|bistro|bar\b|coffee|hospitality|hotel|menu|chef|cuisine|steakhouse|sushi|taco|noodle|brewery/i', $hay)) {
2217 return 'restaurant';
2218 }
2219 if (preg_match('/loginizer|plugin|saas|software|app\b|security|hosting|vpn|backup|wordpress|cyber|brute.?force|2fa|captcha|siteseo|seo\b|analytics|api\b|devtools|cloud|tech\b|startup/i', $hay)) {
2220 return 'saas';
2221 }
2222 if (preg_match('/gym|fitness|yoga|trainer|crossfit|wellness|plumber|dentist|lawyer|attorney|salon|clinic|doctor|consultant|agency|studio|marketing|branding|freelance|cleaning|repair|hvac|roofer|electrician|local\s+business|contractor/i', $hay)) {
2223 return 'service';
2224 }
2225 return 'generic';
2226 }
2227
2228 /**
2229 * @deprecated Layout variation is no longer prescribed. The model invents composition.
2230 */
2231 public function layout_variation($kind = 'home', $industry = 'generic') {
2232 return array(
2233 'hero' => 'invent an original opening for this brand',
2234 'grid' => 'invent an original content composition for this brand',
2235 );
2236 }
2237
2238 /**
2239 * @deprecated Industry wireframes are no longer prescribed. The model invents architecture.
2240 */
2241 public function industry_wireframe($industry) {
2242 return "Invent the information architecture for THIS brand. Do not reuse a canned skeleton.\n";
2243 }
2244
2245 public function parse_plan($raw) {
2246 $data = $this->parse_json($raw);
2247 if (!is_array($data)) {
2248 return array('widgets' => array(), 'sections' => array(), 'layout' => array(), 'palette' => array(), 'fonts' => array(), 'summary' => '', 'mood' => '');
2249 }
2250
2251 $widgets = array();
2252 if (!empty($data['widgets']) && is_array($data['widgets'])) {
2253 $catalog = $this->get_widget_catalog();
2254 foreach ($data['widgets'] as $tag) {
2255 $tag = $this->normalize_tag((string) $tag);
2256 if (isset($catalog[$tag])) {
2257 $widgets[] = $tag;
2258 }
2259 }
2260 }
2261
2262 return array(
2263 'widgets' => array_values(array_unique($widgets)),
2264 'sections' => (!empty($data['sections']) && is_array($data['sections'])) ? array_values($data['sections']) : array(),
2265 'layout' => (!empty($data['layout']) && is_array($data['layout'])) ? array_values($data['layout']) : array(),
2266 'palette' => (!empty($data['palette']) && is_array($data['palette'])) ? $data['palette'] : array(),
2267 'fonts' => (!empty($data['fonts']) && is_array($data['fonts'])) ? $data['fonts'] : array(),
2268 'summary' => !empty($data['summary']) ? (string) $data['summary'] : '',
2269 'mood' => !empty($data['mood']) ? (string) $data['mood'] : '',
2270 );
2271 }
2272
2273 public function extract_globals_from_json($raw) {
2274 $data = is_array($raw) ? $raw : $this->parse_json($raw);
2275 if (!is_array($data)) {
2276 return array('palette' => array(), 'fonts' => array());
2277 }
2278 return array(
2279 'palette' => (!empty($data['palette']) && is_array($data['palette'])) ? $data['palette'] : array(),
2280 'fonts' => (!empty($data['fonts']) && is_array($data['fonts'])) ? $data['fonts'] : array(),
2281 );
2282 }
2283
2284 public function parse_layout($raw) {
2285 $data = $this->parse_json($raw);
2286 if (!is_array($data)) {
2287 return null;
2288 }
2289
2290 if (isset($data['nodes']) && is_array($data['nodes'])) {
2291 $data = $data['nodes'];
2292 } elseif (isset($data['layout']) && is_array($data['layout'])) {
2293 $data = $data['layout'];
2294 } elseif (isset($data['pagelayer_data']) && is_array($data['pagelayer_data'])) {
2295 $data = $data['pagelayer_data'];
2296 }
2297
2298 if (isset($data['tag'])) {
2299 $data = array($data);
2300 }
2301
2302 if (!is_array($data) || empty($data)) {
2303 return null;
2304 }
2305
2306 $clean = array();
2307 foreach ($data as $node) {
2308 if (!is_array($node) || empty($node['tag'])) {
2309 continue;
2310 }
2311 $clean[] = $node;
2312 }
2313
2314 return empty($clean) ? null : array_values($clean);
2315 }
2316
2317 public function normalize_tag($tag) {
2318 $tag = strtolower(trim((string) $tag));
2319 $map = array(
2320 'container' => 'pl_row',
2321 'section' => 'pl_row',
2322 'row' => 'pl_row',
2323 'column' => 'pl_col',
2324 'col' => 'pl_col',
2325 'heading' => 'pl_heading',
2326 'title' => 'pl_heading',
2327 'text' => 'pl_text',
2328 'button' => 'pl_btn',
2329 'btn' => 'pl_btn',
2330 'image' => 'pl_image',
2331 'iconbox' => 'pl_iconbox',
2332 );
2333 $map = apply_filters('pagelayer_ai_tag_aliases', $map);
2334 if (isset($map[$tag])) {
2335 return $map[$tag];
2336 }
2337 if (strpos($tag, 'pagelayer_') === 0) {
2338 return str_replace('pagelayer_', 'pl_', $tag);
2339 }
2340 if ($tag !== '' && strpos($tag, 'pl_') !== 0) {
2341 return 'pl_' . $tag;
2342 }
2343 return $tag;
2344 }
2345
2346 /**
2347 * True when a page-level request came back as a fragment or unstyled sketch.
2348 * The controller retries the model — it does not inject a hardcoded theme.
2349 */
2350 public function is_thin_layout($nodes, $kind = 'home', $requested_sections = array()) {
2351 if (!is_array($nodes) || empty($nodes)) {
2352 return true;
2353 }
2354
2355 $stats = $this->layout_stats($nodes);
2356
2357 if (!empty($requested_sections)) {
2358 if ($stats['rows'] < count($requested_sections)) {
2359 return true;
2360 }
2361 $missing = $this->find_missing_sections($nodes, $requested_sections);
2362 if (!empty($missing)) {
2363 return true;
2364 }
2365 }
2366
2367 $min_rows = 2;
2368 $min_leaves = 4;
2369 $min_styled = 3;
2370 $min_tags = 4;
2371
2372 if ($kind === 'home') {
2373 $min_rows = 5;
2374 $min_leaves = 10;
2375 $min_styled = 8;
2376 $min_tags = 8;
2377 } elseif ($kind === 'footer') {
2378 $min_rows = 2;
2379 $min_leaves = 6;
2380 $min_styled = 4;
2381 $min_tags = 5;
2382 } elseif ($kind === 'header') {
2383 $min_rows = 1;
2384 $min_leaves = 2;
2385 $min_styled = 2;
2386 $min_tags = 3;
2387 } elseif (in_array($kind, array('about', 'service', 'contact', 'pricing', 'faq', 'blog'), true)) {
2388 $min_rows = 2;
2389 $min_leaves = 5;
2390 $min_styled = 3;
2391 $min_tags = 4;
2392 } elseif (in_array($kind, array('404', 'search', 'single'), true)) {
2393 $min_rows = 1;
2394 $min_leaves = 3;
2395 $min_styled = 3;
2396 $min_tags = 4;
2397 } else {
2398 return count($nodes) < 1;
2399 }
2400
2401 return ($stats['rows'] < $min_rows)
2402 || ($stats['leaves'] < $min_leaves)
2403 || ($stats['styled'] < $min_styled)
2404 || ($stats['tags'] < $min_tags);
2405 }
2406
2407 public function layout_stats($nodes) {
2408 $rows = 0;
2409 $leaves = 0;
2410 $styled = 0;
2411 $tags = array();
2412
2413 $walk = function ($list) use (&$walk, &$rows, &$leaves, &$styled, &$tags) {
2414 foreach ((array) $list as $node) {
2415 if (!is_array($node) || empty($node['tag'])) {
2416 continue;
2417 }
2418 $tag = $this->normalize_tag($node['tag']);
2419 $tags[$tag] = true;
2420 if ($this->is_row_tag($tag)) {
2421 $rows++;
2422 }
2423 $has_children = isset($node['content']) && is_array($node['content']) && !empty($node['content']);
2424 if (!$has_children && !$this->is_col_tag($tag) && !$this->is_row_tag($tag)) {
2425 $leaves++;
2426 }
2427 if (!empty($node['attrs']) && is_array($node['attrs'])) {
2428 foreach ($node['attrs'] as $k => $v) {
2429 if ($v === '' || $v === null || $k === 'pagelayer-id' || $k === 'col' || $k === 'col_gap' || $k === 'stretch' || $k === 'widget_space') {
2430 continue;
2431 }
2432 if (preg_match('/(color|typo|padding|shadow|bg_|border|align|icon|image|heading|font_)/i', (string) $k)) {
2433 $styled++;
2434 break;
2435 }
2436 }
2437 }
2438 if ($has_children) {
2439 $walk($node['content']);
2440 }
2441 }
2442 };
2443 $walk($nodes);
2444
2445 return array(
2446 'rows' => $rows,
2447 'leaves' => $leaves,
2448 'styled' => $styled,
2449 'tags' => count($tags),
2450 );
2451 }
2452
2453 private function detect_section_types($nodes) {
2454 $have = array();
2455 $walk = function ($list) use (&$walk, &$have) {
2456 foreach ((array) $list as $node) {
2457 if (!is_array($node)) {
2458 continue;
2459 }
2460 if (!empty($node['section']) && is_string($node['section'])) {
2461 $have[strtolower($node['section'])] = true;
2462 }
2463 $tag = !empty($node['tag']) ? $this->normalize_tag($node['tag']) : '';
2464 if ($tag === 'pl_iconbox' || $tag === 'pl_service') {
2465 $have['features'] = true;
2466 } elseif ($tag === 'pl_testimonial') {
2467 $have['testimonials'] = true;
2468 } elseif ($tag === 'pl_counter') {
2469 $have['stats'] = true;
2470 } elseif ($tag === 'pl_accordion') {
2471 $have['faq'] = true;
2472 } elseif ($tag === 'pl_pricing') {
2473 $have['pricing'] = true;
2474 } elseif (in_array($tag, array('pl_address', 'pl_phone', 'pl_email', 'pl_contact'), true)) {
2475 $have['contact'] = true;
2476 } elseif ($tag === 'pl_heading' && isset($node['content']) && is_string($node['content']) && stripos($node['content'], '<h1') !== false) {
2477 $have['hero'] = true;
2478 }
2479 if (isset($node['content']) && is_array($node['content'])) {
2480 $walk($node['content']);
2481 }
2482 }
2483 };
2484 $walk($nodes);
2485 return $have;
2486 }
2487
2488 public function wants_header($prompt) {
2489 $hay = strtolower(trim((string) $prompt));
2490 if ($hay === '') {
2491 return false;
2492 }
2493 return (bool) (preg_match('/\b(create|make|build|design|add|generate)\b.{0,50}\b(header|nav\s*bar|navbar|navigation\s*bar|site\s*menu|top\s*menu|main\s*menu)\b/i', $hay)
2494 || preg_match('/^(header|nav\s*bar|navbar|navigation\s*bar|top\s*menu|main\s*menu)(\s+for|\s+of|\s+with|\s*$)/i', $hay)
2495 || preg_match('/\bheader\s+(theme\s+)?(template|layout|section|bar)\b/i', $hay)
2496 || preg_match('/\b(theme\s+)?template\b.{0,30}\bheader\b/i', $hay));
2497 }
2498
2499 public function wants_footer($prompt) {
2500 $hay = strtolower(trim((string) $prompt));
2501 if ($hay === '') {
2502 return false;
2503 }
2504 return (bool) (preg_match('/\b(create|make|build|design|add|generate)\b.{0,50}\b(footer|footer\s*bar|copyright\s*bar|site\s*footer|bottom\s*bar)\b/i', $hay)
2505 || preg_match('/^(footer|site\s*footer|bottom\s*bar)(\s+for|\s+of|\s+with|\s*$)/i', $hay)
2506 || preg_match('/\bfooter\s+(theme\s+)?(template|layout|section|page|bar)\b/i', $hay)
2507 || preg_match('/\b(theme\s+)?template\b.{0,30}\bfooter\b/i', $hay));
2508 }
2509
2510 /**
2511 * True only when the user explicitly asked for a features/services SECTION —
2512 * not when those words appear as the brand/industry ("plumbing service").
2513 */
2514 public function explicitly_wants_marketing_band($prompt, $band = 'features') {
2515 $hay = strtolower(trim((string) $prompt));
2516 if ($hay === '') {
2517 return false;
2518 }
2519 if ($band === 'features') {
2520 if (preg_match('/\b(header|footer|404)\b/i', $hay)) {
2521 return false;
2522 }
2523 return (bool) (preg_match('/\b(features?\s+(section|grid|cards?|row|boxes))\b/i', $hay)
2524 || preg_match('/\b(icon\s*box(es)?|benefit\s+cards?)\b/i', $hay)
2525 || preg_match('/\b(add|include|with|plus)\s+(features?|benefits?)\b/i', $hay));
2526 }
2527 if ($band === 'services') {
2528 if (preg_match('/\b(header|footer|404)\b/i', $hay)) {
2529 return false;
2530 }
2531 return (bool) (preg_match('/\b(services?\s+(section|grid|cards?|row|page|template))\b/i', $hay)
2532 || preg_match('/\b(add|include|with|plus)\s+services?\b/i', $hay));
2533 }
2534 if ($band === 'testimonials') {
2535 return (bool) preg_match('/\b(testimonials?|reviews?|ratings?|what\s*(our\s*)?clients\s*say)\b/i', $hay);
2536 }
2537 if ($band === 'pricing') {
2538 return (bool) preg_match('/\b(pricing|plans?\s+page|packages?)\b/i', $hay);
2539 }
2540 return false;
2541 }
2542
2543 /**
2544 * Page-kind scope only. The model invents architecture, widgets, and visuals.
2545 */
2546 public function structure_brief($kind = 'section', $prompt = '') {
2547 $briefs = array(
2548 'header' => "Design a finished site HEADER theme template (exactly ONE slim [pl_row], typical padding 16–28px top/bottom). Columns: brand/logo (pl_heading or pl_image) + Primary Menu (pl_wp_menu, layout=horizontal) + optional CTA button. Full-width color bar (ele_bg_type=color), NOT a photo hero. No features, services, testimonials, or page sections.",
2549 'footer' => "Design a finished site FOOTER theme template — not a page and not a photo hero. TWO rows: (1) a 3- or 4-column row (col 3+3+3+3 or 4+4+4) with Brand/about, Useful links (pl_list + pl_list_item), Contact (address/phone/email), and Social (pl_social_grp > pl_social); (2) a full-width copyright bar (© year + site name, Privacy, Terms). Use ele_bg_type=color (dark brand bar). Do NOT use a background image as the only design. Do NOT add features, services, icon boxes, testimonials, pricing, or any extra marketing section.",
2550 '404' => "Design a finished 404 Not Found theme template. One centered content row (min-height ~70vh, color background — not a random stock photo hero): large 404 number as h1, short headline, 1–2 sentence explanation, pl_search, and a Back to Home pl_btn. Optional second row of 3–4 text/button links (Home, Blog, Contact) — NOT icon boxes or service cards. Absolutely NO features, services, testimonials, pricing, or extra marketing bands. A row that is only a background image plus one heading is a failed 404.",
2551 'blog' => "Design a blog/archive theme template for this brand. Invent the composition. Use catalog post widgets if available. No homepage marketing stack.",
2552 'single' => "Design a single-post theme template. Invent the composition. No homepage.",
2553 'search' => "Design a search-results theme template. Invent the composition. No homepage.",
2554 'service' => "Design ONLY the requested services layout. Do not wrap it in a full homepage or add unrelated sections unless asked.",
2555 'contact' => "Design ONLY the requested contact layout (e.g. form, contact info). No extra marketing sections unless asked.",
2556 'about' => "Design ONLY the requested about section/page. Do not append a full homepage.",
2557 'pricing' => "Design ONLY the requested pricing layout. Not a full homepage.",
2558 'faq' => "Design ONLY the requested FAQ layout. Not a full homepage.",
2559 'home' => "Design a complete branded homepage/theme for this product. Invent original architecture — do not reuse a canned skeleton.",
2560 'section' => "Design ONLY the requested section as finished UI. Do not add extra sections (such as feature icon boxes, services, or testimonials) unless explicitly requested.",
2561 );
2562
2563 $requested_sections = $this->is_scoped_kind($kind) ? array() : $this->extract_requested_sections($prompt);
2564 if (!empty($requested_sections) && !$this->is_scoped_kind($kind)) {
2565 $text = "The user has specified an explicit list of " . count($requested_sections) . " sections that MUST all be created in this layout:\n";
2566 foreach ($requested_sections as $i => $sec) {
2567 $num = $i + 1;
2568 $desc = !empty($sec['desc']) ? ": " . $sec['desc'] : '';
2569 $text .= "{$num}. \"{$sec['name']}\"{$desc}\n";
2570 }
2571 $text .= "CRITICAL: You MUST build every single one of these " . count($requested_sections) . " sections as a dedicated [pl_row] in your output. Do NOT omit or truncate any section.";
2572 } else {
2573 $text = isset($briefs[$kind]) ? $briefs[$kind] : $briefs['section'];
2574 }
2575 $text .= "\nYOU invent rows, column splits (they must sum to 12), widget mix, copy, photos, and palette for THIS brand.";
2576 if (in_array($kind, array('header', 'footer', '404'), true)) {
2577 $text .= " Use color row backgrounds (ele_bg_type=color), not a photo hero. High contrast.";
2578 } else {
2579 $text .= " Every section row keeps 50px to 60px padding-top AND padding-bottom (never 0 top on a following row). 20px left and right. High contrast.";
2580 }
2581 $text .= " Social icons: pl_social_grp with children in a horizontal row.";
2582 if (!$this->wants_header($prompt)) {
2583 $text .= "\nCRITICAL: Do NOT create a website header/navigation bar/logo row. In Pagelayer, headers are handled by Theme Builder templates.";
2584 }
2585 if (!$this->wants_footer($prompt)) {
2586 $text .= "\nCRITICAL: Do NOT create a website footer/copyright bar/bottom links row. In Pagelayer, footers are handled by Theme Builder templates.";
2587 }
2588
2589 return apply_filters('pagelayer_ai_structure_brief', $text, $kind, $this, $prompt);
2590 }
2591
2592 public function uniqueness_seed() {
2593 return $this->structure_brief('home');
2594 }
2595
2596 public function finalize_nodes($nodes, $prompt = '') {
2597 if (!is_array($nodes) || empty($nodes)) {
2598 return array();
2599 }
2600
2601 $this->apply_ai_globals(array(), array());
2602
2603 // Remap guessed attr names onto the live schema BEFORE markup defaults
2604 // are filled, otherwise a default (service_icon, id, ...) occupies the
2605 // real key and the user's icon/img/heading value is dropped.
2606 $nodes = $this->remap_tree($nodes);
2607 $nodes = $this->normalize_ai_nodes($nodes);
2608 $nodes = $this->scrub_unwanted_headers_and_footers($nodes, $prompt);
2609 $nodes = $this->scrub_unwanted_extra_sections($nodes, $prompt);
2610 $nodes = $this->normalize_theme_chrome_rows($nodes, $prompt);
2611 $nodes = $this->reflow_card_grids($nodes);
2612 $nodes = $this->normalize_column_gaps($nodes);
2613 $nodes = $this->sanitize_nodes($nodes);
2614 $nodes = $this->scrub_placeholders($nodes);
2615 $nodes = $this->normalize_badges_and_ribbons($nodes);
2616 $nodes = $this->normalize_social_groups($nodes);
2617 $nodes = $this->apply_theme_visuals($nodes);
2618 $nodes = $this->normalize_badges_and_ribbons($nodes);
2619 $kind = $this->request_kind($prompt);
2620 if (in_array($kind, array('header', 'footer', '404'), true)) {
2621 $nodes = $this->normalize_theme_chrome_rows($nodes, $prompt);
2622 } else {
2623 $nodes = $this->enforce_section_padding($nodes);
2624 $nodes = $this->normalize_row_stack_spacing($nodes);
2625 }
2626 return $nodes;
2627 }
2628
2629 /**
2630 * Remove top navbar/logo rows and bottom copyright/footer rows if not explicitly asked
2631 */
2632 public function scrub_unwanted_headers_and_footers($nodes, $prompt = '') {
2633 if (!is_array($nodes) || count($nodes) <= 1) {
2634 return $nodes;
2635 }
2636
2637 $wants_hdr = $this->wants_header($prompt);
2638 $wants_ftr = $this->wants_footer($prompt);
2639
2640 if ($wants_hdr && $wants_ftr) {
2641 return $nodes;
2642 }
2643
2644 $rows = array_values($nodes);
2645
2646 // Check first row for header navbar
2647 if (!$wants_hdr && count($rows) > 1) {
2648 if ($this->is_header_like_row($rows[0])) {
2649 array_shift($rows);
2650 }
2651 }
2652
2653 // Check last row for footer/copyright
2654 if (!$wants_ftr && count($rows) > 1) {
2655 $last_idx = count($rows) - 1;
2656 if ($this->is_footer_like_row($rows[$last_idx])) {
2657 array_pop($rows);
2658 }
2659 }
2660
2661 return array_values($rows);
2662 }
2663
2664 /**
2665 * Remove unrequested feature, testimonial, or extra marketing rows when the user
2666 * asked for a 404 page or a specific single section/template.
2667 */
2668 public function scrub_unwanted_extra_sections($nodes, $prompt = '') {
2669 if (!is_array($nodes) || empty($nodes)) {
2670 return $nodes;
2671 }
2672
2673 $kind = $this->request_kind($prompt);
2674 $requested_sections = $this->is_scoped_kind($kind) ? array() : $this->extract_requested_sections($prompt);
2675
2676 // If user asked for a full homepage or explicitly listed 2+ sections, keep them
2677 if ($kind === 'home' || count($requested_sections) >= 2) {
2678 return $nodes;
2679 }
2680
2681 $wants_features = $this->explicitly_wants_marketing_band($prompt, 'features')
2682 || $this->explicitly_wants_marketing_band($prompt, 'services');
2683 $wants_testimonials = $this->explicitly_wants_marketing_band($prompt, 'testimonials');
2684 $wants_pricing = $this->explicitly_wants_marketing_band($prompt, 'pricing');
2685
2686 $clean = array();
2687
2688 $is_marketing_row = function ($row_json) {
2689 return (strpos($row_json, 'pl_iconbox') !== false
2690 || strpos($row_json, 'pl_service') !== false
2691 || strpos($row_json, 'pl_testimonial') !== false
2692 || strpos($row_json, 'pl_pricing') !== false
2693 || strpos($row_json, 'pl_counter') !== false
2694 || strpos($row_json, 'pl_flipbox') !== false);
2695 };
2696
2697 if ($kind === 'footer') {
2698 foreach ($nodes as $row) {
2699 if (!is_array($row) || empty($row['tag']) || !$this->is_row_tag($row['tag'])) {
2700 $clean[] = $row;
2701 continue;
2702 }
2703 $row_json = strtolower(wp_json_encode($row));
2704 if ($is_marketing_row($row_json) && !$wants_features && !$wants_testimonials && !$wants_pricing) {
2705 continue;
2706 }
2707 if ($this->is_header_like_row($row)) {
2708 continue;
2709 }
2710 $clean[] = $row;
2711 }
2712 return !empty($clean) ? array_values($clean) : $nodes;
2713 }
2714
2715 if ($kind === 'header') {
2716 foreach ($nodes as $row) {
2717 if (!is_array($row) || empty($row['tag']) || !$this->is_row_tag($row['tag'])) {
2718 $clean[] = $row;
2719 continue;
2720 }
2721 $row_json = strtolower(wp_json_encode($row));
2722 if ($is_marketing_row($row_json) || $this->is_footer_like_row($row)) {
2723 continue;
2724 }
2725 $clean[] = $row;
2726 }
2727 // A header is one slim bar — keep the first remaining row.
2728 if (count($clean) > 1) {
2729 $first = array();
2730 foreach ($clean as $row) {
2731 if (is_array($row) && !empty($row['tag']) && $this->is_row_tag($row['tag'])) {
2732 $first[] = $row;
2733 break;
2734 }
2735 $first[] = $row;
2736 }
2737 $clean = $first;
2738 }
2739 return !empty($clean) ? array_values($clean) : $nodes;
2740 }
2741
2742 if ($kind === '404') {
2743 $found_404_row = false;
2744 foreach ($nodes as $row) {
2745 if (!is_array($row) || empty($row['tag']) || !$this->is_row_tag($row['tag'])) {
2746 $clean[] = $row;
2747 continue;
2748 }
2749 $row_json = strtolower(wp_json_encode($row));
2750 $is_feature_row = (strpos($row_json, 'pl_iconbox') !== false || strpos($row_json, 'pl_service') !== false);
2751 $is_testimonial_row = (strpos($row_json, 'pl_testimonial') !== false || strpos($row_json, 'pl_stars') !== false);
2752 $is_pricing_row = (strpos($row_json, 'pl_pricing') !== false);
2753 $has_404_content = (strpos($row_json, '404') !== false || strpos($row_json, 'not found') !== false || strpos($row_json, 'not-found') !== false || strpos($row_json, 'error') !== false || strpos($row_json, 'oops') !== false || strpos($row_json, 'pl_search') !== false);
2754
2755 if ($is_feature_row && !$wants_features) {
2756 continue;
2757 }
2758 if ($is_testimonial_row && !$wants_testimonials) {
2759 continue;
2760 }
2761 if ($is_pricing_row && !$wants_pricing) {
2762 continue;
2763 }
2764 if ($found_404_row && !$has_404_content && !$wants_features && !$wants_testimonials) {
2765 continue;
2766 }
2767
2768 if ($has_404_content) {
2769 $found_404_row = true;
2770 }
2771
2772 $clean[] = $row;
2773 }
2774
2775 return !empty($clean) ? array_values($clean) : $nodes;
2776 }
2777
2778 // For other single-section / specific template requests:
2779 foreach ($nodes as $row) {
2780 if (!is_array($row) || empty($row['tag']) || !$this->is_row_tag($row['tag'])) {
2781 $clean[] = $row;
2782 continue;
2783 }
2784 $row_json = strtolower(wp_json_encode($row));
2785 $is_feature_row = (strpos($row_json, 'pl_iconbox') !== false || strpos($row_json, 'pl_service') !== false);
2786 $is_testimonial_row = (strpos($row_json, 'pl_testimonial') !== false);
2787 $is_pricing_row = (strpos($row_json, 'pl_pricing') !== false);
2788
2789 if ($is_feature_row && !$wants_features && $kind !== 'service') {
2790 continue;
2791 }
2792 if ($is_testimonial_row && !$wants_testimonials) {
2793 continue;
2794 }
2795 if ($is_pricing_row && !$wants_pricing && $kind !== 'pricing') {
2796 continue;
2797 }
2798
2799 $clean[] = $row;
2800 }
2801
2802 return !empty($clean) ? array_values($clean) : $nodes;
2803 }
2804
2805 /**
2806 * Header/footer/404 must be designed chrome, not a photo-hero with one line of text.
2807 */
2808 public function normalize_theme_chrome_rows($nodes, $prompt = '') {
2809 if (!is_array($nodes) || empty($nodes)) {
2810 return $nodes;
2811 }
2812
2813 $kind = $this->request_kind($prompt);
2814 if (!in_array($kind, array('header', 'footer', '404'), true)) {
2815 return $nodes;
2816 }
2817
2818 $asked_for_image = (bool) preg_match('/\b(background\s+image|bg\s+image|hero\s+image|photo\s+background)\b/i', (string) $prompt);
2819
2820 foreach ($nodes as &$node) {
2821 if (!is_array($node) || empty($node['tag']) || !$this->is_row_tag($node['tag'])) {
2822 continue;
2823 }
2824 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
2825 $node['attrs'] = array();
2826 }
2827
2828 $bg_type = isset($node['attrs']['ele_bg_type']) ? (string) $node['attrs']['ele_bg_type'] : '';
2829 if (!$asked_for_image && ($bg_type === 'image' || !empty($node['attrs']['ele_bg_img']))) {
2830 $node['attrs']['ele_bg_type'] = 'color';
2831 if (empty($node['attrs']['ele_bg_color'])) {
2832 $node['attrs']['ele_bg_color'] = ($kind === '404') ? '$bg' : '$primary';
2833 }
2834 unset($node['attrs']['ele_bg_img'], $node['attrs']['ele_bg_img_size'], $node['attrs']['ele_bg_attachment']);
2835 } elseif ($bg_type === '' || $bg_type === 'color') {
2836 $node['attrs']['ele_bg_type'] = 'color';
2837 if (empty($node['attrs']['ele_bg_color'])) {
2838 $node['attrs']['ele_bg_color'] = ($kind === 'header') ? '$surface' : '$primary';
2839 }
2840 }
2841
2842 if ($kind === 'header') {
2843 if (empty($node['attrs']['ele_padding'])) {
2844 $node['attrs']['ele_padding'] = '18px,24px,18px,24px';
2845 }
2846 $node['attrs']['stretch'] = 'stretch';
2847 } elseif ($kind === 'footer') {
2848 if (empty($node['attrs']['ele_padding'])) {
2849 $node['attrs']['ele_padding'] = $this->is_footer_like_row($node)
2850 ? '18px,24px,18px,24px'
2851 : '56px,24px,40px,24px';
2852 }
2853 $node['attrs']['stretch'] = 'stretch';
2854 } elseif ($kind === '404') {
2855 if (empty($node['attrs']['ele_padding'])) {
2856 $node['attrs']['ele_padding'] = '80px,24px,80px,24px';
2857 }
2858 if (empty($node['attrs']['content_pos'])) {
2859 $node['attrs']['content_pos'] = 'center';
2860 }
2861 $node['attrs']['stretch'] = 'stretch';
2862 }
2863
2864 if (empty($node['attrs']['col_gap'])) {
2865 $node['attrs']['col_gap'] = '20';
2866 }
2867 }
2868 unset($node);
2869
2870 if ($kind === 'header') {
2871 $nodes = $this->ensure_header_nav($nodes);
2872 }
2873
2874 return $nodes;
2875 }
2876
2877 /**
2878 * Bind an existing WP menu to a header that is missing pl_wp_menu.
2879 */
2880 public function ensure_header_nav($nodes) {
2881 if (!is_array($nodes)) {
2882 return $nodes;
2883 }
2884
2885 $has_menu = false;
2886 $walk = function ($list) use (&$walk, &$has_menu) {
2887 foreach ((array) $list as $node) {
2888 if (!is_array($node) || empty($node['tag'])) {
2889 continue;
2890 }
2891 if ($this->normalize_tag($node['tag']) === 'pl_wp_menu') {
2892 $has_menu = true;
2893 return;
2894 }
2895 if (isset($node['content']) && is_array($node['content'])) {
2896 $walk($node['content']);
2897 }
2898 }
2899 };
2900 $walk($nodes);
2901 if ($has_menu) {
2902 return $nodes;
2903 }
2904
2905 $catalog = $this->get_widget_catalog();
2906 if (!isset($catalog['pl_wp_menu'])) {
2907 return $nodes;
2908 }
2909
2910 $menu_id = 0;
2911 $menus = function_exists('wp_get_nav_menus') ? wp_get_nav_menus(array('number' => 1)) : array();
2912 if (!empty($menus) && !is_wp_error($menus)) {
2913 $menu_id = (int) $menus[0]->term_id;
2914 }
2915 if ($menu_id <= 0) {
2916 $locations = get_nav_menu_locations();
2917 if (!empty($locations) && is_array($locations)) {
2918 $menu_id = (int) reset($locations);
2919 }
2920 }
2921
2922 $menu_node = array(
2923 'tag' => 'pl_wp_menu',
2924 'attrs' => array(
2925 'nav_list' => $menu_id > 0 ? (string) $menu_id : '',
2926 'layout' => 'horizontal',
2927 'align' => 'right',
2928 'drop_breakpoint' => 'tablet',
2929 'pointer' => 'underline',
2930 ),
2931 );
2932
2933 foreach ($nodes as &$row) {
2934 if (!is_array($row) || empty($row['tag']) || !$this->is_row_tag($row['tag'])) {
2935 continue;
2936 }
2937 if (!isset($row['content']) || !is_array($row['content'])) {
2938 continue;
2939 }
2940 $cols = $row['content'];
2941 $last = count($cols) - 1;
2942 if ($last >= 0 && !empty($cols[$last]['tag']) && $this->is_col_tag($cols[$last]['tag'])) {
2943 if (!isset($cols[$last]['content']) || !is_array($cols[$last]['content'])) {
2944 $cols[$last]['content'] = array();
2945 }
2946 $cols[$last]['content'][] = $menu_node;
2947 $row['content'] = $cols;
2948 break;
2949 }
2950 }
2951 unset($row);
2952
2953 return $nodes;
2954 }
2955
2956 /**
2957 * Detect if a row is a website header navbar (logo + navigation links / menu)
2958 */
2959 private function is_header_like_row($node) {
2960 if (!is_array($node) || empty($node['tag']) || !$this->is_row_tag($node['tag'])) {
2961 return false;
2962 }
2963
2964 $json = strtolower(wp_json_encode($node));
2965 if (strpos($json, 'pl_nav') !== false || strpos($json, 'pl_menu') !== false || strpos($json, 'pl_nav_menu') !== false) {
2966 return true;
2967 }
2968
2969 // Check if it's a slim row containing only Logo/Brand + Menu links (Home, About, Services, Contact, Sign In)
2970 if (preg_match('/\b(home\b.*\babout\b.*\bcontact|home\b.*\bservices\b|nav[-_]item|navbar|main-menu|site-logo)\b/i', $json)) {
2971 // Ensure it doesn't contain a hero headline like "welcome to" or "h1"
2972 if (strpos($json, '<h1>') === false && strpos($json, 'pagelayer-badge') === false) {
2973 return true;
2974 }
2975 }
2976
2977 return false;
2978 }
2979
2980 /**
2981 * Detect if a row is a website footer / copyright bar
2982 */
2983 private function is_footer_like_row($node) {
2984 if (!is_array($node) || empty($node['tag']) || !$this->is_row_tag($node['tag'])) {
2985 return false;
2986 }
2987
2988 $json = strtolower(wp_json_encode($node));
2989 // Check for copyright text, all rights reserved, footer links
2990 if (preg_match('/(all\s*rights\s*reserved|©|&copy;|copyright\s*\d{4}|privacy\s*policy.*terms\s*of\s*service)/i', $json)) {
2991 return true;
2992 }
2993
2994 return false;
2995 }
2996
2997 private function strip_placeholder_copy($text) {
2998 $text = (string) $text;
2999 $text = preg_replace('/\{\{(?!element\})[a-zA-Z0-9_\-]+\}\}/', '', $text);
3000 $text = preg_replace('/\{[a-zA-Z0-9_\-]+\}/', '', $text);
3001 $text = preg_replace('/\(\s*ribbon[_\s-]*text\s*\)/i', '', $text);
3002 $text = preg_replace('/\bribbon[_\s-]*text\b/i', '', $text);
3003 $text = preg_replace('/\(\s*badge[_\s-]*text\s*\)/i', '', $text);
3004 $text = preg_replace('/\bLorem ipsum\b[^<]*/i', '', $text);
3005 return $text;
3006 }
3007
3008 private function is_placeholder_label($text) {
3009 $plain = strtolower(trim(preg_replace('/\s+/', ' ', wp_strip_all_tags((string) $text))));
3010 if ($plain === '') {
3011 return true;
3012 }
3013 return (bool) preg_match('/^(ribbon([\s_-]*text)?|badge([\s_-]*text)?|your (title|text|heading)|lorem ipsum.*)$/i', $plain);
3014 }
3015
3016 private function scrub_placeholders($nodes) {
3017 if (!is_array($nodes)) {
3018 return $nodes;
3019 }
3020 $out = array();
3021 foreach ($nodes as $node) {
3022 if (!is_array($node)) {
3023 $out[] = $node;
3024 continue;
3025 }
3026 if (isset($node['content']) && is_string($node['content'])) {
3027 $node['content'] = $this->strip_placeholder_copy($node['content']);
3028 }
3029 if (!empty($node['attrs']) && is_array($node['attrs'])) {
3030 foreach ($node['attrs'] as $k => $v) {
3031 if (is_string($v) && $k !== 'ele_css' && $k !== 'ele_attributes') {
3032 $node['attrs'][$k] = $this->strip_placeholder_copy($v);
3033 }
3034 }
3035 }
3036 if (isset($node['content']) && is_array($node['content'])) {
3037 $node['content'] = $this->scrub_placeholders($node['content']);
3038 }
3039 $out[] = $node;
3040 }
3041 return $out;
3042 }
3043
3044 private function normalize_badges_and_ribbons($nodes) {
3045 if (!is_array($nodes)) {
3046 return $nodes;
3047 }
3048 $out = array();
3049 $n = count($nodes);
3050 for ($i = 0; $i < $n; $i++) {
3051 $node = $nodes[$i];
3052 if (!is_array($node) || empty($node['tag'])) {
3053 $out[] = $node;
3054 continue;
3055 }
3056
3057 $tag = $this->normalize_tag($node['tag']);
3058 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
3059 $node['attrs'] = array();
3060 }
3061
3062 if ($tag === 'pl_call') {
3063 $ribbon = isset($node['attrs']['ribbon_text']) ? $node['attrs']['ribbon_text'] : '';
3064 $show = !empty($node['attrs']['show_ribbon']) && $node['attrs']['show_ribbon'] !== 'false' && $node['attrs']['show_ribbon'] !== '0';
3065 if (!$show || $this->is_placeholder_label($ribbon)) {
3066 unset(
3067 $node['attrs']['show_ribbon'],
3068 $node['attrs']['ribbon_text'],
3069 $node['attrs']['ribbon_pos'],
3070 $node['attrs']['ribbon_style'],
3071 $node['attrs']['ribbon_typo'],
3072 $node['attrs']['ribbon_color'],
3073 $node['attrs']['ribbon_bg']
3074 );
3075 $node['attrs']['show_ribbon'] = '';
3076 }
3077 // Ensure clean, generous content padding for Call To Action
3078 if (empty($node['attrs']['content_spacing'])) {
3079 $node['attrs']['content_spacing'] = '32,36,32,36';
3080 } else {
3081 $parts = explode(',', (string) $node['attrs']['content_spacing']);
3082 $left = isset($parts[3]) ? intval($parts[3]) : 0;
3083 $right = isset($parts[1]) ? intval($parts[1]) : 0;
3084 if ($left < 28 || $right < 28) {
3085 $top = isset($parts[0]) && intval($parts[0]) >= 20 ? intval($parts[0]) : 32;
3086 $r = max($right, 36);
3087 $bottom = isset($parts[2]) && intval($parts[2]) >= 20 ? intval($parts[2]) : 32;
3088 $l = max($left, 36);
3089 $node['attrs']['content_spacing'] = "{$top},{$r},{$bottom},{$l}";
3090 }
3091 }
3092 if (empty($node['attrs']['content_valign'])) {
3093 $node['attrs']['content_valign'] = 'center';
3094 }
3095 }
3096
3097 if ($tag === 'pl_flipbox') {
3098 // Prevent text overflowing or spilling out of flipbox
3099 if (empty($node['attrs']['height']) || intval($node['attrs']['height']) < 380) {
3100 $node['attrs']['height'] = '380';
3101 }
3102 if (empty($node['attrs']['front_section_padding']) || $node['attrs']['front_section_padding'] === '100,100,100,100') {
3103 $node['attrs']['front_section_padding'] = '24,28,24,28';
3104 }
3105 if (empty($node['attrs']['back_section_padding']) || $node['attrs']['back_section_padding'] === '100,100,100,100') {
3106 $node['attrs']['back_section_padding'] = '24,28,24,28';
3107 }
3108 if (empty($node['attrs']['content_width']) || intval($node['attrs']['content_width']) < 90) {
3109 $node['attrs']['content_width'] = '100';
3110 }
3111 }
3112
3113 if ($tag === 'pl_heading' && isset($node['content']) && is_string($node['content'])) {
3114 $plain = trim(wp_strip_all_tags($node['content']));
3115 $next = ($i + 1 < $n && is_array($nodes[$i + 1])) ? $nodes[$i + 1] : null;
3116 $next_is_title = false;
3117 if ($next && !empty($next['tag']) && $this->normalize_tag($next['tag']) === 'pl_heading' && isset($next['content']) && is_string($next['content'])) {
3118 $next_is_title = (bool) preg_match('/<h[1-3]\b/i', $next['content']);
3119 }
3120 if ($this->is_placeholder_label($plain)) {
3121 continue;
3122 }
3123 if ($next_is_title && $plain !== '' && strlen($plain) <= 28 && !preg_match('/<h[1-3]\b/i', $node['content'])) {
3124 $node = $this->badge_heading_node($plain, $node);
3125 } elseif (preg_match('/^\s*(?:<[^>]+>)?\s*(?:\(|\[)?\s*(ribbon|badge)[^<]{0,20}$/i', $plain)) {
3126 continue;
3127 }
3128 }
3129
3130 if (isset($node['content']) && is_array($node['content'])) {
3131 $node['content'] = $this->normalize_badges_and_ribbons($node['content']);
3132 }
3133 $out[] = $node;
3134 }
3135 return $out;
3136 }
3137
3138 private function badge_heading_node($label, $base) {
3139 $on_dark = false;
3140 if (!empty($base['attrs']['color'])) {
3141 $on_dark = !$this->color_is_dark($base['attrs']['color']);
3142 }
3143 $bg = $on_dark ? 'rgba(255,255,255,0.14)' : 'rgba(37,99,235,0.12)';
3144 $color = !empty($base['attrs']['color']) ? $base['attrs']['color'] : '$primary';
3145 return array(
3146 'tag' => 'pl_heading',
3147 'attrs' => array(
3148 'align' => !empty($base['attrs']['align']) ? $base['attrs']['align'] : 'left',
3149 'color' => $color,
3150 'heading_typo' => $this->global_font_family('primary') . ',12,,700,,,,1.2,uppercase,1.4,',
3151 'ele_margin' => '0px,0px,16px,0px',
3152 'ele_css' => '{{element}} .pagelayer-heading-holder{display:block;margin:0 0 16px;}'
3153 . '{{element}} .pagelayer-badge{display:inline-block;padding:6px 14px;border-radius:999px;background:' . $bg . ';letter-spacing:0.08em;}',
3154 ),
3155 'content' => '<span class="pagelayer-badge">' . esc_html($label) . '</span>',
3156 );
3157 }
3158
3159 private function normalize_social_groups($nodes) {
3160 if (!is_array($nodes)) {
3161 return $nodes;
3162 }
3163
3164 $flat = array();
3165 $buf = array();
3166 $flush = function () use (&$buf, &$flat) {
3167 if (count($buf) >= 2) {
3168 $flat[] = $this->make_social_grp($buf);
3169 } else {
3170 foreach ($buf as $item) {
3171 $flat[] = $item;
3172 }
3173 }
3174 $buf = array();
3175 };
3176
3177 foreach ($nodes as $node) {
3178 if (!is_array($node) || empty($node['tag'])) {
3179 $flush();
3180 $flat[] = $node;
3181 continue;
3182 }
3183 $tag = $this->normalize_tag($node['tag']);
3184 if ($tag === 'pl_social') {
3185 $buf[] = $node;
3186 continue;
3187 }
3188 $flush();
3189 if ($tag === 'pl_social_grp') {
3190 $node = $this->style_social_grp($node);
3191 }
3192 if (isset($node['content']) && is_array($node['content'])) {
3193 $node['content'] = $this->normalize_social_groups($node['content']);
3194 }
3195 $flat[] = $node;
3196 }
3197 $flush();
3198 return $flat;
3199 }
3200
3201 private function make_social_grp($children) {
3202 return $this->style_social_grp(array(
3203 'tag' => 'pl_social_grp',
3204 'attrs' => array(),
3205 'content' => array_values($children),
3206 ));
3207 }
3208
3209 private function style_social_grp($node, $on_dark = false) {
3210 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
3211 $node['attrs'] = array();
3212 }
3213 $attrs = $node['attrs'];
3214 $attrs['group_layout'] = 'pagelayer-btn-grp-horizontal';
3215 if (empty($attrs['align'])) {
3216 $attrs['align'] = 'center';
3217 }
3218 if (!isset($attrs['icon_spacing']) || $attrs['icon_spacing'] === '' || (string) $attrs['icon_spacing'] === '0') {
3219 $attrs['icon_spacing'] = '8';
3220 }
3221 $size = isset($attrs['icon_size']) ? (int) preg_replace('/[^0-9]/', '', (string) $attrs['icon_size']) : 0;
3222 $attrs['icon_size'] = (string) ($size >= 18 ? $size : 20);
3223 if (empty($attrs['bg_shape'])) {
3224 $attrs['bg_shape'] = 'pagelayer-social-shape-circle';
3225 }
3226 if (empty($attrs['bg_size']) || (int) $attrs['bg_size'] < 6) {
3227 $attrs['bg_size'] = '10';
3228 }
3229
3230 // Custom theme colors so icon vs circle never match. Official scheme
3231 // is fine visually, but AI often sets icon_color == icon_bg_color.
3232 $attrs['color_scheme'] = '';
3233 $icon = isset($attrs['icon_color']) ? trim((string) $attrs['icon_color']) : '';
3234 $bg = isset($attrs['icon_bg_color']) ? trim((string) $attrs['icon_bg_color']) : '';
3235 $same = ($icon !== '' && $bg !== '' && strtolower($icon) === strtolower($bg));
3236 $icon_dark = $icon === '' ? null : $this->color_is_dark($icon, false);
3237 $bg_dark = $bg === '' ? null : $this->color_is_dark($bg, true);
3238 $icon_light = $icon !== '' && $this->color_is_light($icon);
3239 $bg_light = $bg !== '' && $this->color_is_light($bg);
3240
3241 $clash = $same || $icon === '' || $bg === '' || ($icon_dark && $bg_dark) || ($icon_light && $bg_light);
3242 if ($clash) {
3243 $attrs['icon_color'] = '#ffffff';
3244 $attrs['icon_bg_color'] = '$primary';
3245 }
3246 if (empty($attrs['icon_color_hover'])) {
3247 $attrs['icon_color_hover'] = '$primary';
3248 }
3249 if (empty($attrs['icon_bg_color_hover'])) {
3250 $attrs['icon_bg_color_hover'] = '#ffffff';
3251 }
3252
3253 $row = '{{element}}{display:inline-flex;flex-direction:row;flex-wrap:wrap;align-items:center;justify-content:center;gap:14px;}'
3254 . '{{element}}>.pagelayer-ele-wrap,{{element}} .pagelayer-social{display:inline-flex;width:auto;max-width:none;float:none;}'
3255 . '{{element}} .pagelayer-icon-holder{background-color:var(--pagelayer-color-primary,#4f46e5)!important;}'
3256 . '{{element}} .pagelayer-social-fa{color:#ffffff!important;}';
3257 $css = isset($attrs['ele_css']) ? (string) $attrs['ele_css'] : '';
3258 if (strpos($css, 'pagelayer-social-fa') === false) {
3259 $attrs['ele_css'] = $css . $row;
3260 } elseif (strpos($css, 'flex-direction:row') === false) {
3261 $attrs['ele_css'] = $css . '{{element}}{display:inline-flex;flex-direction:row;flex-wrap:wrap;align-items:center;justify-content:center;gap:14px;}';
3262 }
3263 $node['attrs'] = $attrs;
3264 return $node;
3265 }
3266
3267 /**
3268 * Icon / image boxes: stacked layout, centered icon + heading + copy,
3269 * with a readable heading/body type scale.
3270 */
3271 private function style_icon_box($tag, $attrs) {
3272 if (!is_array($attrs)) {
3273 $attrs = array();
3274 }
3275
3276 $heading_family = $this->global_font_family('primary');
3277 $body_family = $this->global_font_family('text');
3278
3279 $attrs['service_alignment'] = 'top';
3280 if ($tag === 'pl_iconbox') {
3281 $attrs['service_icon_alignment'] = 'center';
3282 } else {
3283 $attrs['service_img_alignment'] = 'center';
3284 }
3285 $attrs['heading_alignment'] = 'center';
3286 $attrs['service_text_alignment'] = 'center';
3287
3288 if (empty($attrs['service_icon_spacing']) && $tag === 'pl_iconbox') {
3289 $attrs['service_icon_spacing'] = '0,0,16,0';
3290 }
3291 if (empty($attrs['service_title_spacing'])) {
3292 $attrs['service_title_spacing'] = '0,0,8,0';
3293 }
3294
3295 $attrs['service_heading_typo'] = $this->iconbox_heading_typo(
3296 isset($attrs['service_heading_typo']) ? $attrs['service_heading_typo'] : '',
3297 $heading_family
3298 );
3299
3300 if (empty($attrs['font_family'])) {
3301 $attrs['font_family'] = $body_family;
3302 }
3303 if (empty($attrs['font_size']) || (int) $attrs['font_size'] < 14 || (int) $attrs['font_size'] > 18) {
3304 $attrs['font_size'] = '15';
3305 }
3306 if (empty($attrs['line_height'])) {
3307 $attrs['line_height'] = '1.65';
3308 }
3309
3310 if ($tag === 'pl_service') {
3311 $attrs['service_alignment'] = 'top';
3312 $attrs['service_img_alignment'] = 'center';
3313 $attrs['heading_alignment'] = 'center';
3314 $attrs['service_text_alignment'] = 'center';
3315 if (empty($attrs['service_image_height'])) {
3316 $attrs['service_image_height'] = '220';
3317 }
3318 if (empty($attrs['service_image_object_fit'])) {
3319 $attrs['service_image_object_fit'] = 'cover';
3320 }
3321 if (empty($attrs['service_image_object_pos'])) {
3322 $attrs['service_image_object_pos'] = 'center';
3323 }
3324 if (empty($attrs['service_image_border_radius'])) {
3325 $attrs['service_image_border_radius'] = '8,8,8,8';
3326 }
3327 $rule = '{{element}} .pagelayer-service-image{width:100%!important;overflow:hidden;margin:0 auto 16px auto;border-radius:8px;}'
3328 . '{{element}} .pagelayer-service-image img{width:100%!important;height:220px!important;max-height:220px!important;min-height:220px!important;object-fit:cover!important;object-position:center!important;border-radius:8px;display:block;margin:0 auto;}'
3329 . '{{element}} .pagelayer-service-container{display:flex;flex-direction:column;height:100%;}'
3330 . '{{element}} .pagelayer-service-details{display:flex;flex-direction:column;flex-grow:1;}'
3331 . '{{element}} .pagelayer-service-heading{text-align:center;font-weight:700;line-height:1.3;margin-bottom:8px;}'
3332 . '{{element}} .pagelayer-service-text{text-align:center;font-size:15px;line-height:1.7;font-weight:400;flex-grow:1;margin-bottom:16px;}'
3333 . '{{element}} .pagelayer-service-btn{margin-top:auto;align-self:center;}';
3334 } else {
3335 $attrs['service_alignment'] = 'top';
3336 $attrs['service_icon_alignment'] = 'center';
3337 $attrs['heading_alignment'] = 'center';
3338 $attrs['service_text_alignment'] = 'center';
3339 if (empty($attrs['service_icon_font_size'])) {
3340 $attrs['service_icon_font_size'] = '28';
3341 }
3342 $rule = '{{element}} .pagelayer-service-icon{display:inline-flex!important;align-items:center!important;justify-content:center!important;width:64px!important;height:64px!important;min-width:64px!important;min-height:64px!important;margin:0 auto 16px auto!important;line-height:1!important;}'
3343 . '{{element}} .pagelayer-service-icon i,{{element}} .pagelayer-service-icon svg{font-size:28px!important;width:28px!important;height:28px!important;line-height:28px!important;display:inline-block!important;}'
3344 . '{{element}} .pagelayer-service-container{display:flex;flex-direction:column;height:100%;}'
3345 . '{{element}} .pagelayer-service-details{display:flex;flex-direction:column;flex-grow:1;}'
3346 . '{{element}} .pagelayer-service-heading{text-align:center;font-weight:700;line-height:1.3;margin-bottom:8px;}'
3347 . '{{element}} .pagelayer-service-text{text-align:center;font-size:15px;line-height:1.7;font-weight:400;flex-grow:1;margin-bottom:16px;}'
3348 . '{{element}} .pagelayer-service-btn{margin-top:auto;align-self:center;}';
3349 }
3350 $attrs = $this->append_ele_css($attrs, $rule);
3351
3352 return $attrs;
3353 }
3354
3355 /**
3356 * Image slider: constrained width and height with object-fit: cover,
3357 * preventing massive, viewport-overflowing images.
3358 */
3359 private function style_image_slider($attrs) {
3360 if (!is_array($attrs)) {
3361 $attrs = array();
3362 }
3363
3364 if (empty($attrs['slide_items'])) {
3365 $attrs['slide_items'] = '1';
3366 }
3367 if (empty($attrs['auto'])) {
3368 $attrs['auto'] = 'true';
3369 }
3370 if (empty($attrs['loop'])) {
3371 $attrs['loop'] = 'true';
3372 }
3373 if (empty($attrs['adaptive_height'])) {
3374 $attrs['adaptive_height'] = 'false';
3375 }
3376 if (empty($attrs['controls'])) {
3377 $attrs['controls'] = 'arrows';
3378 }
3379 if (empty($attrs['border_radius'])) {
3380 $attrs['border_radius'] = '12px,12px,12px,12px';
3381 }
3382 $nav = isset($attrs['nav_size']) ? (int) preg_replace('/[^0-9]/', '', (string) $attrs['nav_size']) : 0;
3383 $attrs['nav_size'] = (string) ($nav >= 22 ? $nav : 28);
3384 $btn = isset($attrs['arraow_bg_size']) ? (int) preg_replace('/[^0-9]/', '', (string) $attrs['arraow_bg_size']) : 0;
3385 $attrs['arraow_bg_size'] = (string) ($btn >= 36 ? $btn : 48);
3386 if (empty($attrs['arraow_bg_shape'])) {
3387 $attrs['arraow_bg_shape'] = '50';
3388 }
3389 $arrow_fg = isset($attrs['arraow_color']) ? (string) $attrs['arraow_color'] : '';
3390 $arrow_bg = isset($attrs['arrows_bg']) ? (string) $attrs['arrows_bg'] : '';
3391 if ($arrow_fg === '' || $arrow_bg === '' || $this->colors_clash($arrow_fg, $arrow_bg)) {
3392 $attrs['arrows_bg'] = 'rgba(15,23,42,0.72)';
3393 $attrs['arraow_color'] = '#ffffff';
3394 }
3395
3396 $rule = '{{element}}{max-width:1000px!important;margin:0 auto!important;overflow:visible!important;}'
3397 . '{{element}} .pagelayer-image-slider-div{max-width:1000px!important;margin:0 auto!important;overflow:visible!important;border-radius:12px!important;}'
3398 . '{{element}} .pagelayer-image-slider-ul{margin:0 auto!important;}'
3399 . '{{element}} .pagelayer-slider-item{overflow:hidden!important;border-radius:12px!important;}'
3400 . '{{element}} .pagelayer-slider-item img,{{element}} img.pagelayer-img,{{element}} .pagelayer-image-slider-div img{width:100%!important;height:420px!important;max-height:440px!important;min-height:280px!important;object-fit:cover!important;object-position:center!important;border-radius:12px!important;display:block!important;margin:0 auto!important;}'
3401 . '{{element}} .pagelayer-owl-nav{z-index:5!important;}'
3402 . '{{element}} .pagelayer-owl-prev,{{element}} .pagelayer-owl-next{width:48px!important;height:48px!important;min-width:48px!important;min-height:48px!important;border-radius:50%!important;background:rgba(15,23,42,0.72)!important;color:#ffffff!important;display:flex!important;align-items:center!important;justify-content:center!important;opacity:1!important;}'
3403 . '{{element}} .pagelayer-owl-prev span,{{element}} .pagelayer-owl-next span,{{element}} .pagelayer-owl-prev i,{{element}} .pagelayer-owl-next i{font-size:28px!important;line-height:1!important;color:#ffffff!important;}';
3404
3405 $attrs = $this->append_ele_css($attrs, $rule);
3406
3407 return $attrs;
3408 }
3409
3410 private function iconbox_heading_typo($typo, $family) {
3411 $parts = array_fill(0, 11, '');
3412 if (is_string($typo) && $typo !== '') {
3413 if (isset($typo[0]) && $typo[0] === '$') {
3414 $token_family = $this->global_font_family(substr($typo, 1));
3415 if ($token_family !== '') {
3416 $family = $token_family;
3417 }
3418 } else {
3419 foreach (explode(',', $typo) as $i => $part) {
3420 if ($i < 11) {
3421 $parts[$i] = $part;
3422 }
3423 }
3424 }
3425 }
3426 if ($family !== '' && $parts[0] === '') {
3427 $parts[0] = $family;
3428 }
3429 $size = (int) preg_replace('/[^0-9]/', '', (string) $parts[1]);
3430 if ($size < 18 || $size > 24) {
3431 $parts[1] = '20';
3432 }
3433 $parts[3] = ($parts[3] === '' || (int) $parts[3] < 600) ? '700' : $parts[3];
3434 if ($parts[7] === '' || (float) $parts[7] < 1.2) {
3435 $parts[7] = '1.3';
3436 }
3437 return implode(',', $parts);
3438 }
3439
3440 private function enforce_section_padding($nodes) {
3441 if (!is_array($nodes)) {
3442 return $nodes;
3443 }
3444 foreach ($nodes as &$node) {
3445 if (!is_array($node) || empty($node['tag']) || !$this->is_row_tag($node['tag'])) {
3446 continue;
3447 }
3448 $tag = $this->normalize_tag($node['tag']);
3449 if ($tag === 'pl_inner_row') {
3450 continue;
3451 }
3452 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
3453 $node['attrs'] = array();
3454 }
3455 $node['attrs']['ele_padding'] = $this->section_row_padding(
3456 isset($node['attrs']['ele_padding']) ? $node['attrs']['ele_padding'] : ''
3457 );
3458 }
3459 unset($node);
3460 return $nodes;
3461 }
3462
3463 /**
3464 * Public helper so the controller shortcode fallback can reuse section padding.
3465 */
3466 public function format_section_row_padding($pad_raw) {
3467 return $this->section_row_padding($pad_raw);
3468 }
3469
3470 /**
3471 * Section rows need their own top AND bottom padding. A 0 top on the next
3472 * band makes its columns sit flush against the row above.
3473 */
3474 private function section_row_padding($pad_raw) {
3475 $pad_raw = trim((string) $pad_raw);
3476 $min_y = 50;
3477 $min_x = 20;
3478 $cap_y = 60;
3479
3480 if ($pad_raw === '' || $pad_raw === '0px,0px,0px,0px' || $pad_raw === '0,0,0,0') {
3481 return $min_y . 'px,' . $min_x . 'px,' . $min_y . 'px,' . $min_x . 'px';
3482 }
3483
3484 $parts = $this->split_padding($pad_raw);
3485 $top = $this->padding_to_px($parts[0]);
3486 $right = $this->padding_to_px($parts[1]);
3487 $bot = $this->padding_to_px($parts[2]);
3488 $left = $this->padding_to_px($parts[3]);
3489
3490 if ($top < $min_y) {
3491 $parts[0] = $min_y . 'px';
3492 } elseif ($top > 100) {
3493 $parts[0] = $cap_y . 'px';
3494 }
3495 if ($bot < $min_y) {
3496 $parts[2] = $min_y . 'px';
3497 } elseif ($bot > 100) {
3498 $parts[2] = $cap_y . 'px';
3499 }
3500 if ($right <= 0) {
3501 $parts[1] = $min_x . 'px';
3502 }
3503 if ($left <= 0) {
3504 $parts[3] = $parts[1];
3505 }
3506
3507 return implode(',', $parts);
3508 }
3509
3510 /**
3511 * Apply palette, fonts, row backgrounds, contrast, and schema-owned
3512 * visual settings the model left empty. Does not invent new sections.
3513 */
3514 private function apply_theme_visuals($nodes, $on_dark = false, $row_i = 0) {
3515 if (!is_array($nodes) || empty($nodes)) {
3516 return $nodes;
3517 }
3518
3519 $out = array();
3520 foreach ($nodes as $node) {
3521 if (!is_array($node) || empty($node['tag'])) {
3522 $out[] = $node;
3523 continue;
3524 }
3525
3526 $tag = $this->normalize_tag($node['tag']);
3527 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
3528 $node['attrs'] = array();
3529 }
3530
3531 $child_dark = $on_dark;
3532 if ($this->is_row_tag($tag)) {
3533 $node['attrs'] = $this->ensure_row_background($node['attrs'], $row_i);
3534 $child_dark = $this->effective_is_dark($node['attrs'], $on_dark);
3535 $row_i++;
3536 } elseif ($this->has_own_surface($node['attrs'])) {
3537 $child_dark = $this->effective_is_dark($node['attrs'], $on_dark);
3538 }
3539
3540 $index = $this->get_attr_index($tag);
3541 $node['attrs'] = $this->apply_schema_visuals($tag, $node['attrs'], $node, $index, $child_dark);
3542 if ($tag === 'pl_iconbox' || $tag === 'pl_service') {
3543 $node['attrs'] = $this->style_icon_box($tag, $node['attrs']);
3544 } elseif ($tag === 'pl_image_slider') {
3545 $node['attrs'] = $this->style_image_slider($node['attrs']);
3546 } elseif ($tag === 'pl_accordion' || $tag === 'pl_tabs') {
3547 $node['attrs'] = $this->style_accordion_or_tabs($tag, $node['attrs'], $child_dark);
3548 } elseif ($tag === 'pl_counter') {
3549 $node['attrs'] = $this->style_counter($node['attrs'], $child_dark);
3550 }
3551
3552 // Card chrome may have just received a background — recompute contrast
3553 // so children (and this widget's own text) match the surface they sit on.
3554 if ($this->has_own_surface($node['attrs'])) {
3555 $child_dark = $this->effective_is_dark($node['attrs'], $on_dark);
3556 }
3557
3558 $node['attrs'] = $this->ensure_contrast_text($tag, $node['attrs'], $index, $child_dark);
3559 $node['attrs'] = $this->ensure_widget_contrast($tag, $node['attrs'], $child_dark);
3560 if ($tag === 'pl_social_grp') {
3561 $node = $this->style_social_grp($node, $child_dark);
3562 }
3563 if ($this->is_row_tag($tag) || $this->has_own_surface($node['attrs'])) {
3564 $node['attrs'] = $this->mark_contrast_class($node['attrs'], $child_dark);
3565 }
3566 $node['attrs'] = $this->fill_style_companions($node['attrs']);
3567 $node['attrs'] = $this->sanitize_attrs($tag, $node['attrs']);
3568 $node['attrs'] = $this->fill_style_companions($node['attrs']);
3569
3570 if (isset($node['content']) && is_array($node['content'])) {
3571 $node['content'] = $this->apply_theme_visuals($node['content'], $child_dark, 0);
3572 }
3573
3574 $out[] = $node;
3575 }
3576
3577 return $out;
3578 }
3579
3580 private function ensure_row_background($attrs, $row_i) {
3581 if (!is_array($attrs)) {
3582 $attrs = array();
3583 }
3584 if (!empty($attrs['ele_bg_color']) || (!empty($attrs['ele_bg_type']) && $attrs['ele_bg_type'] !== '' && $attrs['ele_bg_type'] !== 'color')) {
3585 if (!empty($attrs['ele_bg_color']) && (empty($attrs['ele_bg_type']) || $attrs['ele_bg_type'] === '')) {
3586 $attrs['ele_bg_type'] = 'color';
3587 }
3588 return $attrs;
3589 }
3590
3591 $cycle = array('$primary', '$bg', '$surface', '$bg');
3592 $token = $cycle[$row_i % count($cycle)];
3593 $attrs['ele_bg_type'] = 'color';
3594 $attrs['ele_bg_color'] = $token;
3595 return $attrs;
3596 }
3597
3598 /**
3599 * Public contrast helper so the controller/canvas fallback can reuse it.
3600 */
3601 public function is_dark_color($val, $fallback = false) {
3602 return $this->color_is_dark($val, $fallback);
3603 }
3604
3605 private function has_own_surface($attrs) {
3606 if (!is_array($attrs)) {
3607 return false;
3608 }
3609 if (!empty($attrs['ele_bg_color'])) {
3610 return true;
3611 }
3612 $type = isset($attrs['ele_bg_type']) ? (string) $attrs['ele_bg_type'] : '';
3613 return ($type !== '' && $type !== 'color');
3614 }
3615
3616 private function effective_is_dark($attrs, $parent_dark = false) {
3617 if (!is_array($attrs)) {
3618 return $parent_dark;
3619 }
3620 if (!empty($attrs['ele_bg_color'])) {
3621 return $this->color_is_dark($attrs['ele_bg_color'], $parent_dark);
3622 }
3623 $type = isset($attrs['ele_bg_type']) ? (string) $attrs['ele_bg_type'] : '';
3624 if ($type === 'gradient' && !empty($attrs['ele_bg_gradient']) && preg_match('/#([0-9a-f]{3,8})/i', (string) $attrs['ele_bg_gradient'], $m)) {
3625 return $this->color_is_dark('#' . $m[1], $parent_dark);
3626 }
3627 if ($type === 'image') {
3628 if (!empty($attrs['ele_img_color'])) {
3629 return $this->color_is_dark($attrs['ele_img_color'], true);
3630 }
3631 return true;
3632 }
3633 return $parent_dark;
3634 }
3635
3636 private function mark_contrast_class($attrs, $on_dark) {
3637 if (!is_array($attrs)) {
3638 $attrs = array();
3639 }
3640 $cls = isset($attrs['ele_classes']) ? trim((string) $attrs['ele_classes']) : '';
3641 $cls = trim(preg_replace('/\s*pagelayer-ai-on-(dark|light)\s*/i', ' ', $cls));
3642 $attrs['ele_classes'] = trim($cls . ' ' . ($on_dark ? 'pagelayer-ai-on-dark' : 'pagelayer-ai-on-light'));
3643 return $attrs;
3644 }
3645
3646 private function color_is_dark($val, $fallback = false) {
3647 $val = trim((string) $val);
3648 if ($val === '') {
3649 return $fallback;
3650 }
3651 $low = strtolower($val);
3652 if (in_array($low, array('transparent', 'inherit', 'currentcolor', 'currentColor'), true)) {
3653 return $fallback;
3654 }
3655
3656 $dark_names = array(
3657 'black' => 1, 'navy' => 1, 'maroon' => 1, 'purple' => 1, 'indigo' => 1,
3658 'midnightblue' => 1, 'darkblue' => 1, 'darkslategray' => 1, 'darkslategrey' => 1,
3659 'darkgreen' => 1, 'darkred' => 1, 'teal' => 1, 'olive' => 1, 'brown' => 1,
3660 '#000' => 1, '#000000' => 1, '#111' => 1, '#111111' => 1, '#222' => 1, '#222222' => 1,
3661 );
3662 if (isset($dark_names[$low])) {
3663 return true;
3664 }
3665 $light_names = array(
3666 'white' => 1, 'ivory' => 1, 'snow' => 1, 'whitesmoke' => 1, 'ghostwhite' => 1,
3667 'azure' => 1, 'beige' => 1, 'linen' => 1, 'seashell' => 1, '#fff' => 1, '#ffffff' => 1,
3668 );
3669 if (isset($light_names[$low])) {
3670 return false;
3671 }
3672
3673 if (preg_match('/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?\s*\)/i', $val, $m)) {
3674 $alpha = isset($m[4]) && $m[4] !== '' ? (float) $m[4] : 1.0;
3675 if ($alpha < 0.45) {
3676 return $fallback;
3677 }
3678 return $this->rgb_is_dark((int) $m[1], (int) $m[2], (int) $m[3]);
3679 }
3680
3681 if (isset($val[0]) && $val[0] === '$') {
3682 $key = strtolower(substr($val, 1));
3683 $hex = $this->resolve_color_hex($val);
3684 if ($hex !== '') {
3685 return $this->hex_is_dark($hex, $fallback);
3686 }
3687 // Unresolved body-text token is almost always a dark ink color.
3688 return ($key === 'text') ? true : $fallback;
3689 }
3690
3691 $hex = $this->resolve_color_hex($val);
3692 if ($hex === '') {
3693 return $fallback;
3694 }
3695 return $this->hex_is_dark($hex, $fallback);
3696 }
3697
3698 private function hex_is_dark($hex, $fallback = false) {
3699 $hex = ltrim((string) $hex, '#');
3700 if (strlen($hex) === 3) {
3701 $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
3702 } elseif (strlen($hex) === 4) {
3703 $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2] . $hex[3] . $hex[3];
3704 }
3705 if (strlen($hex) >= 8) {
3706 $alpha = hexdec(substr($hex, 6, 2)) / 255;
3707 if ($alpha < 0.45) {
3708 return $fallback;
3709 }
3710 $hex = substr($hex, 0, 6);
3711 }
3712 if (strlen($hex) < 6) {
3713 return $fallback;
3714 }
3715 return $this->rgb_is_dark(
3716 hexdec(substr($hex, 0, 2)),
3717 hexdec(substr($hex, 2, 2)),
3718 hexdec(substr($hex, 4, 2))
3719 );
3720 }
3721
3722 private function rgb_is_dark($r, $g, $b) {
3723 $lum = (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255;
3724 return $lum < 0.55;
3725 }
3726
3727 private function resolve_color_hex($val) {
3728 $val = trim((string) $val);
3729 if ($val === '') {
3730 return '';
3731 }
3732 if ($val[0] === '$') {
3733 $snap = $this->get_globals_snapshot();
3734 $key = substr($val, 1);
3735 if (!empty($snap['colors'][$key]['value'])) {
3736 return $this->coerce_hex($snap['colors'][$key]['value']);
3737 }
3738 return '';
3739 }
3740 return $this->coerce_hex($val);
3741 }
3742
3743 private function apply_schema_visuals($tag, $attrs, $node, $index, $on_dark) {
3744 if (!is_array($attrs)) {
3745 $attrs = array();
3746 }
3747
3748 $heading_family = $this->global_font_family('primary');
3749 $body_family = $this->global_font_family('text');
3750 $light = '#ffffff';
3751 $text_on_light = '$text';
3752 $brand = '$primary';
3753 $accent = '$accent';
3754
3755 if (!empty($index['typography']) && is_array($index['typography'])) {
3756 foreach ($index['typography'] as $typo_key) {
3757 if (stripos($typo_key, 'ribbon') !== false) {
3758 continue;
3759 }
3760 $is_heading = (bool) preg_match('/heading|title|btn|button|display/i', $typo_key);
3761 $family = $is_heading ? $heading_family : $body_family;
3762 $size = '';
3763 $weight = '';
3764 if ($tag === 'pl_heading') {
3765 $content = (isset($node['content']) && is_string($node['content'])) ? $node['content'] : '';
3766 if (stripos($content, '<h1') !== false) {
3767 $size = '48';
3768 $weight = '800';
3769 } elseif (stripos($content, '<h2') !== false) {
3770 $size = '36';
3771 $weight = '700';
3772 } elseif (stripos($content, '<h3') !== false) {
3773 $size = '22';
3774 $weight = '700';
3775 } else {
3776 $size = '18';
3777 $weight = '600';
3778 }
3779 } elseif ($tag === 'pl_iconbox' || $tag === 'pl_service') {
3780 $size = '20';
3781 $weight = '700';
3782 } elseif (empty($attrs[$typo_key])) {
3783 $size = '18';
3784 $weight = '600';
3785 }
3786 $attrs[$typo_key] = $this->typo_with_family(isset($attrs[$typo_key]) ? $attrs[$typo_key] : '', $family, $size, $weight);
3787 }
3788 }
3789
3790 if ($tag === 'pl_text' || $tag === 'pl_heading') {
3791 if (empty($attrs['font_family'])) {
3792 $attrs['font_family'] = ($tag === 'pl_heading') ? $heading_family : $body_family;
3793 }
3794 }
3795
3796 if ($tag === 'pl_text') {
3797 if (empty($attrs['font_size'])) {
3798 $attrs['font_size'] = '16';
3799 }
3800 if (empty($attrs['line_height'])) {
3801 $attrs['line_height'] = '1.7';
3802 }
3803 }
3804
3805 $cards = $this->card_widget_tags();
3806 if (isset($cards[$tag])) {
3807 if (empty($attrs['ele_bg_color'])) {
3808 $attrs['ele_bg_type'] = 'color';
3809 $attrs['ele_bg_color'] = $on_dark ? 'rgba(255,255,255,0.08)' : '#ffffff';
3810 }
3811 if (empty($attrs['border_radius'])) {
3812 $attrs['border_radius'] = '16px,16px,16px,16px';
3813 }
3814 if (empty($attrs['ele_shadow']) && !$this->color_is_dark(!empty($attrs['ele_bg_color']) ? $attrs['ele_bg_color'] : '', $on_dark)) {
3815 $attrs['ele_shadow'] = '0,8,24,rgba(15,23,42,0.08),0,';
3816 }
3817 if (empty($attrs['ele_padding'])) {
3818 $attrs['ele_padding'] = '32px,28px,32px,28px';
3819 }
3820 }
3821
3822 if ($this->has_own_surface($attrs)) {
3823 $on_dark = $this->effective_is_dark($attrs, $on_dark);
3824 }
3825
3826 if (!empty($index['own']) && is_array($index['own']) && !empty($index['allowed'])) {
3827 foreach ($index['own'] as $key => $_) {
3828 if (strpos($key, '_hover') !== false || strpos($key, '_state') !== false) {
3829 continue;
3830 }
3831 $type = isset($index['allowed'][$key]) ? $index['allowed'][$key] : '';
3832 if ($type !== 'color') {
3833 continue;
3834 }
3835 if ($this->is_surface_color_key($key)) {
3836 continue;
3837 }
3838 $token = $this->color_token_for_key($key, $on_dark, $light, $text_on_light, $brand, $accent);
3839 if (empty($attrs[$key])) {
3840 $attrs[$key] = $token;
3841 } elseif ($on_dark && $this->color_is_dark($attrs[$key])) {
3842 $attrs[$key] = $token;
3843 } elseif (!$on_dark && $this->color_is_light($attrs[$key])) {
3844 $attrs[$key] = $token;
3845 }
3846 }
3847 }
3848
3849 return $attrs;
3850 }
3851
3852 /**
3853 * Force readable text on the surface this widget actually sits on.
3854 * Rich Text / service body copy have no color prop, so they inherit the
3855 * theme's black ink unless we set heading/text colors and ele_css.
3856 */
3857 private function ensure_contrast_text($tag, $attrs, $index, $on_dark) {
3858 if (!is_array($attrs)) {
3859 $attrs = array();
3860 }
3861
3862 $light = '#ffffff';
3863 $brand = '$primary';
3864 $ink = '$text';
3865 $dark_ink = '#111827';
3866 $allowed = (!empty($index) && !empty($index['allowed']) && is_array($index['allowed'])) ? $index['allowed'] : array();
3867
3868 $fix_fg = function ($val, $heading = false) use ($on_dark, $light, $brand, $ink, $dark_ink) {
3869 if ($val === '' || $val === null) {
3870 if ($on_dark) {
3871 return $light;
3872 }
3873 return $heading ? $brand : $ink;
3874 }
3875 if ($on_dark && $this->color_is_dark($val)) {
3876 return $light;
3877 }
3878 if (!$on_dark && $this->color_is_light($val)) {
3879 return $heading ? $brand : $dark_ink;
3880 }
3881 return $val;
3882 };
3883
3884 if (isset($allowed['color'])) {
3885 $cur = isset($attrs['color']) ? (string) $attrs['color'] : '';
3886 $attrs['color'] = $fix_fg($cur, $tag === 'pl_heading');
3887 }
3888
3889 $text_keys = array(
3890 'service_heading_color' => true, 'heading_color' => true, 'title_color' => true,
3891 'list_color' => false, 'quote_content_color' => false, 'cite_color' => false,
3892 'counter_number_color' => true, 'counter_text_color' => false,
3893 'service_icon_color' => true, 'icon_color' => true,
3894 'service_text_color' => false, 'desc_color' => false, 'content_color' => false,
3895 );
3896 foreach ($text_keys as $key => $is_heading) {
3897 if (!isset($allowed[$key]) || $allowed[$key] !== 'color') {
3898 continue;
3899 }
3900 $cur = isset($attrs[$key]) ? (string) $attrs[$key] : '';
3901 $attrs[$key] = $fix_fg($cur, $is_heading);
3902 }
3903
3904 $rule = $this->contrast_text_css_for_tag($tag, $on_dark);
3905 if ($rule !== '') {
3906 $attrs = $this->append_ele_css($attrs, $rule);
3907 }
3908
3909 return $attrs;
3910 }
3911
3912 private function is_surface_color_key($key) {
3913 $key_l = strtolower((string) $key);
3914 if (preg_match('/(^|_)bg(_color|_hover|$)|background/i', $key_l)) {
3915 return true;
3916 }
3917 if (preg_match('/heading|title|icon|text|desc|cite|quote|label|name|number|count/i', $key_l)) {
3918 return false;
3919 }
3920 if (preg_match('/btn|button/i', $key_l) && preg_match('/color/i', $key_l) && !preg_match('/bg|background/i', $key_l)) {
3921 return false;
3922 }
3923 return (bool) preg_match('/overlay|shadow|ribbon|border|gradient/i', $key_l);
3924 }
3925
3926 private function colors_clash($a, $b) {
3927 $a = trim((string) $a);
3928 $b = trim((string) $b);
3929 if ($a === '' || $b === '') {
3930 return true;
3931 }
3932 if (strtolower($a) === strtolower($b)) {
3933 return true;
3934 }
3935 $a_dark = $this->color_is_dark($a, false);
3936 $b_dark = $this->color_is_dark($b, false);
3937 $a_light = $this->color_is_light($a);
3938 $b_light = $this->color_is_light($b);
3939 if ($a_dark && $b_dark) {
3940 return true;
3941 }
3942 if ($a_light && $b_light) {
3943 return true;
3944 }
3945 return false;
3946 }
3947
3948 /**
3949 * Foreground/background pairs on a widget must contrast — AI often sets
3950 * both to $primary or both to white.
3951 */
3952 private function ensure_widget_contrast($tag, $attrs, $on_dark) {
3953 if (!is_array($attrs)) {
3954 $attrs = array();
3955 }
3956
3957 $white = '#ffffff';
3958 $ink = '#111827';
3959 $primary = '$primary';
3960 $muted = $on_dark ? 'rgba(255,255,255,0.14)' : '#f1f5f9';
3961 $darkbtn = 'rgba(15,23,42,0.72)';
3962
3963 $pairs = array(
3964 array('arraow_color', 'arrows_bg', $white, $darkbtn),
3965 array('btn_color', 'btn_bg_color', $white, $primary),
3966 array('icon_color', 'icon_bg_color', $white, $primary),
3967 array('service_icon_color', 'service_icon_bg_color', $on_dark ? $white : $primary, $on_dark ? 'rgba(255,255,255,0.12)' : '#eef2ff'),
3968 array('tabs_color', 'tabs_bg_color', $on_dark ? $white : $ink, $muted),
3969 array('tabs_active_color', 'tabs_active_bg_color', $white, $primary),
3970 array('tabs_content_color', 'tabs_content_bg_color', $on_dark ? $white : $ink, $on_dark ? 'rgba(255,255,255,0.08)' : $white),
3971 );
3972
3973 foreach ($pairs as $pair) {
3974 list($fg_key, $bg_key, $fg, $bg) = $pair;
3975 $has_fg = array_key_exists($fg_key, $attrs);
3976 $has_bg = array_key_exists($bg_key, $attrs);
3977 if (!$has_fg && !$has_bg) {
3978 continue;
3979 }
3980 $cur_fg = $has_fg ? trim((string) $attrs[$fg_key]) : '';
3981 $cur_bg = $has_bg ? trim((string) $attrs[$bg_key]) : '';
3982 if ($this->colors_clash($cur_fg, $cur_bg)) {
3983 $attrs[$fg_key] = $fg;
3984 $attrs[$bg_key] = $bg;
3985 }
3986 }
3987
3988 if ($tag === 'pl_btn') {
3989 if ($this->colors_clash(isset($attrs['btn_color']) ? $attrs['btn_color'] : '', isset($attrs['btn_bg_color']) ? $attrs['btn_bg_color'] : '')) {
3990 $attrs['btn_color'] = $white;
3991 $attrs['btn_bg_color'] = $primary;
3992 }
3993 }
3994
3995 return $attrs;
3996 }
3997
3998 private function style_accordion_or_tabs($tag, $attrs, $on_dark) {
3999 if (!is_array($attrs)) {
4000 $attrs = array();
4001 }
4002
4003 $white = '#ffffff';
4004 $ink = '#111827';
4005 $primary = '$primary';
4006 $tab_bg = $on_dark ? 'rgba(255,255,255,0.14)' : '#f1f5f9';
4007 $panel = $on_dark ? 'rgba(255,255,255,0.08)' : '#ffffff';
4008
4009 $attrs['tabs_color'] = $on_dark ? $white : $ink;
4010 $attrs['tabs_bg_color'] = $tab_bg;
4011 $attrs['tabs_active_color'] = $white;
4012 $attrs['tabs_active_bg_color'] = $primary;
4013 if ($tag === 'pl_tabs') {
4014 $attrs['tabs_content_color'] = $on_dark ? $white : $ink;
4015 $attrs['tabs_content_bg_color'] = $panel;
4016 } else {
4017 $attrs['tabs_content_bg_color'] = $panel;
4018 }
4019
4020 $sel_tab = ($tag === 'pl_tabs')
4021 ? '{{element}} .pagelayer-tablinks'
4022 : '{{element}} .pagelayer-accordion-tabs';
4023 $sel_panel = ($tag === 'pl_tabs')
4024 ? '{{element}} .pagelayer-tab,{{element}} .pagelayer-tabcontainer'
4025 : '{{element}} .pagelayer-accordion-panel';
4026
4027 $tab_fg = $on_dark ? $white : $ink;
4028 $panel_fg = $on_dark ? $white : $ink;
4029 $rule = $sel_tab . '{color:' . $tab_fg . '!important;background-color:' . $tab_bg . '!important;}'
4030 . $sel_tab . '.active,{{element}} .active ' . (($tag === 'pl_tabs') ? '.pagelayer-tablinks' : '.pagelayer-accordion-tabs')
4031 . '{color:' . $white . '!important;background-color:var(--pagelayer-color-primary,#4f46e5)!important;}'
4032 . $sel_panel . '{color:' . $panel_fg . '!important;background-color:' . $panel . '!important;}';
4033 $attrs = $this->append_ele_css($attrs, $rule);
4034
4035 return $attrs;
4036 }
4037
4038 private function style_counter($attrs, $on_dark) {
4039 if (!is_array($attrs)) {
4040 $attrs = array();
4041 }
4042
4043 $start = isset($attrs['counter_start_number']) ? (int) $attrs['counter_start_number'] : 0;
4044 if ($start < 1) {
4045 $attrs['counter_start_number'] = '1';
4046 $start = 1;
4047 }
4048 $end = isset($attrs['counter_end_number']) ? (int) $attrs['counter_end_number'] : 0;
4049 if ($end <= $start) {
4050 $attrs['counter_end_number'] = (string) max(100, $start + 99);
4051 }
4052
4053 $num = isset($attrs['counter_number_color']) ? (string) $attrs['counter_number_color'] : '';
4054 $lbl = isset($attrs['counter_text_color']) ? (string) $attrs['counter_text_color'] : '';
4055 $bg = isset($attrs['ele_bg_color']) ? (string) $attrs['ele_bg_color'] : '';
4056 $surface_dark = $bg !== '' ? $this->color_is_dark($bg, $on_dark) : $on_dark;
4057
4058 if ($num === '' || ($bg !== '' && $this->colors_clash($num, $bg)) || ($surface_dark && $this->color_is_dark($num, true)) || (!$surface_dark && $this->color_is_light($num))) {
4059 $attrs['counter_number_color'] = $surface_dark ? '#ffffff' : '$primary';
4060 }
4061 if ($lbl === '' || ($bg !== '' && $this->colors_clash($lbl, $bg)) || ($surface_dark && $this->color_is_dark($lbl, true)) || (!$surface_dark && $this->color_is_light($lbl))) {
4062 $attrs['counter_text_color'] = $surface_dark ? 'rgba(255,255,255,0.85)' : '#111827';
4063 }
4064
4065 return $attrs;
4066 }
4067
4068 private function contrast_text_css_for_tag($tag, $on_dark = true) {
4069 $selectors = array(
4070 'pl_text' => '{{element}},{{element}} .pagelayer-text-holder,{{element}} .pagelayer-text-holder *',
4071 'pl_heading' => '{{element}} .pagelayer-heading-holder,{{element}} .pagelayer-heading-holder *',
4072 'pl_iconbox' => '{{element}} .pagelayer-service-text,{{element}} .pagelayer-service-heading,{{element}} .pagelayer-service-details',
4073 'pl_service' => '{{element}} .pagelayer-service-text,{{element}} .pagelayer-service-heading,{{element}} .pagelayer-service-details',
4074 'pl_testimonial' => '{{element}} .pagelayer-testimonial-content,{{element}} .pagelayer-testimonial-author,{{element}} .pagelayer-testimonial-designation',
4075 'pl_quote' => '{{element}} .pagelayer-quote-content,{{element}} .pagelayer-cite-holder',
4076 'pl_list' => '{{element}} .pagelayer-list-item,{{element}} li,{{element}} .pagelayer-list-li',
4077 'pl_list_item' => '{{element}},{{element}} .pagelayer-list-li,{{element}} .pagelayer-list-item',
4078 'pl_counter' => '{{element}} .pagelayer-counter-text,{{element}} .pagelayer-counter-title,{{element}} .pagelayer-counter-prefix,{{element}} .pagelayer-counter-suffix',
4079 'pl_accordion' => '{{element}} .pagelayer-accordion-tab,{{element}} .pagelayer-accordion-item',
4080 'pl_accordion_item' => '{{element}},{{element}} .pagelayer-accordion-tab',
4081 'pl_tabs' => '{{element}} .pagelayer-tab-title,{{element}} .pagelayer-tab-content',
4082 'pl_tab' => '{{element}},{{element}} .pagelayer-tab-content',
4083 'pl_alert' => '{{element}} .pagelayer-alert-title,{{element}} .pagelayer-alert-text',
4084 'pl_pricing' => '{{element}} .pagelayer-pricing-title,{{element}} .pagelayer-pricing-price,{{element}} .pagelayer-pricing-text,{{element}} li',
4085 'pl_address' => '{{element}},{{element}} .pagelayer-address,{{element}} .pagelayer-address-holder',
4086 'pl_phone' => '{{element}},{{element}} .pagelayer-phone,{{element}} .pagelayer-phone-holder',
4087 'pl_email' => '{{element}},{{element}} .pagelayer-email,{{element}} .pagelayer-email-holder',
4088 );
4089 if (!isset($selectors[$tag])) {
4090 return '';
4091 }
4092 $color = $on_dark ? '#ffffff' : '#111827';
4093 return $selectors[$tag] . '{color:' . $color . ';}';
4094 }
4095
4096 private function color_is_light($val) {
4097 $val = trim((string) $val);
4098 if ($val === '') {
4099 return false;
4100 }
4101 $low = strtolower($val);
4102 $light_names = array(
4103 'white' => 1, 'ivory' => 1, 'snow' => 1, 'whitesmoke' => 1, 'ghostwhite' => 1,
4104 'azure' => 1, 'beige' => 1, 'linen' => 1, 'seashell' => 1, '#fff' => 1, '#ffffff' => 1,
4105 '#f8fafc' => 1, '#f9fafb' => 1, '#fafafa' => 1, '#f5f5f5' => 1, '#eee' => 1, '#eeeeee' => 1,
4106 );
4107 if (isset($light_names[$low])) {
4108 return true;
4109 }
4110 if (isset($val[0]) && $val[0] === '$') {
4111 $key = strtolower(substr($val, 1));
4112 if (in_array($key, array('bg', 'surface', 'light', 'light_bg'), true)) {
4113 $hex = $this->resolve_color_hex($val);
4114 if ($hex === '') {
4115 return true;
4116 }
4117 return !$this->hex_is_dark($hex, false);
4118 }
4119 }
4120 $hex = $this->resolve_color_hex($val);
4121 if ($hex === '') {
4122 return false;
4123 }
4124 $hex = ltrim($hex, '#');
4125 if (strlen($hex) === 3) {
4126 $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
4127 }
4128 if (strlen($hex) < 6) {
4129 return false;
4130 }
4131 $r = hexdec(substr($hex, 0, 2));
4132 $g = hexdec(substr($hex, 2, 2));
4133 $b = hexdec(substr($hex, 4, 2));
4134 $lum = (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255;
4135 return $lum >= 0.78;
4136 }
4137
4138 private function append_ele_css($attrs, $rule) {
4139 if (!is_array($attrs)) {
4140 $attrs = array();
4141 }
4142 $rule = trim((string) $rule);
4143 if ($rule === '') {
4144 return $attrs;
4145 }
4146 $css = isset($attrs['ele_css']) ? (string) $attrs['ele_css'] : '';
4147 if ($css !== '' && strpos($css, $rule) !== false) {
4148 return $attrs;
4149 }
4150 $attrs['ele_css'] = $css . $rule;
4151 return $attrs;
4152 }
4153
4154 private function color_token_for_key($key, $on_dark, $light, $text, $brand, $accent) {
4155 $key_l = strtolower((string) $key);
4156 if ($on_dark && preg_match('/text|desc|content|quote|cite|label|color/i', $key_l) && !preg_match('/icon|number|btn|button|bg/i', $key_l)) {
4157 return $light;
4158 }
4159 if (preg_match('/icon|number|count|accent|star/i', $key_l)) {
4160 return $on_dark ? $light : $accent;
4161 }
4162 if (preg_match('/heading|title|name/i', $key_l)) {
4163 return $on_dark ? $light : $brand;
4164 }
4165 if (preg_match('/text|desc|content|quote|cite|label/i', $key_l)) {
4166 return $on_dark ? $light : $text;
4167 }
4168 if (preg_match('/btn|button/i', $key_l) && preg_match('/color/i', $key_l) && !preg_match('/bg/i', $key_l)) {
4169 return $light;
4170 }
4171 return $on_dark ? $light : $brand;
4172 }
4173
4174 private function typo_with_family($typo, $family, $size = '', $weight = '') {
4175 $parts = array_fill(0, 11, '');
4176 if (is_string($typo) && $typo !== '') {
4177 if (isset($typo[0]) && $typo[0] === '$') {
4178 $token_family = $this->global_font_family(substr($typo, 1));
4179 if ($token_family !== '') {
4180 $family = $token_family;
4181 }
4182 } else {
4183 $existing = explode(',', $typo);
4184 foreach ($existing as $i => $part) {
4185 if ($i < 11) {
4186 $parts[$i] = $part;
4187 }
4188 }
4189 }
4190 }
4191 if ($family !== '' && $parts[0] === '') {
4192 $parts[0] = $family;
4193 }
4194 if ($size !== '' && $parts[1] === '') {
4195 $parts[1] = preg_replace('/[^0-9.]/', '', (string) $size);
4196 }
4197 if ($weight !== '' && $parts[3] === '') {
4198 $parts[3] = (string) $weight;
4199 }
4200 if ($parts[7] === '' && $size !== '' && (int) $size >= 30) {
4201 $parts[7] = '1.15';
4202 }
4203 return implode(',', $parts);
4204 }
4205
4206 private function remap_tree($nodes) {
4207 if (!is_array($nodes)) {
4208 return $nodes;
4209 }
4210
4211 $out = array();
4212 foreach ($nodes as $node) {
4213 if (!is_array($node)) {
4214 $out[] = $node;
4215 continue;
4216 }
4217 if (!empty($node['tag'])) {
4218 $tag = $this->normalize_tag($node['tag']);
4219 $node['tag'] = $tag;
4220 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
4221 $node['attrs'] = array();
4222 }
4223 $this->remap_node_fields($node, $this->get_attr_index($tag));
4224 }
4225 if (isset($node['content']) && is_array($node['content'])) {
4226 $node['content'] = $this->remap_tree($node['content']);
4227 }
4228 $out[] = $node;
4229 }
4230 return $out;
4231 }
4232
4233 private function sanitize_nodes($nodes) {
4234 if (!is_array($nodes)) {
4235 return $nodes;
4236 }
4237 $out = array();
4238 foreach ($nodes as $node) {
4239 if (is_array($node)) {
4240 $out[] = $this->sanitize_node($node);
4241 }
4242 }
4243 return $out;
4244 }
4245
4246 private function sanitize_node($node) {
4247 global $pagelayer;
4248
4249 if (!is_array($node) || empty($node['tag'])) {
4250 return $node;
4251 }
4252
4253 $tag = $this->normalize_tag($node['tag']);
4254 $node['tag'] = $tag;
4255
4256 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
4257 $node['attrs'] = array();
4258 }
4259
4260 if (isset($node['content']) && is_string($node['content'])) {
4261 $node['content'] = $this->strip_placeholder_copy($node['content']);
4262 $node['content'] = preg_replace('/\s*style\s*=\s*([\'"]).*?\1/i', '', $node['content']);
4263 $node['content'] = preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $node['content']);
4264 }
4265
4266 // Strip leaked template tokens like {ribbon_text}, but keep {{element}}
4267 // in ele_css / ele_attributes — that selector is how Pagelayer CSS works.
4268 foreach ($node['attrs'] as $k => $v) {
4269 if (!is_string($v)) {
4270 continue;
4271 }
4272 if ($k === 'ele_css' || $k === 'ele_attributes') {
4273 continue;
4274 }
4275 $v = $this->strip_placeholder_copy($v);
4276 $node['attrs'][$k] = $v;
4277 }
4278
4279 $this->ensure_shortcodes();
4280 $def = isset($pagelayer->shortcodes[$tag]) ? $pagelayer->shortcodes[$tag] : array();
4281 if (!empty($def['holder']) && empty($def['innerHTML']) && isset($node['content']) && is_string($node['content']) && trim($node['content']) !== '') {
4282 $node['content'] = array(
4283 array(
4284 'tag' => 'pl_row',
4285 'attrs' => array(),
4286 'content' => array(
4287 array(
4288 'tag' => 'pl_col',
4289 'attrs' => array('col' => 12),
4290 'content' => array(
4291 array(
4292 'tag' => 'pl_text',
4293 'attrs' => array(),
4294 'content' => $node['content'],
4295 ),
4296 ),
4297 ),
4298 ),
4299 ),
4300 );
4301 }
4302
4303 $index = $this->get_attr_index($tag);
4304 $this->remap_node_fields($node, $index);
4305 $node['attrs'] = $this->fill_style_companions($node['attrs']);
4306 $node['attrs'] = $this->sanitize_attrs($tag, $node['attrs']);
4307 $node['attrs'] = $this->fill_style_companions($node['attrs']);
4308 $node['attrs'] = $this->apply_own_typography($node['attrs'], $index);
4309 $node['attrs'] = $this->apply_spacing_defaults($tag, $node['attrs']);
4310 $node['attrs'] = $this->apply_modern_defaults($tag, $node['attrs'], $node);
4311 $node['attrs'] = $this->fill_style_companions($node['attrs']);
4312
4313 if (isset($node['content']) && is_array($node['content'])) {
4314 $node['content'] = $this->sanitize_nodes($node['content']);
4315 }
4316
4317 return $node;
4318 }
4319
4320 /**
4321 * Widget schema defaults are editor-only — CSS is emitted only when the
4322 * attr is actually set. AI layouts otherwise stack icon boxes / testimonials
4323 * with zero column gap and zero widget spacing.
4324 */
4325 private function apply_spacing_defaults($tag, $attrs) {
4326 if (!is_array($attrs)) {
4327 $attrs = array();
4328 }
4329
4330 $is_inner_row = ($tag === 'pl_inner_row');
4331 $tag = str_replace(array('pl_inner_row', 'pl_inner_col'), array('pl_row', 'pl_col'), $tag);
4332
4333 if ($tag === 'pl_row') {
4334 if (!isset($attrs['col_gap']) || $attrs['col_gap'] === '' || $attrs['col_gap'] === '0') {
4335 $attrs['col_gap'] = '20';
4336 }
4337 if ($is_inner_row) {
4338 // Inner rows must not stretch to the viewport or inherit section padding —
4339 // that creates blank bands on the frontend that the editor does not show.
4340 if (isset($attrs['stretch']) && $attrs['stretch'] === 'full') {
4341 $attrs['stretch'] = 'auto';
4342 }
4343 } else {
4344 if (!isset($attrs['ele_padding']) || $attrs['ele_padding'] === '') {
4345 $attrs['ele_padding'] = '50px,20px,50px,20px';
4346 }
4347 if (!isset($attrs['stretch']) || $attrs['stretch'] === '') {
4348 $attrs['stretch'] = 'full';
4349 }
4350 $attrs = $this->pin_col_gap_horizontal($attrs);
4351 }
4352 }
4353
4354 if ($tag === 'pl_col') {
4355 if (!isset($attrs['widget_space']) || $attrs['widget_space'] === '' || (string) $attrs['widget_space'] === '0') {
4356 $attrs['widget_space'] = '20';
4357 }
4358 }
4359
4360 $cards = $this->card_widget_tags();
4361 if (isset($cards[$tag])) {
4362 if (!isset($attrs['ele_padding']) || $attrs['ele_padding'] === '') {
4363 $attrs['ele_padding'] = '32px,28px,32px,28px';
4364 }
4365 }
4366
4367 return $attrs;
4368 }
4369
4370 /**
4371 * Live index of a widget's real attribute names, labels, and types.
4372 */
4373 private function get_attr_index($tag) {
4374 static $cache = array();
4375
4376 $tag = str_replace(array('pl_inner_row', 'pl_inner_col'), array('pl_row', 'pl_col'), $tag);
4377 if (array_key_exists($tag, $cache)) {
4378 return $cache[$tag];
4379 }
4380
4381 global $pagelayer;
4382 $this->ensure_shortcodes();
4383 if (empty($pagelayer->shortcodes[$tag])) {
4384 return $cache[$tag] = null;
4385 }
4386
4387 $schema = $this->extract_widget_schema($tag, $pagelayer->shortcodes[$tag]);
4388 $own_keys = isset($pagelayer->shortcodes[$tag]['settings']) && is_array($pagelayer->shortcodes[$tag]['settings'])
4389 ? array_keys($pagelayer->shortcodes[$tag]['settings'])
4390 : array();
4391
4392 $index = array(
4393 'allowed' => array(),
4394 'labels' => array(),
4395 'by_type' => array(),
4396 'own' => array(),
4397 'typography' => array(),
4398 'content_attr' => !empty($schema['innerHTML']) ? $schema['innerHTML'] : '',
4399 );
4400
4401 if (empty($schema['sections']) || !is_array($schema['sections'])) {
4402 return $cache[$tag] = $index;
4403 }
4404
4405 foreach ($schema['sections'] as $section_key => $section) {
4406 $is_own = in_array($section_key, $own_keys, true);
4407 if (empty($section['properties']) || !is_array($section['properties'])) {
4408 continue;
4409 }
4410 foreach ($section['properties'] as $key => $prop) {
4411 $type = isset($prop['type']) ? $prop['type'] : '';
4412 $label = isset($prop['label']) ? strtolower(trim(wp_strip_all_tags((string) $prop['label']))) : '';
4413 $index['allowed'][$key] = $type;
4414 $index['labels'][$key] = $label;
4415 if ($type !== '') {
4416 $index['by_type'][$type][] = $key;
4417 }
4418 if ($is_own) {
4419 $index['own'][$key] = true;
4420 if ($type === 'typography' && strpos($key, '_hover') === false) {
4421 $index['typography'][] = $key;
4422 }
4423 }
4424 }
4425 }
4426
4427 return $cache[$tag] = $index;
4428 }
4429
4430 /**
4431 * Map guessed attr names onto the widget's live schema (labels, suffixes, value types).
4432 */
4433 private function remap_node_fields(&$node, $index) {
4434 if (!is_array($index) || empty($node['attrs']) || !is_array($node['attrs'])) {
4435 return;
4436 }
4437
4438 $out = array();
4439 $content_attr = $index['content_attr'];
4440 $aliases = $this->common_style_aliases();
4441
4442 foreach ($node['attrs'] as $key => $val) {
4443 if (isset($index['allowed'][$key])) {
4444 $out[$key] = $val;
4445 continue;
4446 }
4447
4448 if ($key === 'content' && $content_attr !== '' && is_string($val)) {
4449 if (!isset($node['content']) || $node['content'] === '' || $node['content'] === array()) {
4450 $node['content'] = $val;
4451 }
4452 continue;
4453 }
4454
4455 $key_l = strtolower((string) $key);
4456 if (isset($aliases[$key_l]) && isset($index['allowed'][$aliases[$key_l]])) {
4457 $mapped = $aliases[$key_l];
4458 if (!isset($out[$mapped]) || $out[$mapped] === '') {
4459 $out[$mapped] = $val;
4460 }
4461 continue;
4462 }
4463
4464 $mapped = $this->guess_attr_key($key, $val, $index);
4465 if ($mapped === null) {
4466 $out[$key] = $val;
4467 continue;
4468 }
4469
4470 if ($mapped === $content_attr && is_string($val)) {
4471 if (!isset($node['content']) || $node['content'] === '' || $node['content'] === array()) {
4472 $node['content'] = $val;
4473 }
4474 continue;
4475 }
4476
4477 if (!isset($out[$mapped]) || $out[$mapped] === '') {
4478 $out[$mapped] = $val;
4479 }
4480 }
4481
4482 $node['attrs'] = $this->fill_style_companions($out);
4483 }
4484
4485 /**
4486 * Guessed names models often emit instead of Pagelayer common-style keys.
4487 */
4488 private function common_style_aliases() {
4489 return array(
4490 'padding' => 'ele_padding',
4491 'margin' => 'ele_margin',
4492 'background' => 'ele_bg_color',
4493 'background_color' => 'ele_bg_color',
4494 'bg' => 'ele_bg_color',
4495 'bg_color' => 'ele_bg_color',
4496 'bg_type' => 'ele_bg_type',
4497 'shadow' => 'ele_shadow',
4498 'box_shadow' => 'ele_shadow',
4499 'radius' => 'border_radius',
4500 'border' => 'border_type',
4501 'alignment' => 'align',
4502 'text_align' => 'align',
4503 'text_color' => 'color',
4504 'font' => 'font_family',
4505 'fontsize' => 'font_size',
4506 'fontweight' => 'font_weight',
4507 );
4508 }
4509
4510 /**
4511 * Colors/borders/buttons only render when their companion attr is set
4512 * on the same node. Fill those so AI styling is not silently dropped.
4513 */
4514 private function fill_style_companions($attrs) {
4515 if (!is_array($attrs)) {
4516 return array();
4517 }
4518
4519 if (!empty($attrs['ele_bg_color']) && (empty($attrs['ele_bg_type']) || $attrs['ele_bg_type'] === '')) {
4520 $attrs['ele_bg_type'] = 'color';
4521 }
4522 if (!empty($attrs['ele_bg_gradient']) && (empty($attrs['ele_bg_type']) || $attrs['ele_bg_type'] === '')) {
4523 $attrs['ele_bg_type'] = 'gradient';
4524 }
4525 if (!empty($attrs['ele_bg_img']) && (empty($attrs['ele_bg_type']) || $attrs['ele_bg_type'] === '')) {
4526 $attrs['ele_bg_type'] = 'image';
4527 }
4528 if (!empty($attrs['btn_bg_color']) || !empty($attrs['btn_color'])) {
4529 if (empty($attrs['type']) || $attrs['type'] === 'pagelayer-btn-default') {
4530 $attrs['type'] = 'pagelayer-btn-custom';
4531 }
4532 }
4533 if ((!empty($attrs['border_width']) || !empty($attrs['border_color'])) && (empty($attrs['border_type']) || $attrs['border_type'] === '')) {
4534 $attrs['border_type'] = 'solid';
4535 }
4536 if (!empty($attrs['btn_border_width']) || !empty($attrs['btn_border_color']) || !empty($attrs['btn_border_radius'])) {
4537 if (empty($attrs['btn_border_type']) || $attrs['btn_border_type'] === '') {
4538 $attrs['btn_border_type'] = 'solid';
4539 }
4540 }
4541
4542 return $attrs;
4543 }
4544
4545 private function companion_fill_value($key) {
4546 $key = (string) $key;
4547 if ($key === 'border_type' || substr($key, -12) === 'border_type') {
4548 return 'solid';
4549 }
4550 if ($key === 'ele_bg_type' || substr($key, -7) === 'bg_type') {
4551 return 'color';
4552 }
4553 if ($key === 'type') {
4554 return 'pagelayer-btn-custom';
4555 }
4556 return '';
4557 }
4558
4559 private function guess_attr_key($key, $val, $index) {
4560 $key_l = strtolower((string) $key);
4561
4562 $pick_own = function ($hits) use ($index) {
4563 $own = array();
4564 foreach (array_unique($hits) as $ak) {
4565 if (!empty($index['own'][$ak]) && strpos($ak, '_hover') === false) {
4566 $own[] = $ak;
4567 }
4568 }
4569 $own = array_values(array_unique($own));
4570 return count($own) === 1 ? $own[0] : null;
4571 };
4572
4573 $suffix_hits = array();
4574 foreach ($index['allowed'] as $ak => $_type) {
4575 if (strpos($ak, '_hover') !== false) {
4576 continue;
4577 }
4578 $ak_l = strtolower($ak);
4579 if ($ak_l === $key_l || substr($ak_l, -(strlen($key_l) + 1)) === '_' . $key_l) {
4580 $suffix_hits[] = $ak;
4581 }
4582 }
4583 $picked = $pick_own($suffix_hits);
4584 if ($picked) {
4585 return $picked;
4586 }
4587 if (count($suffix_hits) === 1) {
4588 return $suffix_hits[0];
4589 }
4590
4591 $kn = preg_replace('/[^a-z0-9]+/', '', $key_l);
4592 $label_hits = array();
4593 foreach ($index['labels'] as $ak => $label) {
4594 if ($label === '' || strpos($ak, '_hover') !== false) {
4595 continue;
4596 }
4597 $ln = preg_replace('/[^a-z0-9]+/', '', $label);
4598 if ($ln === $kn || $label === $key_l) {
4599 $label_hits[] = $ak;
4600 }
4601 }
4602 $picked = $pick_own($label_hits);
4603 if ($picked) {
4604 return $picked;
4605 }
4606 if (count($label_hits) === 1) {
4607 return $label_hits[0];
4608 }
4609
4610 $content_attr = !empty($index['content_attr']) ? $index['content_attr'] : '';
4611 if ($content_attr !== '') {
4612 $ca_l = strtolower($content_attr);
4613 if ($ca_l === $key_l || strpos($ca_l, $key_l . '_') === 0 || substr($ca_l, -(strlen($key_l) + 1)) === '_' . $key_l) {
4614 return $content_attr;
4615 }
4616 }
4617
4618 $own_of_type = function ($type) use ($index) {
4619 $hits = array();
4620 if (empty($index['by_type'][$type])) {
4621 return $hits;
4622 }
4623 foreach ($index['by_type'][$type] as $ak) {
4624 if (strpos($ak, '_hover') !== false) {
4625 continue;
4626 }
4627 if (!empty($index['own'][$ak])) {
4628 $hits[] = $ak;
4629 }
4630 }
4631 return array_values(array_unique($hits));
4632 };
4633
4634 $type = $this->guess_value_type($val);
4635 if ($type !== '') {
4636 $picked = $pick_own($own_of_type($type));
4637 if ($picked) {
4638 return $picked;
4639 }
4640 }
4641
4642 $own_images = $own_of_type('image');
4643 if (count($own_images) === 1) {
4644 if (in_array($key_l, array('img', 'image', 'src', 'photo', 'picture', 'avatar'), true)) {
4645 return $own_images[0];
4646 }
4647 if ($key_l === 'alt') {
4648 return $own_images[0] . '-alt';
4649 }
4650 }
4651
4652 $own_icons = $own_of_type('icon');
4653 if (count($own_icons) === 1 && in_array($key_l, array('icon', 'fa', 'font_icon'), true)) {
4654 return $own_icons[0];
4655 }
4656
4657 return null;
4658 }
4659
4660 private function guess_value_type($val) {
4661 if (!is_string($val) || $val === '') {
4662 return '';
4663 }
4664 if (preg_match('/^(fas|far|fal|fad|fab)\s+fa-/', $val)) {
4665 return 'icon';
4666 }
4667 if (preg_match('#^(https?:)?//#i', $val) && preg_match('/\.(png|jpe?g|gif|webp|svg)(\?|$)/i', $val)) {
4668 return 'image';
4669 }
4670 if (preg_match('/^#([0-9a-f]{3,8})$/i', $val) || (isset($val[0]) && $val[0] === '$')) {
4671 return 'color';
4672 }
4673 return '';
4674 }
4675
4676 /**
4677 * If the widget has its own typography control, fold common font_* attrs into it.
4678 * font_size on the wrapper does not size the inner heading/button text.
4679 */
4680 private function apply_own_typography($attrs, $index) {
4681 if (!is_array($attrs) || empty($index['typography']) || count($index['typography']) !== 1) {
4682 return $attrs;
4683 }
4684
4685 global $pagelayer;
4686 $typo_key = $index['typography'][0];
4687 $props = !empty($pagelayer->typo_props) && is_array($pagelayer->typo_props)
4688 ? array_values($pagelayer->typo_props)
4689 : array(
4690 'font-family', 'font-size', 'font-style', 'font-weight', 'font-variant',
4691 'text-decoration-line', 'text-decoration-style', 'line-height',
4692 'text-transform', 'letter-spacing', 'word-spacing',
4693 );
4694
4695 if (!empty($attrs[$typo_key]) && is_string($attrs[$typo_key]) && strpos($attrs[$typo_key], '$') === 0) {
4696 $family = $this->global_font_family(substr($attrs[$typo_key], 1));
4697 $attrs[$typo_key] = $this->typo_with_family($attrs[$typo_key], $family);
4698 foreach ($props as $css) {
4699 unset($attrs[str_replace('-', '_', $css)]);
4700 }
4701 return $attrs;
4702 }
4703
4704 $parts = array_fill(0, count($props), '');
4705 if (!empty($attrs[$typo_key]) && is_string($attrs[$typo_key])) {
4706 $existing = explode(',', $attrs[$typo_key]);
4707 foreach ($existing as $i => $part) {
4708 if ($i < count($parts)) {
4709 $parts[$i] = $part;
4710 }
4711 }
4712 }
4713
4714 $changed = false;
4715 foreach ($props as $i => $css) {
4716 $common = str_replace('-', '_', $css);
4717 if (!isset($attrs[$common]) || $attrs[$common] === '' || $attrs[$common] === '0') {
4718 continue;
4719 }
4720 $val = (string) $attrs[$common];
4721 if ($css === 'font-size' || $css === 'line-height' || $css === 'letter-spacing' || $css === 'word-spacing') {
4722 $val = preg_replace('/[^0-9.]/', '', $val);
4723 }
4724 $parts[$i] = $val;
4725 unset($attrs[$common]);
4726 $changed = true;
4727 }
4728
4729 if ($changed && implode('', $parts) !== '') {
4730 $attrs[$typo_key] = implode(',', $parts);
4731 }
4732
4733 return $attrs;
4734 }
4735
4736 private function sanitize_attrs($tag, $attrs) {
4737 if (!is_array($attrs)) {
4738 return $attrs;
4739 }
4740
4741 $rules = $this->widget_attr_rules($tag);
4742 if (!is_array($rules) || empty($rules['allowed'])) {
4743 return $attrs;
4744 }
4745
4746 $reserved = array(
4747 'pagelayer-id' => 1, 'pagelayer-srcset' => 1, 'global_id' => 1, 'is_not_sc' => 1,
4748 'ele_classes' => 1, 'ele_css' => 1,
4749 );
4750 $clean = array();
4751
4752 foreach ($attrs as $key => $val) {
4753 if (isset($reserved[$key]) || isset($rules['allowed'][$key])) {
4754 $clean[$key] = $val;
4755 continue;
4756 }
4757 $dash = strrpos($key, '-');
4758 if ($dash !== false && isset($rules['allowed'][substr($key, 0, $dash)])) {
4759 $clean[$key] = $val;
4760 }
4761 }
4762
4763 if (!empty($rules['req']) && is_array($rules['req'])) {
4764 foreach ($rules['req'] as $attr_key => $requires) {
4765 if (!isset($clean[$attr_key]) || $clean[$attr_key] === '' || !is_array($requires)) {
4766 continue;
4767 }
4768 foreach ($requires as $dep_key => $dep_val) {
4769 if (!is_string($dep_key) || $dep_key === '') {
4770 continue;
4771 }
4772 $negated = ($dep_key[0] === '!');
4773 $real_key = $negated ? substr($dep_key, 1) : $dep_key;
4774 if ($real_key === '') {
4775 continue;
4776 }
4777 $current = isset($clean[$real_key]) ? $clean[$real_key] : '';
4778
4779 if ($negated) {
4780 // req:!border_type='' means border_type must be non-empty
4781 // or the styled attr is discarded at render.
4782 $forbidden = is_array($dep_val) ? $dep_val : array($dep_val);
4783 $is_forbidden = in_array((string) $current, array_map('strval', $forbidden), true);
4784 if ($current !== '' && !$is_forbidden) {
4785 continue;
4786 }
4787 $fill = $this->companion_fill_value($real_key);
4788 if ($fill !== '') {
4789 $clean[$real_key] = $fill;
4790 }
4791 continue;
4792 }
4793
4794 $ok = is_array($dep_val)
4795 ? in_array($current, $dep_val, false)
4796 : ((string) $dep_val === (string) $current);
4797 if ($ok) {
4798 continue;
4799 }
4800 $clean[$real_key] = is_array($dep_val) ? reset($dep_val) : $dep_val;
4801 }
4802 }
4803 }
4804
4805 return $clean;
4806 }
4807
4808 public function validate_nodes($nodes) {
4809 return array('errors' => array(), 'warnings' => array());
4810 }
4811
4812 /**
4813 * Normalize AI JSON nodes for Pagelayer render (ids, innerHTML, CSS units,
4814 * markup defaults). Does not expand canned section presets.
4815 */
4816 private function normalize_ai_nodes($nodes) {
4817 if (!is_array($nodes)) {
4818 return $nodes;
4819 }
4820 $out = array();
4821 foreach ($nodes as $node) {
4822 if (is_array($node)) {
4823 $out[] = $this->normalize_ai_node($node);
4824 }
4825 }
4826 return $out;
4827 }
4828
4829 private function normalize_ai_node($node) {
4830 if (!is_array($node)) {
4831 return $node;
4832 }
4833
4834 $tag = isset($node['tag']) ? (string) $node['tag'] : (isset($node['type']) ? (string) $node['type'] : '');
4835 $node['tag'] = $this->normalize_tag($tag);
4836
4837 if (empty($node['tag'])) {
4838 $node['tag'] = (isset($node['content']) && is_array($node['content'])) ? 'pl_row' : 'pl_text';
4839 }
4840
4841 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
4842 $node['attrs'] = array();
4843 }
4844
4845 if (empty($node['attrs']['pagelayer-id']) && function_exists('pagelayer_create_id')) {
4846 $node['attrs']['pagelayer-id'] = pagelayer_create_id();
4847 }
4848
4849 $this->bridge_inner_html($node);
4850 $this->apply_markup_defaults($node);
4851 $this->add_missing_css_units($node);
4852
4853 if ($node['tag'] === 'pl_row' && !isset($node['attrs']['stretch'])) {
4854 $node['attrs']['stretch'] = 'full';
4855 }
4856
4857 if (isset($node['content']) && is_array($node['content'])) {
4858 $node['content'] = $this->normalize_ai_nodes($node['content']);
4859 }
4860
4861 return $node;
4862 }
4863
4864 private function bridge_inner_html(&$node) {
4865 global $pagelayer;
4866
4867 $tag = isset($node['tag']) ? $node['tag'] : '';
4868 if ($tag === '') {
4869 return;
4870 }
4871
4872 $this->ensure_shortcodes();
4873 $inner_key = isset($pagelayer->shortcodes[$tag]['innerHTML']) ? $pagelayer->shortcodes[$tag]['innerHTML'] : '';
4874
4875 if ($inner_key === '') {
4876 if (
4877 isset($node['content']) && is_string($node['content']) && trim($node['content']) !== ''
4878 && empty($node['attrs']['text'])
4879 ) {
4880 $rules = $this->widget_attr_rules($tag);
4881 if (isset($rules['allowed']['text'])) {
4882 $node['attrs']['text'] = trim(wp_strip_all_tags($node['content']));
4883 $node['content'] = '';
4884 }
4885 }
4886 return;
4887 }
4888
4889 if (empty($node['attrs'][$inner_key]) || !is_string($node['attrs'][$inner_key])) {
4890 return;
4891 }
4892
4893 if (isset($node['content']) && is_array($node['content'])) {
4894 return;
4895 }
4896
4897 if (isset($node['content']) && is_string($node['content']) && trim($node['content']) !== '') {
4898 unset($node['attrs'][$inner_key]);
4899 return;
4900 }
4901
4902 $node['content'] = $node['attrs'][$inner_key];
4903 unset($node['attrs'][$inner_key]);
4904 }
4905
4906 private function add_missing_css_units(&$node) {
4907 if (empty($node['tag']) || empty($node['attrs']) || !is_array($node['attrs'])) {
4908 return;
4909 }
4910
4911 $rules = $this->widget_attr_rules($node['tag']);
4912 if (empty($rules['allowed'])) {
4913 return;
4914 }
4915
4916 foreach ($node['attrs'] as $key => $value) {
4917 if (!is_string($value) || $value === '') {
4918 continue;
4919 }
4920 if (!isset($rules['allowed'][$key]) || $rules['allowed'][$key] !== 'padding') {
4921 continue;
4922 }
4923
4924 $parts = explode(',', $value);
4925 $changed = false;
4926 foreach ($parts as $i => $part) {
4927 $part = trim($part);
4928 if ($part === '' || !preg_match('/^-?\d+(\.\d+)?$/', $part)) {
4929 continue;
4930 }
4931 if ((float) $part === 0.0) {
4932 continue;
4933 }
4934 $parts[$i] = $part . 'px';
4935 $changed = true;
4936 }
4937
4938 if ($changed) {
4939 $node['attrs'][$key] = implode(',', $parts);
4940 }
4941 }
4942 }
4943
4944 private function apply_markup_defaults(&$node) {
4945 global $pagelayer;
4946 static $cache = array();
4947
4948 $tag = isset($node['tag']) ? $node['tag'] : '';
4949 if ($tag === '') {
4950 return;
4951 }
4952
4953 $this->ensure_shortcodes();
4954 if (empty($pagelayer->shortcodes[$tag]['html'])) {
4955 return;
4956 }
4957
4958 if (!isset($cache[$tag])) {
4959 $def = $pagelayer->shortcodes[$tag];
4960 $schema = $this->extract_widget_schema($tag, $def);
4961
4962 $props = array();
4963 foreach ($schema['sections'] as $section) {
4964 foreach ($section['properties'] as $key => $prop) {
4965 $props[$key] = $prop;
4966 }
4967 }
4968
4969 preg_match_all('/\{\{\{?([a-zA-Z0-9_\-]+)\}?\}\}/', $def['html'], $matches);
4970
4971 $inner = isset($def['innerHTML']) ? $def['innerHTML'] : '';
4972 $fill = array();
4973
4974 foreach (array_unique($matches[1]) as $name) {
4975 if (!isset($props[$name]) || $name === $inner) {
4976 continue;
4977 }
4978 $type = isset($props[$name]['type']) ? $props[$name]['type'] : '';
4979 if (in_array($type, array('text', 'textarea', 'editor'), true)) {
4980 continue;
4981 }
4982 if (!empty($props[$name]['requires'])) {
4983 continue;
4984 }
4985 $default = isset($props[$name]['default']) ? $props[$name]['default'] : null;
4986 if ($default === null || $default === '' || is_array($default)) {
4987 continue;
4988 }
4989 $fill[$name] = $default;
4990 }
4991
4992 $cache[$tag] = $fill;
4993 }
4994
4995 foreach ($cache[$tag] as $key => $value) {
4996 if (!isset($node['attrs'][$key]) || $node['attrs'][$key] === '') {
4997 $node['attrs'][$key] = $value;
4998 }
4999 }
5000 }
5001
5002 public function nodes_to_shortcode($nodes, $depth = 0) {
5003 if (!is_array($nodes)) {
5004 return is_string($nodes) ? $nodes : '';
5005 }
5006
5007 $out = '';
5008 foreach ($nodes as $node) {
5009 if (!is_array($node) || empty($node['tag'])) {
5010 continue;
5011 }
5012
5013 $tag = $this->normalize_tag($node['tag']);
5014 if ($depth >= 2) {
5015 if ($tag === 'pl_row') {
5016 $tag = 'pl_inner_row';
5017 } elseif ($tag === 'pl_col') {
5018 $tag = 'pl_inner_col';
5019 }
5020 }
5021
5022 $attrs = isset($node['attrs']) && is_array($node['attrs']) ? $node['attrs'] : array();
5023 $attr_str = $this->encode_shortcode_atts($attrs);
5024 $content = isset($node['content']) ? $node['content'] : '';
5025
5026 if (is_array($content)) {
5027 $inner = $this->nodes_to_shortcode($content, $depth + 1);
5028 } else {
5029 $inner = (string) $content;
5030 }
5031
5032 $out .= '[' . $tag . $attr_str . ']' . $inner . '[/' . $tag . ']';
5033 }
5034
5035 return $out;
5036 }
5037
5038 private function encode_shortcode_atts($attrs) {
5039 if (empty($attrs) || !is_array($attrs)) {
5040 return '';
5041 }
5042
5043 $skip = array('pagelayer-id' => 1, 'pagelayer-srcset' => 1);
5044 $str = '';
5045 foreach ($attrs as $key => $val) {
5046 if (isset($skip[$key]) || $val === null) {
5047 continue;
5048 }
5049 if (is_bool($val)) {
5050 $val = $val ? 'true' : '';
5051 } elseif (is_array($val)) {
5052 $flat = true;
5053 foreach ($val as $item) {
5054 if (!is_scalar($item)) {
5055 $flat = false;
5056 break;
5057 }
5058 }
5059 $val = $flat ? implode(',', $val) : wp_json_encode($val);
5060 }
5061 $val = (string) $val;
5062 $val = str_replace(array('[', ']'), array('&#91;', '&#93;'), $val);
5063 $val = str_replace('"', '&quot;', $val);
5064 $str .= ' ' . $key . '="' . $val . '"';
5065 }
5066 return $str;
5067 }
5068
5069 /**
5070 * Widgets that form a repeating card grid (icon boxes, testimonials, etc.).
5071 */
5072 private function card_widget_tags() {
5073 return array(
5074 'pl_iconbox' => 1,
5075 'pl_service' => 1,
5076 'pl_testimonial' => 1,
5077 'pl_counter' => 1,
5078 'pl_pricing' => 1,
5079 'pl_flipbox' => 1,
5080 'pl_review' => 1,
5081 'pl_author_box' => 1,
5082 'pl_countdown' => 1,
5083 'pl_call' => 1,
5084 'pl_quote' => 1,
5085 );
5086 }
5087
5088 private function is_row_tag($tag) {
5089 $tag = $this->normalize_tag($tag);
5090 return ($tag === 'pl_row' || $tag === 'pl_inner_row');
5091 }
5092
5093 private function is_col_tag($tag) {
5094 $tag = $this->normalize_tag($tag);
5095 return ($tag === 'pl_col' || $tag === 'pl_inner_col');
5096 }
5097
5098 private function is_card_widget($node) {
5099 if (!is_array($node) || empty($node['tag'])) {
5100 return false;
5101 }
5102 $tags = $this->card_widget_tags();
5103 return isset($tags[$this->normalize_tag($node['tag'])]);
5104 }
5105
5106 private function col_width_value($node) {
5107 if (!is_array($node) || empty($node['attrs']['col'])) {
5108 return 12;
5109 }
5110 $c = intval($node['attrs']['col']);
5111 return $c > 0 ? $c : 12;
5112 }
5113
5114 private function col_children($col) {
5115 if (!is_array($col) || !isset($col['content']) || !is_array($col['content'])) {
5116 return array();
5117 }
5118 $out = array();
5119 foreach ($col['content'] as $child) {
5120 if (is_array($child)) {
5121 $out[] = $child;
5122 }
5123 }
5124 return $out;
5125 }
5126
5127 private function count_card_widgets($nodes) {
5128 $n = 0;
5129 foreach ((array) $nodes as $node) {
5130 if ($this->is_card_widget($node)) {
5131 $n++;
5132 }
5133 }
5134 return $n;
5135 }
5136
5137 /**
5138 * A column is a "card cell" when it holds one card widget (optionally with
5139 * a small decorative sibling like stars / a badge heading).
5140 */
5141 private function is_card_column($col) {
5142 if (!is_array($col) || empty($col['tag']) || !$this->is_col_tag($col['tag'])) {
5143 return false;
5144 }
5145 $children = $this->col_children($col);
5146 if (empty($children)) {
5147 return false;
5148 }
5149 $cards = 0;
5150 $others = 0;
5151 foreach ($children as $child) {
5152 if ($this->is_card_widget($child)) {
5153 $cards++;
5154 continue;
5155 }
5156 $tag = !empty($child['tag']) ? $this->normalize_tag($child['tag']) : '';
5157 if ($this->is_row_tag($tag) || $this->is_col_tag($tag)) {
5158 return false;
5159 }
5160 if (in_array($tag, array('pl_heading', 'pl_stars', 'pl_icon', 'pl_badge'), true)) {
5161 continue;
5162 }
5163 $others++;
5164 }
5165 return ($cards === 1 && $others === 0);
5166 }
5167
5168 private function make_col_node($width, $content, $extra = array()) {
5169 $attrs = array_merge(array('col' => (string) intval($width)), $extra);
5170 return array(
5171 'tag' => 'pl_col',
5172 'attrs' => $attrs,
5173 'content' => is_array($content) ? array_values($content) : array($content),
5174 );
5175 }
5176
5177 private function is_card_companion($node) {
5178 if (!is_array($node) || empty($node['tag'])) {
5179 return false;
5180 }
5181 $tag = $this->normalize_tag($node['tag']);
5182 if (in_array($tag, array('pl_stars', 'pl_icon', 'pl_badge'), true)) {
5183 return true;
5184 }
5185 if ($tag !== 'pl_heading') {
5186 return false;
5187 }
5188 $content = (isset($node['content']) && is_string($node['content'])) ? $node['content'] : '';
5189 if (preg_match('/<h[1-3]\b/i', $content)) {
5190 return false;
5191 }
5192 if (preg_match('/[⭐�
5193 ]|stars?/i', $content)) {
5194 return true;
5195 }
5196 $text = trim(wp_strip_all_tags($content));
5197 return (strlen($text) > 0 && strlen($text) <= 24);
5198 }
5199
5200 /**
5201 * Split a widget list into card units vs other content.
5202 * A star rating / badge heading immediately before a card stays in that card's column.
5203 */
5204 private function split_card_units($nodes) {
5205 $runs = array();
5206 $other = array();
5207 $cards = array();
5208 $list = array_values((array) $nodes);
5209 $n = count($list);
5210
5211 $flush_cards = function () use (&$cards, &$runs) {
5212 if (!empty($cards)) {
5213 $runs[] = array('type' => 'cards', 'units' => $cards);
5214 $cards = array();
5215 }
5216 };
5217 $flush_other = function () use (&$other, &$runs) {
5218 if (!empty($other)) {
5219 $runs[] = array('type' => 'other', 'nodes' => $other);
5220 $other = array();
5221 }
5222 };
5223
5224 $i = 0;
5225 while ($i < $n) {
5226 $node = $list[$i];
5227 if (!is_array($node)) {
5228 $i++;
5229 continue;
5230 }
5231 $next = ($i + 1 < $n) ? $list[$i + 1] : null;
5232
5233 if ($this->is_card_widget($node)) {
5234 $flush_other();
5235 $cards[] = array($node);
5236 $i++;
5237 continue;
5238 }
5239 if ($this->is_card_companion($node) && is_array($next) && $this->is_card_widget($next)) {
5240 $flush_other();
5241 $cards[] = array($node, $next);
5242 $i += 2;
5243 continue;
5244 }
5245
5246 $flush_cards();
5247 $other[] = $node;
5248 $i++;
5249 }
5250 $flush_cards();
5251 $flush_other();
5252 return $runs;
5253 }
5254
5255 /**
5256 * Walk the tree and turn stacked / over-wide card groups into sibling rows
5257 * of up to 3 columns.
5258 */
5259 private function reflow_card_grids($nodes) {
5260 if (!is_array($nodes) || empty($nodes)) {
5261 return $nodes;
5262 }
5263 $out = array();
5264 foreach ($nodes as $node) {
5265 if (!is_array($node) || empty($node['tag'])) {
5266 $out[] = $node;
5267 continue;
5268 }
5269 if ($this->is_row_tag($node['tag'])) {
5270 foreach ($this->reflow_row($node) as $row) {
5271 $out[] = $row;
5272 }
5273 continue;
5274 }
5275 $out[] = $node;
5276 }
5277 return $out;
5278 }
5279
5280 /**
5281 * One row -> 1+ rows with card grids of at most 3 columns.
5282 */
5283 private function reflow_row($row) {
5284 if (!is_array($row) || empty($row['tag']) || !$this->is_row_tag($row['tag'])) {
5285 return array($row);
5286 }
5287
5288 $cols = isset($row['content']) && is_array($row['content']) ? array_values($row['content']) : array();
5289 $segments = array();
5290
5291 foreach ($cols as $col) {
5292 if (!is_array($col) || empty($col['tag'])) {
5293 continue;
5294 }
5295 if (!$this->is_col_tag($col['tag'])) {
5296 $segments[] = array('kind' => 'block', 'cols' => array($this->make_col_node(12, array($col))));
5297 continue;
5298 }
5299
5300 $width = $this->col_width_value($col);
5301 $children = $this->col_children($col);
5302 $pending = array();
5303
5304 $flush_pending = function () use (&$pending, &$segments, $col) {
5305 if (empty($pending)) {
5306 return;
5307 }
5308 $col_copy = $col;
5309 $col_copy['content'] = $pending;
5310 $segments[] = array('kind' => 'block', 'cols' => array($col_copy));
5311 $pending = array();
5312 };
5313
5314 foreach ($children as $child) {
5315 if (!is_array($child) || empty($child['tag'])) {
5316 continue;
5317 }
5318
5319 if ($this->is_row_tag($child['tag'])) {
5320 $nested_rows = $this->reflow_row($child);
5321 if ($width >= 8) {
5322 $flush_pending();
5323 foreach ($nested_rows as $nr) {
5324 $nr_cols = isset($nr['content']) && is_array($nr['content']) ? array_values($nr['content']) : array();
5325 $all_cards = !empty($nr_cols);
5326 foreach ($nr_cols as $nc) {
5327 if (!$this->is_card_column($nc)) {
5328 $all_cards = false;
5329 break;
5330 }
5331 }
5332 $segments[] = array(
5333 'kind' => $all_cards ? 'cards' : 'block',
5334 'cols' => $nr_cols,
5335 );
5336 }
5337 continue;
5338 }
5339 foreach ($nested_rows as $nr) {
5340 $pending[] = $nr;
5341 }
5342 continue;
5343 }
5344
5345 $pending[] = $child;
5346 }
5347
5348 if ($width >= 8 && $this->count_card_widgets($pending) >= 2) {
5349 $runs = $this->split_card_units($pending);
5350 foreach ($runs as $run) {
5351 if ($run['type'] === 'cards' && !empty($run['units']) && count($run['units']) >= 2) {
5352 $card_cols = array();
5353 foreach ($run['units'] as $unit) {
5354 $card_cols[] = $this->make_col_node(4, $unit);
5355 }
5356 $segments[] = array('kind' => 'cards', 'cols' => $card_cols);
5357 } else {
5358 $content = array();
5359 if (!empty($run['units']) && is_array($run['units'])) {
5360 foreach ($run['units'] as $unit) {
5361 foreach ((array) $unit as $unit_node) {
5362 $content[] = $unit_node;
5363 }
5364 }
5365 } elseif (!empty($run['nodes'])) {
5366 $content = $run['nodes'];
5367 }
5368 $col_copy = $col;
5369 $col_copy['attrs']['col'] = 12;
5370 $col_copy['content'] = $content;
5371 $this->strip_column_card_chrome($col_copy);
5372 $segments[] = array('kind' => 'block', 'cols' => array($col_copy));
5373 }
5374 }
5375 continue;
5376 }
5377
5378 if (empty($pending)) {
5379 continue;
5380 }
5381
5382 $col['content'] = $pending;
5383 if ($this->is_card_column($col)) {
5384 $segments[] = array('kind' => 'cards', 'cols' => array($col));
5385 } else {
5386 $segments[] = array('kind' => 'block', 'cols' => array($col));
5387 }
5388 }
5389
5390 $merged = array();
5391 foreach ($segments as $seg) {
5392 $last = end($merged);
5393 if ($last && $last['kind'] === $seg['kind']) {
5394 $merged[count($merged) - 1]['cols'] = array_merge($last['cols'], $seg['cols']);
5395 } else {
5396 $merged[] = $seg;
5397 }
5398 }
5399
5400 foreach ($merged as $m_i => $seg) {
5401 if ($seg['kind'] === 'block' && $this->looks_like_equal_grid($seg['cols'])) {
5402 $merged[$m_i]['kind'] = 'cards';
5403 }
5404 }
5405
5406 $rows = array();
5407 $first = true;
5408 $seg_count = count($merged);
5409 foreach ($merged as $s_i => $seg) {
5410 $is_last_seg = ($s_i === $seg_count - 1);
5411 if ($seg['kind'] === 'cards') {
5412 $chunks = $this->chunk_card_columns($seg['cols']);
5413 $n_ch = count($chunks);
5414 foreach ($chunks as $c_i => $chunk) {
5415 $rows[] = $this->row_from_cols($chunk, $row, $first, $is_last_seg && ($c_i === $n_ch - 1));
5416 $first = false;
5417 }
5418 } else {
5419 $rows[] = $this->row_from_cols($seg['cols'], $row, $first, $is_last_seg);
5420 $first = false;
5421 }
5422 }
5423
5424 return !empty($rows) ? $rows : array($row);
5425 }
5426
5427 /**
5428 * 3+ equal-width narrow columns (3 or 4) are a card grid even when the
5429 * cell is a stack of heading/price/button rather than a single card widget.
5430 */
5431 private function looks_like_equal_grid($cols) {
5432 if (!is_array($cols) || count($cols) < 3) {
5433 return false;
5434 }
5435 $width = null;
5436 foreach ($cols as $col) {
5437 $w = $this->col_width_value($col);
5438 if ($w >= 8) {
5439 return false;
5440 }
5441 if ($width === null) {
5442 $width = $w;
5443 } elseif ($w !== $width) {
5444 return false;
5445 }
5446 }
5447 return in_array($width, array(3, 4), true);
5448 }
5449
5450 /**
5451 * Split a list of card columns into groups of at most 3 and assign col widths.
5452 */
5453 private function chunk_card_columns($cols) {
5454 $cols = array_values($cols);
5455 $n = count($cols);
5456 if ($n <= 0) {
5457 return array();
5458 }
5459 if ($n <= 3) {
5460 $this->assign_grid_widths($cols, $n);
5461 return array($cols);
5462 }
5463
5464 // When exactly 4 items, split into 2 balanced rows of 2 (6 cols each) instead of 3 + 1 orphan!
5465 if ($n === 4) {
5466 $chunks = array_chunk($cols, 2);
5467 foreach ($chunks as $i => $chunk) {
5468 $this->assign_grid_widths($chunk, 2);
5469 $chunks[$i] = $chunk;
5470 }
5471 return $chunks;
5472 }
5473
5474 // If n > 4 leaves a remainder of 1 (e.g. 7 items = 3 + 3 + 1), remove the orphan single card
5475 // so the grid stays balanced and professional without an isolated trailing card below
5476 if ($n % 3 === 1) {
5477 array_pop($cols);
5478 $n = count($cols);
5479 }
5480
5481 $chunks = array_chunk($cols, 3);
5482 foreach ($chunks as $i => $chunk) {
5483 $cn = count($chunk);
5484 if ($cn === 3) {
5485 $this->assign_grid_widths($chunk, 3);
5486 } elseif ($cn === 2) {
5487 $this->assign_grid_widths($chunk, 2);
5488 } else {
5489 $this->assign_grid_widths($chunk, 3);
5490 $chunk[0]['attrs']['col'] = '4';
5491 }
5492 $chunks[$i] = $chunk;
5493 }
5494 return $chunks;
5495 }
5496
5497 private function assign_grid_widths(&$cols, $per_row) {
5498 $width = 4;
5499 if ($per_row <= 1) {
5500 $width = 12;
5501 } elseif ($per_row === 2) {
5502 $width = 6;
5503 }
5504 foreach ($cols as &$col) {
5505 if (!isset($col['attrs']) || !is_array($col['attrs'])) {
5506 $col['attrs'] = array();
5507 }
5508 $col['attrs']['col'] = (string) $width;
5509 }
5510 unset($col);
5511 }
5512
5513 private function strip_column_card_chrome(&$col) {
5514 if (!isset($col['attrs']) || !is_array($col['attrs'])) {
5515 return;
5516 }
5517 $drop = array(
5518 'ele_bg_type', 'ele_bg_color', 'ele_shadow',
5519 'border_type', 'border_width', 'border_color', 'border_radius',
5520 'ele_padding',
5521 );
5522 foreach ($drop as $key) {
5523 unset($col['attrs'][$key]);
5524 }
5525 if (!empty($col['attrs']['ele_css']) && is_string($col['attrs']['ele_css'])) {
5526 $css = $col['attrs']['ele_css'];
5527 $css = preg_replace('/\{\{element\}\}\{background-clip:padding-box;[^}]*\}/i', '', $css);
5528 $css = preg_replace('/\{\{element\}\}\s*\.pagelayer-col-holder\{[^}]*\}/i', '', $css);
5529 $css = trim($css);
5530 if ($css === '') {
5531 unset($col['attrs']['ele_css']);
5532 } else {
5533 $col['attrs']['ele_css'] = $css;
5534 }
5535 }
5536 }
5537
5538 private function row_from_cols($cols, $base_row, $is_first, $is_last) {
5539 $attrs = array();
5540 if (is_array($base_row) && isset($base_row['attrs']) && is_array($base_row['attrs'])) {
5541 $attrs = $base_row['attrs'];
5542 }
5543 if (!$is_first) {
5544 unset($attrs['pagelayer-id'], $attrs['ele_id']);
5545 }
5546 if (!isset($attrs['col_gap']) || $attrs['col_gap'] === '' || (string) $attrs['col_gap'] === '0') {
5547 $attrs['col_gap'] = '20';
5548 }
5549 $attrs = $this->pin_col_gap_horizontal($attrs);
5550 return array(
5551 'tag' => 'pl_row',
5552 'attrs' => $attrs,
5553 'content' => array_values($cols),
5554 );
5555 }
5556
5557 /**
5558 * PageLayer col_gap is `padding: Npx` on .pagelayer-col-holder (all sides).
5559 * The left/right padding is the column gutter. The top/bottom padding
5560 * stacks between sibling rows on the frontend as a blank band, while the
5561 * editor wrap/overlays hide it. Keep the col_gap value, but only as
5562 * horizontal padding so both views match.
5563 */
5564 private function pin_col_gap_horizontal($attrs) {
5565 if (!is_array($attrs)) {
5566 $attrs = array();
5567 }
5568 $gap = isset($attrs['col_gap']) ? intval($attrs['col_gap']) : 0;
5569 if ($gap <= 0) {
5570 $gap = 20;
5571 $attrs['col_gap'] = '20';
5572 }
5573 $rule = '{{element}}{margin-top:0;margin-bottom:0;margin-block-start:0;margin-block-end:0;}'
5574 . '{{element}}>.pagelayer-row-holder .pagelayer-col-holder{padding-top:0;padding-bottom:0;}';
5575 $css = isset($attrs['ele_css']) ? (string) $attrs['ele_css'] : '';
5576 if (strpos($css, 'padding-top:0') === false) {
5577 $attrs['ele_css'] = $css . $rule;
5578 }
5579 return $attrs;
5580 }
5581
5582 /**
5583 * Every outer section row keeps its own top and bottom padding so the next
5584 * band never sits flush against the columns above it.
5585 */
5586 private function normalize_row_stack_spacing($nodes) {
5587 if (!is_array($nodes) || empty($nodes)) {
5588 return $nodes;
5589 }
5590
5591 foreach ($nodes as $i => $node) {
5592 if (!is_array($node) || empty($node['tag'])) {
5593 continue;
5594 }
5595 if (isset($node['content']) && is_array($node['content'])) {
5596 $nodes[$i]['content'] = $this->normalize_row_stack_spacing($node['content']);
5597 }
5598 if (!$this->is_row_tag($node['tag'])) {
5599 continue;
5600 }
5601 if ($this->normalize_tag($node['tag']) === 'pl_inner_row') {
5602 continue;
5603 }
5604 if (!isset($nodes[$i]['attrs']) || !is_array($nodes[$i]['attrs'])) {
5605 $nodes[$i]['attrs'] = array();
5606 }
5607 $nodes[$i]['attrs']['ele_padding'] = $this->section_row_padding(
5608 isset($nodes[$i]['attrs']['ele_padding']) ? $nodes[$i]['attrs']['ele_padding'] : ''
5609 );
5610 if (!empty($nodes[$i]['attrs']['ele_padding_mobile'])) {
5611 $nodes[$i]['attrs']['ele_padding_mobile'] = $this->section_row_padding($nodes[$i]['attrs']['ele_padding_mobile']);
5612 }
5613 }
5614
5615 return $nodes;
5616 }
5617
5618 private function split_padding($val) {
5619 $parts = array_map('trim', explode(',', (string) $val));
5620 if (count($parts) === 1 && $parts[0] === '') {
5621 $parts = array('0px', '0px', '0px', '0px');
5622 }
5623 while (count($parts) < 4) {
5624 $parts[] = isset($parts[0]) ? $parts[0] : '0px';
5625 }
5626 return array_slice($parts, 0, 4);
5627 }
5628
5629 private function padding_to_px($part) {
5630 return (int) round((float) preg_replace('/[^0-9.\-]/', '', (string) $part));
5631 }
5632
5633 /**
5634 * Use the real row col_gap setting and stop faking gutters with CSS that
5635 * only shows on the frontend.
5636 */
5637 private function normalize_column_gaps($nodes) {
5638 if (!is_array($nodes)) {
5639 return $nodes;
5640 }
5641 foreach ($nodes as &$node) {
5642 if (!is_array($node) || empty($node['tag'])) {
5643 continue;
5644 }
5645 $tag = $this->normalize_tag($node['tag']);
5646 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
5647 $node['attrs'] = array();
5648 }
5649
5650 if ($tag === 'pl_row' || $tag === 'pl_inner_row') {
5651 if (!isset($node['attrs']['col_gap']) || $node['attrs']['col_gap'] === '' || (string) $node['attrs']['col_gap'] === '0') {
5652 $node['attrs']['col_gap'] = '20';
5653 }
5654 if ($tag === 'pl_inner_row' && isset($node['attrs']['stretch']) && $node['attrs']['stretch'] === 'full') {
5655 $node['attrs']['stretch'] = 'auto';
5656 }
5657 $this->strip_fake_gap_css($node);
5658 if ($tag === 'pl_row') {
5659 $node['attrs'] = $this->pin_col_gap_horizontal($node['attrs']);
5660 }
5661 }
5662
5663 if ($tag === 'pl_col' || $tag === 'pl_inner_col') {
5664 $this->move_column_card_style_to_widget($node);
5665 $this->strip_transparent_gap_border($node);
5666 $this->strip_fake_gap_css($node);
5667 }
5668
5669 $this->strip_fake_gap_css($node);
5670
5671 if (isset($node['content']) && is_array($node['content'])) {
5672 $node['content'] = $this->normalize_column_gaps($node['content']);
5673 }
5674 }
5675 unset($node);
5676 return $nodes;
5677 }
5678
5679 private function strip_fake_gap_css(&$node) {
5680 if (empty($node['attrs']['ele_css']) || !is_string($node['attrs']['ele_css'])) {
5681 return;
5682 }
5683 $css = $node['attrs']['ele_css'];
5684 $css = preg_replace('/(?:column-gap|row-gap|grid-gap|grid-column-gap|grid-row-gap|(?<![a-z-])gap)\s*:\s*[^;}{]+;?/i', '', $css);
5685 $css = preg_replace('/display\s*:\s*grid\s*;?/i', '', $css);
5686 $css = preg_replace('/grid-template-columns\s*:\s*[^;}{]+;?/i', '', $css);
5687 $css = preg_replace('/\{\{element\}\}\{background-clip:padding-box;[^}]*\}/i', '', $css);
5688 $css = preg_replace('/[^{}]+\{[;\s]*\}/', '', $css);
5689 $css = preg_replace('/;\s*;+/', ';', $css);
5690 $css = trim($css, " \t\n\r\0\x0B;");
5691 if ($css === '' || strpos($css, ':') === false) {
5692 unset($node['attrs']['ele_css']);
5693 } else {
5694 $node['attrs']['ele_css'] = $css;
5695 }
5696 }
5697
5698 private function is_transparent_gap_border($attrs) {
5699 if (!is_array($attrs) || empty($attrs['border_color'])) {
5700 return false;
5701 }
5702 $c = strtolower(preg_replace('/\s+/', '', (string) $attrs['border_color']));
5703 return ($c === 'transparent' || $c === 'rgba(0,0,0,0)' || $c === 'rgba(0,0,0,0.0)' || $c === '#00000000' || $c === '#0000');
5704 }
5705
5706 private function strip_transparent_gap_border(&$node) {
5707 if (empty($node['attrs']) || !is_array($node['attrs'])) {
5708 return;
5709 }
5710 if (!$this->is_transparent_gap_border($node['attrs'])) {
5711 return;
5712 }
5713 unset($node['attrs']['border_type'], $node['attrs']['border_width'], $node['attrs']['border_color']);
5714 }
5715
5716 /**
5717 * Card chrome on a column is flush in the editor (width lives on the wrap)
5718 * and only looks gapped on the frontend when a transparent border hack is
5719 * used. Move that chrome onto the single card widget so row col_gap is the
5720 * visible gutter in both views.
5721 */
5722 private function move_column_card_style_to_widget(&$col) {
5723 $children = $this->col_children($col);
5724 $card_idx = -1;
5725 $cards = 0;
5726 foreach ($children as $i => $child) {
5727 if ($this->is_card_widget($child)) {
5728 $cards++;
5729 $card_idx = $i;
5730 }
5731 }
5732 if ($cards !== 1 || $card_idx < 0 || empty($col['attrs'])) {
5733 return;
5734 }
5735
5736 $style_keys = array(
5737 'ele_bg_type', 'ele_bg_color', 'ele_padding', 'ele_shadow',
5738 'border_radius',
5739 );
5740 $has_style = false;
5741 foreach ($style_keys as $k) {
5742 if (!empty($col['attrs'][$k])) {
5743 $has_style = true;
5744 break;
5745 }
5746 }
5747 if (!$this->is_transparent_gap_border($col['attrs']) && !$has_style) {
5748 return;
5749 }
5750
5751 $card = $children[$card_idx];
5752 if (!isset($card['attrs']) || !is_array($card['attrs'])) {
5753 $card['attrs'] = array();
5754 }
5755
5756 foreach ($style_keys as $k) {
5757 if (empty($col['attrs'][$k])) {
5758 continue;
5759 }
5760 if (empty($card['attrs'][$k])) {
5761 $card['attrs'][$k] = $col['attrs'][$k];
5762 }
5763 unset($col['attrs'][$k]);
5764 }
5765
5766 if (!$this->is_transparent_gap_border($col['attrs'])) {
5767 foreach (array('border_type', 'border_width', 'border_color') as $k) {
5768 if (empty($col['attrs'][$k])) {
5769 continue;
5770 }
5771 if (empty($card['attrs'][$k])) {
5772 $card['attrs'][$k] = $col['attrs'][$k];
5773 }
5774 unset($col['attrs'][$k]);
5775 }
5776 } else {
5777 unset($col['attrs']['border_type'], $col['attrs']['border_width'], $col['attrs']['border_color']);
5778 }
5779
5780 $children[$card_idx] = $card;
5781 $col['content'] = $children;
5782 }
5783
5784 /**
5785 * Fill missing modern look-and-feel using real Pagelayer attributes.
5786 */
5787 private function apply_modern_defaults($tag, $attrs, $node) {
5788 if (!is_array($attrs)) {
5789 $attrs = array();
5790 }
5791 $tag = str_replace(array('pl_inner_row', 'pl_inner_col'), array('pl_row', 'pl_col'), $this->normalize_tag($tag));
5792
5793 if ($tag === 'pl_row') {
5794 return $attrs;
5795 }
5796
5797 $cards = $this->card_widget_tags();
5798 if (isset($cards[$tag])) {
5799 if (empty($attrs['ele_padding'])) {
5800 $attrs['ele_padding'] = '32px,28px,32px,28px';
5801 }
5802 if ($tag === 'pl_iconbox' || $tag === 'pl_service') {
5803 $attrs = $this->style_icon_box($tag, $attrs);
5804 if (empty($attrs['service_icon_color'])) {
5805 $attrs['service_icon_color'] = '$primary';
5806 }
5807 if (empty($attrs['service_heading_color'])) {
5808 $attrs['service_heading_color'] = '$primary';
5809 }
5810 }
5811 if ($tag === 'pl_image_slider') {
5812 $attrs = $this->style_image_slider($attrs);
5813 }
5814 if ($tag === 'pl_testimonial') {
5815 if (empty($attrs['alignment'])) {
5816 $attrs['alignment'] = 'center';
5817 }
5818 if (empty($attrs['image_position'])) {
5819 $attrs['image_position'] = 'top-position';
5820 }
5821 if (empty($attrs['cite_color'])) {
5822 $attrs['cite_color'] = '$primary';
5823 }
5824 if (empty($attrs['img_shape'])) {
5825 $attrs['img_shape'] = 'circle';
5826 }
5827 }
5828 if ($tag === 'pl_counter') {
5829 if (empty($attrs['counter_align'])) {
5830 $attrs['counter_align'] = 'center';
5831 }
5832 if (empty($attrs['counter_number_color'])) {
5833 $attrs['counter_number_color'] = '$primary';
5834 }
5835 $attrs = $this->style_counter($attrs, false);
5836 }
5837 }
5838
5839 if ($tag === 'pl_heading') {
5840 if (empty($attrs['color'])) {
5841 $attrs['color'] = '$primary';
5842 }
5843 $content = (isset($node['content']) && is_string($node['content'])) ? $node['content'] : '';
5844 $family = $this->global_font_family('primary');
5845 if (empty($attrs['heading_typo']) && empty($attrs['font_size'])) {
5846 if (stripos($content, '<h1') !== false) {
5847 $attrs['heading_typo'] = $family . ',48,,800,,,,1.15,,,';
5848 } elseif (stripos($content, '<h2') !== false) {
5849 $attrs['heading_typo'] = $family . ',36,,700,,,,1.2,,,';
5850 } elseif (stripos($content, '<h3') !== false) {
5851 $attrs['heading_typo'] = $family . ',22,,700,,,,1.3,,,';
5852 } else {
5853 $attrs['heading_typo'] = $family . ',18,,600,,,,1.4,,,';
5854 }
5855 } elseif (!empty($attrs['heading_typo'])) {
5856 $attrs['heading_typo'] = $this->typo_with_family($attrs['heading_typo'], $family);
5857 }
5858 }
5859
5860 if ($tag === 'pl_btn') {
5861 if (empty($attrs['type'])) {
5862 $attrs['type'] = 'pagelayer-btn-custom';
5863 }
5864 if (empty($attrs['size'])) {
5865 $attrs['size'] = 'pagelayer-btn-large';
5866 }
5867 if (empty($attrs['btn_bg_color'])) {
5868 $attrs['btn_bg_color'] = '$primary';
5869 }
5870 if (empty($attrs['btn_color'])) {
5871 $attrs['btn_color'] = '#ffffff';
5872 }
5873 if (!isset($attrs['font_weight']) || $attrs['font_weight'] === '') {
5874 $attrs['font_weight'] = '600';
5875 }
5876 if (empty($attrs['btn_border_radius'])) {
5877 if (!isset($attrs['btn_border_type']) || $attrs['btn_border_type'] === '') {
5878 $attrs['btn_border_type'] = 'solid';
5879 }
5880 if (empty($attrs['btn_border_width'])) {
5881 $attrs['btn_border_width'] = '0px,0px,0px,0px';
5882 }
5883 $attrs['btn_border_radius'] = '8px,8px,8px,8px';
5884 }
5885 }
5886
5887 if ($tag === 'pl_text') {
5888 if (empty($attrs['font_family'])) {
5889 $attrs['font_family'] = $this->global_font_family('text');
5890 }
5891 if (empty($attrs['font_size'])) {
5892 $attrs['font_size'] = '16';
5893 }
5894 if (empty($attrs['line_height'])) {
5895 $attrs['line_height'] = '1.7';
5896 }
5897 }
5898
5899 if ($tag === 'pl_image') {
5900 if (empty($attrs['align'])) {
5901 $attrs['align'] = 'center';
5902 }
5903 if (empty($attrs['border_radius'])) {
5904 $attrs['border_radius'] = '16px,16px,16px,16px';
5905 }
5906 }
5907
5908 return $attrs;
5909 }
5910
5911 public function default_widget_set() {
5912 $catalog = $this->get_widget_catalog();
5913 $prefer = array(
5914 'pl_row', 'pl_col', 'pl_heading', 'pl_text', 'pl_btn', 'pl_image',
5915 'pl_iconbox', 'pl_service', 'pl_icon', 'pl_list', 'pl_list_item',
5916 'pl_counter', 'pl_testimonial', 'pl_stars', 'pl_divider', 'pl_space',
5917 'pl_accordion', 'pl_accordion_item', 'pl_tabs', 'pl_tab', 'pl_quote',
5918 'pl_progress', 'pl_phone', 'pl_email', 'pl_address', 'pl_social_grp',
5919 'pl_social', 'pl_video', 'pl_google_maps', 'pl_badge', 'pl_alert',
5920 'pl_image_slider', 'pl_grid_gallery',
5921 );
5922 $out = array();
5923 foreach ($prefer as $tag) {
5924 if (isset($catalog[$tag])) {
5925 $out[] = $tag;
5926 }
5927 }
5928
5929 $out = apply_filters('pagelayer_ai_default_widgets', $out, $catalog);
5930 if (!is_array($out)) {
5931 return array();
5932 }
5933
5934 $clean = array();
5935 foreach ($out as $tag) {
5936 $tag = $this->normalize_tag((string) $tag);
5937 if (isset($catalog[$tag]) && !in_array($tag, $clean, true)) {
5938 $clean[] = $tag;
5939 }
5940 }
5941 return $clean;
5942 }
5943 }
5944