<?php
declare(strict_types=1);
namespace WP_Rocket\Engine\Optimization\LazyRenderContent\Frontend\Processor;
use DOMDocument;
use WP_Rocket\Logger\Logger;
class Dom implements ProcessorInterface {
use HelperTrait;
/**
* Number of injects hashes.
*
* @since 3.17
*
* @var int
*/
private $count;
/**
* Maximum number of hashes to inject.
*
* @since 3.17
*
* @var int
*/
private $max_hashes;
/**
* Array of patterns to exclude from hash injection.
*
* @since 3.17.0.2
*
* @var array
*/
private $exclusions = [];
/**
* Sets the exclusions list
*
* @param string[] $exclusions The list of patterns to exclude from hash injection.
*
* @return void
*/
public function set_exclusions( $exclusions ): void {
$this->exclusions = $exclusions;
}
/**
* Gets the exclusions pattern
*
* @return string
*/
private function get_exclusions_pattern(): string {
if ( empty( $this->exclusions ) ) {
return '';
}
$exclusions = array_map(
function ( $exclusion ) {
return preg_quote( $exclusion, '/' );
},
$this->exclusions
);
return implode( '|', $exclusions );
}
/**
* Add hashes to the HTML elements
*
* @param string $html The HTML content.
*
* @return string
*/
public function add_hashes( $html ) {
$internal_errors = libxml_use_internal_errors( true );
// Load HTML into DOMDocument.
$dom = new DOMDocument();
if ( ! $dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ) ) {
foreach ( libxml_get_errors() as $error ) {
Logger::error( $error->message, [ 'LazyRenderContent' ] );
}
libxml_clear_errors();
return $html;
}
libxml_use_internal_errors( $internal_errors );
// Get the body element.
$body = $dom->getElementsByTagName( 'body' )->item( 0 );
if ( ! $body ) {
Logger::error( 'Body element not found in the HTML content.', [ 'LazyRenderContent' ] );
return $html;
}
$this->max_hashes = $this->get_max_tags();
$this->count = 0;
return $this->add_hash_to_element( $body, $this->get_depth(), $html );
}
/**
* Add a hash to the element and its children.
*
* @param \DOMElement $element The element to add the hash to.
* @param int $depth The depth of the recursion.
* @param string $html The HTML content.
*
* @return string
*/
private function add_hash_to_element( $element, $depth, $html ) {
if ( $depth < 0 ) {
return $html;
}
$processed_tags = $this->get_processed_tags();
foreach ( $element->childNodes as $child ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( $this->count >= $this->max_hashes ) {
Logger::warning( 'Stopping LRC hash injection as max_hashes is reached.', [ 'LazyRenderContent' ] );
return $html;
}
if ( ! $child instanceof \DOMElement ) {
continue;
}
// Check if the element contains an SVG use element and skip it.
if ( $child->getElementsByTagName( 'use' )->length > 0 ) {
continue;
}
if (
XML_ELEMENT_NODE !== $child->nodeType // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
||
! $child->tagName // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
||
! in_array( strtoupper( $child->tagName ), $processed_tags, true ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
) {
continue;
}
// Calculate the hash of the opening tag.
$child_html = $child->ownerDocument->saveHTML( $child ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$opening_tag_html = strstr( $child_html, '>', true ) . '>';
$exclusions_pattern = $this->get_exclusions_pattern();
if (
! empty( $exclusions_pattern )
&&
preg_match( '/(' . $exclusions_pattern . ')/i', $opening_tag_html )
) {
continue;
}
$hash = md5( $opening_tag_html . $this->count );
++$this->count;
// Inject the hash as an attribute in the opening tag.
$replace = preg_replace( '/' . $child->tagName . '/is', '$0 data-rocket-location-hash="' . $hash . '"', $opening_tag_html, 1 ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( is_null( $replace ) ) {
continue;
}
// Replace the opening tag in the HTML by the manipulated one
// If DOMDocument automatically modified the original element, we might not find it in the HTML.
// Known issue: if there is an element with the exact same opening tag before in the HTML that did not receive a hash, it will replaced instead of the correct element in the HTML.
$element_replacements = 0;
$modified_html = preg_replace( '/' . preg_quote( $opening_tag_html, '/' ) . '/', $replace, $html, 1, $element_replacements );
if ( $element_replacements < 1 ) {
Logger::warning( 'Opening tag from DOMDocument not found in original HTML.', [ 'LazyRenderContent' ] );
}
if ( is_null( $modified_html ) ) {
continue;
}
$html = $modified_html;
// Recursively process child elements.
$html = $this->add_hash_to_element( $child, $depth - 1, $html );
}
return $html;
}
}