__('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
* pl_counter counter_start_number ("1") -> if="" dropped the whole number
* block, leaving labels with no figures
* pl_iconbox service_icon_view, service_icon_shape_type -> literal
* {{...}} leaked into class names
*
* Scope is deliberately narrow: only params the markup names, and never a
* text-bearing one. Text defaults are placeholder copy ("Counter", "This is
* Icon Box") — writing those would ship filler and trip the quality gate.
* Styling and structural defaults are exactly what we want.
*/
protected static function apply_markup_defaults(&$node) {
global $pagelayer;
static $cache = array();
$tag = isset($node['tag']) ? $node['tag'] : '';
if ($tag === '') {
return;
}
self::ensure_shortcodes_loaded();
if (empty($pagelayer->shortcodes[$tag]['html'])) {
return;
}
if (!isset($cache[$tag])) {
$def = $pagelayer->shortcodes[$tag];
$schema = self::extract_widget_schema($tag, $def);
$props = array();
foreach ($schema['sections'] as $section) {
foreach ($section['properties'] as $key => $prop) {
$props[$key] = $prop;
}
}
// Params named anywhere in the markup, including {{{escaped}}} form.
preg_match_all('/\{\{\{?([a-zA-Z0-9_\-]+)\}?\}\}/', $def['html'], $matches);
$inner = isset($def['innerHTML']) ? $def['innerHTML'] : '';
$fill = array();
foreach (array_unique($matches[1]) as $name) {
if (!isset($props[$name]) || $name === $inner) {
continue;
}
$type = isset($props[$name]['type']) ? $props[$name]['type'] : '';
if (in_array($type, array('text', 'textarea', 'editor'), true)) {
continue; // placeholder copy — never inject it
}
// A param gated behind a companion (`requires`) is discarded at
// render unless that companion is explicitly set, and the
// quality gate treats the orphan as a hard error. Filling its
// default can only ever create that orphan — e.g. pl_iconbox's
// iconbox_button_type, which needs service_button="true" that
// nobody asked for. Leave gated params to the caller.
if (!empty($props[$name]['requires'])) {
continue;
}
$default = isset($props[$name]['default']) ? $props[$name]['default'] : null;
if ($default === null || $default === '' || is_array($default)) {
continue;
}
$fill[$name] = $default;
}
$cache[$tag] = $fill;
}
foreach ($cache[$tag] as $key => $value) {
if (!isset($node['attrs'][$key]) || $node['attrs'][$key] === '') {
$node['attrs'][$key] = $value;
}
}
}
public static function normalize_node($node) {
if (!is_array($node)) {
return $node;
}
// 1. Shorthand Tag Mapping
$tag = isset($node['tag']) ? (string)$node['tag'] : (isset($node['type']) ? (string)$node['type'] : '');
$tag = strtolower(trim($tag));
$tag_map = array(
'container' => 'pl_row',
'section' => 'pl_row',
'row' => 'pl_row',
'pl_section' => 'pl_row',
'pagelayer_section' => 'pl_row',
'column' => 'pl_col',
'col' => 'pl_col',
'pagelayer_col' => 'pl_col',
'heading' => 'pl_heading',
'title' => 'pl_heading',
'text' => 'pl_text',
'paragraph' => 'pl_text',
'button' => 'pl_btn',
'btn' => 'pl_btn',
'image' => 'pl_image',
'img' => 'pl_image',
'iconbox' => 'pl_iconbox',
'icon_box' => 'pl_iconbox',
'accordion' => 'pl_accordion',
'testimonial' => 'pl_testimonial',
);
if (isset($tag_map[$tag])) {
$node['tag'] = $tag_map[$tag];
} elseif (strpos($tag, 'pagelayer_') === 0) {
$node['tag'] = str_replace('pagelayer_', 'pl_', $tag);
} elseif (strpos($tag, 'pl_') !== 0 && !empty($tag)) {
$node['tag'] = 'pl_' . $tag;
}
if (empty($node['tag'])) {
$node['tag'] = isset($node['content']) && is_array($node['content']) ? 'pl_row' : 'pl_text';
}
// Ensure attrs array exists
if (!isset($node['attrs']) || !is_array($node['attrs'])) {
$node['attrs'] = array();
}
// Ensure pagelayer-id exists
if (empty($node['attrs']['pagelayer-id']) && function_exists('pagelayer_create_id')) {
$node['attrs']['pagelayer-id'] = pagelayer_create_id();
}
// Some widgets take their main text from the node's inner content rather
// than from an attribute — the widget declares which param that is via
// 'innerHTML' (pl_iconbox/pl_service => service_text,
// pl_testimonial/pl_quote => quote_content, pl_accordion_item => title).
// Putting that text in attrs is the natural mistake and it fails
// silently: the attribute is a real property so the quality gate accepts
// it, then the renderer reads content, finds nothing, and emits an empty
// element. That is how feature cards shipped with titles but no
// description and testimonials with names but no quote.
//
// Bridge it here, where every caller passes through, so presets and
// hand-written nodes are both fixed. An explicit content value always
// wins — this only fills an empty one.
self::bridge_inner_html($node);
self::apply_markup_defaults($node);
self::add_missing_css_units($node);
// 2. Specific Normalization for Row Nodes
if ($node['tag'] === 'pl_row') {
if (!isset($node['attrs']['stretch'])) {
$node['attrs']['stretch'] = 'full';
}
if (empty($node['attrs']['ele_padding'])) {
$node['attrs']['ele_padding'] = '80px,0px,80px,0px';
}
// Process children inside Row
$raw_children = isset($node['content']) && is_array($node['content']) ? $node['content'] : array();
$normalized_children = array();
// Auto-wrap non-column leaf widgets into Columns
foreach ($raw_children as $child) {
if (!is_array($child)) continue;
$child_tag = isset($child['tag']) ? $child['tag'] : (isset($child['type']) ? $child['type'] : '');
$child_tag = strtolower(trim($child_tag));
if ($child_tag !== 'col' && $child_tag !== 'column' && $child_tag !== 'pl_col' && $child_tag !== 'pagelayer_col') {
// Wrap in column
$child = array(
'tag' => 'pl_col',
'attrs' => array('pagelayer-id' => function_exists('pagelayer_create_id') ? pagelayer_create_id() : ''),
'content' => array($child)
);
}
$normalized_children[] = self::normalize_node($child);
}
// Auto-calculate column grid width (col: 12 / N)
$child_count = count($normalized_children);
if ($child_count > 0) {
$auto_col = max(1, (int) floor(12 / $child_count));
foreach ($normalized_children as &$c_node) {
if (is_array($c_node) && $c_node['tag'] === 'pl_col') {
if (!isset($c_node['attrs']['col']) || empty($c_node['attrs']['col'])) {
$c_node['attrs']['col'] = $auto_col;
}
}
}
unset($c_node);
}
$node['content'] = $normalized_children;
// 3. Specific Normalization for Column Nodes
} elseif ($node['tag'] === 'pl_col') {
if (empty($node['attrs']['col'])) {
$node['attrs']['col'] = 12;
}
if (isset($node['content']) && is_array($node['content'])) {
$norm_content = array();
foreach ($node['content'] as $c) {
$norm_content[] = self::normalize_node($c);
}
$node['content'] = $norm_content;
}
// 4. Default Visual Enhancement Injection for Leaf Widgets.
// Everything injected here must be a REAL attribute of the widget AND
// must satisfy that attribute's `req` dependency, otherwise
// pagelayer_render_shortcode() unsets it before render
// (shortcode_functions.php:191) and the styling silently disappears.
} elseif ($node['tag'] === 'pl_btn') {
// btn_bg_color/btn_color are gated behind req: type must be
// pagelayer-btn-custom or pagelayer-btn-anim. The widget default is
// pagelayer-btn-default, so without this the colors are discarded.
if (empty($node['attrs']['btn_bg_color'])) {
$node['attrs']['btn_bg_color'] = '$primary';
}
if (empty($node['attrs']['btn_color'])) {
$node['attrs']['btn_color'] = '#ffffff';
}
if (empty($node['attrs']['type'])) {
$node['attrs']['type'] = 'pagelayer-btn-custom';
}
// btn_border_radius is itself gated behind btn_border_type != "".
// Injecting the radius alone did nothing at render (Pagelayer drops
// it) AND tripped the quality gate on every page with a button, so
// every such create_page was rejected and regenerated for nothing.
// "solid" with zero width gives the rounded corners with no visible
// border, which is what the radius was here for.
if (empty($node['attrs']['btn_border_radius'])) {
$node['attrs']['btn_border_radius'] = '6px,6px,6px,6px';
if (!isset($node['attrs']['btn_border_type']) || $node['attrs']['btn_border_type'] === '') {
$node['attrs']['btn_border_type'] = 'solid';
}
if (empty($node['attrs']['btn_border_width'])) {
$node['attrs']['btn_border_width'] = '0px,0px,0px,0px';
}
}
if (!isset($node['attrs']['font_weight'])) {
$node['attrs']['font_weight'] = '600';
}
} elseif ($node['tag'] === 'pl_heading') {
if (empty($node['attrs']['color'])) {
$node['attrs']['color'] = '$primary';
}
if (empty($node['attrs']['font_weight'])) {
$node['attrs']['font_weight'] = '700';
}
} elseif ($node['tag'] === 'pl_text') {
// pl_text has no "color" attr of its own — its only param is "text".
// font_size/line_height come from the global font_style section and
// do apply.
if (empty($node['attrs']['font_size'])) {
$node['attrs']['font_size'] = '16';
}
if (empty($node['attrs']['line_height'])) {
$node['attrs']['line_height'] = '1.6';
}
} elseif ($node['tag'] === 'pl_image') {
// pl_image's real image-source attribute is literally named "id"
// (see shortcodes.php pl_image 'params'.'id') — NOT "img". It
// accepts either a full https:// URL or a numeric WP attachment
// ID (resolved via pagelayer_image()). "img" is not a real attr
// on this widget and silently does nothing.
//
// ponytail: deliberately NOT substituting a stock photo when the
// image is unset — the widget's own default-image.png placeholder is
// the honest result, and layout is what we are tuning right now.
// validate_page reports missing images as a warning, not an error.
if (empty($node['attrs']['align'])) {
$node['attrs']['align'] = 'center';
}
}
return $node;
}
public static function serialize_layout_to_blocks($data) {
if (!is_array($data)) {
return '';
}
$prefix = defined('PAGELAYER_BLOCK_PREFIX') ? PAGELAYER_BLOCK_PREFIX : 'wp';
$out = '';
foreach ($data as $node) {
if (!is_array($node) || empty($node['tag'])) {
continue;
}
$tag = $node['tag'];
// Block names MUST keep the widget tag verbatim (underscores and all).
// pagelayer_render_blocks() does substr($block_name, 10) and looks the
// result up directly in $pagelayer->shortcodes with NO dash/underscore
// translation (shortcode_functions.php:47), so "pagelayer/pl-grid_gallery"
// or "pagelayer/pl-grid-gallery" both miss and the widget renders as
// nothing. Core writes "pagelayer/pl_grid_gallery" everywhere (live.php,
// import.php) — match it.
$clean_tag = str_replace('pagelayer_', 'pl_', $tag);
if (strpos($clean_tag, 'pl_') !== 0) {
$clean_tag = 'pl_' . $clean_tag;
}
$attrs = isset($node['attrs']) && is_array($node['attrs']) ? $node['attrs'] : array();
if (empty($attrs['pagelayer-id']) && function_exists('pagelayer_create_id')) {
$attrs['pagelayer-id'] = pagelayer_create_id();
}
$content = isset($node['content']) ? $node['content'] : '';
$block_name = $prefix . ':pagelayer/' . $clean_tag;
$attrs_str = '';
if (!empty($attrs)) {
$attrs_str = ' ' . json_encode($attrs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
$out .= '\n";
if (is_array($content)) {
$out .= self::serialize_layout_to_blocks($content);
} else {
$out .= $content . "\n";
}
$out .= '\n";
}
return $out;
}
public static function get_data_structure_doc() {
return array(
'description' => 'pagelayer_data is a JSON array of element nodes. Each node represents a Row, Column, or Widget. The hierarchy is: Rows contain Columns, Columns contain Widgets (or nested Rows). Every node has "tag", "attrs", and optionally "content".',
'design_consistency' => 'Pagelayer has a built-in design-token system: global_colors and global_fonts. Define the site palette and fonts ONCE, then reference those tokens from every widget using "$" (e.g. "$primary", "$accent") instead of repeating literal hex codes or font stacks.',
'global_reference_syntax' => array(
'color_or_gradient_props' => 'Pass "$" (e.g. "$primary", "$accent") for color/gradient properties to bind to live CSS variables (var(--pagelayer-color-)).',
'typography_props' => 'Pass "$" (e.g. "$primary") for typography properties to inherit global font presets.',
'defined_keys_only' => 'A "$key" that does not exist in global_colors silently resolves to $primary — it does NOT error. Only "primary", "secondary", "text" and "accent" exist by default, so if you want to use "$bg" or "$light_bg" you MUST first define those keys via update_global_styles (or the global_colors argument of create_page/create_website). Otherwise every section background collapses to the same brand color.',
'example' => 'attrs: {"ele_bg_type": "color", "ele_bg_color": "$primary", "color": "$text", "heading_typo": "$secondary"}'
),
'dependent_attributes' => array(
'rule' => 'CRITICAL — many style attributes are gated behind another attribute on the SAME node. At render time Pagelayer deletes a gated attribute if its companion is not explicitly set, and widget defaults do not count. The page still renders, just without your styling and without any error. validate_page and the create/update abilities now report this as a hard error.',
'how_to_check' => 'get_widget_schema returns a "requires" key on every gated property — send every attribute named there, with one of the listed values, on the same node.',
'common_pairs' => array(
'ele_bg_color / ele_bg_gradient / ele_bg_img' => 'require ele_bg_type set to "color" / "gradient" / "image" respectively. A row or column background WILL NOT render without it.',
'btn_bg_color / btn_color' => 'require type = "pagelayer-btn-custom" (or "pagelayer-btn-anim"). The button default is "pagelayer-btn-default", which discards both colors.',
'pl_col col_width' => 'requires col = "" (custom width mode). If you set the 1-12 "col" attr, do not also send col_width.',
),
),
'fast_path_sections' => array(
'rule' => 'DEFAULT TO THIS. A pagelayer_data entry may be a section spec — {"section":"", ...content...} — instead of a hand-written pl_row/pl_col/widget tree. Pagelayer expands it server-side into the same nodes, with correct attribute names, gated companions, spacing, responsive padding and colours already right. It is ~5x fewer tokens to write, it is faster, and it cannot fail the quality gate on invented attributes.',
'presets' => self::section_presets(),
'common_keys' => 'Every preset also accepts: bg (row background — a "$token" or hex), bg_image (url, with bg used as the overlay colour), dark (true|false — forces light text; auto-detected from bg otherwise), padding / padding_mobile ("top,right,bottom,left"), anchor (adds an element id for in-page links).',
'example' => '[{"section":"hero","heading":"Fresh bread, baked at 4am","sub":"Family bakery since 1998.","cta":{"text":"Order online","link":"/order"},"image":"https://...jpg"},{"section":"features","heading":"Why people come back","items":[{"icon":"fas fa-clock","title":"36-hour ferment","text":"Slow rise, easier to digest."}]},{"section":"cta","heading":"Order tonight","cta":{"text":"Start an order","link":"/order"},"bg":"$primary"}]',
'when_to_hand_write' => 'Mix freely: use raw nodes for anything the presets do not cover (galleries, pricing tables, maps, forms, custom layouts) and section specs for the ordinary page furniture. Read the widget schemas only for the parts you hand-write.',
),
'node_format' => array(
'tag' => 'string - The widget shortcode tag, e.g. "pl_row", "pl_col", "pl_heading", "pl_btn", "pl_image", "pl_iconbox", etc.',
'attrs' => 'object - Key-value map of widget attributes matching widget schema. ALL styling lives here.',
'content' => 'array of child nodes for container elements (pl_row, pl_col), or an HTML string for leaf content widgets (pl_heading, pl_text). That HTML is CONTENT MARKUP ONLY — see styling_never_inline.'
),
'styling_never_inline' => array(
'rule' => 'ENFORCED, not bypassable: never write a style="" attribute or a