PluginProbe
Page Builder: Pagelayer – Drag and Drop website builder / 2.2.1
Page Builder: Pagelayer – Drag and Drop website builder v2.2.1
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.2.1, at main/abilitiesregister.php

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