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 / ai-controller.php

ai-controller.php in Page Builder: Pagelayer – Drag and Drop website builder 2.2.1, at main/ai-controller.php

2,283 lines 85.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 //////////////////////////////////////////////////////////////
4 //===========================================================
5 // ai-controller.php
6 //===========================================================
7 // PAGELAYER
8 // AI Layout Engine & Generator Controller
9 //===========================================================
10 //////////////////////////////////////////////////////////////
11
12 if (!defined('ABSPATH')) exit;
13
14 if (!class_exists('Pagelayer_AI_Layout_Engine')) {
15 include_once(dirname(__FILE__) . '/ai-layout-engine.php');
16 }
17
18 class Pagelayer_AI_Controller {
19
20 private static $instance = null;
21 const REST_NAMESPACE = 'pagelayer/v1';
22 const META_KEY = 'pagelayer_ai_keys';
23
24 public static function get_instance() {
25 if (self::$instance === null) {
26 self::$instance = new self();
27 }
28 return self::$instance;
29 }
30
31 public function __construct() {
32 add_action('rest_api_init', array($this, 'register_rest_routes'));
33 add_filter('theme_page_templates', array($this, 'register_canvas_page_template'));
34 add_filter('template_include', array($this, 'include_canvas_page_template'), 1100);
35 }
36
37 /**
38 * Blank full-width template so AI landing pages render edge-to-edge.
39 */
40 public function register_canvas_page_template($templates) {
41 if (!is_array($templates)) {
42 $templates = array();
43 }
44 $templates['pagelayer-canvas'] = __('Pagelayer Canvas', 'pagelayer');
45 return $templates;
46 }
47
48 public function include_canvas_page_template($template) {
49 if (!is_singular()) {
50 return $template;
51 }
52 $post_id = get_queried_object_id();
53 if (!$post_id) {
54 return $template;
55 }
56 $slug = get_page_template_slug($post_id);
57 if ($slug !== 'pagelayer-canvas') {
58 return $template;
59 }
60 $file = (defined('PAGELAYER_DIR') ? PAGELAYER_DIR : dirname(__FILE__) . '/..') . '/templates/pagelayer-canvas.php';
61 return file_exists($file) ? $file : $template;
62 }
63
64 public function assign_canvas_template($post_id) {
65 $post_id = (int) $post_id;
66 if ($post_id <= 0 || get_post_type($post_id) !== 'page') {
67 return false;
68 }
69 update_post_meta($post_id, '_wp_page_template', 'pagelayer-canvas');
70 return true;
71 }
72
73 /**
74 * Register REST API routes
75 */
76 public function register_rest_routes() {
77 // POST /pagelayer/v1/ai/settings
78 register_rest_route(self::REST_NAMESPACE, '/ai/settings', array(
79 array(
80 'methods' => WP_REST_Server::CREATABLE,
81 'callback' => array($this, 'rest_save_settings'),
82 'permission_callback' => array($this, 'check_permissions'),
83 ),
84 array(
85 'methods' => WP_REST_Server::READABLE,
86 'callback' => array($this, 'rest_get_settings'),
87 'permission_callback' => array($this, 'check_permissions'),
88 ),
89 ));
90
91 // POST /pagelayer/v1/ai/generate
92 register_rest_route(self::REST_NAMESPACE, '/ai/generate', array(
93 'methods' => WP_REST_Server::CREATABLE,
94 'callback' => array($this, 'rest_generate'),
95 'permission_callback' => array($this, 'check_permissions'),
96 ));
97 }
98
99 /**
100 * Verify user permissions
101 */
102 public function check_permissions() {
103 return current_user_can('edit_posts');
104 }
105
106 /**
107 * Available models and providers definition
108 */
109 public function get_available_models() {
110 return array(
111 'gemini' => array(
112 'name' => 'Google Gemini',
113 'default_model' => 'gemini-3.7-flash',
114 'placeholder' => 'e.g. gemini-3.7-flash, gemini-1.5-flash, gemini-1.5-pro',
115 'docs_url' => 'https://aistudio.google.com/app/apikey',
116 ),
117 'deepseek' => array(
118 'name' => 'DeepSeek (Official API)',
119 'default_model' => 'deepseek-chat',
120 'placeholder' => 'e.g. deepseek-chat, deepseek-reasoner',
121 'docs_url' => 'https://platform.deepseek.com/api_keys',
122 ),
123 'openrouter' => array(
124 'name' => 'OpenRouter (All Open Models)',
125 'default_model' => 'deepseek/deepseek-chat',
126 'placeholder' => 'e.g. deepseek/deepseek-chat, google/gemini-3.7-flash-001, meta-llama/llama-3.3-70b-instruct',
127 'docs_url' => 'https://openrouter.ai/keys',
128 ),
129 'openai' => array(
130 'name' => 'OpenAI',
131 'default_model' => 'gpt-4o-mini',
132 'placeholder' => 'e.g. gpt-4o-mini, gpt-4o, o3-mini',
133 'docs_url' => 'https://platform.openai.com/api-keys',
134 ),
135 'anthropic' => array(
136 'name' => 'Anthropic Claude',
137 'default_model' => 'claude-3-7-sonnet-20250219',
138 'placeholder' => 'e.g. claude-3-7-sonnet-20250219, claude-3-5-sonnet-20241022, claude-3-5-haiku-20241022',
139 'docs_url' => 'https://console.anthropic.com/settings/keys',
140 ),
141 'custom' => array(
142 'name' => 'Custom / Local (OpenAI-Compatible)',
143 'default_model' => 'llama3.3',
144 'placeholder' => 'e.g. llama3.3, qwen2.5-coder, deepseek-r1, mistral',
145 'docs_url' => '',
146 ),
147 );
148 }
149
150 /**
151 * Encrypt sensitive string
152 */
153 private function encrypt($plain_text) {
154 if (empty($plain_text)) return '';
155 $key = hash('sha256', wp_salt('auth'), true);
156 $iv = openssl_random_pseudo_bytes(16);
157 $cipher = openssl_encrypt($plain_text, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
158 if ($cipher === false) return base64_encode($plain_text);
159 return base64_encode($iv . $cipher);
160 }
161
162 /**
163 * Decrypt sensitive string
164 */
165 private function decrypt($encrypted_text) {
166 if (empty($encrypted_text)) return '';
167 $data = base64_decode($encrypted_text);
168 if ($data === false || strlen($data) < 17) {
169 return $data !== false ? $data : '';
170 }
171 $key = hash('sha256', wp_salt('auth'), true);
172 $iv = substr($data, 0, 16);
173 $cipher = substr($data, 16);
174 $plain = openssl_decrypt($cipher, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
175 if ($plain === false) {
176 return $data;
177 }
178 return $plain;
179 }
180
181 /**
182 * Mask an API key for safe client display
183 */
184 private function mask_key($key) {
185 if (empty($key)) return '';
186 $len = strlen($key);
187 if ($len <= 8) return '••••••••';
188 return substr($key, 0, 4) . '••••••••' . substr($key, -4);
189 }
190
191 /**
192 * Get saved API keys and settings for current user
193 */
194 public function get_user_ai_settings($user_id = 0) {
195 if (!$user_id) {
196 $user_id = get_current_user_id();
197 }
198 $data = get_user_meta($user_id, self::META_KEY, true);
199 if (!is_array($data)) {
200 $data = array();
201 }
202
203 $deepseek_key = !empty($data['deepseek_key']) ? $this->decrypt($data['deepseek_key']) : '';
204 $deepseek_model = !empty($data['deepseek_model']) ? sanitize_text_field($data['deepseek_model']) : (!empty($data['default_model']) && (!empty($data['default_provider']) && $data['default_provider'] === 'deepseek') ? sanitize_text_field($data['default_model']) : 'deepseek-chat');
205 $gemini_key = !empty($data['gemini_key']) ? $this->decrypt($data['gemini_key']) : '';
206 $gemini_model = !empty($data['gemini_model']) ? sanitize_text_field($data['gemini_model']) : (!empty($data['default_model']) && (!empty($data['default_provider']) && $data['default_provider'] === 'gemini') ? sanitize_text_field($data['default_model']) : 'gemini-3.7-flash');
207 if ($gemini_model === 'gemini-2.5-flash' || $gemini_model === 'gemini-2.5-pro' || strpos($gemini_model, 'gemini-2.5') !== false) {
208 $gemini_model = 'gemini-3.7-flash';
209 }
210 $openrouter_key = !empty($data['openrouter_key']) ? $this->decrypt($data['openrouter_key']) : '';
211 $openrouter_model = !empty($data['openrouter_model']) ? sanitize_text_field($data['openrouter_model']) : (!empty($data['default_model']) && (!empty($data['default_provider']) && $data['default_provider'] === 'openrouter') ? sanitize_text_field($data['default_model']) : 'deepseek/deepseek-chat');
212 $openai_key = !empty($data['openai_key']) ? $this->decrypt($data['openai_key']) : '';
213 $openai_model = !empty($data['openai_model']) ? sanitize_text_field($data['openai_model']) : (!empty($data['default_model']) && (!empty($data['default_provider']) && $data['default_provider'] === 'openai') ? sanitize_text_field($data['default_model']) : 'gpt-4o-mini');
214 $anthropic_key = !empty($data['anthropic_key']) ? $this->decrypt($data['anthropic_key']) : '';
215 $anthropic_model = !empty($data['anthropic_model']) ? sanitize_text_field($data['anthropic_model']) : (!empty($data['default_model']) && (!empty($data['default_provider']) && $data['default_provider'] === 'anthropic') ? sanitize_text_field($data['default_model']) : 'claude-3-7-sonnet-20250219');
216 $custom_key = !empty($data['custom_key']) ? $this->decrypt($data['custom_key']) : '';
217 $custom_endpoint = !empty($data['custom_endpoint']) ? sanitize_text_field($data['custom_endpoint']) : '';
218 $custom_model = !empty($data['custom_model']) ? sanitize_text_field($data['custom_model']) : '';
219
220 $resolved = $this->resolve_default_provider($data, array(
221 'deepseek_key' => $deepseek_key,
222 'deepseek_model' => $deepseek_model,
223 'gemini_key' => $gemini_key,
224 'gemini_model' => $gemini_model,
225 'openrouter_key' => $openrouter_key,
226 'openrouter_model' => $openrouter_model,
227 'openai_key' => $openai_key,
228 'openai_model' => $openai_model,
229 'anthropic_key' => $anthropic_key,
230 'anthropic_model' => $anthropic_model,
231 'custom_key' => $custom_key,
232 'custom_endpoint' => $custom_endpoint,
233 'custom_model' => $custom_model,
234 ));
235
236 return array(
237 'default_provider' => $resolved['provider'],
238 'default_model' => $resolved['model'],
239 'deepseek_key' => $deepseek_key,
240 'deepseek_model' => $deepseek_model,
241 'gemini_key' => $gemini_key,
242 'gemini_model' => $gemini_model,
243 'openrouter_key' => $openrouter_key,
244 'openrouter_model' => $openrouter_model,
245 'openai_key' => $openai_key,
246 'openai_model' => $openai_model,
247 'anthropic_key' => $anthropic_key,
248 'anthropic_model' => $anthropic_model,
249 'custom_key' => $custom_key,
250 'custom_endpoint' => $custom_endpoint,
251 'custom_model' => $custom_model,
252 );
253 }
254
255 /**
256 * Prefer a provider the user actually connected. Custom/OpenCode is
257 * connected when an endpoint is set (API key is optional for local servers).
258 * Never keep Gemini as default when it has no key.
259 */
260 private function resolve_default_provider($data, $keys) {
261 $saved_provider = !empty($data['default_provider']) ? $data['default_provider'] : '';
262 $saved_model = !empty($data['default_model']) ? $data['default_model'] : '';
263
264 $connected = array();
265 if (!empty($keys['custom_endpoint'])) {
266 $connected['custom'] = !empty($keys['custom_model']) ? $keys['custom_model'] : 'llama3.3';
267 }
268 if (!empty($keys['deepseek_key'])) {
269 $connected['deepseek'] = !empty($keys['deepseek_model']) ? $keys['deepseek_model'] : 'deepseek-chat';
270 }
271 if (!empty($keys['openrouter_key'])) {
272 $connected['openrouter'] = !empty($keys['openrouter_model']) ? $keys['openrouter_model'] : 'deepseek/deepseek-chat';
273 }
274 if (!empty($keys['openai_key'])) {
275 $connected['openai'] = !empty($keys['openai_model']) ? $keys['openai_model'] : 'gpt-4o-mini';
276 }
277 if (!empty($keys['anthropic_key'])) {
278 $connected['anthropic'] = !empty($keys['anthropic_model']) ? $keys['anthropic_model'] : 'claude-3-7-sonnet-20250219';
279 }
280 if (!empty($keys['gemini_key'])) {
281 $connected['gemini'] = !empty($keys['gemini_model']) ? $keys['gemini_model'] : 'gemini-3.7-flash';
282 }
283
284 if ($saved_provider !== '' && isset($connected[$saved_provider])) {
285 $model = $saved_model;
286 if ($model === '') {
287 $model = $connected[$saved_provider];
288 }
289 return array('provider' => $saved_provider, 'model' => $model);
290 }
291
292 foreach ($connected as $provider => $model) {
293 return array('provider' => $provider, 'model' => $model);
294 }
295
296 return array('provider' => 'gemini', 'model' => !empty($keys['gemini_model']) ? $keys['gemini_model'] : 'gemini-3.7-flash');
297 }
298
299 /**
300 * REST: GET /pagelayer/v1/ai/settings
301 */
302 public function rest_get_settings($request) {
303 $settings = $this->get_user_ai_settings();
304
305 return rest_ensure_response(array(
306 'success' => true,
307 'default_provider' => $settings['default_provider'],
308 'default_model' => $settings['default_model'],
309 'has_deepseek_key' => !empty($settings['deepseek_key']),
310 'has_gemini_key' => !empty($settings['gemini_key']),
311 'has_openrouter_key' => !empty($settings['openrouter_key']),
312 'has_openai_key' => !empty($settings['openai_key']),
313 'has_anthropic_key' => !empty($settings['anthropic_key']),
314 'has_custom_key' => !empty($settings['custom_key']),
315 'deepseek_model' => $settings['deepseek_model'],
316 'gemini_model' => $settings['gemini_model'],
317 'openrouter_model' => $settings['openrouter_model'],
318 'openai_model' => $settings['openai_model'],
319 'anthropic_model' => $settings['anthropic_model'],
320 'custom_endpoint' => $settings['custom_endpoint'],
321 'custom_model' => $settings['custom_model'],
322 'deepseek_masked' => $this->mask_key($settings['deepseek_key']),
323 'gemini_masked' => $this->mask_key($settings['gemini_key']),
324 'openrouter_masked' => $this->mask_key($settings['openrouter_key']),
325 'openai_masked' => $this->mask_key($settings['openai_key']),
326 'anthropic_masked' => $this->mask_key($settings['anthropic_key']),
327 'custom_masked' => $this->mask_key($settings['custom_key']),
328 'available_models' => $this->get_available_models(),
329 ));
330 }
331
332 /**
333 * REST: POST /pagelayer/v1/ai/settings
334 */
335 public function rest_save_settings($request) {
336 $user_id = get_current_user_id();
337 $params = $request->get_json_params();
338 if (empty($params)) {
339 $params = $request->get_params();
340 }
341
342 $existing = get_user_meta($user_id, self::META_KEY, true);
343 if (!is_array($existing)) {
344 $existing = array();
345 }
346
347 // Handle DeepSeek key & model
348 if (isset($params['deepseek_key'])) {
349 $raw_key = trim($params['deepseek_key']);
350 if ($raw_key === '') {
351 unset($existing['deepseek_key']);
352 } elseif (strpos($raw_key, '••••') === false) {
353 $existing['deepseek_key'] = $this->encrypt(sanitize_text_field($raw_key));
354 }
355 }
356 if (isset($params['deepseek_model'])) {
357 $existing['deepseek_model'] = sanitize_text_field(trim($params['deepseek_model']));
358 }
359
360 // Handle Gemini key & model
361 if (isset($params['gemini_key'])) {
362 $raw_key = trim($params['gemini_key']);
363 if ($raw_key === '') {
364 unset($existing['gemini_key']);
365 } elseif (strpos($raw_key, '••••') === false) {
366 $existing['gemini_key'] = $this->encrypt(sanitize_text_field($raw_key));
367 }
368 }
369 if (isset($params['gemini_model'])) {
370 $existing['gemini_model'] = sanitize_text_field(trim($params['gemini_model']));
371 }
372
373 // Handle OpenRouter key & model
374 if (isset($params['openrouter_key'])) {
375 $raw_key = trim($params['openrouter_key']);
376 if ($raw_key === '') {
377 unset($existing['openrouter_key']);
378 } elseif (strpos($raw_key, '••••') === false) {
379 $existing['openrouter_key'] = $this->encrypt(sanitize_text_field($raw_key));
380 }
381 }
382 if (isset($params['openrouter_model'])) {
383 $existing['openrouter_model'] = sanitize_text_field(trim($params['openrouter_model']));
384 }
385
386 // Handle OpenAI key & model
387 if (isset($params['openai_key'])) {
388 $raw_key = trim($params['openai_key']);
389 if ($raw_key === '') {
390 unset($existing['openai_key']);
391 } elseif (strpos($raw_key, '••••') === false) {
392 $existing['openai_key'] = $this->encrypt(sanitize_text_field($raw_key));
393 }
394 }
395 if (isset($params['openai_model'])) {
396 $existing['openai_model'] = sanitize_text_field(trim($params['openai_model']));
397 }
398
399 // Handle Anthropic key & model
400 if (isset($params['anthropic_key'])) {
401 $raw_key = trim($params['anthropic_key']);
402 if ($raw_key === '') {
403 unset($existing['anthropic_key']);
404 } elseif (strpos($raw_key, '••••') === false) {
405 $existing['anthropic_key'] = $this->encrypt(sanitize_text_field($raw_key));
406 }
407 }
408 if (isset($params['anthropic_model'])) {
409 $existing['anthropic_model'] = sanitize_text_field(trim($params['anthropic_model']));
410 }
411
412 // Handle Custom endpoint, key, and model
413 if (isset($params['custom_endpoint'])) {
414 $existing['custom_endpoint'] = sanitize_text_field(trim($params['custom_endpoint']));
415 }
416 if (isset($params['custom_model'])) {
417 $existing['custom_model'] = sanitize_text_field(trim($params['custom_model']));
418 }
419 if (isset($params['custom_key'])) {
420 $raw_key = trim($params['custom_key']);
421 if ($raw_key === '') {
422 unset($existing['custom_key']);
423 } elseif (strpos($raw_key, '••••') === false) {
424 $existing['custom_key'] = $this->encrypt(sanitize_text_field($raw_key));
425 }
426 }
427
428 // Default provider and model
429 if (!empty($params['default_provider'])) {
430 $existing['default_provider'] = sanitize_text_field($params['default_provider']);
431 }
432 if (!empty($params['default_model'])) {
433 $existing['default_model'] = sanitize_text_field($params['default_model']);
434 }
435
436 // Saving a custom/OpenCode endpoint with no Gemini key should not leave
437 // Gemini as the default just because the picker still had it selected.
438 $has_custom = !empty($existing['custom_endpoint']);
439 $has_gemini = !empty($existing['gemini_key']);
440 if ($has_custom && !$has_gemini && (empty($existing['default_provider']) || $existing['default_provider'] === 'gemini')) {
441 $existing['default_provider'] = 'custom';
442 $existing['default_model'] = !empty($existing['custom_model']) ? $existing['custom_model'] : 'custom-model';
443 }
444
445 update_user_meta($user_id, self::META_KEY, $existing);
446
447 $settings = $this->get_user_ai_settings($user_id);
448
449 return rest_ensure_response(array(
450 'success' => true,
451 'message' => __('AI settings saved successfully.', 'pagelayer'),
452 'default_provider' => $settings['default_provider'],
453 'default_model' => $settings['default_model'],
454 'has_deepseek_key' => !empty($settings['deepseek_key']),
455 'has_gemini_key' => !empty($settings['gemini_key']),
456 'has_openrouter_key' => !empty($settings['openrouter_key']),
457 'has_openai_key' => !empty($settings['openai_key']),
458 'has_anthropic_key' => !empty($settings['anthropic_key']),
459 'has_custom_key' => !empty($settings['custom_key']),
460 'deepseek_model' => $settings['deepseek_model'],
461 'gemini_model' => $settings['gemini_model'],
462 'openrouter_model' => $settings['openrouter_model'],
463 'openai_model' => $settings['openai_model'],
464 'anthropic_model' => $settings['anthropic_model'],
465 'custom_endpoint' => $settings['custom_endpoint'],
466 'custom_model' => $settings['custom_model'],
467 'deepseek_masked' => $this->mask_key($settings['deepseek_key']),
468 'gemini_masked' => $this->mask_key($settings['gemini_key']),
469 'openrouter_masked' => $this->mask_key($settings['openrouter_key']),
470 'openai_masked' => $this->mask_key($settings['openai_key']),
471 'anthropic_masked' => $this->mask_key($settings['anthropic_key']),
472 'custom_masked' => $this->mask_key($settings['custom_key']),
473 ));
474 }
475
476 /**
477 * Build system instructions from the live widget registry.
478 */
479 public function get_system_prompt($widget_tags = array()) {
480 $engine = Pagelayer_AI_Layout_Engine::instance();
481 return $engine->get_build_prompt($widget_tags);
482 }
483
484 public function rest_generate($request) {
485 $params = $request->get_json_params();
486 if (empty($params)) {
487 $params = $request->get_params();
488 }
489
490 $prompt = !empty($params['prompt']) ? trim(sanitize_textarea_field($params['prompt'])) : '';
491 if (empty($prompt)) {
492 return new WP_Error('missing_prompt', __('Please provide a prompt describing the layout you want to build.', 'pagelayer'), array('status' => 400));
493 }
494
495 $user_id = get_current_user_id();
496 $settings = $this->get_user_ai_settings($user_id);
497
498 $provider = !empty($params['provider']) ? sanitize_text_field($params['provider']) : $settings['default_provider'];
499 $model = !empty($params['model']) ? sanitize_text_field($params['model']) : $settings['default_model'];
500
501 // Don't try Gemini when the user never connected it.
502 if ($provider === 'gemini' && empty($settings['gemini_key'])) {
503 $provider = $settings['default_provider'];
504 $model = $settings['default_model'];
505 }
506 $context = !empty($params['context']) ? (is_array($params['context']) ? $params['context'] : array('page_context' => sanitize_text_field($params['context']))) : array();
507 $post_id = !empty($params['post_id']) ? intval($params['post_id']) : (!empty($_REQUEST['postID']) ? intval($_REQUEST['postID']) : 0);
508
509 $api_key = '';
510 if (!empty($params['api_key'])) {
511 $api_key = trim(sanitize_text_field($params['api_key']));
512 } else {
513 if ($provider === 'deepseek') {
514 $api_key = $settings['deepseek_key'];
515 } elseif ($provider === 'gemini') {
516 $api_key = $settings['gemini_key'];
517 } elseif ($provider === 'openrouter') {
518 $api_key = $settings['openrouter_key'];
519 } elseif ($provider === 'openai') {
520 $api_key = $settings['openai_key'];
521 } elseif ($provider === 'anthropic') {
522 $api_key = $settings['anthropic_key'];
523 } elseif ($provider === 'custom') {
524 $api_key = $settings['custom_key'];
525 }
526 }
527
528 $engine = Pagelayer_AI_Layout_Engine::instance();
529
530 // 1. Direct Global Color update requests ("change global color to red", "set this global color #3b82f6")
531 if ($engine->is_global_color_request($prompt)) {
532 $res = $engine->handle_global_color_instruction($prompt);
533 return rest_ensure_response($res);
534 }
535
536 // 2. Direct Background Color / Dark mode requests ("make all background dark", "change background color to ...", "set this color")
537 if ($engine->is_background_color_request($prompt)) {
538 $res = $engine->handle_background_color_instruction($prompt);
539 return rest_ensure_response($res);
540 }
541
542 if (empty($api_key) && $provider !== 'custom') {
543 return new WP_Error(
544 'missing_api_key',
545 sprintf(__('No API key found for %s. Please enter your API key in the AI Settings.', 'pagelayer'), strtoupper($provider)),
546 array('status' => 400, 'provider' => $provider)
547 );
548 }
549
550 $globals_mode = !empty($params['globals_mode']) ? sanitize_text_field($params['globals_mode']) : (!empty($params['use_existing_globals']) ? 'existing' : 'new');
551 $use_existing_globals = ($globals_mode === 'existing');
552 $custom_styling = ($globals_mode === 'custom') || !empty($params['custom_styling']);
553
554 $engine = Pagelayer_AI_Layout_Engine::instance();
555 $call = array(
556 'provider' => $provider,
557 'api_key' => $api_key,
558 'model' => $model,
559 'settings' => $settings,
560 'params' => $params,
561 );
562
563 $plan = array(
564 'widgets' => $engine->widgets_for_request($prompt),
565 'sections' => array(),
566 'palette' => array(),
567 'fonts' => array(),
568 'summary' => '',
569 'mood' => '',
570 'layout' => array(),
571 'use_existing_globals' => $use_existing_globals,
572 'custom_styling' => $custom_styling,
573 );
574
575 if ($use_existing_globals) {
576 $existing_tokens = $engine->get_existing_tokens();
577 $plan['palette'] = $existing_tokens['palette'];
578 $plan['fonts'] = $existing_tokens['fonts'];
579 $context['use_existing_globals'] = true;
580 $context['existing_palette'] = $existing_tokens['palette'];
581 $context['existing_fonts'] = $existing_tokens['fonts'];
582 } elseif ($custom_styling) {
583 $context['custom_styling'] = true;
584 }
585
586 $kind = $engine->request_kind($prompt);
587
588 $requested_sections = $engine->is_scoped_kind($kind) ? array() : $engine->extract_requested_sections($prompt);
589 if (!empty($requested_sections)) {
590 $context['requested_sections'] = $requested_sections;
591 $plan['requested_sections'] = $requested_sections;
592 }
593
594 $full_page = ($kind === 'home' || (!$engine->is_scoped_kind($kind) && count($requested_sections) >= 2));
595
596 // Page-level requests plan identity first so the builder designs a theme.
597 if ($full_page || $engine->should_plan($kind)) {
598 $plan_raw = $this->call_provider(
599 $call,
600 $engine->get_plan_prompt(),
601 $engine->get_plan_user_message($prompt, $context),
602 array('temperature' => 0.7, 'max_tokens' => 2048, 'json' => true)
603 );
604 if (!is_wp_error($plan_raw)) {
605 $parsed = $engine->parse_plan($plan_raw);
606 if (!empty($parsed['widgets'])) {
607 $merged = array_values(array_unique(array_merge($parsed['widgets'], $plan['widgets'])));
608 $plan['widgets'] = $engine->expand_related_widgets($merged);
609 $plan['sections'] = $parsed['sections'];
610 if (!$use_existing_globals && !empty($parsed['palette'])) {
611 $plan['palette'] = $parsed['palette'];
612 }
613 if (!$use_existing_globals && !empty($parsed['fonts'])) {
614 $plan['fonts'] = $parsed['fonts'];
615 }
616 $plan['summary'] = $parsed['summary'];
617 $plan['mood'] = !empty($parsed['mood']) ? $parsed['mood'] : '';
618 $plan['layout'] = !empty($parsed['layout']) ? $parsed['layout'] : array();
619 } else {
620 if (!$use_existing_globals && !empty($parsed['palette'])) {
621 $plan['palette'] = $parsed['palette'];
622 }
623 if (!$use_existing_globals && !empty($parsed['fonts'])) {
624 $plan['fonts'] = $parsed['fonts'];
625 }
626 if (!empty($parsed['mood'])) {
627 $plan['mood'] = $parsed['mood'];
628 }
629 if (!empty($parsed['sections'])) {
630 $plan['sections'] = $parsed['sections'];
631 }
632 if (!empty($parsed['summary'])) {
633 $plan['summary'] = $parsed['summary'];
634 }
635 if (!empty($parsed['layout'])) {
636 $plan['layout'] = $parsed['layout'];
637 }
638 }
639 }
640 }
641
642 $plan['widgets'] = $engine->expand_related_widgets($plan['widgets']);
643 $kind_pack = $engine->widgets_for_kind($kind);
644 if (!empty($kind_pack)) {
645 $plan['widgets'] = $engine->expand_related_widgets($kind_pack);
646 if (!empty($plan['layout']) && is_array($plan['layout'])) {
647 $plan['layout'] = $this->filter_plan_layout_widgets($plan['layout'], $plan['widgets']);
648 $plan['layout'] = $this->filter_plan_layout_for_kind($plan['layout'], $kind);
649 }
650 }
651
652 if (!$use_existing_globals) {
653 $engine->apply_ai_globals(
654 !empty($plan['palette']) ? $plan['palette'] : array(),
655 !empty($plan['fonts']) ? $plan['fonts'] : array(),
656 true
657 );
658 }
659
660 $system_prompt = $engine->get_build_prompt($plan['widgets'], array(
661 'full_page' => $full_page,
662 'kind' => $kind,
663 'prompt' => $prompt,
664 'structure_brief' => $engine->structure_brief($kind, $prompt),
665 ));
666 $user_message = $engine->get_build_user_message($prompt, $context, $plan);
667
668 $result = $this->call_provider(
669 $call,
670 $system_prompt,
671 $user_message,
672 array(
673 'temperature' => $full_page ? 0.7 : ($engine->should_plan($kind) ? 0.55 : 0.45),
674 'max_tokens' => $full_page ? 16384 : 8192,
675 'json' => true,
676 )
677 );
678
679 if (is_wp_error($result)) {
680 return $result;
681 }
682
683 $shortcode = '';
684 $layout_globals = $engine->extract_globals_from_json($result);
685 if (!$use_existing_globals) {
686 $engine->apply_ai_globals(
687 !empty($layout_globals['palette']) ? $layout_globals['palette'] : (!empty($plan['palette']) ? $plan['palette'] : array()),
688 !empty($layout_globals['fonts']) ? $layout_globals['fonts'] : (!empty($plan['fonts']) ? $plan['fonts'] : array()),
689 true
690 );
691 }
692
693 $nodes = $engine->parse_layout($result);
694
695 // If user requested specific sections, verify that all sections were created
696 if (!empty($requested_sections) && is_array($nodes)) {
697 // If the last row is an incomplete stub (e.g. heading only for reviews, but no cards)
698 if (count($nodes) > 1) {
699 $last_row = end($nodes);
700 $last_stats = $engine->layout_stats(array($last_row));
701 if ($last_stats['leaves'] <= 1) {
702 $last_summary = $engine->get_row_summary_text($last_row);
703 if (preg_match('/(reviews?|testimonials?|why\s*choose|hours?|location|features?|pricing|faq)/i', $last_summary['text'])) {
704 array_pop($nodes);
705 }
706 }
707 }
708
709 $missing = $engine->find_missing_sections($nodes, $requested_sections);
710 if (!empty($missing)) {
711 $missing_list = array();
712 foreach ($missing as $m_i => $m_sec) {
713 $num = $m_i + 1;
714 $desc = !empty($m_sec['desc']) ? '' . $m_sec['desc'] : '';
715 $missing_list[] = "{$num}. [pl_row] \"{$m_sec['name']}\"{$desc}";
716 }
717
718 $continuation_prompt = "The initial layout generated earlier sections successfully, but the following user-requested sections are still MISSING:\n\n"
719 . implode("\n", $missing_list) . "\n\n"
720 . "Generate ONLY the missing sections above as an array of top-level [pl_row] nodes in JSON: {\"nodes\":[...]}.\n"
721 . "- Every missing section MUST be its own complete [pl_row] with rich, realistic dummy content and full styling.\n"
722 . "- Match the same brand palette and typography.\n"
723 . "- For Customer Reviews: use pl_testimonial or pl_review cards with names, quotes, and star ratings.\n"
724 . "- For Location & Hours: include address, opening hours, and location/map CTA.\n"
725 . "- For Final CTA: include energetic heading, description, and Order Now button.\n"
726 . "Return JSON {\"nodes\":[...]} only. Do not repeat sections already created.";
727
728 $add_result = $this->call_provider(
729 $call,
730 $system_prompt,
731 $continuation_prompt,
732 array(
733 'temperature' => 0.5,
734 'max_tokens' => 8192,
735 'json' => true,
736 )
737 );
738
739 if (!is_wp_error($add_result)) {
740 $additional_nodes = $engine->parse_layout($add_result);
741 if (!empty($additional_nodes)) {
742 $nodes = array_merge((array) $nodes, (array) $additional_nodes);
743 }
744 }
745 }
746 }
747
748 // If a page came back as a fragment or unstyled sketch, ask the model
749 // once more — never inject a canned theme.
750 if (($engine->should_plan($kind) || !empty($requested_sections)) && $engine->is_thin_layout($nodes, $kind, $requested_sections)) {
751 $retry_msg = $user_message . "\nYour previous JSON was too thin or unstyled for this request. ";
752 if (!empty($requested_sections)) {
753 $retry_msg .= "You MUST generate ALL " . count($requested_sections) . " requested sections. ";
754 }
755 $retry_msg .= "Design a complete, visually finished Pagelayer theme for this brand. ";
756 $retry_msg .= "Invent the structure from the product. Set real widget settings from the schemas (colors, typography, backgrounds with ele_bg_type, padding, radius, shadow, icons, images). JSON only.";
757 $retry = $this->call_provider(
758 $call,
759 $system_prompt,
760 $retry_msg,
761 array(
762 'temperature' => 0.75,
763 'max_tokens' => $full_page ? 16384 : 8192,
764 'json' => true,
765 )
766 );
767 if (!is_wp_error($retry)) {
768 $retry_globals = $engine->extract_globals_from_json($retry);
769 if (!$use_existing_globals) {
770 $engine->apply_ai_globals(
771 !empty($retry_globals['palette']) ? $retry_globals['palette'] : array(),
772 !empty($retry_globals['fonts']) ? $retry_globals['fonts'] : array(),
773 true
774 );
775 if (!empty($retry_globals['palette']) || !empty($retry_globals['fonts'])) {
776 $layout_globals = $retry_globals;
777 }
778 }
779 $retry_nodes = $engine->parse_layout($retry);
780 if (!$engine->is_thin_layout($retry_nodes, $kind, $requested_sections)) {
781 $nodes = $retry_nodes;
782 $result = $retry;
783 } elseif (!empty($retry_nodes)) {
784 $nodes = $retry_nodes;
785 }
786 }
787 }
788
789 if (!empty($nodes)) {
790 $nodes = $engine->finalize_nodes($nodes, $prompt);
791 $shortcode = $engine->nodes_to_shortcode($nodes);
792 }
793
794 if (empty($shortcode)) {
795 $shortcode = $this->sanitize_ai_shortcode($result);
796 }
797
798 $shortcode = $this->ensure_dark_band_contrast($shortcode);
799 if (!in_array($kind, array('header', 'footer', '404'), true)) {
800 $shortcode = $this->ensure_row_section_padding($shortcode);
801 }
802 $shortcode = $this->ensure_iconbox_center($shortcode);
803 $shortcode = $this->ensure_slider_image_size($shortcode);
804 $shortcode = $this->ensure_social_contrast($shortcode);
805 $shortcode = $this->ensure_accordion_contrast($shortcode);
806 $shortcode = $this->ensure_counter_start($shortcode);
807
808 if (empty($shortcode)) {
809 return new WP_Error('empty_generation', __('AI generated an empty response. Please try with a more detailed prompt.', 'pagelayer'), array('status' => 500));
810 }
811
812 $theme_template = null;
813 if ($engine->is_theme_template_kind($kind) && !empty($nodes)) {
814 $theme_template = $this->save_theme_template($kind, $prompt, $nodes, $post_id);
815 }
816
817 $editing_matching_template = false;
818 if ($post_id && get_post_type($post_id) === 'pagelayer-template') {
819 $current_type = (string) get_post_meta($post_id, 'pagelayer_template_type', true);
820 $spec = $engine->theme_template_spec($kind, $prompt);
821 $editing_matching_template = ($spec && $current_type === $spec['type']);
822 }
823
824 // Header/footer are Theme Builder chrome — do not dump them as extra
825 // sections onto a regular page. 404 and inner templates still preview
826 // on the current canvas so the user can see the design.
827 $insert_on_canvas = true;
828 if (in_array($kind, array('header', 'footer'), true) && !$editing_matching_template) {
829 $insert_on_canvas = false;
830 }
831
832 if ($post_id && $insert_on_canvas && ($full_page || $kind === 'home')) {
833 $this->assign_canvas_template($post_id);
834 }
835
836 if ($post_id && $kind && $insert_on_canvas) {
837 update_post_meta($post_id, 'pagelayer_ai_layout_kind', $kind);
838 }
839 if (is_array($theme_template) && !empty($theme_template['template_id']) && $kind) {
840 update_post_meta((int) $theme_template['template_id'], 'pagelayer_ai_layout_kind', $kind);
841 }
842
843 $render_id = ($theme_template && !empty($theme_template['template_id'])) ? (int) $theme_template['template_id'] : $post_id;
844 $rendered_html = $this->render_shortcodes_for_canvas($shortcode, $render_id ? $render_id : $post_id);
845 $tree = $this->parse_shortcodes_to_tree($shortcode);
846 $globals = $engine->get_globals_snapshot();
847
848 $message = __('Layout generated successfully.', 'pagelayer');
849 if (is_array($theme_template) && !empty($theme_template['template_id'])) {
850 $labels = array(
851 'header' => __('Header theme template created.', 'pagelayer'),
852 'footer' => __('Footer theme template created.', 'pagelayer'),
853 '404' => __('404 theme template created.', 'pagelayer'),
854 'blog' => __('Blog archive theme template created.', 'pagelayer'),
855 'single' => __('Single post theme template created.', 'pagelayer'),
856 'search' => __('Search results theme template created.', 'pagelayer'),
857 );
858 $message = isset($labels[$kind]) ? $labels[$kind] : $message;
859 }
860
861 return rest_ensure_response(array(
862 'success' => true,
863 'shortcode' => $shortcode,
864 'html' => $rendered_html,
865 'tree' => $tree,
866 'provider' => $provider,
867 'model' => $model,
868 'widgets' => $plan['widgets'],
869 'plan' => $plan,
870 'globals' => $globals,
871 'globals_updated' => ($globals_mode === 'new'),
872 'globals_mode' => $globals_mode,
873 'page_template' => 'pagelayer-canvas',
874 'kind' => $kind,
875 'theme_template' => $theme_template,
876 'insert_on_canvas' => $insert_on_canvas,
877 'message' => $message,
878 ));
879 }
880
881 /**
882 * Persist a generated layout as a Pagelayer Theme Builder template.
883 *
884 * @return array|null
885 */
886 private function save_theme_template($kind, $prompt, $nodes, $post_id = 0) {
887 $engine = Pagelayer_AI_Layout_Engine::instance();
888 $spec = $engine->theme_template_spec($kind, $prompt);
889 if (!$spec || empty($nodes) || !is_array($nodes)) {
890 return null;
891 }
892
893 $existing_id = $this->find_theme_template_id($spec, $post_id);
894
895 if (class_exists('Pagelayer_Abilities_Register')) {
896 $input = array(
897 'title' => $spec['title'],
898 'type' => $spec['type'],
899 'pagelayer_data' => $nodes,
900 'conditions' => $spec['conditions'],
901 'skip_validation' => true,
902 );
903 if ($kind === 'header') {
904 $input['single_page_site'] = $this->header_nodes_need_menu_opt_out($nodes);
905 }
906
907 if ($existing_id) {
908 $input['template_id'] = $existing_id;
909 $res = Pagelayer_Abilities_Register::execute_update_template($input);
910 } else {
911 $res = Pagelayer_Abilities_Register::execute_create_template($input);
912 }
913
914 if (!is_wp_error($res) && !empty($res['template_id'])) {
915 $tid = (int) $res['template_id'];
916 return array(
917 'template_id' => $tid,
918 'type' => $spec['type'],
919 'kind' => $kind,
920 'title' => $spec['title'],
921 'edit_url' => $this->theme_template_edit_url($tid),
922 'updated' => (bool) $existing_id,
923 );
924 }
925 }
926
927 $tid = $this->save_theme_template_direct($spec, $nodes, $existing_id);
928 if (!$tid) {
929 return null;
930 }
931
932 return array(
933 'template_id' => $tid,
934 'type' => $spec['type'],
935 'kind' => $kind,
936 'title' => $spec['title'],
937 'edit_url' => $this->theme_template_edit_url($tid),
938 'updated' => (bool) $existing_id,
939 );
940 }
941
942 private function header_nodes_need_menu_opt_out($nodes) {
943 $has_bound_menu = false;
944 $walk = function ($list) use (&$walk, &$has_bound_menu) {
945 foreach ((array) $list as $node) {
946 if (!is_array($node) || empty($node['tag'])) {
947 continue;
948 }
949 $tag = str_replace('pagelayer_', 'pl_', (string) $node['tag']);
950 if ($tag === 'pl_wp_menu') {
951 $nav = isset($node['attrs']['nav_list']) ? trim((string) $node['attrs']['nav_list']) : '';
952 if ($nav !== '' && $nav !== '0') {
953 $has_bound_menu = true;
954 return;
955 }
956 }
957 if (isset($node['content']) && is_array($node['content'])) {
958 $walk($node['content']);
959 }
960 }
961 };
962 $walk($nodes);
963 return !$has_bound_menu;
964 }
965
966 private function find_theme_template_id($spec, $post_id = 0) {
967 if ($post_id && get_post_type($post_id) === 'pagelayer-template') {
968 $current = (string) get_post_meta($post_id, 'pagelayer_template_type', true);
969 if ($current === $spec['type']) {
970 return (int) $post_id;
971 }
972 }
973
974 $posts = get_posts(array(
975 'post_type' => 'pagelayer-template',
976 'post_status' => array('publish', 'draft'),
977 'posts_per_page' => 20,
978 'meta_key' => 'pagelayer_template_type',
979 'meta_value' => $spec['type'],
980 'fields' => 'ids',
981 ));
982 if (empty($posts)) {
983 return 0;
984 }
985
986 $want_sub = '';
987 if (!empty($spec['conditions'][0]['sub_template'])) {
988 $want_sub = (string) $spec['conditions'][0]['sub_template'];
989 }
990
991 if ($want_sub === '') {
992 return (int) $posts[0];
993 }
994
995 foreach ($posts as $id) {
996 $conds = get_post_meta($id, 'pagelayer_template_conditions', true);
997 if (!is_array($conds)) {
998 continue;
999 }
1000 foreach ($conds as $c) {
1001 if (!empty($c['sub_template']) && (string) $c['sub_template'] === $want_sub) {
1002 return (int) $id;
1003 }
1004 }
1005 }
1006
1007 return 0;
1008 }
1009
1010 private function save_theme_template_direct($spec, $nodes, $existing_id = 0) {
1011 $template_id = (int) $existing_id;
1012 if ($template_id <= 0) {
1013 $template_id = wp_insert_post(array(
1014 'post_title' => $spec['title'],
1015 'post_type' => 'pagelayer-template',
1016 'post_status' => 'publish',
1017 ), true);
1018 if (is_wp_error($template_id) || !$template_id) {
1019 return 0;
1020 }
1021 } else {
1022 wp_update_post(array(
1023 'ID' => $template_id,
1024 'post_title' => $spec['title'],
1025 ));
1026 }
1027
1028 $normalized = $nodes;
1029 if (class_exists('Pagelayer_Abilities_Register') && method_exists('Pagelayer_Abilities_Register', 'normalize_layout_data')) {
1030 $normalized = Pagelayer_Abilities_Register::normalize_layout_data($nodes);
1031 }
1032
1033 update_post_meta($template_id, 'pagelayer-data', $normalized);
1034 update_post_meta($template_id, 'pagelayer_template_type', $spec['type']);
1035 update_post_meta($template_id, 'pagelayer_template_conditions', $spec['conditions']);
1036
1037 if (class_exists('Pagelayer_Abilities_Register') && method_exists('Pagelayer_Abilities_Register', 'serialize_layout_to_blocks')) {
1038 $blocks = Pagelayer_Abilities_Register::serialize_layout_to_blocks($normalized);
1039 wp_update_post(array('ID' => $template_id, 'post_content' => $blocks));
1040 }
1041
1042 return (int) $template_id;
1043 }
1044
1045 private function theme_template_edit_url($template_id) {
1046 $template_id = (int) $template_id;
1047 if ($template_id <= 0) {
1048 return '';
1049 }
1050 if (function_exists('pagelayer_livelink')) {
1051 return pagelayer_livelink($template_id);
1052 }
1053 return admin_url('post.php?post=' . $template_id . '&action=edit');
1054 }
1055
1056 private function filter_plan_layout_for_kind($layout, $kind) {
1057 if (!is_array($layout) || $layout === array()) {
1058 return $layout;
1059 }
1060 $drop = '/(feature|service|testimonial|review|pricing|hero|cta|faq|counter|stats|team|gallery)/i';
1061 if ($kind === 'footer') {
1062 $keep = array();
1063 foreach ($layout as $block) {
1064 $name = is_array($block) && !empty($block['name']) ? (string) $block['name'] : '';
1065 if ($name !== '' && preg_match($drop, $name) && !preg_match('/(footer|copyright|link|contact|social|brand|about)/i', $name)) {
1066 continue;
1067 }
1068 $keep[] = $block;
1069 }
1070 return $keep;
1071 }
1072 if ($kind === 'header') {
1073 return array_slice($layout, 0, 1);
1074 }
1075 if ($kind === '404') {
1076 $keep = array();
1077 foreach ($layout as $block) {
1078 $name = is_array($block) && !empty($block['name']) ? (string) $block['name'] : '';
1079 if ($name !== '' && preg_match($drop, $name) && !preg_match('/(404|error|not\s*found|search)/i', $name)) {
1080 continue;
1081 }
1082 $keep[] = $block;
1083 }
1084 return $keep;
1085 }
1086 return $layout;
1087 }
1088
1089 private function filter_plan_layout_widgets($layout, $allowed) {
1090 if (!is_array($layout) || empty($allowed) || !is_array($allowed)) {
1091 return $layout;
1092 }
1093 $allow = array_flip($allowed);
1094 foreach ($layout as &$block) {
1095 if (!is_array($block) || empty($block['widgets']) || !is_array($block['widgets'])) {
1096 continue;
1097 }
1098 $keep = array();
1099 foreach ($block['widgets'] as $tag) {
1100 $tag = is_string($tag) ? $tag : '';
1101 if ($tag !== '' && isset($allow[$tag])) {
1102 $keep[] = $tag;
1103 }
1104 }
1105 $block['widgets'] = $keep;
1106 }
1107 unset($block);
1108 return $layout;
1109 }
1110
1111 private function call_provider($call, $system_prompt, $user_message, $opts = array()) {
1112 $provider = $call['provider'];
1113 $api_key = $call['api_key'];
1114 $model = $call['model'];
1115 $settings = $call['settings'];
1116 $params = $call['params'];
1117
1118 if ($provider === 'deepseek') {
1119 return $this->call_deepseek($api_key, $model, $system_prompt, $user_message, $opts);
1120 }
1121 if ($provider === 'gemini') {
1122 return $this->call_gemini($api_key, $model, $system_prompt, $user_message, $opts);
1123 }
1124 if ($provider === 'openrouter') {
1125 return $this->call_openrouter($api_key, $model, $system_prompt, $user_message, $opts);
1126 }
1127 if ($provider === 'openai') {
1128 return $this->call_openai($api_key, $model, $system_prompt, $user_message, $opts);
1129 }
1130 if ($provider === 'anthropic') {
1131 return $this->call_anthropic($api_key, $model, $system_prompt, $user_message, $opts);
1132 }
1133 if ($provider === 'custom') {
1134 $endpoint = !empty($params['custom_endpoint']) ? $params['custom_endpoint'] : $settings['custom_endpoint'];
1135 $custom_m = !empty($params['custom_model']) ? $params['custom_model'] : (!empty($settings['custom_model']) ? $settings['custom_model'] : $model);
1136 return $this->call_custom_endpoint($endpoint, $api_key, $custom_m, $system_prompt, $user_message, $opts);
1137 }
1138
1139 return new WP_Error('invalid_provider', __('Unsupported AI provider selected.', 'pagelayer'), array('status' => 400));
1140 }
1141
1142 /**
1143 * Parse shortcode structure into component tree for fast live step-by-step building
1144 */
1145 public function parse_shortcodes_to_tree($shortcode) {
1146 $tree = array();
1147 $shortcode = $this->sanitize_ai_shortcode($shortcode);
1148 $catalog = Pagelayer_AI_Layout_Engine::instance()->get_widget_catalog();
1149
1150 // Match rows
1151 if (preg_match_all('/\[pl_row([^\]]*)\](.*?)\[\/pl_row\]/is', $shortcode, $row_matches, PREG_SET_ORDER)) {
1152 foreach ($row_matches as $r_idx => $r_match) {
1153 $row_atts_raw = trim($r_match[1]);
1154 $row_atts = shortcode_parse_atts($row_atts_raw);
1155 if (!is_array($row_atts)) $row_atts = array();
1156
1157 $row_content = $r_match[2];
1158 $row_node = array(
1159 'id' => 'row_' . $r_idx,
1160 'type' => 'pl_row',
1161 'tag' => 'pl_row',
1162 'name' => 'Section Row',
1163 'atts' => $row_atts,
1164 'cols' => array(),
1165 );
1166
1167 // Match columns inside row
1168 if (preg_match_all('/\[pl_col([^\]]*)\](.*?)\[\/pl_col\]/is', $row_content, $col_matches, PREG_SET_ORDER)) {
1169 foreach ($col_matches as $c_idx => $c_match) {
1170 $col_atts_raw = trim($c_match[1]);
1171 $col_atts = shortcode_parse_atts($col_atts_raw);
1172 if (!is_array($col_atts)) $col_atts = array();
1173
1174 $col_content = $c_match[2];
1175 $col_width_label = !empty($col_atts['col']) ? $col_atts['col'] . '/12' : 'Full';
1176 $col_node = array(
1177 'id' => 'col_' . $r_idx . '_' . $c_idx,
1178 'type' => 'pl_col',
1179 'tag' => 'pl_col',
1180 'name' => 'Column (' . $col_width_label . ')',
1181 'atts' => $col_atts,
1182 'widgets' => array(),
1183 );
1184
1185 // Match widgets inside column
1186 $widget_pattern = '/\[(pl_[a-zA-Z0-9_-]+)([^\]]*)(?:\](.*?)\[\/\1\]|\s*\/\]|\])/is';
1187 if (preg_match_all($widget_pattern, $col_content, $widget_matches, PREG_SET_ORDER)) {
1188 foreach ($widget_matches as $w_idx => $w_match) {
1189 $w_tag = $w_match[1];
1190 $w_atts_raw = trim($w_match[2]);
1191 $w_atts = shortcode_parse_atts($w_atts_raw);
1192 if (!is_array($w_atts)) $w_atts = array();
1193 $w_inner = isset($w_match[3]) ? trim($w_match[3]) : '';
1194
1195 // Individual widget shortcode and HTML
1196 $single_sc = '[' . $w_tag . ($w_atts_raw ? ' ' . $w_atts_raw : '') . ']' . ($w_inner ? $w_inner . '[/' . $w_tag . ']' : '');
1197 $single_html = $this->render_shortcodes_for_canvas($single_sc);
1198
1199 $w_name = ucfirst(str_replace('_', ' ', str_replace('pl_', '', $w_tag)));
1200 if (isset($catalog[$w_tag])) {
1201 $w_name = explode('|', $catalog[$w_tag])[0];
1202 }
1203
1204 $col_node['widgets'][] = array(
1205 'id' => 'w_' . $r_idx . '_' . $c_idx . '_' . $w_idx,
1206 'type' => $w_tag,
1207 'tag' => $w_tag,
1208 'name' => $w_name,
1209 'atts' => $w_atts,
1210 'inner' => $w_inner,
1211 'shortcode' => $single_sc,
1212 'html' => $single_html,
1213 );
1214 }
1215 }
1216
1217 $row_node['cols'][] = $col_node;
1218 }
1219 }
1220
1221 $tree[] = $row_node;
1222 }
1223 }
1224
1225 return $tree;
1226 }
1227
1228 /**
1229 * Call Google Gemini API
1230 */
1231 private function call_gemini($api_key, $model, $system_prompt, $user_prompt, $opts = array()) {
1232 if (empty($model) || $model === 'gemini-2.5-flash' || $model === 'gemini-2.5-pro' || strpos($model, 'gemini-2.5') !== false) {
1233 $model = 'gemini-3.7-flash';
1234 }
1235 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . urlencode($model) . ':generateContent?key=' . urlencode($api_key);
1236
1237 $payload = array(
1238 'systemInstruction' => array(
1239 'parts' => array(
1240 array('text' => $system_prompt)
1241 )
1242 ),
1243 'contents' => array(
1244 array(
1245 'role' => 'user',
1246 'parts' => array(
1247 array('text' => $user_prompt)
1248 )
1249 )
1250 ),
1251 'generationConfig' => array(
1252 'temperature' => isset($opts['temperature']) ? floatval($opts['temperature']) : 0.4,
1253 'maxOutputTokens' => !empty($opts['max_tokens']) ? intval($opts['max_tokens']) : 8192,
1254 )
1255 );
1256 if (!empty($opts['json'])) {
1257 $payload['generationConfig']['responseMimeType'] = 'application/json';
1258 }
1259
1260 $response = wp_remote_post($url, array(
1261 'timeout' => 120,
1262 'headers' => array(
1263 'Content-Type' => 'application/json',
1264 ),
1265 'body' => wp_json_encode($payload),
1266 ));
1267
1268 if (is_wp_error($response)) {
1269 return new WP_Error('gemini_request_failed', $response->get_error_message(), array('status' => 500));
1270 }
1271
1272 $code = wp_remote_retrieve_response_code($response);
1273 $body = wp_remote_retrieve_body($response);
1274 $data = json_decode($body, true);
1275
1276 if ($code !== 200) {
1277 $err_msg = !empty($data['error']['message']) ? $data['error']['message'] : sprintf(__('Gemini API returned error code %d', 'pagelayer'), $code);
1278 return new WP_Error('gemini_api_error', $err_msg, array('status' => $code));
1279 }
1280
1281 if (!empty($data['candidates'][0]['content']['parts']) && is_array($data['candidates'][0]['content']['parts'])) {
1282 $text = '';
1283 foreach ($data['candidates'][0]['content']['parts'] as $part) {
1284 if (!empty($part['text'])) {
1285 $text .= $part['text'];
1286 }
1287 }
1288 if ($text !== '') {
1289 return $text;
1290 }
1291 }
1292
1293 return new WP_Error('gemini_empty_response', __('Gemini returned an empty candidate list.', 'pagelayer'), array('status' => 500));
1294 }
1295
1296 /**
1297 * Call OpenAI API
1298 */
1299 private function call_openai($api_key, $model, $system_prompt, $user_prompt, $opts = array()) {
1300 if (empty($model)) $model = 'gpt-4o-mini';
1301 $url = 'https://api.openai.com/v1/chat/completions';
1302
1303 $payload = array(
1304 'model' => $model,
1305 'messages' => array(
1306 array('role' => 'system', 'content' => $system_prompt),
1307 array('role' => 'user', 'content' => $user_prompt),
1308 ),
1309 'temperature' => isset($opts['temperature']) ? floatval($opts['temperature']) : 0.4,
1310 'max_tokens' => !empty($opts['max_tokens']) ? intval($opts['max_tokens']) : 16384,
1311 );
1312 if (preg_match('/^(o1|o3|gpt-4o|chatgpt)/i', $model)) {
1313 $payload['max_completion_tokens'] = !empty($opts['max_tokens']) ? intval($opts['max_tokens']) : 16384;
1314 if (strpos($model, 'o1') === 0 || strpos($model, 'o3') === 0) {
1315 unset($payload['max_tokens']);
1316 unset($payload['temperature']);
1317 }
1318 }
1319 if (!empty($opts['json'])) {
1320 $payload['response_format'] = array('type' => 'json_object');
1321 }
1322
1323 $response = wp_remote_post($url, array(
1324 'timeout' => 120,
1325 'headers' => array(
1326 'Authorization' => 'Bearer ' . $api_key,
1327 'Content-Type' => 'application/json',
1328 ),
1329 'body' => wp_json_encode($payload),
1330 ));
1331
1332 if (is_wp_error($response)) {
1333 return new WP_Error('openai_request_failed', $response->get_error_message(), array('status' => 500));
1334 }
1335
1336 $code = wp_remote_retrieve_response_code($response);
1337 $body = wp_remote_retrieve_body($response);
1338 $data = json_decode($body, true);
1339
1340 if ($code !== 200) {
1341 $err_msg = !empty($data['error']['message']) ? $data['error']['message'] : sprintf(__('OpenAI API returned error code %d', 'pagelayer'), $code);
1342 return new WP_Error('openai_api_error', $err_msg, array('status' => $code));
1343 }
1344
1345 if (!empty($data['choices'][0]['message']['content'])) {
1346 return $data['choices'][0]['message']['content'];
1347 }
1348
1349 return new WP_Error('openai_empty_response', __('OpenAI returned an empty response.', 'pagelayer'), array('status' => 500));
1350 }
1351
1352 /**
1353 * Call Anthropic Claude API
1354 */
1355 private function call_anthropic($api_key, $model, $system_prompt, $user_prompt, $opts = array()) {
1356 if (empty($model)) $model = 'claude-3-7-sonnet-20250219';
1357 $url = 'https://api.anthropic.com/v1/messages';
1358
1359 $payload = array(
1360 'model' => $model,
1361 'system' => $system_prompt,
1362 'messages' => array(
1363 array('role' => 'user', 'content' => $user_prompt)
1364 ),
1365 'max_tokens' => !empty($opts['max_tokens']) ? intval($opts['max_tokens']) : 8192,
1366 'temperature'=> isset($opts['temperature']) ? floatval($opts['temperature']) : 0.4,
1367 );
1368
1369 $response = wp_remote_post($url, array(
1370 'timeout' => 120,
1371 'connect_timeout' => 30,
1372 'headers' => array(
1373 'x-api-key' => $api_key,
1374 'anthropic-version' => '2023-06-01',
1375 'Content-Type' => 'application/json',
1376 ),
1377 'body' => wp_json_encode($payload),
1378 ));
1379
1380 if (is_wp_error($response)) {
1381 return new WP_Error('anthropic_request_failed', $response->get_error_message(), array('status' => 500));
1382 }
1383
1384 $code = wp_remote_retrieve_response_code($response);
1385 $body = wp_remote_retrieve_body($response);
1386 $data = json_decode($body, true);
1387
1388 if ($code !== 200) {
1389 $err_msg = !empty($data['error']['message']) ? $data['error']['message'] : sprintf(__('Anthropic API returned error code %d', 'pagelayer'), $code);
1390 return new WP_Error('anthropic_api_error', $err_msg, array('status' => $code));
1391 }
1392
1393 if (!empty($data['content'][0]['text'])) {
1394 return $data['content'][0]['text'];
1395 }
1396
1397 return new WP_Error('anthropic_empty_response', __('Anthropic returned an empty response.', 'pagelayer'), array('status' => 500));
1398 }
1399
1400 /**
1401 * Call DeepSeek Official API (deepseek-chat / deepseek-reasoner)
1402 */
1403 private function call_deepseek($api_key, $model, $system_prompt, $user_prompt, $opts = array()) {
1404 $model = trim($model);
1405 if (strpos($model, '/') !== false) {
1406 $model = basename($model);
1407 }
1408 if (empty($model) || ($model !== 'deepseek-reasoner' && $model !== 'deepseek-chat')) {
1409 if (stripos($model, 'reason') !== false || stripos($model, 'r1') !== false) {
1410 $model = 'deepseek-reasoner';
1411 } else {
1412 $model = 'deepseek-chat';
1413 }
1414 }
1415
1416 $url = 'https://api.deepseek.com/chat/completions';
1417 $api_key = trim($api_key);
1418
1419 $payload = array(
1420 'model' => $model,
1421 'messages' => array(
1422 array('role' => 'system', 'content' => $system_prompt),
1423 array('role' => 'user', 'content' => $user_prompt),
1424 ),
1425 'temperature' => isset($opts['temperature']) ? floatval($opts['temperature']) : 0.4,
1426 'max_tokens' => !empty($opts['max_tokens']) ? intval($opts['max_tokens']) : 8192,
1427 );
1428
1429 $response = wp_remote_post($url, array(
1430 'timeout' => 120,
1431 'sslverify' => false,
1432 'headers' => array(
1433 'Authorization' => 'Bearer ' . $api_key,
1434 'Content-Type' => 'application/json',
1435 'Accept' => 'application/json',
1436 ),
1437 'body' => wp_json_encode($payload),
1438 ));
1439
1440 if (is_wp_error($response)) {
1441 return new WP_Error('deepseek_request_failed', $response->get_error_message(), array('status' => 500));
1442 }
1443
1444 $code = wp_remote_retrieve_response_code($response);
1445 $body = wp_remote_retrieve_body($response);
1446 $data = json_decode($body, true);
1447
1448 if ($code !== 200) {
1449 $err_msg = !empty($data['error']['message']) ? $data['error']['message'] : sprintf(__('DeepSeek API returned error code %d', 'pagelayer'), $code);
1450 if ($code === 402 || stripos($err_msg, 'balance') !== false) {
1451 $err_msg = __('DeepSeek API: Insufficient account balance. Please check/top-up your balance at platform.deepseek.com', 'pagelayer');
1452 } elseif ($code === 401 || stripos($err_msg, 'Authentication') !== false || stripos($err_msg, 'invalid key') !== false) {
1453 $err_msg = __('DeepSeek API: Authentication failed. Please verify your DeepSeek API key in AI Settings.', 'pagelayer');
1454 }
1455 return new WP_Error('deepseek_api_error', $err_msg, array('status' => $code));
1456 }
1457
1458 if (!empty($data['choices'][0]['message']['content'])) {
1459 return $data['choices'][0]['message']['content'];
1460 }
1461
1462 return new WP_Error('deepseek_empty_response', __('DeepSeek returned an empty response.', 'pagelayer'), array('status' => 500));
1463 }
1464
1465 /**
1466 * Call OpenRouter API (Access to ALL models: DeepSeek, Llama, Qwen, Mistral, etc.)
1467 */
1468 private function call_openrouter($api_key, $model, $system_prompt, $user_prompt, $opts = array()) {
1469 if (empty($model)) $model = 'deepseek/deepseek-chat';
1470 $url = 'https://openrouter.ai/api/v1/chat/completions';
1471 $api_key = trim($api_key);
1472
1473 $payload = array(
1474 'model' => $model,
1475 'messages' => array(
1476 array('role' => 'system', 'content' => $system_prompt),
1477 array('role' => 'user', 'content' => $user_prompt),
1478 ),
1479 'temperature' => isset($opts['temperature']) ? floatval($opts['temperature']) : 0.4,
1480 'max_tokens' => !empty($opts['max_tokens']) ? intval($opts['max_tokens']) : 8192,
1481 );
1482
1483 $response = wp_remote_post($url, array(
1484 'timeout' => 120,
1485 'connect_timeout' => 30,
1486 'sslverify' => false,
1487 'headers' => array(
1488 'Authorization' => 'Bearer ' . $api_key,
1489 'Content-Type' => 'application/json',
1490 'Accept' => 'application/json',
1491 'HTTP-Referer' => site_url(),
1492 'X-Title' => 'Pagelayer AI',
1493 ),
1494 'body' => wp_json_encode($payload),
1495 ));
1496
1497 if (is_wp_error($response)) {
1498 return new WP_Error('openrouter_request_failed', $response->get_error_message(), array('status' => 500));
1499 }
1500
1501 $code = wp_remote_retrieve_response_code($response);
1502 $body = wp_remote_retrieve_body($response);
1503 $data = json_decode($body, true);
1504
1505 if ($code !== 200) {
1506 $err_msg = !empty($data['error']['message']) ? $data['error']['message'] : sprintf(__('OpenRouter API returned error code %d', 'pagelayer'), $code);
1507 return new WP_Error('openrouter_api_error', $err_msg, array('status' => $code));
1508 }
1509
1510 if (!empty($data['choices'][0]['message']['content'])) {
1511 return $data['choices'][0]['message']['content'];
1512 }
1513
1514 return new WP_Error('openrouter_empty_response', __('OpenRouter returned an empty response.', 'pagelayer'), array('status' => 500));
1515 }
1516
1517 /**
1518 * Call Custom / OpenAI-Compatible Endpoint (OpenCode, Ollama, LM Studio, Groq, DeepSeek, Together, etc.)
1519 */
1520 private function call_custom_endpoint($endpoint, $api_key, $model, $system_prompt, $user_prompt, $opts = array()) {
1521 $endpoint = trim($endpoint);
1522 if (empty($endpoint)) {
1523 $endpoint = 'https://openrouter.ai/api/v1/chat/completions';
1524 }
1525 if (strpos($endpoint, '/chat/completions') === false) {
1526 $endpoint = rtrim($endpoint, '/') . '/chat/completions';
1527 }
1528
1529 if (empty($model)) $model = 'custom-model';
1530
1531 $payload = array(
1532 'model' => $model,
1533 'messages' => array(
1534 array('role' => 'system', 'content' => $system_prompt),
1535 array('role' => 'user', 'content' => $user_prompt),
1536 ),
1537 'temperature' => isset($opts['temperature']) ? floatval($opts['temperature']) : 0.4,
1538 'max_tokens' => !empty($opts['max_tokens']) ? intval($opts['max_tokens']) : 8192,
1539 );
1540
1541 $headers = array(
1542 'Content-Type' => 'application/json',
1543 );
1544 if (!empty($api_key)) {
1545 $headers['Authorization'] = 'Bearer ' . trim($api_key);
1546 }
1547
1548 $response = wp_remote_post($endpoint, array(
1549 'timeout' => 120,
1550 'sslverify' => false,
1551 'headers' => $headers,
1552 'body' => wp_json_encode($payload),
1553 ));
1554
1555 if (is_wp_error($response)) {
1556 return new WP_Error('custom_endpoint_failed', $response->get_error_message(), array('status' => 500));
1557 }
1558
1559 $code = wp_remote_retrieve_response_code($response);
1560 $body = wp_remote_retrieve_body($response);
1561 $data = json_decode($body, true);
1562
1563 if ($code !== 200) {
1564 $err_msg = !empty($data['error']['message']) ? $data['error']['message'] : sprintf(__('Custom API returned error code %d', 'pagelayer'), $code);
1565 return new WP_Error('custom_api_error', $err_msg, array('status' => $code));
1566 }
1567
1568 if (!empty($data['choices'][0]['message']['content'])) {
1569 return $data['choices'][0]['message']['content'];
1570 }
1571
1572 return new WP_Error('custom_empty_response', __('Custom API returned an empty response.', 'pagelayer'), array('status' => 500));
1573 }
1574
1575 /**
1576 * Clean, sanitize and normalize shortcodes from LLM response
1577 */
1578 public function sanitize_ai_shortcode($raw_text) {
1579 $text = trim($raw_text);
1580
1581 // Remove Markdown code block fences
1582 $text = preg_replace('/^```[a-zA-Z0-9_-]*\s*/i', '', $text);
1583 $text = preg_replace('/\s*```$/i', '', $text);
1584 $text = trim($text);
1585
1586 // Normalize [pagelayer_*] into [pl_*] to match Pagelayer shortcode registry
1587 $text = preg_replace('/\[(\/)?pagelayer_([a-zA-Z0-9_-]+)/', '[$1pl_$2', $text);
1588
1589 // If shortcode lacks an outer row, wrap it in a default row & column
1590 if (strpos($text, '[pl_row') === false && strpos($text, '[pagelayer_row') === false) {
1591 $text = '[pl_row col_gap="20" ele_padding="50px,20px,50px,20px"][pl_col col="12"]' . $text . '[/pl_col][/pl_row]';
1592 }
1593
1594 // Remove empty spacer columns. Card grids of 4+ items become sibling
1595 // rows of 3 columns (col=4) instead of being squashed into one row.
1596 $text = preg_replace_callback('/\[pl_row([^\]]*)\](.*?)\[\/pl_row\]/is', function($row_match) {
1597 $row_atts = $row_match[1];
1598 $row_content = $row_match[2];
1599
1600 if (strpos($row_atts, 'col_gap=') === false) {
1601 $row_atts .= ' col_gap="20"';
1602 }
1603
1604 $card_tags = 'pl_iconbox|pl_service|pl_testimonial|pl_counter|pl_pricing|pl_flipbox|pl_review|pl_author_box';
1605
1606 $emit_row = function ($atts, $cols) {
1607 $content = '';
1608 foreach ($cols as $c) {
1609 $content .= '[pl_col' . $c['atts'] . ']' . $c['inner'] . '[/pl_col]';
1610 }
1611 return '[pl_row' . $atts . ']' . $content . '[/pl_row]';
1612 };
1613
1614 $set_col_width = function ($col, $width) {
1615 $atts = preg_replace('/col="[^"]*"/', 'col="' . $width . '"', $col['atts']);
1616 if (strpos($atts, 'col="') === false) {
1617 $atts = ' col="' . $width . '"' . $atts;
1618 }
1619 $col['atts'] = $atts;
1620 return $col;
1621 };
1622
1623 if (preg_match_all('/\[pl_col([^\]]*)\](.*?)\[\/pl_col\]/is', $row_content, $col_matches, PREG_SET_ORDER)) {
1624 $non_empty_cols = array();
1625 foreach ($col_matches as $col) {
1626 $col_atts = $col[1];
1627 $col_inner = trim($col[2]);
1628 if (!empty($col_inner) && preg_match('/\[pl_[a-zA-Z0-9_-]+|<[a-z]+|[a-zA-Z0-9]/', $col_inner)) {
1629 $non_empty_cols[] = array(
1630 'raw' => $col[0],
1631 'atts' => $col_atts,
1632 'inner' => $col_inner,
1633 'is_card' => (bool) preg_match('/\[(' . $card_tags . ')\b/i', $col_inner)
1634 && !preg_match('/\[pl_(heading|text|btn|image|row|inner_row)\b/i', $col_inner),
1635 );
1636 }
1637 }
1638
1639 if (!empty($non_empty_cols)) {
1640 if (count($non_empty_cols) === 1) {
1641 $only = $set_col_width($non_empty_cols[0], 12);
1642 return $emit_row($row_atts, array($only));
1643 }
1644
1645 // Split leading full-width header columns from a trailing card grid.
1646 $header = array();
1647 $grid = array();
1648 $in_grid = false;
1649 foreach ($non_empty_cols as $c) {
1650 $w = 0;
1651 if (preg_match('/col="(\d+)"/', $c['atts'], $cm)) {
1652 $w = intval($cm[1]);
1653 }
1654 if (!$in_grid && ($c['is_card'] || ($w > 0 && $w <= 4))) {
1655 $in_grid = true;
1656 }
1657 if ($in_grid) {
1658 $grid[] = $c;
1659 } else {
1660 $header[] = $c;
1661 }
1662 }
1663
1664 // 4+ card/grid columns: new sibling rows of 3, never squash to one line.
1665 if (count($grid) >= 2) {
1666 $out = '';
1667 if (!empty($header)) {
1668 foreach ($header as $i => $h) {
1669 $header[$i] = $set_col_width($h, 12);
1670 }
1671 $head_atts = $row_atts;
1672 $head_atts = preg_replace('/ele_padding="[^"]*"/', 'ele_padding="50px,20px,50px,20px"', $head_atts);
1673 if (strpos($head_atts, 'ele_padding=') === false) {
1674 $head_atts .= ' ele_padding="50px,20px,50px,20px"';
1675 }
1676 $out .= $emit_row($head_atts, $header);
1677 }
1678 $chunks = array_chunk($grid, 3);
1679 foreach ($chunks as $ci => $chunk) {
1680 $n = count($chunk);
1681 $width = ($n >= 3) ? 4 : (($n === 2) ? 6 : 4);
1682 foreach ($chunk as $i => $c) {
1683 $chunk[$i] = $set_col_width($c, $width);
1684 }
1685 $chunk_atts = $row_atts;
1686 $pad = '50px,20px,50px,20px';
1687 if (preg_match('/ele_padding="/', $chunk_atts)) {
1688 $chunk_atts = preg_replace('/ele_padding="[^"]*"/', 'ele_padding="' . $pad . '"', $chunk_atts);
1689 } else {
1690 $chunk_atts .= ' ele_padding="' . $pad . '"';
1691 }
1692 $out .= $emit_row($chunk_atts, $chunk);
1693 }
1694 return $out;
1695 }
1696
1697 $total_cols = 0;
1698 foreach ($non_empty_cols as $c) {
1699 if (preg_match('/col="(\d+)"/', $c['atts'], $cm)) {
1700 $total_cols += intval($cm[1]);
1701 }
1702 }
1703
1704 // Only redistribute when a small row is missing widths (spacer
1705 // columns removed). Never force 4+ columns to share a single 12.
1706 $count = count($non_empty_cols);
1707 if ($total_cols !== 12 && $count <= 3) {
1708 $each_col = (int) floor(12 / $count);
1709 foreach ($non_empty_cols as $idx => $c) {
1710 $this_col = ($idx === 0) ? (12 - ($each_col * ($count - 1))) : $each_col;
1711 $non_empty_cols[$idx] = $set_col_width($c, $this_col);
1712 }
1713 }
1714
1715 return $emit_row($row_atts, $non_empty_cols);
1716 }
1717 }
1718
1719 // If row has no columns or all columns were empty, wrap content in full 12-column
1720 if (strpos($row_content, '[pl_col') === false && strpos($row_content, '[pagelayer_col') === false) {
1721 return '[pl_row' . $row_atts . '][pl_col col="12"]' . $row_content . '[/pl_col][/pl_row]';
1722 }
1723
1724 return $row_match[0];
1725 }, $text);
1726
1727 // Extract inline styles (color, align) from leaf widgets into shortcode attributes and strip style="..."
1728 $catalog = Pagelayer_AI_Layout_Engine::instance()->get_widget_catalog();
1729 $leaf_list = array();
1730 foreach ($catalog as $tag => $line) {
1731 if (strpos($line, '|children') !== false || $tag === 'pl_row' || $tag === 'pl_col' || $tag === 'pl_inner_row' || $tag === 'pl_inner_col') {
1732 continue;
1733 }
1734 $leaf_list[] = preg_quote($tag, '/');
1735 }
1736 $leaf_tags = !empty($leaf_list) ? implode('|', $leaf_list) : 'pl_heading|pl_text|pl_btn|pl_image';
1737 $text = preg_replace_callback('/\[(' . $leaf_tags . ')([^\]]*)\](.*?)\[\/\1\]/is', function($m) {
1738 $tag = $m[1];
1739 $atts = $m[2];
1740 $inner = $m[3];
1741
1742 if (preg_match('/style=[\"\']([^\"\']+)[\"\']/i', $inner, $sm)) {
1743 $style_str = $sm[1];
1744
1745 // Extract color if not already present in attributes.
1746 // pl_text has no `color` prop — put the value in ele_css so it actually renders.
1747 if (preg_match('/(?:^|;)\s*color\s*:\s*([^;]+)/i', $style_str, $cm)) {
1748 $extracted = trim($cm[1]);
1749 $no_color_prop = ($tag === 'pl_text' || $tag === 'pl_iconbox' || $tag === 'pl_service');
1750 if ($no_color_prop) {
1751 if (strpos($atts, 'ele_css=') === false) {
1752 $atts .= ' ele_css="{{element}},{{element}} .pagelayer-text-holder,{{element}} .pagelayer-service-text{color:' . $extracted . '}"';
1753 }
1754 } elseif (strpos($atts, 'color=') === false) {
1755 $atts .= ' color="' . $extracted . '"';
1756 }
1757 }
1758
1759 // Extract text-align if not already present in attributes
1760 if (strpos($atts, 'align=') === false && preg_match('/text-align\s*:\s*(left|center|right|justify)/i', $style_str, $am)) {
1761 $atts .= ' align="' . trim($am[1]) . '"';
1762 }
1763 }
1764
1765 // Clean all style attributes from inner HTML
1766 $inner = preg_replace('/\s*style=[\"\'][^\"\']*[\"\']/i', '', $inner);
1767
1768 return '[' . $tag . $atts . ']' . $inner . '[/' . $tag . ']';
1769 }, $text);
1770
1771 // Final pass to clean any remaining style attributes from any HTML tags
1772 $text = preg_replace('/(\sstyle=[\"\'][^\"\']*[\"\'])/i', '', $text);
1773
1774 return $this->ensure_counter_start($this->ensure_accordion_contrast($this->ensure_social_contrast($this->ensure_slider_image_size($this->ensure_iconbox_center($this->ensure_row_section_padding($this->ensure_dark_band_contrast($text)))))));
1775 }
1776
1777 /**
1778 * Social Profile: icon glyph and circle background must contrast.
1779 */
1780 private function ensure_social_contrast($text) {
1781 if (!is_string($text) || $text === '' || (stripos($text, 'social_grp') === false && stripos($text, 'social-grp') === false)) {
1782 return $text;
1783 }
1784
1785 $set = function ($atts, $key, $val) {
1786 if (preg_match('/\s' . preg_quote($key, '/') . '="/i', $atts)) {
1787 return preg_replace('/\s' . preg_quote($key, '/') . '="[^"]*"/i', ' ' . $key . '="' . $val . '"', $atts, 1);
1788 }
1789 return $atts . ' ' . $key . '="' . $val . '"';
1790 };
1791
1792 $rule = '{{element}}{display:inline-flex;flex-direction:row;flex-wrap:wrap;align-items:center;justify-content:center;gap:14px;}'
1793 . '{{element}} .pagelayer-icon-holder{background-color:var(--pagelayer-color-primary,#4f46e5)!important;}'
1794 . '{{element}} .pagelayer-social-fa{color:#ffffff!important;}';
1795
1796 return preg_replace_callback('/\[(pl_social_grp|pagelayer_social_grp)([^\]]*)\]/i', function ($m) use ($set, $rule) {
1797 $atts = $m[2];
1798 $icon = '';
1799 $bg = '';
1800 if (preg_match('/icon_color="([^"]*)"/i', $atts, $im)) {
1801 $icon = strtolower(trim($im[1]));
1802 }
1803 if (preg_match('/icon_bg_color="([^"]*)"/i', $atts, $bm)) {
1804 $bg = strtolower(trim($bm[1]));
1805 }
1806 $clash = ($icon === '' || $bg === '' || $icon === $bg);
1807
1808 $atts = $set($atts, 'color_scheme', '');
1809 $atts = $set($atts, 'bg_shape', 'pagelayer-social-shape-circle');
1810 if (!preg_match('/icon_size="/i', $atts) || preg_match('/icon_size="(?:0|1|2|3|4|5|6|7|8|9|10|12|14)"/i', $atts)) {
1811 $atts = $set($atts, 'icon_size', '20');
1812 }
1813 if ($clash) {
1814 $atts = $set($atts, 'icon_color', '#ffffff');
1815 $atts = $set($atts, 'icon_bg_color', '$primary');
1816 }
1817 if (strpos($atts, 'pagelayer-social-fa') === false) {
1818 if (preg_match('/ele_css="([^"]*)"/i', $atts, $cm)) {
1819 $atts = preg_replace('/ele_css="([^"]*)"/i', 'ele_css="' . esc_attr($cm[1] . $rule) . '"', $atts, 1);
1820 } else {
1821 $atts .= ' ele_css="' . esc_attr($rule) . '"';
1822 }
1823 }
1824 return '[' . $m[1] . $atts . ']';
1825 }, $text);
1826 }
1827
1828 /**
1829 * Accordion / Tabs: tab text, tab background, and panel must contrast.
1830 */
1831 private function ensure_accordion_contrast($text) {
1832 if (!is_string($text) || $text === '' || (stripos($text, 'accordion') === false && stripos($text, 'pl_tabs') === false)) {
1833 return $text;
1834 }
1835
1836 $set = function ($atts, $key, $val) {
1837 if (preg_match('/\s' . preg_quote($key, '/') . '="/i', $atts)) {
1838 return preg_replace('/\s' . preg_quote($key, '/') . '="[^"]*"/i', ' ' . $key . '="' . $val . '"', $atts, 1);
1839 }
1840 return $atts . ' ' . $key . '="' . $val . '"';
1841 };
1842
1843 $same = function ($a, $b) {
1844 $a = strtolower(trim((string) $a));
1845 $b = strtolower(trim((string) $b));
1846 return ($a === '' || $b === '' || $a === $b);
1847 };
1848
1849 return preg_replace_callback('/\[(pl_accordion|pl_tabs|pagelayer_accordion|pagelayer_tabs)([^\]]*)\]/i', function ($m) use ($set, $same) {
1850 $tag = strtolower($m[1]);
1851 $atts = $m[2];
1852 $is_tabs = (strpos($tag, 'tabs') !== false);
1853
1854 $tab_fg = $this->shortcode_att($atts, 'tabs_color');
1855 $tab_bg = $this->shortcode_att($atts, 'tabs_bg_color');
1856 $act_fg = $this->shortcode_att($atts, 'tabs_active_color');
1857 $act_bg = $this->shortcode_att($atts, 'tabs_active_bg_color');
1858 $row_bg = $this->shortcode_att($atts, 'ele_bg_color');
1859 $dark = $this->hex_looks_dark($row_bg);
1860
1861 if ($same($tab_fg, $tab_bg) || $same($tab_fg, $row_bg) || $same($tab_bg, $row_bg)) {
1862 $atts = $set($atts, 'tabs_color', $dark ? '#ffffff' : '#111827');
1863 $atts = $set($atts, 'tabs_bg_color', $dark ? 'rgba(255,255,255,0.14)' : '#f1f5f9');
1864 }
1865 if ($same($act_fg, $act_bg)) {
1866 $atts = $set($atts, 'tabs_active_color', '#ffffff');
1867 $atts = $set($atts, 'tabs_active_bg_color', '$primary');
1868 }
1869 $atts = $set($atts, 'tabs_content_bg_color', $dark ? 'rgba(255,255,255,0.08)' : '#ffffff');
1870 if ($is_tabs) {
1871 $atts = $set($atts, 'tabs_content_color', $dark ? '#ffffff' : '#111827');
1872 }
1873
1874 $sel_tab = $is_tabs ? '{{element}} .pagelayer-tablinks' : '{{element}} .pagelayer-accordion-tabs';
1875 $sel_panel = $is_tabs ? '{{element}} .pagelayer-tab' : '{{element}} .pagelayer-accordion-panel';
1876 $rule = $sel_tab . '{color:' . ($dark ? '#ffffff' : '#111827') . '!important;background-color:' . ($dark ? 'rgba(255,255,255,0.14)' : '#f1f5f9') . '!important;}'
1877 . '{{element}} .active ' . ($is_tabs ? '.pagelayer-tablinks' : '.pagelayer-accordion-tabs') . '{color:#ffffff!important;background-color:var(--pagelayer-color-primary,#4f46e5)!important;}'
1878 . $sel_panel . '{color:' . ($dark ? '#ffffff' : '#111827') . '!important;background-color:' . ($dark ? 'rgba(255,255,255,0.08)' : '#ffffff') . '!important;}';
1879 if (strpos($atts, 'pagelayer-accordion-tabs') === false && strpos($atts, 'pagelayer-tablinks') === false) {
1880 if (preg_match('/ele_css="([^"]*)"/i', $atts, $cm)) {
1881 $atts = preg_replace('/ele_css="([^"]*)"/i', 'ele_css="' . esc_attr($cm[1] . $rule) . '"', $atts, 1);
1882 } else {
1883 $atts .= ' ele_css="' . esc_attr($rule) . '"';
1884 }
1885 }
1886 return '[' . $m[1] . $atts . ']';
1887 }, $text);
1888 }
1889
1890 private function shortcode_att($atts, $key) {
1891 if (preg_match('/\s' . preg_quote($key, '/') . '="([^"]*)"/i', (string) $atts, $m)) {
1892 return trim($m[1]);
1893 }
1894 return '';
1895 }
1896
1897 /**
1898 * Counter start must be >= 1 — 0 hides the widget (if="{{counter_start_number}}").
1899 */
1900 private function ensure_counter_start($text) {
1901 if (!is_string($text) || $text === '' || stripos($text, 'counter') === false) {
1902 return $text;
1903 }
1904
1905 return preg_replace_callback('/\[(pl_counter|pagelayer_counter)([^\]]*)\]/i', function ($m) {
1906 $atts = $m[2];
1907 $start = 0;
1908 if (preg_match('/counter_start_number="([^"]*)"/i', $atts, $sm)) {
1909 $start = (int) $sm[1];
1910 }
1911 if ($start < 1) {
1912 if (preg_match('/counter_start_number="/i', $atts)) {
1913 $atts = preg_replace('/counter_start_number="[^"]*"/i', 'counter_start_number="1"', $atts, 1);
1914 } else {
1915 $atts .= ' counter_start_number="1"';
1916 }
1917 $start = 1;
1918 }
1919 $end = 0;
1920 if (preg_match('/counter_end_number="([^"]*)"/i', $atts, $em)) {
1921 $end = (int) $em[1];
1922 }
1923 if ($end <= $start) {
1924 $new_end = (string) max(100, $start + 99);
1925 if (preg_match('/counter_end_number="/i', $atts)) {
1926 $atts = preg_replace('/counter_end_number="[^"]*"/i', 'counter_end_number="' . $new_end . '"', $atts, 1);
1927 } else {
1928 $atts .= ' counter_end_number="' . $new_end . '"';
1929 }
1930 }
1931 return '[' . $m[1] . $atts . ']';
1932 }, $text);
1933 }
1934
1935 /**
1936 * Tag dark (and nested light) surfaces so canvas CSS can keep text readable
1937 * when a widget has no color prop of its own.
1938 */
1939 private function ensure_dark_band_contrast($text) {
1940 if (!is_string($text) || $text === '') {
1941 return $text;
1942 }
1943
1944 $engine = class_exists('Pagelayer_AI_Layout_Engine') ? Pagelayer_AI_Layout_Engine::instance() : null;
1945
1946 return preg_replace_callback(
1947 '/\[(pl_(?:inner_)?(?:row|col|iconbox|service|testimonial|quote|counter|pricing|flipbox|review|author_box|call|countdown|heading|text|list|accordion|tabs|alert|address|phone|email))([^\]]*)\]/i',
1948 function ($m) use ($engine) {
1949 $tag = $m[1];
1950 $atts = $m[2];
1951 if (strpos($atts, 'pagelayer-ai-on-dark') !== false || strpos($atts, 'pagelayer-ai-on-light') !== false) {
1952 return $m[0];
1953 }
1954 if (!preg_match('/ele_bg_color="([^"]*)"/i', $atts, $bm) || trim($bm[1]) === '') {
1955 return $m[0];
1956 }
1957 $dark = $engine
1958 ? $engine->is_dark_color($bm[1], false)
1959 : $this->hex_looks_dark($bm[1]);
1960 $cls = $dark ? 'pagelayer-ai-on-dark' : 'pagelayer-ai-on-light';
1961 if (preg_match('/ele_classes="/i', $atts)) {
1962 $atts = preg_replace('/ele_classes="/i', 'ele_classes="' . $cls . ' ', $atts, 1);
1963 } else {
1964 $atts .= ' ele_classes="' . $cls . '"';
1965 }
1966 return '[' . $tag . $atts . ']';
1967 },
1968 $text
1969 );
1970 }
1971
1972 private function hex_looks_dark($val) {
1973 $val = strtolower(trim((string) $val));
1974 if ($val === '' || $val === 'transparent') {
1975 return false;
1976 }
1977 if (in_array($val, array('black', 'navy', '#000', '#000000', '#111', '#111111'), true) || $val === '$text') {
1978 return true;
1979 }
1980 if ($val === 'white' || $val === '#fff' || $val === '#ffffff') {
1981 return false;
1982 }
1983 if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $val, $m)) {
1984 $hex = $m[1];
1985 if (strlen($hex) === 3) {
1986 $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
1987 }
1988 $r = hexdec(substr($hex, 0, 2));
1989 $g = hexdec(substr($hex, 2, 2));
1990 $b = hexdec(substr($hex, 4, 2));
1991 return ((0.299 * $r + 0.587 * $g + 0.114 * $b) / 255) < 0.55;
1992 }
1993 return false;
1994 }
1995
1996 /**
1997 * Every outer section row keeps 50px top and bottom padding so the next
1998 * band does not sit flush against the columns above it.
1999 */
2000 private function ensure_row_section_padding($text) {
2001 if (!is_string($text) || $text === '') {
2002 return $text;
2003 }
2004
2005 $engine = class_exists('Pagelayer_AI_Layout_Engine') ? Pagelayer_AI_Layout_Engine::instance() : null;
2006
2007 return preg_replace_callback('/\[pl_row([^\]]*)\]/i', function ($m) use ($engine) {
2008 $atts = $m[1];
2009 $pad = '';
2010 if (preg_match('/ele_padding="([^"]*)"/i', $atts, $pm)) {
2011 $pad = $pm[1];
2012 }
2013 $fixed = $engine
2014 ? $engine->format_section_row_padding($pad)
2015 : '50px,20px,50px,20px';
2016 if (preg_match('/ele_padding="/i', $atts)) {
2017 $atts = preg_replace('/ele_padding="[^"]*"/i', 'ele_padding="' . $fixed . '"', $atts, 1);
2018 } else {
2019 $atts .= ' ele_padding="' . $fixed . '"';
2020 }
2021 return '[pl_row' . $atts . ']';
2022 }, $text);
2023 }
2024
2025 /**
2026 * Icon / image boxes: centered icon (Horizontal Position), heading, and copy.
2027 */
2028 private function ensure_iconbox_center($text) {
2029 if (!is_string($text) || $text === '') {
2030 return $text;
2031 }
2032
2033 $set = function ($atts, $key, $val) {
2034 if (preg_match('/\s' . preg_quote($key, '/') . '="/i', $atts)) {
2035 return preg_replace('/\s' . preg_quote($key, '/') . '="[^"]*"/i', ' ' . $key . '="' . $val . '"', $atts, 1);
2036 }
2037 return $atts . ' ' . $key . '="' . $val . '"';
2038 };
2039
2040 $append_css = function ($atts, $rule) {
2041 if (strpos($atts, $rule) !== false) {
2042 return $atts;
2043 }
2044 if (preg_match('/ele_css="([^"]*)"/i', $atts, $cm)) {
2045 return preg_replace('/ele_css="([^"]*)"/i', 'ele_css="$1' . $rule . '"', $atts, 1);
2046 }
2047 return $atts . ' ele_css="' . $rule . '"';
2048 };
2049
2050 return preg_replace_callback('/\[(pl_iconbox|pl_service)([^\]]*)\]/i', function ($m) use ($set, $append_css) {
2051 $tag = strtolower($m[1]);
2052 $atts = $m[2];
2053 $atts = $set($atts, 'service_alignment', 'top');
2054 $atts = $set($atts, ($tag === 'pl_iconbox') ? 'service_icon_alignment' : 'service_img_alignment', 'center');
2055 $atts = $set($atts, 'heading_alignment', 'center');
2056 $atts = $set($atts, 'service_text_alignment', 'center');
2057 if (strpos($atts, 'font_size=') === false) {
2058 $atts = $set($atts, 'font_size', '15');
2059 }
2060 if (strpos($atts, 'line_height=') === false) {
2061 $atts = $set($atts, 'line_height', '1.65');
2062 }
2063 if ($tag === 'pl_service') {
2064 if (strpos($atts, 'service_image_height=') === false) {
2065 $atts = $set($atts, 'service_image_height', '220');
2066 }
2067 if (strpos($atts, 'service_image_object_fit=') === false) {
2068 $atts = $set($atts, 'service_image_object_fit', 'cover');
2069 }
2070 if (strpos($atts, 'service_image_object_pos=') === false) {
2071 $atts = $set($atts, 'service_image_object_pos', 'center');
2072 }
2073 if (strpos($atts, 'service_image_border_radius=') === false) {
2074 $atts = $set($atts, 'service_image_border_radius', '8,8,8,8');
2075 }
2076 $rule = '{{element}} .pagelayer-service-image{width:100%!important;overflow:hidden;margin:0 auto 16px auto;border-radius:8px;}'
2077 . '{{element}} .pagelayer-service-image img{width:100%!important;height:220px!important;max-height:220px!important;min-height:220px!important;object-fit:cover!important;object-position:center!important;border-radius:8px;display:block;margin:0 auto;}'
2078 . '{{element}} .pagelayer-service-container{display:flex;flex-direction:column;height:100%;}'
2079 . '{{element}} .pagelayer-service-details{display:flex;flex-direction:column;flex-grow:1;}'
2080 . '{{element}} .pagelayer-service-heading{text-align:center;font-weight:700;line-height:1.3;margin-bottom:8px;}'
2081 . '{{element}} .pagelayer-service-text{text-align:center;font-size:15px;line-height:1.7;font-weight:400;flex-grow:1;margin-bottom:16px;}'
2082 . '{{element}} .pagelayer-service-btn{margin-top:auto;align-self:center;}';
2083 $atts = $append_css($atts, $rule);
2084 } else {
2085 if (strpos($atts, 'service_icon_font_size=') === false) {
2086 $atts = $set($atts, 'service_icon_font_size', '28');
2087 }
2088 $rule = '{{element}} .pagelayer-service-icon{display:inline-flex!important;align-items:center!important;justify-content:center!important;width:64px!important;height:64px!important;min-width:64px!important;min-height:64px!important;margin:0 auto 16px auto!important;line-height:1!important;}'
2089 . '{{element}} .pagelayer-service-icon i,{{element}} .pagelayer-service-icon svg{font-size:28px!important;width:28px!important;height:28px!important;line-height:28px!important;display:inline-block!important;}'
2090 . '{{element}} .pagelayer-service-container{display:flex;flex-direction:column;height:100%;}'
2091 . '{{element}} .pagelayer-service-details{display:flex;flex-direction:column;flex-grow:1;}'
2092 . '{{element}} .pagelayer-service-heading{text-align:center;font-weight:700;line-height:1.3;margin-bottom:8px;}'
2093 . '{{element}} .pagelayer-service-text{text-align:center;font-size:15px;line-height:1.7;font-weight:400;flex-grow:1;margin-bottom:16px;}'
2094 . '{{element}} .pagelayer-service-btn{margin-top:auto;align-self:center;}';
2095 $atts = $append_css($atts, $rule);
2096 }
2097 return '[' . $m[1] . $atts . ']';
2098 }, $text);
2099 }
2100
2101 /**
2102 * Ensure image slider images are constrained to a balanced height (420px, object-fit: cover)
2103 * instead of blowing up to full natural image height.
2104 */
2105 private function ensure_slider_image_size($text) {
2106 if (empty($text) || (stripos($text, 'image_slider') === false && stripos($text, 'image-slider') === false)) {
2107 return $text;
2108 }
2109
2110 $rule = '{{element}}{max-width:1000px!important;margin:0 auto!important;overflow:visible!important;}'
2111 . '{{element}} .pagelayer-image-slider-div{max-width:1000px!important;margin:0 auto!important;overflow:visible!important;border-radius:12px!important;}'
2112 . '{{element}} .pagelayer-image-slider-ul{margin:0 auto!important;}'
2113 . '{{element}} .pagelayer-slider-item{overflow:hidden!important;border-radius:12px!important;}'
2114 . '{{element}} .pagelayer-slider-item img,{{element}} img.pagelayer-img,{{element}} .pagelayer-image-slider-div img{width:100%!important;height:420px!important;max-height:440px!important;min-height:280px!important;object-fit:cover!important;object-position:center!important;border-radius:12px!important;display:block!important;margin:0 auto!important;}'
2115 . '{{element}} .pagelayer-owl-nav{z-index:5!important;}'
2116 . '{{element}} .pagelayer-owl-prev,{{element}} .pagelayer-owl-next{width:48px!important;height:48px!important;min-width:48px!important;min-height:48px!important;border-radius:50%!important;background:rgba(15,23,42,0.72)!important;color:#ffffff!important;display:flex!important;align-items:center!important;justify-content:center!important;opacity:1!important;}'
2117 . '{{element}} .pagelayer-owl-prev span,{{element}} .pagelayer-owl-next span,{{element}} .pagelayer-owl-prev i,{{element}} .pagelayer-owl-next i{font-size:28px!important;line-height:1!important;color:#ffffff!important;}';
2118
2119 $set = function ($atts, $key, $val) {
2120 if (preg_match('/\s' . preg_quote($key, '/') . '="/i', $atts)) {
2121 return preg_replace('/\s' . preg_quote($key, '/') . '="[^"]*"/i', ' ' . $key . '="' . $val . '"', $atts, 1);
2122 }
2123 return $atts . ' ' . $key . '="' . $val . '"';
2124 };
2125
2126 return preg_replace_callback('/\[(pl_image_slider|pagelayer-image[_-]slider)(\s+[^\]]*)?\]/i', function ($m) use ($rule, $set) {
2127 $atts = isset($m[2]) ? $m[2] : '';
2128 $atts = $set($atts, 'controls', 'arrows');
2129 if (!preg_match('/nav_size="/i', $atts) || preg_match('/nav_size="(?:0|1|2|3|4|5|6|7|8|9|10|12|14|16)"/i', $atts)) {
2130 $atts = $set($atts, 'nav_size', '28');
2131 }
2132 if (!preg_match('/arraow_bg_size="/i', $atts) || preg_match('/arraow_bg_size="(?:0|1[0-9]|2[0-9]|3[0-5])"/i', $atts)) {
2133 $atts = $set($atts, 'arraow_bg_size', '48');
2134 }
2135 if (!preg_match('/arraow_bg_shape="/i', $atts)) {
2136 $atts = $set($atts, 'arraow_bg_shape', '50');
2137 }
2138 $arrow_fg = '';
2139 $arrow_bg = '';
2140 if (preg_match('/arraow_color="([^"]*)"/i', $atts, $fm)) {
2141 $arrow_fg = strtolower(trim($fm[1]));
2142 }
2143 if (preg_match('/arrows_bg="([^"]*)"/i', $atts, $bm)) {
2144 $arrow_bg = strtolower(trim($bm[1]));
2145 }
2146 if ($arrow_fg === '' || $arrow_bg === '' || $arrow_fg === $arrow_bg) {
2147 $atts = $set($atts, 'arrows_bg', 'rgba(15,23,42,0.72)');
2148 $atts = $set($atts, 'arraow_color', '#ffffff');
2149 }
2150
2151 if (strpos($atts, 'font-size:28px') === false) {
2152 if (preg_match('/ele_css="([^"]*)"/', $atts, $m_css)) {
2153 $existing_css = $m_css[1];
2154 $new_css = $existing_css . $rule;
2155 $atts = str_replace($m_css[0], 'ele_css="' . esc_attr($new_css) . '"', $atts);
2156 } else {
2157 $atts .= ' ele_css="' . esc_attr($rule) . '"';
2158 }
2159 }
2160
2161 return '[' . $m[1] . $atts . ']';
2162 }, $text);
2163 }
2164
2165 /**
2166 * Render shortcode to live canvas HTML with data-attributes and Pagelayer editor structure
2167 */
2168 public function render_shortcodes_for_canvas($shortcode, $post_id = 0) {
2169 global $post, $wp_query;
2170
2171 // Set live mode flag so pagelayer_is_live() returns true and creates pagelayer-ele, pagelayer-tag, and data comments
2172 $_REQUEST['pagelayer-live'] = 1;
2173 if (!empty($post_id)) {
2174 $_REQUEST['postID'] = $post_id;
2175 }
2176
2177 if (!empty($post_id)) {
2178 $post_obj = get_post($post_id);
2179 if (!empty($post_obj)) {
2180 $post = $post_obj;
2181 $GLOBALS['post'] = $post_obj;
2182 $GLOBALS['wp_query'] = new WP_Query(array(
2183 'post_type' => $post_obj->post_type,
2184 'post__in' => array($post_id),
2185 ));
2186 }
2187 }
2188
2189 if (empty($GLOBALS['post'])) {
2190 $posts = get_posts(array('numberposts' => 1, 'post_status' => 'any'));
2191 if (!empty($posts)) {
2192 $post = $posts[0];
2193 $GLOBALS['post'] = $posts[0];
2194 }
2195 }
2196
2197 if (function_exists('pagelayer_load_shortcodes')) {
2198 pagelayer_load_shortcodes();
2199 }
2200
2201 // Ensure pagelayer_* shortcode aliases exist as well
2202 $this->register_pagelayer_aliases();
2203
2204 if (function_exists('pagelayer_the_content')) {
2205 $rendered = pagelayer_the_content($shortcode, true);
2206 return $rendered;
2207 }
2208
2209 return do_shortcode($shortcode);
2210 }
2211
2212 /**
2213 * Register pagelayer_* alias tags pointing to pl_* shortcodes
2214 */
2215 private function register_pagelayer_aliases() {
2216 global $shortcode_tags;
2217 if (!is_array($shortcode_tags)) return;
2218
2219 foreach ($shortcode_tags as $tag => $callback) {
2220 if (strpos($tag, 'pl_') !== 0) {
2221 continue;
2222 }
2223 $alias = 'pagelayer_' . substr($tag, 3);
2224 if (!isset($shortcode_tags[$alias])) {
2225 add_shortcode($alias, $callback);
2226 }
2227 }
2228 }
2229
2230 /**
2231 * Params for admin-ajax fallbacks (JSON body or $_POST).
2232 */
2233 public function ajax_request_params() {
2234 $raw = file_get_contents('php://input');
2235 if (is_string($raw) && $raw !== '') {
2236 $decoded = json_decode($raw, true);
2237 if (is_array($decoded) && !empty($decoded)) {
2238 return $decoded;
2239 }
2240 }
2241 return is_array($_POST) ? $_POST : array();
2242 }
2243
2244 /**
2245 * Unwrap a REST callback result to an array or WP_Error.
2246 */
2247 private function unwrap_rest_result($response) {
2248 if (is_wp_error($response)) {
2249 return $response;
2250 }
2251 if ($response instanceof WP_REST_Response) {
2252 $data = $response->get_data();
2253 $status = $response->get_status();
2254 if ($status >= 400) {
2255 $msg = is_array($data) && !empty($data['message']) ? $data['message'] : __('Request failed.', 'pagelayer');
2256 return new WP_Error('pagelayer_ai_error', $msg, array('status' => $status));
2257 }
2258 return $data;
2259 }
2260 return $response;
2261 }
2262
2263 public function process_generate($params = null) {
2264 $request = new WP_REST_Request('POST', '/' . self::REST_NAMESPACE . '/ai/generate');
2265 $request->set_body_params(is_array($params) ? $params : $this->ajax_request_params());
2266 return $this->unwrap_rest_result($this->rest_generate($request));
2267 }
2268
2269 public function process_get_settings() {
2270 $request = new WP_REST_Request('GET', '/' . self::REST_NAMESPACE . '/ai/settings');
2271 return $this->unwrap_rest_result($this->rest_get_settings($request));
2272 }
2273
2274 public function process_save_settings($params = null) {
2275 $request = new WP_REST_Request('POST', '/' . self::REST_NAMESPACE . '/ai/settings');
2276 $request->set_body_params(is_array($params) ? $params : $this->ajax_request_params());
2277 return $this->unwrap_rest_result($this->rest_save_settings($request));
2278 }
2279 }
2280
2281 // Instantiate
2282 Pagelayer_AI_Controller::get_instance();
2283