PluginProbe ʕ •ᴥ•ʔ
VikAppointments Services Booking Calendar / 1.2.18
VikAppointments Services Booking Calendar v1.2.18
1.2.21 1.2.20 trunk 1.2.17 1.2.18 1.2.19
vikappointments / libraries / adapter / module / widget.php
vikappointments / libraries / adapter / module Last commit date
factory.php 6 months ago helper.php 6 months ago widget.php 6 months ago
widget.php
1066 lines
1 <?php
2 /**
3 * @package VikWP - Libraries
4 * @subpackage adapter.module
5 * @author E4J s.r.l.
6 * @copyright Copyright (C) 2023 E4J s.r.l. All Rights Reserved.
7 * @license http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL
8 * @link https://vikwp.com
9 */
10
11 // No direct access
12 defined('ABSPATH') or die('No script kiddies please!');
13
14 JLoader::import('adapter.form.form');
15
16 /**
17 * Adapter class to extend WP widget functionalities.
18 *
19 * @see WP_Widget
20 * @since 10.0
21 */
22 class JWidget extends WP_Widget
23 {
24 /**
25 * Absolute module path.
26 *
27 * @var string
28 */
29 protected $_path;
30
31 /**
32 * Internal ID.
33 *
34 * @var string
35 */
36 protected $_id;
37
38 /**
39 * Widget form data.
40 *
41 * @var JForm
42 */
43 protected $_form = null;
44
45 /**
46 * The name of the plugin that owns the module.
47 *
48 * @var string
49 */
50 protected $_option = null;
51
52 /**
53 * Whether or not the widget has been registered yet.
54 *
55 * @var boolean
56 * @since 10.1.38
57 */
58 protected $registered = false;
59
60 /**
61 * An incremental counter to make sure the used ID is always unique,
62 * since Gutenberg seems to always use the number ID for the widgets
63 * published under the same page.
64 *
65 * @var int
66 * @since 10.1.59
67 */
68 protected static $incrementalCounter = 0;
69
70 /**
71 * Class constructor.
72 *
73 * @param string $path The widget absolute path.
74 *
75 * @uses loadLanguage()
76 * @uses loadXml()
77 */
78 public function __construct($path)
79 {
80 // widget ID
81 $id = basename($path);
82
83 $this->_path = $path;
84 $this->_id = $id;
85
86 /**
87 * Extract component name from path.
88 *
89 * @since 10.1.20
90 */
91 if (preg_match("/plugins[\/\\\\]([a-z0-9_]+)[\/\\\\]modules/i", $this->_path, $match))
92 {
93 $this->_option = end($match);
94 }
95
96 // load text domain
97 $this->loadLanguage($path, $id);
98
99 /**
100 * @note: translations will be available only from here.
101 */
102
103 // load widget data from XML
104 $data = $this->loadXml($path, $id);
105
106 // translate widget name
107 $name = JText::translate((string) $data->name);
108
109 // build arguments
110 $args = array();
111 $args['description'] = JText::translate((string) $data->description);
112 // $args['version'] = $data->version;
113
114 /**
115 * Since the widget description is displayed by escaping HTML tags,
116 * we should strip them in order to display a plain text.
117 *
118 * @since 10.1.21
119 */
120 $args['description'] = strip_tags($args['description']);
121
122 parent::__construct($id, $name, $args);
123
124 /**
125 * Add support for jQuery in page head every time
126 * a widget is instantiated. Proceed only in case the
127 * headers haven't been sent yet.
128 *
129 * @since 10.1.22
130 */
131 if (!headers_sent())
132 {
133 add_filter('wp_enqueue_scripts', function() {
134 wp_enqueue_script('jquery', null, [], false, false);
135 });
136 }
137 }
138
139 /**
140 * Front-end display of widget.
141 *
142 * @param array $args Widget arguments.
143 * @param array $config Saved values from database.
144 *
145 * @return void
146 */
147 public function widget($args, $config)
148 {
149 // make the module helper accessible
150 JLoader::import('adapter.module.helper');
151 JModuleHelper::setPath($this->_path);
152
153 $layout = $this->_path . DIRECTORY_SEPARATOR . $this->_id . '.php';
154
155 // check if the widget owns a layout
156 if (!JFile::exists($layout))
157 {
158 return;
159 }
160
161 // include system.js file to support JoomlaCore
162 JHtml::fetch('system.js');
163
164 /**
165 * Added support for module class suffix.
166 *
167 * @since 10.1.21
168 */
169 if (!empty($config['moduleclass_sfx']))
170 {
171 // extract class from wrapper
172 if (preg_match("/class=\"([a-z0-9_\-\s]*)\"/i", $args['before_widget'], $match))
173 {
174 // replace class attribute with previous classes and the custom suffix
175 $args['before_widget'] = str_replace($match[0], 'class="' . $match[1] . ' ' . $config['moduleclass_sfx'] . '"', $args['before_widget']);
176 }
177 }
178
179 // begin widget
180 echo $args['before_widget'];
181
182 // display the title if set
183 if (!empty($config['title']))
184 {
185 echo $args['before_title'] . apply_filters('widget_title', $config['title']) . $args['after_title'];
186 }
187
188 // wrap the $config in a registry
189 $params = new JObject($config);
190
191 /**
192 * Create $module object for accessing the widget ID.
193 *
194 * @since 10.1.30
195 */
196 $module = new stdClass;
197 $module->id = ++static::$incrementalCounter;
198
199 /**
200 * Plugins can manipulate the configuration of the widget at runtime.
201 * Fires before dispatching the widget in the front-end.
202 *
203 * @param string $id The widget ID (path name).
204 * @param JObject &$params The widget configuration registry.
205 *
206 * @since 10.1.28
207 */
208 do_action_ref_array('vik_widget_before_dispatch_site', array($this->_id, &$params));
209
210 // start buffer
211 ob_start();
212 // include layout file
213 include $layout;
214 // get contents
215 $html = ob_get_contents();
216 // clear buffer
217 ob_end_clean();
218
219 /**
220 * Plugins can manipulate here the fetched HTML of the widget.
221 * Fires before displaying the HTML of the widget in the front-end.
222 *
223 * @param string $id The widget ID (path name).
224 * @param string &$html The HTML of the widget to display.
225 *
226 * @since 10.1.28
227 */
228 do_action_ref_array('vik_widget_after_dispatch_site', array($this->_id, &$html));
229
230 // display the widget HTML
231 echo $html;
232
233 // terminate widget
234 echo $args['after_widget'];
235
236 // print JSON configuration
237 JHtml::fetch('behavior.core');
238
239 // add support for Joomla JS variable
240 JFactory::getDocument()->addScriptDeclaration(
241 <<<JS
242 if (typeof Joomla === 'undefined') {
243 var Joomla = new JoomlaCore();
244 } else {
245 // reload options
246 JoomlaCore.loadOptions();
247 }
248 JS
249 );
250 }
251
252 /**
253 * Loads widget text domain.
254 *
255 * @param string $path The widget path.
256 * @param string $id The domain name.
257 *
258 * @return void
259 */
260 private function loadLanguage($path, $id)
261 {
262 // init language
263 $lang = JFactory::getLanguage();
264
265 // search for a language handler (/language/handler.php)
266 $handler = $path . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR . 'handler.php';
267
268 if (!is_file($handler))
269 {
270 /**
271 * Try also to search within "languages" folder.
272 *
273 * @since 10.1.21
274 */
275 $handler = $path . DIRECTORY_SEPARATOR . 'languages' . DIRECTORY_SEPARATOR . 'handler.php';
276 }
277
278 if (is_file($handler))
279 {
280 // attach handler
281 $lang->attachHandler($handler, $id);
282 }
283
284 /**
285 * @since 10.0.1 It is no more needed to load the language
286 * file (.mo) of the widget as all the translations
287 * are contained within the main language file
288 * of the plugin.
289 */
290 }
291
292 /**
293 * Loads widget data from the XML installation file.
294 *
295 * @param string $path The widget path.
296 * @param string $id The widget name.
297 *
298 * @return object The XML data.
299 */
300 private function loadXml($path, $id)
301 {
302 $file = $path . DIRECTORY_SEPARATOR . $id . '.xml';
303
304 // make sure the installation file exists
305 if (!is_file($file))
306 {
307 throw new Exception('Missing installation file [' . $id . '.xml].', 404);
308 }
309
310 // load form data
311 $this->_form = JForm::getInstance($id, $file, array('client' => $this->_option));
312
313 // get XML element
314 $xml = $this->_form->getXml();
315
316 $data = new stdClass;
317
318 // iterate the args and assign them to the $data object
319 foreach (array('name', 'description') as $k)
320 {
321 $data->{$k} = (string) $xml->{$k};
322 }
323
324 return $data;
325 }
326
327 /**
328 * Back-end widget form.
329 *
330 * @param array $instance Previously saved values from database.
331 *
332 * @return void
333 */
334 public function form($instance)
335 {
336 // get form fields
337 $fields = $this->_form->getFields();
338
339 /**
340 * Add support for title field by creating a custom XML field,
341 * only if the XML of the module doesn't declare it.
342 *
343 * @since 10.1.21
344 */
345 if (!$this->_form->getField('title'))
346 {
347 // create title field
348 $title = simplexml_load_string('<field name="title" type="text" default="" label="TITLE" />');
349 // push title at the beginning of the list
350 array_unshift($fields, $title);
351 }
352
353 /**
354 * Filter the fields by removing useless settings.
355 *
356 * @since 10.1.31
357 */
358 $fields = array_filter($fields, function($field)
359 {
360 // exclude field in case it starts with "loadjquery"
361 return preg_match("/^loadjquery/", (string) $field->attributes()->name) == false;
362 });
363
364 // create layout file
365 $file = new JLayoutFile('html.widget.fieldset.open');
366
367 if ($this->_option)
368 {
369 // we found an option, add an include path to make sure layouts are accessible
370 $file->addIncludePath(implode(DIRECTORY_SEPARATOR, array(WP_PLUGIN_DIR, $this->_option, 'libraries')));
371 }
372
373 // open fieldset
374 echo $file->render();
375
376 foreach ($fields as $field)
377 {
378 $attrs = $field->attributes();
379 $name = (string) $attrs->name;
380
381 $data = array();
382 $data['id'] = $this->get_field_id($name);
383 $data['label'] = (string) $attrs->label;
384 $data['description'] = (string) $attrs->description;
385 $data['name'] = $this->get_field_name($name);
386 $data['required'] = ((string) $attrs->required) === 'true';
387
388 /**
389 * Open control only in case the input shouldn't be hidden.
390 *
391 * @since 10.1.21
392 */
393 if ($attrs->type != 'hidden' && $attrs->type != 'spacer' && empty($attrs->hidden))
394 {
395 // open control
396 $file->setLayoutId('html.widget.control.open');
397 echo $file->render($data);
398 }
399
400 if (isset($instance[$name]))
401 {
402 $data['value'] = $instance[$name];
403 }
404
405 // attach module path (useful to obtain the available layouts)
406 $data['modpath'] = $this->_path;
407 $data['modowner'] = $this->_option;
408
409 // obtain field class and display input layout
410 echo $this->_form->renderField($field, $data);
411
412 /**
413 * Close control only in case the input shouldn't be hidden.
414 *
415 * @since 10.1.21
416 */
417 if ($attrs->type != 'hidden' && $attrs->type != 'spacer' && empty($attrs->hidden))
418 {
419 // close control
420 $file->setLayoutId('html.widget.control.close');
421 echo $file->render();
422 }
423 }
424
425 // close fieldset
426 $file->setLayoutId('html.widget.fieldset.close');
427 echo $file->render();
428
429 // include form scripts
430 // $this->useScript();
431 }
432
433 /**
434 * Includes the scripts used by the form.
435 *
436 * @return void
437 */
438 protected function useScript()
439 {
440 if (wp_doing_ajax())
441 {
442 return;
443 }
444
445 $document = JFactory::getDocument();
446
447 /**
448 * Include system.js file to support JFormValidator.
449 *
450 * Since WP 5.9, the widgets resources must be loaded through the
451 * _register_one method, which seems to be invoked on every page.
452 * So, we should load them only if we are under widgets.php.
453 */
454 global $pagenow;
455 if ($pagenow === 'widgets.php')
456 {
457 JHtml::fetch('system.js');
458 }
459
460 JHtml::fetch('formbehavior.chosen');
461
462 static $loaded = 0;
463
464 // load only once
465 if (!$loaded)
466 {
467 // override getLabel() method to attach invalid
468 // class to the correct form structure
469 $document->addScriptDeclaration(
470 <<<JS
471 if (typeof JFormValidator !== 'undefined') {
472 JFormValidator.prototype.getLabel = function(input) {
473 var name = jQuery(input).attr('name');
474
475 if (this.labels.hasOwnProperty(name)) {
476 return jQuery(this.labels[name]);
477 }
478
479 return jQuery(input).parent().find('label').first();
480 }
481 }
482 JS
483 );
484 }
485
486 // load form validation
487 $document->addScriptDeclaration(
488 <<<JS
489 if (typeof VIK_WIDGET_SAVE_LOOKUP === 'undefined') {
490 var VIK_WIDGET_SAVE_LOOKUP = {};
491 }
492
493 (function($) {
494 $(document).on('widget-added', function(event, control) {
495 registerWidgetScripts($(control).find('form'));
496 });
497
498 function registerWidgetScripts(form) {
499 if (!form) {
500 // if the form was not provided, find it using the widget ID (before WP 5.8)
501 form = $('div[id$="{$this->id}"] form');
502 }
503
504 if (typeof JFormValidator !== 'undefined') {
505 // init internal validator
506 var validator = new JFormValidator(form);
507
508 // validate fields every time the SAVE button is clicked
509 form.find('input[name="savewidget"]').on('click', function(event) {
510 return validator.validate();
511 });
512 }
513
514 // init select2 on dropdown with multiple selection
515 if (jQuery.fn.select2) {
516 form.find('select[multiple]').select2({
517 width: '100%'
518 });
519 }
520
521 // initialize popover within the form
522 if (jQuery.fn.popover) {
523 form.find('.inline-popover').popover({sanitize: false, container: 'body'});
524 }
525 }
526
527 $(function() {
528 // If the widget is not a template, register the scripts.
529 // A widget template ID always ends with "__i__"
530 if (!"{$this->id}".match(/__i__$/)) {
531 registerWidgetScripts();
532 }
533
534 // Attach event to the "ADD WIDGET" button
535 $('.widgets-chooser-add').on('click', function(e) {
536 // find widget parent of the clicked button
537 var parent = this.closest('div[id$="{$this->id}"]');
538
539 if (!parent) {
540 return;
541 }
542
543 // extract ID from the template parent (exclude "__i__")
544 var id = $(parent).attr('id').match(/(.*?)__i__$/);
545
546 if (!id) {
547 return;
548 }
549
550 // register scripts with a short delay to make sure the
551 // template has been moved on the right side
552 setTimeout(function() {
553 // obtain the box that has been created
554 var createdForm = $('div[id^="' + id.pop() + '"]').last();
555
556 // find form within the box
557 var _form = $(createdForm).find('form');
558
559 // register scripts at runtime
560 registerWidgetScripts(_form);
561 }, 32);
562 });
563
564 // register save callback for this kind of widget only once
565 if (!VIK_WIDGET_SAVE_LOOKUP.hasOwnProperty('{$this->_id}')) {
566 // flag as loaded
567 VIK_WIDGET_SAVE_LOOKUP['{$this->_id}'] = 1;
568
569 // Attach event to SAVE callback
570 $(document).ajaxSuccess(function(event, xhr, settings) {
571 // make sure the request was used to save the widget settings
572 if (!settings.data || typeof settings.data !== 'string' || settings.data.indexOf('action=save-widget') === -1) {
573 // wrong request
574 return;
575 }
576
577 // extract widget ID from request
578 var widget_id = settings.data.match(/widget-id=([a-z0-9_-]+)(?:&|$)/i);
579
580 // make sure this is the widget that was saved
581 if (!widget_id) {
582 // wrong widget
583 return;
584 }
585
586 // get cleansed widget ID
587 widget_id = widget_id.pop();
588
589 // make sure the widget starts with this ID
590 if (widget_id.indexOf('{$this->_id}') !== 0) {
591 // wrong widget
592 return;
593 }
594
595 // obtain the box that has been updated
596 var updatedForm = $('div[id$="' + widget_id + '"]').find('form');
597
598 // register scripts at runtime
599 registerWidgetScripts(updatedForm);
600 });
601 }
602 });
603 })(jQuery);
604 JS
605 );
606 }
607
608 /**
609 * Sanitize widget form values as they are saved.
610 *
611 * @param array $new_instance Values just sent to be saved.
612 * @param array $old_instance Previously saved values from database.
613 *
614 * @return array Updated safe values to be saved.
615 *
616 * @since 10.1.21
617 */
618 public function update($new_instance, $old_instance)
619 {
620 if (!empty($new_instance['moduleclass_sfx']))
621 {
622 // make mod class suffix safe
623 $new_instance['moduleclass_sfx'] = preg_replace("/[^a-zA-Z0-9_\-\s]+/", '', $new_instance['moduleclass_sfx']);
624 }
625
626 return $new_instance;
627 }
628
629 /**
630 * Add hooks for enqueueing assets when registering all widget instances of this widget class.
631 *
632 * @param integer $number Optional. The unique order number of this widget instance
633 * compared to other instances of the same class. Default -1.
634 *
635 * @return void
636 *
637 * @since 10.1.38
638 */
639 public function _register_one($number = -1)
640 {
641 // invoke parent
642 parent::_register_one($number);
643
644 if (!$this->registered)
645 {
646 // load required resources
647 $this->useScript();
648
649 // flag as already registered
650 $this->registered = true;
651 }
652 }
653
654 /**
655 * Converts a WordPress widget into a WordPress block.
656 * The usage of this method requires an external script with a
657 * path built as the following one:
658 * /modules/mod_[PLUGIN]_[NAME]/[PLUGIN]-[NAME]-widget-block.js
659 *
660 * @param string $blockScriptUri The relative URI of the script declaring the tools
661 * that will be actually used by the block.
662 * @param array $data The widget manifest data (@see WP_Block_Type).
663 *
664 * @return void
665 *
666 * @since 10.1.51
667 */
668 protected function registerBlockType(string $blockScriptUri, array $data)
669 {
670 /**
671 * Make sure Gutenberg is up and running to avoid
672 * any fatal errors, as the register_block_type()
673 * function may be not available on old instances.
674 */
675 if (!function_exists('register_block_type'))
676 {
677 return;
678 }
679
680 // create a block identifier for this widget
681 $block_id = preg_replace("/^mod_{$this->_option}_/", '', $this->_id);
682 $block_id = preg_replace("/_/", '-', $block_id) . '-widget-block';
683
684 // define the block manifest
685 $data = array_merge([
686 'id' => $this->_option . '/' . $block_id,
687 'title' => $this->name,
688 'description' => $this->widget_options['description'],
689 'textdomain' => $this->_option,
690 'category' => 'widgets',
691 'attributes' => [],
692 'supports' => [
693 // do not edit as HTML
694 'html' => false,
695 // use the block just once per post
696 'multiple' => true,
697 // don't allow the block to be converted into a reusable block
698 'reusable' => false,
699 ],
700 ], $data);
701
702 $form = [];
703
704 // get form fieldsets
705 foreach ($this->_form->getFieldset() as $fieldset)
706 {
707 $set = [];
708 $set['name'] = (string) $fieldset->attributes()->name;
709 $set['title'] = JText::translate((string) $fieldset->attributes()->label ?: 'COM_MENUS_' . strtoupper($set['name']) . '_FIELDSET_LABEL');
710 $set['fields'] = [];
711
712 // take only the fields that belong to this fieldset
713 $fields = $fieldset->xpath('//fieldset[@name="' . $set['name'] . '"] //field');
714
715 /**
716 * Add support for title field by creating a custom XML field,
717 * only if the XML of the module doesn't declare it.
718 *
719 * @since 10.1.21
720 */
721 if ($set['name'] === 'basic' && !$this->_form->getField('title'))
722 {
723 // create title field
724 $title = simplexml_load_string('<field name="title" type="text" default="" label="COM_MODULES_FIELD_TITLE_LABEL" description="COM_MODULES_FIELD_TITLE_DESC" />');
725 // push title at the beginning of the list
726 array_unshift($fields, $title);
727 }
728
729 // get form fields
730 foreach ($fields as $field)
731 {
732 // get form field
733 $field = JFormField::getInstance($field);
734
735 // skip the module class suffix field as it is supported by default by Gutenberg blocks
736 if ($field->name === 'moduleclass_sfx')
737 {
738 continue;
739 }
740
741 // attach module path (useful to obtain the available layouts)
742 $field->bind($this->_path, 'modpath');
743 $field->bind($this->_option, 'modowner');
744
745 // obtain field layout data
746 $displayData = array_merge(
747 [
748 'type' => $field->type,
749 'layout' => $field->layoutId,
750 'label' => JText::translate($field->label ?? ''),
751 'description' => strip_tags(JText::translate($field->description ?? '')),
752 'showon' => $field->showon,
753 ],
754 $field->getLayoutData()
755 );
756
757 // in case the field does not provide the layout, use the HTML type
758 // and render here the input data
759 if (!$field->layoutId)
760 {
761 // convert a HTML document into a JSON-compatible structure
762 $json = $this->createElementsFromHtml($field->getInput());
763
764 // overwrite type and inject the converted structure within the layout attribute
765 $displayData['type'] = 'html';
766 $displayData['layout'] = $json;
767 }
768
769 // fetch default value
770 $default = $displayData['value'] ?? '';
771 $default = ($default !== '' && $default !== null) ? $default : ($displayData['default'] ?? ($field->multiple ? [] : ''));
772
773 // normalize options structure
774 if (isset($displayData['options']) && is_array($displayData['options']))
775 {
776 /**
777 * In case the default value is not a valid option, use the first available one.
778 *
779 * @todo In case the option is not an associative array, the following condition
780 * will not work. If we want to extend this compatibility we should manually
781 * iterate the normalized array in search of an option with matching value.
782 */
783 if (is_scalar($default) && !isset($displayData['options'][$default]))
784 {
785 $default = key($displayData['options']);
786 }
787
788 $options = [];
789
790 foreach ($displayData['options'] as $value => $label)
791 {
792 if (is_object($label) || is_array($label))
793 {
794 $label = (object) $label;
795
796 $options[] = [
797 'label' => JText::translate($label->text),
798 'value' => $label->value,
799 ];
800 }
801 else
802 {
803 $options[] = [
804 'label' => JText::translate($label),
805 'value' => $value,
806 ];
807 }
808 }
809
810 $displayData['options'] = $options;
811 }
812
813 if ($field->multiple)
814 {
815 $attrType = 'array';
816 }
817 else if ($field->type === 'radio' && $field->class === 'btn-group btn-group-yesno')
818 {
819 $attrType = 'integer';
820 }
821 else
822 {
823 $attrType = 'string';
824 }
825
826 if (!empty($displayData['name']))
827 {
828 // bind field attributes
829 $data['attributes'][$displayData['name']] = [
830 'type' => $attrType,
831 'default' => $default,
832 ];
833 }
834
835 // enqueue form field
836 $set['fields'][] = $displayData;
837 }
838
839 $form[] = $set;
840 }
841
842 // register the script declaring the reusable functions for Gutenberg
843 wp_register_script(
844 $this->_option . '-gutenberg-tools',
845 $blockScriptUri . 'js/gutenberg-tools.js',
846 ['wp-blocks', 'wp-element', 'wp-i18n'],
847 constant(strtoupper($this->_option . '_software_version'))
848 );
849
850 // register the script that contains all the JS functions used
851 // to implement a new block for Gutenberg editor
852 wp_register_script(
853 $this->_option . '-gutenberg-widgets',
854 $blockScriptUri . 'js/gutenberg-widgets.js',
855 ['wp-blocks', 'wp-element', 'wp-i18n'],
856 constant(strtoupper($this->_option . '_software_version'))
857 );
858
859 // register the script that will be used to support this widget
860 // as Gutenberg block editor
861 wp_register_script(
862 $this->_option . '-' . $block_id,
863 plugin_dir_url($this->_path) . $this->_id . '/' . $this->_option . '-' . $block_id . '.js',
864 ['wp-blocks', 'wp-element', 'wp-i18n'],
865 constant(strtoupper($this->_option . '_software_version'))
866 );
867
868 // Pass the manifest data to the script previously loaded.
869 // The object variable will be named as:
870 // MOD_[PLUGIN]_[NAME]_BLOCK_DATA
871 wp_localize_script(
872 $this->_option . '-' . $block_id,
873 strtoupper($this->_id . '_block_data'),
874 array_merge(
875 $data,
876 [
877 'form' => $form,
878 ]
879 )
880 );
881
882 // create a new block type, which must provide the scripts previously loaded
883 register_block_type($this->_option . '/' . $block_id, array_merge($data, [
884 'render_callback' => function($config) {
885 // prepare widget arguments
886 $args = [
887 'before_widget' => '<div class="widget widget_' . $this->_id . '" id="' . $this->_id . '_' . (++static::$incrementalCounter) . '">',
888 'before_title' => '<h3 class="widget-title">',
889 'after_title' => '</h3>',
890 'after_widget' => '</div>',
891 ];
892
893 // adjust widget configuration
894 $config['moduleclass_sfx'] = $config['className'] ?? '';
895
896 $requestUri = JFactory::getApplication()->input->server->getString('REQUEST_URI', '');
897
898 $is_rest_api = strpos($requestUri, trailingslashit(rest_get_url_prefix())) !== false
899 || JUri::getInstance($requestUri)->hasVar('rest_route');
900
901 // define a callback to include a placeholder in case the widget is not able to render any contents
902 $previewPlaceholderCallback = function($id, &$html) {
903 // get rid of any script and style declared by the widget layout
904 $test = preg_replace("/<script(?:.*?)>(?:.*?)<\/script>/s", '', $html);
905 $test = preg_replace("/<style(?:.*?)>(?:.*?)<\/style>/s", '', $test);
906
907 // check whether the test var contains some texts
908 if (!trim(strip_tags($test))) {
909 $html = '<div style="padding: 10px; background: #eee; border: 2px solid #ddd;">'
910 . JText::translate('COM_MODULES_PREVIEW_NOT_AVAIL')
911 . '</div>';
912 }
913 };
914
915 // start buffer
916 ob_start();
917
918 if ($is_rest_api || is_admin())
919 {
920 // overwrite the callback to register an asset declaration at runtime
921 JFactory::getDocument()->attachToHeadCustomCallback = function($callback) {
922 // prevent the system document from displaying the asset
923 };
924
925 // register a callback to display a placeholder when the widget contents are empty
926 add_action('vik_widget_after_dispatch_site', $previewPlaceholderCallback, 10, 2);
927 }
928
929 // render the widget for the front-end
930 $this->widget($args, $config);
931
932 // if we are under a REST API, the block is probably
933 // requesting a server-side rendering of the widget
934 if ($is_rest_api)
935 {
936 // force WordPress to include the styles and the scripts
937 // within the rendered HTML
938 wp_print_styles();
939
940 // DO NOT print the scripts to prevent JS errors
941 // wp_print_head_scripts();
942 }
943
944 // get contents
945 $html = ob_get_contents();
946 // clear buffer
947 ob_end_clean();
948
949 if ($is_rest_api || is_admin())
950 {
951 // get rid of any script declared by the widget layout
952 $html = preg_replace("/<script(?:.*?)>(?:.*?)<\/script>/s", '', $html);
953
954 // restore the original callback used to register the assets
955 JFactory::getDocument()->attachToHeadCustomCallback = null;
956
957 // unregister the callback used to display a placeholder when the widget contents are empty
958 remove_action('vik_widget_after_dispatch_site', $previewPlaceholderCallback);
959 }
960
961 return $html;
962 },
963 'editor_script_handles' => [
964 $this->_option . '-gutenberg-tools',
965 $this->_option . '-gutenberg-widgets',
966 $this->_option . '-' . $block_id,
967 ],
968 ]));
969 }
970
971 /**
972 * Converts an HTML string into a JSON-compatible structure.
973 *
974 * @param string $html The HTML to convert.
975 *
976 * @return object[] A list of nodes.
977 *
978 * @since 10.1.51
979 */
980 private function createElementsFromHtml(string $html)
981 {
982 // wrap the HTML into a dom document
983 $dom = new DOMDocument;
984 $dom->loadHTML($html);
985
986 // set up root
987 $root = new stdClass;
988 $root->tag = 'html';
989 $root->children = [];
990
991 // recursively extract the nodes from the document
992 $this->extractHtmlElements($dom, $root);
993
994 // take only the children of the root ("html")
995 return $root->children;
996 }
997
998 /**
999 * Extracts the HTML tags from the provided node.
1000 *
1001 * @param DOMNode $domNode The current DOM node to scan.
1002 * @param object &$parent When the extracted tags should be attached.
1003 *
1004 * @return void
1005 *
1006 * @since 10.1.51
1007 */
1008 private function extractHtmlElements(DOMNode $domNode, &$parent)
1009 {
1010 foreach ($domNode->childNodes as $node)
1011 {
1012 $tag = $parent;
1013
1014 if (!in_array($node->nodeName, ['html', 'body']))
1015 {
1016 $tag = new stdClass;
1017 $tag->tag = $node->nodeName;
1018 $tag->children = [];
1019
1020 if ($node->hasAttributes())
1021 {
1022 $tag->attributes = [];
1023
1024 foreach ($node->attributes as $attr)
1025 {
1026 if ($attr->nodeName === 'style')
1027 {
1028 $tag->attributes['style'] = [];
1029
1030 // convert style string into an associative array
1031 if (preg_match_all("/([a-z0-9-]+)\s*:\s*([^;]+);/i", (string) $attr->nodeValue, $matches))
1032 {
1033 for ($i = 0; $i < count($matches[0]); $i++)
1034 {
1035 $propertyName = $matches[1][$i];
1036 $propertyValue = $matches[2][$i];
1037
1038 $tag->attributes['style'][$propertyName] = $propertyValue;
1039 }
1040 }
1041 }
1042 else
1043 {
1044 $tag->attributes[$attr->nodeName] = $attr->nodeValue;
1045 }
1046 }
1047 }
1048
1049 if ($node->nodeName === '#text')
1050 {
1051 $parent->content = trim((string) $node->nodeValue);
1052 }
1053 else
1054 {
1055 $parent->children[] = $tag;
1056 }
1057 }
1058
1059 if ($node->hasChildNodes())
1060 {
1061 $this->extractHtmlElements($node, $tag);
1062 }
1063 }
1064 }
1065 }
1066