| 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[eé]|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|©|©|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('[', ']'), $val); |
| 5063 |
$val = str_replace('"', '"', $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 |
|