PluginProbe
Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits / 3.1.9
Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits v3.1.9
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.9, at inc/admin/widget-builder/class-dynamic-widget.php

854 lines 36.5 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 Widget_Template_Engine|null Built lazily on first render. */
35 private $jltma_template_engine = null;
36
37 /** @var \MasterAddons\Inc\Admin\WidgetBuilder\Control_Manager */
38 private $control_manager;
39
40 /**
41 * Elementor instantiates widgets as `new Class($data, $args)`, so the
42 * signature MUST match. The widget post id is derived from the registration
43 * args (type instance) or from the element's widgetType `jltma_wb_{id}`
44 * (rendered element) — never from a positional id argument.
45 *
46 * @param array $data Elementor element data.
47 * @param array|null $args Elementor element/registration args.
48 */
49 public function __construct($data = [], $args = null) {
50 $this->jltma_post_id = $this->jltma_resolve_post_id($data, $args);
51 $this->jltma_load_data();
52
53 require_once __DIR__ . '/class-control-manager.php';
54 $this->control_manager = Control_Manager::get_instance();
55
56 // Register external libraries declared in the widget's includes.
57 $this->jltma_register_external_libraries();
58
59 parent::__construct($data, $args);
60 }
61
62 /**
63 * Resolve the widget post id from the registration args (jltma_post_id) or
64 * from the element's widgetType name (jltma_wb_{id}).
65 */
66 private function jltma_resolve_post_id($data, $args) {
67 if (is_array($args) && !empty($args['jltma_post_id'])) {
68 return absint($args['jltma_post_id']);
69 }
70 if (is_array($data) && !empty($data['widgetType']) && preg_match('/^jltma_wb_(\d+)$/', $data['widgetType'], $m)) {
71 return absint($m[1]);
72 }
73 return 0;
74 }
75
76 /* ------------------------------------------------------------------ *
77 * Data loading
78 * ------------------------------------------------------------------ */
79
80 private function jltma_load_data() {
81 $data = get_post_meta($this->jltma_post_id, '_jltma_widget_data', true);
82
83 // Fallback to default values if no data is found or if the data is not an array.
84 if (empty($data) || !is_array($data)) {
85 $data = [
86 'title' => get_the_title($this->jltma_post_id),
87 'icon' => 'eicon-code',
88 'category' => get_post_meta($this->jltma_post_id, '_jltma_widget_category', true) ?: 'master-addons',
89 'html_code' => '',
90 'css_code' => '',
91 'js_code' => '',
92 ];
93 }
94
95 $sections = get_post_meta($this->jltma_post_id, '_jltma_widget_sections', true);
96 $data['sections'] = (!empty($sections) && is_array($sections)) ? $sections : [];
97
98 // Premium-gated and normalised; free builds resolve to an empty set.
99 $data['includes'] = Widget_Builder_Init::get_widget_includes($this->jltma_post_id);
100
101 $this->jltma_data = $data;
102 }
103
104 /* ------------------------------------------------------------------ *
105 * Widget identity
106 * ------------------------------------------------------------------ */
107
108 public function get_name() {
109 return 'jltma_wb_' . $this->jltma_post_id;
110 }
111
112 public function get_title() {
113 $title = !empty($this->jltma_data['title']) ? $this->jltma_data['title'] : 'Custom Widget';
114 // translators: dynamic user-defined widget title.
115 return esc_html($title);
116 }
117
118 public function get_icon() {
119 $icon = !empty($this->jltma_data['icon']) ? $this->jltma_data['icon'] : 'eicon-code';
120 return sanitize_text_field($icon);
121 }
122
123 public function get_categories() {
124 $category = !empty($this->jltma_data['category']) ? $this->jltma_data['category'] : 'master-addons';
125 return [sanitize_text_field($category)];
126 }
127
128 public function get_style_depends() {
129 return wp_list_pluck($this->jltma_data['includes']['css_libraries'], 'handle');
130 }
131
132 public function get_script_depends() {
133 return wp_list_pluck($this->jltma_data['includes']['js_libraries'], 'handle');
134 }
135
136 /**
137 * Opt out of Elementor's static element cache.
138 *
139 * A cached element is stored as finished HTML and its render() is never run
140 * again, which would silently drop the custom JS this widget queues for the
141 * footer. Reporting the widget as dynamic makes Elementor cache it as a
142 * re-rendered [elementor-element] placeholder instead, so render() runs on
143 * every request — the same treatment core gives Counter, Tabs and the other
144 * stateful widgets.
145 *
146 * Frontend only. Document::print_elements() turns the placeholder path on
147 * for the editor and preview too, but nothing expands the shortcode there,
148 * so the widget would render as an empty canvas while editing.
149 *
150 * @return bool
151 */
152 protected function is_dynamic_content(): bool {
153 return !Widget_Builder_Init::is_editor_context();
154 }
155
156 /**
157 * Register the external CSS/JS libraries declared via the widget's includes.
158 *
159 * The list is already premium-gated, normalised and URL-validated by
160 * Widget_Builder_Init::get_widget_includes(), so free builds register
161 * nothing here. The widget's own CSS/JS is emitted by render(), not from a
162 * file.
163 */
164 private function jltma_register_external_libraries() {
165 foreach ($this->jltma_data['includes']['css_libraries'] as $lib) {
166 wp_register_style($lib['handle'], $lib['src'], $lib['dependencies'], '1.0.0');
167 }
168 foreach ($this->jltma_data['includes']['js_libraries'] as $lib) {
169 wp_register_script($lib['handle'], $lib['src'], $lib['dependencies'], '1.0.0', true);
170 }
171 }
172
173 /* ------------------------------------------------------------------ *
174 * Controls (runtime; parity with Widget_Generator::build_register_controls)
175 * ------------------------------------------------------------------ */
176
177 protected function register_controls() {
178 $this->jltma_used_keys = [];
179
180 if (empty($this->jltma_data['sections']) || !is_array($this->jltma_data['sections'])) {
181 return;
182 }
183
184 foreach ($this->jltma_sort_sections($this->jltma_data['sections']) as $section_id => $section) {
185 if (is_array($section)) {
186 $this->jltma_register_section($section_id, $section);
187 }
188 }
189 }
190
191 /** Order: content, style, advanced (matches generator). */
192 private function jltma_sort_sections($sections) {
193 $content = $style = $advanced = [];
194 foreach ($sections as $id => $section) {
195 if (!is_array($section)) {
196 continue;
197 }
198 $tab = !empty($section['tab']) ? $section['tab'] : 'content';
199 if ('style' === $tab) {
200 $style[$id] = $section;
201 } elseif ('advanced' === $tab) {
202 $advanced[$id] = $section;
203 } else {
204 $content[$id] = $section;
205 }
206 }
207 return $content + $style + $advanced;
208 }
209
210 private function jltma_register_section($section_id, $section) {
211 $label = !empty($section['title']) ? $section['title'] : (!empty($section['label']) ? $section['label'] : 'Section');
212 $tab = !empty($section['tab']) ? $section['tab'] : 'content';
213
214 $tab_prefix = 'jltma_content_';
215 $tab_const = \Elementor\Controls_Manager::TAB_CONTENT;
216 if ('style' === $tab) {
217 $tab_prefix = 'jltma_style_';
218 $tab_const = \Elementor\Controls_Manager::TAB_STYLE;
219 } elseif ('advanced' === $tab) {
220 $tab_prefix = 'jltma_advanced_';
221 $tab_const = \Elementor\Controls_Manager::TAB_ADVANCED;
222 }
223
224 $section_key = $tab_prefix . $this->jltma_sanitize_key($label) . '_' . $section_id . '_' . $this->jltma_post_id;
225
226 $this->start_controls_section($section_key, [
227 // translators: dynamic user-defined section label.
228 'label' => esc_html($label),
229 'tab' => $tab_const,
230 ]);
231
232 $controls = !empty($section['controls']) ? $section['controls'] : (!empty($section['fields']) ? $section['fields'] : []);
233 if (!empty($controls) && is_array($controls)) {
234 foreach ($controls as $field_id => $field) {
235 $this->jltma_register_control($field_id, $field, $tab);
236 }
237 }
238
239 $this->end_controls_section();
240 }
241
242 private function jltma_register_control($field_id, $field, $tab) {
243 $type = !empty($field['type']) ? strtoupper($field['type']) : 'TEXT';
244 $tab_prefix = $this->jltma_tab_prefix($tab);
245 $locked = $this->control_manager->is_locked_type($type);
246
247 // Locked TABS: the container itself holds no value, but its children do,
248 // and they consume control keys from the same counter. Walk them in the
249 // same order so every later control keeps the key its value is stored
250 // under — just hidden instead of editable.
251 if ($locked && 'TABS' === $type) {
252 $this->jltma_register_locked_tabs($field_id, $field, $tab, $tab_prefix);
253 return;
254 }
255
256 // TABS: structural container keyed by its own name (matches generator).
257 if ('TABS' === $type) {
258 $tabs_key = !empty($field['name']) ? $field['name'] : 'tabs_' . $field_id;
259 $this->jltma_register_tabs($tabs_key, $field, $tab, $tab_prefix);
260 return;
261 }
262
263 $label = !empty($field['label']) ? $field['label'] : 'Control';
264 $control_key = $this->jltma_make_control_key($label, $tab_prefix);
265 $field = $this->jltma_inject_context($field, $tab, $tab_prefix, true);
266
267 // POPOVER_TOGGLE: a normal toggle control followed by a popover of child fields.
268 if ('POPOVER_TOGGLE' === $type) {
269 // Locked: no popover to open, so the children are registered hidden
270 // (keys are derived from the toggle's key, not the shared counter).
271 if ($locked) {
272 $this->jltma_apply_control($this->control_manager->build_locked_control_config($control_key, $field, $type));
273 $this->jltma_register_locked_popover_fields($control_key, $field);
274 return;
275 }
276 $this->jltma_apply_control($this->control_manager->build_control_config($control_key, $field, $type));
277 if (!empty($field['popover_fields']) && is_array($field['popover_fields'])) {
278 $this->jltma_register_popover_fields($control_key, $field, $tab, $tab_prefix);
279 }
280 return;
281 }
282
283 // REPEATER: descriptor carries sub-controls; jltma_apply_control builds the Repeater.
284 if ('REPEATER' === $type) {
285 $this->jltma_apply_control($this->control_manager->build_control_config($control_key, $field, $type));
286 return;
287 }
288
289 if ('DATE_TIME' === $type) {
290 $field = $this->jltma_preprocess_date_time($field);
291 }
292
293 $this->jltma_apply_control($this->control_manager->build_control_config($control_key, $field, $type));
294 }
295
296 private function jltma_tab_prefix($tab) {
297 if ('style' === $tab) {
298 return 'jltma_style_';
299 }
300 if ('advanced' === $tab) {
301 return 'jltma_advanced_';
302 }
303 return 'jltma_content_';
304 }
305
306 /** Deterministic unique control key (matches generator's tab-prefix + slug + counter). */
307 private function jltma_make_control_key($label, $tab_prefix) {
308 $slug = $this->jltma_sanitize_key($label);
309 $key = $tab_prefix . $slug . '_' . $this->jltma_post_id;
310 $c = 1;
311 while (in_array($key, $this->jltma_used_keys, true)) {
312 $key = $tab_prefix . $slug . '_' . $c . '_' . $this->jltma_post_id;
313 $c++;
314 }
315 $this->jltma_used_keys[] = $key;
316 return $key;
317 }
318
319 /** Inject the context the control builders use for condition-key conversion. */
320 private function jltma_inject_context($field, $tab, $tab_prefix, $with_sections = true) {
321 $field['_tab'] = $tab;
322 $field['_widget_id'] = $this->jltma_post_id;
323 $field['_tab_prefix'] = $tab_prefix;
324 if ($with_sections) {
325 $field['_sections_data'] = $this->jltma_data['sections'] ?? [];
326 }
327 return $field;
328 }
329
330 /**
331 * Normalize a TABS control descriptor into a list of tabs, each:
332 * ['name' => string, 'label' => string, 'controls' => [ <child field defs> ]].
333 * Accepts the processed shape ('tabs' array) or the raw UI shape
334 * ('fields' + 'tab_fields'). Used by both registration and context building
335 * so the two stay in lockstep.
336 */
337 private function jltma_extract_tabs($field) {
338 if (!empty($field['tabs']) && is_array($field['tabs'])) {
339 return $field['tabs'];
340 }
341 $tabs = [];
342 if (!empty($field['fields']) && is_array($field['fields'])) {
343 $tab_fields = (!empty($field['tab_fields']) && is_array($field['tab_fields'])) ? $field['tab_fields'] : [];
344 foreach ($field['fields'] as $td) {
345 if (empty($td['name'])) {
346 continue;
347 }
348 $tn = $td['name'];
349 $tabs[] = [
350 'name' => $tn,
351 'label' => !empty($td['label']) ? $td['label'] : ucfirst($tn),
352 'controls' => (!empty($tab_fields[$tn]) && is_array($tab_fields[$tn])) ? $tab_fields[$tn] : [],
353 ];
354 }
355 }
356 return $tabs;
357 }
358
359 /** Register a TABS structural control (ports Tabs::build to runtime calls). */
360 private function jltma_register_tabs($tabs_key, $field, $tab, $tab_prefix) {
361 $tabs = $this->jltma_extract_tabs($field);
362
363 if (empty($tabs)) {
364 return;
365 }
366
367 $this->start_controls_tabs($tabs_key);
368
369 foreach ($tabs as $tab_index => $tabdef) {
370 if (empty($tabdef['name']) || empty($tabdef['label'])) {
371 continue;
372 }
373 $tab_key = $tabs_key . '_tab_' . $tabdef['name'];
374 $tab_label = !empty($tabdef['label']) ? $tabdef['label'] : 'Tab ' . ($tab_index + 1);
375
376 $tab_args = [
377 // translators: dynamic user-defined tab label.
378 'label' => esc_html($tab_label),
379 ];
380 if (!empty($tabdef['condition']) && is_array($tabdef['condition'])) {
381 $tab_args['condition'] = $tabdef['condition'];
382 }
383
384 $this->start_controls_tab($tab_key, $tab_args);
385
386 if (!empty($tabdef['controls']) && is_array($tabdef['controls'])) {
387 foreach ($tabdef['controls'] as $child) {
388 $this->jltma_register_child_control($child, $tab, $tab_prefix);
389 }
390 }
391
392 $this->end_controls_tab();
393 }
394
395 $this->end_controls_tabs();
396 }
397
398 /** Register a control nested inside a tab (no section context, no date_time preprocess; matches generator). */
399 private function jltma_register_child_control($child, $tab, $tab_prefix) {
400 if (empty($child['type']) || empty($child['name'])) {
401 return;
402 }
403 $label = !empty($child['label']) ? $child['label'] : $child['name'];
404 $key = $this->jltma_make_control_key($label, $tab_prefix);
405 $child = $this->jltma_inject_context($child, $tab, $tab_prefix, false);
406 $this->jltma_apply_control($this->control_manager->build_control_config($key, $child, strtoupper($child['type'])));
407 }
408
409 /**
410 * Register a locked TABS control: one notice, then every child as a hidden
411 * control, walked in the same order jltma_register_tabs() would use so the
412 * shared key counter advances identically.
413 */
414 private function jltma_register_locked_tabs($field_id, $field, $tab, $tab_prefix) {
415 $notice_key = $tab_prefix . 'jltma_pro_locked_' . $this->jltma_sanitize_key((string) $field_id) . '_' . $this->jltma_post_id;
416 $descriptor = $this->control_manager->build_locked_control_config($notice_key, $field, 'TABS');
417
418 if (!empty($descriptor['notice'])) {
419 $this->add_control($descriptor['notice']['key'], $descriptor['notice']['args']);
420 }
421
422 foreach ($this->jltma_extract_tabs($field) as $tab_def) {
423 foreach (($tab_def['controls'] ?? []) as $child) {
424 if (empty($child['type']) || empty($child['name'])) {
425 continue;
426 }
427 $label = !empty($child['label']) ? $child['label'] : $child['name'];
428 $key = $this->jltma_make_control_key($label, $tab_prefix);
429 $this->jltma_apply_control(
430 $this->control_manager->build_locked_control_config($key, $child, strtoupper($child['type']), false)
431 );
432 }
433 }
434 }
435
436 /** Register a locked popover's child fields as hidden value carriers. */
437 private function jltma_register_locked_popover_fields($control_key, $field) {
438 if (empty($field['popover_fields']) || !is_array($field['popover_fields'])) {
439 return;
440 }
441
442 foreach ($field['popover_fields'] as $pf) {
443 if (empty($pf['name']) || empty($pf['type'])) {
444 continue;
445 }
446 $pf_key = $control_key . '_' . $this->jltma_sanitize_key($pf['name']);
447 $this->jltma_apply_control(
448 $this->control_manager->build_locked_control_config($pf_key, $pf, strtoupper($pf['type']), false)
449 );
450 }
451 }
452
453 /** Register popover child fields (start_popover / children / end_popover). */
454 private function jltma_register_popover_fields($control_key, $field, $tab, $tab_prefix) {
455 $this->start_popover();
456 foreach ($field['popover_fields'] as $pf) {
457 if (empty($pf['name']) || empty($pf['type'])) {
458 continue;
459 }
460 $pf_key = $control_key . '_' . $this->jltma_sanitize_key($pf['name']);
461 $pf = $this->jltma_inject_context($pf, $tab, $tab_prefix, false);
462 if (empty($pf['label'])) {
463 $pf['label'] = ucfirst($pf['name']);
464 }
465 $this->jltma_apply_control($this->control_manager->build_control_config($pf_key, $pf, strtoupper($pf['type'])));
466 }
467 $this->end_popover();
468 }
469
470
471 /**
472 * Apply a control descriptor returned by Control_Manager::build_control_config().
473 * Descriptor: ['key' => string, 'responsive' => bool, 'args' => array,
474 * optional 'method' => 'add_group_control', 'group_type' => string].
475 */
476 private function jltma_apply_control($descriptor) {
477 if (empty($descriptor) || empty($descriptor['key']) || !isset($descriptor['args'])) {
478 return;
479 }
480
481 // Locked premium control: the "(Pro)" lock badge, then a hidden control
482 // that keeps the stored value flowing to render().
483 if (!empty($descriptor['method']) && 'locked' === $descriptor['method']) {
484 if (!empty($descriptor['notice'])) {
485 $notice = $descriptor['notice'];
486 $method = !empty($notice['responsive']) ? 'add_responsive_control' : 'add_control';
487 $this->{$method}($notice['key'], $notice['args']);
488 }
489 $this->add_control($descriptor['key'], $descriptor['args']);
490 return;
491 }
492
493 // Group controls (pro) signal a different registration method.
494 if (!empty($descriptor['method']) && 'add_group_control' === $descriptor['method'] && !empty($descriptor['group_type'])) {
495 $this->add_group_control($descriptor['group_type'], $descriptor['args']);
496 return;
497 }
498
499 // Repeater: build an \Elementor\Repeater, add its sub-controls, then register.
500 if (!empty($descriptor['method']) && 'repeater' === $descriptor['method']) {
501 $repeater = new \Elementor\Repeater();
502 foreach (($descriptor['sub_controls'] ?? []) as $sub) {
503 if (!empty($sub['name'])) {
504 $repeater->add_control($sub['name'], $sub['args']);
505 }
506 }
507 $args = $descriptor['args'];
508 $args['fields'] = $repeater->get_controls();
509 $this->add_control($descriptor['key'], $args);
510 return;
511 }
512
513 $method = !empty($descriptor['responsive']) ? 'add_responsive_control' : 'add_control';
514 $this->{$method}($descriptor['key'], $descriptor['args']);
515 }
516
517 private function jltma_preprocess_date_time($field) {
518 $picker = [];
519 $enable_time = isset($field['enable_time']) ? (bool) $field['enable_time'] : false;
520 if (isset($field['enable_time'])) {
521 $picker['enableTime'] = $enable_time;
522 }
523 $picker['dateFormat'] = $enable_time ? 'Y-m-d H:i' : 'Y-m-d';
524 $picker['time_24hr'] = true;
525 if (!empty($field['minute_increment'])) {
526 $picker['minuteIncrement'] = intval($field['minute_increment']);
527 }
528 if (!empty($field['picker_options']) && is_array($field['picker_options'])) {
529 $picker = array_merge($field['picker_options'], $picker);
530 }
531 if (!empty($picker)) {
532 $field['picker_options'] = $picker;
533 }
534 return $field;
535 }
536
537 /** Matches Control_Base::sanitize_key()/generator (spaces -> underscores). */
538 private function jltma_sanitize_key($label) {
539 $key = strtolower($label);
540 $key = str_replace(' ', '_', $key);
541 $key = preg_replace('/[^a-z0-9_]/', '', $key);
542 $key = preg_replace('/_+/', '_', $key);
543 return trim($key, '_');
544 }
545
546 /* ------------------------------------------------------------------ *
547 * Render (runtime; parity with Widget_Generator::build_render value output)
548 * ------------------------------------------------------------------ */
549
550 protected function render() {
551 $settings = $this->get_settings_for_display();
552 if (!is_array($settings)) {
553 $settings = [];
554 }
555 $mapping = $this->jltma_build_control_mapping();
556 $context = $this->jltma_build_context($settings, $mapping);
557
558 // TABS values: nested so templates read {{ <tabs>.<tab>.<field> }}
559 // e.g. {{ tabs.tab_1.person }}. Overwrites the scalar placeholder the
560 // TABS control name would otherwise hold (a structural control has no value).
561 foreach ($this->jltma_build_tabs_data($settings) as $tabs_key => $tabs_value) {
562 $context[$tabs_key] = $tabs_value;
563 }
564
565 $html = isset($this->jltma_data['html_code']) ? (string) $this->jltma_data['html_code'] : '';
566 $css = isset($this->jltma_data['css_code']) ? (string) $this->jltma_data['css_code'] : '';
567 $js = isset($this->jltma_data['js_code']) ? (string) $this->jltma_data['js_code'] : '';
568
569 // Inline CSS. The stored body is PHP-/tag-stripped on save; placeholder
570 // substitution is escaped per output by the template renderer, so only
571 // the <style> wrapper is added here.
572 $css_rendered = ('' !== trim($css)) ? $this->jltma_render_template($css, $context) : '';
573 if ('' !== trim($css_rendered)) {
574 echo '<style>' . $css_rendered . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plugin-sanitized CSS body; value substitution escaped per-output
575 }
576
577 // HTML body.
578 echo $this->jltma_render_template($html, $context); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value substitution escaped per-output; HTML body is plugin-sanitized data
579
580 // Custom JS. Raw JS is only ever persisted for users with unfiltered_html
581 // — see REST_Controller::save_widget_data().
582 //
583 // On the frontend it is queued for the footer rather than echoed here: an
584 // inline <script> in this position sits inside the_content, where core's
585 // convert_chars() rewrites every `&` to `&#038;` (it does not skip script
586 // blocks), which breaks any `&&` on parse.
587 //
588 // The editor canvas needs the opposite: it swaps a widget's markup on
589 // every edit without reloading the document, so a footer script would run
590 // once and never again — leaving anything the script reveals invisible.
591 // Editor output is not run through the_content, so inline is safe there.
592 $js_rendered = ('' !== trim($js)) ? $this->jltma_render_template($js, $context) : '';
593 if ('' !== trim($js_rendered)) {
594 if (Widget_Builder_Init::is_editor_context()) {
595 echo '<script>' . $js_rendered . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plugin-sanitized JS body; value substitution escaped per-output
596 } else {
597 Widget_Builder_Init::enqueue_inline_js($js_rendered, $this->jltma_post_id);
598 }
599 }
600 }
601
602 /** Build the template variable context: control name => resolved value. */
603 private function jltma_build_context($settings, $mapping) {
604 $context = [];
605 foreach ($mapping as $name => $key) {
606 $context[$name] = array_key_exists($key, $settings) ? $settings[$key] : '';
607 }
608 return $context;
609 }
610
611 /** placeholder (control name) => full control key. Mirrors generator. */
612 private function jltma_build_control_mapping() {
613 $mapping = [];
614 if (empty($this->jltma_data['sections']) || !is_array($this->jltma_data['sections'])) {
615 return $mapping;
616 }
617 $tab_prefix_map = ['content' => 'jltma_content_', 'style' => 'jltma_style_', 'advanced' => 'jltma_advanced_'];
618
619 foreach ($this->jltma_data['sections'] as $section) {
620 if (!is_array($section)) {
621 continue;
622 }
623 $tab = !empty($section['tab']) ? $section['tab'] : 'content';
624 $tab_prefix = $tab_prefix_map[$tab] ?? 'jltma_content_';
625 $controls = !empty($section['controls']) ? $section['controls'] : (!empty($section['fields']) ? $section['fields'] : []);
626
627 foreach ($controls as $control) {
628 if (empty($control['name'])) {
629 continue;
630 }
631 $control_key = $tab_prefix . $this->jltma_sanitize_key($control['label'] ?? $control['name']) . '_' . $this->jltma_post_id;
632 $mapping[$control['name']] = $control_key;
633
634 if (!empty($control['type']) && 'POPOVER_TOGGLE' === strtoupper($control['type']) && !empty($control['popover_fields']) && is_array($control['popover_fields'])) {
635 foreach ($control['popover_fields'] as $pf) {
636 if (empty($pf['name'])) {
637 continue;
638 }
639 $mapping[$control['name'] . '_' . $pf['name']] = $control_key . '_' . $this->jltma_sanitize_key($pf['name']);
640 }
641 }
642 }
643 }
644 return $mapping;
645 }
646
647 /**
648 * Build the nested TABS context, keyed by tabs-control name:
649 * [ <tabs_name> => [ <tab_name> => [ <field_name> => value ] ] ]
650 * Templates read a single value with {{ <tabs_name>.<tab_name>.<field_name> }}
651 * (e.g. {{ tabs.tab_1.person }}) — no looping required.
652 *
653 * Tab-child control keys are counter-deduped at registration time
654 * (jltma_make_control_key), so two tabs that reuse a field name — e.g. the
655 * default "field" — resolve to ..._field_ and ..._field_1_. We replay that
656 * exact counter, walking controls in register_controls() order, so the keys
657 * we read from $settings match the ones that were registered.
658 */
659 private function jltma_build_tabs_data($settings) {
660 $data = [];
661 if (empty($this->jltma_data['sections']) || !is_array($this->jltma_data['sections'])) {
662 return $data;
663 }
664
665 // Mirror of jltma_make_control_key()'s dedup, with its own counter state.
666 $used = [];
667 $make_key = function ($label, $tab_prefix) use (&$used) {
668 $slug = $this->jltma_sanitize_key($label);
669 $key = $tab_prefix . $slug . '_' . $this->jltma_post_id;
670 $c = 1;
671 while (in_array($key, $used, true)) {
672 $key = $tab_prefix . $slug . '_' . $c . '_' . $this->jltma_post_id;
673 $c++;
674 }
675 $used[] = $key;
676 return $key;
677 };
678
679 foreach ($this->jltma_sort_sections($this->jltma_data['sections']) as $section) {
680 if (!is_array($section)) {
681 continue;
682 }
683 $tab = !empty($section['tab']) ? $section['tab'] : 'content';
684 $tab_prefix = $this->jltma_tab_prefix($tab);
685 $controls = !empty($section['controls']) ? $section['controls'] : (!empty($section['fields']) ? $section['fields'] : []);
686
687 foreach ($controls as $field_id => $control) {
688 if (empty($control['type'])) {
689 continue;
690 }
691 $type = strtoupper($control['type']);
692
693 if ('TABS' === $type) {
694 $tabs_key = !empty($control['name']) ? $control['name'] : 'tabs_' . $field_id;
695 $entry = [];
696 foreach ($this->jltma_extract_tabs($control) as $tabdef) {
697 // Same skip as jltma_register_tabs(): unregistered tabs
698 // must not advance the shared dedup counter.
699 if (empty($tabdef['name']) || empty($tabdef['label'])) {
700 continue;
701 }
702 $fields = [];
703 if (!empty($tabdef['controls']) && is_array($tabdef['controls'])) {
704 foreach ($tabdef['controls'] as $child) {
705 if (empty($child['name']) || empty($child['type'])) {
706 continue;
707 }
708 // Mirrors jltma_register_child_control()'s label fallback + key.
709 $child_label = !empty($child['label']) ? $child['label'] : $child['name'];
710 $child_key = $make_key($child_label, $tab_prefix);
711 $fields[$child['name']] = array_key_exists($child_key, $settings) ? $settings[$child_key] : '';
712 }
713 }
714 $entry[$tabdef['name']] = $fields;
715 }
716 $data[$tabs_key] = $entry;
717 continue;
718 }
719
720 // Non-tab control: advance the counter to mirror registration order
721 // (jltma_register_control() uses a 'Control' label fallback).
722 $label = !empty($control['label']) ? $control['label'] : 'Control';
723 $make_key($label, $tab_prefix);
724 }
725 }
726 return $data;
727 }
728
729 /**
730 * Resolve a tab-child field type from a dotted path (<tabs>.<tab>.<field>),
731 * so {{ tabs.tab_1.person }} escapes per the child's own control type.
732 * Returns null when the path is not a known tab-child.
733 */
734 private function jltma_tab_field_type($path) {
735 $parts = explode('.', $path);
736 if (count($parts) < 3) {
737 return null;
738 }
739 list($tabs_name, $tab_name, $field_name) = [$parts[0], $parts[1], $parts[2]];
740 if (empty($this->jltma_data['sections']) || !is_array($this->jltma_data['sections'])) {
741 return null;
742 }
743 foreach ($this->jltma_data['sections'] as $section) {
744 if (!is_array($section)) {
745 continue;
746 }
747 $controls = !empty($section['controls']) ? $section['controls'] : (!empty($section['fields']) ? $section['fields'] : []);
748 foreach ($controls as $control) {
749 if (empty($control['type']) || 'TABS' !== strtoupper($control['type']) || ($control['name'] ?? '') !== $tabs_name) {
750 continue;
751 }
752 foreach ($this->jltma_extract_tabs($control) as $tabdef) {
753 if (($tabdef['name'] ?? '') !== $tab_name) {
754 continue;
755 }
756 foreach (($tabdef['controls'] ?? []) as $child) {
757 if (($child['name'] ?? '') === $field_name) {
758 return $child['type'] ?? 'text';
759 }
760 }
761 }
762 }
763 }
764 return null;
765 }
766
767 private function jltma_control_type($control_name) {
768 // Tab-child path (<tabs>.<tab>.<field>) resolves to the child's own type.
769 if (false !== strpos($control_name, '.')) {
770 $tab_type = $this->jltma_tab_field_type($control_name);
771 if (null !== $tab_type) {
772 return $tab_type;
773 }
774 $control_name = explode('.', $control_name)[0];
775 }
776 if (empty($this->jltma_data['sections']) || !is_array($this->jltma_data['sections'])) {
777 return 'text';
778 }
779 foreach ($this->jltma_data['sections'] as $section) {
780 if (!is_array($section)) {
781 continue;
782 }
783 $controls = !empty($section['controls']) ? $section['controls'] : (!empty($section['fields']) ? $section['fields'] : []);
784 foreach ($controls as $control) {
785 if (!empty($control['name']) && $control['name'] === $control_name) {
786 return $control['type'] ?? 'text';
787 }
788 if (!empty($control['type']) && 'POPOVER_TOGGLE' === strtoupper($control['type']) && !empty($control['popover_fields'])) {
789 foreach ($control['popover_fields'] as $pf) {
790 if (!empty($pf['name']) && $control_name === $control['name'] . '_' . $pf['name']) {
791 return $pf['type'] ?? 'text';
792 }
793 }
794 }
795 }
796 }
797 return 'text';
798 }
799
800 /* ------------------------------------------------------------------ *
801 * Template rendering — delegated to Widget_Template_Engine so the editor
802 * preview and the front end run the same Twig-subset engine.
803 * ------------------------------------------------------------------ */
804
805 /** Render a template string against the variable context. */
806 private function jltma_render_template($template, $context) {
807 if (null === $this->jltma_template_engine) {
808 $this->jltma_template_engine = new Widget_Template_Engine(
809 $this->jltma_collect_control_types()
810 );
811 }
812
813 return $this->jltma_template_engine->render($template, $context);
814 }
815
816 /** Map every control name to its type, for the engine's output escaping. */
817 private function jltma_collect_control_types() {
818 $types = [];
819
820 if (empty($this->jltma_data['sections']) || !is_array($this->jltma_data['sections'])) {
821 return $types;
822 }
823
824 foreach ($this->jltma_data['sections'] as $section) {
825 if (!is_array($section)) {
826 continue;
827 }
828
829 $controls = !empty($section['controls'])
830 ? $section['controls']
831 : (!empty($section['fields']) ? $section['fields'] : []);
832
833 foreach ((array) $controls as $control) {
834 if (empty($control['name'])) {
835 continue;
836 }
837
838 $types[$control['name']] = $control['type'] ?? 'text';
839
840 if (!empty($control['popover_fields']) && is_array($control['popover_fields'])) {
841 foreach ($control['popover_fields'] as $pf) {
842 if (!empty($pf['name'])) {
843 $types[$pf['name']] = $pf['type'] ?? 'text';
844 }
845 }
846 }
847 }
848 }
849
850 return $types;
851 }
852 }
853 }
854