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

abilitiesregister.php in Page Builder: Pagelayer – Drag and Drop website builder 2.1.8, at main/abilitiesregister.php

6,857 lines 272.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if(!defined('PAGELAYER_VERSION')) {
4 exit('Hacking Attempt !');
5 }
6
7 /**
8 * Pagelayer Abilities Register
9 * PageLayer AI Website Builder v1.0 MCP & WordPress Abilities Engine
10 */
11 class Pagelayer_Abilities_Register {
12
13 public static function init() {
14 if (function_exists('wp_register_ability')) {
15 self::register_categories();
16 self::register_abilities();
17 }
18 }
19
20 public static function register_categories() {
21 if (!function_exists('wp_register_ability_category')) {
22 return;
23 }
24
25 wp_register_ability_category('pagelayer-pages', array(
26 'label' => __('Pagelayer Pages', 'pagelayer'),
27 'description' => __('Create, update, validate, duplicate, and publish pages built with Pagelayer.', 'pagelayer'),
28 ));
29 wp_register_ability_category('pagelayer-posts', array(
30 'label' => __('Pagelayer Posts', 'pagelayer'),
31 'description' => __('Create, update, list, duplicate, and publish individual blog posts built with Pagelayer.', 'pagelayer'),
32 ));
33 wp_register_ability_category('pagelayer-widgets', array(
34 'label' => __('Pagelayer Widgets', 'pagelayer'),
35 'description' => __('Discover widgets, schemas, controls, nesting rules, and example nodes.', 'pagelayer'),
36 ));
37 wp_register_ability_category('pagelayer-global', array(
38 'label' => __('Pagelayer Global Styles & Presets', 'pagelayer'),
39 'description' => __('Manage design systems, global colors/fonts, theme settings, icons, fonts, and presets.', 'pagelayer'),
40 ));
41 wp_register_ability_category('pagelayer-templates', array(
42 'label' => __('Pagelayer Templates', 'pagelayer'),
43 'description' => __('Manage theme builder templates (header, footer, archive, single, search, 404, popup, woocommerce).', 'pagelayer'),
44 ));
45 wp_register_ability_category('pagelayer-menus', array(
46 'label' => __('Pagelayer Navigation Menus', 'pagelayer'),
47 'description' => __('Build the WordPress nav menus that the Primary Menu / Mega Menu widgets render in headers and footers.', 'pagelayer'),
48 ));
49 wp_register_ability_category('pagelayer-media', array(
50 'label' => __('Pagelayer Media', 'pagelayer'),
51 'description' => __('Upload and manage media library assets for Pagelayer layouts.', 'pagelayer'),
52 ));
53 }
54
55 public static function register_abilities() {
56 self::register_widget_abilities();
57 self::register_global_abilities();
58 self::register_template_abilities();
59 self::register_menu_abilities();
60 self::register_pages_abilities();
61 self::register_posts_abilities();
62 self::register_media_abilities();
63 }
64
65 // ------------------------------------------------------------------
66 // Permission callbacks
67 // ------------------------------------------------------------------
68
69 public static function can_edit_posts() {
70 return current_user_can('edit_posts');
71 }
72
73 public static function can_manage_options() {
74 return current_user_can('manage_options');
75 }
76
77 // ------------------------------------------------------------------
78 // Shared helpers
79 // ------------------------------------------------------------------
80
81 protected static function ensure_shortcodes_loaded() {
82 global $pagelayer;
83 if (empty($pagelayer->shortcodes) && function_exists('pagelayer_load_shortcodes')) {
84 pagelayer_load_shortcodes();
85 }
86 }
87
88 /**
89 * Pagelayer stores a global colour/font as array('title' => ..., 'value' =>
90 * ...) — customizer.php emits the CSS custom properties by reading
91 * $entry['value']. An AI client naturally sends the flat map it was asked
92 * for ({"primary": "#E50914"}), and writing that through verbatim made
93 * every --pagelayer-color-* variable render EMPTY, so every "$token" in the
94 * generated site resolved to nothing and the whole palette silently
95 * vanished. Accept both shapes and store the one the renderer reads.
96 */
97 protected static function normalize_global_map($map) {
98 if (!is_array($map)) {
99 return array();
100 }
101
102 $out = array();
103 foreach ($map as $key => $entry) {
104 if (is_array($entry)) {
105 // Already in storage shape (or close enough) — keep it, but make
106 // sure the keys the renderer needs are present.
107 if (!isset($entry['value'])) {
108 continue;
109 }
110 $out[$key] = array(
111 'title' => isset($entry['title']) ? $entry['title'] : ucwords(str_replace('_', ' ', $key)),
112 'value' => $entry['value'],
113 );
114 continue;
115 }
116
117 $out[$key] = array(
118 'title' => ucwords(str_replace('_', ' ', $key)),
119 'value' => $entry,
120 );
121 }
122
123 return $out;
124 }
125
126 public static function maybe_update_global_styles($input) {
127 if (isset($input['global_colors'])) {
128 update_option('pagelayer_global_colors', json_encode(self::normalize_global_map($input['global_colors'])));
129 }
130 if (isset($input['global_fonts'])) {
131 update_option('pagelayer_global_fonts', json_encode(self::normalize_global_map($input['global_fonts'])));
132 }
133 if (isset($input['content_width'])) {
134 update_option('pagelayer_content_width', sanitize_text_field($input['content_width']));
135 }
136 }
137
138 // ------------------------------------------------------------------
139 // Widget Schema Extractor & Examples
140 // ------------------------------------------------------------------
141
142 public static function extract_widget_schema($tag, $data) {
143 global $pagelayer;
144
145 $schema = array(
146 'id' => $tag,
147 'name' => isset($data['name']) ? $data['name'] : $tag,
148 'group' => isset($data['group']) ? $data['group'] : 'misc',
149 'html' => isset($data['html']) ? $data['html'] : '',
150 'holder' => isset($data['holder']) ? $data['holder'] : '',
151 'innerHTML' => isset($data['innerHTML']) ? $data['innerHTML'] : '',
152 'parent' => isset($data['parent']) ? $data['parent'] : array(),
153 'has_group' => isset($data['has_group']) ? $data['has_group'] : array(),
154 'skip_props_cat' => isset($data['skip_props_cat']) ? $data['skip_props_cat'] : array(),
155 'skip_props' => isset($data['skip_props']) ? $data['skip_props'] : array(),
156 'sections' => array(),
157 );
158
159 $settings_tabs = isset($data['settings']) ? $data['settings'] : array();
160 $options = isset($data['options']) ? $data['options'] : array();
161 $section_keys = array();
162
163 if (!empty($pagelayer->tabs) && is_array($pagelayer->tabs)) {
164 foreach ($pagelayer->tabs as $tab) {
165 if (empty($data[$tab]) || !is_array($data[$tab])) {
166 continue;
167 }
168 foreach ($data[$tab] as $section_key => $section_label) {
169 $section_keys[] = $section_key;
170 }
171 }
172 }
173
174 foreach ($section_keys as $section_key) {
175 $props = array();
176 if (isset($data[$section_key]) && is_array($data[$section_key])) {
177 $props = $data[$section_key];
178 } elseif (isset($pagelayer->styles[$section_key]) && is_array($pagelayer->styles[$section_key])) {
179 $props = $pagelayer->styles[$section_key];
180 }
181
182 if (empty($props)) {
183 continue;
184 }
185
186 $clean_props = array();
187 foreach ($props as $prop_key => $prop_def) {
188 if (!is_array($prop_def)) {
189 $clean_props[$prop_key] = array('label' => $prop_def);
190 continue;
191 }
192
193 $clean_prop = array(
194 'type' => isset($prop_def['type']) ? $prop_def['type'] : '',
195 'label' => isset($prop_def['label']) ? $prop_def['label'] : '',
196 'default' => isset($prop_def['default']) ? $prop_def['default'] : null,
197 );
198
199 if (isset($prop_def['list']) && is_array($prop_def['list'])) {
200 $clean_prop['allowed_values'] = $prop_def['list'];
201 }
202 if (isset($prop_def['min'])) $clean_prop['min'] = $prop_def['min'];
203 if (isset($prop_def['max'])) $clean_prop['max'] = $prop_def['max'];
204 if (isset($prop_def['step'])) $clean_prop['step'] = $prop_def['step'];
205 if (isset($prop_def['units'])) $clean_prop['units'] = $prop_def['units'];
206
207 if (isset($prop_def['screen'])) $clean_prop['responsive'] = (bool)$prop_def['screen'];
208 if (isset($prop_def['req'])) $clean_prop['requires'] = $prop_def['req'];
209 if (isset($prop_def['show'])) $clean_prop['show_when'] = $prop_def['show'];
210 if (isset($prop_def['edit'])) $clean_prop['edit_selector'] = $prop_def['edit'];
211 if (isset($prop_def['desc'])) $clean_prop['desc'] = $prop_def['desc'];
212
213 $clean_props[$prop_key] = $clean_prop;
214 }
215
216 $schema['sections'][$section_key] = array(
217 'label' => isset($settings_tabs[$section_key]) ? $settings_tabs[$section_key] : (isset($options[$section_key]) ? $options[$section_key] : ucfirst($section_key)),
218 'properties' => $clean_props,
219 );
220 }
221
222 if (isset($pagelayer->default_params[$tag])) {
223 $schema['default_attrs'] = $pagelayer->default_params[$tag];
224 }
225
226 return $schema;
227 }
228
229 // ------------------------------------------------------------------
230 // Token-compaction layer
231 //
232 // extract_widget_schema() stays full-fidelity because the server-side
233 // quality gate (widget_attr_rules) validates against every section. What
234 // follows only trims the OUTPUT that crosses the wire to the AI client.
235 //
236 // The measured problem: a single get_widget_schema call returned ~27KB, of
237 // which ~25KB was the ten style sections that pagelayer_add_shortcode()
238 // bolts onto all 125 widgets identically (motion_effects alone is 11KB /
239 // 53 props). Sending that per widget re-teaches the model the same
240 // boilerplate every call. Now the widget's OWN sections go out by default
241 // and the shared ones are fetched once via get_common_styles.
242 // ------------------------------------------------------------------
243
244 /**
245 * One property rendered as a single compact string instead of an object:
246 * "select|def:left|opts:left,center,right|resp"
247 * "color|req:ele_bg_type=color"
248 * The legend travels once per response (see compact_legend), not per prop,
249 * so the per-property cost drops from ~120 bytes of JSON scaffolding to ~30.
250 */
251 protected static function compact_prop($prop) {
252 $parts = array();
253 $parts[] = !empty($prop['type']) ? $prop['type'] : 'text';
254
255 if (isset($prop['default']) && $prop['default'] !== '' && $prop['default'] !== null) {
256 $def = is_array($prop['default']) ? json_encode($prop['default']) : (string)$prop['default'];
257 if (strlen($def) > 40) {
258 $def = substr($def, 0, 40) . '';
259 }
260 $parts[] = 'def:' . $def;
261 }
262
263 if (!empty($prop['allowed_values']) && is_array($prop['allowed_values'])) {
264 // Lists are either value=>label maps or plain value lists; the model
265 // only needs the values it is allowed to send.
266 $vals = array_values(array_filter(array_keys($prop['allowed_values']), 'strlen'));
267 if (empty($vals) || $vals === range(0, count($prop['allowed_values']) - 1)) {
268 $vals = array_values($prop['allowed_values']);
269 }
270 $vals = array_map(function($v) { return is_scalar($v) ? (string)$v : ''; }, $vals);
271 $parts[] = 'opts:' . implode(',', array_filter($vals, 'strlen'));
272 }
273
274 if (isset($prop['min']) || isset($prop['max'])) {
275 $range = (isset($prop['min']) ? $prop['min'] : '') . '-' . (isset($prop['max']) ? $prop['max'] : '');
276 if (!empty($prop['units'])) {
277 $units = is_array($prop['units']) ? implode('/', $prop['units']) : $prop['units'];
278 $range .= $units;
279 }
280 $parts[] = $range;
281 }
282
283 // The render-time gate. This one is never dropped, however compact the
284 // output gets: an attribute sent without its companion is silently
285 // discarded and the page renders unstyled with no error.
286 if (!empty($prop['requires']) && is_array($prop['requires'])) {
287 $req = array();
288 foreach ($prop['requires'] as $k => $v) {
289 $req[] = $k . '=' . (is_array($v) ? implode('/', $v) : $v);
290 }
291 $parts[] = 'req:' . implode('&', $req);
292 }
293
294 if (!empty($prop['responsive'])) {
295 $parts[] = 'resp';
296 }
297
298 return implode('|', $parts);
299 }
300
301 protected static function compact_legend() {
302 return 'prop format "type|def:X|opts:a,b|min-maxunit|req:attr=val|resp". '
303 . '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. '
304 . '"a/b" = any one of those values, "&" = all conditions must hold, and a leading "!" negates ("req:!view=default" means view must not be default). '
305 . 'resp = also accepts _tablet and _mobile suffixed siblings.';
306 }
307
308 /**
309 * Section keys the widget itself declares (its `settings` tab) versus the
310 * ten global style sections shared by every widget.
311 */
312 protected static function own_section_keys($tag) {
313 global $pagelayer;
314 self::ensure_shortcodes_loaded();
315 $data = isset($pagelayer->shortcodes[$tag]) ? $pagelayer->shortcodes[$tag] : array();
316 return isset($data['settings']) && is_array($data['settings']) ? array_keys($data['settings']) : array();
317 }
318
319 /**
320 * Compact a full schema for transport.
321 *
322 * $mode 'own' - only the widget's own sections (default, ~95% smaller)
323 * 'all' - own + shared style sections
324 * 'shared' - only the shared style sections
325 * $only - optional explicit list of section keys, overrides $mode.
326 */
327 public static function compact_widget_schema($schema, $mode = 'own', $only = array()) {
328 $tag = $schema['id'];
329 $own = self::own_section_keys($tag);
330 $out = array(
331 'id' => $tag,
332 'name' => $schema['name'],
333 'group' => $schema['group'],
334 );
335
336 if (!empty($schema['parent'])) {
337 $out['must_be_inside'] = $schema['parent'];
338 }
339 if (!empty($schema['holder']) || !empty($schema['has_group'])) {
340 $out['accepts_children'] = true;
341 }
342 if (!empty($schema['innerHTML'])) {
343 // The one genuinely load-bearing quirk of the node format: this
344 // widget's main text goes in the node's "content" field, not attrs.
345 $out['content_attr'] = $schema['innerHTML'];
346 $out['content_note'] = 'Main text goes in the node "content" field, not in attrs.' . $schema['innerHTML'] . '.';
347 }
348 if (!empty($schema['skip_props'])) {
349 $out['unsupported_props'] = $schema['skip_props'];
350 }
351
352 $sections = array();
353 foreach ($schema['sections'] as $key => $section) {
354 $is_own = in_array($key, $own, true);
355
356 if (!empty($only)) {
357 if (!in_array($key, $only, true)) {
358 continue;
359 }
360 } elseif ($mode === 'own' && !$is_own) {
361 continue;
362 } elseif ($mode === 'shared' && $is_own) {
363 continue;
364 }
365
366 $props = array();
367 foreach ($section['properties'] as $prop_key => $prop) {
368 // _hover variants double the payload and are almost never what a
369 // text/content edit needs; get_widget_schema(sections:[...]) still
370 // surfaces them when explicitly asked for.
371 if ($mode === 'own' && strpos($prop_key, '_hover') !== false) {
372 continue;
373 }
374 $props[$prop_key] = self::compact_prop($prop);
375 }
376 $sections[$key] = $props;
377 }
378
379 $out['props'] = $sections;
380
381 if (empty($only) && $mode === 'own') {
382 $shared = array_values(array_diff(array_keys($schema['sections']), $own));
383 if (!empty($shared)) {
384 $out['shared_style_sections'] = $shared;
385 $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.';
386 }
387 }
388
389 $out['legend'] = self::compact_legend();
390
391 return $out;
392 }
393
394 public static function get_all_widget_schemas() {
395 global $pagelayer;
396 self::ensure_shortcodes_loaded();
397
398 $schemas = array();
399 if (!empty($pagelayer->shortcodes) && is_array($pagelayer->shortcodes)) {
400 foreach ($pagelayer->shortcodes as $tag => $data) {
401 $schemas[$tag] = self::extract_widget_schema($tag, $data);
402 }
403 }
404 return $schemas;
405 }
406
407 /**
408 * Every attribute name a widget really accepts, plus the render-time
409 * dependency each one is gated behind.
410 *
411 * Pagelayer walks the same sections at render time and does two things that
412 * make bad attrs invisible rather than loud (shortcode_functions.php ~157-245):
413 * 1. an attribute whose name is not in this map is never looked at;
414 * 2. an attribute whose `req` is not satisfied by another EXPLICITLY SET
415 * attribute is unset before any CSS is generated — widget defaults are
416 * NOT merged in first, so e.g. ele_bg_color does nothing unless
417 * ele_bg_type=color travels with it, and btn_bg_color does nothing
418 * unless type=pagelayer-btn-custom travels with it.
419 * Both cases render a perfectly valid-looking page with none of the styling
420 * that was asked for, which is why they are reported as hard errors.
421 *
422 * Returns null for tags that have no registered schema.
423 */
424 public static function widget_attr_rules($tag) {
425 global $pagelayer;
426 static $cache = array();
427
428 // pl_inner_row/pl_inner_col are rendered through the pl_row/pl_col schema.
429 $lookup = str_replace(array('pl_inner_row', 'pl_inner_col'), array('pl_row', 'pl_col'), $tag);
430
431 if (array_key_exists($lookup, $cache)) {
432 return $cache[$lookup];
433 }
434
435 self::ensure_shortcodes_loaded();
436 if (empty($pagelayer->shortcodes[$lookup])) {
437 return $cache[$lookup] = null;
438 }
439
440 $schema = self::extract_widget_schema($lookup, $pagelayer->shortcodes[$lookup]);
441 $rules = array('allowed' => array(), 'req' => array());
442
443 foreach ($schema['sections'] as $section) {
444 foreach ($section['properties'] as $key => $prop) {
445 $rules['allowed'][$key] = isset($prop['type']) ? $prop['type'] : '';
446
447 if (!empty($prop['requires']) && is_array($prop['requires'])) {
448 $rules['req'][$key] = $prop['requires'];
449 }
450
451 // Responsive props accept _tablet / _mobile siblings.
452 if (!empty($prop['responsive'])) {
453 $rules['allowed'][$key . '_tablet'] = $rules['allowed'][$key];
454 $rules['allowed'][$key . '_mobile'] = $rules['allowed'][$key];
455 }
456 }
457 }
458
459 return $cache[$lookup] = $rules;
460 }
461
462 public static function get_widget_schema($widget_id) {
463 global $pagelayer;
464 self::ensure_shortcodes_loaded();
465
466 if (!isset($pagelayer->shortcodes[$widget_id])) {
467 return null;
468 }
469 return self::extract_widget_schema($widget_id, $pagelayer->shortcodes[$widget_id]);
470 }
471
472 /**
473 * Canonical JSON node examples, derived LIVE from each widget's own
474 * registered schema (same source as extract_widget_schema/get_widget_schema)
475 * instead of a hand-maintained list. A hand-written example silently
476 * drifts from the real widget params (e.g. pl_iconbox's real fields are
477 * service_heading/service_text/service_icon_color, not title/desc/icon_color)
478 * and any AI that trusts the wrong field name ends up setting nothing —
479 * the widget then renders its own built-in default text/icon instead.
480 * Deriving examples from the live schema makes that class of bug
481 * impossible and automatically covers every widget, not just a curated few.
482 */
483 public static function get_widget_examples($widget_id = null) {
484 self::ensure_shortcodes_loaded();
485 global $pagelayer;
486
487 $examples = array();
488
489 if ($widget_id) {
490 if (isset($pagelayer->shortcodes[$widget_id])) {
491 $examples[$widget_id] = self::build_widget_example($widget_id, $pagelayer->shortcodes[$widget_id]);
492 }
493 return $examples;
494 }
495
496 if (!empty($pagelayer->shortcodes) && is_array($pagelayer->shortcodes)) {
497 foreach ($pagelayer->shortcodes as $tag => $data) {
498 $examples[$tag] = self::build_widget_example($tag, $data);
499 }
500 }
501
502 // One verified, hand-checked nesting example — schema extraction only
503 // yields flat single-widget examples, so this is kept separately to
504 // still demonstrate the Row > Column > Widget hierarchy in practice.
505 $examples['_structure_example'] = array(
506 'tag' => 'pl_row',
507 // ele_bg_type=color is mandatory alongside ele_bg_color, and "$bg" only
508 // resolves if a global color with the key "bg" actually exists —
509 // unknown keys silently fall back to $primary.
510 'attrs' => array('stretch' => 'full', 'ele_bg_type' => 'color', 'ele_bg_color' => '$primary', 'ele_padding' => '80px,0px,80px,0px'),
511 'content' => array(
512 array(
513 'tag' => 'pl_col',
514 'attrs' => array('col' => 12),
515 'content' => array(
516 array(
517 'tag' => 'pl_heading',
518 'attrs' => array('align' => 'center', 'color' => '$primary'),
519 'content' => '<h1>Real, on-topic headline for this section</h1>',
520 ),
521 ),
522 ),
523 ),
524 );
525
526 return $examples;
527 }
528
529 /**
530 * Build one widget's example node from its live schema. Content-bearing
531 * fields (text/textarea/editor) get an instructional placeholder rather
532 * than the widget's own built-in default — copying the widget's real
533 * default verbatim would just recreate the "This is Icon Box" problem.
534 */
535 protected static function build_widget_example($tag, $data) {
536 $schema = self::extract_widget_schema($tag, $data);
537 $inner_key = isset($data['innerHTML']) ? $data['innerHTML'] : '';
538
539 // Only the widget's OWN settings sections. The `options` tab holds the ten
540 // global style sections that pagelayer_add_shortcode() bolts onto every
541 // single widget (background, border, font, position, animation, motion,
542 // responsive, attributes, custom CSS) — including them made a single
543 // widget's "example" hundreds of attrs long with every colour prop set to
544 // $primary, which is both unreadable and terrible design advice.
545 $own_sections = isset($data['settings']) && is_array($data['settings']) ? $data['settings'] : array();
546
547 $attrs = array();
548 foreach ($schema['sections'] as $section_key => $section) {
549 if (!isset($own_sections[$section_key])) {
550 continue;
551 }
552 foreach ($section['properties'] as $key => $prop) {
553 $type = isset($prop['type']) ? $prop['type'] : '';
554 if (strpos($key, '_hover') !== false) {
555 continue;
556 }
557
558 // Props gated behind a `req` need their companion attr set too, or
559 // Pagelayer discards them at render. Leave them out of the example
560 // rather than modelling a combination that silently does nothing.
561 if (!empty($prop['requires'])) {
562 continue;
563 }
564
565 if (in_array($type, array('text', 'textarea', 'editor'), true)) {
566 $label = !empty($prop['label']) ? $prop['label'] : $key;
567 $attrs[$key] = '<real, unique, on-topic ' . $label . ' — never leave this as the widget default>';
568 } elseif ($type === 'color') {
569 $attrs[$key] = '$primary';
570 } elseif ($type === 'icon') {
571 $attrs[$key] = !empty($prop['default']) ? $prop['default'] : 'fas fa-star';
572 } elseif ($type === 'image') {
573 $attrs[$key] = '<image URL from search_images, or a WP attachment ID — omit the attr entirely to leave the builder placeholder>';
574 } elseif ($type === 'link') {
575 $attrs[$key] = '#';
576 }
577 }
578 }
579
580 $example = array('tag' => $tag, 'attrs' => $attrs);
581
582 if ($inner_key && isset($attrs[$inner_key])) {
583 $example['content'] = $attrs[$inner_key];
584 unset($example['attrs'][$inner_key]);
585 $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.';
586 }
587
588 return $example;
589 }
590
591 // ------------------------------------------------------------------
592 // Layout normalization & serialization
593 // ------------------------------------------------------------------
594
595 // ------------------------------------------------------------------
596 // Section shorthand
597 // ------------------------------------------------------------------
598 //
599 // Writing a page out as raw nodes costs the model ~300 tokens per section,
600 // and generating those tokens is where nearly all the wall-clock time of a
601 // site build goes (the PHP side of create_page is ~1.5 ms). A section spec
602 // carries only the content — {"section":"features","heading":"...","items":
603 // [...]} — and PHP expands it here into the same node tree it would have
604 // written by hand, using attribute names verified against the live widget
605 // schemas. Roughly 6-10x fewer output tokens per section, and no chance of
606 // inventing an attribute that fails the quality gate.
607 //
608 // Raw nodes still work exactly as before; the two can be mixed in one page.
609
610 public static function section_presets() {
611 return array(
612 'hero' => 'Full-width opening section. {heading*, sub, cta:{text,link}, cta2:{text,link}, image (url, sits in a second column), align:"left"|"center"}',
613 'features' => 'Icon cards grid. {heading, sub, items*:[{icon:"fas fa-bolt", title, text}], columns:2-4 (default 3)}',
614 'about' => 'Image + copy split. {heading*, text, image, cta, flip:true to put the image first}',
615 'stats' => 'Animated counters. {heading, items*:[{number, label, prefix, suffix}]}',
616 'testimonials' => 'Quote cards. {heading, items*:[{quote, name, role, avatar}]}',
617 'faq' => 'Accordion. {heading, items*:[{q, a}]}',
618 'cta' => 'Closing call to action. {heading*, text, cta:{text,link}}',
619 'team' => 'Photo cards. {heading, items*:[{name, role, text, image}]}',
620 );
621 }
622
623 /**
624 * Replaces every {"section": ...} spec in a node list with real nodes,
625 * recursing into container content so a spec nested in a column also works.
626 */
627 public static function expand_sections($nodes) {
628 if (!is_array($nodes)) {
629 return $nodes;
630 }
631
632 $out = array();
633 foreach ($nodes as $node) {
634 if (is_array($node) && !empty($node['section']) && is_string($node['section'])) {
635 foreach (self::expand_section($node) as $expanded) {
636 $out[] = $expanded;
637 }
638 continue;
639 }
640 if (is_array($node) && isset($node['content']) && is_array($node['content'])) {
641 $node['content'] = self::expand_sections($node['content']);
642 }
643 $out[] = $node;
644 }
645
646 return $out;
647 }
648
649 protected static function sec_str($spec, $key, $default = '') {
650 return isset($spec[$key]) && is_string($spec[$key]) && $spec[$key] !== '' ? $spec[$key] : $default;
651 }
652
653 /**
654 * Whether the section sits on a dark background, so text has to invert.
655 * Callers can be explicit with "dark": true|false.
656 */
657 protected static function sec_is_dark($spec) {
658 if (isset($spec['dark'])) {
659 return !empty($spec['dark']);
660 }
661
662 $bg = self::sec_str($spec, 'bg');
663 if ($bg === '') {
664 return !empty($spec['bg_image']);
665 }
666
667 $colors = json_decode((string) get_option('pagelayer_global_colors', ''), true);
668
669 return self::bg_is_dark($bg, is_array($colors) ? $colors : array());
670 }
671
672 /**
673 * Is this background dark enough that text must invert?
674 *
675 * Kept free of WordPress so it can be exercised directly — see
676 * test-sec-is-dark.php.
677 *
678 * A "$token" MUST be resolved against the live palette before it is judged.
679 * This used to guess from the token NAME, treating only $primary/$secondary
680 * as dark and everything else as light. A site whose palette defined
681 * light_bg as #18181C therefore got white cards, red headings and
682 * theme-default body copy on a near-black band — invisible text, and
683 * silently so, because every attribute involved is schema-valid and the
684 * quality gate has nothing to complain about.
685 *
686 * @param string $bg Hex colour or "$token".
687 * @param array<string,string> $colors The global_colors palette.
688 * @return bool
689 */
690 public static function bg_is_dark($bg, $colors = array()) {
691 $bg = trim((string) $bg);
692
693 if (strpos($bg, '$') === 0) {
694 // Stored palettes are array('title','value'); callers and tests may
695 // pass the flat map instead. Accept both.
696 $resolve = function ($key) use ($colors) {
697 if (!isset($colors[$key])) {
698 return '';
699 }
700 $entry = $colors[$key];
701 if (is_array($entry)) {
702 return isset($entry['value']) && is_string($entry['value']) ? $entry['value'] : '';
703 }
704 return is_string($entry) ? $entry : '';
705 };
706
707 $key = substr($bg, 1);
708 $value = $resolve($key);
709
710 // An undefined key does not error — Pagelayer resolves it to
711 // primary at render, so judge the colour that will actually paint.
712 if ($value === '') {
713 $value = $resolve('primary');
714 }
715
716 if ($value === '') {
717 // No palette to consult: a brand-coloured band, assume dark as
718 // this function always has.
719 return true;
720 }
721
722 $bg = $value;
723 }
724
725 if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $bg)) {
726 $hex = ltrim($bg, '#');
727 if (strlen($hex) === 3) {
728 $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
729 }
730 $lum = (0.299 * hexdec(substr($hex, 0, 2)) + 0.587 * hexdec(substr($hex, 2, 2)) + 0.114 * hexdec(substr($hex, 4, 2))) / 255;
731 return $lum < 0.55;
732 }
733
734 return false;
735 }
736
737 /**
738 * A heading node. Also used for body copy on dark sections: pl_text has no
739 * colour control of its own (its only param is the editor field), so white
740 * paragraph copy has to come from pl_heading, which does have "color",
741 * carrying <p> markup.
742 */
743 /**
744 * Heading node.
745 *
746 * Sizing MUST go through heading_typo, not font_size. font_size renders as
747 * `{{element}}{font-size:..}` — the wrapper div only — so the <h1> inside
748 * kept the theme's own h1 rule and rendered at the theme's size: a 54px
749 * headline came out around 90px, overflowed its column and collided with
750 * the paragraph beneath it. heading_typo targets
751 * `.pagelayer-heading-holder` and its children with !important, which is
752 * the element that actually carries the text.
753 *
754 * heading_typo is not a responsive prop (no _tablet/_mobile sibling), and
755 * its !important would beat any wrapper-level override anyway, so the
756 * smaller breakpoints go through ele_css — the sanctioned escape hatch,
757 * used here precisely because no control covers responsive heading type.
758 */
759 protected static function sec_heading($html, $color, $align = '', $size = '', $weight = '700') {
760 $attrs = array('color' => $color);
761 if ($align !== '') {
762 $attrs['align'] = $align;
763 }
764
765 if ($size === '') {
766 return array('tag' => 'pl_heading', 'attrs' => $attrs, 'content' => $html);
767 }
768
769 $px = (int) preg_replace('/[^0-9]/', '', (string) $size);
770 $lh = $px >= 30 ? '1.15' : '1.6';
771
772 // Comma-joined, 11 fixed positions:
773 // family,size,style,weight,variant,decoration-line,decoration-style,
774 // line-height(em),text-transform,letter-spacing,word-spacing.
775 // Positions left blank inherit from the theme, which is what we want for
776 // family and transform — only size, weight and leading are ours to set.
777 $attrs['heading_typo'] = implode(',', array('', $px, '', $weight, '', '', '', $lh, '', '', ''));
778
779 $tablet = self::scale_type($size, 0.78);
780 $mobile = self::scale_type($size, 0.60);
781 $sel = '{{element}} .pagelayer-heading-holder, {{element}} .pagelayer-heading-holder *';
782
783 $attrs['ele_css'] = '@media (max-width:780px){' . $sel . '{font-size:' . $tablet . 'px !important}}'
784 . '@media (max-width:480px){' . $sel . '{font-size:' . $mobile . 'px !important}}';
785
786 return array('tag' => 'pl_heading', 'attrs' => $attrs, 'content' => $html);
787 }
788
789 /**
790 * Smaller-screen type size. Scales down but never below a readable floor,
791 * so body copy (17px) stays legible while a 54px display headline drops far
792 * enough to fit a phone.
793 */
794 protected static function scale_type($size, $factor, $min = 15) {
795 $px = (int) preg_replace('/[^0-9]/', '', (string) $size);
796 if ($px <= 0) {
797 return $size;
798 }
799 return max($min, (int) round($px * $factor));
800 }
801
802 protected static function sec_body($text, $dark, $align = '') {
803 $html = (strpos($text, '<') === 0) ? $text : '<p>' . $text . '</p>';
804
805 // pl_text is the natural widget for body copy but it has no colour and
806 // no alignment control of its own — in the builder those come from the
807 // editor toolbar, i.e. inline CSS, which is exactly what we may not
808 // emit. So centred or on-dark copy is rendered through pl_heading
809 // (which does have color/align) carrying <p> markup.
810 if ($dark || ($align !== '' && $align !== 'left')) {
811 return self::sec_heading($html, $dark ? '#ffffff' : '$text', $align, '17', '400');
812 }
813
814 return array('tag' => 'pl_text', 'attrs' => array('font_size' => '17', 'line_height' => '1.7'), 'content' => $html);
815 }
816
817 protected static function sec_btn($cta, $dark, $align = '', $secondary = false) {
818 if (!is_array($cta) || empty($cta['text'])) {
819 return null;
820 }
821
822 $attrs = array(
823 'text' => (string) $cta['text'],
824 'link' => isset($cta['link']) ? (string) $cta['link'] : '#',
825 'type' => 'pagelayer-btn-custom',
826 'font_weight' => '600',
827 // "size" defaults to pagelayer-btn-large in the widget, but a node
828 // built here carries only the attrs set explicitly — the default is
829 // never merged in, so the button rendered with no size class at all
830 // and came out as a tiny bordered scrap of text.
831 'size' => 'pagelayer-btn-large',
832 'font_size' => '16',
833 );
834
835 if ($secondary) {
836 $attrs['btn_bg_color'] = 'rgba(0,0,0,0)';
837 $attrs['btn_color'] = $dark ? '#ffffff' : '$primary';
838 $attrs['btn_border_type'] = 'solid';
839 $attrs['btn_border_width'] = '2px,2px,2px,2px';
840 $attrs['btn_border_color'] = $dark ? '#ffffff' : '$primary';
841 } else {
842 $attrs['btn_bg_color'] = $dark ? '#ffffff' : '$primary';
843 $attrs['btn_color'] = $dark ? '$primary' : '#ffffff';
844 }
845
846 if ($align !== '') {
847 $attrs['align'] = $align;
848 }
849
850 return array('tag' => 'pl_btn', 'attrs' => $attrs);
851 }
852
853 protected static function sec_col($col, $content, $extra = array()) {
854 return array('tag' => 'pl_col', 'attrs' => array_merge(array('col' => $col), $extra), 'content' => $content);
855 }
856
857 /**
858 * The card treatment for a grid item (feature, testimonial, team member).
859 *
860 * The card visual lives on the COLUMN, and columns are `width: 33.333%` with
861 * `box-sizing: border-box` — so adjacent cards are flush and the row reads as
862 * one continuous slab rather than a set of cards. Margin cannot fix that: it
863 * sits outside the width and tips the row past 100%, wrapping the grid.
864 *
865 * A border does sit inside border-box, so a transparent border of the gap
866 * width plus `background-clip: padding-box` (which stops the background at
867 * the padding edge instead of running under the border) produces a real gap
868 * and cannot disturb the column math. col_gap is not usable for this — it
869 * pads `.pagelayer-col-holder` INSIDE the card, insetting the contents while
870 * leaving the cards themselves touching.
871 */
872 protected static function sec_card_style($dark, $gap = '12px') {
873 return array(
874 'ele_bg_type' => 'color',
875 'ele_bg_color' => $dark ? 'rgba(255,255,255,0.08)' : '#ffffff',
876 'ele_padding' => '32px,28px,32px,28px',
877 // box_shadow is "x,y,blur,color,spread,inset" split on commas, so an
878 // rgba() colour tears itself apart mid-value and emits
879 // "box-shadow: 0px 8px 24px 23px rgba(15 42" — invalid, dropped, and
880 // the cards had no shadow at all. 8-digit hex carries the alpha
881 // without commas; the renderer converts it via hex8_to_rgba().
882 'ele_shadow' => '0,8,24,#0f172a14,0,',
883 'border_radius' => '10px,10px,10px,10px',
884 'border_type' => 'solid',
885 'border_width' => $gap . ',' . $gap . ',' . $gap . ',' . $gap,
886 'border_color' => 'transparent',
887 'ele_css' => '{{element}}{background-clip:padding-box}',
888 );
889 }
890
891 /**
892 * The section row wrapper: background, generous desktop padding and a
893 * tighter mobile override so a generated page is not a wall of whitespace
894 * on a phone.
895 */
896 protected static function sec_row($spec, $cols) {
897 $attrs = array(
898 'stretch' => 'full',
899 'ele_padding' => self::sec_str($spec, 'padding', '80px,20px,80px,20px'),
900 'ele_padding_mobile' => self::sec_str($spec, 'padding_mobile', '48px,16px,48px,16px'),
901 );
902
903 $bg_image = self::sec_str($spec, 'bg_image');
904 $bg = self::sec_str($spec, 'bg');
905
906 if ($bg_image !== '') {
907 $attrs['ele_bg_type'] = 'image';
908 $attrs['ele_bg_img'] = $bg_image;
909 if ($bg !== '') {
910 $attrs['ele_bg_overlay_type'] = 'color';
911 $attrs['ele_bg_overlay_color'] = $bg;
912 }
913 } elseif ($bg !== '') {
914 $attrs['ele_bg_type'] = 'color';
915 $attrs['ele_bg_color'] = $bg;
916 }
917
918 if (!empty($spec['anchor'])) {
919 $attrs['ele_id'] = sanitize_title($spec['anchor']);
920 }
921
922 return array('tag' => 'pl_row', 'attrs' => $attrs, 'content' => $cols);
923 }
924
925 /**
926 * Section heading + optional sub-heading as a full-width column, so the
927 * item columns below it wrap onto the next flex line.
928 */
929 protected static function sec_header_col($spec, $dark, $align = 'center') {
930 $heading = self::sec_str($spec, 'heading');
931 $sub = self::sec_str($spec, 'sub');
932
933 if ($heading === '' && $sub === '') {
934 return null;
935 }
936
937 $content = array();
938 if ($heading !== '') {
939 $content[] = self::sec_heading('<h2>' . $heading . '</h2>', $dark ? '#ffffff' : '$primary', $align, '38');
940 }
941 if ($sub !== '') {
942 $content[] = self::sec_body($sub, $dark, $align);
943 }
944
945 return self::sec_col(12, $content, array('ele_padding' => '0px,0px,32px,0px'));
946 }
947
948 protected static function sec_items($spec) {
949 return isset($spec['items']) && is_array($spec['items']) ? $spec['items'] : array();
950 }
951
952 /**
953 * One section spec -> real nodes. Every attribute used here is checked
954 * against the widget's live schema by the test sweep, so an expanded
955 * section always passes the quality gate.
956 */
957 public static function expand_section($spec) {
958 $type = strtolower(trim($spec['section']));
959 $dark = self::sec_is_dark($spec);
960 $cols = array();
961
962 switch ($type) {
963 case 'hero':
964 $align = self::sec_str($spec, 'align', self::sec_str($spec, 'image') !== '' ? 'left' : 'center');
965 $image = self::sec_str($spec, 'image');
966 $content = array();
967
968 $heading = self::sec_str($spec, 'heading');
969 if ($heading !== '') {
970 $content[] = self::sec_heading('<h1>' . $heading . '</h1>', $dark ? '#ffffff' : '$primary', $align, '54');
971 }
972 if (self::sec_str($spec, 'sub') !== '') {
973 $content[] = self::sec_body(self::sec_str($spec, 'sub'), $dark, $align);
974 }
975
976 $btns = array();
977 $b1 = self::sec_btn(isset($spec['cta']) ? $spec['cta'] : null, $dark, $align);
978 $b2 = self::sec_btn(isset($spec['cta2']) ? $spec['cta2'] : null, $dark, $align, true);
979 if ($b1) { $btns[] = $b1; }
980 if ($b2) { $btns[] = $b2; }
981
982 if (count($btns) === 2) {
983 // Side by side, each in its own inner column.
984 $content[] = array('tag' => 'pl_inner_row', 'content' => array(
985 array('tag' => 'pl_inner_col', 'attrs' => array('col' => 6), 'content' => array($btns[0])),
986 array('tag' => 'pl_inner_col', 'attrs' => array('col' => 6), 'content' => array($btns[1])),
987 ));
988 } elseif (!empty($btns)) {
989 $content[] = $btns[0];
990 }
991
992 if ($image !== '') {
993 $cols[] = self::sec_col(6, $content);
994 $cols[] = self::sec_col(6, array(
995 array('tag' => 'pl_image', 'attrs' => array('id' => $image, 'id-alt' => $heading !== '' ? $heading : 'Hero image', 'align' => 'center')),
996 ));
997 } else {
998 $cols[] = self::sec_col(12, $content);
999 }
1000 break;
1001
1002 case 'features':
1003 $items = self::sec_items($spec);
1004 $columns = isset($spec['columns']) ? max(1, min(4, (int) $spec['columns'])) : 3;
1005 $width = (int) floor(12 / $columns);
1006 $header = self::sec_header_col($spec, $dark);
1007 if ($header) { $cols[] = $header; }
1008
1009 $item_align = self::sec_str($spec, 'item_align', 'left');
1010 // With a left/right aligned icon the glyph sits inline against
1011 // the title, and icon spacing has no default — so the two ran
1012 // together with no gap at all.
1013 $icon_gap = ($item_align === 'top') ? ',,14px,' : ',14px,,';
1014
1015 foreach ($items as $item) {
1016 if (!is_array($item)) { continue; }
1017 $cols[] = self::sec_col($width, array(array(
1018 'tag' => 'pl_iconbox',
1019 'attrs' => array(
1020 'service_icon_spacing' => $icon_gap,
1021 'service_icon' => self::sec_str($item, 'icon', 'fas fa-check'),
1022 'service_icon_color' => $dark ? '#ffffff' : '$primary',
1023 'service_heading' => self::sec_str($item, 'title'),
1024 // Only the icon was coloured, so on a dark card the
1025 // title kept the theme's dark default and was
1026 // effectively invisible against it.
1027 'service_heading_color' => $dark ? '#ffffff' : '$primary',
1028 'service_text' => self::sec_str($item, 'text'),
1029 'service_alignment' => $item_align,
1030 // The card's body copy has no colour prop of its own
1031 // — only the heading and icon do — so on a dark card
1032 // it kept the theme's dark default and read as a
1033 // barely-visible grey. ele_css is the only route.
1034 'ele_css' => $dark ? '{{element}} .pagelayer-service-text{color:rgba(255,255,255,0.72)}' : '',
1035 ),
1036 )), self::sec_card_style($dark));
1037 }
1038 break;
1039
1040 case 'about':
1041 $image = self::sec_str($spec, 'image');
1042 $content = array();
1043 if (self::sec_str($spec, 'heading') !== '') {
1044 $content[] = self::sec_heading('<h2>' . self::sec_str($spec, 'heading') . '</h2>', $dark ? '#ffffff' : '$primary', 'left', '38');
1045 }
1046 if (self::sec_str($spec, 'text') !== '') {
1047 $content[] = self::sec_body(self::sec_str($spec, 'text'), $dark, 'left');
1048 }
1049 $btn = self::sec_btn(isset($spec['cta']) ? $spec['cta'] : null, $dark, 'left');
1050 if ($btn) { $content[] = $btn; }
1051
1052 $text_col = self::sec_col($image !== '' ? 6 : 12, $content);
1053 $img_col = $image !== '' ? self::sec_col(6, array(
1054 array('tag' => 'pl_image', 'attrs' => array('id' => $image, 'id-alt' => self::sec_str($spec, 'heading', 'About us'), 'align' => 'center')),
1055 )) : null;
1056
1057 if ($img_col && !empty($spec['flip'])) {
1058 $cols[] = $img_col;
1059 $cols[] = $text_col;
1060 } else {
1061 $cols[] = $text_col;
1062 if ($img_col) { $cols[] = $img_col; }
1063 }
1064 break;
1065
1066 case 'stats':
1067 $items = self::sec_items($spec);
1068 $width = (int) floor(12 / max(1, min(4, count($items) ?: 1)));
1069 $header = self::sec_header_col($spec, $dark);
1070 if ($header) { $cols[] = $header; }
1071
1072 foreach ($items as $item) {
1073 if (!is_array($item)) { continue; }
1074 $attrs = array(
1075 // counter_start_number is deliberately NOT set here. The
1076 // number block is gated by if="{{counter_start_number}}",
1077 // and "0" is falsy — setting it to zero hid the figures
1078 // just as completely as omitting it did. The widget's own
1079 // default ("1") is truthy and is supplied by
1080 // apply_markup_defaults(), which is where markup-critical
1081 // params belong.
1082 'counter_end_number' => (string) (isset($item['number']) ? preg_replace('/[^0-9.]/', '', (string) $item['number']) : '0'),
1083 'counter_text' => self::sec_str($item, 'label'),
1084 'counter_align' => 'center',
1085 'counter_text_color' => $dark ? '#ffffff' : '$text',
1086 'counter_number_color' => $dark ? '#ffffff' : '$primary',
1087 );
1088 if (self::sec_str($item, 'prefix') !== '') { $attrs['number_prefix'] = self::sec_str($item, 'prefix'); }
1089 if (self::sec_str($item, 'suffix') !== '') { $attrs['number_suffix'] = self::sec_str($item, 'suffix'); }
1090 $cols[] = self::sec_col($width, array(array('tag' => 'pl_counter', 'attrs' => $attrs)));
1091 }
1092 break;
1093
1094 case 'testimonials':
1095 $items = self::sec_items($spec);
1096 $width = (int) floor(12 / max(1, min(3, count($items) ?: 1)));
1097 $header = self::sec_header_col($spec, $dark);
1098 if ($header) { $cols[] = $header; }
1099
1100 foreach ($items as $item) {
1101 if (!is_array($item)) { continue; }
1102 $attrs = array(
1103 'quote_content' => self::sec_str($item, 'quote'),
1104 'cite' => self::sec_str($item, 'name'),
1105 'designation' => self::sec_str($item, 'role'),
1106 // Nothing was coloured here at all, so on a dark card
1107 // the name and role rendered in the theme's dark default
1108 // and disappeared into the background.
1109 'cite_color' => $dark ? '#ffffff' : '$primary',
1110 'designation_color' => $dark ? 'rgba(255,255,255,0.7)' : '$text',
1111 // The quote body, like the icon-box text, has no colour
1112 // prop — only the cite and designation do.
1113 'ele_css' => $dark ? '{{element}} .pagelayer-testimonial-content{color:rgba(255,255,255,0.72)}' : '',
1114 'image_position' => 'top-position',
1115 'alignment' => 'center',
1116 );
1117 if (self::sec_str($item, 'avatar') !== '') {
1118 $attrs['avatar'] = self::sec_str($item, 'avatar');
1119 $attrs['img_shape'] = 'circle';
1120 // Without a fixed size the avatar stretches into an oval.
1121 $attrs['testimonial_image_size'] = '80';
1122 }
1123 $cols[] = self::sec_col($width, array(array('tag' => 'pl_testimonial', 'attrs' => $attrs)), self::sec_card_style($dark));
1124 }
1125 break;
1126
1127 case 'faq':
1128 $items = self::sec_items($spec);
1129 $header = self::sec_header_col($spec, $dark);
1130 if ($header) { $cols[] = $header; }
1131
1132 $acc_items = array();
1133 foreach ($items as $i => $item) {
1134 if (!is_array($item)) { continue; }
1135 $answer = self::sec_str($item, 'a');
1136 $acc_items[] = array(
1137 'tag' => 'pl_accordion_item',
1138 'attrs' => array(
1139 'title' => self::sec_str($item, 'q'),
1140 'default_active' => $i === 0 ? 'true' : '',
1141 ),
1142 'content' => array(
1143 array('tag' => 'pl_inner_row', 'content' => array(
1144 array('tag' => 'pl_inner_col', 'attrs' => array('col' => 12), 'content' => array(
1145 self::sec_body($answer, $dark, 'left'),
1146 )),
1147 )),
1148 ),
1149 );
1150 }
1151
1152 // An uncoloured accordion on a dark section renders dark question
1153 // text on a dark panel — the FAQ was there but unreadable.
1154 $acc_attrs = array('acc_space' => '12');
1155 if ($dark) {
1156 $acc_attrs['tabs_color'] = '#ffffff';
1157 $acc_attrs['tabs_bg_color'] = 'rgba(255,255,255,0.08)';
1158 $acc_attrs['tabs_active_color'] = '#ffffff';
1159 $acc_attrs['tabs_active_bg_color'] = 'rgba(255,255,255,0.14)';
1160 $acc_attrs['tabs_content_bg_color'] = 'rgba(255,255,255,0.05)';
1161 }
1162
1163 $cols[] = self::sec_col(12, array(array(
1164 'tag' => 'pl_accordion',
1165 'attrs' => $acc_attrs,
1166 'content' => $acc_items,
1167 )));
1168 break;
1169
1170 case 'cta':
1171 $content = array();
1172 if (self::sec_str($spec, 'heading') !== '') {
1173 $content[] = self::sec_heading('<h2>' . self::sec_str($spec, 'heading') . '</h2>', $dark ? '#ffffff' : '$primary', 'center', '38');
1174 }
1175 if (self::sec_str($spec, 'text') !== '') {
1176 $content[] = self::sec_body(self::sec_str($spec, 'text'), $dark, 'center');
1177 }
1178 $btn = self::sec_btn(isset($spec['cta']) ? $spec['cta'] : null, $dark, 'center');
1179 if ($btn) { $content[] = $btn; }
1180 $cols[] = self::sec_col(12, $content);
1181 break;
1182
1183 case 'team':
1184 $items = self::sec_items($spec);
1185 $width = (int) floor(12 / max(1, min(4, count($items) ?: 1)));
1186 $header = self::sec_header_col($spec, $dark);
1187 if ($header) { $cols[] = $header; }
1188
1189 foreach ($items as $item) {
1190 if (!is_array($item)) { continue; }
1191 $attrs = array(
1192 'service_heading' => self::sec_str($item, 'name'),
1193 'service_text' => self::sec_str($item, 'role') . (self::sec_str($item, 'text') !== '' ? '' . self::sec_str($item, 'text') : ''),
1194 'service_alignment' => 'center',
1195 // Same absent-colour problem as the feature cards.
1196 'service_heading_color' => $dark ? '#ffffff' : '$primary',
1197 'ele_css' => $dark ? '{{element}} .pagelayer-service-text{color:rgba(255,255,255,0.72)}' : '',
1198 );
1199 if (self::sec_str($item, 'image') !== '') {
1200 $attrs['service_image'] = self::sec_str($item, 'image');
1201 // Portraits and landscapes sitting in one row render at
1202 // their natural aspect ratios, so one card came out twice
1203 // the height of its neighbours and the row looked broken.
1204 // A fixed height plus object-fit:cover crops them to a
1205 // common shape instead of distorting them.
1206 $attrs['service_image_height'] = '260';
1207 $attrs['service_image_object_fit'] = 'cover';
1208 }
1209 $cols[] = self::sec_col($width, array(array('tag' => 'pl_service', 'attrs' => $attrs)), self::sec_card_style($dark));
1210 }
1211 break;
1212
1213 default:
1214 // Unknown preset: keep it visible as an error the gate will
1215 // report, rather than silently dropping the caller's content.
1216 return array(array(
1217 'tag' => 'pl_' . preg_replace('/[^a-z0-9_]/', '', $type),
1218 'attrs' => array(),
1219 'content' => '',
1220 ));
1221 }
1222
1223 if (empty($cols)) {
1224 return array();
1225 }
1226
1227 return array(self::sec_row($spec, $cols));
1228 }
1229
1230 public static function normalize_layout_data($data) {
1231 if (!is_array($data)) {
1232 return $data;
1233 }
1234 $data = self::expand_sections($data);
1235 $normalized = array();
1236 foreach ($data as $node) {
1237 if (!is_array($node)) {
1238 $normalized[] = $node;
1239 continue;
1240 }
1241 $normalized[] = self::normalize_node($node);
1242 }
1243
1244 return $normalized;
1245 }
1246
1247 /**
1248 * Move a widget's innerHTML-backed text from attrs into node content.
1249 *
1250 * Only acts when the node has no usable content of its own, and never on a
1251 * container (whose content is an array of child nodes).
1252 */
1253 protected static function bridge_inner_html(&$node) {
1254 global $pagelayer;
1255
1256 $tag = isset($node['tag']) ? $node['tag'] : '';
1257 if ($tag === '') {
1258 return;
1259 }
1260
1261 self::ensure_shortcodes_loaded();
1262 $inner_key = isset($pagelayer->shortcodes[$tag]['innerHTML']) ? $pagelayer->shortcodes[$tag]['innerHTML'] : '';
1263
1264 // The mirror case: a widget with NO innerHTML mapping reads its label
1265 // from an attribute and ignores node content completely. pl_btn is the
1266 // one that bites — its label span is gated `if="{{text}}"`, so a button
1267 // whose caption was written as node content renders as an empty
1268 // coloured rectangle. Nothing objects: content is legal on any node and
1269 // the missing attr is simply absent.
1270 if ($inner_key === '') {
1271 if (
1272 isset($node['content']) && is_string($node['content']) && trim($node['content']) !== ''
1273 && empty($node['attrs']['text'])
1274 ) {
1275 $rules = self::widget_attr_rules($tag);
1276 if (isset($rules['allowed']['text'])) {
1277 $node['attrs']['text'] = trim(wp_strip_all_tags($node['content']));
1278 $node['content'] = '';
1279 }
1280 }
1281 return;
1282 }
1283
1284 if (empty($node['attrs'][$inner_key]) || !is_string($node['attrs'][$inner_key])) {
1285 return;
1286 }
1287
1288 // A container's content holds child nodes — never overwrite it.
1289 if (isset($node['content']) && is_array($node['content'])) {
1290 return;
1291 }
1292
1293 if (isset($node['content']) && is_string($node['content']) && trim($node['content']) !== '') {
1294 // Author supplied content explicitly; drop the duplicate attr so the
1295 // two cannot disagree.
1296 unset($node['attrs'][$inner_key]);
1297 return;
1298 }
1299
1300 $node['content'] = $node['attrs'][$inner_key];
1301 unset($node['attrs'][$inner_key]);
1302 }
1303
1304 /**
1305 * Add missing CSS units to padding-style attribute values.
1306 *
1307 * Props of type "padding" (ele_padding, ele_margin, *_border_width,
1308 * *_border_radius, icon padding, ...) render through templates that emit the
1309 * stored value VERBATIM — "padding-top: {{val[0]}}". A bare number therefore
1310 * produces `padding-top: 15`, which is not valid CSS, so the browser drops
1311 * every one of those declarations and the element ends up with no padding at
1312 * all.
1313 *
1314 * "80px,20px,80px,20px" and "15,20,15,20" look equally reasonable when
1315 * writing JSON, and the second silently does nothing — the attribute name is
1316 * real and the numbers are sane, so neither the schema nor the quality gate
1317 * has anything to object to. That is how a header ended up with its
1318 * navigation jammed against the edge of the viewport.
1319 *
1320 * Only bare numbers are touched; anything already carrying a unit (px, %,
1321 * em, rem, vh, auto, calc(...)) is left exactly as written.
1322 */
1323 protected static function add_missing_css_units(&$node) {
1324 if (empty($node['tag']) || empty($node['attrs']) || !is_array($node['attrs'])) {
1325 return;
1326 }
1327
1328 $rules = self::widget_attr_rules($node['tag']);
1329 if (empty($rules['allowed'])) {
1330 return;
1331 }
1332
1333 foreach ($node['attrs'] as $key => $value) {
1334 if (!is_string($value) || $value === '') {
1335 continue;
1336 }
1337 if (!isset($rules['allowed'][$key]) || $rules['allowed'][$key] !== 'padding') {
1338 continue;
1339 }
1340
1341 $parts = explode(',', $value);
1342 $changed = false;
1343 foreach ($parts as $i => $part) {
1344 $part = trim($part);
1345 if ($part === '' || !preg_match('/^-?\d+(\.\d+)?$/', $part)) {
1346 continue;
1347 }
1348 // A bare 0 is valid CSS on its own; everything else needs a unit.
1349 if ((float) $part === 0.0) {
1350 continue;
1351 }
1352 $parts[$i] = $part . 'px';
1353 $changed = true;
1354 }
1355
1356 if ($changed) {
1357 $node['attrs'][$key] = implode(',', $parts);
1358 }
1359 }
1360 }
1361
1362 /**
1363 * Supply widget defaults for the params the widget's MARKUP depends on.
1364 *
1365 * Pagelayer writes a widget's defaults into the node when the editor inserts
1366 * it; nothing does that for a node built through the abilities layer, so it
1367 * carries only what was set explicitly. Any param the html template
1368 * interpolates then renders as the literal token, and any block gated by
1369 * if="{{param}}" is dropped outright. Observed consequences:
1370 *
1371 * pl_wp_menu layout ("horizontal") -> class="pagelayer-menu-type-{{layout}}"
1372 * so the nav fell back to a vertical
1373 * bulleted <ul>
1374 * pl_counter counter_start_number ("1") -> if="" dropped the whole number
1375 * block, leaving labels with no figures
1376 * pl_iconbox service_icon_view, service_icon_shape_type -> literal
1377 * {{...}} leaked into class names
1378 *
1379 * Scope is deliberately narrow: only params the markup names, and never a
1380 * text-bearing one. Text defaults are placeholder copy ("Counter", "This is
1381 * Icon Box") — writing those would ship filler and trip the quality gate.
1382 * Styling and structural defaults are exactly what we want.
1383 */
1384 protected static function apply_markup_defaults(&$node) {
1385 global $pagelayer;
1386 static $cache = array();
1387
1388 $tag = isset($node['tag']) ? $node['tag'] : '';
1389 if ($tag === '') {
1390 return;
1391 }
1392
1393 self::ensure_shortcodes_loaded();
1394 if (empty($pagelayer->shortcodes[$tag]['html'])) {
1395 return;
1396 }
1397
1398 if (!isset($cache[$tag])) {
1399 $def = $pagelayer->shortcodes[$tag];
1400 $schema = self::extract_widget_schema($tag, $def);
1401
1402 $props = array();
1403 foreach ($schema['sections'] as $section) {
1404 foreach ($section['properties'] as $key => $prop) {
1405 $props[$key] = $prop;
1406 }
1407 }
1408
1409 // Params named anywhere in the markup, including {{{escaped}}} form.
1410 preg_match_all('/\{\{\{?([a-zA-Z0-9_\-]+)\}?\}\}/', $def['html'], $matches);
1411
1412 $inner = isset($def['innerHTML']) ? $def['innerHTML'] : '';
1413 $fill = array();
1414
1415 foreach (array_unique($matches[1]) as $name) {
1416 if (!isset($props[$name]) || $name === $inner) {
1417 continue;
1418 }
1419 $type = isset($props[$name]['type']) ? $props[$name]['type'] : '';
1420 if (in_array($type, array('text', 'textarea', 'editor'), true)) {
1421 continue; // placeholder copy — never inject it
1422 }
1423
1424 // A param gated behind a companion (`requires`) is discarded at
1425 // render unless that companion is explicitly set, and the
1426 // quality gate treats the orphan as a hard error. Filling its
1427 // default can only ever create that orphan — e.g. pl_iconbox's
1428 // iconbox_button_type, which needs service_button="true" that
1429 // nobody asked for. Leave gated params to the caller.
1430 if (!empty($props[$name]['requires'])) {
1431 continue;
1432 }
1433 $default = isset($props[$name]['default']) ? $props[$name]['default'] : null;
1434 if ($default === null || $default === '' || is_array($default)) {
1435 continue;
1436 }
1437 $fill[$name] = $default;
1438 }
1439
1440 $cache[$tag] = $fill;
1441 }
1442
1443 foreach ($cache[$tag] as $key => $value) {
1444 if (!isset($node['attrs'][$key]) || $node['attrs'][$key] === '') {
1445 $node['attrs'][$key] = $value;
1446 }
1447 }
1448 }
1449
1450 public static function normalize_node($node) {
1451 if (!is_array($node)) {
1452 return $node;
1453 }
1454
1455 // 1. Shorthand Tag Mapping
1456 $tag = isset($node['tag']) ? (string)$node['tag'] : (isset($node['type']) ? (string)$node['type'] : '');
1457 $tag = strtolower(trim($tag));
1458
1459 $tag_map = array(
1460 'container' => 'pl_row',
1461 'section' => 'pl_row',
1462 'row' => 'pl_row',
1463 'pl_section' => 'pl_row',
1464 'pagelayer_section' => 'pl_row',
1465 'column' => 'pl_col',
1466 'col' => 'pl_col',
1467 'pagelayer_col' => 'pl_col',
1468 'heading' => 'pl_heading',
1469 'title' => 'pl_heading',
1470 'text' => 'pl_text',
1471 'paragraph' => 'pl_text',
1472 'button' => 'pl_btn',
1473 'btn' => 'pl_btn',
1474 'image' => 'pl_image',
1475 'img' => 'pl_image',
1476 'iconbox' => 'pl_iconbox',
1477 'icon_box' => 'pl_iconbox',
1478 'accordion' => 'pl_accordion',
1479 'testimonial' => 'pl_testimonial',
1480 );
1481
1482 if (isset($tag_map[$tag])) {
1483 $node['tag'] = $tag_map[$tag];
1484 } elseif (strpos($tag, 'pagelayer_') === 0) {
1485 $node['tag'] = str_replace('pagelayer_', 'pl_', $tag);
1486 } elseif (strpos($tag, 'pl_') !== 0 && !empty($tag)) {
1487 $node['tag'] = 'pl_' . $tag;
1488 }
1489
1490 if (empty($node['tag'])) {
1491 $node['tag'] = isset($node['content']) && is_array($node['content']) ? 'pl_row' : 'pl_text';
1492 }
1493
1494 // Ensure attrs array exists
1495 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
1496 $node['attrs'] = array();
1497 }
1498
1499 // Ensure pagelayer-id exists
1500 if (empty($node['attrs']['pagelayer-id']) && function_exists('pagelayer_create_id')) {
1501 $node['attrs']['pagelayer-id'] = pagelayer_create_id();
1502 }
1503
1504 // Some widgets take their main text from the node's inner content rather
1505 // than from an attribute — the widget declares which param that is via
1506 // 'innerHTML' (pl_iconbox/pl_service => service_text,
1507 // pl_testimonial/pl_quote => quote_content, pl_accordion_item => title).
1508 // Putting that text in attrs is the natural mistake and it fails
1509 // silently: the attribute is a real property so the quality gate accepts
1510 // it, then the renderer reads content, finds nothing, and emits an empty
1511 // element. That is how feature cards shipped with titles but no
1512 // description and testimonials with names but no quote.
1513 //
1514 // Bridge it here, where every caller passes through, so presets and
1515 // hand-written nodes are both fixed. An explicit content value always
1516 // wins — this only fills an empty one.
1517 self::bridge_inner_html($node);
1518
1519 self::apply_markup_defaults($node);
1520
1521 self::add_missing_css_units($node);
1522
1523 // 2. Specific Normalization for Row Nodes
1524 if ($node['tag'] === 'pl_row') {
1525 if (!isset($node['attrs']['stretch'])) {
1526 $node['attrs']['stretch'] = 'full';
1527 }
1528 if (empty($node['attrs']['ele_padding'])) {
1529 $node['attrs']['ele_padding'] = '80px,0px,80px,0px';
1530 }
1531
1532 // Process children inside Row
1533 $raw_children = isset($node['content']) && is_array($node['content']) ? $node['content'] : array();
1534 $normalized_children = array();
1535
1536 // Auto-wrap non-column leaf widgets into Columns
1537 foreach ($raw_children as $child) {
1538 if (!is_array($child)) continue;
1539 $child_tag = isset($child['tag']) ? $child['tag'] : (isset($child['type']) ? $child['type'] : '');
1540 $child_tag = strtolower(trim($child_tag));
1541 if ($child_tag !== 'col' && $child_tag !== 'column' && $child_tag !== 'pl_col' && $child_tag !== 'pagelayer_col') {
1542 // Wrap in column
1543 $child = array(
1544 'tag' => 'pl_col',
1545 'attrs' => array('pagelayer-id' => function_exists('pagelayer_create_id') ? pagelayer_create_id() : ''),
1546 'content' => array($child)
1547 );
1548 }
1549 $normalized_children[] = self::normalize_node($child);
1550 }
1551
1552 // Auto-calculate column grid width (col: 12 / N)
1553 $child_count = count($normalized_children);
1554 if ($child_count > 0) {
1555 $auto_col = max(1, (int) floor(12 / $child_count));
1556 foreach ($normalized_children as &$c_node) {
1557 if (is_array($c_node) && $c_node['tag'] === 'pl_col') {
1558 if (!isset($c_node['attrs']['col']) || empty($c_node['attrs']['col'])) {
1559 $c_node['attrs']['col'] = $auto_col;
1560 }
1561 }
1562 }
1563 unset($c_node);
1564 }
1565
1566 $node['content'] = $normalized_children;
1567
1568 // 3. Specific Normalization for Column Nodes
1569 } elseif ($node['tag'] === 'pl_col') {
1570 if (empty($node['attrs']['col'])) {
1571 $node['attrs']['col'] = 12;
1572 }
1573 if (isset($node['content']) && is_array($node['content'])) {
1574 $norm_content = array();
1575 foreach ($node['content'] as $c) {
1576 $norm_content[] = self::normalize_node($c);
1577 }
1578 $node['content'] = $norm_content;
1579 }
1580
1581 // 4. Default Visual Enhancement Injection for Leaf Widgets.
1582 // Everything injected here must be a REAL attribute of the widget AND
1583 // must satisfy that attribute's `req` dependency, otherwise
1584 // pagelayer_render_shortcode() unsets it before render
1585 // (shortcode_functions.php:191) and the styling silently disappears.
1586 } elseif ($node['tag'] === 'pl_btn') {
1587 // btn_bg_color/btn_color are gated behind req: type must be
1588 // pagelayer-btn-custom or pagelayer-btn-anim. The widget default is
1589 // pagelayer-btn-default, so without this the colors are discarded.
1590 if (empty($node['attrs']['btn_bg_color'])) {
1591 $node['attrs']['btn_bg_color'] = '$primary';
1592 }
1593 if (empty($node['attrs']['btn_color'])) {
1594 $node['attrs']['btn_color'] = '#ffffff';
1595 }
1596 if (empty($node['attrs']['type'])) {
1597 $node['attrs']['type'] = 'pagelayer-btn-custom';
1598 }
1599 // btn_border_radius is itself gated behind btn_border_type != "".
1600 // Injecting the radius alone did nothing at render (Pagelayer drops
1601 // it) AND tripped the quality gate on every page with a button, so
1602 // every such create_page was rejected and regenerated for nothing.
1603 // "solid" with zero width gives the rounded corners with no visible
1604 // border, which is what the radius was here for.
1605 if (empty($node['attrs']['btn_border_radius'])) {
1606 $node['attrs']['btn_border_radius'] = '6px,6px,6px,6px';
1607 if (!isset($node['attrs']['btn_border_type']) || $node['attrs']['btn_border_type'] === '') {
1608 $node['attrs']['btn_border_type'] = 'solid';
1609 }
1610 if (empty($node['attrs']['btn_border_width'])) {
1611 $node['attrs']['btn_border_width'] = '0px,0px,0px,0px';
1612 }
1613 }
1614 if (!isset($node['attrs']['font_weight'])) {
1615 $node['attrs']['font_weight'] = '600';
1616 }
1617 } elseif ($node['tag'] === 'pl_heading') {
1618 if (empty($node['attrs']['color'])) {
1619 $node['attrs']['color'] = '$primary';
1620 }
1621 if (empty($node['attrs']['font_weight'])) {
1622 $node['attrs']['font_weight'] = '700';
1623 }
1624 } elseif ($node['tag'] === 'pl_text') {
1625 // pl_text has no "color" attr of its own — its only param is "text".
1626 // font_size/line_height come from the global font_style section and
1627 // do apply.
1628 if (empty($node['attrs']['font_size'])) {
1629 $node['attrs']['font_size'] = '16';
1630 }
1631 if (empty($node['attrs']['line_height'])) {
1632 $node['attrs']['line_height'] = '1.6';
1633 }
1634 } elseif ($node['tag'] === 'pl_image') {
1635 // pl_image's real image-source attribute is literally named "id"
1636 // (see shortcodes.php pl_image 'params'.'id') — NOT "img". It
1637 // accepts either a full https:// URL or a numeric WP attachment
1638 // ID (resolved via pagelayer_image()). "img" is not a real attr
1639 // on this widget and silently does nothing.
1640 //
1641 // ponytail: deliberately NOT substituting a stock photo when the
1642 // image is unset — the widget's own default-image.png placeholder is
1643 // the honest result, and layout is what we are tuning right now.
1644 // validate_page reports missing images as a warning, not an error.
1645 if (empty($node['attrs']['align'])) {
1646 $node['attrs']['align'] = 'center';
1647 }
1648 }
1649
1650 return $node;
1651 }
1652
1653 public static function serialize_layout_to_blocks($data) {
1654 if (!is_array($data)) {
1655 return '';
1656 }
1657 $prefix = defined('PAGELAYER_BLOCK_PREFIX') ? PAGELAYER_BLOCK_PREFIX : 'wp';
1658 $out = '';
1659 foreach ($data as $node) {
1660 if (!is_array($node) || empty($node['tag'])) {
1661 continue;
1662 }
1663 $tag = $node['tag'];
1664
1665 // Block names MUST keep the widget tag verbatim (underscores and all).
1666 // pagelayer_render_blocks() does substr($block_name, 10) and looks the
1667 // result up directly in $pagelayer->shortcodes with NO dash/underscore
1668 // translation (shortcode_functions.php:47), so "pagelayer/pl-grid_gallery"
1669 // or "pagelayer/pl-grid-gallery" both miss and the widget renders as
1670 // nothing. Core writes "pagelayer/pl_grid_gallery" everywhere (live.php,
1671 // import.php) — match it.
1672 $clean_tag = str_replace('pagelayer_', 'pl_', $tag);
1673 if (strpos($clean_tag, 'pl_') !== 0) {
1674 $clean_tag = 'pl_' . $clean_tag;
1675 }
1676
1677 $attrs = isset($node['attrs']) && is_array($node['attrs']) ? $node['attrs'] : array();
1678 if (empty($attrs['pagelayer-id']) && function_exists('pagelayer_create_id')) {
1679 $attrs['pagelayer-id'] = pagelayer_create_id();
1680 }
1681
1682 $content = isset($node['content']) ? $node['content'] : '';
1683 $block_name = $prefix . ':pagelayer/' . $clean_tag;
1684 $attrs_str = '';
1685 if (!empty($attrs)) {
1686 $attrs_str = ' ' . json_encode($attrs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
1687 }
1688
1689 $out .= '<!-- ' . $block_name . $attrs_str . " -->\n";
1690 if (is_array($content)) {
1691 $out .= self::serialize_layout_to_blocks($content);
1692 } else {
1693 $out .= $content . "\n";
1694 }
1695 $out .= '<!-- /' . $block_name . " -->\n";
1696 }
1697 return $out;
1698 }
1699
1700 public static function get_data_structure_doc() {
1701 return array(
1702 '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".',
1703 '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 "$<key>" (e.g. "$primary", "$accent") instead of repeating literal hex codes or font stacks.',
1704 'global_reference_syntax' => array(
1705 'color_or_gradient_props' => 'Pass "$<key>" (e.g. "$primary", "$accent") for color/gradient properties to bind to live CSS variables (var(--pagelayer-color-<key>)).',
1706 'typography_props' => 'Pass "$<key>" (e.g. "$primary") for typography properties to inherit global font presets.',
1707 '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.',
1708 'example' => 'attrs: {"ele_bg_type": "color", "ele_bg_color": "$primary", "color": "$text", "heading_typo": "$secondary"}'
1709 ),
1710 'dependent_attributes' => array(
1711 '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.',
1712 '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.',
1713 'common_pairs' => array(
1714 '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.',
1715 '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.',
1716 'pl_col col_width' => 'requires col = "" (custom width mode). If you set the 1-12 "col" attr, do not also send col_width.',
1717 ),
1718 ),
1719 'fast_path_sections' => array(
1720 'rule' => 'DEFAULT TO THIS. A pagelayer_data entry may be a section spec — {"section":"<preset>", ...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.',
1721 'presets' => self::section_presets(),
1722 '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).',
1723 '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"}]',
1724 '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.',
1725 ),
1726 'node_format' => array(
1727 'tag' => 'string - The widget shortcode tag, e.g. "pl_row", "pl_col", "pl_heading", "pl_btn", "pl_image", "pl_iconbox", etc.',
1728 'attrs' => 'object - Key-value map of widget attributes matching widget schema. ALL styling lives here.',
1729 '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.'
1730 ),
1731 'styling_never_inline' => array(
1732 'rule' => 'ENFORCED, not bypassable: never write a style="" attribute or a <style> block into rich text — not in node.content, not in a text/textarea/editor attribute (service_heading, quote_content, text, ...), and not via a "style=" entry in ele_attributes. The write is rejected and nothing is saved.',
1733 'why' => 'Pagelayer renders that HTML verbatim, so the inline rule outranks every builder control: the widget\'s own color/typography options stop having any effect, the _tablet/_mobile variants never apply to it, a later global-color change leaves the page half-rebranded, and the site owner cannot undo any of it from the Pagelayer UI.',
1734 'allowed_in_rich_text' => 'Content markup only: <strong>, <em>, <u>, <a href>, <br>, <span> without style, <ul>/<ol>/<li>.',
1735 'do_this_instead' => array(
1736 '1_widget_attribute' => 'Set the widget\'s own attribute — get_widget_schema for widget-specific props, get_common_styles for the ones every widget/row/column accepts (color, font_size, font_weight, font_style, ele_padding, ele_margin, ele_bg_*, ele_border_*, and their _tablet/_mobile variants).',
1737 '2_custom_css_attribute' => 'ONLY when no control exists for what you need: put a real CSS rule in the "ele_css" attribute of that same node, using {{element}} as the element selector. Example: attrs: {"ele_css": "{{element}} .pagelayer-heading-holder h2 { letter-spacing: 2px; text-transform: uppercase; }"}. This is the sanctioned place for hand-written CSS and is the ONLY attribute exempt from the rule.',
1738 '3_never' => 'Do not route around the rule with pl_embed or pl_shortcodes — those exist for third-party embed code, not for hand-rolled styled markup.',
1739 ),
1740 'example' => 'WRONG pl_heading content: "<h2 style=\"color:#fff;font-size:42px;text-align:center\">Welcome</h2>" | RIGHT: content "<h2>Welcome</h2>" plus attrs {"color": "$primary", "heading_typo": "$primary", "align": "center"} — pl_heading sizes its text through heading_typo, not a font-size in the markup. Confirm the exact attr names per widget with get_widget_schema.',
1741 ),
1742 'site_navigation' => array(
1743 'rule' => 'ENFORCED on header templates: unless the user explicitly asked for a ONE-PAGE / single-page site, the header must contain the Primary Menu widget (tag "pl_wp_menu") with attrs.nav_list set to a real WordPress menu id. create_template/update_template reject a header without it and nothing is saved.',
1744 'why' => 'pl_wp_menu renders an actual WordPress menu: the owner can edit it from Appearance > Menus, it collapses into a mobile toggle, it supports submenus, mega dropdowns and current-page highlighting. A row of pl_btn/pl_text links looks the same in the builder and gives none of that — and every new page has to be added by hand in the builder.',
1745 'order_of_operations' => array(
1746 '1' => 'Create the pages first (create_page / create_website) so there is something to link to.',
1747 '2' => 'Call create_menu {name:"Primary Menu", location:"<slug from get_menus.locations>", items:[{title, page_id}, ...]} — it returns menu_id. get_menus lists menus that already exist.',
1748 '3' => 'Build the header with a pl_wp_menu node carrying that menu_id, then configure the widget: nav_list, layout (horizontal|vertical|dropdown), align, drop_breakpoint (tablet/mobile — this is what produces the hamburger toggle), pointer, m_animation, submenu_ind, plus the menu/submenu colour and typography props. Call get_widget_schema {"widget":"pl_wp_menu"} for the full list; never guess.',
1749 ),
1750 'mega_menu' => 'A Mega Menu is a per-ITEM setting, not a separate widget: pass menu_type:"mega" plus mega_content (an array of pl_inner_row nodes, same node format as any layout) on that item in create_menu. Pagelayer stores it on the menu item and the Primary Menu widget renders it as the dropdown. menu_type:"column" gives a multi-column plain dropdown (with columns / col_gap); the default "" is a normal flyout submenu. Use mega for a big services/products dropdown with icons, images or promo blocks — a plain submenu is fine for two or three links.',
1751 'one_page_sites' => 'Only when the user actually asked for a one-pager: pass single_page_site:true to create_template/create_website and use pl_btn/pl_list links with "#section-id" hrefs, adding a pl_anchor node at each target section.',
1752 'footer' => 'A footer may use a second menu the same way (a compact "Quick Links" menu via pl_wp_menu with layout "vertical"), but it is not enforced there.',
1753 ),
1754 'theme_template_conditions' => array(
1755 'rule' => 'Header and footer templates are always saved with Display Conditions = Action Type "include", Display On "Full Site" — i.e. {"type":"include","template":"","sub_template":"","id":""}. If you send conditions without that rule, it is added back at the front of your list.',
1756 'why' => 'A header scoped to "singular" or "front_page" simply does not render on archives, search or 404 views, and Pagelayer gives no warning about it — the site just looks broken on those pages.',
1757 'other_types' => 'For every other template type (blog_archive, single_blog, search, 404, popup, woocommerce_*) you choose the conditions: template "archives" or "singular" narrows it, sub_template narrows further (e.g. singular + front_page, archives + search), and "id" pins it to one object. "exclude" rules can be combined with the site-wide include to carve out exceptions.',
1758 ),
1759 'hierarchy_rules' => array(
1760 'pl_row' => 'Top-level section container. Content must be array of pl_col nodes.',
1761 'pl_col' => 'Column inside a Row. Content is array of widget nodes or inner rows. Attr "col" sets grid width (1-12).',
1762 'widgets' => 'Placed inside Columns. Never place leaf widgets directly inside a Row without a Column.',
1763 'parent_widgets' => 'Widgets with parent constraints (e.g. pl_tab inside pl_tabs, pl_accordion_item inside pl_accordion).'
1764 ),
1765 'responsive_properties' => 'Properties supporting per-device overrides append screen suffixes in attrs: base for desktop, _tablet for tablet, _mobile for mobile (e.g., ele_padding, ele_padding_mobile, font_size, font_size_mobile).',
1766 'design_workflow' => array(
1767 'step_1' => 'Call list_widgets or get_widgets_summary to see every widget available on this install (never assume a fixed catalog).',
1768 'step_2' => 'Call get_widget_schema (or get_all_schemas) for the widgets you plan to use, to learn their real attributes, allowed values, and defaults on this site.',
1769 'step_3' => 'Call get_widget_examples for canonical node shapes, get_color_presets / get_spacing_presets / get_fonts for design-token starting points, and get_icons for icon classes. Check the "requires" key on every property you intend to set (see dependent_attributes) — a gated attribute sent without its companion is discarded at render. Images are optional: call search_images for a topically relevant photo and put the URL in pl_image\'s "id" attr (NOT "img", which does not exist), or omit it and accept the placeholder.',
1770 'step_4' => 'Define global_colors/global_fonts (via update_global_styles or when creating the page) based on the requested brand/niche, then compose pagelayer_data nodes using "$<key>" references and the schemas discovered above.',
1771 'step_5' => 'Use validate_page before publishing to catch hierarchy, accessibility, and SEO issues.'
1772 ),
1773 'content_quality_rules' => array(
1774 'no_placeholders' => 'Every widget must ship with real, unique, on-topic copy for the requested business/niche. Never leave pl_iconbox/pl_testimonial/pl_heading/pl_text relying on the widget\'s built-in default text (e.g. generic "Icon Box" titles) — this is an ENFORCED gate on create_page/create_website/update_page/add_element/update_element/create_design_ui/edit_layout, not just a validate_page warning: the call fails and nothing is saved.',
1775 'no_inline_css' => 'ENFORCED and NOT bypassable by skip_validation: rich text must not contain style="" attributes or <style> blocks (see styling_never_inline in get_data_structure). Put styling in the node\'s attrs — a real widget attribute, or the "ele_css" custom-CSS attribute when no control exists for it.',
1776 'valid_attributes_only' => 'ENFORCED: an attribute name that is not in the widget\'s schema, or a gated attribute sent without its companion (see dependent_attributes), fails the same gate. Both would otherwise render a page with none of the requested styling and no error, so they are treated as hard errors. Call get_widget_schema for any widget you have not used before.',
1777 'images_are_optional_right_now' => 'Images are NOT gated. If you have a relevant photo, call search_images with a specific keyword and put the result URL in attrs.id — that is pl_image\'s real image field, NOT "img", which does not exist on this widget and silently renders nothing. If you do not, simply omit attrs.id and the builder placeholder renders; validate_page reports it as a warning. Do NOT invent image URLs or reuse one photo across widgets to fill the gap — a placeholder is better than a wrong or broken image.',
1778 'section_variety' => 'A "full website" page is expected to include, at minimum: hero, feature/benefit grid, an about/story split section, social proof (testimonials or stats), a call-to-action section, and a footer — not a single thin column of default widgets. Build each section with the purpose-built widget listed in widget_recommendations rather than hand-rolling it out of pl_row/pl_col/pl_text — the dedicated widget already has the right markup, animation, and structure that a generic composition will not match.',
1779 'visual_polish' => 'Apply shadows and generous spacing (see get_spacing_presets) to cards and buttons so the result looks like a designed template, not raw defaults. Plain pl_image has NO border-radius attribute (only img_shadow exists) — do not claim or attempt to round its corners. If a widget pairs an image with a shape control (pl_testimonial\'s img_shape, pl_iconbox\'s stacked icon view), that shape only renders as a true circle/square when the matching width/height-style size control (e.g. testimonial_image_size) is explicitly set to one fixed value — omit it and the image stretches into an uneven oval.',
1780 'consistent_image_sizing' => 'When several images sit in the same row (a gallery, a row of avatars, a row of cards), they need a consistent size/aspect ratio. Prefer a dedicated gallery widget (pl_grid_gallery, pl_image_slider) over hand-placed pl_image nodes for galleries. For anything hand-placed, explicitly set matching width/height-style attrs on every image in that row — never let images with different natural dimensions sit side by side unconstrained, the row will look visibly broken.',
1781 'safe_layout_composition' => 'Default to normal stacked flow inside a column: heading, then subtext, then buttons, each a sibling block in reading order, spaced apart via padding/margin-style attrs (e.g. ele_padding). Do not stack multiple text/button elements on top of each other with overlapping/absolute positioning to fake a "layered hero" — that reliably renders as illegible overlapping text. The one safe layering pattern is a background image on the ROW itself (ele_bg_type=image, optionally with an overlay color) with the heading/text/buttons flowing normally inside its column on top of it.',
1782 'widget_recommendations' => array(
1783 'stats_or_counters_row' => 'pl_counter — animated number counters (e.g. "1240+ Active Members"), not plain pl_heading numbers in a row.',
1784 'image_gallery_or_portfolio' => 'pl_grid_gallery — real masonry/grid gallery widget, not a manual grid of pl_image nodes.',
1785 'before_after_or_carousel_images' => 'pl_image_slider for a slideshow of images.',
1786 'feature_or_service_cards' => 'pl_iconbox — icon + heading + text card (real fields: service_icon/service_heading/service_text/service_icon_color, NOT icon/title/desc). For a card with a real photo instead of an icon, use pl_service ("Image Box": service_image/service_heading/service_text) instead. Always confirm exact field names via get_widget_examples before using either.',
1787 'testimonials_or_reviews' => 'pl_testimonial — real fields are quote_content/cite/designation/avatar, NOT content/name/image.',
1788 'star_ratings' => 'pl_stars for a review/rating display, not text like "�
1789
1790
1791
1792
1793 ".',
1794 'progress_or_skill_bars' => 'pl_progress for animated progress/skill bars.',
1795 'faq_or_expandable_content' => 'pl_accordion (with pl_accordion_item children) for FAQs, not a stack of pl_heading+pl_text pairs.',
1796 'tabbed_content' => 'pl_tabs (with pl_tab children).',
1797 'pull_quote_or_highlighted_statement' => 'pl_quote for a single stylized blockquote-like statement.',
1798 'call_to_action_button' => 'pl_btn with a real "link" and, where relevant, an icon — never a plain text link.',
1799 'numbered_or_bulleted_list' => 'pl_list (with pl_list_item children) for checklists/feature lists — do not fake bullets inside pl_text HTML.',
1800 'map_or_location' => 'pl_google_maps for an embedded map, pl_address/pl_phone/pl_email for contact details.',
1801 'site_navigation_in_header' => 'pl_wp_menu (Primary Menu) bound to a real menu built with create_menu — see site_navigation in get_data_structure. Never a row of pl_btn/pl_text links. Mega dropdowns are the menu item\'s menu_type:"mega" + mega_content, not a separate widget.',
1802 'social_links' => 'pl_social_grp (with pl_social children) for a row of social icons, not raw pl_btn/pl_icon guesses.',
1803 'video_embed' => 'pl_video for embedded/self-hosted video.',
1804 'note' => 'This list is not exhaustive and is not a substitute for calling list_widgets/get_widgets_summary — new or renamed widgets may exist on this install. When in doubt, look for a widget whose name matches the content type before composing it manually from generic row/col/text/image nodes.',
1805 ),
1806 ),
1807 );
1808 }
1809
1810 // ==================================================================
1811 // REGISTRATION REGISTRY
1812 // ==================================================================
1813
1814 protected static function register_widget_abilities() {
1815 $abilities = array(
1816 'list_widgets' => array(
1817 'label' => __('List Pagelayer Widgets', 'pagelayer'),
1818 'description' => __('Compact list of every registered widget (tag, name, group, nesting rules). Filter with group or search to keep it small.', 'pagelayer'),
1819 'category' => 'pagelayer-widgets',
1820 'input_schema' => array(
1821 'type' => 'object',
1822 'properties' => array(
1823 'group' => array('type' => 'string'),
1824 'search' => array('type' => 'string', 'description' => 'Substring match on widget name or tag.'),
1825 ),
1826 'additionalProperties' => false
1827 ),
1828 'execute' => array(__CLASS__, 'execute_list_widgets'),
1829 ),
1830 'get_widget' => array(
1831 'label' => __('Get Widget Details', 'pagelayer'),
1832 'description' => __('Metadata and setting-section names for one widget. For the actual attributes use get_widget_schema.', 'pagelayer'),
1833 'category' => 'pagelayer-widgets',
1834 // Strict subset of get_widget_schema.
1835 'mcp_public' => false,
1836 'input_schema' => array(
1837 'type' => 'object',
1838 'properties' => array('widget' => array('type' => 'string')),
1839 'required' => array('widget'),
1840 'additionalProperties' => false
1841 ),
1842 'execute' => array(__CLASS__, 'execute_get_widget'),
1843 ),
1844 'get_widget_schema' => array(
1845 'label' => __('Get Widget Schema', 'pagelayer'),
1846 'description' => __('Attributes for one widget, compact. Returns only that widget\'s OWN props by default; the style props shared by all widgets come from get_common_styles (call it once per session).', 'pagelayer'),
1847 'category' => 'pagelayer-widgets',
1848 'input_schema' => array(
1849 'type' => 'object',
1850 'properties' => array(
1851 'widget' => array('type' => 'string'),
1852 'mode' => array('type' => 'string', 'description' => 'own (default) | all | shared'),
1853 'sections' => array('type' => 'array', 'description' => 'Specific section keys only, e.g. ["ele_bg_styles"].'),
1854 'verbose' => array('type' => 'boolean', 'description' => 'Raw uncompacted schema. Very large — avoid.'),
1855 ),
1856 'required' => array('widget'),
1857 'additionalProperties' => false
1858 ),
1859 'execute' => array(__CLASS__, 'execute_get_widget_schema'),
1860 ),
1861 'get_common_styles' => array(
1862 'label' => __('Get Common Style Props', 'pagelayer'),
1863 'description' => __('The style attributes every widget/row/column accepts (background, border, font, position, animation, motion, responsive, custom CSS). Fetch ONCE per session — get_widget_schema omits them.', 'pagelayer'),
1864 'category' => 'pagelayer-widgets',
1865 'input_schema' => array(
1866 'type' => 'object',
1867 'properties' => array(
1868 'sections' => array('type' => 'array', 'description' => 'Limit to specific section keys.'),
1869 ),
1870 'additionalProperties' => false
1871 ),
1872 'execute' => array(__CLASS__, 'execute_get_common_styles'),
1873 ),
1874 'get_widget_examples' => array(
1875 'label' => __('Get Widget Examples', 'pagelayer'),
1876 'description' => __('Canonical JSON node example for a widget, or for several at once. Not needed for anything you build with section specs.', 'pagelayer'),
1877 'category' => 'pagelayer-widgets',
1878 'input_schema' => array(
1879 'type' => 'object',
1880 'properties' => array(
1881 'widget' => array('type' => 'string'),
1882 'widgets' => array('type' => 'array', 'description' => 'Up to 8 tags in one call.'),
1883 ),
1884 'additionalProperties' => false
1885 ),
1886 'execute' => array(__CLASS__, 'execute_get_widget_examples'),
1887 ),
1888 'get_widgets_summary' => array(
1889 'label' => __('Get Widgets Summary', 'pagelayer'),
1890 'description' => __('Same compact widget list as list_widgets.', 'pagelayer'),
1891 'category' => 'pagelayer-widgets',
1892 // Identical to list_widgets since both were made compact.
1893 'mcp_public' => false,
1894 'input_schema' => array(
1895 'type' => 'object',
1896 'properties' => array(
1897 'group' => array('type' => 'string'),
1898 'search' => array('type' => 'string'),
1899 ),
1900 'additionalProperties' => false
1901 ),
1902 'execute' => array(__CLASS__, 'execute_get_widgets_summary'),
1903 ),
1904 'get_library_sections' => array(
1905 'label' => __('Get PopularFX Library Sections', 'pagelayer'),
1906 'description' => __('Browse prebuilt official PopularFX and PageLayer section templates (Hero, Features, Menu, Pricing, Testimonials, Headers, Footers).', 'pagelayer'),
1907 'category' => 'pagelayer-templates',
1908 'input_schema' => array(
1909 'type' => 'object',
1910 'properties' => array(
1911 'type' => array('type' => 'string', 'default' => 'sections', 'description' => 'sections, pages, header, footer')
1912 ),
1913 'additionalProperties' => false
1914 ),
1915 'execute' => array(__CLASS__, 'execute_get_library_sections'),
1916 ),
1917 'import_library_section' => array(
1918 'label' => __('Import Library Section', 'pagelayer'),
1919 'description' => __('Import and insert an official PopularFX / PageLayer library section directly into a post/page by section_id.', 'pagelayer'),
1920 'category' => 'pagelayer-templates',
1921 'input_schema' => array(
1922 'type' => 'object',
1923 'properties' => array(
1924 'section_id' => array('type' => 'string'),
1925 'post_id' => array('type' => 'integer'),
1926 ),
1927 'required' => array('section_id', 'post_id'),
1928 'additionalProperties' => false
1929 ),
1930 'execute' => array(__CLASS__, 'execute_import_library_section'),
1931 ),
1932 'scrape_website_content' => array(
1933 'label' => __('Scrape Website Content', 'pagelayer'),
1934 'description' => __('Fetch a URL and extract its raw content (title, headings, paragraphs, image URLs) for reference. Extraction only — it imposes no layout.', 'pagelayer'),
1935 'category' => 'pagelayer-templates',
1936 'input_schema' => array(
1937 'type' => 'object',
1938 'properties' => array(
1939 'url' => array('type' => 'string', 'description' => 'Target website URL to read (e.g. https://example.com/)')
1940 ),
1941 'required' => array('url'),
1942 'additionalProperties' => false
1943 ),
1944 'execute' => array(__CLASS__, 'execute_scrape_website_content'),
1945 ),
1946 'get_all_schemas' => array(
1947 'label' => __('Get Schemas For Several Widgets', 'pagelayer'),
1948 'description' => __('Compact schemas for up to 12 named widgets in one call. The widgets list is required.', 'pagelayer'),
1949 'category' => 'pagelayer-widgets',
1950 'input_schema' => array(
1951 'type' => 'object',
1952 'properties' => array(
1953 'widgets' => array('type' => 'array', 'description' => 'Widget tags, e.g. ["pl_heading","pl_btn"]. Max 12.'),
1954 ),
1955 'required' => array('widgets'),
1956 'additionalProperties' => false
1957 ),
1958 'execute' => array(__CLASS__, 'execute_get_all_widget_schemas'),
1959 ),
1960 );
1961
1962 foreach ($abilities as $id => $def) {
1963 self::do_register_ability('pagelayer-widgets/' . str_replace('_', '-', $id), $def);
1964 }
1965 }
1966
1967 protected static function register_global_abilities() {
1968 $abilities = array(
1969 'get_theme_settings' => array(
1970 'label' => __('Get Theme Settings', 'pagelayer'),
1971 'description' => __('Retrieve PageLayer theme options, active layout settings, header/footer assignments, WooCommerce status, and global styles.', 'pagelayer'),
1972 'category' => 'pagelayer-global',
1973 'input_schema' => array('type' => 'object', 'additionalProperties' => false),
1974 'execute' => array(__CLASS__, 'execute_get_theme_settings'),
1975 ),
1976 'get_global_styles' => array(
1977 'label' => __('Get Global Styles', 'pagelayer'),
1978 'description' => __('Retrieve site design system tokens: global colors, global fonts, content width, and style presets.', 'pagelayer'),
1979 'category' => 'pagelayer-global',
1980 'input_schema' => array('type' => 'object', 'additionalProperties' => false),
1981 'execute' => array(__CLASS__, 'execute_get_styles'),
1982 ),
1983 'update_global_styles' => array(
1984 'label' => __('Update Global Styles', 'pagelayer'),
1985 'description' => __('Update site design system tokens: global colors, global fonts, content width.', 'pagelayer'),
1986 'category' => 'pagelayer-global',
1987 'input_schema' => array(
1988 'type' => 'object',
1989 'properties' => array(
1990 'global_colors' => array('type' => 'object'),
1991 'global_fonts' => array('type' => 'object'),
1992 'content_width' => array('type' => 'string'),
1993 ),
1994 'additionalProperties' => false
1995 ),
1996 'execute' => array(__CLASS__, 'execute_update_styles'),
1997 'perm' => array(__CLASS__, 'can_manage_options')
1998 ),
1999 'get_icons' => array(
2000 'label' => __('Get Icon Catalog', 'pagelayer'),
2001 'description' => __('List available FontAwesome and Pagelayer icons with category filtering and search.', 'pagelayer'),
2002 'category' => 'pagelayer-global',
2003 'input_schema' => array(
2004 'type' => 'object',
2005 'properties' => array(
2006 'search' => array('type' => 'string'),
2007 'category' => array('type' => 'string'),
2008 ),
2009 'additionalProperties' => false
2010 ),
2011 'execute' => array(__CLASS__, 'execute_get_icons'),
2012 ),
2013 'get_fonts' => array(
2014 'label' => __('Get Font Catalog', 'pagelayer'),
2015 'description' => __('List Google Fonts, system fonts, and Pagelayer custom fonts available for layouts.', 'pagelayer'),
2016 'category' => 'pagelayer-global',
2017 'input_schema' => array('type' => 'object', 'additionalProperties' => false),
2018 'execute' => array(__CLASS__, 'execute_get_fonts'),
2019 ),
2020 'get_color_presets' => array(
2021 'label' => __('Get Color Presets', 'pagelayer'),
2022 'description' => __('Retrieve curated color palette presets (Modern Agency, Sleek Dark, Vibrant Tech, Elegant Serif, Warm Minimal) as a starting point — these are optional suggestions, not mandatory themes.', 'pagelayer'),
2023 'category' => 'pagelayer-global',
2024 'input_schema' => array('type' => 'object', 'additionalProperties' => false),
2025 'execute' => array(__CLASS__, 'execute_get_color_presets'),
2026 ),
2027 'get_spacing_presets' => array(
2028 'label' => __('Get Spacing Presets', 'pagelayer'),
2029 'description' => __('Retrieve standardized spacing scale presets (container width, section padding, column gaps, border radiuses, shadows).', 'pagelayer'),
2030 'category' => 'pagelayer-global',
2031 'input_schema' => array('type' => 'object', 'additionalProperties' => false),
2032 'execute' => array(__CLASS__, 'execute_get_spacing_presets'),
2033 ),
2034 'search_images' => array(
2035 'label' => __('Search Real Images', 'pagelayer'),
2036 'description' => __('Search Pexels for real licensed photos and return direct URLs. Put the URL in pl_image\'s "id" attr (not "img", which does not exist). Pass "queries" with ALL the photos a page needs in ONE call — one keyword per distinct image, never reuse a result. Needs a Pexels API key on the Pagelayer AI Agents settings page.', 'pagelayer'),
2037 'category' => 'pagelayer-global',
2038 'input_schema' => array(
2039 'type' => 'object',
2040 'properties' => array(
2041 'queries' => array('type' => 'array', 'description' => 'PREFERRED: up to 10 specific keywords in one call, e.g. ["wood fired pizza oven","barista pouring latte"]. Returns {batch: {keyword: [results]}}.'),
2042 'query' => array('type' => 'string', 'description' => 'Single search keyword, when you only need one photo.'),
2043 'per_page' => array('type' => 'integer', 'default' => 5, 'description' => 'Results per keyword (max 20; defaults to 3 in batch mode).'),
2044 'orientation' => array('type' => 'string', 'description' => 'landscape, portrait, or square. Optional.'),
2045 ),
2046 'additionalProperties' => false
2047 ),
2048 'execute' => array(__CLASS__, 'execute_search_images'),
2049 ),
2050 );
2051
2052 foreach ($abilities as $id => $def) {
2053 self::do_register_ability('pagelayer-global/' . str_replace('_', '-', $id), $def);
2054 }
2055 }
2056
2057 protected static function register_template_abilities() {
2058 $abilities = array(
2059 'get_templates' => array(
2060 'label' => __('Get Templates', 'pagelayer'),
2061 'description' => __('List Pagelayer theme templates (header, footer, blog_archive, single_blog, sidebar, search, 404, popup, woocommerce) and library items.', 'pagelayer'),
2062 'category' => 'pagelayer-templates',
2063 'input_schema' => array(
2064 'type' => 'object',
2065 'properties' => array('type' => array('type' => 'string')),
2066 'additionalProperties' => false
2067 ),
2068 'execute' => array(__CLASS__, 'execute_get_templates'),
2069 ),
2070 'create_template' => array(
2071 'label' => __('Create Template', 'pagelayer'),
2072 'description' => __('Create a reusable theme template or library section with pagelayer_data and display conditions. header/footer templates are always saved with the Include / Full Site display condition. A header must contain the Primary Menu widget (pl_wp_menu) bound to a real menu — build it with create_menu first — unless single_page_site:true.', 'pagelayer'),
2073 'category' => 'pagelayer-templates',
2074 'input_schema' => array(
2075 'type' => 'object',
2076 'properties' => array(
2077 'title' => array('type' => 'string'),
2078 'type' => array('type' => 'string', 'description' => 'header, footer, blog_archive, single_blog, sidebar, search, 404, popup, woocommerce_shop, woocommerce_product, general'),
2079 'pagelayer_data' => array('type' => 'object'),
2080 'conditions' => array('type' => 'array', 'description' => 'Display conditions: [{type:"include"|"exclude", template:""|"archives"|"singular", sub_template:"", id:""}]. An empty "template" means Full Site. header/footer templates always get the Include / Full Site rule, added back if you omit it.'),
2081 'single_page_site' => array('type' => 'boolean', 'description' => 'Only for a header on a genuine ONE-PAGE site whose nav links are in-page anchors. Opts out of the requirement that a header contains the Primary Menu widget.'),
2082 ),
2083 'required' => array('title', 'type', 'pagelayer_data'),
2084 'additionalProperties' => false
2085 ),
2086 'execute' => array(__CLASS__, 'execute_create_template'),
2087 ),
2088 'update_template' => array(
2089 'label' => __('Update Template', 'pagelayer'),
2090 'description' => __('Update an existing Pagelayer theme template title, layout data, or display conditions. Same header/footer rules as create_template: Include / Full Site is enforced, and a header needs a Primary Menu widget with a real nav_list.', 'pagelayer'),
2091 'category' => 'pagelayer-templates',
2092 'input_schema' => array(
2093 'type' => 'object',
2094 'properties' => array(
2095 'template_id' => array('type' => 'integer'),
2096 'title' => array('type' => 'string'),
2097 'type' => array('type' => 'string'),
2098 'pagelayer_data' => array('type' => 'object'),
2099 'conditions' => array('type' => 'array'),
2100 'single_page_site' => array('type' => 'boolean'),
2101 ),
2102 'required' => array('template_id'),
2103 'additionalProperties' => false
2104 ),
2105 'execute' => array(__CLASS__, 'execute_update_template'),
2106 ),
2107 'delete_template' => array(
2108 'label' => __('Delete Template', 'pagelayer'),
2109 'description' => __('Delete a Pagelayer theme template by ID.', 'pagelayer'),
2110 'category' => 'pagelayer-templates',
2111 'input_schema' => array(
2112 'type' => 'object',
2113 'properties' => array('template_id' => array('type' => 'integer')),
2114 'required' => array('template_id'),
2115 'additionalProperties' => false
2116 ),
2117 'execute' => array(__CLASS__, 'execute_delete_template'),
2118 ),
2119 'save_template' => array(
2120 'label' => __('Save Section Template', 'pagelayer'),
2121 'description' => __('Save a specific page section or layout to the local template library.', 'pagelayer'),
2122 'category' => 'pagelayer-templates',
2123 'input_schema' => array(
2124 'type' => 'object',
2125 'properties' => array(
2126 'name' => array('type' => 'string'),
2127 'post_id' => array('type' => 'integer'),
2128 'element_id' => array('type' => 'string'),
2129 ),
2130 'required' => array('name', 'post_id'),
2131 'additionalProperties' => false
2132 ),
2133 'execute' => array(__CLASS__, 'execute_save_template'),
2134 ),
2135 'insert_template' => array(
2136 'label' => __('Insert Template', 'pagelayer'),
2137 'description' => __('Insert a saved template layout structure into a target container on a page.', 'pagelayer'),
2138 'category' => 'pagelayer-templates',
2139 'input_schema' => array(
2140 'type' => 'object',
2141 'properties' => array(
2142 'name' => array('type' => 'string'),
2143 'post_id' => array('type' => 'integer'),
2144 'parent_id' => array('type' => 'string'),
2145 'index' => array('type' => 'integer'),
2146 ),
2147 'required' => array('name', 'post_id'),
2148 'additionalProperties' => false
2149 ),
2150 'execute' => array(__CLASS__, 'execute_insert_template'),
2151 ),
2152 );
2153
2154 foreach ($abilities as $id => $def) {
2155 self::do_register_ability('pagelayer-templates/' . str_replace('_', '-', $id), $def);
2156 }
2157 }
2158
2159 /**
2160 * The Primary Menu widget (pl_wp_menu) renders a real WordPress nav menu by
2161 * term id — it has no item list of its own. Without these abilities an AI
2162 * client can drop the widget into a header and the header renders an empty
2163 * <ul>, so building the menu has to be part of the same toolset.
2164 */
2165 protected static function register_menu_abilities() {
2166 $abilities = array(
2167 'get_menus' => array(
2168 'label' => __('Get Navigation Menus', 'pagelayer'),
2169 'description' => __('List every WordPress nav menu with its term id and item tree, plus the theme\'s registered menu locations and what is assigned to them. The term id is what goes in the Primary Menu widget\'s "nav_list" attribute.', 'pagelayer'),
2170 'category' => 'pagelayer-menus',
2171 'input_schema' => array(
2172 'type' => 'object',
2173 'properties' => array(
2174 'menu' => array('type' => 'string', 'description' => 'Optional name, slug or term id to return just one menu.'),
2175 ),
2176 'additionalProperties' => false
2177 ),
2178 'execute' => array(__CLASS__, 'execute_get_menus'),
2179 ),
2180 'create_menu' => array(
2181 'label' => __('Create / Update Navigation Menu', 'pagelayer'),
2182 'description' => __('Create a WordPress nav menu (or rebuild an existing one by the same name) from a list of items, optionally assigning it to a theme menu location. Items can nest via "children" and can carry Pagelayer per-item settings — menu_type "mega" with mega_content builds a real Mega Menu dropdown. Returns the menu term id to put in the Primary Menu widget\'s "nav_list" attribute. Call this BEFORE building the header template.', 'pagelayer'),
2183 'category' => 'pagelayer-menus',
2184 'input_schema' => array(
2185 'type' => 'object',
2186 'properties' => array(
2187 'name' => array('type' => 'string', 'description' => 'Menu name, e.g. "Primary Menu". An existing menu with this name is reused.'),
2188 'location' => array('type' => 'string', 'description' => 'Optional theme menu location slug to assign this menu to (see get_menus.locations).'),
2189 'replace_items' => array('type' => 'boolean', 'description' => 'Default true — the menu ends up containing exactly the items sent. false appends instead.'),
2190 'items' => array('type' => 'array', 'description' => 'Item objects: {title, page_id|post_id|url, target, children:[...], menu_type:""|"mega"|"column", mega_content:[pl_inner_row nodes], mega_width, mega_custom_width, columns, col_gap, menu_icon, highlight_label, disable_link}.'),
2191 ),
2192 'required' => array('name', 'items'),
2193 'additionalProperties' => false
2194 ),
2195 'execute' => array(__CLASS__, 'execute_create_menu'),
2196 'perm' => array(__CLASS__, 'can_manage_options'),
2197 ),
2198 'delete_menu' => array(
2199 'label' => __('Delete Navigation Menu', 'pagelayer'),
2200 'description' => __('Delete a WordPress nav menu by name, slug or term id.', 'pagelayer'),
2201 'category' => 'pagelayer-menus',
2202 'input_schema' => array(
2203 'type' => 'object',
2204 'properties' => array('menu' => array('type' => 'string')),
2205 'required' => array('menu'),
2206 'additionalProperties' => false
2207 ),
2208 'execute' => array(__CLASS__, 'execute_delete_menu'),
2209 'perm' => array(__CLASS__, 'can_manage_options'),
2210 ),
2211 );
2212
2213 foreach ($abilities as $id => $def) {
2214 self::do_register_ability('pagelayer-menus/' . str_replace('_', '-', $id), $def);
2215 }
2216 }
2217
2218 protected static function register_pages_abilities() {
2219 $abilities = array(
2220 'create_website' => array(
2221 'label' => __('Create Entire Website', 'pagelayer'),
2222 'description' => __('Generate a full multi-page website for any niche in ONE call — always prefer this over repeated create_page. Build each page out of section specs ({"section":"hero",...}, see fast_path_sections in get_data_structure) and fetch every photo in one batched search_images call first. Before calling, read get_data_structure with topic:"all" — it carries the node format, the global colour tokens, the gated-attribute rule and the enforced content-quality gate that decides whether a page is accepted. A failing page is rejected unsaved and the error lists what to fix. Pages are created first, then a Primary Menu from those pages (unless single_page_site), then the templates — so a header can bind its Primary Menu widget to a real menu (leave nav_list empty or "auto" in a header you pass and it is filled in for you). Auto-creates minimal Header/Footer templates only if none exist.', 'pagelayer'),
2223 'category' => 'pagelayer-pages',
2224 'input_schema' => array(
2225 'type' => 'object',
2226 'properties' => array(
2227 'site_name' => array('type' => 'string'),
2228 'primary_color' => array('type' => 'string', 'description' => 'Fallback color used only for the auto-generated bare header/footer scaffolding if none exist.'),
2229 'global_colors' => array('type' => 'object'),
2230 'global_fonts' => array('type' => 'object'),
2231 'content_width' => array('type' => 'string'),
2232 'pages' => array('type' => 'array', 'description' => 'List of page objects to create (title, pagelayer_data, status, skip_validation, ...)'),
2233 'theme_templates' => array('type' => 'array'),
2234 'menu' => array('type' => 'object', 'description' => 'Optional nav menu spec {name, location, items:[...]} in create_menu format. Omit and a "Primary Menu" is built from the pages created here (homepage first) and assigned to a free theme location.'),
2235 'single_page_site' => array('type' => 'boolean', 'description' => 'Set ONLY when the user asked for a one-page site. Skips menu creation; header links are expected to be in-page anchors.'),
2236 ),
2237 'required' => array('site_name', 'pages'),
2238 'additionalProperties' => false
2239 ),
2240 'execute' => array(__CLASS__, 'execute_create_website'),
2241 ),
2242 'create_page' => array(
2243 'label' => __('Create Page', 'pagelayer'),
2244 'description' => __('Create one page with Pagelayer builder data, status and global styles. FASTEST PATH: send section specs ({"section":"hero",...}) instead of hand-written node trees — see fast_path_sections in get_data_structure. Enforced quality gate: rejected unsaved if any widget keeps its placeholder text, uses an attr not in its schema, sets a gated attr without its companion, or uses an unregistered tag. Styling must live in attrs — an inline style attribute or <style> block in rich text is rejected and cannot be bypassed. Missing images are only a warning. Read get_data_structure and get_widget_schema first.', 'pagelayer'),
2245 'category' => 'pagelayer-pages',
2246 'input_schema' => array(
2247 'type' => 'object',
2248 'properties' => array(
2249 'title' => array('type' => 'string'),
2250 'pagelayer_data' => array('type' => 'object'),
2251 'status' => array('type' => 'string', 'default' => 'publish'),
2252 'is_homepage' => array('type' => 'boolean'),
2253 'is_posts_page' => array('type' => 'boolean'),
2254 'global_colors' => array('type' => 'object'),
2255 'global_fonts' => array('type' => 'object'),
2256 'content_width' => array('type' => 'string'),
2257 'skip_validation' => array('type' => 'boolean', 'description' => 'Bypass the content-quality gate for an intentional unfinished draft. Not recommended.'),
2258 ),
2259 'required' => array('title', 'pagelayer_data'),
2260 'additionalProperties' => false
2261 ),
2262 'execute' => array(__CLASS__, 'execute_create_page'),
2263 ),
2264 'update_page' => array(
2265 'label' => __('Update Page', 'pagelayer'),
2266 'description' => __('Update page title, status, or the WHOLE pagelayer_data tree. Sending pagelayer_data REPLACES the entire layout and discards any human edits made in the editor since — to change part of a page use update_element/add_element/change_styles instead, which are far cheaper and non-destructive. Same enforced quality gate as create_page, including the no-inline-CSS-in-rich-text rule.', 'pagelayer'),
2267 'category' => 'pagelayer-pages',
2268 'input_schema' => array(
2269 'type' => 'object',
2270 'properties' => array(
2271 'post_id' => array('type' => 'integer'),
2272 'title' => array('type' => 'string'),
2273 'pagelayer_data' => array('type' => 'object'),
2274 'status' => array('type' => 'string'),
2275 'skip_validation' => array('type' => 'boolean', 'description' => 'Bypass the content-quality gate for an intentional unfinished draft. Not recommended.'),
2276 ),
2277 'required' => array('post_id'),
2278 'additionalProperties' => false
2279 ),
2280 'execute' => array(__CLASS__, 'execute_update_page'),
2281 ),
2282 'get_page' => array(
2283 'label' => __('Get Page', 'pagelayer'),
2284 'description' => __('Page details plus a compact outline of its elements (id, tag, text preview) — enough to locate anything you want to edit. Pass element_id for one node in full, or mode:"full" for the whole raw tree (large; only needed to rewrite the entire layout).', 'pagelayer'),
2285 'category' => 'pagelayer-pages',
2286 'input_schema' => array(
2287 'type' => 'object',
2288 'properties' => array(
2289 'post_id' => array('type' => 'integer'),
2290 'mode' => array('type' => 'string', 'description' => 'outline (default) | full'),
2291 'element_id' => array('type' => 'string', 'description' => 'Return just this node, in full.'),
2292 ),
2293 'required' => array('post_id'),
2294 'additionalProperties' => false
2295 ),
2296 'execute' => array(__CLASS__, 'execute_get_page'),
2297 ),
2298 'list_pages' => array(
2299 'label' => __('List Pages', 'pagelayer'),
2300 'description' => __('List WordPress pages built with Pagelayer.', 'pagelayer'),
2301 'category' => 'pagelayer-pages',
2302 'input_schema' => array(
2303 'type' => 'object',
2304 'properties' => array(
2305 'limit' => array('type' => 'integer', 'default' => 20),
2306 'status' => array('type' => 'string', 'default' => 'any'),
2307 ),
2308 'additionalProperties' => false
2309 ),
2310 'execute' => array(__CLASS__, 'execute_list_pages'),
2311 ),
2312 'publish_page' => array(
2313 'label' => __('Publish Page', 'pagelayer'),
2314 'description' => __('Change page status to publish.', 'pagelayer'),
2315 'category' => 'pagelayer-pages',
2316 'input_schema' => array(
2317 'type' => 'object',
2318 'properties' => array('post_id' => array('type' => 'integer')),
2319 'required' => array('post_id'),
2320 'additionalProperties' => false
2321 ),
2322 'execute' => array(__CLASS__, 'execute_publish_page'),
2323 ),
2324 'duplicate_page' => array(
2325 'label' => __('Duplicate Page', 'pagelayer'),
2326 'description' => __('Clone an existing page and regenerate all Pagelayer element IDs.', 'pagelayer'),
2327 'category' => 'pagelayer-pages',
2328 'input_schema' => array(
2329 'type' => 'object',
2330 'properties' => array(
2331 'post_id' => array('type' => 'integer'),
2332 'title' => array('type' => 'string'),
2333 ),
2334 'required' => array('post_id'),
2335 'additionalProperties' => false
2336 ),
2337 'execute' => array(__CLASS__, 'execute_duplicate_page'),
2338 ),
2339 'delete_page' => array(
2340 'label' => __('Delete Page', 'pagelayer'),
2341 'description' => __('Trash or delete a page by ID.', 'pagelayer'),
2342 'category' => 'pagelayer-pages',
2343 'input_schema' => array(
2344 'type' => 'object',
2345 'properties' => array(
2346 'post_id' => array('type' => 'integer'),
2347 'force' => array('type' => 'boolean', 'default' => false),
2348 ),
2349 'required' => array('post_id'),
2350 'additionalProperties' => false
2351 ),
2352 'execute' => array(__CLASS__, 'execute_delete_page'),
2353 ),
2354 'preview_page' => array(
2355 'label' => __('Preview Page', 'pagelayer'),
2356 'description' => __('Retrieve the live view/preview URL for a post or page.', 'pagelayer'),
2357 'category' => 'pagelayer-pages',
2358 'input_schema' => array(
2359 'type' => 'object',
2360 'properties' => array('post_id' => array('type' => 'integer')),
2361 'required' => array('post_id'),
2362 'additionalProperties' => false
2363 ),
2364 'execute' => array(__CLASS__, 'execute_get_preview'),
2365 ),
2366 'validate_page' => array(
2367 'label' => __('Validate Page Layout', 'pagelayer'),
2368 'description' => __('Perform comprehensive AI validation checks (widget compatibility, structure, responsiveness, accessibility, SEO, global styles token usage).', 'pagelayer'),
2369 'category' => 'pagelayer-pages',
2370 'input_schema' => array(
2371 'type' => 'object',
2372 'properties' => array(
2373 'post_id' => array('type' => 'integer'),
2374 'pagelayer_data' => array('type' => 'object'),
2375 ),
2376 'additionalProperties' => false
2377 ),
2378 'execute' => array(__CLASS__, 'execute_validate_page'),
2379 ),
2380 'create_design_ui' => array(
2381 'label' => __('Create Design UI Sections', 'pagelayer'),
2382 'description' => __('Append custom UI section nodes to an existing page. ENFORCED content-quality gate on the appended nodes — no placeholder text, no missing images, no unregistered widgets. Pass skip_validation:true to bypass for an intentional draft.', 'pagelayer'),
2383 'category' => 'pagelayer-pages',
2384 'input_schema' => array(
2385 'type' => 'object',
2386 'properties' => array(
2387 'post_id' => array('type' => 'integer'),
2388 'pagelayer_data' => array('type' => 'object'),
2389 'skip_validation' => array('type' => 'boolean'),
2390 ),
2391 'required' => array('post_id', 'pagelayer_data'),
2392 'additionalProperties' => false
2393 ),
2394 'execute' => array(__CLASS__, 'execute_create_design_ui'),
2395 ),
2396 'edit_layout' => array(
2397 'label' => __('Edit Page Layout', 'pagelayer'),
2398 'description' => __('Replace the full layout structure of a Pagelayer page. ENFORCED content-quality gate on the new layout — no placeholder text, no missing images, no unregistered widgets. Pass skip_validation:true to bypass for an intentional draft.', 'pagelayer'),
2399 'category' => 'pagelayer-pages',
2400 // Same full-layout replace as update_page with pagelayer_data.
2401 'mcp_public' => false,
2402 'input_schema' => array(
2403 'type' => 'object',
2404 'properties' => array(
2405 'post_id' => array('type' => 'integer'),
2406 'pagelayer_data' => array('type' => 'object'),
2407 'skip_validation' => array('type' => 'boolean'),
2408 ),
2409 'required' => array('post_id', 'pagelayer_data'),
2410 'additionalProperties' => false
2411 ),
2412 'execute' => array(__CLASS__, 'execute_edit_layout'),
2413 ),
2414 'change_styles' => array(
2415 'label' => __('Change Element Styles', 'pagelayer'),
2416 'description' => __('Batch update style properties on page elements by ID or widget tag.', 'pagelayer'),
2417 'category' => 'pagelayer-pages',
2418 'input_schema' => array(
2419 'type' => 'object',
2420 'properties' => array(
2421 'post_id' => array('type' => 'integer'),
2422 'styles' => array('type' => 'array'),
2423 ),
2424 'required' => array('post_id', 'styles'),
2425 'additionalProperties' => false
2426 ),
2427 'execute' => array(__CLASS__, 'execute_change_styles'),
2428 ),
2429 'get_data_structure' => array(
2430 'label' => __('Get Data Structure Guide', 'pagelayer'),
2431 'description' => __('How pagelayer_data nodes, global $color tokens and gated attributes work. Default topic covers editing; pass topic:"quality"/"widgets"/"workflow"/"all" when building a page from scratch.', 'pagelayer'),
2432 'category' => 'pagelayer-pages',
2433 'input_schema' => array(
2434 'type' => 'object',
2435 'properties' => array(
2436 'topic' => array('type' => 'string', 'description' => 'core (default) | quality | widgets | navigation | workflow | all'),
2437 ),
2438 'additionalProperties' => false
2439 ),
2440 'execute' => array(__CLASS__, 'execute_get_data_structure'),
2441 ),
2442 'find_elements' => array(
2443 'label' => __('Find Elements', 'pagelayer'),
2444 'description' => __('Find elements on a page by tag and/or text. Returns compact "id tag text" lines; matches text held in attrs as well as node content.', 'pagelayer'),
2445 'category' => 'pagelayer-pages',
2446 'input_schema' => array(
2447 'type' => 'object',
2448 'properties' => array(
2449 'post_id' => array('type' => 'integer'),
2450 'tag' => array('type' => 'string'),
2451 'query' => array('type' => 'string'),
2452 'include_attrs' => array('type' => 'boolean', 'description' => 'Include every attr of each match. Large — usually unnecessary.'),
2453 ),
2454 'required' => array('post_id'),
2455 'additionalProperties' => false
2456 ),
2457 'execute' => array(__CLASS__, 'execute_find_elements'),
2458 ),
2459 'navigator' => array(
2460 'label' => __('Page Navigator Outline', 'pagelayer'),
2461 'description' => __('Indented outline of every row, column and widget on a page with ids and text previews. Same data as get_page in outline mode.', 'pagelayer'),
2462 'category' => 'pagelayer-pages',
2463 // get_page returns this same outline by default.
2464 'mcp_public' => false,
2465 'input_schema' => array(
2466 'type' => 'object',
2467 'properties' => array('post_id' => array('type' => 'integer')),
2468 'required' => array('post_id'),
2469 'additionalProperties' => false
2470 ),
2471 'execute' => array(__CLASS__, 'execute_navigator'),
2472 ),
2473 'update_element' => array(
2474 'label' => __('Update Element', 'pagelayer'),
2475 'description' => __('Change attrs and/or content of one node by pagelayer-id — the cheapest way to edit an existing page. Given attrs are merged, not replaced. Enforced quality gate on the resulting node (no placeholder text, valid attr names, gated attrs sent with their companion); skip_validation:true bypasses it. Inline CSS in the content/attrs you send is always rejected.', 'pagelayer'),
2476 'category' => 'pagelayer-pages',
2477 'input_schema' => array(
2478 'type' => 'object',
2479 'properties' => array(
2480 'post_id' => array('type' => 'integer'),
2481 'element_id' => array('type' => 'string', 'description' => 'pagelayer-id or "@0.1.2" position path, as shown in the get_page outline.'),
2482 'attrs' => array('type' => 'object'),
2483 'content' => array('type' => 'string'),
2484 'skip_validation' => array('type' => 'boolean'),
2485 ),
2486 'required' => array('post_id', 'element_id'),
2487 'additionalProperties' => false
2488 ),
2489 'execute' => array(__CLASS__, 'execute_update_element'),
2490 ),
2491 'add_element' => array(
2492 'label' => __('Add Element', 'pagelayer'),
2493 'description' => __('Insert a new element node into a parent container at an index. Enforced quality gate on the new element (no placeholder text, valid attrs, registered tag); skip_validation:true bypasses it. Inline CSS in rich text is always rejected.', 'pagelayer'),
2494 'category' => 'pagelayer-pages',
2495 'input_schema' => array(
2496 'type' => 'object',
2497 'properties' => array(
2498 'post_id' => array('type' => 'integer'),
2499 'parent_id' => array('type' => 'string'),
2500 'element' => array('type' => 'object'),
2501 'index' => array('type' => 'integer'),
2502 'skip_validation' => array('type' => 'boolean'),
2503 ),
2504 'required' => array('post_id', 'element'),
2505 'additionalProperties' => false
2506 ),
2507 'execute' => array(__CLASS__, 'execute_add_element'),
2508 ),
2509 'delete_element' => array(
2510 'label' => __('Delete Element', 'pagelayer'),
2511 'description' => __('Remove an element from a page by its pagelayer-id.', 'pagelayer'),
2512 'category' => 'pagelayer-pages',
2513 'input_schema' => array(
2514 'type' => 'object',
2515 'properties' => array(
2516 'post_id' => array('type' => 'integer'),
2517 'element_id' => array('type' => 'string'),
2518 ),
2519 'required' => array('post_id', 'element_id'),
2520 'additionalProperties' => false
2521 ),
2522 'execute' => array(__CLASS__, 'execute_delete_element'),
2523 ),
2524 'move_element' => array(
2525 'label' => __('Move Element', 'pagelayer'),
2526 'description' => __('Relocate an element to a target parent container or index.', 'pagelayer'),
2527 'category' => 'pagelayer-pages',
2528 'input_schema' => array(
2529 'type' => 'object',
2530 'properties' => array(
2531 'post_id' => array('type' => 'integer'),
2532 'element_id' => array('type' => 'string'),
2533 'parent_id' => array('type' => 'string'),
2534 'index' => array('type' => 'integer'),
2535 ),
2536 'required' => array('post_id', 'element_id'),
2537 'additionalProperties' => false
2538 ),
2539 'execute' => array(__CLASS__, 'execute_move_element'),
2540 ),
2541 'duplicate_element' => array(
2542 'label' => __('Duplicate Element', 'pagelayer'),
2543 'description' => __('Clone an element by ID, generating new IDs for all child nodes.', 'pagelayer'),
2544 'category' => 'pagelayer-pages',
2545 'input_schema' => array(
2546 'type' => 'object',
2547 'properties' => array(
2548 'post_id' => array('type' => 'integer'),
2549 'element_id' => array('type' => 'string'),
2550 ),
2551 'required' => array('post_id', 'element_id'),
2552 'additionalProperties' => false
2553 ),
2554 'execute' => array(__CLASS__, 'execute_duplicate_element'),
2555 ),
2556 'begin_transaction' => array(
2557 'label' => __('Begin Transaction', 'pagelayer'),
2558 'description' => __('Backup page layout state before multi-step modifications.', 'pagelayer'),
2559 'category' => 'pagelayer-pages',
2560 'input_schema' => array(
2561 'type' => 'object',
2562 'properties' => array('post_id' => array('type' => 'integer')),
2563 'required' => array('post_id'),
2564 'additionalProperties' => false
2565 ),
2566 'execute' => array(__CLASS__, 'execute_begin_transaction'),
2567 ),
2568 'commit_transaction' => array(
2569 'label' => __('Commit Transaction', 'pagelayer'),
2570 'description' => __('Commit layout changes and delete backup state.', 'pagelayer'),
2571 'category' => 'pagelayer-pages',
2572 'input_schema' => array(
2573 'type' => 'object',
2574 'properties' => array('post_id' => array('type' => 'integer')),
2575 'required' => array('post_id'),
2576 'additionalProperties' => false
2577 ),
2578 'execute' => array(__CLASS__, 'execute_commit_transaction'),
2579 ),
2580 'rollback_transaction' => array(
2581 'label' => __('Rollback Transaction', 'pagelayer'),
2582 'description' => __('Restore original page layout state from transaction backup.', 'pagelayer'),
2583 'category' => 'pagelayer-pages',
2584 'input_schema' => array(
2585 'type' => 'object',
2586 'properties' => array('post_id' => array('type' => 'integer')),
2587 'required' => array('post_id'),
2588 'additionalProperties' => false
2589 ),
2590 'execute' => array(__CLASS__, 'execute_rollback_transaction'),
2591 ),
2592 );
2593
2594 foreach ($abilities as $id => $def) {
2595 self::do_register_ability('pagelayer-pages/' . str_replace('_', '-', $id), $def);
2596 }
2597 }
2598
2599 protected static function register_posts_abilities() {
2600 $abilities = array(
2601 'create_post' => array(
2602 'label' => __('Create Individual Post', 'pagelayer'),
2603 'description' => __('Create a blog post built with Pagelayer, specifying categories, tags, excerpt, featured image, and layout.', 'pagelayer'),
2604 'category' => 'pagelayer-posts',
2605 'input_schema' => array(
2606 'type' => 'object',
2607 'properties' => array(
2608 'title' => array('type' => 'string'),
2609 'pagelayer_data' => array('type' => 'object'),
2610 'status' => array('type' => 'string', 'default' => 'publish'),
2611 'categories' => array('type' => 'array', 'items' => array('type' => 'string')),
2612 'tags' => array('type' => 'array', 'items' => array('type' => 'string')),
2613 'excerpt' => array('type' => 'string'),
2614 'featured_image' => array('type' => 'string'),
2615 'global_colors' => array('type' => 'object'),
2616 'global_fonts' => array('type' => 'object'),
2617 'content_width' => array('type' => 'string'),
2618 ),
2619 'required' => array('title', 'pagelayer_data'),
2620 'additionalProperties' => false
2621 ),
2622 'execute' => array(__CLASS__, 'execute_create_post'),
2623 ),
2624 'update_post' => array(
2625 'label' => __('Update Individual Post', 'pagelayer'),
2626 'description' => __('Update an existing blog post title, layout data, categories, tags, excerpt, or status.', 'pagelayer'),
2627 'category' => 'pagelayer-posts',
2628 'input_schema' => array(
2629 'type' => 'object',
2630 'properties' => array(
2631 'post_id' => array('type' => 'integer'),
2632 'title' => array('type' => 'string'),
2633 'pagelayer_data' => array('type' => 'object'),
2634 'status' => array('type' => 'string'),
2635 'categories' => array('type' => 'array', 'items' => array('type' => 'string')),
2636 'tags' => array('type' => 'array', 'items' => array('type' => 'string')),
2637 'excerpt' => array('type' => 'string'),
2638 'featured_image' => array('type' => 'string'),
2639 ),
2640 'required' => array('post_id'),
2641 'additionalProperties' => false
2642 ),
2643 'execute' => array(__CLASS__, 'execute_update_post'),
2644 ),
2645 'get_post' => array(
2646 'label' => __('Get Individual Post', 'pagelayer'),
2647 'description' => __('Retrieve blog post details, categories, tags, excerpt, featured image, and pagelayer_data.', 'pagelayer'),
2648 'category' => 'pagelayer-posts',
2649 'input_schema' => array(
2650 'type' => 'object',
2651 'properties' => array('post_id' => array('type' => 'integer')),
2652 'required' => array('post_id'),
2653 'additionalProperties' => false
2654 ),
2655 'execute' => array(__CLASS__, 'execute_get_post'),
2656 ),
2657 'list_posts' => array(
2658 'label' => __('List Blog Posts', 'pagelayer'),
2659 'description' => __('List WordPress blog posts built with Pagelayer.', 'pagelayer'),
2660 'category' => 'pagelayer-posts',
2661 'input_schema' => array(
2662 'type' => 'object',
2663 'properties' => array(
2664 'limit' => array('type' => 'integer', 'default' => 20),
2665 'status' => array('type' => 'string', 'default' => 'any'),
2666 'category' => array('type' => 'string'),
2667 ),
2668 'additionalProperties' => false
2669 ),
2670 'execute' => array(__CLASS__, 'execute_list_posts'),
2671 ),
2672 'publish_post' => array(
2673 'label' => __('Publish Individual Post', 'pagelayer'),
2674 'description' => __('Publish a draft blog post.', 'pagelayer'),
2675 'category' => 'pagelayer-posts',
2676 'input_schema' => array(
2677 'type' => 'object',
2678 'properties' => array('post_id' => array('type' => 'integer')),
2679 'required' => array('post_id'),
2680 'additionalProperties' => false
2681 ),
2682 'execute' => array(__CLASS__, 'execute_publish_post'),
2683 ),
2684 'duplicate_post' => array(
2685 'label' => __('Duplicate Individual Post', 'pagelayer'),
2686 'description' => __('Clone an existing blog post with new Pagelayer element IDs.', 'pagelayer'),
2687 'category' => 'pagelayer-posts',
2688 'input_schema' => array(
2689 'type' => 'object',
2690 'properties' => array(
2691 'post_id' => array('type' => 'integer'),
2692 'title' => array('type' => 'string'),
2693 ),
2694 'required' => array('post_id'),
2695 'additionalProperties' => false
2696 ),
2697 'execute' => array(__CLASS__, 'execute_duplicate_post'),
2698 ),
2699 'delete_post' => array(
2700 'label' => __('Delete Individual Post', 'pagelayer'),
2701 'description' => __('Trash or delete a blog post by ID.', 'pagelayer'),
2702 'category' => 'pagelayer-posts',
2703 'input_schema' => array(
2704 'type' => 'object',
2705 'properties' => array(
2706 'post_id' => array('type' => 'integer'),
2707 'force' => array('type' => 'boolean', 'default' => false),
2708 ),
2709 'required' => array('post_id'),
2710 'additionalProperties' => false
2711 ),
2712 'execute' => array(__CLASS__, 'execute_delete_post'),
2713 ),
2714 );
2715
2716 foreach ($abilities as $id => $def) {
2717 self::do_register_ability('pagelayer-posts/' . str_replace('_', '-', $id), $def);
2718 }
2719 }
2720
2721 protected static function register_media_abilities() {
2722 $abilities = array(
2723 'upload_media' => array(
2724 'label' => __('Upload Media', 'pagelayer'),
2725 'description' => __('Sideload an image from a URL into the WordPress Media Library.', 'pagelayer'),
2726 'category' => 'pagelayer-media',
2727 'input_schema' => array(
2728 'type' => 'object',
2729 'properties' => array(
2730 'url' => array('type' => 'string'),
2731 'alt_text' => array('type' => 'string'),
2732 ),
2733 'required' => array('url'),
2734 'additionalProperties' => false
2735 ),
2736 'execute' => array(__CLASS__, 'execute_upload_media'),
2737 ),
2738 );
2739
2740 foreach ($abilities as $id => $def) {
2741 self::do_register_ability('pagelayer-media/' . str_replace('_', '-', $id), $def);
2742 }
2743 }
2744
2745 protected static function do_register_ability($id, $def) {
2746 $perm = isset($def['perm']) ? $def['perm'] : array(__CLASS__, 'can_edit_posts');
2747
2748 // Every exposed tool's description and input schema is re-sent to the
2749 // model on EVERY request, so a duplicate tool costs tokens forever, not
2750 // once. Abilities marked mcp_public=false stay fully registered and
2751 // callable over the REST abilities API, but the MCP adapter leaves them
2752 // out of the advertised tool list (DefaultServerFactory filters on this
2753 // flag). Flip one back to true to re-expose it.
2754 $mcp_public = array_key_exists('mcp_public', $def) ? (bool)$def['mcp_public'] : true;
2755
2756 wp_register_ability($id, array(
2757 'label' => $def['label'],
2758 'description' => $def['description'],
2759 'category' => $def['category'],
2760 'input_schema' => $def['input_schema'],
2761 'execute_callback' => $def['execute'],
2762 'permission_callback' => $perm,
2763 'meta' => array('show_in_rest' => true, 'mcp' => array('public' => $mcp_public)),
2764 ));
2765 }
2766
2767 // ==================================================================
2768 // EXECUTE CALLBACK IMPLEMENTATIONS
2769 // ==================================================================
2770
2771 // ------------------------------------------------------------------
2772 // Widget Callbacks
2773 // ------------------------------------------------------------------
2774
2775 /**
2776 * Was a near-duplicate of get_widgets_summary that cost slightly MORE
2777 * tokens (the extra field it carried, `icon`, is a dashicon class the model
2778 * has no use for). Both now share one compact representation.
2779 */
2780 public static function execute_list_widgets($input) {
2781 return self::execute_get_widgets_summary($input);
2782 }
2783
2784 public static function execute_get_widget($input) {
2785 $widget_id = isset($input['widget']) ? sanitize_text_field($input['widget']) : '';
2786 self::ensure_shortcodes_loaded();
2787 global $pagelayer;
2788
2789 if (!isset($pagelayer->shortcodes[$widget_id])) {
2790 return new \WP_Error('invalid_widget', __('Widget not found.', 'pagelayer'));
2791 }
2792
2793 $data = $pagelayer->shortcodes[$widget_id];
2794 return array(
2795 'widget' => array(
2796 'id' => $widget_id,
2797 'name' => isset($data['name']) ? $data['name'] : $widget_id,
2798 'group' => isset($data['group']) ? $data['group'] : 'misc',
2799 'holder' => isset($data['holder']) ? $data['holder'] : '',
2800 'parent' => isset($data['parent']) ? $data['parent'] : array(),
2801 'has_group' => isset($data['has_group']) ? $data['has_group'] : array(),
2802 'settings' => isset($data['settings']) ? $data['settings'] : array(),
2803 'options' => isset($data['options']) ? $data['options'] : array(),
2804 )
2805 );
2806 }
2807
2808 public static function execute_get_widget_schema($input) {
2809 $widget_id = isset($input['widget']) ? sanitize_text_field($input['widget']) : (isset($input['widget_id']) ? sanitize_text_field($input['widget_id']) : '');
2810 $schema = self::get_widget_schema($widget_id);
2811
2812 if (!$schema) {
2813 return new \WP_Error('invalid_widget', __('Widget not found.', 'pagelayer'));
2814 }
2815
2816 if (!empty($input['verbose'])) {
2817 return array('widget' => $schema);
2818 }
2819
2820 $only = isset($input['sections']) && is_array($input['sections']) ? array_map('sanitize_text_field', $input['sections']) : array();
2821 $mode = isset($input['mode']) ? sanitize_text_field($input['mode']) : 'own';
2822
2823 return array('widget' => self::compact_widget_schema($schema, $mode, $only));
2824 }
2825
2826 /**
2827 * The ten style sections every widget inherits, emitted once instead of
2828 * once per widget. Derived from a live widget rather than hardcoded so it
2829 * cannot drift from what pagelayer_add_shortcode() actually attaches.
2830 */
2831 public static function execute_get_common_styles($input) {
2832 global $pagelayer;
2833 self::ensure_shortcodes_loaded();
2834
2835 $probe = isset($pagelayer->shortcodes['pl_heading']) ? 'pl_heading' : key($pagelayer->shortcodes);
2836 if (!$probe) {
2837 return new \WP_Error('no_widgets', __('No widgets are registered.', 'pagelayer'));
2838 }
2839
2840 $schema = self::extract_widget_schema($probe, $pagelayer->shortcodes[$probe]);
2841 $only = isset($input['sections']) && is_array($input['sections']) ? array_map('sanitize_text_field', $input['sections']) : array();
2842 $compact = self::compact_widget_schema($schema, 'shared', $only);
2843
2844 return array(
2845 'common_styles' => $compact['props'],
2846 'legend' => self::compact_legend(),
2847 'note' => 'These style props are accepted by EVERY Pagelayer widget, row and column. Fetch this once per session — get_widget_schema omits them and returns only widget-specific props. A widget may drop a few via its own "unsupported_props".',
2848 'styling_rule' => 'Styling goes in attrs, never into rich text. A style="" attribute or <style> block inside node.content or any text/editor attribute is rejected outright by the write abilities. When nothing in this list or in the widget\'s own schema covers what you need, write a real CSS rule in the "ele_css" attribute of that node using {{element}} as the selector, e.g. "{{element}} .pagelayer-heading-holder h2 { letter-spacing: 2px; }" — that is the one sanctioned place for hand-written CSS.',
2849 );
2850 }
2851
2852 /**
2853 * Dumping all 125 examples is ~9k tokens for a payload the model asked for
2854 * by accident — one forgotten argument used to cost more context than the
2855 * whole page it was about to write. Bare calls now return the widget list
2856 * instead, which is what the caller actually needed to pick one.
2857 */
2858 public static function execute_get_widget_examples($input) {
2859 $widget_id = isset($input['widget']) ? sanitize_text_field($input['widget']) : '';
2860
2861 if ($widget_id === '') {
2862 $widgets = isset($input['widgets']) && is_array($input['widgets']) ? array_map('sanitize_text_field', $input['widgets']) : array();
2863 if (empty($widgets)) {
2864 return array(
2865 'examples' => array(),
2866 'error' => 'Pass "widget" (one tag) or "widgets" (a few tags). Fetching all 125 examples costs more context than the page you are building — call list_widgets to choose first.',
2867 );
2868 }
2869
2870 $out = array();
2871 foreach (array_slice($widgets, 0, 8) as $tag) {
2872 $one = self::get_widget_examples($tag);
2873 if (!empty($one)) {
2874 $out = array_merge($out, is_array($one) ? $one : array($one));
2875 }
2876 }
2877 return array('examples' => $out);
2878 }
2879
2880 return array('examples' => self::get_widget_examples($widget_id));
2881 }
2882
2883 /**
2884 * Batch schema fetch. The unbounded form used to serialize every section of
2885 * all 125 widgets — 3.6MB, roughly 900k tokens, which no client can hold.
2886 * It now requires an explicit widget list and returns the compact form.
2887 */
2888 public static function execute_get_all_widget_schemas($input) {
2889 self::ensure_shortcodes_loaded();
2890 global $pagelayer;
2891
2892 $wanted = isset($input['widgets']) && is_array($input['widgets']) ? array_map('sanitize_text_field', $input['widgets']) : array();
2893
2894 if (empty($wanted)) {
2895 return new \WP_Error(
2896 'widgets_required',
2897 __('Pass widgets:["pl_heading","pl_btn"] — the schemas for all registered widgets are several hundred thousand tokens and will not fit in context. Use get_widgets_summary to pick the widgets you need first.', 'pagelayer')
2898 );
2899 }
2900
2901 if (count($wanted) > 12) {
2902 $wanted = array_slice($wanted, 0, 12);
2903 }
2904
2905 $out = array();
2906 $unknown = array();
2907 foreach ($wanted as $tag) {
2908 if (!isset($pagelayer->shortcodes[$tag])) {
2909 $unknown[] = $tag;
2910 continue;
2911 }
2912 $schema = self::extract_widget_schema($tag, $pagelayer->shortcodes[$tag]);
2913 $compact = self::compact_widget_schema($schema, 'own');
2914 // The legend and shared-section note are identical for every widget;
2915 // hoist them out of the per-widget payloads.
2916 unset($compact['legend'], $compact['shared_note'], $compact['shared_style_sections']);
2917 $out[$tag] = $compact;
2918 }
2919
2920 $result = array('widgets' => $out, 'legend' => self::compact_legend());
2921 if (!empty($unknown)) {
2922 $result['unknown_widgets'] = $unknown;
2923 }
2924 $result['note'] = 'Widget-specific props only. Call get_common_styles once for the style props shared by all widgets.';
2925
2926 return $result;
2927 }
2928
2929 public static function execute_get_widgets_summary($input) {
2930 self::ensure_shortcodes_loaded();
2931 global $pagelayer;
2932
2933 $group_filter = isset($input['group']) ? sanitize_text_field($input['group']) : '';
2934 $search = isset($input['search']) ? strtolower(sanitize_text_field($input['search'])) : '';
2935
2936 // One line per widget: "Display Name|group|children|parent1,parent2".
2937 // The repeated JSON keys in the old object-per-widget shape were most of
2938 // the payload, and the model needs none of them to pick a widget.
2939 $summary = array();
2940 if (!empty($pagelayer->shortcodes) && is_array($pagelayer->shortcodes)) {
2941 foreach ($pagelayer->shortcodes as $tag => $data) {
2942 $group = isset($data['group']) ? $data['group'] : 'misc';
2943 if ($group_filter && $group !== $group_filter) {
2944 continue;
2945 }
2946 $name = isset($data['name']) ? $data['name'] : $tag;
2947 if ($search && strpos(strtolower($name . ' ' . $tag), $search) === false) {
2948 continue;
2949 }
2950
2951 $line = $name . '|' . $group;
2952 if (!empty($data['holder']) || !empty($data['has_group'])) {
2953 $line .= '|children';
2954 }
2955 if (!empty($data['parent'])) {
2956 $line .= '|in:' . implode(',', (array)$data['parent']);
2957 }
2958 $summary[$tag] = $line;
2959 }
2960 }
2961
2962 return array(
2963 'widgets' => $summary,
2964 'legend' => 'tag => "Display Name|group[|children][|in:required parent tags]"',
2965 );
2966 }
2967
2968 public static function execute_get_library_sections($input) {
2969 $type = isset($input['type']) ? sanitize_text_field($input['type']) : 'sections';
2970 $url = 'https://api.pagelayer.com/library.php?give=' . rawurlencode($type);
2971
2972 $res = wp_remote_get($url, array('timeout' => 30));
2973 if (is_wp_error($res)) {
2974 return array('error' => $res->get_error_message(), 'sections' => array());
2975 }
2976
2977 $body = wp_remote_retrieve_body($res);
2978 $data = json_decode($body, true);
2979
2980 return array(
2981 'type' => $type,
2982 'library' => is_array($data) ? $data : array()
2983 );
2984 }
2985
2986 public static function execute_import_library_section($input) {
2987 $section_id = isset($input['section_id']) ? sanitize_text_field($input['section_id']) : '';
2988 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
2989
2990 if (empty($section_id) || !$post_id || !get_post($post_id)) {
2991 return new \WP_Error('invalid_input', __('Valid section_id and post_id are required.', 'pagelayer'));
2992 }
2993
2994 global $pagelayer;
2995 $license_key = !empty($pagelayer->license['license']) ? $pagelayer->license['license'] : '';
2996 $url = 'https://api.pagelayer.com/library.php?give_id=' . rawurlencode($section_id) . '&license=' . rawurlencode($license_key) . '&url=' . rawurlencode(site_url());
2997
2998 $res = wp_remote_get($url, array('timeout' => 60));
2999 if (is_wp_error($res)) {
3000 return $res;
3001 }
3002
3003 $body = wp_remote_retrieve_body($res);
3004 $data = json_decode($body, true);
3005
3006 if (empty($data['code'])) {
3007 return new \WP_Error('import_failed', __('Could not retrieve section data from library.', 'pagelayer'));
3008 }
3009
3010 if (preg_match_all('/"'.preg_quote('{{pl_lib_images}}', '/').'([^"]*)"/is', $data['code'], $matches)) {
3011 $urls = array();
3012 foreach ($matches[0] as $v) {
3013 $img_url = trim($v, '"\'');
3014 $urls[$img_url] = $img_url;
3015 }
3016 foreach ($urls as $img_url) {
3017 $filename = basename($img_url);
3018 if (!empty($data[$filename])) {
3019 $attachment_id = pagelayer_upload_media($filename, base64_decode($data[$filename]));
3020 if (!empty($attachment_id)) {
3021 $data['code'] = str_replace('"'.$img_url.'"', '"'.$attachment_id.'"', $data['code']);
3022 }
3023 }
3024 }
3025 }
3026
3027 $blocks_content = get_post_field('post_content', $post_id);
3028 $blocks_content .= "\n" . $data['code'];
3029
3030 wp_update_post(array(
3031 'ID' => $post_id,
3032 'post_content' => $blocks_content
3033 ));
3034
3035 return array(
3036 'success' => true,
3037 'section_id' => $section_id,
3038 'post_id' => $post_id,
3039 'url' => get_permalink($post_id)
3040 );
3041 }
3042
3043 /**
3044 * Extract real, raw content from a live URL: title, headings, paragraph
3045 * snippets and image URLs. This performs NO design decisions and creates
3046 * NO page — it only gives the AI real source material to work from. The
3047 * caller is expected to design the actual pagelayer_data using
3048 * list_widgets / get_widget_schema / get_widget_examples / get_data_structure
3049 * and its own judgement about layout, matching the requested niche/brand.
3050 */
3051 public static function execute_scrape_website_content($input) {
3052 $url = isset($input['url']) ? esc_url_raw($input['url']) : '';
3053 if (empty($url)) {
3054 return new \WP_Error('missing_url', __('URL is required.', 'pagelayer'));
3055 }
3056
3057 $response = wp_remote_get($url, array(
3058 'timeout' => 25,
3059 'redirection' => 5,
3060 'sslverify' => false,
3061 'headers' => array('User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
3062 ));
3063
3064 if (is_wp_error($response)) {
3065 return $response;
3066 }
3067
3068 $html = wp_remote_retrieve_body($response);
3069 if (empty($html)) {
3070 return new \WP_Error('empty_response', __('Could not retrieve any content from the target URL.', 'pagelayer'));
3071 }
3072
3073 $title = '';
3074 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $m)) {
3075 $title = trim(html_entity_decode(strip_tags($m[1])));
3076 }
3077
3078 $meta_description = '';
3079 if (preg_match('/<meta[^>]+name=["\']description["\'][^>]+content=["\']([^"\']*)["\']/is', $html, $m)) {
3080 $meta_description = trim(html_entity_decode(strip_tags($m[1])));
3081 }
3082
3083 $extract_tag_text = function($tag) use ($html) {
3084 $out = array();
3085 if (preg_match_all('/<' . $tag . '[^>]*>(.*?)<\/' . $tag . '>/is', $html, $m)) {
3086 foreach ($m[1] as $t) {
3087 $clean = trim(html_entity_decode(strip_tags($t)));
3088 if ($clean !== '') {
3089 $out[] = $clean;
3090 }
3091 }
3092 }
3093 return $out;
3094 };
3095
3096 $h1_list = $extract_tag_text('h1');
3097 $h2_list = $extract_tag_text('h2');
3098 $h3_list = $extract_tag_text('h3');
3099 $p_list = array_slice($extract_tag_text('p'), 0, 12);
3100
3101 $images = array();
3102 if (preg_match_all('/<img[^>]+src=["\']([^"\']+)["\']/is', $html, $m)) {
3103 $parsed = parse_url($url);
3104 $base = (!empty($parsed['scheme']) ? $parsed['scheme'] : 'https') . '://' . (!empty($parsed['host']) ? $parsed['host'] : '');
3105 foreach ($m[1] as $img_src) {
3106 if (strpos($img_src, 'data:image') === 0) continue;
3107 if (strpos($img_src, '//') === 0) {
3108 $img_src = 'https:' . $img_src;
3109 } elseif (strpos($img_src, 'http') !== 0) {
3110 $img_src = $base . '/' . ltrim($img_src, '/');
3111 }
3112 $images[] = $img_src;
3113 if (count($images) >= 20) break;
3114 }
3115 }
3116
3117 return array(
3118 'source_url' => $url,
3119 'title' => $title,
3120 'meta_description' => $meta_description,
3121 'headings' => array(
3122 'h1' => $h1_list,
3123 'h2' => $h2_list,
3124 'h3' => $h3_list,
3125 ),
3126 'paragraphs' => $p_list,
3127 'images' => $images,
3128 'note' => __('This is raw extracted content only. Use list_widgets, get_widget_schema, get_widget_examples, get_color_presets/get_fonts, and get_data_structure to design an original page layout around this content — do not fabricate facts (pricing, testimonials, features) that were not actually found here.', 'pagelayer'),
3129 );
3130 }
3131
3132 // ------------------------------------------------------------------
3133 // Global Style & Preset Callbacks
3134 // ------------------------------------------------------------------
3135
3136 public static function execute_get_theme_settings($input) {
3137 $options = get_option('pagelayer_options', array());
3138 $global_colors = json_decode(get_option('pagelayer_global_colors', '[]'), true);
3139 $global_fonts = json_decode(get_option('pagelayer_global_fonts', '[]'), true);
3140 $content_width = get_option('pagelayer_content_width', '1170');
3141
3142 return array(
3143 'site_name' => get_option('blogname'),
3144 'site_description' => get_option('blogdescription'),
3145 'active_theme' => wp_get_theme()->get('Name'),
3146 'content_width' => $content_width,
3147 'woocommerce_active'=> class_exists('WooCommerce'),
3148 'global_colors' => is_array($global_colors) ? $global_colors : (object) array(),
3149 'global_fonts' => is_array($global_fonts) ? $global_fonts : (object) array(),
3150 'options' => $options,
3151 );
3152 }
3153
3154 public static function execute_get_styles($input) {
3155 $global_colors = json_decode(get_option('pagelayer_global_colors', '[]'), true);
3156 $global_fonts = json_decode(get_option('pagelayer_global_fonts', '[]'), true);
3157 $content_width = get_option('pagelayer_content_width', '1170');
3158
3159 return array(
3160 'global_colors' => is_array($global_colors) ? $global_colors : (object) array(),
3161 'global_fonts' => is_array($global_fonts) ? $global_fonts : (object) array(),
3162 'content_width' => $content_width,
3163 );
3164 }
3165
3166 public static function execute_update_styles($input) {
3167 self::maybe_update_global_styles($input);
3168 return array('success' => true);
3169 }
3170
3171 public static function execute_get_icons($input) {
3172 $search = isset($input['search']) ? strtolower(sanitize_text_field($input['search'])) : '';
3173 $category = isset($input['category']) ? strtolower(sanitize_text_field($input['category'])) : '';
3174
3175 $icons = array(
3176 array('class' => 'fas fa-rocket', 'name' => 'Rocket', 'category' => 'ui'),
3177 array('class' => 'fas fa-star', 'name' => 'Star', 'category' => 'ui'),
3178 array('class' => 'fas fa-check-circle', 'name' => 'Check Circle', 'category' => 'ui'),
3179 array('class' => 'fas fa-heart', 'name' => 'Heart', 'category' => 'ui'),
3180 array('class' => 'fas fa-envelope', 'name' => 'Envelope', 'category' => 'communication'),
3181 array('class' => 'fas fa-phone-alt', 'name' => 'Phone', 'category' => 'communication'),
3182 array('class' => 'fas fa-map-marker-alt', 'name' => 'Map Marker', 'category' => 'communication'),
3183 array('class' => 'fas fa-globe', 'name' => 'Globe', 'category' => 'communication'),
3184 array('class' => 'fas fa-briefcase', 'name' => 'Briefcase', 'category' => 'business'),
3185 array('class' => 'fas fa-chart-line', 'name' => 'Chart Line', 'category' => 'business'),
3186 array('class' => 'fas fa-laptop-code', 'name' => 'Laptop Code', 'category' => 'business'),
3187 array('class' => 'fas fa-shield-alt', 'name' => 'Shield', 'category' => 'business'),
3188 array('class' => 'fab fa-twitter', 'name' => 'Twitter / X', 'category' => 'social'),
3189 array('class' => 'fab fa-facebook-f', 'name' => 'Facebook', 'category' => 'social'),
3190 array('class' => 'fab fa-instagram', 'name' => 'Instagram', 'category' => 'social'),
3191 array('class' => 'fab fa-linkedin-in', 'name' => 'LinkedIn', 'category' => 'social'),
3192 array('class' => 'fab fa-github', 'name' => 'GitHub', 'category' => 'social'),
3193 array('class' => 'fab fa-youtube', 'name' => 'YouTube', 'category' => 'social'),
3194 array('class' => 'fas fa-play', 'name' => 'Play', 'category' => 'media'),
3195 array('class' => 'fas fa-image', 'name' => 'Image', 'category' => 'media'),
3196 array('class' => 'fas fa-shopping-cart', 'name' => 'Shopping Cart', 'category' => 'ecommerce'),
3197 array('class' => 'fas fa-tag', 'name' => 'Tag', 'category' => 'ecommerce'),
3198 );
3199
3200 $filtered = array();
3201 foreach ($icons as $ico) {
3202 if ($category && strtolower($ico['category']) !== $category) {
3203 continue;
3204 }
3205 if ($search && strpos(strtolower($ico['name']), $search) === false && strpos(strtolower($ico['class']), $search) === false) {
3206 continue;
3207 }
3208 $filtered[] = $ico;
3209 }
3210
3211 return array('icons' => $filtered);
3212 }
3213
3214 public static function execute_get_fonts($input) {
3215 $system_fonts = array('Arial', 'Helvetica', 'Georgia', 'Times New Roman', 'Trebuchet MS', 'Verdana', 'Courier New', 'Impact');
3216 $google_fonts = array(
3217 array('family' => 'Inter', 'category' => 'sans-serif', 'weights' => array('300','400','500','600','700','800')),
3218 array('family' => 'Roboto', 'category' => 'sans-serif', 'weights' => array('300','400','500','700')),
3219 array('family' => 'Open Sans', 'category' => 'sans-serif', 'weights' => array('300','400','600','700')),
3220 array('family' => 'Montserrat', 'category' => 'sans-serif', 'weights' => array('400','500','600','700','800')),
3221 array('family' => 'Poppins', 'category' => 'sans-serif', 'weights' => array('300','400','500','600','700')),
3222 array('family' => 'Playfair Display', 'category' => 'serif', 'weights' => array('400','600','700','900')),
3223 array('family' => 'Merriweather', 'category' => 'serif', 'weights' => array('300','400','700')),
3224 array('family' => 'Outfit', 'category' => 'sans-serif', 'weights' => array('300','400','500','600','700')),
3225 array('family' => 'Plus Jakarta Sans', 'category' => 'sans-serif', 'weights' => array('400','500','600','700','800')),
3226 );
3227
3228 return array(
3229 'system_fonts' => $system_fonts,
3230 'google_fonts' => $google_fonts,
3231 );
3232 }
3233
3234 public static function execute_get_color_presets($input) {
3235 return array(
3236 'presets' => array(
3237 'modern_agency' => array(
3238 'title' => 'Modern Agency',
3239 'colors' => array(
3240 'primary' => array('title' => 'Primary', 'value' => '#0F172A'),
3241 'secondary' => array('title' => 'Secondary', 'value' => '#3B82F6'),
3242 'accent' => array('title' => 'Accent', 'value' => '#06B6D4'),
3243 'text' => array('title' => 'Text', 'value' => '#334155'),
3244 'bg' => array('title' => 'Background', 'value' => '#FFFFFF'),
3245 'light_bg' => array('title' => 'Light Background', 'value' => '#F8FAFC'),
3246 )
3247 ),
3248 'sleek_dark' => array(
3249 'title' => 'Sleek Dark',
3250 'colors' => array(
3251 'primary' => array('title' => 'Primary', 'value' => '#10B981'),
3252 'secondary' => array('title' => 'Secondary', 'value' => '#34D399'),
3253 'accent' => array('title' => 'Accent', 'value' => '#6EE7B7'),
3254 'text' => array('title' => 'Text', 'value' => '#F9FAFB'),
3255 'bg' => array('title' => 'Background', 'value' => '#090D16'),
3256 'light_bg' => array('title' => 'Light Background', 'value' => '#1E293B'),
3257 )
3258 ),
3259 'vibrant_tech' => array(
3260 'title' => 'Vibrant Tech',
3261 'colors' => array(
3262 'primary' => array('title' => 'Primary', 'value' => '#6366F1'),
3263 'secondary' => array('title' => 'Secondary', 'value' => '#818CF8'),
3264 'accent' => array('title' => 'Accent', 'value' => '#F43F5E'),
3265 'text' => array('title' => 'Text', 'value' => '#1E293B'),
3266 'bg' => array('title' => 'Background', 'value' => '#FFFFFF'),
3267 'light_bg' => array('title' => 'Light Background', 'value' => '#F0FDF4'),
3268 )
3269 ),
3270 'elegant_serif' => array(
3271 'title' => 'Elegant Serif',
3272 'colors' => array(
3273 'primary' => array('title' => 'Primary', 'value' => '#78350F'),
3274 'secondary' => array('title' => 'Secondary', 'value' => '#D97706'),
3275 'accent' => array('title' => 'Accent', 'value' => '#B45309'),
3276 'text' => array('title' => 'Text', 'value' => '#27272A'),
3277 'bg' => array('title' => 'Background', 'value' => '#FFFBEB'),
3278 'light_bg' => array('title' => 'Light Background', 'value' => '#FEF3C7'),
3279 )
3280 ),
3281 'warm_minimal' => array(
3282 'title' => 'Warm Minimal',
3283 'colors' => array(
3284 'primary' => array('title' => 'Primary', 'value' => '#9A3412'),
3285 'secondary' => array('title' => 'Secondary', 'value' => '#FB923C'),
3286 'accent' => array('title' => 'Accent', 'value' => '#EA580C'),
3287 'text' => array('title' => 'Text', 'value' => '#1F2937'),
3288 'bg' => array('title' => 'Background', 'value' => '#FFFFFF'),
3289 'light_bg' => array('title' => 'Light Background', 'value' => '#FAF5FF'),
3290 )
3291 )
3292 )
3293 );
3294 }
3295
3296 public static function execute_get_spacing_presets($input) {
3297 return array(
3298 'container_widths' => array('boxed' => '1170px', 'wide' => '1320px', 'full' => '100%'),
3299 'section_padding' => array('compact' => '40px,0px,40px,0px', 'medium' => '80px,0px,80px,0px', 'spacious' => '120px,0px,120px,0px'),
3300 'column_gaps' => array('narrow' => '15px', 'normal' => '30px', 'wide' => '45px'),
3301 'border_radiuses' => array('none' => '0px', 'sm' => '4px', 'md' => '8px', 'lg' => '16px', 'pill' => '9999px'),
3302 // Order is x,y,blur,COLOUR,spread,inset — the renderer reads the
3303 // colour from position 3 and the spread from position 4, appends its
3304 // own "px" to the numbers, and splits the whole value on commas.
3305 // These were previously written as "0px,4px,12px,0px,rgba(0,0,0,.05)",
3306 // which got all three of those wrong at once: doubled units, colour
3307 // and spread transposed, and an rgba() that tore in half on its own
3308 // commas — so every shadow the model was told to use rendered as
3309 // invalid CSS and silently did nothing. Alpha travels as 8-digit hex.
3310 'box_shadows' => array(
3311 'none' => 'none',
3312 'soft' => '0,4,12,#0000000d,0,',
3313 'medium' => '0,8,24,#00000014,0,',
3314 'floating' => '0,16,40,#0000001f,0,',
3315 'glow' => '0,0,20,#3b82f64d,0,',
3316 ),
3317 'box_shadow_format' => 'x,y,blur,color,spread,inset — bare numbers (the renderer appends px) and an #rrggbbaa colour, never rgba() (its commas split the value).'
3318 );
3319 }
3320
3321 /**
3322 * One or many searches. A page needs 4-8 photos and each Pexels call is a
3323 * ~400 ms network round trip plus a full model turn; batching the queries
3324 * turns "eight tool calls, eight waits" into one, which is most of the
3325 * image time in a site build.
3326 */
3327 public static function execute_search_images($input) {
3328 $queries = array();
3329 if (!empty($input['queries']) && is_array($input['queries'])) {
3330 foreach ($input['queries'] as $q) {
3331 $q = sanitize_text_field((string) $q);
3332 if ($q !== '') {
3333 $queries[] = $q;
3334 }
3335 }
3336 $queries = array_slice(array_unique($queries), 0, 10);
3337 }
3338
3339 if (!empty($queries)) {
3340 $batch = array();
3341 $per_one = isset($input['per_page']) ? $input['per_page'] : 3;
3342 foreach ($queries as $q) {
3343 $one = self::search_one_image_query(array_merge($input, array('query' => $q, 'per_page' => $per_one)));
3344 if (is_wp_error($one)) {
3345 // A key/config failure is fatal for every query — report it
3346 // once instead of ten times.
3347 if (in_array($one->get_error_code(), array('no_image_api_key', 'invalid_image_api_key'), true)) {
3348 return $one;
3349 }
3350 $batch[$q] = array('error' => $one->get_error_message());
3351 continue;
3352 }
3353 $batch[$q] = $one['results'];
3354 }
3355 return array('batch' => $batch);
3356 }
3357
3358 return self::search_one_image_query($input);
3359 }
3360
3361 protected static function search_one_image_query($input) {
3362 $query = isset($input['query']) ? sanitize_text_field($input['query']) : '';
3363 if (empty($query)) {
3364 return new \WP_Error('missing_query', __('A search query is required — pass "query" for one search or "queries" for several in one call.', 'pagelayer'));
3365 }
3366
3367 $api_key = get_option('pagelayer_pexels_api_key', '');
3368 if (empty($api_key)) {
3369 return new \WP_Error(
3370 'no_image_api_key',
3371 __('No Pexels API key is configured. Ask the site owner to add one on the Pagelayer AI Agents settings page (get a free key at https://www.pexels.com/api/), then retry search_images. Do not fall back to placeholder or made-up image URLs.', 'pagelayer')
3372 );
3373 }
3374
3375 $per_page = isset($input['per_page']) ? max(1, min(20, (int) $input['per_page'])) : 5;
3376
3377 // add_query_arg() already URL-encodes — rawurlencode()ing first sent
3378 // "wood%2520fired%2520oven" to Pexels and matched nothing.
3379 $url = add_query_arg(array(
3380 'query' => $query,
3381 'per_page' => $per_page,
3382 ), 'https://api.pexels.com/v1/search');
3383
3384 if (!empty($input['orientation']) && in_array($input['orientation'], array('landscape', 'portrait', 'square'), true)) {
3385 $url = add_query_arg('orientation', $input['orientation'], $url);
3386 }
3387
3388 $response = wp_remote_get($url, array(
3389 'timeout' => 20,
3390 'headers' => array('Authorization' => $api_key),
3391 ));
3392
3393 if (is_wp_error($response)) {
3394 return $response;
3395 }
3396
3397 $code = wp_remote_retrieve_response_code($response);
3398 $body = json_decode(wp_remote_retrieve_body($response), true);
3399
3400 if ($code === 401) {
3401 return new \WP_Error('invalid_image_api_key', __('The configured Pexels API key was rejected. Ask the site owner to check it on the Pagelayer AI Agents settings page.', 'pagelayer'));
3402 }
3403 if ($code !== 200 || !is_array($body)) {
3404 return new \WP_Error('image_search_failed', sprintf(__('Pexels search failed with status %d.', 'pagelayer'), $code));
3405 }
3406
3407 $results = array();
3408 foreach (($body['photos'] ?? array()) as $photo) {
3409 $results[] = array(
3410 'url' => isset($photo['src']['large']) ? $photo['src']['large'] : (isset($photo['src']['original']) ? $photo['src']['original'] : ''),
3411 'thumb' => isset($photo['src']['medium']) ? $photo['src']['medium'] : '',
3412 'alt' => isset($photo['alt']) && $photo['alt'] !== '' ? $photo['alt'] : $query,
3413 'width' => isset($photo['width']) ? (int) $photo['width'] : 0,
3414 'height' => isset($photo['height']) ? (int) $photo['height'] : 0,
3415 'photographer' => isset($photo['photographer']) ? $photo['photographer'] : '',
3416 );
3417 }
3418
3419 return array('query' => $query, 'results' => $results);
3420 }
3421
3422 // ------------------------------------------------------------------
3423 // Template Callbacks
3424 // ------------------------------------------------------------------
3425
3426 public static function execute_get_templates($input) {
3427 $type_filter = isset($input['type']) ? sanitize_text_field($input['type']) : '';
3428
3429 $args = array(
3430 'post_type' => 'pagelayer-template',
3431 'posts_per_page' => -1,
3432 'post_status' => array('publish', 'draft'),
3433 );
3434
3435 if (!empty($type_filter)) {
3436 $args['meta_key'] = 'pagelayer_template_type';
3437 $args['meta_value'] = $type_filter;
3438 }
3439
3440 $query = new \WP_Query($args);
3441 $templates = array();
3442
3443 foreach ($query->posts as $post) {
3444 $tt_type = get_post_meta($post->ID, 'pagelayer_template_type', true);
3445 $tt_conditions = get_post_meta($post->ID, 'pagelayer_template_conditions', true);
3446 $templates[] = array(
3447 'id' => $post->ID,
3448 'title' => $post->post_title,
3449 'type' => $tt_type ?: 'general',
3450 'status' => $post->post_status,
3451 'conditions' => is_array($tt_conditions) ? $tt_conditions : array(),
3452 );
3453 }
3454
3455 // Also check local template library
3456 $library = get_option('pagelayer_template_library', array());
3457 foreach ($library as $lib_name => $lib_data) {
3458 if (empty($type_filter) || $type_filter === 'library') {
3459 $templates[] = array(
3460 'id' => 'lib_' . sanitize_title($lib_name),
3461 'title' => $lib_name,
3462 'type' => 'library',
3463 'status' => 'publish',
3464 'conditions' => array(),
3465 );
3466 }
3467 }
3468
3469 return array('templates' => $templates);
3470 }
3471
3472 /**
3473 * Header and footer templates are site furniture: they must carry an
3474 * Include / Full Site display condition (type "include" with an empty
3475 * "template", which is what pagelayer_template_check_conditons() treats as
3476 * the general, whole-site rule — see main/template.php). An AI client that
3477 * scopes a header to "singular" or "front_page" leaves every other view
3478 * without one, with nothing to warn the site owner, so the rule is added
3479 * back if it is missing. Any exclude rules the caller sent are kept — they
3480 * still work as exceptions on top of the site-wide include.
3481 */
3482 protected static function force_full_site_condition($conditions, $type) {
3483 if (!in_array($type, array('header', 'footer'), true)) {
3484 return $conditions;
3485 }
3486
3487 if (!is_array($conditions)) {
3488 $conditions = array();
3489 }
3490
3491 foreach ($conditions as $c) {
3492 if (!is_array($c)) {
3493 continue;
3494 }
3495 $c_type = isset($c['type']) ? $c['type'] : 'include';
3496 $c_template = isset($c['template']) ? $c['template'] : '';
3497 if ($c_type === 'include' && $c_template === '') {
3498 return $conditions;
3499 }
3500 }
3501
3502 array_unshift($conditions, array('type' => 'include', 'template' => '', 'sub_template' => '', 'id' => ''));
3503 return $conditions;
3504 }
3505
3506 /**
3507 * A multi-page site needs real navigation in its header. The Primary Menu
3508 * widget (pl_wp_menu) renders a WordPress menu — hand-placed pl_btn/pl_text
3509 * links look similar in the builder but give the owner no menu to edit, no
3510 * mobile toggle, no submenus and no current-page state. So a header must
3511 * carry a pl_wp_menu bound to a real menu, unless the caller states the
3512 * site is a one-pager (single_page_site:true), where in-page anchor links
3513 * are the correct pattern.
3514 *
3515 * Skipped entirely when pl_wp_menu is not registered on this install (the
3516 * widget ships with the Pagelayer Pro add-on) — there is no point demanding
3517 * a widget that does not exist.
3518 */
3519 protected static function header_nav_gate($type, $p_data, $single_page_site = false) {
3520 if ($type !== 'header' || $single_page_site) {
3521 return null;
3522 }
3523
3524 self::ensure_shortcodes_loaded();
3525 global $pagelayer;
3526 if (empty($pagelayer->shortcodes['pl_wp_menu'])) {
3527 return null;
3528 }
3529
3530 $menu_nodes = array();
3531 $walk = function($nodes) use (&$walk, &$menu_nodes) {
3532 if (!is_array($nodes)) {
3533 return;
3534 }
3535 foreach ($nodes as $node) {
3536 if (!is_array($node) || empty($node['tag'])) {
3537 continue;
3538 }
3539 if (str_replace('pagelayer_', 'pl_', $node['tag']) === 'pl_wp_menu') {
3540 $menu_nodes[] = $node;
3541 }
3542 if (isset($node['content']) && is_array($node['content'])) {
3543 $walk($node['content']);
3544 }
3545 }
3546 };
3547 $walk($p_data);
3548
3549 if (empty($menu_nodes)) {
3550 return new \WP_Error('header_without_menu', __('A header template must contain the Primary Menu widget (tag "pl_wp_menu") — nothing was saved. Build the menu first with create_menu (it returns a menu_id), then place {"tag":"pl_wp_menu","attrs":{"nav_list":"<menu_id>","layout":"horizontal","align":"right","drop_breakpoint":"tablet","pointer":"underline"}} in the header, and style it with the widget\'s own attributes (get_widget_schema for pl_wp_menu). Hand-placed pl_btn/pl_text links are not navigation: the site owner gets no editable menu, no mobile toggle, no submenus and no current-page highlight. If the user asked for a ONE-PAGE site whose header links are in-page anchors, pass single_page_site:true to opt out.', 'pagelayer'));
3551 }
3552
3553 foreach ($menu_nodes as $node) {
3554 $nav_list = isset($node['attrs']['nav_list']) ? trim((string) $node['attrs']['nav_list']) : '';
3555 if ($nav_list === '' || $nav_list === '0') {
3556 return new \WP_Error('menu_widget_without_menu', __('The Primary Menu widget in this header has no menu selected ("nav_list" is empty), so it renders an empty menu — nothing was saved. Call create_menu with the pages this site has, then set attrs.nav_list on the pl_wp_menu node to the menu_id it returns (get_menus lists existing menus).', 'pagelayer'));
3557 }
3558
3559 if (is_numeric($nav_list) && !wp_get_nav_menu_object((int) $nav_list)) {
3560 return new \WP_Error('menu_not_found', sprintf(__('The Primary Menu widget points at nav_list "%s", which is not an existing WordPress menu — nothing was saved. Call get_menus for the real ids, or create_menu to build one.', 'pagelayer'), $nav_list));
3561 }
3562 }
3563
3564 return null;
3565 }
3566
3567 /**
3568 * Give a header/footer an explicit background drawn from the site palette.
3569 *
3570 * A theme template whose row sets no background is transparent, so it shows
3571 * the THEME's body colour — light on a stock theme — while every page below
3572 * it is dark. That is what produced a white header bar with white menu links
3573 * on it: the caller's colour choices (white nav, $text headings) were right
3574 * for the dark chrome it thought it was building, and only the background
3575 * was missing. Supplying it makes the rest correct in one move.
3576 *
3577 * Only ever fills a gap — a row that sets its own background is untouched.
3578 */
3579 protected static function theme_chrome_background($p_data, $type) {
3580 if (!in_array($type, array('header', 'footer'), true) || !is_array($p_data)) {
3581 return $p_data;
3582 }
3583
3584 $colors = json_decode((string) get_option('pagelayer_global_colors', ''), true);
3585 $colors = is_array($colors) ? $colors : array();
3586
3587 // Prefer a key the palette actually defines, darkest-intent first.
3588 $token = '';
3589 foreach (array('bg', 'secondary', 'primary') as $key) {
3590 if (!empty($colors[$key])) {
3591 $token = '$' . $key;
3592 break;
3593 }
3594 }
3595 if ($token === '') {
3596 return $p_data;
3597 }
3598
3599 $dark = self::bg_is_dark($token, $colors);
3600
3601 foreach ($p_data as $i => $node) {
3602 if (!is_array($node) || ($node['tag'] ?? '') !== 'pl_row') {
3603 continue;
3604 }
3605 $attrs = isset($node['attrs']) && is_array($node['attrs']) ? $node['attrs'] : array();
3606
3607 if (!empty($attrs['ele_bg_type'])) {
3608 continue;
3609 }
3610
3611 $attrs['ele_bg_type'] = 'color';
3612 $attrs['ele_bg_color'] = $token;
3613
3614 // pl_text has no colour control at all, so paragraph and link copy
3615 // in the chrome keeps the theme's dark default and vanishes on a
3616 // dark bar. ele_css is the sanctioned place for a rule no widget
3617 // attribute can express.
3618 if ($dark && empty($attrs['ele_css'])) {
3619 $attrs['ele_css'] = '{{element}} p, {{element}} li, {{element}} .pagelayer-text-holder{color:rgba(255,255,255,0.75)}'
3620 . '{{element}} a{color:rgba(255,255,255,0.75)}'
3621 . '{{element}} a:hover{color:var(--pagelayer-color-primary)}';
3622 }
3623
3624 $p_data[$i]['attrs'] = $attrs;
3625 }
3626
3627 self::ensure_menu_spacing($p_data);
3628
3629 return $p_data;
3630 }
3631
3632 /**
3633 * Give the navigation its item padding.
3634 *
3635 * pl_wp_menu declares horizontal_padding / vertical_padding with a default
3636 * of 10, but a default is only written into a node by the editor when the
3637 * widget is inserted — a node built through the abilities layer carries only
3638 * what was set explicitly. The menu therefore renders with no padding at all
3639 * and the links sit jammed against each other and against the edge of the
3640 * viewport.
3641 *
3642 * These are slider props whose CSS template appends its own unit, so a bare
3643 * number is correct here (unlike the padding-typed props handled by
3644 * add_missing_css_units).
3645 */
3646 protected static function ensure_menu_spacing(&$nodes) {
3647 foreach ($nodes as &$node) {
3648 if (!is_array($node)) {
3649 continue;
3650 }
3651
3652 if (($node['tag'] ?? '') === 'pl_wp_menu') {
3653 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
3654 $node['attrs'] = array();
3655 }
3656 foreach (array('horizontal_padding' => '18', 'vertical_padding' => '10') as $key => $val) {
3657 if (!isset($node['attrs'][$key]) || $node['attrs'][$key] === '') {
3658 $node['attrs'][$key] = $val;
3659 }
3660 }
3661 }
3662
3663 if (isset($node['content']) && is_array($node['content'])) {
3664 self::ensure_menu_spacing($node['content']);
3665 }
3666 }
3667 unset($node);
3668 }
3669
3670 public static function execute_create_template($input) {
3671 $title = isset($input['title']) ? sanitize_text_field($input['title']) : '';
3672 $type = isset($input['type']) ? sanitize_text_field($input['type']) : 'general';
3673 $p_data = isset($input['pagelayer_data']) && is_array($input['pagelayer_data']) ? $input['pagelayer_data'] : array();
3674
3675 $raw_conditions = isset($input['conditions']) && is_array($input['conditions']) && !empty($input['conditions']) ? $input['conditions'] : array(
3676 array('type' => 'include', 'template' => '', 'sub_template' => '', 'id' => '')
3677 );
3678 $conditions = array();
3679 foreach ($raw_conditions as $c) {
3680 if (!is_array($c)) continue;
3681 $conditions[] = array(
3682 'type' => isset($c['type']) ? sanitize_text_field($c['type']) : 'include',
3683 'template' => isset($c['template']) ? sanitize_text_field($c['template']) : '',
3684 'sub_template' => isset($c['sub_template']) ? sanitize_text_field($c['sub_template']) : '',
3685 'id' => isset($c['id']) ? sanitize_text_field($c['id']) : '',
3686 );
3687 }
3688 if (empty($conditions)) {
3689 $conditions = array(
3690 array('type' => 'include', 'template' => '', 'sub_template' => '', 'id' => '')
3691 );
3692 }
3693
3694 // A header/footer that is not displayed site-wide is a header/footer
3695 // nobody sees on most of the site.
3696 $conditions = self::force_full_site_condition($conditions, $type);
3697
3698 if (empty($title) || empty($p_data)) {
3699 return new \WP_Error('missing_params', __('Title and pagelayer_data are required.', 'pagelayer'));
3700 }
3701
3702 // Before the post is created, so a rejected template leaves nothing behind.
3703 $inline_css = self::inline_css_gate($p_data);
3704 if (is_wp_error($inline_css)) {
3705 return $inline_css;
3706 }
3707
3708 $nav_gate = self::header_nav_gate($type, $p_data, !empty($input['single_page_site']));
3709 if (is_wp_error($nav_gate)) {
3710 return $nav_gate;
3711 }
3712
3713 // The header and footer appear on every page of the site, yet they were
3714 // the only builder output the content-quality gate never saw — which is
3715 // how four pl_text "color" attrs (a prop pl_text does not have) reached
3716 // a live footer and were dropped at render, leaving default-blue links.
3717 if (empty($input['skip_validation'])) {
3718 $gate = self::quality_gate($p_data);
3719 if (is_wp_error($gate)) {
3720 return $gate;
3721 }
3722 }
3723
3724 $p_data = self::theme_chrome_background($p_data, $type);
3725
3726 $singleton_types = array('header', 'footer');
3727 $template_id = null;
3728
3729 if (in_array($type, $singleton_types, true)) {
3730 $existing = get_posts(array(
3731 'post_type' => 'pagelayer-template',
3732 'post_status' => array('publish', 'draft'),
3733 'posts_per_page' => 1,
3734 'meta_key' => 'pagelayer_template_type',
3735 'meta_value' => $type,
3736 'fields' => 'ids',
3737 ));
3738 if (!empty($existing)) {
3739 $template_id = (int) $existing[0];
3740 wp_update_post(wp_slash(array('ID' => $template_id, 'post_title' => $title)));
3741 }
3742 }
3743
3744 if (empty($template_id)) {
3745 $postarr = array(
3746 'post_title' => $title,
3747 'post_type' => 'pagelayer-template',
3748 'post_status' => 'publish',
3749 );
3750 $template_id = wp_insert_post(wp_slash($postarr), true);
3751 if (is_wp_error($template_id)) {
3752 return $template_id;
3753 }
3754 }
3755
3756 $normalized = self::normalize_layout_data($p_data);
3757 update_post_meta($template_id, 'pagelayer-data', $normalized);
3758 update_post_meta($template_id, 'pagelayer_template_type', $type);
3759 update_post_meta($template_id, 'pagelayer_template_conditions', $conditions);
3760
3761 $blocks_content = self::serialize_layout_to_blocks($normalized);
3762 wp_update_post(array('ID' => $template_id, 'post_content' => $blocks_content));
3763
3764 return array(
3765 'template_id' => $template_id,
3766 'title' => $title,
3767 'type' => $type,
3768 'success' => true,
3769 );
3770 }
3771
3772 public static function execute_update_template($input) {
3773 $template_id = isset($input['template_id']) ? (int) $input['template_id'] : 0;
3774 if (!$template_id || get_post_type($template_id) !== 'pagelayer-template') {
3775 return new \WP_Error('invalid_template', __('Template not found.', 'pagelayer'));
3776 }
3777
3778 $type = isset($input['type'])
3779 ? sanitize_text_field($input['type'])
3780 : (string) get_post_meta($template_id, 'pagelayer_template_type', true);
3781
3782 if (isset($input['title'])) {
3783 wp_update_post(array('ID' => $template_id, 'post_title' => sanitize_text_field($input['title'])));
3784 }
3785 if (isset($input['type'])) {
3786 update_post_meta($template_id, 'pagelayer_template_type', $type);
3787 }
3788 if (isset($input['conditions']) && is_array($input['conditions'])) {
3789 update_post_meta($template_id, 'pagelayer_template_conditions', self::force_full_site_condition($input['conditions'], $type));
3790 }
3791 if (isset($input['pagelayer_data']) && is_array($input['pagelayer_data'])) {
3792 $inline_css = self::inline_css_gate($input['pagelayer_data']);
3793 if (is_wp_error($inline_css)) {
3794 return $inline_css;
3795 }
3796
3797 $nav_gate = self::header_nav_gate($type, $input['pagelayer_data'], !empty($input['single_page_site']));
3798 if (is_wp_error($nav_gate)) {
3799 return $nav_gate;
3800 }
3801
3802 if (empty($input['skip_validation'])) {
3803 $gate = self::quality_gate($input['pagelayer_data']);
3804 if (is_wp_error($gate)) {
3805 return $gate;
3806 }
3807 }
3808
3809 $normalized = self::normalize_layout_data(self::theme_chrome_background($input['pagelayer_data'], $type));
3810 update_post_meta($template_id, 'pagelayer-data', $normalized);
3811 $blocks_content = self::serialize_layout_to_blocks($normalized);
3812 wp_update_post(array('ID' => $template_id, 'post_content' => $blocks_content));
3813 }
3814
3815 return array('success' => true);
3816 }
3817
3818 public static function execute_delete_template($input) {
3819 $template_id = isset($input['template_id']) ? (int) $input['template_id'] : 0;
3820 if (!$template_id || get_post_type($template_id) !== 'pagelayer-template') {
3821 return new \WP_Error('invalid_template', __('Template not found.', 'pagelayer'));
3822 }
3823
3824 $res = wp_delete_post($template_id, true);
3825 return array('success' => (bool)$res);
3826 }
3827
3828 // ------------------------------------------------------------------
3829 // Navigation Menu Callbacks
3830 // ------------------------------------------------------------------
3831 //
3832 // pl_wp_menu ("Primary Menu") renders a real WordPress nav menu picked by
3833 // term id in its "nav_list" attr, and the per-item Mega Menu settings live
3834 // on the menu ITEM as a serialized pagelayer/pl_nav_menu_item block in its
3835 // "_pagelayer_content" meta (see main/nav_walker.php). Both are built here.
3836
3837 /**
3838 * Accepts a term id, slug or name.
3839 */
3840 protected static function resolve_menu($ref) {
3841 if ($ref === '' || $ref === null) {
3842 return false;
3843 }
3844 $menu = wp_get_nav_menu_object(is_numeric($ref) ? (int) $ref : $ref);
3845 return $menu ? $menu : false;
3846 }
3847
3848 protected static function menu_items_tree($menu) {
3849 $items = wp_get_nav_menu_items($menu->term_id);
3850 if (!is_array($items)) {
3851 return array();
3852 }
3853
3854 $by_parent = array();
3855 foreach ($items as $item) {
3856 $by_parent[(int) $item->menu_item_parent][] = $item;
3857 }
3858
3859 $build = function($parent_id) use (&$build, $by_parent) {
3860 $out = array();
3861 if (empty($by_parent[$parent_id])) {
3862 return $out;
3863 }
3864 foreach ($by_parent[$parent_id] as $item) {
3865 $row = array(
3866 'item_id' => (int) $item->ID,
3867 'title' => $item->title,
3868 'url' => $item->url,
3869 'object' => $item->object,
3870 'object_id' => (int) $item->object_id,
3871 );
3872
3873 $settings = self::read_menu_item_settings($item->ID);
3874 if (!empty($settings)) {
3875 $row['pagelayer_settings'] = $settings;
3876 }
3877
3878 $children = $build((int) $item->ID);
3879 if (!empty($children)) {
3880 $row['children'] = $children;
3881 }
3882 $out[] = $row;
3883 }
3884 return $out;
3885 };
3886
3887 return $build(0);
3888 }
3889
3890 /**
3891 * The pl_nav_menu_item attrs stored on a menu item, without the mega-menu
3892 * body (which can be a whole layout tree and is not worth echoing back).
3893 */
3894 protected static function read_menu_item_settings($item_id) {
3895 $content = get_post_meta($item_id, '_pagelayer_content', true);
3896 if (empty($content) || !function_exists('parse_blocks')) {
3897 return array();
3898 }
3899
3900 foreach (parse_blocks($content) as $block) {
3901 if (empty($block['blockName']) || $block['blockName'] !== 'pagelayer/pl_nav_menu_item') {
3902 continue;
3903 }
3904 $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : array();
3905 unset($attrs['pagelayer-id']);
3906 if (!empty($block['innerBlocks'])) {
3907 $attrs['has_mega_content'] = true;
3908 }
3909 return $attrs;
3910 }
3911
3912 return array();
3913 }
3914
3915 public static function execute_get_menus($input) {
3916 $locations = function_exists('get_registered_nav_menus') ? get_registered_nav_menus() : array();
3917 $assignments = function_exists('get_nav_menu_locations') ? (array) get_nav_menu_locations() : array();
3918
3919 $wanted = isset($input['menu']) ? sanitize_text_field($input['menu']) : '';
3920 $menus = array();
3921
3922 foreach (wp_get_nav_menus() as $menu) {
3923 if ($wanted !== '' && (string) $menu->term_id !== $wanted && $menu->slug !== $wanted && $menu->name !== $wanted) {
3924 continue;
3925 }
3926
3927 $assigned = array();
3928 foreach ($assignments as $slug => $term_id) {
3929 if ((int) $term_id === (int) $menu->term_id) {
3930 $assigned[] = $slug;
3931 }
3932 }
3933
3934 $menus[] = array(
3935 'menu_id' => (int) $menu->term_id,
3936 'name' => $menu->name,
3937 'slug' => $menu->slug,
3938 'item_count' => (int) $menu->count,
3939 'locations' => $assigned,
3940 'items' => self::menu_items_tree($menu),
3941 );
3942 }
3943
3944 $location_rows = array();
3945 foreach ($locations as $slug => $label) {
3946 $location_rows[] = array(
3947 'slug' => $slug,
3948 'label' => $label,
3949 'assigned_menu_id' => isset($assignments[$slug]) ? (int) $assignments[$slug] : 0,
3950 );
3951 }
3952
3953 return array(
3954 'menus' => $menus,
3955 'locations' => $location_rows,
3956 'note' => 'Put a "menu_id" from this list in the pl_wp_menu (Primary Menu) widget\'s "nav_list" attribute — that widget has no item list of its own, it renders the WordPress menu you point it at. Build or rebuild a menu with create_menu.',
3957 );
3958 }
3959
3960 public static function execute_create_menu($input) {
3961 $name = isset($input['name']) ? sanitize_text_field($input['name']) : '';
3962 if ($name === '') {
3963 return new \WP_Error('missing_name', __('A menu name is required.', 'pagelayer'));
3964 }
3965
3966 $items = isset($input['items']) && is_array($input['items']) ? $input['items'] : array();
3967 if (empty($items)) {
3968 return new \WP_Error('missing_items', __('At least one menu item is required.', 'pagelayer'));
3969 }
3970
3971 // Mega-menu bodies are layout data like any other, so they answer to the
3972 // same inline-CSS rule.
3973 $inline_css_found = array();
3974 $scrub_mega = function($rows) use (&$scrub_mega, &$inline_css_found) {
3975 foreach ($rows as $i => $row) {
3976 if (!is_array($row)) {
3977 continue;
3978 }
3979 if (isset($row['mega_content']) && is_array($row['mega_content'])) {
3980 self::scrub_layout_inline_css($row['mega_content'], $inline_css_found);
3981 }
3982 if (isset($row['children']) && is_array($row['children'])) {
3983 $row['children'] = $scrub_mega($row['children']);
3984 }
3985 $rows[$i] = $row;
3986 }
3987 return $rows;
3988 };
3989 $items = $scrub_mega($items);
3990 if (!empty($inline_css_found)) {
3991 return self::inline_css_error($inline_css_found);
3992 }
3993
3994 $menu = self::resolve_menu($name);
3995 if (!$menu) {
3996 $menu_id = wp_create_nav_menu($name);
3997 if (is_wp_error($menu_id)) {
3998 return $menu_id;
3999 }
4000 $menu = wp_get_nav_menu_object($menu_id);
4001 }
4002
4003 if (!$menu) {
4004 return new \WP_Error('menu_failed', __('The navigation menu could not be created.', 'pagelayer'));
4005 }
4006
4007 $replace = !isset($input['replace_items']) || !empty($input['replace_items']);
4008 if ($replace) {
4009 foreach ((array) wp_get_nav_menu_items($menu->term_id) as $existing) {
4010 wp_delete_post($existing->ID, true);
4011 }
4012 }
4013
4014 $created = array();
4015 $failures = array();
4016
4017 $insert = function($rows, $parent_id) use (&$insert, $menu, &$created, &$failures) {
4018 $out = array();
4019
4020 foreach ($rows as $row) {
4021 if (!is_array($row)) {
4022 continue;
4023 }
4024
4025 $item_id = self::insert_menu_item($menu->term_id, $parent_id, $row);
4026 if (is_wp_error($item_id)) {
4027 $failures[] = array(
4028 'title' => isset($row['title']) ? $row['title'] : '',
4029 'error' => $item_id->get_error_message(),
4030 );
4031 continue;
4032 }
4033
4034 $entry = array(
4035 'item_id' => $item_id,
4036 'title' => isset($row['title']) ? $row['title'] : '',
4037 );
4038
4039 if (!empty($row['children']) && is_array($row['children'])) {
4040 $entry['children'] = $insert($row['children'], $item_id);
4041 }
4042
4043 $created[] = $item_id;
4044 $out[] = $entry;
4045 }
4046
4047 return $out;
4048 };
4049
4050 $tree = $insert($items, 0);
4051
4052 $assigned_location = '';
4053 if (!empty($input['location'])) {
4054 $location = sanitize_text_field($input['location']);
4055 $registered = function_exists('get_registered_nav_menus') ? get_registered_nav_menus() : array();
4056
4057 if (!isset($registered[$location])) {
4058 $failures[] = array(
4059 'title' => '',
4060 'error' => sprintf(__('Theme location "%1$s" is not registered by this theme. Registered: %2$s', 'pagelayer'), $location, implode(', ', array_keys($registered)) ?: '-'),
4061 );
4062 } else {
4063 $locations = (array) get_nav_menu_locations();
4064 $locations[$location] = (int) $menu->term_id;
4065 set_theme_mod('nav_menu_locations', $locations);
4066 $assigned_location = $location;
4067 }
4068 }
4069
4070 $result = array(
4071 'success' => empty($failures),
4072 'menu_id' => (int) $menu->term_id,
4073 'name' => $menu->name,
4074 'items' => $tree,
4075 'assigned_location' => $assigned_location,
4076 'next_step' => sprintf('Set the Primary Menu widget\'s nav_list attribute to %d: {"tag":"pl_wp_menu","attrs":{"nav_list":"%d", ...}}', $menu->term_id, $menu->term_id),
4077 );
4078
4079 if (!empty($failures)) {
4080 $result['failed'] = $failures;
4081 }
4082
4083 return $result;
4084 }
4085
4086 /**
4087 * One nav menu item plus, when the item carries Pagelayer settings, the
4088 * pl_nav_menu_item block that the nav walker reads them from.
4089 */
4090 protected static function insert_menu_item($menu_id, $parent_id, $row) {
4091 $title = isset($row['title']) ? sanitize_text_field($row['title']) : '';
4092 $args = array(
4093 'menu-item-title' => $title,
4094 'menu-item-parent-id' => (int) $parent_id,
4095 'menu-item-status' => 'publish',
4096 );
4097
4098 $object_id = 0;
4099 foreach (array('page_id', 'post_id', 'object_id') as $key) {
4100 if (!empty($row[$key])) {
4101 $object_id = (int) $row[$key];
4102 break;
4103 }
4104 }
4105
4106 if ($object_id > 0) {
4107 $post = get_post($object_id);
4108 if (!$post) {
4109 return new \WP_Error('invalid_menu_target', sprintf(__('Menu item "%1$s" points at post %2$d, which does not exist.', 'pagelayer'), $title, $object_id));
4110 }
4111 $args['menu-item-type'] = 'post_type';
4112 $args['menu-item-object'] = $post->post_type;
4113 $args['menu-item-object-id'] = $object_id;
4114 if ($title === '') {
4115 $args['menu-item-title'] = get_the_title($object_id);
4116 }
4117 } else {
4118 $url = isset($row['url']) ? esc_url_raw($row['url']) : '';
4119 if ($url === '' && $title === '') {
4120 return new \WP_Error('invalid_menu_item', __('A menu item needs a title plus either page_id or url.', 'pagelayer'));
4121 }
4122 $args['menu-item-type'] = 'custom';
4123 $args['menu-item-url'] = $url !== '' ? $url : '#';
4124 }
4125
4126 if (!empty($row['target'])) {
4127 $args['menu-item-target'] = sanitize_text_field($row['target']);
4128 }
4129 if (!empty($row['description'])) {
4130 $args['menu-item-description'] = sanitize_text_field($row['description']);
4131 }
4132
4133 $item_id = wp_update_nav_menu_item($menu_id, 0, $args);
4134 if (is_wp_error($item_id)) {
4135 return $item_id;
4136 }
4137
4138 $content = self::menu_item_settings_block($row);
4139 if ($content !== '') {
4140 update_post_meta($item_id, '_pagelayer_content', $content);
4141 }
4142
4143 return (int) $item_id;
4144 }
4145
4146 /**
4147 * Serialized pagelayer/pl_nav_menu_item block for one item's settings. Its
4148 * inner blocks are the Mega Menu body, which is what makes the walker treat
4149 * the item as a mega item at all (menu_type alone is not enough — it also
4150 * checks that the block has content).
4151 */
4152 protected static function menu_item_settings_block($row) {
4153 $allowed = array('menu_type', 'mega_width', 'mega_custom_width', 'columns', 'col_gap', 'menu_icon', 'icon_position', 'highlight_label', 'disable_link');
4154 $attrs = array();
4155
4156 foreach ($allowed as $key) {
4157 if (isset($row[$key]) && $row[$key] !== '' && !is_array($row[$key])) {
4158 $attrs[$key] = is_bool($row[$key]) ? ($row[$key] ? 'true' : '') : sanitize_text_field($row[$key]);
4159 }
4160 }
4161
4162 $mega = isset($row['mega_content']) && is_array($row['mega_content']) ? $row['mega_content'] : array();
4163
4164 if (empty($attrs) && empty($mega)) {
4165 return '';
4166 }
4167
4168 if (!empty($mega) && empty($attrs['menu_type'])) {
4169 $attrs['menu_type'] = 'mega';
4170 }
4171
4172 $node = array(
4173 'tag' => 'pl_nav_menu_item',
4174 'attrs' => $attrs,
4175 );
4176
4177 if (!empty($mega)) {
4178 $node['content'] = self::normalize_layout_data($mega);
4179 }
4180
4181 return self::serialize_layout_to_blocks(array($node));
4182 }
4183
4184 public static function execute_delete_menu($input) {
4185 $menu = self::resolve_menu(isset($input['menu']) ? sanitize_text_field($input['menu']) : '');
4186 if (!$menu) {
4187 return new \WP_Error('invalid_menu', __('Navigation menu not found.', 'pagelayer'));
4188 }
4189
4190 $res = wp_delete_nav_menu($menu->term_id);
4191 if (is_wp_error($res)) {
4192 return $res;
4193 }
4194
4195 return array('success' => (bool) $res, 'menu_id' => (int) $menu->term_id);
4196 }
4197
4198 // ------------------------------------------------------------------
4199 // Page Callbacks
4200 // ------------------------------------------------------------------
4201
4202 public static function execute_create_page($input) {
4203 $input['post_type'] = 'page';
4204 return self::create_post_or_page($input);
4205 }
4206
4207 public static function execute_update_page($input) {
4208 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
4209 if (!$post_id || get_post_type($post_id) !== 'page') {
4210 return new \WP_Error('invalid_page', __('Page not found.', 'pagelayer'));
4211 }
4212
4213 if (isset($input['title'])) {
4214 wp_update_post(array('ID' => $post_id, 'post_title' => sanitize_text_field($input['title'])));
4215 }
4216 if (isset($input['status'])) {
4217 wp_update_post(array('ID' => $post_id, 'post_status' => sanitize_text_field($input['status'])));
4218 }
4219 if (isset($input['pagelayer_data']) && is_array($input['pagelayer_data'])) {
4220 $inline_css = self::inline_css_gate($input['pagelayer_data']);
4221 if (is_wp_error($inline_css)) {
4222 return $inline_css;
4223 }
4224
4225 $normalized = self::normalize_layout_data($input['pagelayer_data']);
4226
4227 if (empty($input['skip_validation'])) {
4228 $gate = self::quality_gate($normalized);
4229 if (is_wp_error($gate)) {
4230 return $gate;
4231 }
4232 }
4233
4234 update_post_meta($post_id, 'pagelayer-data', $normalized);
4235 $blocks_content = self::serialize_layout_to_blocks($normalized);
4236 wp_update_post(array('ID' => $post_id, 'post_content' => $blocks_content));
4237 }
4238
4239 return array('success' => true, 'post_id' => $post_id, 'url' => get_permalink($post_id));
4240 }
4241
4242 public static function execute_get_page($input) {
4243 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
4244 $post = get_post($post_id);
4245 if (!$post || $post->post_type !== 'page') {
4246 return new \WP_Error('invalid_page', __('Page not found.', 'pagelayer'));
4247 }
4248
4249 $data = get_post_meta($post_id, 'pagelayer-data', true);
4250 $data = is_array($data) ? $data : array();
4251
4252 $result = array(
4253 'post_id' => $post->ID,
4254 'title' => $post->post_title,
4255 'status' => $post->post_status,
4256 'url' => get_permalink($post->ID),
4257 'edit_url' => admin_url('post.php?post=' . $post->ID . '&action=edit'),
4258 );
4259
4260 // One element's full node — the normal read before an update_element.
4261 if (!empty($input['element_id'])) {
4262 $node = self::find_node_by_id($data, sanitize_text_field($input['element_id']));
4263 if ($node === null) {
4264 return new \WP_Error('not_found', sprintf(__('Element %s not found on this page.', 'pagelayer'), $input['element_id']));
4265 }
4266 $result['element'] = $node;
4267 return $result;
4268 }
4269
4270 // Default is the outline, not the tree. A real page's pagelayer_data is
4271 // tens of KB of style attrs that a targeted edit never reads; the model
4272 // asks for mode:"full" on the rare occasion it needs all of it.
4273 $mode = isset($input['mode']) ? sanitize_text_field($input['mode']) : 'outline';
4274
4275 if ($mode === 'full') {
4276 $result['pagelayer_data'] = $data;
4277 return $result;
4278 }
4279
4280 $lines = array();
4281 $result['outline'] = self::outline_nodes($data, 0, $lines);
4282 $result['legend'] = 'One line per node: [indent = nesting depth] <ref> <tag> [col=N] "text preview". <ref> is the node\'s pagelayer-id, or an "@0.1.2" position path when it has none yet — either form works as element_id/parent_id in every element ability. To read one node in full pass element_id; mode:"full" returns the entire raw tree (large).';
4283
4284 return $result;
4285 }
4286
4287 public static function execute_list_pages($input) {
4288 $limit = isset($input['limit']) ? (int) $input['limit'] : 20;
4289 $status = isset($input['status']) ? sanitize_text_field($input['status']) : 'any';
4290
4291 $query = new \WP_Query(array(
4292 'post_type' => 'page',
4293 'posts_per_page' => $limit,
4294 'post_status' => $status,
4295 'meta_query' => array(
4296 array('key' => 'pagelayer-data', 'compare' => 'EXISTS'),
4297 ),
4298 ));
4299
4300 $pages = array();
4301 foreach ($query->posts as $post) {
4302 $pages[] = array(
4303 'id' => $post->ID,
4304 'title' => $post->post_title,
4305 'status' => $post->post_status,
4306 'url' => get_permalink($post->ID),
4307 );
4308 }
4309
4310 return array('pages' => $pages);
4311 }
4312
4313 public static function execute_publish_page($input) {
4314 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
4315 if (!$post_id || !get_post($post_id)) {
4316 return new \WP_Error('invalid_post', __('Post or page not found.', 'pagelayer'));
4317 }
4318
4319 wp_update_post(array('ID' => $post_id, 'post_status' => 'publish'));
4320 return array('success' => true, 'url' => get_permalink($post_id));
4321 }
4322
4323 public static function execute_duplicate_page($input) {
4324 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
4325 $post = get_post($post_id);
4326 if (!$post) {
4327 return new \WP_Error('invalid_post', __('Post or page not found.', 'pagelayer'));
4328 }
4329
4330 $title = isset($input['title']) ? sanitize_text_field($input['title']) : $post->post_title . ' (Copy)';
4331
4332 $new_id = wp_insert_post(array(
4333 'post_title' => $title,
4334 'post_type' => $post->post_type,
4335 'post_status' => 'draft',
4336 'post_content' => $post->post_content,
4337 ));
4338
4339 if (is_wp_error($new_id)) {
4340 return $new_id;
4341 }
4342
4343 $data = get_post_meta($post_id, 'pagelayer-data', true);
4344 if (is_array($data)) {
4345 $refresh_ids = function(&$node) use (&$refresh_ids) {
4346 if (!is_array($node)) return;
4347 if (isset($node['attrs']['pagelayer-id'])) {
4348 $node['attrs']['pagelayer-id'] = pagelayer_create_id();
4349 }
4350 if (isset($node['content']) && is_array($node['content'])) {
4351 foreach ($node['content'] as &$child) {
4352 $refresh_ids($child);
4353 }
4354 unset($child);
4355 }
4356 };
4357 foreach ($data as &$node) {
4358 $refresh_ids($node);
4359 }
4360 unset($node);
4361
4362 $normalized = self::normalize_layout_data($data);
4363 update_post_meta($new_id, 'pagelayer-data', $normalized);
4364 wp_update_post(array('ID' => $new_id, 'post_content' => self::serialize_layout_to_blocks($normalized)));
4365 }
4366
4367 return array(
4368 'success' => true,
4369 'post_id' => $new_id,
4370 'url' => get_permalink($new_id),
4371 );
4372 }
4373
4374 public static function execute_delete_page($input) {
4375 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
4376 $force = !empty($input['force']);
4377 if (!$post_id || get_post_type($post_id) !== 'page') {
4378 return new \WP_Error('invalid_page', __('Page not found.', 'pagelayer'));
4379 }
4380
4381 $res = wp_delete_post($post_id, $force);
4382 return array('success' => (bool)$res);
4383 }
4384
4385 public static function execute_validate_page($input) {
4386 $p_data = null;
4387 if (isset($input['pagelayer_data']) && is_array($input['pagelayer_data'])) {
4388 $p_data = $input['pagelayer_data'];
4389 } elseif (!empty($input['post_id'])) {
4390 $p_data = get_post_meta((int)$input['post_id'], 'pagelayer-data', true);
4391 }
4392
4393 if (!is_array($p_data)) {
4394 return new \WP_Error('invalid_input', __('No pagelayer_data or valid post_id provided for validation.', 'pagelayer'));
4395 }
4396
4397 $result = self::run_layout_validation($p_data);
4398
4399 return array(
4400 'valid' => $result['valid'],
4401 'score' => $result['score'],
4402 'summary' => $result['valid'] ? sprintf(__('Validation passed cleanly with quality score %d/100.', 'pagelayer'), $result['score']) : sprintf(__('Validation failed with %d errors and %d warnings.', 'pagelayer'), count($result['errors']), count($result['warnings'])),
4403 'checks' => array(
4404 'widget_compatibility' => count($result['errors']) === 0,
4405 'hierarchy' => count($result['errors']) === 0,
4406 'accessibility' => count($result['warnings']) === 0,
4407 'seo' => $result['h1_count'] === 1,
4408 'global_styles' => $result['global_refs'] > 0,
4409 ),
4410 'errors' => $result['errors'],
4411 'warnings' => $result['warnings'],
4412 'suggestions' => $result['suggestions'],
4413 );
4414 }
4415
4416 /**
4417 * Shared node-walking validation used by both the public validate_page
4418 * ability and the internal quality_gate() enforcement in the write paths.
4419 */
4420 /**
4421 * Whether one property's `req` map is satisfied by a node's attrs — the
4422 * same test pagelayer_render_shortcode() applies before it decides to keep
4423 * or discard a gated attribute. A leading "!" on the key negates it.
4424 */
4425 public static function deps_satisfied($requires, $attrs) {
4426 if (!is_array($requires)) {
4427 return true;
4428 }
4429
4430 foreach ($requires as $dep_key => $dep_val) {
4431 $negated = (isset($dep_key[0]) && $dep_key[0] === '!');
4432 $dep_key = $negated ? substr($dep_key, 1) : $dep_key;
4433 $current = isset($attrs[$dep_key]) ? $attrs[$dep_key] : '';
4434
4435 $matched = is_array($dep_val)
4436 ? in_array($current, $dep_val, false)
4437 : ((string) $dep_val === (string) $current);
4438
4439 if ($negated ? $matched : !$matched) {
4440 return false;
4441 }
4442 }
4443
4444 return true;
4445 }
4446
4447 public static function run_layout_validation($p_data) {
4448 self::ensure_shortcodes_loaded();
4449 global $pagelayer;
4450
4451 $registered_tags = array_keys($pagelayer->shortcodes ?: array());
4452 $structural_tags = array('pl_row', 'pl_col', 'pl_inner_row', 'pl_inner_col', 'pagelayer_section', 'pagelayer_row', 'pagelayer_col');
4453
4454 $errors = array();
4455 $warnings = array();
4456 $suggestions = array();
4457 $h1_count = 0;
4458 $global_refs = 0;
4459 $total_nodes = 0;
4460
4461 $schema_cache = array();
4462
4463 $walk = function($nodes, $parent_tag = '') use (&$walk, &$errors, &$warnings, &$suggestions, &$h1_count, &$global_refs, &$total_nodes, &$schema_cache, $registered_tags, $structural_tags, $pagelayer) {
4464 if (!is_array($nodes)) return;
4465
4466 foreach ($nodes as $node) {
4467 if (!is_array($node) || empty($node['tag'])) continue;
4468 $total_nodes++;
4469 $tag = $node['tag'];
4470 $clean_tag = str_replace('pagelayer_', 'pl_', $tag);
4471 $attrs = isset($node['attrs']) && is_array($node['attrs']) ? $node['attrs'] : array();
4472 $node_id = isset($attrs['pagelayer-id']) ? $attrs['pagelayer-id'] : '';
4473
4474 // 1. Widget compatibility check
4475 if (!in_array($clean_tag, $registered_tags, true) && !in_array($clean_tag, $structural_tags, true) && !in_array($tag, $structural_tags, true)) {
4476 $errors[] = array(
4477 'element_id' => $node_id,
4478 'widget' => $tag,
4479 'issue' => 'Unregistered or unsupported widget tag',
4480 'recommendation' => 'Replace with a widget returned by list_widgets()'
4481 );
4482 }
4483
4484 // 2. Hierarchy check
4485 if ($clean_tag === 'pl_row') {
4486 $has_col = false;
4487 if (isset($node['content']) && is_array($node['content'])) {
4488 foreach ($node['content'] as $child) {
4489 if (is_array($child) && isset($child['tag']) && str_replace('pagelayer_', 'pl_', $child['tag']) === 'pl_col') {
4490 $has_col = true;
4491 break;
4492 }
4493 }
4494 }
4495 if (!$has_col) {
4496 $errors[] = array(
4497 'element_id' => $node_id,
4498 'widget' => $tag,
4499 'issue' => 'Row contains no Column children',
4500 'recommendation' => 'Place widgets inside pl_col nodes'
4501 );
4502 }
4503 }
4504
4505 if (!in_array($clean_tag, array('pl_row', 'pl_col', 'pl_inner_row', 'pl_inner_col')) && $parent_tag === 'pl_row') {
4506 $errors[] = array(
4507 'element_id' => $node_id,
4508 'widget' => $tag,
4509 'issue' => 'Widget placed directly inside Row without Column wrapper',
4510 'recommendation' => 'Wrap widget inside pl_col node'
4511 );
4512 }
4513
4514 // 3. SEO Heading check
4515 if ($clean_tag === 'pl_heading') {
4516 $content = isset($node['content']) && is_string($node['content']) ? $node['content'] : '';
4517 if (strpos($content, '<h1') !== false || (isset($attrs['heading_type']) && $attrs['heading_type'] === 'h1')) {
4518 $h1_count++;
4519 }
4520 }
4521
4522 // 4. Accessibility check — pl_image's alt attr is "id-alt"
4523 // (bound to the "id" image-source field), not "alt".
4524 if ($clean_tag === 'pl_image') {
4525 if (empty($attrs['id-alt'])) {
4526 $warnings[] = array(
4527 'element_id' => $node_id,
4528 'widget' => $tag,
4529 'issue' => 'Image missing alt text for accessibility',
4530 'recommendation' => 'Add an "id-alt" attribute with descriptive alt text'
4531 );
4532 }
4533 }
4534
4535 // 5. Global style token check
4536 foreach ($attrs as $k => $v) {
4537 if (is_string($v) && strpos($v, '$') === 0) {
4538 $global_refs++;
4539 }
4540 }
4541
4542 // 4b. Menu widget sanity. An unconfigured Primary Menu renders,
4543 // but as a plain unstyled list that overflows on phones.
4544 if ($clean_tag === 'pl_wp_menu') {
4545 $nav_list = isset($attrs['nav_list']) ? trim((string) $attrs['nav_list']) : '';
4546 if ($nav_list === '' || $nav_list === '0') {
4547 $errors[] = array(
4548 'element_id' => $node_id,
4549 'widget' => $tag,
4550 'issue' => 'Primary Menu widget has no menu selected — it renders an empty menu',
4551 'recommendation' => 'Set attrs.nav_list to a WordPress menu id (get_menus lists them, create_menu builds one)'
4552 );
4553 }
4554 $layout = isset($attrs['layout']) ? $attrs['layout'] : '';
4555 if (empty($attrs['drop_breakpoint']) && $layout !== 'dropdown') {
4556 $warnings[] = array(
4557 'element_id' => $node_id,
4558 'widget' => $tag,
4559 'issue' => 'Primary Menu has no drop_breakpoint, so it never collapses into a mobile toggle',
4560 'recommendation' => 'Set attrs.drop_breakpoint to "tablet" (or "mobile") unless the menu is deliberately always expanded'
4561 );
4562 }
4563 }
4564
4565 // 5a. Inline CSS in rich text. Layouts written through the
4566 // abilities are scrubbed of this at the input boundary, so
4567 // anything reaching here was authored elsewhere — report it,
4568 // never rewrite it (see the inline-CSS guard).
4569 foreach (self::inline_css_hits($node) as $hit) {
4570 $warnings[] = array(
4571 'element_id' => $node_id,
4572 'widget' => $tag,
4573 'issue' => sprintf('Inline CSS in rich text (%s): "%s" — it overrides the widget\'s own controls and cannot be changed from the builder UI', $hit['where'], $hit['css']),
4574 'recommendation' => 'Drop the style="" attribute and set the equivalent widget attribute instead (get_widget_schema / get_common_styles); if no control covers it, move the rule into "ele_css" using {{element}} as the selector.'
4575 );
4576 }
4577
4578 // 5b. Attribute names + render-time dependencies. Both failure
4579 // modes below produce a page that renders without the requested
4580 // styling and without any error, so catch them here.
4581 $rules = self::widget_attr_rules($clean_tag);
4582 if (is_array($rules)) {
4583 $reserved = array('pagelayer-id' => 1, 'pagelayer-srcset' => 1, 'global_id' => 1, 'is_not_sc' => 1);
4584
4585 foreach ($attrs as $attr_key => $attr_val) {
4586 if (isset($reserved[$attr_key]) || isset($rules['allowed'][$attr_key])) {
4587 continue;
4588 }
4589
4590 // Image/link props expose derived keys (id-alt, id-title,
4591 // ele_bg_img-url, ...) that are not declared separately.
4592 $dash = strrpos($attr_key, '-');
4593 if ($dash !== false && isset($rules['allowed'][substr($attr_key, 0, $dash)])) {
4594 continue;
4595 }
4596
4597 $errors[] = array(
4598 'element_id' => $node_id,
4599 'widget' => $tag,
4600 'issue' => sprintf('"%s" is not an attribute of %s — it will be dropped at render and the styling will not appear', $attr_key, $tag),
4601 'recommendation' => sprintf('Call get_widget_schema({"widget":"%s"}) and use one of its real property names.', $tag)
4602 );
4603 }
4604
4605 foreach ($rules['req'] as $attr_key => $requires) {
4606 if (!isset($attrs[$attr_key]) || $attrs[$attr_key] === '') {
4607 continue;
4608 }
4609
4610 foreach ($requires as $dep_key => $dep_val) {
4611 $negated = (isset($dep_key[0]) && $dep_key[0] === '!');
4612 $dep_key = $negated ? substr($dep_key, 1) : $dep_key;
4613 $current = isset($attrs[$dep_key]) ? $attrs[$dep_key] : '';
4614
4615 $matched = is_array($dep_val)
4616 ? in_array($current, $dep_val, false)
4617 : ((string) $dep_val === (string) $current);
4618
4619 if ($negated ? !$matched : $matched) {
4620 continue;
4621 }
4622
4623 $expected = is_array($dep_val) ? implode('" or "', $dep_val) : $dep_val;
4624 $errors[] = array(
4625 'element_id' => $node_id,
4626 'widget' => $tag,
4627 'issue' => sprintf('"%s" is set but its dependency is not, so Pagelayer discards it at render', $attr_key),
4628 'recommendation' => $negated
4629 ? sprintf('Remove attrs.%s (it must NOT be "%s") or drop attrs.%s.', $dep_key, $expected, $attr_key)
4630 : sprintf('Also set attrs.%s to "%s" on this same node.', $dep_key, $expected)
4631 );
4632 }
4633 }
4634 }
4635
4636 // 6. Placeholder / default-content check (generic — works for any niche)
4637 $placeholder_patterns = array(
4638 '/^this is icon box$/i',
4639 '/^lorem ipsum/i',
4640 '/^your (title|heading|text) here$/i',
4641 '/^click here$/i',
4642 '/^enter (your )?(title|description|text)/i',
4643 '/choose your image/i',
4644 );
4645 $text_fields_to_check = array('title', 'desc');
4646 foreach ($text_fields_to_check as $field) {
4647 if (!empty($attrs[$field]) && is_string($attrs[$field])) {
4648 foreach ($placeholder_patterns as $pattern) {
4649 if (preg_match($pattern, trim($attrs[$field]))) {
4650 $errors[] = array(
4651 'element_id' => $node_id,
4652 'widget' => $tag,
4653 'issue' => 'Widget still contains builder placeholder/default text instead of real content',
4654 'recommendation' => 'Write unique, on-topic copy for this widget before publishing'
4655 );
4656 }
4657 }
4658 }
4659 }
4660 // The node's own "content" field (not attrs.content) is how
4661 // most leaf/composite widgets carry their main text — check
4662 // it too, e.g. an untouched pl_heading/pl_text default.
4663 if (isset($node['content']) && is_string($node['content']) && $node['content'] !== '') {
4664 foreach ($placeholder_patterns as $pattern) {
4665 if (preg_match($pattern, trim(wp_strip_all_tags($node['content'])))) {
4666 $errors[] = array(
4667 'element_id' => $node_id,
4668 'widget' => $tag,
4669 'issue' => 'Widget still contains builder placeholder/default text instead of real content',
4670 'recommendation' => 'Write unique, on-topic copy for this widget before publishing'
4671 );
4672 break;
4673 }
4674 }
4675 }
4676 // pl_image's real image attr is "id" (not "img") — accepts a
4677 // full https:// URL or a numeric WP attachment ID. Flag it
4678 // empty or still pointing at the widget's own default image.
4679 // A missing image renders the builder's placeholder, which is an
4680 // acceptable intermediate state — reported, but it does not block
4681 // a save the way a broken layout does.
4682 $image_id_val = isset($attrs['id']) ? $attrs['id'] : '';
4683 $is_default_image = is_string($image_id_val) && (stripos($image_id_val, '/images/default-image.png') !== false || stripos($image_id_val, 'choose your image') !== false);
4684 if ($clean_tag === 'pl_image' && (empty($image_id_val) || $is_default_image)) {
4685 $warnings[] = array(
4686 'element_id' => $node_id,
4687 'widget' => $tag,
4688 'issue' => 'Image widget has no image set — the builder placeholder will render',
4689 'recommendation' => 'Set attrs.id to a real image URL (e.g. from search_images) — note the field is named "id", not "img"'
4690 );
4691 }
4692
4693 // 7. Missing primary content field, derived from this widget's
4694 // OWN registered schema (not a hardcoded field-name list, so it
4695 // generalizes to every widget). If a text/textarea/editor field
4696 // is absent and that field's builder default is itself generic
4697 // filler (e.g. pl_iconbox's service_heading defaults to the
4698 // literal string "This is Icon Box"), the widget will silently
4699 // render that filler — flag it before it ever gets that far.
4700 if (!empty($pagelayer->shortcodes[$clean_tag])) {
4701 if (!isset($schema_cache[$clean_tag])) {
4702 $schema_cache[$clean_tag] = self::extract_widget_schema($clean_tag, $pagelayer->shortcodes[$clean_tag]);
4703 }
4704 $w_schema = $schema_cache[$clean_tag];
4705 $inner_key = isset($pagelayer->shortcodes[$clean_tag]['innerHTML']) ? $pagelayer->shortcodes[$clean_tag]['innerHTML'] : '';
4706 $has_content_bridge = $inner_key && isset($node['content']) && is_string($node['content']) && $node['content'] !== '';
4707
4708 foreach ($w_schema['sections'] as $section) {
4709 foreach ($section['properties'] as $prop_key => $prop) {
4710 $type = isset($prop['type']) ? $prop['type'] : '';
4711 if (!in_array($type, array('text', 'textarea', 'editor'), true)) {
4712 continue;
4713 }
4714 if (isset($attrs[$prop_key]) && $attrs[$prop_key] !== '') {
4715 continue; // explicitly set — fine
4716 }
4717 if ($prop_key === $inner_key && $has_content_bridge) {
4718 continue; // supplied via node.content instead, which is valid
4719 }
4720 // A field gated behind a companion attr only renders
4721 // when that companion is set, so its filler default
4722 // can never reach the page. pl_image's "text" (the
4723 // overlay caption, gated on overlay=true) defaults to
4724 // lorem ipsum — demanding it turned every plain image
4725 // into a rejected page, with advice that makes no
4726 // sense for an image widget.
4727 if (is_array($rules) && !empty($rules['req'][$prop_key]) && !self::deps_satisfied($rules['req'][$prop_key], $attrs)) {
4728 continue;
4729 }
4730 $default = isset($prop['default']) ? $prop['default'] : '';
4731 if (!is_string($default) || $default === '') {
4732 continue;
4733 }
4734 foreach ($placeholder_patterns as $pattern) {
4735 if (preg_match($pattern, trim(wp_strip_all_tags($default)))) {
4736 $errors[] = array(
4737 'element_id' => $node_id,
4738 'widget' => $tag,
4739 'issue' => sprintf('Widget did not set "%s" (%s) — it will silently render the builder\'s own default filler text/image for this field', $prop_key, !empty($prop['label']) ? $prop['label'] : $prop_key),
4740 'recommendation' => sprintf('Set attrs.%s to real, on-topic content. Call get_widget_examples({"widget":"%s"}) for the exact field name.', $prop_key, $tag)
4741 );
4742 break;
4743 }
4744 }
4745 }
4746 }
4747 }
4748
4749 // Recurse children
4750 if (isset($node['content']) && is_array($node['content'])) {
4751 $walk($node['content'], $clean_tag);
4752 }
4753 }
4754 };
4755
4756 $walk($p_data);
4757
4758 if ($h1_count === 0) {
4759 $warnings[] = array(
4760 'element_id' => '',
4761 'widget' => 'pl_heading',
4762 'issue' => 'No <h1> heading found on page',
4763 'recommendation' => 'Include one primary <h1> heading for SEO'
4764 );
4765 } elseif ($h1_count > 1) {
4766 $warnings[] = array(
4767 'element_id' => '',
4768 'widget' => 'pl_heading',
4769 'issue' => 'Multiple <h1> headings found',
4770 'recommendation' => 'Use only one <h1> per page for proper SEO heading structure'
4771 );
4772 }
4773
4774 if ($global_refs === 0 && $total_nodes > 3) {
4775 $suggestions[] = 'Consider using global design tokens ($primary, $secondary, etc.) in widget attributes for consistent site styling.';
4776 }
4777
4778 $valid = (count($errors) === 0);
4779 $score = max(0, 100 - (count($errors) * 20) - (count($warnings) * 5));
4780
4781 return array(
4782 'valid' => $valid,
4783 'score' => $score,
4784 'errors' => $errors,
4785 'warnings' => $warnings,
4786 'suggestions' => $suggestions,
4787 'h1_count' => $h1_count,
4788 'global_refs' => $global_refs,
4789 'total_nodes' => $total_nodes,
4790 );
4791 }
4792
4793 // ------------------------------------------------------------------
4794 // Inline-CSS guard
4795 // ------------------------------------------------------------------
4796 //
4797 // Rich text is content, never styling. When an AI client writes
4798 // <h2 style="color:#fff;font-size:42px"> into a widget's editor field, that
4799 // CSS is rendered verbatim and outranks the whole builder: the widget's own
4800 // color/typography controls no longer show or change anything, the
4801 // _tablet/_mobile variants never apply to it, a global-color change leaves
4802 // the page half-rebranded, and nobody can undo it from the Pagelayer UI.
4803 // Every such declaration belongs on the node instead — as a real widget
4804 // attribute, or, when the widget has no control for it, as a rule in the
4805 // "ele_css" custom-CSS attribute.
4806 //
4807 // The scrub below runs on AI-supplied input ONLY. Data already stored on the
4808 // post is left alone on purpose: Pagelayer's own WYSIWYG writes inline
4809 // styles (the justify buttons run execCommand with styleWithCSS), so
4810 // rewriting stored content would silently destroy human edits.
4811
4812 /**
4813 * Widgets whose whole purpose is to carry third-party markup — an embed
4814 * snippet legitimately ships with its own style attributes.
4815 */
4816 protected static $inline_css_exempt_tags = array('pl_embed' => 1, 'pl_shortcodes' => 1);
4817
4818 /**
4819 * One opening HTML tag. Quoted attribute values are consumed as a unit so a
4820 * ">" inside one (title="a > b") neither ends the match early nor lets a
4821 * style attribute after it slip through.
4822 */
4823 const INLINE_CSS_TAG_RE = '#<[a-z][a-z0-9:_-]*(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
4824
4825 /**
4826 * Every inline CSS declaration in one HTML string: style="" attributes on
4827 * tags, plus whole <style> blocks. Read-only.
4828 */
4829 public static function find_inline_css($html) {
4830 $found = array();
4831
4832 if (!is_string($html) || $html === '' || stripos($html, 'style') === false) {
4833 return $found;
4834 }
4835
4836 if (preg_match_all('#<\s*style\b[^>]*>(.*?)<\s*/\s*style\s*>#is', $html, $blocks)) {
4837 foreach ($blocks[1] as $css) {
4838 $css = trim(preg_replace('/\s+/', ' ', $css));
4839 if ($css !== '') {
4840 $found[] = '<style> ' . $css;
4841 }
4842 }
4843 }
4844
4845 if (preg_match_all(self::INLINE_CSS_TAG_RE, $html, $tags)) {
4846 foreach ($tags[0] as $tag) {
4847 if (!preg_match('#\sstyle\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $tag, $m)) {
4848 continue;
4849 }
4850 // Only one of the three alternatives participates in a match.
4851 $css = '';
4852 foreach (array(4, 3, 2) as $group) {
4853 if (isset($m[$group]) && $m[$group] !== '') {
4854 $css = $m[$group];
4855 break;
4856 }
4857 }
4858 $css = trim(preg_replace('/\s+/', ' ', $css));
4859 if ($css !== '') {
4860 $found[] = $css;
4861 }
4862 }
4863 }
4864
4865 return $found;
4866 }
4867
4868 /**
4869 * The same string with every style="" attribute and <style> block removed.
4870 * Content markup (<strong>, <a>, <br>, lists, ...) is untouched.
4871 */
4872 public static function strip_inline_css($html) {
4873 if (!is_string($html) || $html === '' || stripos($html, 'style') === false) {
4874 return $html;
4875 }
4876
4877 $html = preg_replace('#<\s*style\b[^>]*>.*?<\s*/\s*style\s*>#is', '', $html);
4878 $html = preg_replace('#<\s*link\b[^>]*\srel\s*=\s*["\']?stylesheet["\']?[^>]*>#is', '', $html);
4879
4880 return preg_replace_callback(self::INLINE_CSS_TAG_RE, function($m) {
4881 $tag = preg_replace('#\sstyle\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $m[0], -1, $count);
4882 if (!$count) {
4883 return $m[0];
4884 }
4885 // Do not leave "<span >" or break a self-closing tag behind.
4886 return preg_replace('#\s+(/?)>$#', '$1>', $tag);
4887 }, $html);
4888 }
4889
4890 /**
4891 * "ele_attributes" is a name=value;name2=value2 list of extra HTML
4892 * attributes — a style entry there is inline CSS by another route.
4893 */
4894 protected static function attributes_field_style($value) {
4895 if (!is_string($value) || stripos($value, 'style') === false) {
4896 return '';
4897 }
4898
4899 foreach (explode(';', $value) as $pair) {
4900 $parts = explode('=', $pair, 2);
4901 if (strtolower(trim($parts[0])) === 'style' && isset($parts[1]) && trim($parts[1]) !== '') {
4902 return trim($parts[1]);
4903 }
4904 }
4905
4906 return '';
4907 }
4908
4909 protected static function strip_attributes_field_style($value) {
4910 $kept = array();
4911
4912 foreach (explode(';', $value) as $pair) {
4913 if (trim($pair) === '') {
4914 continue;
4915 }
4916 $parts = explode('=', $pair, 2);
4917 if (strtolower(trim($parts[0])) === 'style') {
4918 continue;
4919 }
4920 $kept[] = trim($pair);
4921 }
4922
4923 return implode(';', $kept);
4924 }
4925
4926 /**
4927 * Inline CSS carried by ONE node — its rich-text content and its attribute
4928 * values. Children are not visited. Read-only.
4929 */
4930 public static function inline_css_hits($node) {
4931 $hits = array();
4932
4933 if (!is_array($node)) {
4934 return $hits;
4935 }
4936
4937 $tag = isset($node['tag']) ? $node['tag'] : '';
4938 $clean_tag = str_replace('pagelayer_', 'pl_', $tag);
4939 if (isset(self::$inline_css_exempt_tags[$clean_tag])) {
4940 return $hits;
4941 }
4942
4943 $attrs = isset($node['attrs']) && is_array($node['attrs']) ? $node['attrs'] : array();
4944 $node_id = isset($attrs['pagelayer-id']) ? $attrs['pagelayer-id'] : '';
4945
4946 if (isset($node['content']) && is_string($node['content'])) {
4947 foreach (self::find_inline_css($node['content']) as $css) {
4948 $hits[] = array('element_id' => $node_id, 'widget' => $tag, 'where' => 'content', 'css' => $css);
4949 }
4950 }
4951
4952 foreach ($attrs as $key => $value) {
4953 // ele_css is the sanctioned place for hand-written CSS.
4954 if ($key === 'ele_css') {
4955 continue;
4956 }
4957
4958 // Nested values (a section spec's items[], a link prop's sub-keys).
4959 if (is_array($value)) {
4960 foreach (self::inline_css_hits(array('tag' => $tag, 'attrs' => $value)) as $nested) {
4961 $nested['element_id'] = $node_id;
4962 $nested['where'] = 'attrs.' . $key . '.' . preg_replace('/^attrs\./', '', $nested['where']);
4963 $hits[] = $nested;
4964 }
4965 continue;
4966 }
4967
4968 if (!is_string($value)) {
4969 continue;
4970 }
4971
4972 if ($key === 'ele_attributes') {
4973 $css = self::attributes_field_style($value);
4974 if ($css !== '') {
4975 $hits[] = array('element_id' => $node_id, 'widget' => $tag, 'where' => 'attrs.ele_attributes', 'css' => $css);
4976 }
4977 continue;
4978 }
4979
4980 foreach (self::find_inline_css($value) as $css) {
4981 $hits[] = array('element_id' => $node_id, 'widget' => $tag, 'where' => 'attrs.' . $key, 'css' => $css);
4982 }
4983 }
4984
4985 return $hits;
4986 }
4987
4988 /**
4989 * Strips inline CSS from one node and everything below it, collecting what
4990 * was removed into $found.
4991 */
4992 public static function scrub_node_inline_css(&$node, &$found) {
4993 if (!is_array($node)) {
4994 return;
4995 }
4996
4997 // A section spec carries its copy in top-level keys (heading, sub,
4998 // items[].text ...) rather than in attrs, so scrub the whole spec.
4999 if (!empty($node['section']) && is_string($node['section'])) {
5000 $section = $node['section'];
5001 $before = $node;
5002 unset($before['section']);
5003
5004 foreach (self::inline_css_hits(array('tag' => 'section:' . $section, 'attrs' => $before)) as $hit) {
5005 $found[] = $hit;
5006 }
5007
5008 $after = self::strip_attrs_inline_css($before);
5009 $after['section'] = $section;
5010 $node = $after;
5011 return;
5012 }
5013
5014 $clean_tag = isset($node['tag']) ? str_replace('pagelayer_', 'pl_', $node['tag']) : '';
5015 if (!isset(self::$inline_css_exempt_tags[$clean_tag])) {
5016 foreach (self::inline_css_hits($node) as $hit) {
5017 $found[] = $hit;
5018 }
5019
5020 if (isset($node['content']) && is_string($node['content'])) {
5021 $node['content'] = self::strip_inline_css($node['content']);
5022 }
5023
5024 if (isset($node['attrs']) && is_array($node['attrs'])) {
5025 $node['attrs'] = self::strip_attrs_inline_css($node['attrs']);
5026 }
5027 }
5028
5029 if (isset($node['content']) && is_array($node['content'])) {
5030 self::scrub_layout_inline_css($node['content'], $found);
5031 }
5032 }
5033
5034 /**
5035 * Same, for a list of nodes (a whole pagelayer_data tree).
5036 */
5037 public static function scrub_layout_inline_css(&$nodes, &$found) {
5038 if (!is_array($nodes)) {
5039 return;
5040 }
5041
5042 foreach ($nodes as &$node) {
5043 self::scrub_node_inline_css($node, $found);
5044 }
5045 unset($node);
5046 }
5047
5048 /**
5049 * Same, for a bare attrs map (update_element / change_styles send one
5050 * without a node around it).
5051 */
5052 public static function strip_attrs_inline_css($attrs) {
5053 if (!is_array($attrs)) {
5054 return $attrs;
5055 }
5056
5057 foreach ($attrs as $key => $value) {
5058 if ($key === 'ele_css') {
5059 continue;
5060 }
5061 if (is_array($value)) {
5062 $attrs[$key] = self::strip_attrs_inline_css($value);
5063 } elseif (is_string($value)) {
5064 $attrs[$key] = ($key === 'ele_attributes')
5065 ? self::strip_attributes_field_style($value)
5066 : self::strip_inline_css($value);
5067 }
5068 }
5069
5070 return $attrs;
5071 }
5072
5073 /**
5074 * Failed tool call describing the inline CSS that was rejected, so the
5075 * client rewrites it as attributes instead of retrying the same markup.
5076 */
5077 protected static function inline_css_error($found) {
5078 $lines = array();
5079 foreach (array_slice($found, 0, 6) as $hit) {
5080 $lines[] = sprintf('[%s%s] %s: "%s"',
5081 !empty($hit['widget']) ? $hit['widget'] : '?',
5082 !empty($hit['element_id']) ? ' ' . $hit['element_id'] : '',
5083 $hit['where'],
5084 $hit['css']
5085 );
5086 }
5087 $more = count($found) > 6 ? sprintf(__(' (+%d more)', 'pagelayer'), count($found) - 6) : '';
5088
5089 $message = sprintf(
5090 __('Inline CSS in rich text is not allowed — nothing was saved. %1$d occurrence(s): %2$s%3$s. Rich text carries content only (<strong>, <em>, <a>, <br>, lists). Styling belongs on the node: use the widget\'s own attributes (get_widget_schema) or the shared style props every widget accepts (get_common_styles — color, font_size, font_weight, ele_padding, ele_margin, ... plus their _tablet/_mobile variants). ONLY when no control exists for what you need, put a real CSS rule in the "ele_css" attribute of that same node, using {{element}} as the selector, e.g. ele_css: "{{element}} .pagelayer-heading-holder h2 { letter-spacing: 2px; }". Resubmit with every style="" attribute removed.', 'pagelayer'),
5091 count($found),
5092 implode(' | ', $lines),
5093 $more
5094 );
5095
5096 return new \WP_Error('inline_css_not_allowed', $message, array('occurrences' => $found));
5097 }
5098
5099 /**
5100 * Enforcement entry point for AI-supplied layout data. The tree is scrubbed
5101 * in place, and a WP_Error is returned so the caller abandons the write and
5102 * the client resubmits the styling as attributes. Returns null when there
5103 * was nothing to strip. Unlike quality_gate this is not bypassable with
5104 * skip_validation — inline CSS in rich text is never a valid layout.
5105 */
5106 protected static function inline_css_gate(&$data) {
5107 $found = array();
5108 self::scrub_layout_inline_css($data, $found);
5109
5110 if (empty($found)) {
5111 return null;
5112 }
5113
5114 return self::inline_css_error($found);
5115 }
5116
5117 /**
5118 * Hard content-quality gate used by every layout-writing ability. Returns
5119 * true when the layout is clean, or a WP_Error describing what to fix
5120 * (placeholder text, missing images, unregistered widgets, broken
5121 * hierarchy) when it is not. Callers should return the WP_Error as-is so
5122 * the AI client sees it as a failed tool call and can retry with fixes.
5123 */
5124 protected static function quality_gate($data) {
5125 if (!is_array($data) || empty($data)) {
5126 return true;
5127 }
5128
5129 $result = self::run_layout_validation($data);
5130 if (empty($result['errors'])) {
5131 return true;
5132 }
5133
5134 $lines = array();
5135 foreach (array_slice($result['errors'], 0, 8) as $err) {
5136 $widget = isset($err['widget']) ? $err['widget'] : '?';
5137 $lines[] = sprintf('[%s] %s — %s', $widget, $err['issue'], $err['recommendation']);
5138 }
5139 $more = count($result['errors']) > 8 ? sprintf(__(' (+%d more)', 'pagelayer'), count($result['errors']) - 8) : '';
5140
5141 $message = sprintf(
5142 __('Content quality gate failed with %1$d issue(s) — nothing was saved. Fix these and resubmit (or pass skip_validation:true to bypass for an intentional draft): %2$s%3$s', 'pagelayer'),
5143 count($result['errors']),
5144 implode(' | ', $lines),
5145 $more
5146 );
5147
5148 return new \WP_Error('quality_gate_failed', $message, array('errors' => $result['errors']));
5149 }
5150
5151 // ------------------------------------------------------------------
5152 // Post Callbacks (Individual Posts)
5153 // ------------------------------------------------------------------
5154
5155 public static function execute_create_post($input) {
5156 $input['post_type'] = 'post';
5157 return self::create_post_or_page($input);
5158 }
5159
5160 public static function execute_update_post($input) {
5161 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
5162 $post = get_post($post_id);
5163 if (!$post || $post->post_type !== 'post') {
5164 return new \WP_Error('invalid_post', __('Blog post not found.', 'pagelayer'));
5165 }
5166
5167 if (isset($input['title'])) {
5168 wp_update_post(array('ID' => $post_id, 'post_title' => sanitize_text_field($input['title'])));
5169 }
5170 if (isset($input['status'])) {
5171 wp_update_post(array('ID' => $post_id, 'post_status' => sanitize_text_field($input['status'])));
5172 }
5173 if (isset($input['excerpt'])) {
5174 wp_update_post(array('ID' => $post_id, 'post_excerpt' => sanitize_textarea_field($input['excerpt'])));
5175 }
5176 if (isset($input['pagelayer_data']) && is_array($input['pagelayer_data'])) {
5177 $inline_css = self::inline_css_gate($input['pagelayer_data']);
5178 if (is_wp_error($inline_css)) {
5179 return $inline_css;
5180 }
5181
5182 $normalized = self::normalize_layout_data($input['pagelayer_data']);
5183 update_post_meta($post_id, 'pagelayer-data', $normalized);
5184 $blocks_content = self::serialize_layout_to_blocks($normalized);
5185 wp_update_post(array('ID' => $post_id, 'post_content' => $blocks_content));
5186 }
5187 if (!empty($input['categories']) && is_array($input['categories'])) {
5188 $cat_ids = array();
5189 foreach ($input['categories'] as $cat) {
5190 if (is_numeric($cat)) {
5191 $cat_ids[] = (int) $cat;
5192 } else {
5193 $term = get_term_by('name', $cat, 'category');
5194 if (!$term) {
5195 $term = wp_insert_term($cat, 'category');
5196 }
5197 if (!is_wp_error($term) && isset($term['term_id'])) {
5198 $cat_ids[] = (int) $term['term_id'];
5199 }
5200 }
5201 }
5202 if (!empty($cat_ids)) {
5203 wp_set_post_categories($post_id, $cat_ids);
5204 }
5205 }
5206 if (!empty($input['tags'])) {
5207 wp_set_post_tags($post_id, $input['tags']);
5208 }
5209 if (!empty($input['featured_image'])) {
5210 $img_id = 0;
5211 if (is_numeric($input['featured_image'])) {
5212 $img_id = (int) $input['featured_image'];
5213 } elseif (filter_var($input['featured_image'], FILTER_VALIDATE_URL)) {
5214 $upload = self::execute_upload_media(array('url' => $input['featured_image']));
5215 if (!is_wp_error($upload) && !empty($upload['attachment_id'])) {
5216 $img_id = $upload['attachment_id'];
5217 }
5218 }
5219 if ($img_id > 0) {
5220 set_post_thumbnail($post_id, $img_id);
5221 }
5222 }
5223
5224 return array('success' => true, 'post_id' => $post_id, 'url' => get_permalink($post_id));
5225 }
5226
5227 public static function execute_get_post($input) {
5228 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
5229 $post = get_post($post_id);
5230 if (!$post || $post->post_type !== 'post') {
5231 return new \WP_Error('invalid_post', __('Blog post not found.', 'pagelayer'));
5232 }
5233
5234 $data = get_post_meta($post_id, 'pagelayer-data', true);
5235 $cats = wp_get_post_categories($post_id, array('fields' => 'names'));
5236 $tags = wp_get_post_tags($post_id, array('fields' => 'names'));
5237 $feat_image = get_the_post_thumbnail_url($post_id, 'full');
5238
5239 return array(
5240 'post_id' => $post->ID,
5241 'title' => $post->post_title,
5242 'status' => $post->post_status,
5243 'excerpt' => $post->post_excerpt,
5244 'url' => get_permalink($post->ID),
5245 'edit_url' => admin_url('post.php?post=' . $post->ID . '&action=edit'),
5246 'categories' => $cats,
5247 'tags' => $tags,
5248 'featured_image' => $feat_image ?: '',
5249 'pagelayer_data' => is_array($data) ? $data : array(),
5250 );
5251 }
5252
5253 public static function execute_list_posts($input) {
5254 $limit = isset($input['limit']) ? (int) $input['limit'] : 20;
5255 $status = isset($input['status']) ? sanitize_text_field($input['status']) : 'any';
5256
5257 $args = array(
5258 'post_type' => 'post',
5259 'posts_per_page' => $limit,
5260 'post_status' => $status,
5261 'meta_query' => array(
5262 array('key' => 'pagelayer-data', 'compare' => 'EXISTS'),
5263 ),
5264 );
5265
5266 if (!empty($input['category'])) {
5267 $args['category_name'] = sanitize_text_field($input['category']);
5268 }
5269
5270 $query = new \WP_Query($args);
5271 $posts = array();
5272
5273 foreach ($query->posts as $post) {
5274 $posts[] = array(
5275 'id' => $post->ID,
5276 'title' => $post->post_title,
5277 'status' => $post->post_status,
5278 'url' => get_permalink($post->ID),
5279 );
5280 }
5281
5282 return array('posts' => $posts);
5283 }
5284
5285 public static function execute_publish_post($input) {
5286 return self::execute_publish_page($input);
5287 }
5288
5289 public static function execute_duplicate_post($input) {
5290 return self::execute_duplicate_page($input);
5291 }
5292
5293 public static function execute_delete_post($input) {
5294 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
5295 $force = !empty($input['force']);
5296 if (!$post_id || get_post_type($post_id) !== 'post') {
5297 return new \WP_Error('invalid_post', __('Blog post not found.', 'pagelayer'));
5298 }
5299
5300 $res = wp_delete_post($post_id, $force);
5301 return array('success' => (bool)$res);
5302 }
5303
5304 // ------------------------------------------------------------------
5305 // Core Post/Page Creator Helper
5306 // ------------------------------------------------------------------
5307
5308 protected static function create_post_or_page($input) {
5309 self::maybe_update_global_styles($input);
5310 $title = isset($input['title']) ? sanitize_text_field($input['title']) : '';
5311 $post_type = isset($input['post_type']) ? sanitize_text_field($input['post_type']) : 'page';
5312 $status = isset($input['status']) ? sanitize_text_field($input['status']) : 'publish';
5313
5314 if (empty($title)) {
5315 return new \WP_Error('missing_title', __('A title is required.', 'pagelayer'));
5316 }
5317
5318 if (!isset($input['pagelayer_data']) || !is_array($input['pagelayer_data'])) {
5319 return new \WP_Error('missing_pagelayer_data', __('pagelayer_data is required.', 'pagelayer'));
5320 }
5321
5322 $inline_css = self::inline_css_gate($input['pagelayer_data']);
5323 if (is_wp_error($inline_css)) {
5324 return $inline_css;
5325 }
5326
5327 $normalized_data = self::normalize_layout_data($input['pagelayer_data']);
5328
5329 if (empty($input['skip_validation'])) {
5330 $gate = self::quality_gate($normalized_data);
5331 if (is_wp_error($gate)) {
5332 return $gate;
5333 }
5334 }
5335
5336 $postarr = array(
5337 'post_title' => $title,
5338 'post_type' => $post_type,
5339 'post_status' => $status,
5340 );
5341
5342 if (isset($input['excerpt'])) {
5343 $postarr['post_excerpt'] = sanitize_textarea_field($input['excerpt']);
5344 }
5345
5346 $post_id = wp_insert_post(wp_slash($postarr), true);
5347 if (is_wp_error($post_id)) {
5348 return $post_id;
5349 }
5350
5351 // 'pagelayer-data' is the single source of truth for the layout tree —
5352 // every other ability (add_element, update_element, validate_page,
5353 // find_elements, navigator, transactions, duplicate) reads/writes it
5354 // directly. It also doubles as the native PageLayer "is this a
5355 // PageLayer post" flag (checked via empty()), which a populated array
5356 // satisfies just as well as the old time() placeholder did.
5357 update_post_meta($post_id, 'pagelayer-data', $normalized_data);
5358
5359 $blocks_content = self::serialize_layout_to_blocks($normalized_data);
5360 wp_update_post(array(
5361 'ID' => $post_id,
5362 'post_content' => $blocks_content,
5363 ));
5364
5365 if (!empty($input['categories']) && is_array($input['categories'])) {
5366 $cat_ids = array();
5367 foreach ($input['categories'] as $cat) {
5368 if (is_numeric($cat)) {
5369 $cat_ids[] = (int) $cat;
5370 } else {
5371 $term = get_term_by('name', $cat, 'category');
5372 if (!$term) {
5373 $term = wp_insert_term($cat, 'category');
5374 }
5375 if (!is_wp_error($term) && isset($term['term_id'])) {
5376 $cat_ids[] = (int) $term['term_id'];
5377 }
5378 }
5379 }
5380 if (!empty($cat_ids)) {
5381 wp_set_post_categories($post_id, $cat_ids);
5382 }
5383 }
5384
5385 if (!empty($input['tags'])) {
5386 wp_set_post_tags($post_id, $input['tags']);
5387 }
5388
5389 if (!empty($input['featured_image'])) {
5390 $img_id = 0;
5391 if (is_numeric($input['featured_image'])) {
5392 $img_id = (int) $input['featured_image'];
5393 } elseif (filter_var($input['featured_image'], FILTER_VALIDATE_URL)) {
5394 $upload = self::execute_upload_media(array('url' => $input['featured_image']));
5395 if (!is_wp_error($upload) && !empty($upload['attachment_id'])) {
5396 $img_id = $upload['attachment_id'];
5397 }
5398 }
5399 if ($img_id > 0) {
5400 set_post_thumbnail($post_id, $img_id);
5401 }
5402 }
5403
5404 if (!empty($input['is_homepage'])) {
5405 update_option('show_on_front', 'page');
5406 update_option('page_on_front', $post_id);
5407 }
5408 if (!empty($input['is_posts_page'])) {
5409 update_option('page_for_posts', $post_id);
5410 }
5411
5412 return array(
5413 'post_id' => $post_id,
5414 'url' => get_permalink($post_id),
5415 'edit_url' => admin_url('post.php?post=' . $post_id . '&action=edit'),
5416 );
5417 }
5418
5419 // ------------------------------------------------------------------
5420 // Builder & Website Callbacks
5421 // ------------------------------------------------------------------
5422
5423 protected static function widget_exists($tag) {
5424 self::ensure_shortcodes_loaded();
5425 global $pagelayer;
5426 return !empty($pagelayer->shortcodes[$tag]);
5427 }
5428
5429 /**
5430 * The nav menu a generated site's header points at. Uses the caller's "menu"
5431 * spec when given, otherwise builds one from the pages just created (home
5432 * first), and reuses an existing populated menu rather than duplicating it.
5433 */
5434 protected static function ensure_site_menu($input, $pages_created, $homepage_id) {
5435 if (!empty($input['menu']) && is_array($input['menu'])) {
5436 $spec = $input['menu'];
5437 if (empty($spec['name'])) {
5438 $spec['name'] = __('Primary Menu', 'pagelayer');
5439 }
5440 if (empty($spec['items'])) {
5441 $spec['items'] = self::menu_items_from_pages($pages_created, $homepage_id);
5442 }
5443 if (empty($spec['items'])) {
5444 return array();
5445 }
5446 $res = self::execute_create_menu($spec);
5447 return is_wp_error($res) ? array('error' => $res->get_error_message()) : $res;
5448 }
5449
5450 // Nothing asked for, but the site already has a menu with items in it —
5451 // use that rather than creating a second one nobody asked for.
5452 foreach (wp_get_nav_menus() as $existing) {
5453 if ((int) $existing->count > 0) {
5454 return array('menu_id' => (int) $existing->term_id, 'name' => $existing->name, 'reused' => true);
5455 }
5456 }
5457
5458 $items = self::menu_items_from_pages($pages_created, $homepage_id);
5459 if (empty($items)) {
5460 return array();
5461 }
5462
5463 $spec = array('name' => __('Primary Menu', 'pagelayer'), 'items' => $items);
5464
5465 // Assign to the theme's own location too, so the menu still works in
5466 // theme areas that are not rendered by the Pagelayer header.
5467 $registered = function_exists('get_registered_nav_menus') ? get_registered_nav_menus() : array();
5468 if (!empty($registered)) {
5469 $assigned = (array) get_nav_menu_locations();
5470 foreach (array_keys($registered) as $slug) {
5471 if (empty($assigned[$slug])) {
5472 $spec['location'] = $slug;
5473 break;
5474 }
5475 }
5476 }
5477
5478 $res = self::execute_create_menu($spec);
5479 return is_wp_error($res) ? array('error' => $res->get_error_message()) : $res;
5480 }
5481
5482 protected static function menu_items_from_pages($pages_created, $homepage_id) {
5483 $items = array();
5484 $home = array();
5485
5486 foreach ($pages_created as $page) {
5487 if (empty($page['post_id'])) {
5488 continue;
5489 }
5490 $row = array('title' => get_the_title($page['post_id']), 'page_id' => (int) $page['post_id']);
5491 if ($homepage_id && (int) $page['post_id'] === (int) $homepage_id) {
5492 $home = $row;
5493 continue;
5494 }
5495 $items[] = $row;
5496 }
5497
5498 if (!empty($home)) {
5499 array_unshift($items, $home);
5500 }
5501
5502 return $items;
5503 }
5504
5505 /**
5506 * Fills in the menu id on any pl_wp_menu node that was written before the
5507 * menu existed (empty nav_list, or the literal "auto").
5508 */
5509 protected static function fill_menu_placeholder(&$nodes, $menu_id) {
5510 if (!is_array($nodes)) {
5511 return;
5512 }
5513
5514 foreach ($nodes as &$node) {
5515 if (!is_array($node)) {
5516 continue;
5517 }
5518 if (!empty($node['tag']) && str_replace('pagelayer_', 'pl_', $node['tag']) === 'pl_wp_menu') {
5519 $current = isset($node['attrs']['nav_list']) ? trim((string) $node['attrs']['nav_list']) : '';
5520 if ($current === '' || $current === '0' || strtolower($current) === 'auto') {
5521 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
5522 $node['attrs'] = array();
5523 }
5524 $node['attrs']['nav_list'] = (string) $menu_id;
5525 }
5526 }
5527 if (isset($node['content']) && is_array($node['content'])) {
5528 self::fill_menu_placeholder($node['content'], $menu_id);
5529 }
5530 }
5531 unset($node);
5532 }
5533
5534 public static function execute_create_website($input) {
5535 self::maybe_update_global_styles($input);
5536 $site_name = isset($input['site_name']) ? sanitize_text_field($input['site_name']) : get_option('blogname');
5537 if (empty($site_name)) {
5538 return new \WP_Error('missing_site_name', __('A site name is required.', 'pagelayer'));
5539 }
5540
5541 update_option('blogname', $site_name);
5542 // Fallback color used ONLY for bare scaffolding below if no header/footer exists yet.
5543 // This is NOT a themed design — the caller is expected to supply real pagelayer_data
5544 // for every page, and its own global_colors/global_fonts for the actual brand.
5545 $primary = !empty($input['primary_color']) ? sanitize_text_field($input['primary_color']) : '#0F172A';
5546
5547 $has_template_type = function($type) {
5548 $posts = get_posts(array(
5549 'post_type' => 'pagelayer-template',
5550 'post_status' => array('publish', 'draft'),
5551 'posts_per_page' => 1,
5552 'meta_key' => 'pagelayer_template_type',
5553 'meta_value' => $type,
5554 'fields' => 'ids',
5555 ));
5556 return !empty($posts);
5557 };
5558
5559 $created_templates = array();
5560 $single_page_site = !empty($input['single_page_site']);
5561
5562 // 1. Auto-create a bare, minimal Footer scaffold ONLY if none exists yet.
5563 if (!$has_template_type('footer')) {
5564 $footer_data = array(
5565 array(
5566 'tag' => 'pl_row',
5567 'attrs' => array('stretch' => 'full', 'ele_bg_type' => 'color', 'ele_bg_color' => $primary, 'ele_padding' => '40px,0px,40px,0px'),
5568 'content' => array(
5569 array('tag' => 'pl_col', 'attrs' => array('col' => 12), 'content' => array(
5570 // pl_text has neither "color" nor "align" (its only
5571 // param is the editor field) — both were silently
5572 // dropped here, leaving dark text on a dark footer.
5573 // pl_heading carries the same <p> markup and does
5574 // have colour and alignment.
5575 array('tag' => 'pl_heading', 'attrs' => array('color' => '#ffffff', 'align' => 'center', 'font_size' => '14', 'font_weight' => '400'), 'content' => '<p>&copy; ' . date('Y') . ' ' . esc_html($site_name) . '</p>')
5576 ))
5577 )
5578 )
5579 );
5580 $res = self::execute_create_template(array(
5581 'title' => $site_name . ' Footer',
5582 'type' => 'footer',
5583 'pagelayer_data' => $footer_data,
5584 'conditions' => array(array('type' => 'include', 'template' => '', 'sub_template' => '', 'id' => ''))
5585 ));
5586 if (!is_wp_error($res)) {
5587 $created_templates['footer'] = $res;
5588 }
5589 }
5590
5591 // Per-item failures are collected and reported, never swallowed. Returning
5592 // success:true while three of six pages were rejected by the quality gate
5593 // left the caller believing it had built a site that did not exist.
5594 $failures = array();
5595
5596 // Pages come BEFORE the templates now: the header's Primary Menu widget
5597 // has to point at a menu, and a menu of pages cannot be built until the
5598 // pages exist. Templates never reference pages, so nothing is lost by
5599 // the reorder.
5600 $pages_created = array();
5601 $posts_created = array();
5602 $homepage_id = null;
5603
5604 if (isset($input['pages']) && is_array($input['pages'])) {
5605 foreach ($input['pages'] as $i => $page_input) {
5606 $res = self::create_post_or_page($page_input);
5607
5608 if (is_wp_error($res)) {
5609 $failures[] = array(
5610 'item' => 'pages[' . $i . ']',
5611 'title' => isset($page_input['title']) ? $page_input['title'] : '',
5612 'error' => $res->get_error_message(),
5613 );
5614 continue;
5615 }
5616
5617 $p_type = isset($page_input['post_type']) ? $page_input['post_type'] : 'page';
5618 if ($p_type === 'post') {
5619 $posts_created[] = $res;
5620 } else {
5621 $pages_created[] = $res;
5622 }
5623 if (!empty($page_input['is_homepage'])) {
5624 $homepage_id = $res['post_id'];
5625 }
5626 }
5627 }
5628
5629 // Front page. create_page honours is_homepage, but create_website — the
5630 // tool callers are told to prefer — only ever used $homepage_id to sort
5631 // the menu, so a generated site kept WordPress's default "latest posts"
5632 // root and the designed home page sat at /home/ where nobody saw it.
5633 //
5634 // Most callers never send is_homepage at all (it is not mentioned in the
5635 // pages description), so fall back to a page that is obviously the home
5636 // page, then to the first one created. Only ever applied when the site
5637 // is still on the WordPress default, so a deliberate existing front page
5638 // is never clobbered.
5639 if (!$homepage_id && !empty($pages_created)) {
5640 foreach ($pages_created as $page) {
5641 // The create result carries no title, only ids and urls.
5642 $title = strtolower(trim(get_the_title($page['post_id'])));
5643 if (in_array($title, array('home', 'homepage', 'home page'), true)) {
5644 $homepage_id = $page['post_id'];
5645 break;
5646 }
5647 }
5648 if (!$homepage_id) {
5649 $homepage_id = $pages_created[0]['post_id'];
5650 }
5651 }
5652
5653 if ($homepage_id && 'page' !== get_option('show_on_front')) {
5654 update_option('show_on_front', 'page');
5655 update_option('page_on_front', $homepage_id);
5656 }
5657
5658 // The site's navigation menu, so the header has something real to render.
5659 $menu = $single_page_site ? array() : self::ensure_site_menu($input, $pages_created, $homepage_id);
5660 $menu_id = isset($menu['menu_id']) ? (int) $menu['menu_id'] : 0;
5661
5662 if (isset($input['theme_templates']) && is_array($input['theme_templates'])) {
5663 foreach ($input['theme_templates'] as $i => $tt) {
5664 // A header the caller wrote before the menu existed can leave
5665 // nav_list empty (or "auto") — fill it in rather than reject it.
5666 if ($menu_id && isset($tt['type']) && $tt['type'] === 'header' && isset($tt['pagelayer_data']) && is_array($tt['pagelayer_data'])) {
5667 self::fill_menu_placeholder($tt['pagelayer_data'], $menu_id);
5668 }
5669 if ($single_page_site && !isset($tt['single_page_site'])) {
5670 $tt['single_page_site'] = true;
5671 }
5672
5673 $res = self::execute_create_template($tt);
5674 if (is_wp_error($res)) {
5675 $failures[] = array(
5676 'item' => 'theme_templates[' . $i . ']',
5677 'title' => isset($tt['title']) ? $tt['title'] : '',
5678 'error' => $res->get_error_message(),
5679 );
5680 } elseif (isset($tt['type'])) {
5681 $created_templates[$tt['type']] = $res;
5682 }
5683 }
5684 }
5685
5686 // Bare Header scaffold, ONLY if the caller supplied none and none exists.
5687 if (!$has_template_type('header')) {
5688 $brand_col = array('tag' => 'pl_col', 'attrs' => array('col' => 4), 'content' => array(
5689 array('tag' => 'pl_heading', 'attrs' => array('color' => $primary, 'font_size' => '24px', 'font_weight' => '800'), 'content' => '<span>' . esc_html($site_name) . '</span>')
5690 ));
5691
5692 $nav_col = array('tag' => 'pl_col', 'attrs' => array('col' => 8), 'content' => array());
5693 if ($menu_id && self::widget_exists('pl_wp_menu')) {
5694 $nav_col['content'][] = array(
5695 'tag' => 'pl_wp_menu',
5696 'attrs' => array(
5697 'nav_list' => (string) $menu_id,
5698 'layout' => 'horizontal',
5699 'align' => 'right',
5700 'drop_breakpoint' => 'tablet',
5701 'pointer' => 'underline',
5702 'm_animation' => 'slide',
5703 'submenu_ind' => 'caret-down',
5704 ),
5705 );
5706 }
5707
5708 $header_data = array(
5709 array(
5710 'tag' => 'pl_row',
5711 // ele_bg_color needs ele_bg_type=color to survive render, and the
5712 // element shadow prop is ele_shadow (there is no ele_box_shadow).
5713 'attrs' => array('stretch' => 'full', 'ele_bg_type' => 'color', 'ele_bg_color' => '#ffffff', 'ele_padding' => '20px,0px,20px,0px', 'ele_shadow' => '0,4,20,rgba(0,0,0,0.05),0,'),
5714 'content' => array($brand_col, $nav_col),
5715 )
5716 );
5717
5718 $res = self::execute_create_template(array(
5719 'title' => $site_name . ' Header',
5720 'type' => 'header',
5721 'pagelayer_data' => $header_data,
5722 'conditions' => array(array('type' => 'include', 'template' => '', 'sub_template' => '', 'id' => '')),
5723 // The scaffold can only carry a menu widget when one is available;
5724 // without it this bare header would fail its own nav gate.
5725 'single_page_site' => ($single_page_site || !$menu_id || !self::widget_exists('pl_wp_menu')),
5726 ));
5727 if (!is_wp_error($res)) {
5728 $created_templates['header'] = $res;
5729 } else {
5730 $failures[] = array('item' => 'header_scaffold', 'title' => $site_name . ' Header', 'error' => $res->get_error_message());
5731 }
5732 }
5733
5734 $result = array(
5735 'success' => empty($failures),
5736 'site_name' => $site_name,
5737 'homepage_id' => $homepage_id,
5738 'created_templates' => $created_templates,
5739 'menu' => $menu,
5740 'pages' => $pages_created,
5741 'posts' => $posts_created,
5742 );
5743
5744 if (!empty($failures)) {
5745 $result['failed'] = $failures;
5746 $result['message'] = sprintf(
5747 __('%1$d of %2$d item(s) were rejected and NOT created. Fix the issues listed in "failed" and re-submit just those items with create_page.', 'pagelayer'),
5748 count($failures),
5749 count($failures) + count($pages_created) + count($posts_created)
5750 );
5751 }
5752
5753 return $result;
5754 }
5755
5756 public static function execute_create_design_ui($input) {
5757 self::maybe_update_global_styles($input);
5758 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
5759 if (!$post_id || !get_post($post_id)) {
5760 return new \WP_Error('invalid_post', __('Post not found.', 'pagelayer'));
5761 }
5762
5763 if (!isset($input['pagelayer_data']) || !is_array($input['pagelayer_data'])) {
5764 return new \WP_Error('missing_pagelayer_data', __('pagelayer_data is required.', 'pagelayer'));
5765 }
5766
5767 $existing_data = get_post_meta($post_id, 'pagelayer-data', true);
5768 if (!is_array($existing_data)) {
5769 $existing_data = array();
5770 }
5771
5772 $inline_css = self::inline_css_gate($input['pagelayer_data']);
5773 if (is_wp_error($inline_css)) {
5774 return $inline_css;
5775 }
5776
5777 $normalized_new = self::normalize_layout_data($input['pagelayer_data']);
5778
5779 if (empty($input['skip_validation'])) {
5780 $gate = self::quality_gate($normalized_new);
5781 if (is_wp_error($gate)) {
5782 return $gate;
5783 }
5784 }
5785
5786 $merged_data = array_merge($existing_data, $normalized_new);
5787 $normalized_data = self::normalize_layout_data($merged_data);
5788 update_post_meta($post_id, 'pagelayer-data', $normalized_data);
5789
5790 $blocks_content = self::serialize_layout_to_blocks($normalized_data);
5791 wp_update_post(array(
5792 'ID' => $post_id,
5793 'post_content' => $blocks_content,
5794 ));
5795
5796 return array(
5797 'success' => true,
5798 'post_id' => $post_id,
5799 'url' => get_permalink($post_id)
5800 );
5801 }
5802
5803 public static function execute_edit_layout($input) {
5804 return self::execute_update_data($input);
5805 }
5806
5807 public static function execute_update_data($input) {
5808 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
5809 if (!$post_id || !get_post($post_id)) {
5810 return new \WP_Error('invalid_post', __('Post not found.', 'pagelayer'));
5811 }
5812
5813 if (isset($input['pagelayer_data']) && is_array($input['pagelayer_data'])) {
5814 $inline_css = self::inline_css_gate($input['pagelayer_data']);
5815 if (is_wp_error($inline_css)) {
5816 return $inline_css;
5817 }
5818
5819 $normalized_data = self::normalize_layout_data($input['pagelayer_data']);
5820
5821 if (empty($input['skip_validation'])) {
5822 $gate = self::quality_gate($normalized_data);
5823 if (is_wp_error($gate)) {
5824 return $gate;
5825 }
5826 }
5827
5828 update_post_meta($post_id, 'pagelayer-data', $normalized_data);
5829
5830 $blocks_content = self::serialize_layout_to_blocks($normalized_data);
5831 wp_update_post(array(
5832 'ID' => $post_id,
5833 'post_content' => $blocks_content,
5834 ));
5835 }
5836
5837 return array('success' => true);
5838 }
5839
5840 public static function execute_change_styles($input) {
5841 $post_id = isset($input['post_id']) ? (int) $input['post_id'] : 0;
5842 if (!$post_id || !get_post($post_id)) {
5843 return new \WP_Error('invalid_post', __('Post not found.', 'pagelayer'));
5844 }
5845
5846 $styles = isset($input['styles']) && is_array($input['styles']) ? $input['styles'] : array();
5847 if (empty($styles)) {
5848 return new \WP_Error('no_styles', __('No style changes provided.', 'pagelayer'));
5849 }
5850
5851 // Props land straight in attrs, so they get the same inline-CSS guard.
5852 $inline_css_found = array();
5853 foreach ($styles as &$style_check) {
5854 if (!isset($style_check['props']) || !is_array($style_check['props'])) {
5855 continue;
5856 }
5857 $props_node = array('tag' => isset($style_check['selector']) ? $style_check['selector'] : '', 'attrs' => $style_check['props']);
5858 self::scrub_node_inline_css($props_node, $inline_css_found);
5859 $style_check['props'] = $props_node['attrs'];
5860 }
5861 unset($style_check);
5862
5863 if (!empty($inline_css_found)) {
5864 return self::inline_css_error($inline_css_found);
5865 }
5866
5867 // Selectors may be an "@0.1.2" outline path as well as a pagelayer-id
5868 // or a widget tag.
5869 foreach ($styles as &$style) {
5870 if (isset($style['selector']) && strpos((string)$style['selector'], '@') === 0) {
5871 $style['selector'] = self::resolve_element_ref($post_id, $style['selector']);
5872 }
5873 }
5874 unset($style);
5875
5876 $data = get_post_meta($post_id, 'pagelayer-data', true);
5877 if (!is_array($data)) {
5878 return new \WP_Error('no_data', __('No Pagelayer data found for this post.', 'pagelayer'));
5879 }
5880
5881 $applied = 0;
5882 $walk = function(&$node) use (&$walk, $styles, &$applied) {
5883 if (!is_array($node)) return;
5884
5885 $node_id = isset($node['attrs']['pagelayer-id']) ? $node['attrs']['pagelayer-id'] : (isset($node['id']) ? $node['id'] : '');
5886 $node_tag = isset($node['tag']) ? $node['tag'] : '';
5887
5888 foreach ($styles as $style) {
5889 $selector = isset($style['selector']) ? $style['selector'] : '';
5890 $props = isset($style['props']) && is_array($style['props']) ? $style['props'] : array();
5891
5892 $match = false;
5893 $clean_selector = str_replace(array('pagelayer_', 'pl_'), '', ltrim($selector, '.'));
5894 $clean_tag = str_replace(array('pagelayer_', 'pl_'), '', $node_tag);
5895 $clean_id = str_replace(array('pagelayer_', 'pl_'), '', $node_id);
5896
5897 if (!empty($node_id) && ($selector === $node_id || $clean_selector === $clean_id)) {
5898 $match = true;
5899 } elseif (!empty($node_tag) && ($selector === $node_tag || $selector === ('.' . $node_tag) || $clean_selector === $clean_tag)) {
5900 $match = true;
5901 }
5902
5903 if ($match && !empty($props)) {
5904 if (!isset($node['attrs']) || !is_array($node['attrs'])) {
5905 $node['attrs'] = array();
5906 }
5907 $node['attrs'] = array_merge($node['attrs'], $props);
5908 $applied++;
5909 }
5910 }
5911
5912 if (isset($node['content']) && is_array($node['content'])) {
5913 foreach ($node['content'] as &$child) {
5914 $walk($child);
5915 }
5916 unset($child);
5917 }
5918 };
5919
5920 foreach ($data as &$section) {
5921 $walk($section);
5922 }
5923 unset($section);
5924
5925 $normalized_data = self::normalize_layout_data($data);
5926 update_post_meta($post_id, 'pagelayer-data', $normalized_data);
5927
5928 $blocks_content = self::serialize_layout_to_blocks($normalized_data);
5929 wp_update_post(array(
5930 'ID' => $post_id,
5931 'post_content' => $blocks_content,
5932 ));
5933
5934 return array('success' => true, 'changes_applied' => $applied);
5935 }
5936
5937 /**
5938 * The full guide is ~2.6k tokens, and most of it (the section-variety and
5939 * widget-recommendation guidance) only matters when BUILDING a page. An
5940 * edit to existing content needs the node format and the dependent-attribute
5941 * rule and nothing else, so those are what the default topic returns.
5942 */
5943 public static function execute_get_data_structure($input) {
5944 $doc = self::get_data_structure_doc();
5945 $topic = isset($input['topic']) ? sanitize_text_field($input['topic']) : 'core';
5946
5947 if ($topic === 'all') {
5948 return $doc;
5949 }
5950
5951 if ($topic === 'quality') {
5952 $quality = $doc['content_quality_rules'];
5953 unset($quality['widget_recommendations']);
5954 return array(
5955 'content_quality_rules' => $quality,
5956 'styling_never_inline' => $doc['styling_never_inline'],
5957 'site_navigation' => $doc['site_navigation'],
5958 'theme_template_conditions' => $doc['theme_template_conditions'],
5959 );
5960 }
5961
5962 if ($topic === 'navigation' || $topic === 'menus') {
5963 return array(
5964 'site_navigation' => $doc['site_navigation'],
5965 'theme_template_conditions' => $doc['theme_template_conditions'],
5966 );
5967 }
5968
5969 if ($topic === 'widgets') {
5970 return array('widget_recommendations' => $doc['content_quality_rules']['widget_recommendations']);
5971 }
5972
5973 if ($topic === 'workflow') {
5974 return array('design_workflow' => $doc['design_workflow']);
5975 }
5976
5977 return array(
5978 'description' => $doc['description'],
5979 'fast_path_sections' => $doc['fast_path_sections'],
5980 'node_format' => $doc['node_format'],
5981 'styling_never_inline' => $doc['styling_never_inline'],
5982 'hierarchy_rules' => $doc['hierarchy_rules'],
5983 'global_reference_syntax' => $doc['global_reference_syntax'],
5984 'dependent_attributes' => $doc['dependent_attributes'],
5985 'responsive_properties' => $doc['responsive_properties'],
5986 'more_topics' => 'Editing existing content needs nothing beyond this. When BUILDING a page also read topic:"quality" (the enforced content gate), topic:"widgets" (which widget to use for which section), topic:"navigation" (header menus and header/footer display conditions — both enforced), topic:"workflow", or topic:"all".',
5987 );
5988 }
5989
5990 public static function execute_find_elements($input) {
5991 $post_id = (int) $input['post_id'];
5992 $data = get_post_meta($post_id, 'pagelayer-data', true);
5993 if (!is_array($data)) {
5994 return array('elements' => array());
5995 }
5996
5997 $results = array();
5998 $target_tag = isset($input['tag']) ? sanitize_text_field($input['tag']) : '';
5999 $search_query = isset($input['query']) ? strtolower($input['query']) : '';
6000 $with_attrs = !empty($input['include_attrs']);
6001
6002 // A text query used to be tested against node["content"] only, so it
6003 // silently missed every widget that keeps its copy in attrs instead
6004 // (pl_iconbox, pl_testimonial, pl_btn...). It now matches the same
6005 // preview text the outline shows.
6006 $search_node = function($node) use (&$results, &$search_node, $target_tag, $search_query, $with_attrs) {
6007 if (!is_array($node) || empty($node['tag'])) {
6008 return;
6009 }
6010
6011 $match = true;
6012 $preview = self::node_preview($node, 120);
6013
6014 if ($target_tag && $node['tag'] !== $target_tag) {
6015 $match = false;
6016 }
6017 if ($search_query && strpos(strtolower($preview), $search_query) === false) {
6018 $match = false;
6019 }
6020
6021 if ($match) {
6022 $id = isset($node['attrs']['pagelayer-id']) ? $node['attrs']['pagelayer-id'] : '';
6023 if ($with_attrs) {
6024 // Opt-in: every style attr on the node, which is most of what
6025 // made this call expensive when it was the default.
6026 $results[] = array(
6027 'id' => $id,
6028 'tag' => $node['tag'],
6029 'attrs' => isset($node['attrs']) ? $node['attrs'] : array(),
6030 'content' => isset($node['content']) && is_string($node['content']) ? $node['content'] : '',
6031 );
6032 } else {
6033 $results[] = trim($id . ' ' . $node['tag'] . ($preview !== '' ? ' "' . $preview . '"' : ''));
6034 }
6035 }
6036
6037 if (isset($node['content']) && is_array($node['content'])) {
6038 foreach ($node['content'] as $child) {
6039 $search_node($child);
6040 }
6041 }
6042 };
6043
6044 foreach ($data as $section) {
6045 $search_node($section);
6046 }
6047
6048 $out = array('elements' => $results, 'count' => count($results));
6049 if (!$with_attrs) {
6050 $out['legend'] = '"<ref> <tag> \"text\"", where <ref> is a pagelayer-id or an "@0.1.2" position path — both usable as element_id. Pass include_attrs:true for full attrs, or read one node via get_page with element_id.';
6051 }
6052 return $out;
6053 }
6054
6055 // ------------------------------------------------------------------
6056 // Page outline
6057 //
6058 // The old navigator returned ids and tags but no text, so it could not
6059 // answer "which node is the hero heading?" — the model had to pull the
6060 // whole pagelayer_data tree (33KB on a real page) just to find one
6061 // element's id. The outline carries a text preview per node, which is what
6062 // makes a targeted edit possible without ever reading the full tree.
6063 // ------------------------------------------------------------------
6064
6065 /**
6066 * Text-bearing attribute names for a widget, from its own schema.
6067 */
6068 protected static function text_attrs_for($tag) {
6069 global $pagelayer;
6070 static $cache = array();
6071
6072 if (isset($cache[$tag])) {
6073 return $cache[$tag];
6074 }
6075
6076 self::ensure_shortcodes_loaded();
6077 if (empty($pagelayer->shortcodes[$tag])) {
6078 return $cache[$tag] = array();
6079 }
6080
6081 $data = $pagelayer->shortcodes[$tag];
6082 $schema = self::extract_widget_schema($tag, $data);
6083 $own = isset($data['settings']) && is_array($data['settings']) ? $data['settings'] : array();
6084 $keys = array();
6085
6086 foreach ($schema['sections'] as $key => $section) {
6087 if (!isset($own[$key])) {
6088 continue;
6089 }
6090 foreach ($section['properties'] as $prop_key => $prop) {
6091 if (in_array(isset($prop['type']) ? $prop['type'] : '', array('text', 'textarea', 'editor'), true)) {
6092 $keys[] = $prop_key;
6093 }
6094 }
6095 }
6096
6097 return $cache[$tag] = $keys;
6098 }
6099
6100 /**
6101 * Short human-readable preview of what a node actually says on the page.
6102 */
6103 protected static function node_preview($node, $limit = 70) {
6104 $tag = isset($node['tag']) ? $node['tag'] : '';
6105 $attrs = isset($node['attrs']) && is_array($node['attrs']) ? $node['attrs'] : array();
6106 $text = '';
6107
6108 if (isset($node['content']) && is_string($node['content'])) {
6109 $text = $node['content'];
6110 }
6111
6112 if ($text === '') {
6113 foreach (self::text_attrs_for($tag) as $key) {
6114 if (!empty($attrs[$key]) && is_string($attrs[$key])) {
6115 $text = $attrs[$key];
6116 break;
6117 }
6118 }
6119 }
6120
6121 $text = trim(preg_replace('/\s+/', ' ', wp_strip_all_tags((string)$text)));
6122 if ($text === '') {
6123 return '';
6124 }
6125 if (function_exists('mb_strlen') && mb_strlen($text) > $limit) {
6126 return mb_substr($text, 0, $limit) . '';
6127 }
6128 if (strlen($text) > $limit) {
6129 return substr($text, 0, $limit) . '';
6130 }
6131 return $text;
6132 }
6133
6134 /**
6135 * Flat, indentation-encoded outline. Indentation carries the hierarchy, so
6136 * there is no nested-object scaffolding to pay for:
6137 * " a8fn2 pl_heading \"Luxury Car Detailing\""
6138 */
6139 public static function outline_nodes($nodes, $depth = 0, &$lines = array(), $path = '') {
6140 $index = -1;
6141 foreach ($nodes as $node) {
6142 $index++;
6143 if (!is_array($node) || empty($node['tag'])) {
6144 continue;
6145 }
6146
6147 $here = ($path === '' ? '' : $path . '.') . $index;
6148
6149 // Pages built in the editor, imported from a template, or written by
6150 // older versions carry no pagelayer-id at all, which used to make
6151 // every element tool unusable on them. Fall back to the positional
6152 // path, which every element ability also accepts.
6153 $id = !empty($node['attrs']['pagelayer-id']) ? $node['attrs']['pagelayer-id'] : '@' . $here;
6154 $line = str_repeat(' ', $depth) . $id . ' ' . $node['tag'];
6155
6156 // Column widths are structural — the model needs them to reason
6157 // about layout without reading attrs.
6158 if ($node['tag'] === 'pl_col' && isset($node['attrs']['col'])) {
6159 $line .= ' col=' . $node['attrs']['col'];
6160 }
6161
6162 $preview = self::node_preview($node);
6163 if ($preview !== '') {
6164 $line .= ' "' . $preview . '"';
6165 }
6166
6167 $lines[] = $line;
6168
6169 if (isset($node['content']) && is_array($node['content'])) {
6170 self::outline_nodes($node['content'], $depth + 1, $lines, $here);
6171 }
6172 }
6173 return $lines;
6174 }
6175
6176 /**
6177 * Locate one node by pagelayer-id, or by "@0.1.2" positional path.
6178 */
6179 public static function find_node_by_id($nodes, $element_id) {
6180 if (strpos($element_id, '@') === 0) {
6181 $level = $nodes;
6182 $node = null;
6183 foreach (explode('.', substr($element_id, 1)) as $step) {
6184 $step = (int)$step;
6185 if (!isset($level[$step]) || !is_array($level[$step])) {
6186 return null;
6187 }
6188 $node = $level[$step];
6189 $level = (isset($node['content']) && is_array($node['content'])) ? $node['content'] : array();
6190 }
6191 return $node;
6192 }
6193
6194 foreach ($nodes as $node) {
6195 if (!is_array($node)) {
6196 continue;
6197 }
6198 if (isset($node['attrs']['pagelayer-id']) && $node['attrs']['pagelayer-id'] === $element_id) {
6199 return $node;
6200 }
6201 if (isset($node['content']) && is_array($node['content'])) {
6202 $found = self::find_node_by_id($node['content'], $element_id);
6203 if ($found !== null) {
6204 return $found;
6205 }
6206 }
6207 }
6208 return null;
6209 }
6210
6211 /**
6212 * Turn an element reference into a real pagelayer-id.
6213 *
6214 * A "@0.1.2" path refers to a node that has no id yet; give it a permanent
6215 * one and persist it, so this and every later call can address it by id.
6216 * Anything else is already an id and passes through untouched.
6217 */
6218 public static function resolve_element_ref($post_id, $ref) {
6219 $ref = trim((string)$ref);
6220 if ($ref === '' || strpos($ref, '@') !== 0) {
6221 return $ref;
6222 }
6223
6224 $data = get_post_meta($post_id, 'pagelayer-data', true);
6225 if (!is_array($data)) {
6226 return $ref;
6227 }
6228
6229 $steps = array_map('intval', explode('.', substr($ref, 1)));
6230 $id = null;
6231
6232 // Walk by reference so the assigned id is written back into $data.
6233 $cursor = &$data;
6234 $last = count($steps) - 1;
6235 foreach ($steps as $i => $step) {
6236 if (!isset($cursor[$step]) || !is_array($cursor[$step])) {
6237 return $ref;
6238 }
6239 if ($i === $last) {
6240 if (empty($cursor[$step]['attrs']['pagelayer-id'])) {
6241 if (!function_exists('pagelayer_create_id')) {
6242 return $ref;
6243 }
6244 $cursor[$step]['attrs']['pagelayer-id'] = pagelayer_create_id();
6245 update_post_meta($post_id, 'pagelayer-data', $data);
6246 }
6247 $id = $cursor[$step]['attrs']['pagelayer-id'];
6248 break;
6249 }
6250 if (!isset($cursor[$step]['content']) || !is_array($cursor[$step]['content'])) {
6251 return $ref;
6252 }
6253 $cursor = &$cursor[$step]['content'];
6254 }
6255 unset($cursor);
6256
6257 return $id === null ? $ref : $id;
6258 }
6259
6260 public static function execute_navigator($input) {
6261 $post_id = (int) $input['post_id'];
6262 $data = get_post_meta($post_id, 'pagelayer-data', true);
6263 if (!is_array($data)) {
6264 return array('outline' => array());
6265 }
6266
6267 $lines = array();
6268 return array(
6269 'outline' => self::outline_nodes($data, 0, $lines),
6270 'legend' => 'One line per node: [indent = nesting depth] <ref> <tag> [col=N] "text preview". <ref> is the node\'s pagelayer-id, or an "@0.1.2" position path when it has none yet. Pass either to update_element/delete_element/move_element, or to get_page as element_id to read that node in full.',
6271 );
6272 }
6273
6274 public static function execute_update_element($input) {
6275 $post_id = (int) $input['post_id'];
6276 $element_id = self::resolve_element_ref($post_id, sanitize_text_field($input['element_id']));
6277 $data = get_post_meta($post_id, 'pagelayer-data', true);
6278 if (!is_array($data)) {
6279 return new \WP_Error('no_data', __('Page has no Pagelayer data.', 'pagelayer'));
6280 }
6281
6282 // Guard what the client sent, not what is already on the node — the
6283 // builder's own editor writes inline styles that are not ours to strip.
6284 $in_attrs = isset($input['attrs']) && is_array($input['attrs']) ? $input['attrs'] : null;
6285 $in_content = isset($input['content']) && is_string($input['content']) ? $input['content'] : null;
6286 if ($in_attrs !== null || $in_content !== null) {
6287 $check_node = array('tag' => '', 'attrs' => $in_attrs !== null ? $in_attrs : array());
6288 if ($in_content !== null) {
6289 $check_node['content'] = $in_content;
6290 }
6291
6292 $inline_css_found = array();
6293 self::scrub_node_inline_css($check_node, $inline_css_found);
6294
6295 if ($in_attrs !== null) {
6296 $input['attrs'] = $check_node['attrs'];
6297 }
6298 if ($in_content !== null) {
6299 $input['content'] = $check_node['content'];
6300 }
6301
6302 if (!empty($inline_css_found)) {
6303 return self::inline_css_error($inline_css_found);
6304 }
6305 }
6306
6307 $updated = false;
6308 $matched_node = null;
6309 $update_node = function(&$node) use ($element_id, $input, &$updated, &$update_node, &$matched_node) {
6310 if (!is_array($node)) return;
6311 if (isset($node['attrs']['pagelayer-id']) && $node['attrs']['pagelayer-id'] === $element_id) {
6312 if (isset($input['attrs']) && is_array($input['attrs'])) {
6313 $node['attrs'] = array_merge($node['attrs'], $input['attrs']);
6314 }
6315 if (isset($input['content'])) {
6316 $node['content'] = $input['content'];
6317 }
6318 $updated = true;
6319 $matched_node = $node;
6320 return;
6321 }
6322 if (isset($node['content']) && is_array($node['content'])) {
6323 foreach ($node['content'] as &$child) {
6324 $update_node($child);
6325 }
6326 unset($child);
6327 }
6328 };
6329
6330 foreach ($data as &$section) {
6331 $update_node($section);
6332 }
6333 unset($section);
6334
6335 if (!$updated) {
6336 return new \WP_Error('not_found', sprintf(__('Element with ID %s not found.', 'pagelayer'), $element_id));
6337 }
6338
6339 if (empty($input['skip_validation']) && is_array($matched_node)) {
6340 $gate = self::quality_gate(array($matched_node));
6341 if (is_wp_error($gate)) {
6342 return $gate;
6343 }
6344 }
6345
6346 $normalized = self::normalize_layout_data($data);
6347 update_post_meta($post_id, 'pagelayer-data', $normalized);
6348 $blocks_content = self::serialize_layout_to_blocks($normalized);
6349 wp_update_post(array('ID' => $post_id, 'post_content' => $blocks_content));
6350 return array('success' => true);
6351 }
6352
6353 public static function execute_add_element($input) {
6354 $post_id = (int) $input['post_id'];
6355 $parent_id = isset($input['parent_id']) ? self::resolve_element_ref($post_id, sanitize_text_field($input['parent_id'])) : '';
6356
6357 $inline_css_found = array();
6358 self::scrub_node_inline_css($input['element'], $inline_css_found);
6359 if (!empty($inline_css_found)) {
6360 return self::inline_css_error($inline_css_found);
6361 }
6362
6363 $element = self::normalize_node($input['element']);
6364
6365 if (empty($input['skip_validation'])) {
6366 $gate = self::quality_gate(array($element));
6367 if (is_wp_error($gate)) {
6368 return $gate;
6369 }
6370 }
6371
6372 $data = get_post_meta($post_id, 'pagelayer-data', true);
6373 if (!is_array($data)) {
6374 $data = array();
6375 }
6376
6377 $inserted = false;
6378 $index = isset($input['index']) ? (int) $input['index'] : -1;
6379
6380 if (empty($parent_id)) {
6381 if ($index >= 0 && $index < count($data)) {
6382 array_splice($data, $index, 0, array($element));
6383 } else {
6384 $data[] = $element;
6385 }
6386 $inserted = true;
6387 } else {
6388 $insert_node = function(&$node) use ($parent_id, $element, $index, &$inserted, &$insert_node) {
6389 if (!is_array($node)) return;
6390 if (isset($node['attrs']['pagelayer-id']) && $node['attrs']['pagelayer-id'] === $parent_id) {
6391 if (!isset($node['content']) || !is_array($node['content'])) {
6392 $node['content'] = array();
6393 }
6394 if ($index >= 0 && $index < count($node['content'])) {
6395 array_splice($node['content'], $index, 0, array($element));
6396 } else {
6397 $node['content'][] = $element;
6398 }
6399 $inserted = true;
6400 return;
6401 }
6402 if (isset($node['content']) && is_array($node['content'])) {
6403 foreach ($node['content'] as &$child) {
6404 $insert_node($child);
6405 }
6406 unset($child);
6407 }
6408 };
6409
6410 foreach ($data as &$section) {
6411 $insert_node($section);
6412 }
6413 unset($section);
6414 }
6415
6416 if ($inserted) {
6417 $normalized = self::normalize_layout_data($data);
6418 update_post_meta($post_id, 'pagelayer-data', $normalized);
6419 $blocks_content = self::serialize_layout_to_blocks($normalized);
6420 wp_update_post(array('ID' => $post_id, 'post_content' => $blocks_content));
6421 return array('success' => true, 'element_id' => isset($element['attrs']['pagelayer-id']) ? $element['attrs']['pagelayer-id'] : '');
6422 }
6423 return new \WP_Error('parent_not_found', __('Parent element not found.', 'pagelayer'));
6424 }
6425
6426 public static function execute_delete_element($input) {
6427 $post_id = (int) $input['post_id'];
6428 $element_id = self::resolve_element_ref($post_id, sanitize_text_field($input['element_id']));
6429 $data = get_post_meta($post_id, 'pagelayer-data', true);
6430 if (!is_array($data)) {
6431 return new \WP_Error('no_data', __('Page has no Pagelayer data.', 'pagelayer'));
6432 }
6433
6434 $deleted = false;
6435 foreach ($data as $idx => $node) {
6436 if (isset($node['attrs']['pagelayer-id']) && $node['attrs']['pagelayer-id'] === $element_id) {
6437 array_splice($data, $idx, 1);
6438 $deleted = true;
6439 break;
6440 }
6441 }
6442
6443 if (!$deleted) {
6444 $delete_node = function(&$node) use ($element_id, &$deleted, &$delete_node) {
6445 if (!is_array($node)) return;
6446 if (isset($node['content']) && is_array($node['content'])) {
6447 foreach ($node['content'] as $idx => $child) {
6448 if (isset($child['attrs']['pagelayer-id']) && $child['attrs']['pagelayer-id'] === $element_id) {
6449 array_splice($node['content'], $idx, 1);
6450 $deleted = true;
6451 return;
6452 }
6453 }
6454 foreach ($node['content'] as &$child) {
6455 $delete_node($child);
6456 }
6457 unset($child);
6458 }
6459 };
6460
6461 foreach ($data as &$section) {
6462 if ($deleted) break;
6463 $delete_node($section);
6464 }
6465 unset($section);
6466 }
6467
6468 if ($deleted) {
6469 $normalized = self::normalize_layout_data($data);
6470 update_post_meta($post_id, 'pagelayer-data', $normalized);
6471 $blocks_content = self::serialize_layout_to_blocks($normalized);
6472 wp_update_post(array('ID' => $post_id, 'post_content' => $blocks_content));
6473 return array('success' => true);
6474 }
6475 return new \WP_Error('not_found', __('Element not found.', 'pagelayer'));
6476 }
6477
6478 public static function execute_move_element($input) {
6479 $post_id = (int) $input['post_id'];
6480 $element_id = self::resolve_element_ref($post_id, sanitize_text_field($input['element_id']));
6481 $parent_id = isset($input['parent_id']) ? self::resolve_element_ref($post_id, sanitize_text_field($input['parent_id'])) : '';
6482 $index = isset($input['index']) ? (int) $input['index'] : -1;
6483
6484 $data = get_post_meta($post_id, 'pagelayer-data', true);
6485 if (!is_array($data)) {
6486 return new \WP_Error('no_data', __('Page has no Pagelayer data.', 'pagelayer'));
6487 }
6488
6489 $extracted = null;
6490 foreach ($data as $idx => $node) {
6491 if (isset($node['attrs']['pagelayer-id']) && $node['attrs']['pagelayer-id'] === $element_id) {
6492 $extracted = $node;
6493 array_splice($data, $idx, 1);
6494 break;
6495 }
6496 }
6497
6498 if (!$extracted) {
6499 $extract_node = function(&$node) use ($element_id, &$extracted, &$extract_node) {
6500 if (!is_array($node)) return;
6501 if (isset($node['content']) && is_array($node['content'])) {
6502 foreach ($node['content'] as $idx => $child) {
6503 if (isset($child['attrs']['pagelayer-id']) && $child['attrs']['pagelayer-id'] === $element_id) {
6504 $extracted = $child;
6505 array_splice($node['content'], $idx, 1);
6506 return;
6507 }
6508 }
6509 foreach ($node['content'] as &$child) {
6510 if ($extracted) return;
6511 $extract_node($child);
6512 }
6513 unset($child);
6514 }
6515 };
6516 foreach ($data as &$section) {
6517 if ($extracted) break;
6518 $extract_node($section);
6519 }
6520 unset($section);
6521 }
6522
6523 if (!$extracted) {
6524 return new \WP_Error('not_found', __('Element to move not found.', 'pagelayer'));
6525 }
6526
6527 $inserted = false;
6528 if (empty($parent_id)) {
6529 if ($index >= 0 && $index < count($data)) {
6530 array_splice($data, $index, 0, array($extracted));
6531 } else {
6532 $data[] = $extracted;
6533 }
6534 $inserted = true;
6535 } else {
6536 $insert_node = function(&$node) use ($parent_id, $extracted, $index, &$inserted, &$insert_node) {
6537 if (!is_array($node)) return;
6538 if (isset($node['attrs']['pagelayer-id']) && $node['attrs']['pagelayer-id'] === $parent_id) {
6539 if (!isset($node['content']) || !is_array($node['content'])) {
6540 $node['content'] = array();
6541 }
6542 if ($index >= 0 && $index < count($node['content'])) {
6543 array_splice($node['content'], $index, 0, array($extracted));
6544 } else {
6545 $node['content'][] = $extracted;
6546 }
6547 $inserted = true;
6548 return;
6549 }
6550 if (isset($node['content']) && is_array($node['content'])) {
6551 foreach ($node['content'] as &$child) {
6552 if ($inserted) return;
6553 $insert_node($child);
6554 }
6555 unset($child);
6556 }
6557 };
6558 foreach ($data as &$section) {
6559 if ($inserted) break;
6560 $insert_node($section);
6561 }
6562 unset($section);
6563 }
6564
6565 if ($inserted) {
6566 $normalized = self::normalize_layout_data($data);
6567 update_post_meta($post_id, 'pagelayer-data', $normalized);
6568 $blocks_content = self::serialize_layout_to_blocks($normalized);
6569 wp_update_post(array('ID' => $post_id, 'post_content' => $blocks_content));
6570 return array('success' => true);
6571 }
6572 return new \WP_Error('parent_not_found', __('Target parent not found.', 'pagelayer'));
6573 }
6574
6575 public static function execute_duplicate_element($input) {
6576 $post_id = (int) $input['post_id'];
6577 $element_id = self::resolve_element_ref($post_id, sanitize_text_field($input['element_id']));
6578 $data = get_post_meta($post_id, 'pagelayer-data', true);
6579 if (!is_array($data)) {
6580 return new \WP_Error('no_data', __('Page has no Pagelayer data.', 'pagelayer'));
6581 }
6582
6583 $refresh_ids = function(&$node) use (&$refresh_ids) {
6584 if (!is_array($node)) return;
6585 if (isset($node['attrs']['pagelayer-id']) && function_exists('pagelayer_create_id')) {
6586 $node['attrs']['pagelayer-id'] = pagelayer_create_id();
6587 }
6588 if (isset($node['content']) && is_array($node['content'])) {
6589 foreach ($node['content'] as &$child) {
6590 $refresh_ids($child);
6591 }
6592 unset($child);
6593 }
6594 };
6595
6596 $duplicated = false;
6597 $new_id = '';
6598
6599 foreach ($data as $idx => $node) {
6600 if (isset($node['attrs']['pagelayer-id']) && $node['attrs']['pagelayer-id'] === $element_id) {
6601 $copy = $node;
6602 $refresh_ids($copy);
6603 $new_id = isset($copy['attrs']['pagelayer-id']) ? $copy['attrs']['pagelayer-id'] : '';
6604 array_splice($data, $idx + 1, 0, array($copy));
6605 $duplicated = true;
6606 break;
6607 }
6608 }
6609
6610 if (!$duplicated) {
6611 $duplicate_node = function(&$node) use ($element_id, $refresh_ids, &$duplicated, &$new_id, &$duplicate_node) {
6612 if (!is_array($node)) return;
6613 if (isset($node['content']) && is_array($node['content'])) {
6614 foreach ($node['content'] as $idx => $child) {
6615 if (isset($child['attrs']['pagelayer-id']) && $child['attrs']['pagelayer-id'] === $element_id) {
6616 $copy = $child;
6617 $refresh_ids($copy);
6618 $new_id = isset($copy['attrs']['pagelayer-id']) ? $copy['attrs']['pagelayer-id'] : '';
6619 array_splice($node['content'], $idx + 1, 0, array($copy));
6620 $duplicated = true;
6621 return;
6622 }
6623 }
6624 foreach ($node['content'] as &$child) {
6625 if ($duplicated) return;
6626 $duplicate_node($child);
6627 }
6628 unset($child);
6629 }
6630 };
6631 foreach ($data as &$section) {
6632 if ($duplicated) break;
6633 $duplicate_node($section);
6634 }
6635 unset($section);
6636 }
6637
6638 if ($duplicated) {
6639 $normalized = self::normalize_layout_data($data);
6640 update_post_meta($post_id, 'pagelayer-data', $normalized);
6641 $blocks_content = self::serialize_layout_to_blocks($normalized);
6642 wp_update_post(array('ID' => $post_id, 'post_content' => $blocks_content));
6643 return array('success' => true, 'new_element_id' => $new_id);
6644 }
6645 return new \WP_Error('not_found', __('Element to duplicate not found.', 'pagelayer'));
6646 }
6647
6648 public static function execute_begin_transaction($input) {
6649 $post_id = (int) $input['post_id'];
6650 $data = get_post_meta($post_id, 'pagelayer-data', true);
6651 $post = get_post($post_id);
6652 if (!$post) {
6653 return new \WP_Error('invalid_post', __('Post not found.', 'pagelayer'));
6654 }
6655
6656 $backup = array(
6657 'data' => $data,
6658 'content' => $post->post_content
6659 );
6660 update_option('pagelayer_tx_backup_' . $post_id, $backup);
6661 return array('success' => true);
6662 }
6663
6664 public static function execute_commit_transaction($input) {
6665 $post_id = (int) $input['post_id'];
6666 delete_option('pagelayer_tx_backup_' . $post_id);
6667 return array('success' => true);
6668 }
6669
6670 public static function execute_rollback_transaction($input) {
6671 $post_id = (int) $input['post_id'];
6672 $backup = get_option('pagelayer_tx_backup_' . $post_id);
6673 if (!$backup) {
6674 return new \WP_Error('no_backup', __('No active transaction to rollback.', 'pagelayer'));
6675 }
6676
6677 update_post_meta($post_id, 'pagelayer-data', $backup['data']);
6678 wp_update_post(array(
6679 'ID' => $post_id,
6680 'post_content' => $backup['content']
6681 ));
6682 delete_option('pagelayer_tx_backup_' . $post_id);
6683 return array('success' => true);
6684 }
6685
6686 public static function execute_save_template($input) {
6687 $template_name = sanitize_text_field($input['name']);
6688 $post_id = (int) $input['post_id'];
6689 $element_id = isset($input['element_id']) ? self::resolve_element_ref($post_id, sanitize_text_field($input['element_id'])) : '';
6690
6691 $data = get_post_meta($post_id, 'pagelayer-data', true);
6692 if (!is_array($data)) {
6693 return new \WP_Error('no_data', __('No layout data to save.', 'pagelayer'));
6694 }
6695
6696 $template_data = $data;
6697 if (!empty($element_id)) {
6698 $found = null;
6699 $find_node = function($node) use ($element_id, &$found, &$find_node) {
6700 if (!is_array($node)) return;
6701 if (isset($node['attrs']['pagelayer-id']) && $node['attrs']['pagelayer-id'] === $element_id) {
6702 $found = $node;
6703 return;
6704 }
6705 if (isset($node['content']) && is_array($node['content'])) {
6706 foreach ($node['content'] as $child) {
6707 $find_node($child);
6708 }
6709 }
6710 };
6711 foreach ($data as $section) {
6712 $find_node($section);
6713 }
6714 if (!$found) {
6715 return new \WP_Error('not_found', __('Element to save not found.', 'pagelayer'));
6716 }
6717 $template_data = array($found);
6718 }
6719
6720 $library = get_option('pagelayer_template_library', array());
6721 $library[$template_name] = $template_data;
6722 update_option('pagelayer_template_library', $library);
6723 return array('success' => true);
6724 }
6725
6726 public static function execute_list_templates($input) {
6727 $library = get_option('pagelayer_template_library', array());
6728 return array('templates' => array_keys($library));
6729 }
6730
6731 public static function execute_insert_template($input) {
6732 $template_name = sanitize_text_field($input['name']);
6733 $post_id = (int) $input['post_id'];
6734 $parent_id = isset($input['parent_id']) ? sanitize_text_field($input['parent_id']) : '';
6735 $index = isset($input['index']) ? (int) $input['index'] : -1;
6736
6737 $library = get_option('pagelayer_template_library', array());
6738 if (!isset($library[$template_name])) {
6739 return new \WP_Error('not_found', __('Template not found.', 'pagelayer'));
6740 }
6741
6742 $template_data = $library[$template_name];
6743 $refresh_ids = function(&$node) use (&$refresh_ids) {
6744 if (!is_array($node)) return;
6745 if (isset($node['attrs']['pagelayer-id']) && function_exists('pagelayer_create_id')) {
6746 $node['attrs']['pagelayer-id'] = pagelayer_create_id();
6747 }
6748 if (isset($node['content']) && is_array($node['content'])) {
6749 foreach ($node['content'] as &$child) {
6750 $refresh_ids($child);
6751 }
6752 unset($child);
6753 }
6754 };
6755 foreach ($template_data as &$node) {
6756 $refresh_ids($node);
6757 }
6758 unset($node);
6759
6760 $data = get_post_meta($post_id, 'pagelayer-data', true);
6761 if (!is_array($data)) {
6762 $data = array();
6763 }
6764
6765 $inserted = false;
6766 if (empty($parent_id)) {
6767 if ($index >= 0 && $index < count($data)) {
6768 array_splice($data, $index, 0, $template_data);
6769 } else {
6770 $data = array_merge($data, $template_data);
6771 }
6772 $inserted = true;
6773 } else {
6774 $insert_node = function(&$node) use ($parent_id, $template_data, $index, &$inserted, &$insert_node) {
6775 if (!is_array($node)) return;
6776 if (isset($node['attrs']['pagelayer-id']) && $node['attrs']['pagelayer-id'] === $parent_id) {
6777 if (!isset($node['content']) || !is_array($node['content'])) {
6778 $node['content'] = array();
6779 }
6780 if ($index >= 0 && $index < count($node['content'])) {
6781 array_splice($node['content'], $index, 0, $template_data);
6782 } else {
6783 $node['content'] = array_merge($node['content'], $template_data);
6784 }
6785 $inserted = true;
6786 return;
6787 }
6788 if (isset($node['content']) && is_array($node['content'])) {
6789 foreach ($node['content'] as &$child) {
6790 $insert_node($child);
6791 }
6792 unset($child);
6793 }
6794 };
6795 foreach ($data as &$section) {
6796 $insert_node($section);
6797 }
6798 unset($section);
6799 }
6800
6801 if ($inserted) {
6802 $normalized = self::normalize_layout_data($data);
6803 update_post_meta($post_id, 'pagelayer-data', $normalized);
6804 $blocks_content = self::serialize_layout_to_blocks($normalized);
6805 wp_update_post(array('ID' => $post_id, 'post_content' => $blocks_content));
6806 return array('success' => true);
6807 }
6808 return new \WP_Error('parent_not_found', __('Parent not found.', 'pagelayer'));
6809 }
6810
6811 public static function execute_upload_media($input) {
6812 $url = esc_url_raw($input['url']);
6813 $desc = isset($input['alt_text']) ? sanitize_text_field($input['alt_text']) : '';
6814
6815 require_once(ABSPATH . 'wp-admin/includes/image.php');
6816 require_once(ABSPATH . 'wp-admin/includes/file.php');
6817 require_once(ABSPATH . 'wp-admin/includes/media.php');
6818
6819 $tmp = download_url($url);
6820 if (is_wp_error($tmp)) {
6821 return $tmp;
6822 }
6823
6824 $file_array = array(
6825 'name' => basename($url),
6826 'tmp_name' => $tmp
6827 );
6828
6829 if (strpos($file_array['name'], '.') === false) {
6830 $file_array['name'] .= '.jpg';
6831 }
6832
6833 $id = media_handle_sideload($file_array, 0, $desc);
6834 if (is_wp_error($id)) {
6835 @unlink($tmp);
6836 return $id;
6837 }
6838
6839 return array(
6840 'attachment_id' => $id,
6841 'url' => wp_get_attachment_url($id)
6842 );
6843 }
6844
6845 public static function execute_get_preview($input) {
6846 $post_id = (int) $input['post_id'];
6847 $post = get_post($post_id);
6848 if (!$post) {
6849 return new \WP_Error('invalid_post', __('Post not found.', 'pagelayer'));
6850 }
6851 $url = ('publish' === $post->post_status)
6852 ? get_permalink($post_id)
6853 : get_preview_post_link($post_id);
6854 return array('url' => $url);
6855 }
6856
6857 }