| 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 |
} |