text, deleting nodes) use tightly-scoped helpers.
*
* @package EasyFonts
*/
namespace EasyFonts\Frontend;
use WP_HTML_Tag_Processor;
defined( 'ABSPATH' ) || exit;
/**
* Reliable HTML read/rewrite helpers.
*/
class HtmlProcessor {
/**
* Is WordPress's HTML API available? (WP 6.2+.) When it isn't, every method
* here fails open — returning the HTML unchanged — so a missing core class can
* never fatal inside the output buffer and white-screen the site.
*
* @return bool
*/
private function api_ready(): bool {
return class_exists( '\\WP_HTML_Tag_Processor' );
}
/**
* Iterate every , letting a callback rewrite the href.
*
* @param string $html HTML.
* @param callable $callback fn(string $href, array $attrs): ?string — return new href or null to skip.
* @return array{html:string,changed:bool}
*/
public function rewrite_stylesheet_links( string $html, callable $callback ): array {
if ( ! $this->api_ready() ) {
return array( 'html' => $html, 'changed' => false );
}
$processor = new WP_HTML_Tag_Processor( $html );
$changed = false;
while ( $processor->next_tag( array( 'tag_name' => 'LINK' ) ) ) {
$rel = (string) $processor->get_attribute( 'rel' );
if ( false === stripos( $rel, 'stylesheet' ) ) {
continue;
}
$href = $processor->get_attribute( 'href' );
if ( ! is_string( $href ) || '' === $href ) {
continue;
}
$attrs = array(
'id' => $processor->get_attribute( 'id' ),
'media' => $processor->get_attribute( 'media' ),
);
$new = $callback( $href, $attrs );
if ( is_string( $new ) && $new !== $href ) {
$processor->set_attribute( 'href', $new );
$changed = true;
}
}
return array(
'html' => $processor->get_updated_html(),
'changed' => $changed,
);
}
/**
* Collect all hrefs (read-only).
*
* @param string $html HTML.
* @return string[]
*/
public function stylesheet_hrefs( string $html ): array {
if ( ! $this->api_ready() ) {
return array();
}
$processor = new WP_HTML_Tag_Processor( $html );
$hrefs = array();
while ( $processor->next_tag( array( 'tag_name' => 'LINK' ) ) ) {
$rel = (string) $processor->get_attribute( 'rel' );
if ( false === stripos( $rel, 'stylesheet' ) ) {
continue;
}
$href = $processor->get_attribute( 'href' );
if ( is_string( $href ) && '' !== $href ) {
$hrefs[] = $href;
}
}
return $hrefs;
}
/**
* Physically remove specific stylesheet tags by exact href.
*
* The tokenizer can't delete nodes, so we mark them inert with the
* tokenizer (reliable matching) and then strip the marked tags by regex.
*
* @param string $html HTML.
* @param string[] $hrefs Exact href values to remove.
* @return string
*/
public function remove_stylesheet_links( string $html, array $hrefs ): string {
if ( empty( $hrefs ) ) {
return $html;
}
if ( ! $this->api_ready() ) {
return $html;
}
$targets = array_fill_keys( $hrefs, true );
$processor = new WP_HTML_Tag_Processor( $html );
$hit = false;
while ( $processor->next_tag( array( 'tag_name' => 'LINK' ) ) ) {
$rel = (string) $processor->get_attribute( 'rel' );
if ( false === stripos( $rel, 'stylesheet' ) ) {
continue;
}
$href = $processor->get_attribute( 'href' );
if ( is_string( $href ) && isset( $targets[ $href ] ) ) {
$processor->set_attribute( 'data-easyfonts-remove', '1' );
$hit = true;
}
}
if ( ! $hit ) {
return $html;
}
$updated = $processor->get_updated_html();
// Drop any carrying our marker. If PCRE bails (e.g. backtrack
// limit), keep the tokenized HTML rather than blanking the document.
$cleaned = preg_replace( '#]*\bdata-easyfonts-remove=([\'"])1\1[^>]*/?>\s*#i', '', $updated );
return null === $cleaned ? $updated : $cleaned;
}
/**
* Process the CSS contents of each scanner — NOT a regex. A regex such as
// `()/s` catastrophically backtracks (and on
// PCRE2+JIT can crash the process) over the megabyte-sized inline CSS that
// page builders emit, which silently blanked the page. This strpos walk is
// O(n) and cannot backtrack or overflow.
$max = (int) apply_filters( 'easyfonts_max_style_block', 2 * 1024 * 1024 );
$out = '';
$offset = 0;
$len = strlen( $html );
while ( $offset < $len ) {
$open = stripos( $html, '', $gt + 1 );
if ( false === $close ) {
$out .= substr( $html, $offset );
break;
}
// Copy everything up to this ';
$offset = $close + 8;
}
return array(
'html' => $out,
'changed' => $changed,
);
}
/**
* Remove Google-font resource hints (preconnect / dns-prefetch / preload).
*
* @param string $html HTML.
* @param string[] $domains Domains to strip hints for.
* @return array{html:string,changed:bool}
*/
public function strip_font_hints( string $html, array $domains ): array {
if ( ! $this->api_ready() ) {
return array( 'html' => $html, 'changed' => false );
}
$input = $html;
$processor = new WP_HTML_Tag_Processor( $html );
$remove = array();
// Identify offending hint tags by their href (read pass).
while ( $processor->next_tag( array( 'tag_name' => 'LINK' ) ) ) {
$rel = strtolower( (string) $processor->get_attribute( 'rel' ) );
if ( ! in_array( $rel, array( 'preconnect', 'dns-prefetch', 'preload' ), true ) ) {
continue;
}
$href = (string) $processor->get_attribute( 'href' );
foreach ( $domains as $domain ) {
if ( false !== strpos( $href, $domain ) ) {
// Neutralise: blank the href + rel so the hint is inert.
$processor->set_attribute( 'href', '' );
$processor->set_attribute( 'rel', 'easyfonts-removed' );
$remove[] = true;
break;
}
}
}
$html = $processor->get_updated_html();
// Physically drop the neutralised tags. If PCRE bails, fall back to the
// input HTML rather than returning '' (which would blank the page).
$cleaned = preg_replace( '/]*\brel=([\'"])easyfonts-removed\1[^>]*>\s*/i', '', $html );
if ( null === $cleaned ) {
return array( 'html' => $input, 'changed' => false );
}
return array(
'html' => $cleaned,
'changed' => ! empty( $remove ),
);
}
/**
* Insert a snippet immediately before .
*
* @param string $html HTML.
* @param string $snippet Snippet.
* @return string
*/
public function before_head_close( string $html, string $snippet ): string {
$pos = stripos( $html, '' );
if ( false === $pos ) {
return $html;
}
return substr_replace( $html, $snippet . "\n", $pos, 7 );
}
}