__('Pagelayer Pages', 'pagelayer'), 'description' => __('Create, update, validate, duplicate, and publish pages built with Pagelayer.', 'pagelayer'), )); wp_register_ability_category('pagelayer-posts', array( 'label' => __('Pagelayer Posts', 'pagelayer'), 'description' => __('Create, update, list, duplicate, and publish individual blog posts built with Pagelayer.', 'pagelayer'), )); wp_register_ability_category('pagelayer-widgets', array( 'label' => __('Pagelayer Widgets', 'pagelayer'), 'description' => __('Discover widgets, schemas, controls, nesting rules, and example nodes.', 'pagelayer'), )); wp_register_ability_category('pagelayer-global', array( 'label' => __('Pagelayer Global Styles & Presets', 'pagelayer'), 'description' => __('Manage design systems, global colors/fonts, theme settings, icons, fonts, and presets.', 'pagelayer'), )); wp_register_ability_category('pagelayer-templates', array( 'label' => __('Pagelayer Templates', 'pagelayer'), 'description' => __('Manage theme builder templates (header, footer, archive, single, search, 404, popup, woocommerce).', 'pagelayer'), )); wp_register_ability_category('pagelayer-menus', array( 'label' => __('Pagelayer Navigation Menus', 'pagelayer'), 'description' => __('Build the WordPress nav menus that the Primary Menu / Mega Menu widgets render in headers and footers.', 'pagelayer'), )); wp_register_ability_category('pagelayer-media', array( 'label' => __('Pagelayer Media', 'pagelayer'), 'description' => __('Upload and manage media library assets for Pagelayer layouts.', 'pagelayer'), )); } public static function register_abilities() { self::register_widget_abilities(); self::register_global_abilities(); self::register_template_abilities(); self::register_menu_abilities(); self::register_pages_abilities(); self::register_posts_abilities(); self::register_media_abilities(); } // ------------------------------------------------------------------ // Permission callbacks // ------------------------------------------------------------------ public static function can_edit_posts() { return current_user_can('edit_posts'); } public static function can_edit_pages() { return current_user_can('edit_pages'); } public static function can_publish_pages() { return current_user_can('publish_pages'); } public static function can_publish_posts() { return current_user_can('publish_posts'); } public static function can_delete_pages() { return current_user_can('delete_pages'); } public static function can_delete_posts() { return current_user_can('delete_posts'); } public static function can_edit_theme_options() { return current_user_can('edit_theme_options'); } public static function can_manage_options() { return current_user_can('manage_options'); } public static function can_upload_files() { return current_user_can('upload_files'); } // ------------------------------------------------------------------ // Shared helpers // ------------------------------------------------------------------ protected static function ensure_shortcodes_loaded() { global $pagelayer; if (empty($pagelayer->shortcodes) && function_exists('pagelayer_load_shortcodes')) { pagelayer_load_shortcodes(); } } /** * Pagelayer stores a global colour/font as array('title' => ..., 'value' => * ...) — customizer.php emits the CSS custom properties by reading * $entry['value']. An AI client naturally sends the flat map it was asked * for ({"primary": "#E50914"}), and writing that through verbatim made * every --pagelayer-color-* variable render EMPTY, so every "$token" in the * generated site resolved to nothing and the whole palette silently * vanished. Accept both shapes and store the one the renderer reads. */ protected static function normalize_global_map($map) { if (!is_array($map)) { return array(); } $out = array(); foreach ($map as $key => $entry) { if (is_array($entry)) { // Already in storage shape (or close enough) — keep it, but make // sure the keys the renderer needs are present. if (!isset($entry['value'])) { continue; } $out[$key] = array( 'title' => isset($entry['title']) ? $entry['title'] : ucwords(str_replace('_', ' ', $key)), 'value' => $entry['value'], ); continue; } $out[$key] = array( 'title' => ucwords(str_replace('_', ' ', $key)), 'value' => $entry, ); } return $out; } public static function maybe_update_global_styles($input) { if (!current_user_can('manage_options')) { return; } if (isset($input['global_colors'])) { update_option('pagelayer_global_colors', json_encode(self::normalize_global_map($input['global_colors']))); } if (isset($input['global_fonts'])) { update_option('pagelayer_global_fonts', json_encode(self::normalize_global_map($input['global_fonts']))); } if (isset($input['content_width'])) { update_option('pagelayer_content_width', sanitize_text_field($input['content_width'])); } } // ------------------------------------------------------------------ // Widget Schema Extractor & Examples // ------------------------------------------------------------------ public static function extract_widget_schema($tag, $data) { global $pagelayer; $schema = array( 'id' => $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']; $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; } // ------------------------------------------------------------------ // Token-compaction layer // // extract_widget_schema() stays full-fidelity because the server-side // quality gate (widget_attr_rules) validates against every section. What // follows only trims the OUTPUT that crosses the wire to the AI client. // // The measured problem: a single get_widget_schema call returned ~27KB, of // which ~25KB was the ten style sections that pagelayer_add_shortcode() // bolts onto all 125 widgets identically (motion_effects alone is 11KB / // 53 props). Sending that per widget re-teaches the model the same // boilerplate every call. Now the widget's OWN sections go out by default // and the shared ones are fetched once via get_common_styles. // ------------------------------------------------------------------ /** * One property rendered as a single compact string instead of an object: * "select|def:left|opts:left,center,right|resp" * "color|req:ele_bg_type=color" * The legend travels once per response (see compact_legend), not per prop, * so the per-property cost drops from ~120 bytes of JSON scaffolding to ~30. */ protected static function compact_prop($prop) { $parts = array(); $parts[] = !empty($prop['type']) ? $prop['type'] : 'text'; if (isset($prop['default']) && $prop['default'] !== '' && $prop['default'] !== null) { $def = is_array($prop['default']) ? 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'])) { // Lists are either value=>label maps or plain value lists; the model // only needs the values it is allowed to send. $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; } // The render-time gate. This one is never dropped, however compact the // output gets: an attribute sent without its companion is silently // discarded and the page renders unstyled with no error. 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'; } return implode('|', $parts); } protected static function compact_legend() { return 'prop format "type|def:X|opts:a,b|min-maxunit|req:attr=val|resp". ' . 'req = companion attr that must ALSO be set explicitly on the same node, else Pagelayer discards this property at render and the page looks unstyled with no error. ' . '"a/b" = any one of those values, "&" = all conditions must hold, and a leading "!" negates ("req:!view=default" means view must not be default). ' . 'resp = also accepts _tablet and _mobile suffixed siblings.'; } /** * Section keys the widget itself declares (its `settings` tab) versus the * ten global style sections shared by every widget. */ protected static function own_section_keys($tag) { global $pagelayer; self::ensure_shortcodes_loaded(); $data = isset($pagelayer->shortcodes[$tag]) ? $pagelayer->shortcodes[$tag] : array(); return isset($data['settings']) && is_array($data['settings']) ? array_keys($data['settings']) : array(); } /** * Compact a full schema for transport. * * $mode 'own' - only the widget's own sections (default, ~95% smaller) * 'all' - own + shared style sections * 'shared' - only the shared style sections * $only - optional explicit list of section keys, overrides $mode. */ public static function compact_widget_schema($schema, $mode = 'own', $only = array()) { $tag = $schema['id']; $own = self::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'])) { // The one genuinely load-bearing quirk of the node format: this // widget's main text goes in the node's "content" field, not attrs. $out['content_attr'] = $schema['innerHTML']; $out['content_note'] = 'Main text goes in the node "content" field, not in attrs.' . $schema['innerHTML'] . '.'; } if (!empty($schema['skip_props'])) { $out['unsupported_props'] = $schema['skip_props']; } $sections = array(); 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) { // _hover variants double the payload and are almost never what a // text/content edit needs; get_widget_schema(sections:[...]) still // surfaces them when explicitly asked for. if ($mode === 'own' && strpos($prop_key, '_hover') !== false) { continue; } $props[$prop_key] = self::compact_prop($prop); } $sections[$key] = $props; } $out['props'] = $sections; if (empty($only) && $mode === 'own') { $shared = array_values(array_diff(array_keys($schema['sections']), $own)); if (!empty($shared)) { $out['shared_style_sections'] = $shared; $out['shared_note'] = 'These ' . count($shared) . ' sections are identical on every widget and are omitted here. Call get_common_styles ONCE per session for them, or get_widget_schema with sections:["ele_bg_styles"] for one of them.'; } } $out['legend'] = self::compact_legend(); return $out; } public static function get_all_widget_schemas() { global $pagelayer; self::ensure_shortcodes_loaded(); $schemas = array(); if (!empty($pagelayer->shortcodes) && is_array($pagelayer->shortcodes)) { foreach ($pagelayer->shortcodes as $tag => $data) { $schemas[$tag] = self::extract_widget_schema($tag, $data); } } return $schemas; } /** * Every attribute name a widget really accepts, plus the render-time * dependency each one is gated behind. * * Pagelayer walks the same sections at render time and does two things that * make bad attrs invisible rather than loud (shortcode_functions.php ~157-245): * 1. an attribute whose name is not in this map is never looked at; * 2. an attribute whose `req` is not satisfied by another EXPLICITLY SET * attribute is unset before any CSS is generated — widget defaults are * NOT merged in first, so e.g. ele_bg_color does nothing unless * ele_bg_type=color travels with it, and btn_bg_color does nothing * unless type=pagelayer-btn-custom travels with it. * Both cases render a perfectly valid-looking page with none of the styling * that was asked for, which is why they are reported as hard errors. * * Returns null for tags that have no registered schema. */ public static function widget_attr_rules($tag) { global $pagelayer; static $cache = array(); // pl_inner_row/pl_inner_col are rendered through the pl_row/pl_col schema. $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]; } self::ensure_shortcodes_loaded(); if (empty($pagelayer->shortcodes[$lookup])) { return $cache[$lookup] = null; } $schema = self::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']; } // Responsive props accept _tablet / _mobile siblings. if (!empty($prop['responsive'])) { $rules['allowed'][$key . '_tablet'] = $rules['allowed'][$key]; $rules['allowed'][$key . '_mobile'] = $rules['allowed'][$key]; } } } return $cache[$lookup] = $rules; } public static function get_widget_schema($widget_id) { global $pagelayer; self::ensure_shortcodes_loaded(); if (!isset($pagelayer->shortcodes[$widget_id])) { return null; } return self::extract_widget_schema($widget_id, $pagelayer->shortcodes[$widget_id]); } /** * Canonical JSON node examples, derived LIVE from each widget's own * registered schema (same source as extract_widget_schema/get_widget_schema) * instead of a hand-maintained list. A hand-written example silently * drifts from the real widget params (e.g. pl_iconbox's real fields are * service_heading/service_text/service_icon_color, not title/desc/icon_color) * and any AI that trusts the wrong field name ends up setting nothing — * the widget then renders its own built-in default text/icon instead. * Deriving examples from the live schema makes that class of bug * impossible and automatically covers every widget, not just a curated few. */ public static function get_widget_examples($widget_id = null) { self::ensure_shortcodes_loaded(); global $pagelayer; $examples = array(); if ($widget_id) { if (isset($pagelayer->shortcodes[$widget_id])) { $examples[$widget_id] = self::build_widget_example($widget_id, $pagelayer->shortcodes[$widget_id]); } return $examples; } if (!empty($pagelayer->shortcodes) && is_array($pagelayer->shortcodes)) { foreach ($pagelayer->shortcodes as $tag => $data) { $examples[$tag] = self::build_widget_example($tag, $data); } } // One verified, hand-checked nesting example — schema extraction only // yields flat single-widget examples, so this is kept separately to // still demonstrate the Row > Column > Widget hierarchy in practice. $examples['_structure_example'] = array( 'tag' => 'pl_row', // ele_bg_type=color is mandatory alongside ele_bg_color, and "$bg" only // resolves if a global color with the key "bg" actually exists — // unknown keys silently fall back to $primary. 'attrs' => array('stretch' => 'full', 'ele_bg_type' => 'color', 'ele_bg_color' => '$primary', 'ele_padding' => '80px,0px,80px,0px'), 'content' => array( array( 'tag' => 'pl_col', 'attrs' => array('col' => 12), 'content' => array( array( 'tag' => 'pl_heading', 'attrs' => array('align' => 'center', 'color' => '$primary'), 'content' => '

Real, on-topic headline for this section

', ), ), ), ), ); return $examples; } /** * Build one widget's example node from its live schema. Content-bearing * fields (text/textarea/editor) get an instructional placeholder rather * than the widget's own built-in default — copying the widget's real * default verbatim would just recreate the "This is Icon Box" problem. */ protected static function build_widget_example($tag, $data) { $schema = self::extract_widget_schema($tag, $data); $inner_key = isset($data['innerHTML']) ? $data['innerHTML'] : ''; // Only the widget's OWN settings sections. The `options` tab holds the ten // global style sections that pagelayer_add_shortcode() bolts onto every // single widget (background, border, font, position, animation, motion, // responsive, attributes, custom CSS) — including them made a single // widget's "example" hundreds of attrs long with every colour prop set to // $primary, which is both unreadable and terrible design advice. $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) { continue; } // Props gated behind a `req` need their companion attr set too, or // Pagelayer discards them at render. Leave them out of the example // rather than modelling a combination that silently does nothing. if (!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] = ''; } 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]); $example['_note'] = 'This widget\'s main text is bound to the "' . $inner_key . '" attr but should be supplied via the node\'s "content" field (it is copied into that attr at render time) — do not also duplicate it as an attrs key.'; } return $example; } // ------------------------------------------------------------------ // Layout normalization & serialization // ------------------------------------------------------------------ // ------------------------------------------------------------------ // Section shorthand // ------------------------------------------------------------------ // // Writing a page out as raw nodes costs the model ~300 tokens per section, // and generating those tokens is where nearly all the wall-clock time of a // site build goes (the PHP side of create_page is ~1.5 ms). A section spec // carries only the content — {"section":"features","heading":"...","items": // [...]} — and PHP expands it here into the same node tree it would have // written by hand, using attribute names verified against the live widget // schemas. Roughly 6-10x fewer output tokens per section, and no chance of // inventing an attribute that fails the quality gate. // // Raw nodes still work exactly as before; the two can be mixed in one page. public static function section_presets() { return array( 'hero' => 'Full-width opening section. {heading*, sub, cta:{text,link}, cta2:{text,link}, image (url, sits in a second column), align:"left"|"center"}', 'features' => 'Icon cards grid. {heading, sub, items*:[{icon:"fas fa-bolt", title, text}], columns:2-4 (default 3)}', 'about' => 'Image + copy split. {heading*, text, image, cta, flip:true to put the image first}', 'stats' => 'Animated counters. {heading, items*:[{number, label, prefix, suffix}]}', 'testimonials' => 'Quote cards. {heading, items*:[{quote, name, role, avatar}]}', 'faq' => 'Accordion. {heading, items*:[{q, a}]}', 'cta' => 'Closing call to action. {heading*, text, cta:{text,link}}', 'team' => 'Photo cards. {heading, items*:[{name, role, text, image}]}', ); } /** * Replaces every {"section": ...} spec in a node list with real nodes, * recursing into container content so a spec nested in a column also works. */ public static function expand_sections($nodes) { if (!is_array($nodes)) { return $nodes; } $out = array(); foreach ($nodes as $node) { if (is_array($node) && !empty($node['section']) && is_string($node['section'])) { foreach (self::expand_section($node) as $expanded) { $out[] = $expanded; } continue; } if (is_array($node) && isset($node['content']) && is_array($node['content'])) { $node['content'] = self::expand_sections($node['content']); } $out[] = $node; } return $out; } protected static function sec_str($spec, $key, $default = '') { return isset($spec[$key]) && is_string($spec[$key]) && $spec[$key] !== '' ? $spec[$key] : $default; } /** * Whether the section sits on a dark background, so text has to invert. * Callers can be explicit with "dark": true|false. */ protected static function sec_is_dark($spec) { if (isset($spec['dark'])) { return !empty($spec['dark']); } $bg = self::sec_str($spec, 'bg'); if ($bg === '') { return !empty($spec['bg_image']); } $colors = json_decode((string) get_option('pagelayer_global_colors', ''), true); return self::bg_is_dark($bg, is_array($colors) ? $colors : array()); } /** * Is this background dark enough that text must invert? * * Kept free of WordPress so it can be exercised directly — see * test-sec-is-dark.php. * * A "$token" MUST be resolved against the live palette before it is judged. * This used to guess from the token NAME, treating only $primary/$secondary * as dark and everything else as light. A site whose palette defined * light_bg as #18181C therefore got white cards, red headings and * theme-default body copy on a near-black band — invisible text, and * silently so, because every attribute involved is schema-valid and the * quality gate has nothing to complain about. * * @param string $bg Hex colour or "$token". * @param array $colors The global_colors palette. * @return bool */ public static function bg_is_dark($bg, $colors = array()) { $bg = trim((string) $bg); if (strpos($bg, '$') === 0) { // Stored palettes are array('title','value'); callers and tests may // pass the flat map instead. Accept both. $resolve = function ($key) use ($colors) { if (!isset($colors[$key])) { return ''; } $entry = $colors[$key]; if (is_array($entry)) { return isset($entry['value']) && is_string($entry['value']) ? $entry['value'] : ''; } return is_string($entry) ? $entry : ''; }; $key = substr($bg, 1); $value = $resolve($key); // An undefined key does not error — Pagelayer resolves it to // primary at render, so judge the colour that will actually paint. if ($value === '') { $value = $resolve('primary'); } if ($value === '') { // No palette to consult: a brand-coloured band, assume dark as // this function always has. return true; } $bg = $value; } if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $bg)) { $hex = ltrim($bg, '#'); if (strlen($hex) === 3) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } $lum = (0.299 * hexdec(substr($hex, 0, 2)) + 0.587 * hexdec(substr($hex, 2, 2)) + 0.114 * hexdec(substr($hex, 4, 2))) / 255; return $lum < 0.55; } return false; } /** * A heading node. Also used for body copy on dark sections: pl_text has no * colour control of its own (its only param is the editor field), so white * paragraph copy has to come from pl_heading, which does have "color", * carrying

markup. */ /** * Heading node. * * Sizing MUST go through heading_typo, not font_size. font_size renders as * `{{element}}{font-size:..}` — the wrapper div only — so the

inside * kept the theme's own h1 rule and rendered at the theme's size: a 54px * headline came out around 90px, overflowed its column and collided with * the paragraph beneath it. heading_typo targets * `.pagelayer-heading-holder` and its children with !important, which is * the element that actually carries the text. * * heading_typo is not a responsive prop (no _tablet/_mobile sibling), and * its !important would beat any wrapper-level override anyway, so the * smaller breakpoints go through ele_css — the sanctioned escape hatch, * used here precisely because no control covers responsive heading type. */ protected static function sec_heading($html, $color, $align = '', $size = '', $weight = '700') { $attrs = array('color' => $color); if ($align !== '') { $attrs['align'] = $align; } if ($size === '') { return array('tag' => 'pl_heading', 'attrs' => $attrs, 'content' => $html); } $px = (int) preg_replace('/[^0-9]/', '', (string) $size); $lh = $px >= 30 ? '1.15' : '1.6'; // Comma-joined, 11 fixed positions: // family,size,style,weight,variant,decoration-line,decoration-style, // line-height(em),text-transform,letter-spacing,word-spacing. // Positions left blank inherit from the theme, which is what we want for // family and transform — only size, weight and leading are ours to set. $attrs['heading_typo'] = implode(',', array('', $px, '', $weight, '', '', '', $lh, '', '', '')); $tablet = self::scale_type($size, 0.78); $mobile = self::scale_type($size, 0.60); $sel = '{{element}} .pagelayer-heading-holder, {{element}} .pagelayer-heading-holder *'; $attrs['ele_css'] = '@media (max-width:780px){' . $sel . '{font-size:' . $tablet . 'px !important}}' . '@media (max-width:480px){' . $sel . '{font-size:' . $mobile . 'px !important}}'; return array('tag' => 'pl_heading', 'attrs' => $attrs, 'content' => $html); } /** * Smaller-screen type size. Scales down but never below a readable floor, * so body copy (17px) stays legible while a 54px display headline drops far * enough to fit a phone. */ protected static function scale_type($size, $factor, $min = 15) { $px = (int) preg_replace('/[^0-9]/', '', (string) $size); if ($px <= 0) { return $size; } return max($min, (int) round($px * $factor)); } protected static function sec_body($text, $dark, $align = '') { $html = (strpos($text, '<') === 0) ? $text : '

' . $text . '

'; // pl_text is the natural widget for body copy but it has no colour and // no alignment control of its own — in the builder those come from the // editor toolbar, i.e. inline CSS, which is exactly what we may not // emit. So centred or on-dark copy is rendered through pl_heading // (which does have color/align) carrying

markup. if ($dark || ($align !== '' && $align !== 'left')) { return self::sec_heading($html, $dark ? '#ffffff' : '$text', $align, '17', '400'); } return array('tag' => 'pl_text', 'attrs' => array('font_size' => '17', 'line_height' => '1.7'), 'content' => $html); } protected static function sec_btn($cta, $dark, $align = '', $secondary = false) { if (!is_array($cta) || empty($cta['text'])) { return null; } $attrs = array( 'text' => (string) $cta['text'], 'link' => isset($cta['link']) ? (string) $cta['link'] : '#', 'type' => 'pagelayer-btn-custom', 'font_weight' => '600', // "size" defaults to pagelayer-btn-large in the widget, but a node // built here carries only the attrs set explicitly — the default is // never merged in, so the button rendered with no size class at all // and came out as a tiny bordered scrap of text. 'size' => 'pagelayer-btn-large', 'font_size' => '16', ); if ($secondary) { $attrs['btn_bg_color'] = 'rgba(0,0,0,0)'; $attrs['btn_color'] = $dark ? '#ffffff' : '$primary'; $attrs['btn_border_type'] = 'solid'; $attrs['btn_border_width'] = '2px,2px,2px,2px'; $attrs['btn_border_color'] = $dark ? '#ffffff' : '$primary'; } else { $attrs['btn_bg_color'] = $dark ? '#ffffff' : '$primary'; $attrs['btn_color'] = $dark ? '$primary' : '#ffffff'; } if ($align !== '') { $attrs['align'] = $align; } return array('tag' => 'pl_btn', 'attrs' => $attrs); } protected static function sec_col($col, $content, $extra = array()) { return array('tag' => 'pl_col', 'attrs' => array_merge(array('col' => $col), $extra), 'content' => $content); } /** * The card treatment for a grid item (feature, testimonial, team member). * * The card visual lives on the COLUMN, and columns are `width: 33.333%` with * `box-sizing: border-box` — so adjacent cards are flush and the row reads as * one continuous slab rather than a set of cards. Margin cannot fix that: it * sits outside the width and tips the row past 100%, wrapping the grid. * * A border does sit inside border-box, so a transparent border of the gap * width plus `background-clip: padding-box` (which stops the background at * the padding edge instead of running under the border) produces a real gap * and cannot disturb the column math. col_gap is not usable for this — it * pads `.pagelayer-col-holder` INSIDE the card, insetting the contents while * leaving the cards themselves touching. */ protected static function sec_card_style($dark, $gap = '12px') { return array( 'ele_bg_type' => 'color', 'ele_bg_color' => $dark ? 'rgba(255,255,255,0.08)' : '#ffffff', 'ele_padding' => '32px,28px,32px,28px', // box_shadow is "x,y,blur,color,spread,inset" split on commas, so an // rgba() colour tears itself apart mid-value and emits // "box-shadow: 0px 8px 24px 23px rgba(15 42" — invalid, dropped, and // the cards had no shadow at all. 8-digit hex carries the alpha // without commas; the renderer converts it via hex8_to_rgba(). 'ele_shadow' => '0,8,24,#0f172a14,0,', 'border_radius' => '10px,10px,10px,10px', 'border_type' => 'solid', 'border_width' => $gap . ',' . $gap . ',' . $gap . ',' . $gap, 'border_color' => 'transparent', 'ele_css' => '{{element}}{background-clip:padding-box}', ); } /** * The section row wrapper: background, generous desktop padding and a * tighter mobile override so a generated page is not a wall of whitespace * on a phone. */ protected static function sec_row($spec, $cols) { $attrs = array( 'stretch' => 'full', 'ele_padding' => self::sec_str($spec, 'padding', '80px,20px,80px,20px'), 'ele_padding_mobile' => self::sec_str($spec, 'padding_mobile', '48px,16px,48px,16px'), ); $bg_image = self::sec_str($spec, 'bg_image'); $bg = self::sec_str($spec, 'bg'); if ($bg_image !== '') { $attrs['ele_bg_type'] = 'image'; $attrs['ele_bg_img'] = $bg_image; if ($bg !== '') { $attrs['ele_bg_overlay_type'] = 'color'; $attrs['ele_bg_overlay_color'] = $bg; } } elseif ($bg !== '') { $attrs['ele_bg_type'] = 'color'; $attrs['ele_bg_color'] = $bg; } if (!empty($spec['anchor'])) { $attrs['ele_id'] = sanitize_title($spec['anchor']); } return array('tag' => 'pl_row', 'attrs' => $attrs, 'content' => $cols); } /** * Section heading + optional sub-heading as a full-width column, so the * item columns below it wrap onto the next flex line. */ protected static function sec_header_col($spec, $dark, $align = 'center') { $heading = self::sec_str($spec, 'heading'); $sub = self::sec_str($spec, 'sub'); if ($heading === '' && $sub === '') { return null; } $content = array(); if ($heading !== '') { $content[] = self::sec_heading('

' . $heading . '

', $dark ? '#ffffff' : '$primary', $align, '38'); } if ($sub !== '') { $content[] = self::sec_body($sub, $dark, $align); } return self::sec_col(12, $content, array('ele_padding' => '0px,0px,32px,0px')); } protected static function sec_items($spec) { return isset($spec['items']) && is_array($spec['items']) ? $spec['items'] : array(); } /** * One section spec -> real nodes. Every attribute used here is checked * against the widget's live schema by the test sweep, so an expanded * section always passes the quality gate. */ public static function expand_section($spec) { $type = strtolower(trim($spec['section'])); $dark = self::sec_is_dark($spec); $cols = array(); switch ($type) { case 'hero': $align = self::sec_str($spec, 'align', self::sec_str($spec, 'image') !== '' ? 'left' : 'center'); $image = self::sec_str($spec, 'image'); $content = array(); $heading = self::sec_str($spec, 'heading'); if ($heading !== '') { $content[] = self::sec_heading('

' . $heading . '

', $dark ? '#ffffff' : '$primary', $align, '54'); } if (self::sec_str($spec, 'sub') !== '') { $content[] = self::sec_body(self::sec_str($spec, 'sub'), $dark, $align); } $btns = array(); $b1 = self::sec_btn(isset($spec['cta']) ? $spec['cta'] : null, $dark, $align); $b2 = self::sec_btn(isset($spec['cta2']) ? $spec['cta2'] : null, $dark, $align, true); if ($b1) { $btns[] = $b1; } if ($b2) { $btns[] = $b2; } if (count($btns) === 2) { // Side by side, each in its own inner column. $content[] = array('tag' => 'pl_inner_row', 'content' => array( array('tag' => 'pl_inner_col', 'attrs' => array('col' => 6), 'content' => array($btns[0])), array('tag' => 'pl_inner_col', 'attrs' => array('col' => 6), 'content' => array($btns[1])), )); } elseif (!empty($btns)) { $content[] = $btns[0]; } if ($image !== '') { $cols[] = self::sec_col(6, $content); $cols[] = self::sec_col(6, array( array('tag' => 'pl_image', 'attrs' => array('id' => $image, 'id-alt' => $heading !== '' ? $heading : 'Hero image', 'align' => 'center')), )); } else { $cols[] = self::sec_col(12, $content); } break; case 'features': $items = self::sec_items($spec); $columns = isset($spec['columns']) ? max(1, min(4, (int) $spec['columns'])) : 3; $width = (int) floor(12 / $columns); $header = self::sec_header_col($spec, $dark); if ($header) { $cols[] = $header; } $item_align = self::sec_str($spec, 'item_align', 'left'); // With a left/right aligned icon the glyph sits inline against // the title, and icon spacing has no default — so the two ran // together with no gap at all. $icon_gap = ($item_align === 'top') ? ',,14px,' : ',14px,,'; foreach ($items as $item) { if (!is_array($item)) { continue; } $cols[] = self::sec_col($width, array(array( 'tag' => 'pl_iconbox', 'attrs' => array( 'service_icon_spacing' => $icon_gap, 'service_icon' => self::sec_str($item, 'icon', 'fas fa-check'), 'service_icon_color' => $dark ? '#ffffff' : '$primary', 'service_heading' => self::sec_str($item, 'title'), // Only the icon was coloured, so on a dark card the // title kept the theme's dark default and was // effectively invisible against it. 'service_heading_color' => $dark ? '#ffffff' : '$primary', 'service_text' => self::sec_str($item, 'text'), 'service_alignment' => $item_align, // The card's body copy has no colour prop of its own // — only the heading and icon do — so on a dark card // it kept the theme's dark default and read as a // barely-visible grey. ele_css is the only route. 'ele_css' => $dark ? '{{element}} .pagelayer-service-text{color:rgba(255,255,255,0.72)}' : '', ), )), self::sec_card_style($dark)); } break; case 'about': $image = self::sec_str($spec, 'image'); $content = array(); if (self::sec_str($spec, 'heading') !== '') { $content[] = self::sec_heading('

' . self::sec_str($spec, 'heading') . '

', $dark ? '#ffffff' : '$primary', 'left', '38'); } if (self::sec_str($spec, 'text') !== '') { $content[] = self::sec_body(self::sec_str($spec, 'text'), $dark, 'left'); } $btn = self::sec_btn(isset($spec['cta']) ? $spec['cta'] : null, $dark, 'left'); if ($btn) { $content[] = $btn; } $text_col = self::sec_col($image !== '' ? 6 : 12, $content); $img_col = $image !== '' ? self::sec_col(6, array( array('tag' => 'pl_image', 'attrs' => array('id' => $image, 'id-alt' => self::sec_str($spec, 'heading', 'About us'), 'align' => 'center')), )) : null; if ($img_col && !empty($spec['flip'])) { $cols[] = $img_col; $cols[] = $text_col; } else { $cols[] = $text_col; if ($img_col) { $cols[] = $img_col; } } break; case 'stats': $items = self::sec_items($spec); $width = (int) floor(12 / max(1, min(4, count($items) ?: 1))); $header = self::sec_header_col($spec, $dark); if ($header) { $cols[] = $header; } foreach ($items as $item) { if (!is_array($item)) { continue; } $attrs = array( // counter_start_number is deliberately NOT set here. The // number block is gated by if="{{counter_start_number}}", // and "0" is falsy — setting it to zero hid the figures // just as completely as omitting it did. The widget's own // default ("1") is truthy and is supplied by // apply_markup_defaults(), which is where markup-critical // params belong. 'counter_end_number' => (string) (isset($item['number']) ? preg_replace('/[^0-9.]/', '', (string) $item['number']) : '0'), 'counter_text' => self::sec_str($item, 'label'), 'counter_align' => 'center', 'counter_text_color' => $dark ? '#ffffff' : '$text', 'counter_number_color' => $dark ? '#ffffff' : '$primary', ); if (self::sec_str($item, 'prefix') !== '') { $attrs['number_prefix'] = self::sec_str($item, 'prefix'); } if (self::sec_str($item, 'suffix') !== '') { $attrs['number_suffix'] = self::sec_str($item, 'suffix'); } $cols[] = self::sec_col($width, array(array('tag' => 'pl_counter', 'attrs' => $attrs))); } break; case 'testimonials': $items = self::sec_items($spec); $width = (int) floor(12 / max(1, min(3, count($items) ?: 1))); $header = self::sec_header_col($spec, $dark); if ($header) { $cols[] = $header; } foreach ($items as $item) { if (!is_array($item)) { continue; } $attrs = array( 'quote_content' => self::sec_str($item, 'quote'), 'cite' => self::sec_str($item, 'name'), 'designation' => self::sec_str($item, 'role'), // Nothing was coloured here at all, so on a dark card // the name and role rendered in the theme's dark default // and disappeared into the background. 'cite_color' => $dark ? '#ffffff' : '$primary', 'designation_color' => $dark ? 'rgba(255,255,255,0.7)' : '$text', // The quote body, like the icon-box text, has no colour // prop — only the cite and designation do. 'ele_css' => $dark ? '{{element}} .pagelayer-testimonial-content{color:rgba(255,255,255,0.72)}' : '', 'image_position' => 'top-position', 'alignment' => 'center', ); if (self::sec_str($item, 'avatar') !== '') { $attrs['avatar'] = self::sec_str($item, 'avatar'); $attrs['img_shape'] = 'circle'; // Without a fixed size the avatar stretches into an oval. $attrs['testimonial_image_size'] = '80'; } $cols[] = self::sec_col($width, array(array('tag' => 'pl_testimonial', 'attrs' => $attrs)), self::sec_card_style($dark)); } break; case 'faq': $items = self::sec_items($spec); $header = self::sec_header_col($spec, $dark); if ($header) { $cols[] = $header; } $acc_items = array(); foreach ($items as $i => $item) { if (!is_array($item)) { continue; } $answer = self::sec_str($item, 'a'); $acc_items[] = array( 'tag' => 'pl_accordion_item', 'attrs' => array( 'title' => self::sec_str($item, 'q'), 'default_active' => $i === 0 ? 'true' : '', ), 'content' => array( array('tag' => 'pl_inner_row', 'content' => array( array('tag' => 'pl_inner_col', 'attrs' => array('col' => 12), 'content' => array( self::sec_body($answer, $dark, 'left'), )), )), ), ); } // An uncoloured accordion on a dark section renders dark question // text on a dark panel — the FAQ was there but unreadable. $acc_attrs = array('acc_space' => '12'); if ($dark) { $acc_attrs['tabs_color'] = '#ffffff'; $acc_attrs['tabs_bg_color'] = 'rgba(255,255,255,0.08)'; $acc_attrs['tabs_active_color'] = '#ffffff'; $acc_attrs['tabs_active_bg_color'] = 'rgba(255,255,255,0.14)'; $acc_attrs['tabs_content_bg_color'] = 'rgba(255,255,255,0.05)'; } $cols[] = self::sec_col(12, array(array( 'tag' => 'pl_accordion', 'attrs' => $acc_attrs, 'content' => $acc_items, ))); break; case 'cta': $content = array(); if (self::sec_str($spec, 'heading') !== '') { $content[] = self::sec_heading('

' . self::sec_str($spec, 'heading') . '

', $dark ? '#ffffff' : '$primary', 'center', '38'); } if (self::sec_str($spec, 'text') !== '') { $content[] = self::sec_body(self::sec_str($spec, 'text'), $dark, 'center'); } $btn = self::sec_btn(isset($spec['cta']) ? $spec['cta'] : null, $dark, 'center'); if ($btn) { $content[] = $btn; } $cols[] = self::sec_col(12, $content); break; case 'team': $items = self::sec_items($spec); $width = (int) floor(12 / max(1, min(4, count($items) ?: 1))); $header = self::sec_header_col($spec, $dark); if ($header) { $cols[] = $header; } foreach ($items as $item) { if (!is_array($item)) { continue; } $attrs = array( 'service_heading' => self::sec_str($item, 'name'), 'service_text' => self::sec_str($item, 'role') . (self::sec_str($item, 'text') !== '' ? ' — ' . self::sec_str($item, 'text') : ''), 'service_alignment' => 'center', // Same absent-colour problem as the feature cards. 'service_heading_color' => $dark ? '#ffffff' : '$primary', 'ele_css' => $dark ? '{{element}} .pagelayer-service-text{color:rgba(255,255,255,0.72)}' : '', ); if (self::sec_str($item, 'image') !== '') { $attrs['service_image'] = self::sec_str($item, 'image'); // Portraits and landscapes sitting in one row render at // their natural aspect ratios, so one card came out twice // the height of its neighbours and the row looked broken. // A fixed height plus object-fit:cover crops them to a // common shape instead of distorting them. $attrs['service_image_height'] = '260'; $attrs['service_image_object_fit'] = 'cover'; } $cols[] = self::sec_col($width, array(array('tag' => 'pl_service', 'attrs' => $attrs)), self::sec_card_style($dark)); } break; default: // Unknown preset: keep it visible as an error the gate will // report, rather than silently dropping the caller's content. return array(array( 'tag' => 'pl_' . preg_replace('/[^a-z0-9_]/', '', $type), 'attrs' => array(), 'content' => '', )); } if (empty($cols)) { return array(); } return array(self::sec_row($spec, $cols)); } public static function normalize_layout_data($data) { if (!is_array($data)) { return $data; } $data = self::expand_sections($data); $normalized = array(); foreach ($data as $node) { if (!is_array($node)) { $normalized[] = $node; continue; } $normalized[] = self::normalize_node($node); } return $normalized; } /** * Move a widget's innerHTML-backed text from attrs into node content. * * Only acts when the node has no usable content of its own, and never on a * container (whose content is an array of child nodes). */ protected static function bridge_inner_html(&$node) { global $pagelayer; $tag = isset($node['tag']) ? $node['tag'] : ''; if ($tag === '') { return; } self::ensure_shortcodes_loaded(); $inner_key = isset($pagelayer->shortcodes[$tag]['innerHTML']) ? $pagelayer->shortcodes[$tag]['innerHTML'] : ''; // The mirror case: a widget with NO innerHTML mapping reads its label // from an attribute and ignores node content completely. pl_btn is the // one that bites — its label span is gated `if="{{text}}"`, so a button // whose caption was written as node content renders as an empty // coloured rectangle. Nothing objects: content is legal on any node and // the missing attr is simply absent. if ($inner_key === '') { if ( isset($node['content']) && is_string($node['content']) && trim($node['content']) !== '' && empty($node['attrs']['text']) ) { $rules = self::widget_attr_rules($tag); if (isset($rules['allowed']['text'])) { $node['attrs']['text'] = trim(wp_strip_all_tags($node['content'])); $node['content'] = ''; } } return; } if (empty($node['attrs'][$inner_key]) || !is_string($node['attrs'][$inner_key])) { return; } // A container's content holds child nodes — never overwrite it. if (isset($node['content']) && is_array($node['content'])) { return; } if (isset($node['content']) && is_string($node['content']) && trim($node['content']) !== '') { // Author supplied content explicitly; drop the duplicate attr so the // two cannot disagree. unset($node['attrs'][$inner_key]); return; } $node['content'] = $node['attrs'][$inner_key]; unset($node['attrs'][$inner_key]); } /** * Add missing CSS units to padding-style attribute values. * * Props of type "padding" (ele_padding, ele_margin, *_border_width, * *_border_radius, icon padding, ...) render through templates that emit the * stored value VERBATIM — "padding-top: {{val[0]}}". A bare number therefore * produces `padding-top: 15`, which is not valid CSS, so the browser drops * every one of those declarations and the element ends up with no padding at * all. * * "80px,20px,80px,20px" and "15,20,15,20" look equally reasonable when * writing JSON, and the second silently does nothing — the attribute name is * real and the numbers are sane, so neither the schema nor the quality gate * has anything to object to. That is how a header ended up with its * navigation jammed against the edge of the viewport. * * Only bare numbers are touched; anything already carrying a unit (px, %, * em, rem, vh, auto, calc(...)) is left exactly as written. */ protected static function add_missing_css_units(&$node) { if (empty($node['tag']) || empty($node['attrs']) || !is_array($node['attrs'])) { return; } $rules = self::widget_attr_rules($node['tag']); if (empty($rules['allowed'])) { return; } foreach ($node['attrs'] as $key => $value) { if (!is_string($value) || $value === '') { continue; } if (!isset($rules['allowed'][$key]) || $rules['allowed'][$key] !== 'padding') { continue; } $parts = explode(',', $value); $changed = false; foreach ($parts as $i => $part) { $part = trim($part); if ($part === '' || !preg_match('/^-?\d+(\.\d+)?$/', $part)) { continue; } // A bare 0 is valid CSS on its own; everything else needs a unit. if ((float) $part === 0.0) { continue; } $parts[$i] = $part . 'px'; $changed = true; } if ($changed) { $node['attrs'][$key] = implode(',', $parts); } } } /** * Supply widget defaults for the params the widget's MARKUP depends on. * * Pagelayer writes a widget's defaults into the node when the editor inserts * it; nothing does that for a node built through the abilities layer, so it * carries only what was set explicitly. Any param the html template * interpolates then renders as the literal token, and any block gated by * if="{{param}}" is dropped outright. Observed consequences: * * pl_wp_menu layout ("horizontal") -> class="pagelayer-menu-type-{{layout}}" * so the nav fell back to a vertical * bulleted