PluginProbe
Microthemer Lite – Visual Editor to Customize CSS / trunk
Microthemer Lite – Visual Editor to Customize CSS vtrunk
trunk 5.0.0.2
microthemer / src / Content / HTML.php

HTML.php in Microthemer Lite – Visual Editor to Customize CSS trunk, at src/Content/HTML.php

978 lines 28.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Microthemer\Content;
4
5 use \Microthemer\TimerTrait;
6
7 /*
8 * HTML
9 *
10 * HTML editor that works on the final DOM string before being served to the browser
11 */
12
13 class HTML {
14
15 use TimerTrait;
16
17 var $contentClass;
18 var $assetClass;
19 var $published;
20 var $logs = array();
21 var $doc;
22 var $xpath;
23 var $devMode = false;
24
25 public static $attributes = array(
26 'accept', 'accept-charset', 'accesskey', 'action', 'allow', 'alpha', 'alt', 'as', 'async', 'autocapitalize', 'autocomplete', 'autoplay', 'capture', 'charset', 'checked', 'cite', 'class', 'colorspace', 'cols', 'colspan', 'content', 'contenteditable', 'controls', 'coords', 'crossorigin', 'csp', 'datetime', 'decoding', 'default', 'defer', 'dir', 'dirname', 'disabled', 'download', 'draggable', 'enctype', 'enterkeyhint', 'elementtiming', 'for', 'form', 'formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'http-equiv', 'id', 'integrity', 'inputmode', 'ismap', 'itemprop', 'kind', 'label', 'lang', 'loading', 'list', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'novalidate', 'open', 'optimum', 'pattern', 'ping', 'placeholder', 'playsinline', 'poster', 'preload', 'readonly', 'referrerpolicy', 'rel', 'required', 'reversed', 'role', 'rows', 'rowspan', 'sandbox', 'scope', 'selected', 'shape', 'size', 'sizes', 'slot', 'span', 'spellcheck', 'src', 'srcdoc', 'srclang', 'srcset', 'start', 'step', 'style', 'tabindex', 'target', 'title', 'translate', 'type', 'usemap', 'value', 'width', 'wrap'
27 );
28
29 public function __construct(&$contentClass, $devMode = false) {
30 $this->contentClass = $contentClass;
31 $this->assetClass = $this->contentClass->assetClass;
32 $this->published = $this->assetClass->draft ? 0 : 1;
33 $this->devMode = $devMode;
34 }
35
36 function log($message, $data = null, $type = 'error'){
37 $this->logs[] = array_merge(
38 array(
39 'message' => $message,
40 'type' => $type,
41 'data' => $data
42 ),
43 );
44 }
45
46 // max-width < 980 (exclude desktop) min-width >= 980 (exclude mobile)
47 function excludeMediaQuery($mediaQuery, $userHasMobile){
48
49 if (empty($this->assetClass->preferences['m_queries'])){
50 return false;
51 }
52
53 // Look up the media query config array
54 // (we may have a different system in future that will make more use of User MQ config)
55 $mQueries = $this->assetClass->preferences['m_queries'];
56
57 foreach ($mQueries as $mq) {
58
59 if (isset($mq['query']) && $mq['query'] === $mediaQuery) {
60
61 $min = isset($mq['min']) ? (int)$mq['min'] : 0;
62 $max = isset($mq['max']) ? (int)$mq['max'] : 0;
63
64 // Exclude desktop-only media queries when on mobile
65 if ($userHasMobile && $min >= 980) {
66 return true;
67 }
68
69 // Exclude mobile-only media queries when on desktop
70 if (!$userHasMobile && $max > 0 && $max < 980) {
71 return true;
72 }
73
74 // Don't exclude if the query spans both (e.g., 768-1199)
75 break;
76 }
77 }
78
79 return false;
80 }
81 // Iterate mods, if they are present
82 // Only now will $modList be populated by the extraction script
83 // As the class property passed by reference has now been updated
84 function iterateMods(&$modList, &$html){
85
86 // If there are no server-side mods, and we do not need to print debug info
87 if (!count($modList) && !$this->contentClass->debugAmends){
88
89 // Return zero, if timing
90 if ($this->contentClass->timeAmends){
91 $this->contentClass->returnServerTiming(0, $html);
92 }
93
94 return;
95 }
96
97 $this->contentClass->startT('all_server_side_html_changes');
98
99 // Parse the document
100 $this->doc = new \DOMDocument();
101 $this->doc->loadHTML($html, LIBXML_NOERROR | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
102 $this->xpath = new \DOMXPath($this->doc);
103
104 // pull lazy loaded content out of DOM and store in DB
105 $lazy = array(
106 'slugs' => array(),
107 'store' => array()
108 );
109
110 // cache user's device type for adaptive resource loading / changes
111 $userHasMobile = wp_is_mobile();
112
113 // Iterate each mod
114 foreach ($modList as $data){
115
116 $xpathSelector = $data[0];
117 $mod = $data[1];
118 $aspect = $data[2];
119 $newValue = $data[3];
120 $sectionSlug = $data[4];
121 $selectorCode = $data[5];
122 $mediaQuery = $data[6];
123
124 // Skip if media query does not match < 980px (mobile) or >= 980 (desktop)
125 if ($this->excludeMediaQuery($mediaQuery, $userHasMobile)){
126 continue;
127 }
128
129 $nodes = $this->xpath->query($xpathSelector);
130
131 try {
132 $this->applyMod(
133 $nodes, $mod, $aspect, $newValue, $lazy
134 );
135 } catch (\Exception $e) {
136
137 if ($this->contentClass->isEditing){
138 $this->log(
139 'Error with modification: ' . $e->getMessage(),
140 array(
141 'folder' => $sectionSlug,
142 'selector' => $selectorCode,
143 'modification' => $mod,
144 )
145 );
146 }
147 }
148 }
149
150 // Save extracted lazyLoad content in the Database and add data for deferred loading
151 $footerJS = '';
152 if ($this->maybeStoreLazyContent($lazy)){
153 $footerJS.= '
154 <script class="fetch-lazy-content">
155 window.amender._ajax_url = "' . admin_url('admin-ajax.php') . '";
156 window.amender._ajax_nonce = "' . wp_create_nonce('tvra_request') . '";
157 window.amender.lazy = ' . wp_json_encode(array_keys($lazy['store'])) . ';
158 '.file_get_contents($this->assetClass->rootDir . 'mt/js/amender/mt-lazy.js').'
159 </script>'. "\n";
160 }
161 $body = $this->xpath->query('.//body');
162 if ($footerJS){
163 $this->applyMod($body, array('action' => 'append'), 'html', $footerJS, $lazy);
164 }
165
166 // Stop server-side timer
167 $this->contentClass->endT('all_server_side_html_changes');
168
169 // If we are timing server-side changes, present json
170 if ($this->contentClass->timeAmends){
171 $this->contentClass->returnServerTiming(
172 $this->contentClass->profiler['all_server_side_html_changes']['total_time'],
173 $html,
174 $this->contentClass->memoryProfiler
175 );
176 }
177
178 // Regular page view
179 else {
180 // Display debug data / error logs to administrators
181 if ($this->contentClass->isEditing && ($this->contentClass->debugAmends || count($this->logs))){
182
183 $debug = new Debug($this->contentClass);
184 $display = $debug->generateHTML(
185 $this->contentClass->debugAmends,
186 $modList,
187 $this->contentClass->profiler,
188 $this->contentClass->memoryProfiler,
189 $this->contentClass->clientSide,
190 $this->logs,
191 $lazy
192 );
193
194 if ($display){
195 $body = $this->xpath->query('.//body');
196 $this->applyMod($body, array('action' => 'append'), 'html', $display, $lazy);
197 }
198
199 }
200
201 // save the object back to an HTML string
202 $html = $this->doc->saveHTML();
203
204 // Free memory
205 unset($this->xpath, $this->doc);
206 ContentHelper::cleanupMemory();
207
208 }
209
210 }
211
212 function maybeStoreLazyContent(&$lazy) {
213
214 if (count($lazy['store'])) {
215
216 global $wpdb;
217
218 // Define the table name
219 $content_table = $wpdb->prefix . "micro_content";
220
221 // Initialize an array to hold the values for the query
222 $values = array();
223 $placeholders = array();
224
225 // Loop through each lazy_id in the store
226 foreach ($lazy['store'] as $lazy_id => $content_array) {
227
228 // JSON encode the content
229 $json_content = wp_json_encode($content_array);
230
231 // Collect the placeholders and values for each row
232 $placeholders[] = "(%s, %s, %s, %d)";
233 $values[] = $lazy_id; // Use lazy_id as the slug
234 $values[] = $json_content; // JSON-encoded content
235 $values[] = 'lazy'; // Type is 'lazy'
236 $values[] = 0; // Published is 0 (since it's part of the primary key)
237 }
238
239 // Execute the query with all the values
240 $wpdb->query(
241 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- multi-row VALUES placeholders built dynamically; table name is plugin-owned.
242 $wpdb->prepare(
243 "INSERT INTO $content_table (slug, content, type, published)
244 VALUES " . implode( ', ', $placeholders ) .
245 " ON DUPLICATE KEY UPDATE
246 content = VALUES(content),
247 modified_at = NOW(),
248 published = VALUES(published)",
249 ...$values
250 )
251 // phpcs:enable
252 );
253
254 return true;
255 }
256
257 return false;
258 }
259
260 function aspectIsAttribute($value){
261
262 if ($value === 'attributesString' || in_array($value, HTML::$attributes, true)) {
263 return true;
264 }
265
266 // Prefix-based pattern match
267 return preg_match('/^(data|x|hx|ng|v|aria)-[^\s"\'<>\/=]+$/', $value) === 1;
268 }
269
270 // Check if the HTML tag has no leading or trailing text
271 function isPureTag($content) {
272 return preg_match('/^\s*</', $content) && preg_match('/>\s*$/', $content);
273 }
274
275 // To make undo operations manageable (on client side), wrap leading/trailing loose text with a tag
276 // This is done here for parity with frontend editing
277 function maybeWrapWithTag($content) {
278
279 // Allow for possibility $content is a node.
280 if (!is_string($content)) {
281 return $content;
282 }
283
284 $inlineElements = [
285 "span", "a", "strong", "em", "img", "br", "i", "b", "u", "small", "mark",
286 "q", "cite", "code", "kbd", "var", "abbr", "time", "sub", "sup",
287 "button", "label", "input", "textarea", "select", "option"
288 ];
289
290 $pattern = '/^\s*(<\s*([a-zA-Z0-9]+)[^>]*>(?:([\s\S]*?)<\s*\/\s*\2\s*>|\s*)?)\s*$/';
291
292 preg_match_all($pattern, $content, $tagMatch);
293
294 // Check if there were any matches
295 $hasMatches = !empty($tagMatch[0]);
296
297 // Use the updated logic for $requiresWrapper
298 $requiresWrapper = !$hasMatches || !$this->isPureTag($content);
299
300 if (!$requiresWrapper) {
301 return $content;
302 }
303
304 // Determine the tag type based on the matched tag or default to 'div'
305 $tag = ($hasMatches && in_array($tagMatch[2][0], $inlineElements)) ? 'span' : 'div';
306
307 return '<' . $tag . '>' . $content . '</' . $tag . '>';
308 }
309
310 function insert($node, $action, $aspect, $newValue){
311
312 // add tag if not already one, we always insert content within a HTML tag
313 $newValue = $this->maybeWrapWithTag($newValue);
314
315 switch($action) {
316 case 'prepend':
317 $node->prepend($this->createNodeFromInput($newValue, $node));
318 break;
319 case 'add':
320 case 'append':
321 $node->appendChild($this->createNodeFromInput(
322 $newValue, null, false
323 ));
324 break;
325 case 'insertBefore':
326 $node->before($this->createNodeFromInput($newValue, $node));
327 break;
328 case 'insertAfter':
329 $node->after($this->createNodeFromInput($newValue, $node));
330 break;
331 }
332 }
333
334 function lazyLoad($node, $lazy_id, &$lazy){
335 $replacedNode = $node->parentNode->replaceChild(
336 $this->createNodeFromInput(
337 $this->lazyPlaceholder($lazy_id, $node), $node, false
338 ),
339 $node
340 );
341 $lazy['store'][$lazy_id][] = $this->doc->saveHTML($replacedNode);
342 }
343
344 function replace($node, $action, $aspect, $newValue, $isTextNode, $find){
345
346 //$this->log('replace', [$action, $aspect, esc_html($newValue), $isTextNode, $find]);
347
348 // text / inner HTML
349 if ($isTextNode || $aspect === 'innerHTML'){
350 $detached = $this->detachChildNodes($node, $action);
351
352 if ($action === 'replaceSubstring'){
353 $newValue = $this->substringReplace(
354 $detached['innerHTML'], $find, $newValue
355 );
356 }
357
358 $node->appendChild(
359 $this->createNodeFromInput($newValue, $node, false)
360 );
361 }
362
363 // outerHTML
364 else {
365
366 if ($action === 'replaceSubstring') {
367 $newValue = $this->substringReplace(
368 $node->ownerDocument->saveHTML($node), $find, $newValue
369 );
370 }
371
372 $node->parentNode->replaceChild(
373 $this->createNodeFromInput($newValue, $node),
374 $node
375 );
376 }
377 }
378
379 function remove($node, $action, $aspect, $isTextNode){
380 if ($isTextNode || $aspect === 'innerHTML'){
381 $this->detachChildNodes($node, $action);
382 } else {
383 $node->parentNode->removeChild($node);
384 }
385 }
386
387 function applyMod(&$nodes, $mod, $aspect, $newValueForNodes, &$lazy){
388
389 if (!$nodes){
390 return false;
391 }
392
393 $action = isset($mod['action']) ? $mod['action'] : null;
394 $find = isset($mod['find']) ? $mod['find'] : null;
395 $newValIsEmpty = $newValueForNodes === '';
396 $tag = isset($mod['tag']) ? $mod['tag'] : ''; // div
397 $lazy_id = isset($mod['lazy_id']) ? $mod['lazy_id'] : null;
398 $removalDisabled = empty($mod['enable']) && ($action === 'remove' || $action === 'lazyLoad');
399
400 // Bail if no action or replacement content is missing
401 if (!$action
402 || ($action === 'replace' && $newValueForNodes === '')
403 || $removalDisabled
404 || ($action === 'replaceSubstring' && $find === '')){
405 return false;
406 }
407
408 foreach ($nodes as $node){
409
410 // reset default $newValue for each node to prevent accumulated changes within the loop
411 // e.g. for string replace adjustments to $newValue
412 $newValue = $newValueForNodes;
413
414 // Aspect
415 if (!empty($aspect)){
416
417 $isTextNode = $aspect === 'text';
418
419 // Handle an attribute
420 if ($this->aspectIsAttribute($aspect)){
421
422 $attName = $aspect;
423
424 $split = false;
425 $splitOn = null;
426 $joinOn = null;
427
428 // special case for attributesString
429 if ($attName === 'attributesString'){
430 switch ($action) {
431 case 'add':
432 case 'prepend':
433 case 'append':
434 case 'insertBefore':
435 case 'insertAfter':
436 $this->applyAttributesFromString($node, $newValue);
437 break;
438 case 'remove':
439 $this->removeAllAttributes($node);
440 break;
441 case 'replace':
442 $this->removeAllAttributes($node);
443 $this->applyAttributesFromString($node, $newValue);
444 break;
445 case 'replaceSubstring':
446 $originalAttributes = $this->removeAllAttributes($node, true);
447 $newValue = $this->substringReplace(
448 $originalAttributes, $find, $newValue
449 );
450 $this->applyAttributesFromString($node, $newValue);
451 break;
452 }
453
454 }
455
456 // regular attribute
457 else {
458
459 // normalise actions
460 if ($attName === 'class' || $attName === 'style'){
461 $split = true;
462 $splitOn = "/\s+/";
463 $joinOn = " ";
464 if ($action === 'add'){
465 $action = 'append';
466 } if ($attName === 'style'){
467 $splitOn = "/\s*;\s*/";
468 $joinOn = "; ";
469 }
470 } else {
471 if ($action === 'add'){
472 $action = 'replace';
473 }
474 }
475
476 switch($action){
477 case 'prepend':
478 case 'append':
479 case 'insertBefore':
480 case 'insertAfter':
481
482 if ($newValIsEmpty){
483 break;
484 }
485
486 $curVal = $node->getAttribute($attName);
487
488 // if class or style, prevent dupe and ensure correct position
489 if ($split){
490
491 $curValArray = preg_split($splitOn, $curVal);
492 $length = $curValArray ? count($curValArray) : 0;
493
494 // prevent duplication
495 if ($length){
496 for ($i = $length - 1; $i >= 0; $i--) {
497 if ($curValArray[$i] === $newValue){
498 unset($curValArray[$i]);
499 }
500 }
501 } else {
502 $curValArray = array();
503 }
504
505 // add at start or end of array
506 if ($action === 'append' || $action === 'insertAfter'){
507 $curValArray[] = $newValue;
508 } else {
509 array_unshift($curValArray, $newValue);
510 }
511
512 $newValue = implode($joinOn, $curValArray);
513 }
514
515 // all other attributes - simply prepend/append string
516 else {
517 if ($action === 'append' || $action === 'insertAfter'){
518 $newValue = $curVal . $newValue;
519 } else {
520 $newValue = $newValue . $curVal;
521 }
522 }
523 break;
524 // replace is just a simple use of the $newValue
525 // replaceSubstring
526 case 'replaceSubstring':
527 $newValue = $this->substringReplace(
528 $node->getAttribute($attName), $find, $newValue
529 );
530 break;
531 }
532
533 // remove attribute or set resolved attribute (for all other actions)
534 if ($action === 'remove'){
535 $node->removeAttribute($attName);
536 } else {
537 $node->setAttribute($attName, $newValue);
538 }
539 }
540 }
541
542 // All other aspects
543 else {
544
545 switch ($aspect){
546 case 'html':
547 case 'text':
548 case 'innerHTML':
549 switch($action) {
550 case 'prepend':
551 case 'add':
552 case 'append':
553 case 'insertBefore':
554 case 'insertAfter':
555 if (!$newValIsEmpty){
556 $this->insert($node, $action, $aspect, $newValue);
557 }
558 break;
559 case 'move':
560 $this->move($node, $mod, $aspect, $lazy);
561 break;
562 case 'lazyLoad':
563 $this->lazyLoad($node, $lazy_id, $lazy);
564 break;
565 case 'remove':
566 $this->remove($node, $action, $aspect, $isTextNode);
567 break;
568 case 'replace':
569 case 'replaceSubstring':
570 $this->replace($node, $action, $aspect, $newValue, $isTextNode, $find);
571 }
572 break;
573 case 'parentWrapper':
574 switch($action){
575 case 'add':
576 case 'prepend':
577 case 'append':
578
579 if ($tag){
580
581 // create element with any attributes
582 $wrapperNode = $this->createNewNode($tag, $newValue);
583
584 // replace node with new element
585 $node->parentNode->replaceChild(
586 $wrapperNode,
587 $node
588 );
589
590 // now it is safe to append the original node to the new element
591 $wrapperNode->appendChild($node);
592 }
593 break;
594 case 'remove':
595 if ($node->parentNode){
596 $this->removeWrapperNodes($node->parentNode, $action);
597 }
598 break;
599 }
600 break;
601 case 'childWrapper':
602 switch($action) {
603 case 'add':
604 case 'prepend':
605 case 'append':
606 $detached = $this->detachChildNodes($node, $action);
607 $wrapperNode = $this->createNewNode($tag, $newValue);
608 $node->appendChild($wrapperNode);
609 foreach ($detached['nodes'] as $childNode){
610 $wrapperNode->appendChild($childNode);
611 }
612 break;
613 case 'remove':
614 if ($node->childNodes){
615 foreach($node->childNodes as $childNode){
616 if ($childNode->nodeType === XML_ELEMENT_NODE){
617 $this->removeWrapperNodes($childNode, $action);
618 break;
619 }
620 }
621 }
622 break;
623 }
624 }
625 }
626 }
627 }
628 }
629
630 function move($node, $mod, $aspect, &$lazy){
631
632 $destNode = $this->resolveDestNode($node, $mod);
633
634 if ($destNode){
635
636 // detach the node to move
637 $detachedNode = $node->parentNode->removeChild($node);
638
639 // format $destNode as array for applyMod format
640 $destNodeArray = array($destNode);
641
642 $move_action = isset($mod['move_action']) ? $mod['move_action'] : 'append';
643
644 // Add the
645 $this->applyMod($destNodeArray, array(
646 'action' => $move_action
647 ), 'html', $detachedNode, $lazy);
648 }
649 }
650
651 public function resolveDestNode($node, $mod) {
652
653 $relativeDom = isset($mod['move_relative_dom'])
654 ? json_decode($mod['move_relative_dom'], true)
655 : null;
656
657 if (!$relativeDom) {
658 return false;
659 }
660
661 $xpath = isset($relativeDom['xpath']) ? $relativeDom['xpath'] : '';
662
663 // If no relative DOM is provided
664 if (empty($relativeDom['iterate'])) {
665 return $this->getFirstNode($this->xpath->query($xpath));
666 }
667
668 // If we have relative DOM instructions
669 $nodes = array($node); // Start with an array containing the initial $node
670
671 foreach ($relativeDom['iterate'] as $item) {
672 $item_xpath = isset($item['xpath']) ? $item['xpath'] : $xpath;
673 $matchedNodes = array();
674
675 // Process each node in the array
676 foreach ($nodes as $node) {
677 switch ($item['directive']) {
678 case 'parent':
679 if ($parentNode = $node->parentNode) {
680 $matchedNodes[] = $parentNode;
681 }
682 break;
683 case 'parents':
684 $matchedNodes = $this->getParents($node, $item_xpath);
685 break;
686 case 'closest':
687 if ($closestNode = $this->getClosest($node, $item_xpath)) {
688 $matchedNodes[] = $closestNode;
689 }
690 break;
691 case 'children':
692 $matchedNodes = $this->getChildren($node, $item_xpath);
693 break;
694 case 'find':
695 $matchedNodes = $this->getDescendents($node, $item_xpath);
696 break;
697 case 'prev':
698 case 'next':
699 $type = $item['directive'] === 'prev' ? 'previousSibling' : 'nextSibling';
700 if ($sibling = $this->getSibling($type, $node, $item_xpath)) {
701 $matchedNodes[] = $sibling;
702 }
703 break;
704 case 'siblings':
705 $matchedNodes = $this->getSiblings($node, $item_xpath);
706 break;
707 }
708 }
709
710 $nodes = $matchedNodes;
711
712 // If there are no more matched nodes, break early
713 if (empty($nodes)) {
714 return null;
715 }
716 }
717
718 return $nodes[0]; // count($nodes) === 1 ? $nodes[0] : $nodes; // Return single node or array of nodes
719 }
720
721 private function getFirstNode($nodes) {
722 return $nodes->length > 0 ? $nodes->item(0) : null;
723 }
724
725 // Helper function to check if an element matches a selector or XPath
726 private function isElementMatching($node, $xpath) {
727
728 if (!$xpath) {
729 return true; // match any node if no $xpath is provided
730 }
731
732 $nodes = $this->xpath->query($xpath, $node->parentNode); // Scope the query to the parent
733 foreach ($nodes as $n) {
734 if ($n->isSameNode($node)) {
735 return true;
736 }
737 }
738 return false;
739 }
740
741 // Find parent nodes that match an XPath
742 private function getParents($node, $xpath) {
743 $matchedNodes = array();
744 while ($node = $node->parentNode) {
745 if ($this->isElementMatching($node, $xpath)) {
746 $matchedNodes[] = $node;
747 }
748 }
749 return $matchedNodes;
750 }
751
752 // Find the closest ancestor matching the XPath
753 private function getClosest($node, $xpath) {
754 do {
755 if ($this->isElementMatching($node, $xpath)) {
756 return $node;
757 }
758 } while ($node = $node->parentNode);
759
760 return null;
761 }
762
763 // Get direct child nodes matching the XPath
764 private function getChildren($node, $xpath) {
765 $matchedNodes = array();
766 foreach ($node->childNodes as $child) {
767 if ($child->nodeType === XML_ELEMENT_NODE && $this->isElementMatching($child, $xpath)) {
768 $matchedNodes[] = $child;
769 }
770 }
771 return $matchedNodes;
772 }
773
774 // Get descendant nodes matching the XPath
775 private function getDescendents($node, $xpath) {
776 $descendents = array();
777 $nodes = $this->xpath->query($xpath, $node);
778 foreach ($nodes as $descendent) {
779 $descendents[] = $descendent;
780 }
781 return $descendents;
782 }
783
784 private function getSibling($type, $node, $xpath) {
785 while ($node = $node->$type) {
786 if ($node->nodeType === XML_ELEMENT_NODE && $this->isElementMatching($node, $xpath)) {
787 return $node;
788 }
789 }
790 return null;
791 }
792
793 // Find sibling elements matching the XPath
794 private function getSiblings($node, $xpath) {
795 $matchedNodes = array();
796 $parent = $node->parentNode;
797
798 if (!$parent) {
799 return $matchedNodes;
800 }
801
802 foreach ($parent->childNodes as $sibling) {
803 if ($sibling !== $node && $sibling->nodeType === XML_ELEMENT_NODE) {
804 if ($this->isElementMatching($sibling, $xpath)) {
805 $matchedNodes[] = $sibling;
806 }
807 }
808 }
809 return $matchedNodes;
810 }
811
812
813 function lazyPlaceholder($lazy_id, $node){
814
815 // Get the existing class attribute from the node, if any
816 $existingClasses = $node->hasAttribute('class') ? $node->getAttribute('class') : '';
817
818 // Combine the existing classes with 'lazy-placeholder' and the dynamic 'lazy-{lazy_id}' class
819 $combinedClasses = trim($existingClasses . ' lazy-placeholder lazy-' . $lazy_id);
820
821 // Return the new placeholder HTML with the combined classes and lazy-id data attribute
822 return '<div class="' . $combinedClasses . '" data-lazy-id="' . $lazy_id . '"></div>';
823 }
824
825 // remove inner or outer wrapper nodes (todo at varying levels, for bloat removal)
826 function removeWrapperNodes(&$wrapper, $action, $levels = 1){
827 if (!$wrapper) return;
828
829 $detached = $this->detachChildNodes($wrapper, $action);
830 $prevChild = null;
831
832 // If there are children, replace the wrapper with the first,
833 // then insert the rest after, preserving DOM order.
834 if (!empty($detached['nodes'])) {
835 foreach ($detached['nodes'] as $i => $childNode) {
836 if ($i < 1) {
837 // Anchor the group exactly where the wrapper was
838 $wrapper->replaceWith($childNode);
839 } else {
840 $prevChild->after($childNode);
841 }
842 $prevChild = $childNode;
843 }
844 }
845 // If the wrapper still exists (e.g. it had no children), remove it now.
846 if ($wrapper->parentNode) {
847 $wrapper->parentNode->removeChild($wrapper);
848 }
849 }
850
851 function createNewNode($tagName, $attributesString, $text = null){
852 $wrapperNode = $this->doc->createElement($tagName);
853 $this->applyAttributesFromString($wrapperNode, $attributesString);
854 return $wrapperNode;
855 }
856
857 function detachChildNodes($node, $action){
858
859 $detached = array(
860 'nodes' => array(),
861 'innerHTML' => ''
862 );
863
864 // mark nodes for removing, doesn't work removing them in the loop
865 foreach ($node->childNodes as $childNode){
866 $detached['nodes'][] = $childNode;
867 if ($action === 'replaceSubstring'){
868 $detached['innerHTML'].= $this->doc->saveHTML($childNode);
869 }
870 }
871
872 foreach ($detached['nodes'] as $removeNode){
873 $node->removeChild($removeNode);
874 }
875
876 return $detached;
877 }
878
879 public function removeAllAttributes(&$node, $getCurrent = false) {
880
881 $current = '';
882
883 if ($node instanceof \DOMElement) {
884
885 if ($getCurrent) {
886 foreach ($node->attributes as $attr) {
887 $current .= $attr->name . '="' . $attr->value . '" ';
888 }
889 $current = trim($current);
890 }
891
892 while ($node->attributes->length > 0) {
893 $node->removeAttribute($node->attributes->item(0)->name);
894 }
895 }
896
897 return $current;
898 }
899
900 function applyAttributesFromString(&$node, $string){
901 try {
902 $attsArray = current((array) new \SimpleXMLElement("<element $string />"));
903 foreach($attsArray as $attKey => $attValue) {
904 if ($attKey){
905 $node->setAttribute($attKey, $attValue);
906 }
907 }
908 } catch (\Exception $e) {
909 throw new \Exception("Attributes string error for: " . esc_html($string));
910 }
911 }
912
913 function substringReplace($string, $find, $replacement){
914 if (str_starts_with($find, '/')){
915 return @preg_replace($find, $replacement, $string);
916 }
917 return str_replace($find, $replacement, $string);
918 }
919
920 // input could be text, html, or $node reference
921 function createNodeFromInput($input, $node = null, $register = true, $isTextNode = false) {
922
923 // If the input is a DOMNode, use it directly
924 if ($input instanceof \DOMNode) {
925 $importNode = $this->doc->importNode($input, true);
926 }
927 // If it's a string (either HTML or text), process it accordingly
928 else {
929 $tmpDoc = new \DOMDocument();
930
931 if ($isTextNode) {
932 // Treat the input as a text node
933 $importNode = $this->doc->createTextNode($input);
934 } else {
935 // Wrap the input in a minimal HTML structure
936 $htmlOrTextWrapped = "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"></head><body>" . $input . "</body></html>";
937
938 // Load the HTML with the temporary wrapper
939 //libxml_use_internal_errors(true);
940 $tmpDoc->loadHTML($htmlOrTextWrapped, LIBXML_NOERROR | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); // | LIBXML_NOCDATA
941
942 // Extract the inner content (all nodes inside <body>)
943 $body = $tmpDoc->getElementsByTagName('body')->item(0);
944
945 // Create a container for the imported nodes
946 $fragment = $this->doc->createDocumentFragment();
947
948 // Loop through the body’s child nodes and import them one by one
949 foreach ($body->childNodes as $childNode) {
950 $importedNode = $this->doc->importNode($childNode, true);
951 $fragment->appendChild($importedNode);
952 }
953
954 $importNode = $fragment;
955 }
956 }
957
958 if (!$register) {
959 return $importNode;
960 }
961
962 return $this->registerNode($importNode, $node);
963 }
964
965 // for a fragment to be usable it must be added to a node using appendChild
966 // this functions appends the fragment and then immediately removes it, so it can be placed where we want
967 function registerNode($fragment, $node){
968
969 if (!$node){
970 $node = $this->doc->documentElement;
971 }
972
973 $tempNode = $node->appendChild($fragment);
974
975 return $node->removeChild($tempNode);
976 }
977
978 }