PluginProbe
Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits / 3.1.2
Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits v3.1.2
3.2.2 3.2.3 3.2.1 3.2.0 3.1.9 3.1.8 3.1.7 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.9 trunk 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.3 1.1.4 1.1.5 All 174 releases
master-addons / inc / admin / widget-builder / class-dynamic-widget.php

class-dynamic-widget.php in Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits 3.1.2, at inc/admin/widget-builder/class-dynamic-widget.php

865 lines 34.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace MasterAddons\Inc\Admin\WidgetBuilder;
3
4 use MasterAddons\Inc\Classes\Base\Master_Widget;
5
6 defined('ABSPATH') || exit;
7
8 /**
9 * Dynamic Widget (runtime renderer)
10 *
11 * Replaces the legacy "generate a widget.php file and require_once it" approach.
12 * One shipped class, instantiated per widget post, that reads the stored schema
13 * from post meta and:
14 * - registers Elementor controls at runtime (no generated PHP), and
15 * - renders the HTML/CSS/JS template with escaped {{placeholder}} substitution.
16 *
17 * No user input is ever written to or executed as PHP.
18 *
19 * @package MasterAddons
20 * @subpackage WidgetBuilder
21 */
22 if (!class_exists('MasterAddons\Inc\Admin\WidgetBuilder\Dynamic_Widget')) {
23 class Dynamic_Widget extends Master_Widget {
24
25 /** @var int */
26 private $jltma_post_id;
27
28 /** @var array */
29 private $jltma_data;
30
31 /** @var array Tracks used control keys for deterministic uniqueness (parity with generator). */
32 private $jltma_used_keys = [];
33
34 /** @var \MasterAddons\Inc\Admin\WidgetBuilder\Control_Manager */
35 private $control_manager;
36
37 /**
38 * Elementor instantiates widgets as `new Class($data, $args)`, so the
39 * signature MUST match. The widget post id is derived from the registration
40 * args (type instance) or from the element's widgetType `jltma_wb_{id}`
41 * (rendered element) — never from a positional id argument.
42 *
43 * @param array $data Elementor element data.
44 * @param array|null $args Elementor element/registration args.
45 */
46 public function __construct($data = [], $args = null) {
47 $this->jltma_post_id = $this->jltma_resolve_post_id($data, $args);
48 $this->jltma_load_data();
49
50 require_once __DIR__ . '/class-control-manager.php';
51 $this->control_manager = Control_Manager::get_instance();
52
53 // Register external libraries declared in the widget's includes.
54 $this->jltma_register_external_libraries();
55
56 parent::__construct($data, $args);
57 }
58
59 /**
60 * Resolve the widget post id from the registration args (jltma_post_id) or
61 * from the element's widgetType name (jltma_wb_{id}).
62 */
63 private function jltma_resolve_post_id($data, $args) {
64 if (is_array($args) && !empty($args['jltma_post_id'])) {
65 return absint($args['jltma_post_id']);
66 }
67 if (is_array($data) && !empty($data['widgetType']) && preg_match('/^jltma_wb_(\d+)$/', $data['widgetType'], $m)) {
68 return absint($m[1]);
69 }
70 return 0;
71 }
72
73 /* ------------------------------------------------------------------ *
74 * Data loading
75 * ------------------------------------------------------------------ */
76
77 private function jltma_load_data() {
78 $data = get_post_meta($this->jltma_post_id, '_jltma_widget_data', true);
79 if (empty($data) || !is_array($data)) {
80 $data = [
81 'title' => get_the_title($this->jltma_post_id),
82 'icon' => 'eicon-code',
83 'category' => get_post_meta($this->jltma_post_id, '_jltma_widget_category', true) ?: 'master-addons',
84 'html_code' => '',
85 'css_code' => '',
86 'js_code' => '',
87 ];
88 }
89
90 $sections = get_post_meta($this->jltma_post_id, '_jltma_widget_sections', true);
91 $data['sections'] = (!empty($sections) && is_array($sections)) ? $sections : [];
92
93 $includes = get_post_meta($this->jltma_post_id, '_jltma_widget_includes', true);
94 $data['includes'] = (!empty($includes) && is_array($includes))
95 ? $includes
96 : ['css_libraries' => [], 'js_libraries' => []];
97
98 $this->jltma_data = $data;
99 }
100
101 /* ------------------------------------------------------------------ *
102 * Widget identity
103 * ------------------------------------------------------------------ */
104
105 public function get_name() {
106 return 'jltma_wb_' . $this->jltma_post_id;
107 }
108
109 public function get_title() {
110 $title = !empty($this->jltma_data['title']) ? $this->jltma_data['title'] : 'Custom Widget';
111 // translators: dynamic user-defined widget title.
112 return esc_html($title);
113 }
114
115 public function get_icon() {
116 $icon = !empty($this->jltma_data['icon']) ? $this->jltma_data['icon'] : 'eicon-code';
117 return sanitize_text_field($icon);
118 }
119
120 public function get_categories() {
121 $category = !empty($this->jltma_data['category']) ? $this->jltma_data['category'] : 'master-addons';
122 return [sanitize_text_field($category)];
123 }
124
125 public function get_style_depends() {
126 $handles = [];
127 foreach (($this->jltma_data['includes']['css_libraries'] ?? []) as $lib) {
128 if (!empty($lib['handle'])) {
129 $handles[] = sanitize_text_field($lib['handle']);
130 }
131 }
132 return $handles;
133 }
134
135 public function get_script_depends() {
136 $handles = [];
137 foreach (($this->jltma_data['includes']['js_libraries'] ?? []) as $lib) {
138 if (!empty($lib['handle'])) {
139 $handles[] = sanitize_text_field($lib['handle']);
140 }
141 }
142 return $handles;
143 }
144
145 /**
146 * Register external CSS/JS libraries declared via the widget's includes.
147 * Only URL sources are registered; the widget's own CSS/JS is emitted inline
148 * in render() (no files written).
149 */
150 private function jltma_register_external_libraries() {
151 foreach (($this->jltma_data['includes']['css_libraries'] ?? []) as $lib) {
152 if (!empty($lib['handle']) && !empty($lib['src']) && filter_var($lib['src'], FILTER_VALIDATE_URL)) {
153 $deps = (!empty($lib['dependencies']) && is_array($lib['dependencies'])) ? array_map('sanitize_text_field', $lib['dependencies']) : [];
154 wp_register_style(sanitize_text_field($lib['handle']), esc_url_raw($lib['src']), $deps, '1.0.0');
155 }
156 }
157 foreach (($this->jltma_data['includes']['js_libraries'] ?? []) as $lib) {
158 if (!empty($lib['handle']) && !empty($lib['src']) && filter_var($lib['src'], FILTER_VALIDATE_URL)) {
159 $deps = (!empty($lib['dependencies']) && is_array($lib['dependencies'])) ? array_map('sanitize_text_field', $lib['dependencies']) : [];
160 wp_register_script(sanitize_text_field($lib['handle']), esc_url_raw($lib['src']), $deps, '1.0.0', true);
161 }
162 }
163 }
164
165 /* ------------------------------------------------------------------ *
166 * Controls (runtime; parity with Widget_Generator::build_register_controls)
167 * ------------------------------------------------------------------ */
168
169 protected function register_controls() {
170 $this->jltma_used_keys = [];
171
172 if (empty($this->jltma_data['sections']) || !is_array($this->jltma_data['sections'])) {
173 return;
174 }
175
176 foreach ($this->jltma_sort_sections($this->jltma_data['sections']) as $section_id => $section) {
177 if (is_array($section)) {
178 $this->jltma_register_section($section_id, $section);
179 }
180 }
181 }
182
183 /** Order: content, style, advanced (matches generator). */
184 private function jltma_sort_sections($sections) {
185 $content = $style = $advanced = [];
186 foreach ($sections as $id => $section) {
187 if (!is_array($section)) {
188 continue;
189 }
190 $tab = !empty($section['tab']) ? $section['tab'] : 'content';
191 if ('style' === $tab) {
192 $style[$id] = $section;
193 } elseif ('advanced' === $tab) {
194 $advanced[$id] = $section;
195 } else {
196 $content[$id] = $section;
197 }
198 }
199 return $content + $style + $advanced;
200 }
201
202 private function jltma_register_section($section_id, $section) {
203 $label = !empty($section['title']) ? $section['title'] : (!empty($section['label']) ? $section['label'] : 'Section');
204 $tab = !empty($section['tab']) ? $section['tab'] : 'content';
205
206 $tab_prefix = 'jltma_content_';
207 $tab_const = \Elementor\Controls_Manager::TAB_CONTENT;
208 if ('style' === $tab) {
209 $tab_prefix = 'jltma_style_';
210 $tab_const = \Elementor\Controls_Manager::TAB_STYLE;
211 } elseif ('advanced' === $tab) {
212 $tab_prefix = 'jltma_advanced_';
213 $tab_const = \Elementor\Controls_Manager::TAB_ADVANCED;
214 }
215
216 $section_key = $tab_prefix . $this->jltma_sanitize_key($label) . '_' . $section_id . '_' . $this->jltma_post_id;
217
218 $this->start_controls_section($section_key, [
219 // translators: dynamic user-defined section label.
220 'label' => esc_html($label),
221 'tab' => $tab_const,
222 ]);
223
224 $controls = !empty($section['controls']) ? $section['controls'] : (!empty($section['fields']) ? $section['fields'] : []);
225 if (!empty($controls) && is_array($controls)) {
226 foreach ($controls as $field_id => $field) {
227 $this->jltma_register_control($field_id, $field, $tab);
228 }
229 }
230
231 $this->end_controls_section();
232 }
233
234 private function jltma_register_control($field_id, $field, $tab) {
235 $type = !empty($field['type']) ? strtoupper($field['type']) : 'TEXT';
236 $tab_prefix = $this->jltma_tab_prefix($tab);
237
238 // TABS: structural container keyed by its own name (matches generator).
239 if ('TABS' === $type) {
240 $tabs_key = !empty($field['name']) ? $field['name'] : 'tabs_' . $field_id;
241 $this->jltma_register_tabs($tabs_key, $field, $tab, $tab_prefix);
242 return;
243 }
244
245 $label = !empty($field['label']) ? $field['label'] : 'Control';
246 $control_key = $this->jltma_make_control_key($label, $tab_prefix);
247 $field = $this->jltma_inject_context($field, $tab, $tab_prefix, true);
248
249 // POPOVER_TOGGLE: a normal toggle control followed by a popover of child fields.
250 if ('POPOVER_TOGGLE' === $type) {
251 $this->jltma_apply_control($this->control_manager->build_control_config($control_key, $field, $type));
252 if (!empty($field['popover_fields']) && is_array($field['popover_fields'])) {
253 $this->jltma_register_popover_fields($control_key, $field, $tab, $tab_prefix);
254 }
255 return;
256 }
257
258 // REPEATER: descriptor carries sub-controls; jltma_apply_control builds the Repeater.
259 if ('REPEATER' === $type) {
260 $this->jltma_apply_control($this->control_manager->build_control_config($control_key, $field, $type));
261 return;
262 }
263
264 if ('DATE_TIME' === $type) {
265 $field = $this->jltma_preprocess_date_time($field);
266 }
267
268 $this->jltma_apply_control($this->control_manager->build_control_config($control_key, $field, $type));
269 }
270
271 private function jltma_tab_prefix($tab) {
272 if ('style' === $tab) {
273 return 'jltma_style_';
274 }
275 if ('advanced' === $tab) {
276 return 'jltma_advanced_';
277 }
278 return 'jltma_content_';
279 }
280
281 /** Deterministic unique control key (matches generator's tab-prefix + slug + counter). */
282 private function jltma_make_control_key($label, $tab_prefix) {
283 $slug = $this->jltma_sanitize_key($label);
284 $key = $tab_prefix . $slug . '_' . $this->jltma_post_id;
285 $c = 1;
286 while (in_array($key, $this->jltma_used_keys, true)) {
287 $key = $tab_prefix . $slug . '_' . $c . '_' . $this->jltma_post_id;
288 $c++;
289 }
290 $this->jltma_used_keys[] = $key;
291 return $key;
292 }
293
294 /** Inject the context the control builders use for condition-key conversion. */
295 private function jltma_inject_context($field, $tab, $tab_prefix, $with_sections = true) {
296 $field['_tab'] = $tab;
297 $field['_widget_id'] = $this->jltma_post_id;
298 $field['_tab_prefix'] = $tab_prefix;
299 if ($with_sections) {
300 $field['_sections_data'] = $this->jltma_data['sections'] ?? [];
301 }
302 return $field;
303 }
304
305 /** Register a TABS structural control (ports Tabs::build to runtime calls). */
306 private function jltma_register_tabs($tabs_key, $field, $tab, $tab_prefix) {
307 $tabs = [];
308 if (!empty($field['tabs']) && is_array($field['tabs'])) {
309 $tabs = $field['tabs'];
310 } elseif (!empty($field['fields']) && is_array($field['fields'])) {
311 $tab_fields = (!empty($field['tab_fields']) && is_array($field['tab_fields'])) ? $field['tab_fields'] : [];
312 foreach ($field['fields'] as $td) {
313 if (empty($td['name'])) {
314 continue;
315 }
316 $tn = $td['name'];
317 $tabs[] = [
318 'name' => $tn,
319 'label' => !empty($td['label']) ? $td['label'] : ucfirst($tn),
320 'controls' => (!empty($tab_fields[$tn]) && is_array($tab_fields[$tn])) ? $tab_fields[$tn] : [],
321 ];
322 }
323 }
324
325 if (empty($tabs)) {
326 return;
327 }
328
329 $this->start_controls_tabs($tabs_key);
330
331 foreach ($tabs as $tab_index => $tabdef) {
332 if (empty($tabdef['name']) || empty($tabdef['label'])) {
333 continue;
334 }
335 $tab_key = $tabs_key . '_tab_' . $tabdef['name'];
336 $tab_label = !empty($tabdef['label']) ? $tabdef['label'] : 'Tab ' . ($tab_index + 1);
337
338 $tab_args = [
339 // translators: dynamic user-defined tab label.
340 'label' => esc_html($tab_label),
341 ];
342 if (!empty($tabdef['condition']) && is_array($tabdef['condition'])) {
343 $tab_args['condition'] = $tabdef['condition'];
344 }
345
346 $this->start_controls_tab($tab_key, $tab_args);
347
348 if (!empty($tabdef['controls']) && is_array($tabdef['controls'])) {
349 foreach ($tabdef['controls'] as $child) {
350 $this->jltma_register_child_control($child, $tab, $tab_prefix);
351 }
352 }
353
354 $this->end_controls_tab();
355 }
356
357 $this->end_controls_tabs();
358 }
359
360 /** Register a control nested inside a tab (no section context, no date_time preprocess; matches generator). */
361 private function jltma_register_child_control($child, $tab, $tab_prefix) {
362 if (empty($child['type']) || empty($child['name'])) {
363 return;
364 }
365 $label = !empty($child['label']) ? $child['label'] : $child['name'];
366 $key = $this->jltma_make_control_key($label, $tab_prefix);
367 $child = $this->jltma_inject_context($child, $tab, $tab_prefix, false);
368 $this->jltma_apply_control($this->control_manager->build_control_config($key, $child, strtoupper($child['type'])));
369 }
370
371 /** Register popover child fields (start_popover / children / end_popover). */
372 private function jltma_register_popover_fields($control_key, $field, $tab, $tab_prefix) {
373 $this->start_popover();
374 foreach ($field['popover_fields'] as $pf) {
375 if (empty($pf['name']) || empty($pf['type'])) {
376 continue;
377 }
378 $pf_key = $control_key . '_' . $this->jltma_sanitize_key($pf['name']);
379 $pf = $this->jltma_inject_context($pf, $tab, $tab_prefix, false);
380 if (empty($pf['label'])) {
381 $pf['label'] = ucfirst($pf['name']);
382 }
383 $this->jltma_apply_control($this->control_manager->build_control_config($pf_key, $pf, strtoupper($pf['type'])));
384 }
385 $this->end_popover();
386 }
387
388
389 /**
390 * Apply a control descriptor returned by Control_Manager::build_control_config().
391 * Descriptor: ['key' => string, 'responsive' => bool, 'args' => array,
392 * optional 'method' => 'add_group_control', 'group_type' => string].
393 */
394 private function jltma_apply_control($descriptor) {
395 if (empty($descriptor) || empty($descriptor['key']) || !isset($descriptor['args'])) {
396 return;
397 }
398
399 // Group controls (pro) signal a different registration method.
400 if (!empty($descriptor['method']) && 'add_group_control' === $descriptor['method'] && !empty($descriptor['group_type'])) {
401 $this->add_group_control($descriptor['group_type'], $descriptor['args']);
402 return;
403 }
404
405 // Repeater: build an \Elementor\Repeater, add its sub-controls, then register.
406 if (!empty($descriptor['method']) && 'repeater' === $descriptor['method']) {
407 $repeater = new \Elementor\Repeater();
408 foreach (($descriptor['sub_controls'] ?? []) as $sub) {
409 if (!empty($sub['name'])) {
410 $repeater->add_control($sub['name'], $sub['args']);
411 }
412 }
413 $args = $descriptor['args'];
414 $args['fields'] = $repeater->get_controls();
415 $this->add_control($descriptor['key'], $args);
416 return;
417 }
418
419 $method = !empty($descriptor['responsive']) ? 'add_responsive_control' : 'add_control';
420 $this->{$method}($descriptor['key'], $descriptor['args']);
421 }
422
423 private function jltma_preprocess_date_time($field) {
424 $picker = [];
425 $enable_time = isset($field['enable_time']) ? (bool) $field['enable_time'] : false;
426 if (isset($field['enable_time'])) {
427 $picker['enableTime'] = $enable_time;
428 }
429 $picker['dateFormat'] = $enable_time ? 'Y-m-d H:i' : 'Y-m-d';
430 $picker['time_24hr'] = true;
431 if (!empty($field['minute_increment'])) {
432 $picker['minuteIncrement'] = intval($field['minute_increment']);
433 }
434 if (!empty($field['picker_options']) && is_array($field['picker_options'])) {
435 $picker = array_merge($field['picker_options'], $picker);
436 }
437 if (!empty($picker)) {
438 $field['picker_options'] = $picker;
439 }
440 return $field;
441 }
442
443 /** Matches Control_Base::sanitize_key()/generator (spaces -> underscores). */
444 private function jltma_sanitize_key($label) {
445 $key = strtolower($label);
446 $key = str_replace(' ', '_', $key);
447 $key = preg_replace('/[^a-z0-9_]/', '', $key);
448 $key = preg_replace('/_+/', '_', $key);
449 return trim($key, '_');
450 }
451
452 /* ------------------------------------------------------------------ *
453 * Render (runtime; parity with Widget_Generator::build_render value output)
454 * ------------------------------------------------------------------ */
455
456 protected function render() {
457 $settings = $this->get_settings_for_display();
458 if (!is_array($settings)) {
459 $settings = [];
460 }
461 $mapping = $this->jltma_build_control_mapping();
462 $context = $this->jltma_build_context($settings, $mapping);
463
464 $html = isset($this->jltma_data['html_code']) ? (string) $this->jltma_data['html_code'] : '';
465 $css = isset($this->jltma_data['css_code']) ? (string) $this->jltma_data['css_code'] : '';
466 $js = isset($this->jltma_data['js_code']) ? (string) $this->jltma_data['js_code'] : '';
467
468 // Inline CSS (template rendered, values escaped).
469 if ('' !== trim($css)) {
470 echo '<style>' . $this->jltma_render_template($css, $context) . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value substitution escaped per-output; CSS body is plugin-sanitized data
471 }
472
473 // HTML body.
474 echo $this->jltma_render_template($html, $context); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value substitution escaped per-output; HTML body is plugin-sanitized data
475
476 // Inline JS.
477 if ('' !== trim($js)) {
478 echo '<script>' . $this->jltma_render_template($js, $context) . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value substitution escaped per-output; JS body is plugin-sanitized data
479 }
480 }
481
482 /** Build the template variable context: control name => resolved value. */
483 private function jltma_build_context($settings, $mapping) {
484 $context = [];
485 foreach ($mapping as $name => $key) {
486 $context[$name] = array_key_exists($key, $settings) ? $settings[$key] : '';
487 }
488 return $context;
489 }
490
491 /** placeholder (control name) => full control key. Mirrors generator. */
492 private function jltma_build_control_mapping() {
493 $mapping = [];
494 if (empty($this->jltma_data['sections']) || !is_array($this->jltma_data['sections'])) {
495 return $mapping;
496 }
497 $tab_prefix_map = ['content' => 'jltma_content_', 'style' => 'jltma_style_', 'advanced' => 'jltma_advanced_'];
498
499 foreach ($this->jltma_data['sections'] as $section) {
500 if (!is_array($section)) {
501 continue;
502 }
503 $tab = !empty($section['tab']) ? $section['tab'] : 'content';
504 $tab_prefix = $tab_prefix_map[$tab] ?? 'jltma_content_';
505 $controls = !empty($section['controls']) ? $section['controls'] : (!empty($section['fields']) ? $section['fields'] : []);
506
507 foreach ($controls as $control) {
508 if (empty($control['name'])) {
509 continue;
510 }
511 $control_key = $tab_prefix . $this->jltma_sanitize_key($control['label'] ?? $control['name']) . '_' . $this->jltma_post_id;
512 $mapping[$control['name']] = $control_key;
513
514 if (!empty($control['type']) && 'POPOVER_TOGGLE' === strtoupper($control['type']) && !empty($control['popover_fields']) && is_array($control['popover_fields'])) {
515 foreach ($control['popover_fields'] as $pf) {
516 if (empty($pf['name'])) {
517 continue;
518 }
519 $mapping[$control['name'] . '_' . $pf['name']] = $control_key . '_' . $this->jltma_sanitize_key($pf['name']);
520 }
521 }
522 }
523 }
524 return $mapping;
525 }
526
527 private function jltma_control_type($control_name) {
528 if (empty($this->jltma_data['sections']) || !is_array($this->jltma_data['sections'])) {
529 return 'text';
530 }
531 foreach ($this->jltma_data['sections'] as $section) {
532 if (!is_array($section)) {
533 continue;
534 }
535 $controls = !empty($section['controls']) ? $section['controls'] : (!empty($section['fields']) ? $section['fields'] : []);
536 foreach ($controls as $control) {
537 if (!empty($control['name']) && $control['name'] === $control_name) {
538 return $control['type'] ?? 'text';
539 }
540 if (!empty($control['type']) && 'POPOVER_TOGGLE' === strtoupper($control['type']) && !empty($control['popover_fields'])) {
541 foreach ($control['popover_fields'] as $pf) {
542 if (!empty($pf['name']) && $control_name === $control['name'] . '_' . $pf['name']) {
543 return $pf['type'] ?? 'text';
544 }
545 }
546 }
547 }
548 }
549 return 'text';
550 }
551
552 /* ------------------------------------------------------------------ *
553 * Twig-syntax template engine (safe subset; no eval, no compiled PHP).
554 * Supports: {{ var }} {{ var.prop }} {{ var|raw }} {{ var|upper }}
555 * {% if expr %} {% elseif expr %} {% else %} {% endif %}
556 * {% for item in list %} ... {% endfor %}
557 * Conditions: == != > < >= <= and or not plus bare truthiness.
558 * All output is escaped per control type unless the |raw filter is used.
559 * ------------------------------------------------------------------ */
560
561 /** Render a template string against the variable context. */
562 private function jltma_render_template($template, $context) {
563 $template = (string) $template;
564 if ('' === $template) {
565 return '';
566 }
567 $tokens = $this->jltma_tokenize_template($template);
568 $pos = 0;
569 $ast = $this->jltma_parse_template($tokens, $pos, []);
570 return $this->jltma_eval_nodes($ast, $context);
571 }
572
573 /** Split a template into text / {{ output }} / {% tag %} tokens. */
574 private function jltma_tokenize_template($template) {
575 $parts = preg_split('/(\{%.*?%\}|\{\{.*?\}\})/s', $template, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
576 $tokens = [];
577 foreach ($parts as $part) {
578 if (preg_match('/^\{%\s*(.*?)\s*%\}$/s', $part, $m)) {
579 $inner = trim($m[1]);
580 $space = strpos($inner, ' ');
581 $keyword = (false === $space) ? $inner : substr($inner, 0, $space);
582 $expr = (false === $space) ? '' : trim(substr($inner, $space + 1));
583 $tokens[] = ['type' => 'tag', 'kw' => $keyword, 'expr' => $expr];
584 } elseif (preg_match('/^\{\{\s*(.*?)\s*\}\}$/s', $part, $m)) {
585 $tokens[] = ['type' => 'out', 'expr' => trim($m[1])];
586 } else {
587 $tokens[] = ['type' => 'text', 'value' => $part];
588 }
589 }
590 return $tokens;
591 }
592
593 /** Recursive-descent parse into an AST. Stops (without consuming) on a $stops keyword. */
594 private function jltma_parse_template($tokens, &$pos, $stops) {
595 $nodes = [];
596 $count = count($tokens);
597 while ($pos < $count) {
598 $tok = $tokens[$pos];
599 if ('text' === $tok['type']) {
600 $nodes[] = ['text', $tok['value']];
601 $pos++;
602 continue;
603 }
604 if ('out' === $tok['type']) {
605 $nodes[] = ['out', $tok['expr']];
606 $pos++;
607 continue;
608 }
609 // tag
610 $kw = $tok['kw'];
611 if (in_array($kw, $stops, true)) {
612 return $nodes; // leave $pos on the stop tag for the caller
613 }
614 if ('if' === $kw) {
615 $pos++;
616 $branches = [];
617 $cond = $tok['expr'];
618 while (true) {
619 $body = $this->jltma_parse_template($tokens, $pos, ['elseif', 'else', 'endif']);
620 $branches[] = [$cond, $body];
621 if ($pos >= $count) {
622 break;
623 }
624 $next = $tokens[$pos];
625 if ('endif' === $next['kw']) {
626 $pos++;
627 break;
628 }
629 if ('elseif' === $next['kw']) {
630 $cond = $next['expr'];
631 $pos++;
632 continue;
633 }
634 if ('else' === $next['kw']) {
635 $cond = '__else__';
636 $pos++;
637 continue;
638 }
639 break;
640 }
641 $nodes[] = ['if', $branches];
642 continue;
643 }
644 if ('for' === $kw) {
645 $pos++;
646 $body = $this->jltma_parse_template($tokens, $pos, ['endfor']);
647 if ($pos < $count && 'endfor' === $tokens[$pos]['kw']) {
648 $pos++;
649 }
650 $nodes[] = ['for', $tok['expr'], $body];
651 continue;
652 }
653 // stray close/else with no opener -> skip
654 $pos++;
655 }
656 return $nodes;
657 }
658
659 /** Evaluate an AST node list to a string. */
660 private function jltma_eval_nodes($nodes, $context) {
661 $out = '';
662 foreach ($nodes as $node) {
663 switch ($node[0]) {
664 case 'text':
665 $out .= $node[1];
666 break;
667 case 'out':
668 $out .= $this->jltma_render_output($node[1], $context);
669 break;
670 case 'if':
671 foreach ($node[1] as $branch) {
672 if ('__else__' === $branch[0] || $this->jltma_eval_condition($branch[0], $context)) {
673 $out .= $this->jltma_eval_nodes($branch[1], $context);
674 break;
675 }
676 }
677 break;
678 case 'for':
679 if (preg_match('/^(\w+)\s+in\s+(.+)$/s', trim($node[1]), $m)) {
680 $list = $this->jltma_resolve_value(trim($m[2]), $context);
681 if (is_array($list)) {
682 foreach ($list as $row) {
683 $scope = $context;
684 $scope[$m[1]] = $row;
685 $out .= $this->jltma_eval_nodes($node[2], $scope);
686 }
687 }
688 }
689 break;
690 }
691 }
692 return $out;
693 }
694
695 /** Resolve an expression to its raw value: literal, number, bool, or dotted var path. */
696 private function jltma_resolve_value($expr, $context) {
697 $expr = trim($expr);
698 if ('' === $expr) {
699 return null;
700 }
701 $first = $expr[0];
702 $last = substr($expr, -1);
703 if (('"' === $first && '"' === $last) || ("'" === $first && "'" === $last)) {
704 return substr($expr, 1, -1);
705 }
706 if (is_numeric($expr)) {
707 return $expr + 0;
708 }
709 if ('true' === $expr) {
710 return true;
711 }
712 if ('false' === $expr) {
713 return false;
714 }
715 if ('null' === $expr) {
716 return null;
717 }
718 $value = $context;
719 foreach (explode('.', $expr) as $part) {
720 if (is_array($value) && array_key_exists($part, $value)) {
721 $value = $value[$part];
722 } else {
723 return null;
724 }
725 }
726 return $value;
727 }
728
729 /** Evaluate a boolean condition (or / and / not / comparison / truthiness). */
730 private function jltma_eval_condition($expr, $context) {
731 $expr = trim($expr);
732 if ('__else__' === $expr || 'true' === $expr) {
733 return true;
734 }
735 if ('' === $expr || 'false' === $expr) {
736 return false;
737 }
738 // or (lowest precedence)
739 $parts = preg_split('/\s+or\s+/', $expr);
740 if (count($parts) > 1) {
741 foreach ($parts as $part) {
742 if ($this->jltma_eval_condition($part, $context)) {
743 return true;
744 }
745 }
746 return false;
747 }
748 // and
749 $parts = preg_split('/\s+and\s+/', $expr);
750 if (count($parts) > 1) {
751 foreach ($parts as $part) {
752 if (!$this->jltma_eval_condition($part, $context)) {
753 return false;
754 }
755 }
756 return true;
757 }
758 // not
759 if (preg_match('/^not\s+(.+)$/s', $expr, $m)) {
760 return !$this->jltma_eval_condition($m[1], $context);
761 }
762 // comparison (longest operators tried first via alternation order)
763 if (preg_match('/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/s', $expr, $m)) {
764 return $this->jltma_compare(
765 $this->jltma_resolve_value($m[1], $context),
766 $this->jltma_resolve_value($m[3], $context),
767 $m[2]
768 );
769 }
770 // bare truthiness
771 return $this->jltma_truthy($this->jltma_resolve_value($expr, $context));
772 }
773
774 /** Compare two resolved values; numeric when both numeric, else string. */
775 private function jltma_compare($a, $b, $op) {
776 if (is_numeric($a) && is_numeric($b)) {
777 $a += 0;
778 $b += 0;
779 } else {
780 $a = (string) $a;
781 $b = (string) $b;
782 }
783 switch ($op) {
784 case '==':
785 return $a == $b; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- template equality is intentionally loose
786 case '!=':
787 return $a != $b; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- template inequality is intentionally loose
788 case '>':
789 return $a > $b;
790 case '<':
791 return $a < $b;
792 case '>=':
793 return $a >= $b;
794 case '<=':
795 return $a <= $b;
796 }
797 return false;
798 }
799
800 /** Twig/Handlebars truthiness: '', '0', 0, null, false, [] are falsy. */
801 private function jltma_truthy($value) {
802 if (null === $value || false === $value) {
803 return false;
804 }
805 if (is_array($value)) {
806 return !empty($value);
807 }
808 $string = (string) $value;
809 return '' !== $string && '0' !== $string;
810 }
811
812 /** Render a {{ output }} expression: resolve, apply filters, escape per type. */
813 private function jltma_render_output($expr, $context) {
814 $segments = array_map('trim', explode('|', trim($expr)));
815 $base = array_shift($segments);
816 $value = $this->jltma_resolve_value($base, $context);
817
818 if (is_array($value)) {
819 $value = isset($value['url']) ? $value['url'] : '';
820 }
821 $value = (string) $value;
822
823 $raw = false;
824 foreach ($segments as $filter) {
825 switch ($filter) {
826 case 'raw':
827 $raw = true;
828 break;
829 case 'e':
830 case 'escape':
831 $raw = false;
832 break;
833 case 'upper':
834 $value = strtoupper($value);
835 break;
836 case 'lower':
837 $value = strtolower($value);
838 break;
839 case 'trim':
840 $value = trim($value);
841 break;
842 }
843 }
844 if ($raw) {
845 return $value;
846 }
847 $name = explode('.', $base)[0];
848 $type = strtolower($this->jltma_control_type($name));
849 return $this->jltma_escape_value($value, $type);
850 }
851
852 private function jltma_escape_value($value, $type) {
853 switch ($type) {
854 case 'wysiwyg':
855 case 'code':
856 return wp_kses_post($value);
857 case 'url':
858 return esc_url($value);
859 default:
860 return esc_html($value);
861 }
862 }
863 }
864 }
865