$tag, 'name' => isset($data['name']) ? $data['name'] : $tag, 'group' => isset($data['group']) ? $data['group'] : 'misc', 'html' => isset($data['html']) ? $data['html'] : '', 'holder' => isset($data['holder']) ? $data['holder'] : '', 'innerHTML' => isset($data['innerHTML']) ? $data['innerHTML'] : '', 'parent' => isset($data['parent']) ? $data['parent'] : array(), 'has_group' => isset($data['has_group']) ? $data['has_group'] : array(), 'skip_props_cat' => isset($data['skip_props_cat']) ? $data['skip_props_cat'] : array(), 'skip_props' => isset($data['skip_props']) ? $data['skip_props'] : array(), 'sections' => array(), ); $settings_tabs = isset($data['settings']) ? $data['settings'] : array(); $options = isset($data['options']) ? $data['options'] : array(); $section_keys = array(); if (!empty($pagelayer->tabs) && is_array($pagelayer->tabs)) { foreach ($pagelayer->tabs as $tab) { if (empty($data[$tab]) || !is_array($data[$tab])) { continue; } foreach ($data[$tab] as $section_key => $section_label) { $section_keys[] = $section_key; } } } foreach ($section_keys as $section_key) { $props = array(); if (isset($data[$section_key]) && is_array($data[$section_key])) { $props = $data[$section_key]; } elseif (isset($pagelayer->styles[$section_key]) && is_array($pagelayer->styles[$section_key])) { $props = $pagelayer->styles[$section_key]; } if (empty($props)) { continue; } $clean_props = array(); foreach ($props as $prop_key => $prop_def) { if (!is_array($prop_def)) { $clean_props[$prop_key] = array('label' => $prop_def); continue; } $clean_prop = array( 'type' => isset($prop_def['type']) ? $prop_def['type'] : '', 'label' => isset($prop_def['label']) ? $prop_def['label'] : '', 'default' => isset($prop_def['default']) ? $prop_def['default'] : null, ); if (isset($prop_def['list']) && is_array($prop_def['list'])) { $clean_prop['allowed_values'] = $prop_def['list']; } if (isset($prop_def['min'])) { $clean_prop['min'] = $prop_def['min']; } if (isset($prop_def['max'])) { $clean_prop['max'] = $prop_def['max']; } if (isset($prop_def['step'])) { $clean_prop['step'] = $prop_def['step']; } if (isset($prop_def['units'])) { $clean_prop['units'] = $prop_def['units']; } if (isset($prop_def['screen'])) { $clean_prop['responsive'] = (bool) $prop_def['screen']; } if (isset($prop_def['req'])) { $clean_prop['requires'] = $prop_def['req']; } if (isset($prop_def['show'])) { $clean_prop['show_when'] = $prop_def['show']; } if (isset($prop_def['edit'])) { $clean_prop['edit_selector'] = $prop_def['edit']; } if (isset($prop_def['desc'])) { $clean_prop['desc'] = $prop_def['desc']; } if (!empty($prop_def['pro'])) { $clean_prop['pro'] = 1; } $clean_props[$prop_key] = $clean_prop; } $schema['sections'][$section_key] = array( 'label' => isset($settings_tabs[$section_key]) ? $settings_tabs[$section_key] : (isset($options[$section_key]) ? $options[$section_key] : ucfirst($section_key)), 'properties' => $clean_props, ); } if (isset($pagelayer->default_params[$tag])) { $schema['default_attrs'] = $pagelayer->default_params[$tag]; } return $schema; } private function compact_prop($prop) { $type = !empty($prop['type']) ? $prop['type'] : 'text'; $parts = array($type); if (!empty($prop['label']) && is_string($prop['label'])) { $lbl = trim(preg_replace('/\s+/', ' ', wp_strip_all_tags($prop['label']))); if ($lbl !== '') { if (function_exists('mb_substr')) { $lbl = mb_substr($lbl, 0, 40); } else { $lbl = substr($lbl, 0, 40); } $parts[] = 'lbl:' . str_replace(array('|', ':'), array('/', '-'), $lbl); } } if (isset($prop['default']) && $prop['default'] !== '' && $prop['default'] !== null) { $def = is_array($prop['default']) ? wp_json_encode($prop['default']) : (string) $prop['default']; if (strlen($def) > 40) { $def = substr($def, 0, 40) . '…'; } $parts[] = 'def:' . $def; } if (!empty($prop['allowed_values']) && is_array($prop['allowed_values'])) { $vals = array_values(array_filter(array_keys($prop['allowed_values']), 'strlen')); if (empty($vals) || $vals === range(0, count($prop['allowed_values']) - 1)) { $vals = array_values($prop['allowed_values']); } $vals = array_map(function ($v) { return is_scalar($v) ? (string) $v : ''; }, $vals); $parts[] = 'opts:' . implode(',', array_filter($vals, 'strlen')); } if (isset($prop['min']) || isset($prop['max'])) { $range = (isset($prop['min']) ? $prop['min'] : '') . '-' . (isset($prop['max']) ? $prop['max'] : ''); if (!empty($prop['units'])) { $units = is_array($prop['units']) ? implode('/', $prop['units']) : $prop['units']; $range .= $units; } $parts[] = $range; } if (!empty($prop['requires']) && is_array($prop['requires'])) { $req = array(); foreach ($prop['requires'] as $k => $v) { $req[] = $k . '=' . (is_array($v) ? implode('/', $v) : $v); } $parts[] = 'req:' . implode('&', $req); } if (!empty($prop['responsive'])) { $parts[] = 'resp'; } if ($type === 'typography') { $parts[] = 'fields:family,size,style,weight,variant,deco-line,deco-style,line-height,transform,letter,word'; } elseif ($type === 'image') { $parts[] = 'val:url-or-id'; } elseif ($type === 'padding') { $parts[] = 'val:t,r,b,l+unit'; } elseif ($type === 'icon') { $parts[] = 'val:fa5-class'; } return implode('|', $parts); } private function own_section_keys($tag) { global $pagelayer; $this->ensure_shortcodes(); $data = isset($pagelayer->shortcodes[$tag]) ? $pagelayer->shortcodes[$tag] : array(); return isset($data['settings']) && is_array($data['settings']) ? array_keys($data['settings']) : array(); } private function compact_widget_schema($schema, $mode = 'own', $only = array()) { $tag = $schema['id']; $own = $this->own_section_keys($tag); $out = array( 'id' => $tag, 'name' => $schema['name'], 'group' => $schema['group'], ); if (!empty($schema['parent'])) { $out['must_be_inside'] = $schema['parent']; } if (!empty($schema['holder']) || !empty($schema['has_group'])) { $out['accepts_children'] = true; } if (!empty($schema['innerHTML'])) { $out['content_attr'] = $schema['innerHTML']; $out['content_note'] = 'Main text goes in the node "content" field, not as attrs[' . $schema['innerHTML'] . '].'; } if (!empty($schema['html']) && is_string($schema['html'])) { preg_match_all('/\{\{\{?([a-zA-Z0-9_\-]+)\}?\}\}/', $schema['html'], $markup_m); if (!empty($markup_m[1])) { $renders = array(); foreach (array_unique($markup_m[1]) as $name) { if (strpos($name, 'pagelayer') === 0 || strpos($name, 'func_') === 0) { continue; } $renders[] = $name; } if (!empty($renders)) { $out['renders'] = array_values($renders); } } } if (!empty($schema['skip_props'])) { $out['unsupported_props'] = $schema['skip_props']; } $sections = array(); $include_pro = $this->include_pro_settings(); foreach ($schema['sections'] as $key => $section) { $is_own = in_array($key, $own, true); if (!empty($only)) { if (!in_array($key, $only, true)) { continue; } } elseif ($mode === 'own' && !$is_own) { continue; } elseif ($mode === 'shared' && $is_own) { continue; } $props = array(); foreach ($section['properties'] as $prop_key => $prop) { if ($mode === 'own' && substr($prop_key, -6) === '_hover') { $base = substr($prop_key, 0, -6); if ($base !== '' && isset($section['properties'][$base])) { continue; } } if (!$include_pro && !empty($prop['pro'])) { continue; } $props[$prop_key] = $this->compact_prop($prop); } if (empty($props)) { continue; } $sections[$key] = $props; } $out['props'] = $sections; return $out; } private function widget_attr_rules($tag) { global $pagelayer; static $cache = array(); $lookup = str_replace(array('pl_inner_row', 'pl_inner_col'), array('pl_row', 'pl_col'), $tag); if (array_key_exists($lookup, $cache)) { return $cache[$lookup]; } $this->ensure_shortcodes(); if (empty($pagelayer->shortcodes[$lookup])) { return $cache[$lookup] = null; } $schema = $this->extract_widget_schema($lookup, $pagelayer->shortcodes[$lookup]); $rules = array('allowed' => array(), 'req' => array()); foreach ($schema['sections'] as $section) { foreach ($section['properties'] as $key => $prop) { $rules['allowed'][$key] = isset($prop['type']) ? $prop['type'] : ''; if (!empty($prop['requires']) && is_array($prop['requires'])) { $rules['req'][$key] = $prop['requires']; } if (!empty($prop['responsive'])) { $rules['allowed'][$key . '_tablet'] = $rules['allowed'][$key]; $rules['allowed'][$key . '_mobile'] = $rules['allowed'][$key]; } } } return $cache[$lookup] = $rules; } private function build_widget_example($tag, $data) { $schema = $this->extract_widget_schema($tag, $data); $inner_key = isset($data['innerHTML']) ? $data['innerHTML'] : ''; $own_sections = isset($data['settings']) && is_array($data['settings']) ? $data['settings'] : array(); $attrs = array(); foreach ($schema['sections'] as $section_key => $section) { if (!isset($own_sections[$section_key])) { continue; } foreach ($section['properties'] as $key => $prop) { $type = isset($prop['type']) ? $prop['type'] : ''; if (strpos($key, '_hover') !== false || !empty($prop['requires'])) { continue; } if (in_array($type, array('text', 'textarea', 'editor'), true)) { $label = !empty($prop['label']) ? $prop['label'] : $key; $attrs[$key] = ''; } elseif ($type === 'color') { $attrs[$key] = '$primary'; } elseif ($type === 'icon') { $attrs[$key] = !empty($prop['default']) ? $prop['default'] : 'fas fa-star'; } elseif ($type === 'image') { $attrs[$key] = 'https://images.unsplash.com/photo-1557804506-669a67965ba0?w=1200'; } elseif ($type === 'link') { $attrs[$key] = '#'; } } } $example = array('tag' => $tag, 'attrs' => $attrs); if ($inner_key && isset($attrs[$inner_key])) { $example['content'] = $attrs[$inner_key]; unset($example['attrs'][$inner_key]); } return $example; } /** * Compact catalog of every registered widget the builder may emit. * Format: tag => "Name|group[|children][|in:parent][|content:innerHTML]" */ public function get_widget_catalog() { global $pagelayer; $this->ensure_shortcodes(); $catalog = array(); if (empty($pagelayer->shortcodes) || !is_array($pagelayer->shortcodes)) { return $catalog; } foreach ($pagelayer->shortcodes as $tag => $data) { if (in_array($tag, self::$skip_tags, true)) { continue; } if (strpos($tag, 'pl_wp_') === 0 && $tag !== 'pl_wp_menu') { continue; } $name = isset($data['name']) ? $data['name'] : $tag; $group = isset($data['group']) ? $data['group'] : 'misc'; $line = $name . '|' . $group; if (!empty($data['holder']) || !empty($data['has_group'])) { $line .= '|children'; } if (!empty($data['parent'])) { $line .= '|in:' . implode(',', (array) $data['parent']); } if (!empty($data['innerHTML'])) { $line .= '|content:' . $data['innerHTML']; } $catalog[$tag] = $line; } return apply_filters('pagelayer_ai_widget_catalog', $catalog); } public function get_compact_schemas($tags = array()) { global $pagelayer; $this->ensure_shortcodes(); $catalog = $this->get_widget_catalog(); if (empty($tags)) { $tags = array_keys($catalog); } $pro_flag = $this->include_pro_settings() ? '1' : '0'; $pro_ver = defined('PAGELAYER_PRO_VERSION') ? PAGELAYER_PRO_VERSION : '0'; $cache_key = 'pagelayer_ai_own_schemas_v5_' . PAGELAYER_VERSION . '_' . $pro_ver . '_' . $pro_flag . '_' . md5(implode(',', $tags) . count($pagelayer->shortcodes)); $cached = get_transient($cache_key); if (is_array($cached)) { return $cached; } $out = array(); foreach ($tags as $tag) { if (!isset($pagelayer->shortcodes[$tag])) { continue; } $schema = $this->extract_widget_schema($tag, $pagelayer->shortcodes[$tag]); $compact = $this->compact_widget_schema($schema, 'own'); unset($compact['legend'], $compact['shared_note'], $compact['shared_style_sections']); $out[$tag] = $compact; } set_transient($cache_key, $out, self::SCHEMA_CACHE_TTL); return $out; } public function get_common_styles() { global $pagelayer; $this->ensure_shortcodes(); $probe = isset($pagelayer->shortcodes['pl_heading']) ? 'pl_heading' : (is_array($pagelayer->shortcodes) ? key($pagelayer->shortcodes) : ''); if (!$probe || empty($pagelayer->shortcodes[$probe])) { return array(); } $sections = apply_filters('pagelayer_ai_common_style_sections', array( 'ele_bg_styles', 'ele_styles', 'border_styles', 'font_style', 'custom_styles', )); $schema = $this->extract_widget_schema($probe, $pagelayer->shortcodes[$probe]); $compact = $this->compact_widget_schema($schema, 'shared', $sections); $styles = !empty($compact['props']) && is_array($compact['props']) ? $compact['props'] : array(); foreach ($styles as $section => $props) { if (!is_array($props)) { continue; } foreach (array_keys($props) as $key) { if (strpos($key, '_hover') !== false || strpos($key, '_state') !== false) { unset($styles[$section][$key]); } } } return $styles; } public function get_global_color_keys() { $snap = $this->get_globals_snapshot(); $keys = array_keys($snap['colors']); return !empty($keys) ? $keys : array('primary', 'secondary', 'text', 'accent'); } /** * Current Pagelayer global colors + fonts as stored (title/value maps). */ public function get_globals_snapshot() { global $pagelayer; if (function_exists('pagelayer_load_global_palette')) { pagelayer_load_global_palette(); } $colors = (isset($pagelayer->global_colors) && is_array($pagelayer->global_colors)) ? $pagelayer->global_colors : array(); $fonts = (isset($pagelayer->global_fonts) && is_array($pagelayer->global_fonts)) ? $pagelayer->global_fonts : array(); if (empty($colors)) { $colors = array( 'primary' => array('title' => 'Primary', 'value' => '#2563eb'), 'secondary' => array('title' => 'Secondary', 'value' => '#0f172a'), 'text' => array('title' => 'Text', 'value' => '#0f172a'), 'accent' => array('title' => 'Accent', 'value' => '#f59e0b'), 'bg' => array('title' => 'Background', 'value' => '#ffffff'), 'surface' => array('title' => 'Surface', 'value' => '#f8fafc'), ); } return array( 'colors' => $colors, 'fonts' => $fonts, ); } private function sanitize_hex($hex) { $hex = $this->coerce_hex($hex); return $hex; } private function coerce_hex($val) { $val = trim((string) $val); if ($val === '') { return ''; } if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $val)) { return strtolower($val); } if (preg_match('/^([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $val)) { return '#' . strtolower($val); } if (preg_match('/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i', $val, $m)) { return sprintf('#%02x%02x%02x', min(255, (int) $m[1]), min(255, (int) $m[2]), min(255, (int) $m[3])); } $named = array( 'dark' => '#0f172a', 'light' => '#f8fafc', 'black' => '#111827', 'white' => '#ffffff', 'red' => '#ef4444', 'blue' => '#2563eb', 'green' => '#10b981', 'yellow' => '#f59e0b', 'orange' => '#f97316', 'purple' => '#8b5cf6', 'pink' => '#ec4899', 'navy' => '#0f172a', 'gold' => '#d97706', 'teal' => '#14b8a6', 'gray' => '#64748b', 'grey' => '#64748b', 'charcoal' => '#1e293b', 'cream' => '#fdf8f4', 'indigo' => '#6366f1', 'cyan' => '#06b6d4', ); $low = strtolower($val); if (isset($named[$low])) { return $named[$low]; } return ''; } private function sanitize_font_family($name) { $name = $this->first_font_name($name); $name = preg_replace('/[^a-zA-Z0-9 \-]/', '', $name); $name = trim(preg_replace('/\s+/', ' ', $name)); if ($name === '' || strlen($name) > 60) { return ''; } return $name; } private function first_font_name($name) { $name = trim((string) $name); $name = trim($name, "\"'`"); if (strpos($name, ',') !== false) { $name = trim(explode(',', $name)[0], "\"'` "); } return $name; } private function mix_hex($a, $b, $t) { $a = ltrim($this->coerce_hex($a), '#'); $b = ltrim($this->coerce_hex($b), '#'); if (strlen($a) === 3) { $a = $a[0] . $a[0] . $a[1] . $a[1] . $a[2] . $a[2]; } if (strlen($b) === 3) { $b = $b[0] . $b[0] . $b[1] . $b[1] . $b[2] . $b[2]; } if (strlen($a) < 6 || strlen($b) < 6) { return '#' . $a; } $t = max(0, min(1, (float) $t)); $out = '#'; for ($i = 0; $i < 3; $i++) { $av = hexdec(substr($a, $i * 2, 2)); $bv = hexdec(substr($b, $i * 2, 2)); $out .= sprintf('%02x', (int) round($av + ($bv - $av) * $t)); } return $out; } private function complete_palette($palette) { $snap = $this->get_globals_snapshot(); $keys = array( 'primary' => '#2563eb', 'secondary' => '', 'text' => '#0f172a', 'accent' => '', 'bg' => '#ffffff', 'surface' => '', ); $out = array(); foreach ($keys as $key => $fallback) { $hex = ''; if (is_array($palette) && !empty($palette[$key])) { $hex = $this->coerce_hex($palette[$key]); } if ($hex === '' && !empty($snap['colors'][$key]['value'])) { $hex = $this->coerce_hex($snap['colors'][$key]['value']); } $out[$key] = $hex !== '' ? $hex : $fallback; } if ($out['primary'] === '') { $out['primary'] = '#2563eb'; } if ($out['secondary'] === '') { $out['secondary'] = $this->mix_hex($out['primary'], '#0f172a', 0.45); } if ($out['text'] === '') { $out['text'] = '#0f172a'; } if ($out['accent'] === '') { $out['accent'] = $this->mix_hex($out['primary'], '#f59e0b', 0.4); } if ($out['bg'] === '') { $out['bg'] = '#ffffff'; } if ($out['surface'] === '') { $out['surface'] = $this->mix_hex($out['primary'], '#ffffff', 0.92); } return $out; } private function complete_fonts($fonts) { $snap = $this->get_globals_snapshot(); $read = function ($key, $fallback) use ($fonts, $snap) { $raw = ''; if (is_array($fonts) && !empty($fonts[$key])) { if (is_string($fonts[$key])) { $raw = $fonts[$key]; } elseif (is_array($fonts[$key])) { if (!empty($fonts[$key]['font-family']) && is_string($fonts[$key]['font-family'])) { $raw = $fonts[$key]['font-family']; } elseif (!empty($fonts[$key]['value']['font-family'])) { $raw = $fonts[$key]['value']['font-family']; } } } if ($raw === '' && !empty($snap['fonts'][$key]['value']['font-family'])) { $raw = $snap['fonts'][$key]['value']['font-family']; } $family = $this->sanitize_font_family($raw); return $family !== '' ? $family : $fallback; }; return array( 'primary' => $read('primary', 'Poppins'), 'secondary' => $read('secondary', 'Poppins'), 'text' => $read('text', 'Inter'), 'accent' => $read('accent', 'Poppins'), ); } /** * Write AI-chosen palette/fonts into Pagelayer global settings and memory * so $primary / $text tokens resolve during render. Always completes bg + fonts. */ public function apply_ai_globals($palette = array(), $fonts = array(), $persist = true) { global $pagelayer; $palette = $this->complete_palette(is_array($palette) ? $palette : array()); $fonts = $this->complete_fonts(is_array($fonts) ? $fonts : array()); $snap = $this->get_globals_snapshot(); $colors = $snap['colors']; $gfonts = $snap['fonts']; $titles = array( 'primary' => 'Primary', 'secondary' => 'Secondary', 'text' => 'Text', 'accent' => 'Accent', 'bg' => 'Background', 'surface' => 'Surface', ); foreach ($titles as $key => $title) { if (empty($palette[$key])) { continue; } $hex = $this->coerce_hex($palette[$key]); if ($hex === '') { continue; } if (!isset($colors[$key]) || !is_array($colors[$key])) { $colors[$key] = array('title' => $title, 'value' => $hex); } else { $colors[$key]['value'] = $hex; if (empty($colors[$key]['title'])) { $colors[$key]['title'] = $title; } } } foreach (array('primary' => 'Primary', 'secondary' => 'Secondary', 'text' => 'Text', 'accent' => 'Accent') as $key => $title) { if (empty($fonts[$key])) { continue; } $family = $this->sanitize_font_family($fonts[$key]); if ($family === '') { continue; } $base = (isset($pagelayer) && is_object($pagelayer) && method_exists($pagelayer, 'default_font_styles')) ? $pagelayer->default_font_styles(array('font-family' => $family)) : array('font-family' => $family); if (!isset($gfonts[$key]) || !is_array($gfonts[$key])) { $gfonts[$key] = array('title' => $title, 'value' => $base); } else { if (empty($gfonts[$key]['value']) || !is_array($gfonts[$key]['value'])) { $gfonts[$key]['value'] = $base; } else { $gfonts[$key]['value']['font-family'] = $family; } if (empty($gfonts[$key]['title'])) { $gfonts[$key]['title'] = $title; } } } if (isset($pagelayer) && is_object($pagelayer)) { $pagelayer->global_colors = $colors; $pagelayer->global_fonts = $gfonts; } if ($persist) { update_option('pagelayer_global_colors', wp_json_encode($colors)); update_option('pagelayer_global_fonts', wp_json_encode($gfonts)); } return array( 'colors' => $colors, 'fonts' => $gfonts, ); } /** * Detect if user prompt is asking to update/change global colors */ public function is_global_color_request($prompt) { $hay = strtolower(trim((string) $prompt)); if ($hay === '') { return false; } // If build/create/design command, it is a build request, not a pure color request 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)) { return false; } 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)) { return true; } 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)) { return true; } if (preg_match('/\b(give\s+)?set\s+this\s+global\s+colou?r\b/i', $hay)) { return true; } return false; } /** * Detect if user prompt is asking to change/set background color or canvas dark/light mode */ public function is_background_color_request($prompt) { $hay = strtolower(trim((string) $prompt)); if ($hay === '') { return false; } // If build/create/design command, it is a build request, not a pure background request 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)) { return false; } if (preg_match('/\b(make|set|change|turn|switch)\b.{0,25}\b(all\s+)?(background|bg|theme)\b/i', $hay)) { return true; } 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)) { return true; } 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)) { return true; } 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)) { return true; } 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)) { return true; } if (preg_match('/\b(give\s+)?set\s+this\s+colou?r\b/i', $hay)) { return true; } return false; } /** * Process a global color instruction, persist to DB, and return snapshot */ public function handle_global_color_instruction($prompt) { $hay = strtolower(trim((string) $prompt)); $snap = $this->get_globals_snapshot(); $palette = array(); foreach ($snap['colors'] as $k => $c) { $palette[$k] = !empty($c['value']) ? $c['value'] : '#ffffff'; } $extracted_color = $this->extract_color_from_string($hay); if ($extracted_color === '') { $extracted_color = '#2563eb'; } $target_key = 'primary'; if (preg_match('/\b(secondary)\b/i', $hay)) { $target_key = 'secondary'; } elseif (preg_match('/\b(text|copy|font\s*colou?r)\b/i', $hay)) { $target_key = 'text'; } elseif (preg_match('/\b(accent)\b/i', $hay)) { $target_key = 'accent'; } elseif (preg_match('/\b(background|bg)\b/i', $hay)) { $target_key = 'bg'; } elseif (preg_match('/\b(surface)\b/i', $hay)) { $target_key = 'surface'; } $palette[$target_key] = $extracted_color; if ($target_key === 'bg') { $is_dark = $this->is_dark_color($extracted_color); $palette['surface'] = $is_dark ? '#1e293b' : '#f8fafc'; $palette['text'] = $is_dark ? '#f8fafc' : '#0f172a'; } $updated = $this->apply_ai_globals($palette, array(), true); return array( 'success' => true, 'action' => 'update_globals', 'target_key' => $target_key, 'color' => $extracted_color, 'palette' => $palette, 'globals' => $this->get_globals_snapshot(), 'globals_updated' => true, 'message' => sprintf(__('Global %s color updated to %s.', 'pagelayer'), ucfirst($target_key), $extracted_color), ); } /** * Process a background color or dark mode instruction, persist to DB, and return status */ public function handle_background_color_instruction($prompt) { $hay = strtolower(trim((string) $prompt)); $is_dark = false; $bg_color = ''; if (preg_match('/\b(dark|black|navy|charcoal|night|dark\s*mode)\b/i', $hay)) { $is_dark = true; $bg_color = '#0f172a'; } elseif (preg_match('/\b(light|white|bright|light\s*mode)\b/i', $hay)) { $is_dark = false; $bg_color = '#ffffff'; } else { $extracted = $this->extract_color_from_string($hay); if ($extracted !== '') { $bg_color = $extracted; $is_dark = $this->is_dark_color($bg_color); } else { $is_dark = true; $bg_color = '#0f172a'; } } $snap = $this->get_globals_snapshot(); $palette = array(); foreach ($snap['colors'] as $k => $c) { $palette[$k] = !empty($c['value']) ? $c['value'] : '#ffffff'; } $palette['bg'] = $bg_color; $palette['surface'] = $is_dark ? '#1e293b' : '#f8fafc'; $palette['text'] = $is_dark ? '#f8fafc' : '#0f172a'; $updated = $this->apply_ai_globals($palette, array(), true); return array( 'success' => true, 'action' => 'update_background', 'bg_color' => $bg_color, 'is_dark' => $is_dark, 'palette' => $palette, 'globals' => $this->get_globals_snapshot(), 'globals_updated' => true, 'message' => $is_dark ? __('Canvas background and section rows set to dark mode.', 'pagelayer') : sprintf(__('Canvas background color set to %s.', 'pagelayer'), $bg_color), ); } /** * Extract a hex or named color from any text string */ public function extract_color_from_string($str) { if (preg_match('/#([0-9a-f]{6}|[0-9a-f]{3})\b/i', $str, $m)) { return strtolower($m[0]); } if (preg_match('/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i', $str, $m)) { return sprintf('#%02x%02x%02x', min(255, (int) $m[1]), min(255, (int) $m[2]), min(255, (int) $m[3])); } $named = array( 'dark' => '#0f172a', 'light' => '#f8fafc', 'black' => '#111827', 'white' => '#ffffff', 'red' => '#ef4444', 'blue' => '#2563eb', 'green' => '#10b981', 'yellow' => '#f59e0b', 'orange' => '#f97316', 'purple' => '#8b5cf6', 'pink' => '#ec4899', 'navy' => '#0f172a', 'gold' => '#d97706', 'teal' => '#14b8a6', 'gray' => '#64748b', 'grey' => '#64748b', 'charcoal' => '#1e293b', 'cream' => '#fdf8f4', 'indigo' => '#6366f1', 'cyan' => '#06b6d4', ); foreach ($named as $name => $hex) { if (preg_match('/\b' . $name . '\b/i', $str)) { return $hex; } } return ''; } /** * Check if custom site globals already exist */ public function has_custom_globals() { $colors_opt = get_option('pagelayer_global_colors', ''); $fonts_opt = get_option('pagelayer_global_fonts', ''); return (!empty($colors_opt) && $colors_opt !== '[]' && $colors_opt !== '""') || (!empty($fonts_opt) && $fonts_opt !== '[]' && $fonts_opt !== '""'); } /** * Extract flat color map and font map from current site globals snapshot */ public function get_existing_tokens() { $snap = $this->get_globals_snapshot(); $palette = array(); if (!empty($snap['colors']) && is_array($snap['colors'])) { foreach ($snap['colors'] as $k => $c) { $palette[$k] = !empty($c['value']) ? $c['value'] : (is_string($c) ? $c : ''); } } $fonts = array(); if (!empty($snap['fonts']) && is_array($snap['fonts'])) { foreach ($snap['fonts'] as $k => $f) { if (!empty($f['value']['font-family'])) { $fonts[$k] = $f['value']['font-family']; } elseif (is_string($f)) { $fonts[$k] = $f; } } } return array( 'palette' => $palette, 'fonts' => $fonts, ); } public function global_font_family($key = 'primary') { $snap = $this->get_globals_snapshot(); if (!empty($snap['fonts'][$key]['value']['font-family'])) { $family = $this->sanitize_font_family($snap['fonts'][$key]['value']['font-family']); if ($family !== '') { return $family; } } if ($key !== 'primary' && !empty($snap['fonts']['primary']['value']['font-family'])) { $family = $this->sanitize_font_family($snap['fonts']['primary']['value']['font-family']); if ($family !== '') { return $family; } } return ($key === 'text') ? 'Inter' : 'Poppins'; } /** * Live widget groups from the shortcode registry (tag => "Name"). */ public function get_widget_groups() { global $pagelayer; $this->ensure_shortcodes(); $groups = array(); $catalog = $this->get_widget_catalog(); if (empty($pagelayer->groups) || !is_array($pagelayer->groups)) { foreach ($catalog as $tag => $line) { $parts = explode('|', $line); $name = isset($parts[0]) ? $parts[0] : $tag; $group = isset($parts[1]) ? $parts[1] : 'misc'; $groups[$group][] = $tag . ':' . $name; } return $groups; } foreach ($pagelayer->groups as $group => $tags) { if (!is_array($tags)) { continue; } foreach ($tags as $tag) { if (!isset($catalog[$tag])) { continue; } $parts = explode('|', $catalog[$tag]); $groups[$group][] = $tag . ':' . $parts[0]; } } return $groups; } /** * Pick widgets whose live name/tag/group matches the user's request. * The catalog is the source of truth — no hardcoded widget field names. */ public function widgets_for_request($prompt) { $catalog = $this->get_widget_catalog(); $matched = array(); foreach (array('pl_row', 'pl_col') as $required) { if (isset($catalog[$required])) { $matched[] = $required; } } $hay = strtolower((string) $prompt); $words = preg_split('/[^a-z0-9]+/i', $hay, -1, PREG_SPLIT_NO_EMPTY); $stop = array( 'a' => 1, 'an' => 1, 'the' => 1, 'and' => 1, 'or' => 1, 'for' => 1, 'with' => 1, 'from' => 1, 'this' => 1, 'that' => 1, 'page' => 1, 'section' => 1, 'layout' => 1, 'make' => 1, 'create' => 1, 'build' => 1, 'want' => 1, 'please' => 1, 'add' => 1, 'using' => 1, 'into' => 1, 'onto' => 1, 'your' => 1, 'our' => 1, 'my' => 1, 'modern' => 1, 'nice' => 1, 'good' => 1, 'best' => 1, 'new' => 1, 'some' => 1, 'need' => 1, 'have' => 1, 'just' => 1, 'like' => 1, 'also' => 1, 'theme' => 1, 'template' => 1, 'templates' => 1, 'header' => 1, 'footer' => 1, 'error' => 1, 'found' => 1, 'design' => 1, 'generate' => 1, ); $meaningful = array(); foreach ($words as $w) { $w = strtolower($w); if (strlen($w) < 3 || isset($stop[$w])) { continue; } $meaningful[] = $w; if (strlen($w) > 4 && substr($w, -1) === 's') { $meaningful[] = substr($w, 0, -1); } } foreach ($catalog as $tag => $line) { $parts = explode('|', $line); $name = strtolower(isset($parts[0]) ? $parts[0] : ''); $group = strtolower(isset($parts[1]) ? $parts[1] : ''); $slug = strtolower(str_replace(array('pl_', '_'), array('', ' '), $tag)); $blob = $name . ' ' . $group . ' ' . $slug . ' ' . $tag; if ($name !== '' && strpos($hay, $name) !== false) { $matched[] = $tag; continue; } foreach ($meaningful as $w) { if (strpos($blob, $w) !== false) { $matched[] = $tag; break; } } } $matched = array_values(array_unique($matched)); foreach (array('pl_heading', 'pl_text', 'pl_btn', 'pl_image') as $primitive) { if (isset($catalog[$primitive]) && !in_array($primitive, $matched, true)) { $matched[] = $primitive; } } // Expand holders/children of prompt matches first so purpose-built // widgets (including Pro) are not sliced off by the fallback pack. $matched = $this->expand_related_widgets($matched); $kind = $this->request_kind($prompt); $kind_pack = $this->widgets_for_kind($kind); if (!empty($kind_pack)) { $restricted = array(); foreach ($kind_pack as $t) { if (isset($catalog[$t])) { $restricted[] = $t; } } return $this->expand_related_widgets($restricted); } $extra = array(); if ($kind === 'blog' || $kind === 'single') { $extra = array('pl_archive_posts', 'pl_posts', 'pl_featured_image', 'pl_post_title', 'pl_post_excerpt', 'pl_post_info', 'pl_post_content'); } elseif ($kind === 'contact') { $extra = array('pl_heading', 'pl_text', 'pl_btn', 'pl_address', 'pl_phone', 'pl_email'); } elseif ($kind === 'faq') { $extra = array('pl_accordion', 'pl_accordion_item', 'pl_heading', 'pl_text'); } foreach ($extra as $tag) { if (isset($catalog[$tag]) && !in_array($tag, $matched, true)) { $matched[] = $tag; } } $requested_sections = $this->extract_requested_sections($prompt); $is_full_page = ($kind === 'home' || (!$this->is_scoped_kind($kind) && count($requested_sections) >= 2)); if ($is_full_page) { foreach ($this->default_widget_set() as $tag) { if (!in_array($tag, $matched, true)) { $matched[] = $tag; } } foreach (array_keys($catalog) as $tag) { if (!in_array($tag, $matched, true)) { $matched[] = $tag; } } } else { foreach (array('pl_row', 'pl_col', 'pl_heading', 'pl_text', 'pl_btn', 'pl_image') as $tag) { if (isset($catalog[$tag]) && !in_array($tag, $matched, true)) { $matched[] = $tag; } } } return $this->expand_related_widgets($matched); } /** * Widget allow-list for theme-builder chrome and 404 so the model cannot * reach for icon boxes / services / testimonials. */ public function widgets_for_kind($kind) { $packs = array( 'header' => array( 'pl_row', 'pl_col', 'pl_heading', 'pl_image', 'pl_btn', 'pl_icon', 'pl_wp_menu', 'pl_search', 'pl_social_grp', 'pl_social', ), 'footer' => array( 'pl_row', 'pl_col', 'pl_heading', 'pl_text', 'pl_btn', 'pl_image', 'pl_icon', 'pl_list', 'pl_list_item', 'pl_social_grp', 'pl_social', 'pl_address', 'pl_phone', 'pl_email', 'pl_divider', 'pl_space', ), '404' => array( 'pl_row', 'pl_col', 'pl_heading', 'pl_text', 'pl_btn', 'pl_search', 'pl_icon', 'pl_image', 'pl_list', 'pl_list_item', ), ); return isset($packs[$kind]) ? $packs[$kind] : array(); } /** * If a holder is selected, include its children; if a child is selected, include its parent. */ public function expand_related_widgets($tags) { $catalog = $this->get_widget_catalog(); $set = array(); foreach ((array) $tags as $tag) { $tag = $this->normalize_tag((string) $tag); if (empty($catalog) || isset($catalog[$tag])) { $set[$tag] = true; } } foreach ($catalog as $tag => $line) { $parts = explode('|', $line); foreach ($parts as $part) { if (strpos($part, 'in:') !== 0) { continue; } $parents = explode(',', substr($part, 3)); if (isset($set[$tag])) { foreach ($parents as $parent) { $parent = trim($parent); if (isset($catalog[$parent])) { $set[$parent] = true; } } } foreach ($parents as $parent) { $parent = trim($parent); if (isset($set[$parent]) && isset($catalog[$tag])) { $set[$tag] = true; } } } } $out = array_keys($set); $max = $this->max_schema_widgets(); if (count($out) > $max) { $priority = array(); foreach ((array) $tags as $tag) { $tag = $this->normalize_tag((string) $tag); if (isset($set[$tag])) { $priority[$tag] = true; } } $rest = array(); foreach ($out as $tag) { if (!isset($priority[$tag])) { $rest[] = $tag; } } $out = array_merge(array_keys($priority), $rest); $out = array_slice($out, 0, $max); } return $out; } public function max_schema_widgets() { $max = (int) apply_filters('pagelayer_ai_max_schema_widgets', self::MAX_SCHEMA_WIDGETS); return $max > 0 ? $max : self::MAX_SCHEMA_WIDGETS; } public function get_compact_examples($tags) { global $pagelayer; $this->ensure_shortcodes(); $out = array(); foreach ((array) $tags as $tag) { $tag = $this->normalize_tag((string) $tag); if (empty($pagelayer->shortcodes[$tag])) { continue; } $example = $this->build_widget_example($tag, $pagelayer->shortcodes[$tag]); if (empty($example) || !is_array($example)) { continue; } if (!empty($example['attrs']) && is_array($example['attrs'])) { foreach ($example['attrs'] as $k => $v) { if (is_string($v) && strlen($v) > 80) { $example['attrs'][$k] = substr($v, 0, 77) . '...'; } } } if (!empty($example['content']) && is_string($example['content']) && strlen($example['content']) > 80) { $example['content'] = substr($example['content'], 0, 77) . '...'; } $out[$tag] = $example; } return $out; } private function schema_legend() { return 'prop format "type|lbl:Label|def:X|opts:a,b|min-maxunit|req:attr=val|resp". ' . 'Use the property KEY as the attrs key and lbl as the meaning — never invent a shorter alias. ' . 'req = companion attr that must ALSO be set on the same node or Pagelayer discards the property at render with no error. ' . '"a/b" = any one of those values; "&" = all conditions; leading "!" negates. ' . 'resp = also accepts _tablet and _mobile suffixed siblings. ' . 'content_attr = that field is the node "content" string, not an attrs key. ' . 'renders = attrs the widget markup actually interpolates.'; } public function get_plan_prompt() { $catalog = $this->get_widget_catalog(); $groups = $this->get_widget_groups(); $prompt = "You are a senior product/brand designer planning a Pagelayer page or Theme Builder template.\n"; $prompt .= "Infer what the named brand, domain, or product actually is (industry, audience, offer) from the prompt. "; $prompt .= "SiteSEO is an SEO product, not a restaurant; a pizzeria is not a gym. Design for THAT business.\n"; $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"; $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"; $prompt .= "Choose REAL widgets from the live catalog. Do not invent tags. Include holder children.\n"; $prompt .= "Invent a complete, original information architecture and column map for THIS request. "; $prompt .= "Do not reuse a canned skeleton and do not shrink a homepage to 2–3 generic bands.\n"; $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"; $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"; $prompt .= "OUTPUT a JSON object only:\n"; $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'; $prompt .= "layout[] is the architecture you invented (column splits + widgets per band) for THIS brand. Always include pl_row and pl_col.\n"; $prompt .= "palette MUST include bg and surface. fonts.primary is the heading family, fonts.text is the body family (real Google fonts).\n"; $prompt .= "widgets[] should list every catalog tag this page will use — prefer purpose-built widgets, not only heading/text/button/image.\n"; $prompt .= "\nWIDGET GROUPS (live on this install):\n"; $prompt .= wp_json_encode($groups, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n\n"; $prompt .= "CATALOG (tag => Name|group[|children][|in:parent][|content:innerHTML]):\n"; $prompt .= wp_json_encode($catalog, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; return apply_filters('pagelayer_ai_plan_prompt', $prompt, $this); } public function get_build_prompt($widget_tags = array(), $opts = array()) { $catalog = $this->get_widget_catalog(); $groups = $this->get_widget_groups(); $kind = !empty($opts['kind']) ? $opts['kind'] : 'section'; if (empty($widget_tags)) { $widget_tags = array_keys($catalog); } $widget_tags = $this->expand_related_widgets(array_merge( array('pl_row', 'pl_col'), $widget_tags )); $schemas = $this->get_compact_schemas($widget_tags); $styles = $this->get_common_styles(); $example_tags = array_values(array_diff($widget_tags, array('pl_row', 'pl_col', 'pl_inner_row', 'pl_inner_col'))); $examples = $this->get_compact_examples(array_slice($example_tags, 0, 20)); $scoped = $this->is_scoped_kind($kind); $chrome = in_array($kind, array('header', 'footer', '404'), true); if ($chrome) { $allowed = $this->widgets_for_kind($kind); if (!empty($allowed)) { $widget_tags = $this->expand_related_widgets(array_merge(array('pl_row', 'pl_col'), $allowed)); } $filtered = array(); foreach ($widget_tags as $t) { if (isset($catalog[$t])) { $filtered[$t] = $catalog[$t]; } } if (!empty($filtered)) { $catalog = $filtered; } $schemas = $this->get_compact_schemas($widget_tags); $example_tags = array_values(array_diff($widget_tags, array('pl_row', 'pl_col', 'pl_inner_row', 'pl_inner_col'))); $examples = $this->get_compact_examples(array_slice($example_tags, 0, 20)); } $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"; $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"; if ($kind !== 'header' && $kind !== 'footer') { $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"; } $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"; $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"; $prompt .= "BADGES: A section badge is its own widget/row above the heading, wrapped in 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"; $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"; if (!empty($opts['structure_brief'])) { $prompt .= $opts['structure_brief'] . "\n\n"; } elseif ($kind !== 'section') { $prompt .= $this->structure_brief($kind, !empty($opts['prompt']) ? $opts['prompt'] : '') . "\n\n"; } if ($kind === 'home') { $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"; } elseif ($kind === 'header') { $prompt .= "STRICT SCOPE FOR HEADER THEME TEMPLATE:\n"; $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"; $prompt .= "- Color bar background (ele_bg_type=color). Do NOT use a full-bleed photo as the design.\n"; $prompt .= "- ABSOLUTELY DO NOT add features, services, icon boxes, testimonials, heroes, or any extra page sections.\n\n"; } elseif ($kind === 'footer') { $prompt .= "STRICT SCOPE FOR FOOTER THEME TEMPLATE:\n"; $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"; $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"; $prompt .= "- ABSOLUTELY DO NOT add features, services, icon boxes, testimonials, pricing, hero, or any extra marketing sections.\n\n"; } elseif ($kind === '404') { $prompt .= "STRICT SCOPE FOR 404 ERROR THEME TEMPLATE:\n"; $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"; $prompt .= "- Centered on a color background. A 404 that is only a background image plus one heading is a FAILED response.\n"; $prompt .= "- ABSOLUTELY DO NOT add feature cards, icon boxes, services, counters, testimonials, pricing, or marketing bands.\n\n"; } elseif ($kind === 'blog') { $prompt .= "This is a BLOG LISTING / ARCHIVE theme template only. Invent the composition. Use catalog post widgets if available. No marketing homepage.\n\n"; } elseif ($kind === 'single') { $prompt .= "This is a SINGLE POST theme template only. Invent the composition. No homepage.\n\n"; } elseif ($kind === 'service') { $prompt .= "This is a SERVICES page only. Invent a layout that fits this domain. Do not wrap it in a homepage.\n\n"; } elseif (in_array($kind, array('about', 'contact', 'pricing', 'faq', 'search'), true)) { $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"; } else { $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"; } $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"; $prompt .= "OUTPUT a JSON object only:\n"; $prompt .= '{"palette":{"primary":"#hex","secondary":"#hex","text":"#hex","accent":"#hex","bg":"#hex"},"fonts":{"primary":"Font Name","text":"Font Name"},"nodes":[ ... ]}' . "\n"; $prompt .= "palette/fonts are written to Pagelayer global settings. Each node: {\"tag\":\"pl_...\",\"attrs\":{...},\"content\": ...}\n"; $prompt .= "- content is a STRING when the schema has content_attr (that text is innerHTML).\n"; $prompt .= "- content is an ARRAY of child nodes for containers (pl_row, pl_col, and any widget marked |children or accepts_children).\n"; $prompt .= "- No markdown. No greeting. JSON only.\n\n"; $prompt .= "HIERARCHY:\n"; $prompt .= "1. Root nodes are pl_row.\n"; $prompt .= "2. pl_row children are ONLY pl_col. Column widths (attrs.col 1-12) in a row MUST sum to 12.\n"; $prompt .= "3. Widgets live inside pl_col. Nested rows inside a column are still pl_row/pl_col.\n"; $prompt .= "4. Widgets marked |in:parent MUST be nested under that parent.\n"; $prompt .= "5. Never emit empty columns. Never put a leaf widget directly in a row.\n\n"; if (!$chrome) { $prompt .= "CARD GRID LAYOUT (only when you actually use repeating cards):\n"; $prompt .= "- You are NOT required to use icon boxes. Use them only if this page needs that content.\n"; $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"; $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"; $prompt .= "- 4+ items: fill a row of 3 columns, then start a NEW sibling pl_row underneath for the rest. Example 6 items:\n"; $prompt .= " Row A: [Item1 col=4] [Item2 col=4] [Item3 col=4]\n"; $prompt .= " Row B: [Item4 col=4] [Item5 col=4] [Item6 col=4]\n"; $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"; $prompt .= "- Section headings/subcopy go in their own full-width col=12 row above the card grid, not inside the first card column.\n"; $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"; } if ($kind === 'home' || (!empty($opts['full_page']) && !$scoped)) { $prompt .= "WIDGET MIX (required for homepage / full page):\n"; $prompt .= "- Use the catalog. A homepage built only from heading+text+button+image is a failed response.\n"; $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"; $prompt .= "- A homepage should typically use 8+ different widget tags besides pl_row/pl_col.\n\n"; } elseif ($kind === 'header') { $prompt .= "WIDGET MIX (header theme template):\n"; $prompt .= "- Use pl_heading or pl_image for the brand, pl_wp_menu for navigation, optional pl_btn CTA.\n"; $prompt .= "- Do NOT use icon boxes, services, testimonials, or page sections.\n\n"; } elseif ($kind === 'footer') { $prompt .= "WIDGET MIX (footer theme template):\n"; $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"; $prompt .= "- Do NOT use icon boxes, services, testimonials, counters, or pricing.\n\n"; } elseif ($kind === '404') { $prompt .= "WIDGET MIX (for 404 error theme template):\n"; $prompt .= "- Use pl_heading (404 number + title), pl_text (explanation), pl_btn (Back to Home), pl_search, optional pl_icon and link buttons.\n"; $prompt .= "- Absolutely DO NOT use icon boxes, testimonials, counters, or feature/service cards.\n\n"; } else { $prompt .= "WIDGET MIX:\n"; $prompt .= "- Use purpose-built widgets that strictly match what the user requested.\n"; $prompt .= "- Do NOT add feature cards, icon boxes, services, or testimonials unless the user specifically asked for them.\n\n"; } $prompt .= "FONTS:\n"; $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"; $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"; $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"; $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"; $prompt .= "COLORS AND BACKGROUNDS:\n"; $prompt .= "- Invent a NEW palette for THIS brand. JSON palette MUST include primary, secondary, text, accent, bg, surface as #hex.\n"; $prompt .= "- After you output palette, PHP writes it to the site. Reference colors as \$primary, \$secondary, \$text, \$accent, \$bg, \$surface.\n"; $prompt .= "- EVERY pl_row needs ele_bg_type=color AND ele_bg_color (\$primary / \$bg / \$surface / a hex). Alternate dark and light bands.\n"; $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"; $prompt .= "- Social icons: bg_shape=pagelayer-social-shape-circle, icon_color=#ffffff, icon_bg_color=\$primary (never the same value).\n"; $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"; $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"; $prompt .= "- Counter: counter_start_number must be 1 or greater (never 0 — 0 hides the widget). counter_end_number greater than start.\n"; $prompt .= "- EVERY widget: never use the same color for a foreground (text/icon/arrow) and its background.\n"; $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"; $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"; $prompt .= "SETTINGS (read the schema — do not guess names; unstyled widgets are a failed response):\n"; $prompt .= "- The property KEY is the attrs key. lbl: is the meaning. renders: lists attrs the widget markup actually uses.\n"; $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"; $prompt .= "- NEVER write style=\"\" or