(the LCP candidate) and emits * a for each in * the , carrying srcset/sizes as imagesrcset/imagesizes so the * browser can pick the right candidate — then adds fetchpriority="high" * to the itself so it beats any loading="lazy" the theme set. * 2. Emits for detected web-font hosts * (fonts.googleapis.com + fonts.gstatic.com) and any user-supplied * hosts, deduped. * * Kept as a pure static so the test suite can drive it without booting the * module or WordPress hooks (mirrors Lazy_Loader::process_html). All output * is escaped at build time; callers echo the result verbatim into the body. * * @package XSpeed */ declare(strict_types=1); namespace XSpeed; defined( 'ABSPATH' ) || exit; final class Resource_Hints_Processor { /** * Transform the page HTML, injecting preload + preconnect hints. * * @param string $html Fully-rendered page HTML. * @param array $opts Preload module settings. * @return string Rewritten HTML (unchanged when disabled or no match). */ public static function process( string $html, array $opts ): string { if ( empty( $opts['enabled'] ) ) { return $html; } // Only touch real HTML documents. A JSON/XML/feed body that happens // to reach here should pass through untouched. if ( false === stripos( $html, ' matching an exclusion pattern, strip lazy + // set fetchpriority=high. NOTE: this mutates $html even when no // hints are emitted, so it must apply before the empty-$hints early-out. $eager = array_filter( array_map( 'strval', (array) ( $opts['eager_excluded_images'] ?? array() ) ) ); if ( ! empty( $eager ) ) { $html = self::promote_excluded_images( $html, $eager ); } if ( '' === $hints ) { return $html; } return self::inject_into_head( $html, $hints ); } /** * Strip core `loading="lazy"` and add `fetchpriority="high"` + * `decoding="async"` on every whose tag matches one of the given * exclusion substrings. Mirrors what Lazy_Loader does for an excluded image * inside the_content, but page-wide so heroes outside it are covered too. * (FBS-83553 H2) * * @param string $html Full page HTML. * @param string[] $exclusions Substring patterns identifying above-the-fold heroes. */ private static function promote_excluded_images( string $html, array $exclusions ): string { return (string) preg_replace_callback( '#"\'])*>#i', static function ( array $m ) use ( $exclusions ) { $tag = $m[0]; foreach ( $exclusions as $needle ) { if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { $tag = (string) preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag ); $tag = self::set_fetchpriority( $tag ); if ( ! preg_match( '#\bdecoding=#i', $tag ) ) { $tag = (string) preg_replace( '#s for detected font hosts + user hosts. * Deduped and idempotent (skips hosts already preconnected in $html). * * @param string $html Page HTML (scanned for font stylesheets). * @param string[] $user_hosts Extra hosts to always preconnect. * @param bool $auto_fonts Whether to auto-add font hosts. * @return string preconnect markup. */ private static function build_preconnect( string $html, array $user_hosts, bool $auto_fonts ): string { $hosts = array(); if ( $auto_fonts && false !== stripos( $html, 'fonts.googleapis.com' ) ) { // The stylesheet is on googleapis; the font files stream from // gstatic — preconnect both, gstatic needs crossorigin. $hosts['https://fonts.googleapis.com'] = false; $hosts['https://fonts.gstatic.com'] = true; } foreach ( $user_hosts as $host ) { $host = trim( (string) $host ); if ( '' === $host ) { continue; } // Cross-origin hosts get crossorigin by default; harmless for // same-scheme document hosts and required for fonts/fetch. $hosts[ untrailingslashit( $host ) ] = true; } $out = ''; foreach ( $hosts as $host => $crossorigin ) { // Idempotency: skip a host already preconnected in the document. if ( preg_match( '#rel=["\']preconnect["\'][^>]*' . preg_quote( $host, '#' ) . '#i', $html ) || preg_match( '#' . preg_quote( $host, '#' ) . '[^>]*rel=["\']preconnect["\']#i', $html ) ) { continue; } $out .= sprintf( '' . "\n", esc_url( $host ), $crossorigin ? ' crossorigin' : '' ); } return $out; } /** * Find the first $count eligible tags, add fetchpriority="high" * to each, and return the matching markup. * * @param string $html Page HTML. * @param int $count How many top images to preload. * @param string[] $exclusions Substring patterns that exempt an . * @return array{0:string,1:string} [rewritten html, preload markup] */ private static function build_lcp_preload( string $html, int $count, array $exclusions ): array { if ( $count < 1 ) { return array( $html, '' ); } $preload = ''; $done = 0; // Snapshot of already-present preload markup, for idempotency: a second // pass (e.g. cache-off ob_start over an already-processed body) must not // re-emit a for an image we preloaded before. $existing = $html; // Walk tags in document order. preg_replace_callback lets us // rewrite the tag (add fetchpriority) and harvest the src in one pass. $html = preg_replace_callback( '#]*>#i', static function ( array $m ) use ( &$done, &$preload, $count, $exclusions, $existing ) { $tag = $m[0]; if ( $done >= $count ) { return $tag; } // Skip anything the user excluded. foreach ( $exclusions as $needle ) { if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { return $tag; } } // Resolve the EFFECTIVE image URL. Page builders + JS lazy // loaders park a placeholder (a data: URI or a 1px spacer) in // `src` and the real URL in `data-src`, so the hero the browser // actually paints is behind data-src. Reading `src` alone made // the LCP picker skip the real hero and fasten onto a later plain // decoy. Prefer data-src when src is a placeholder. // (FBS-83553 H1) [ $src, $srcset, $sizes ] = self::effective_image_src( $tag ); if ( '' === $src ) { return $tag; // no real URL (pure data-URI spacer, no data-src). } // Size gate: never spend the LCP preload budget on an image that // is obviously not the hero — a logo/icon/avatar. When explicit // width & height are on the tag and it's small in BOTH dimensions, // skip it and keep looking. Missing dimensions → don't guess, let // it through. (FBS-83553 H1 "logo before hero".) if ( self::looks_too_small( $tag ) ) { return $tag; } // Idempotency: if this src is already the target of a // rel="preload" as="image" link, count it as done (so the // budget is respected) but don't emit a duplicate . $already = (bool) preg_match( '#rel=["\']preload["\'][^>]*as=["\']image["\'][^>]*' . preg_quote( $src, '#' ) . '#i', $existing ); if ( ! $already ) { $preload .= self::preload_link( $src, $srcset, $sizes ); } $done++; // Add fetchpriority="high" AND remove any loading="lazy" the // theme / WP core left on the LCP image. fetchpriority="high" // with loading="lazy" is contradictory — the browser can still // defer a lazy image, so preloading it while it stays lazy wins // nothing. Stripping lazy is what actually lets the preload land. return self::promote_lcp_img( $tag ); }, $html ); return array( (string) $html, $preload ); } /** * Assemble one . * * The href/srcset run through the `xspeed_lcp_preload_url` / * `xspeed_lcp_preload_srcset` filters first. This is the coordination point * with format-negotiating layers (Pro's Images module wraps the LCP in * a with a WebP/AVIF , so the browser paints e.g. * hero.png.webp, NOT the hero.png this preload would otherwise point at — * making the high-priority preload a wasted download while the real LCP * resource goes un-preloaded). By filtering the URL, a webp/avif layer can * redirect the preload to the format it will actually serve, WITHOUT Free * knowing that layer exists. (FBS-83553 H3) */ private static function preload_link( string $src, string $srcset, string $sizes ): string { // Resolve the `type` from the ORIGINAL image URL (before rewriting), so a // negotiating layer can key off the source .jpg/.png — after rewriting, // the URL is already a .webp and the derivation would no-op. $original = $src; /** * Filter an explicit `type` for the preload link (e.g. "image/webp"). * Empty = omit. A typed image preload is only fetched by browsers that * accept that type, so pairing a webp href with type="image/webp" is safe * even though the markup is baked into a shared cache file. * * @param string $type Defaults to '' (no type attribute). * @param string $src The ORIGINAL (pre-rewrite) preload URL. */ $type = (string) apply_filters( 'xspeed_lcp_preload_type', '', $original ); /** * Filter the LCP preload href. Return a modern-format sibling (webp/avif) * when one will actually be served for this image. * * @param string $src The original image URL chosen for preload. */ $src = (string) apply_filters( 'xspeed_lcp_preload_url', $src ); if ( '' !== $srcset ) { /** @param string $srcset The original srcset chosen for preload. */ $srcset = (string) apply_filters( 'xspeed_lcp_preload_srcset', $srcset ); } $attrs = sprintf( 'href="%s"', esc_url( $src ) ); if ( '' !== $srcset ) { // Preserve the responsive candidate set so the browser preloads // the same file it would have chosen from the . $attrs .= sprintf( ' imagesrcset="%s"', esc_attr( html_entity_decode( $srcset, ENT_QUOTES ) ) ); if ( '' !== $sizes ) { $attrs .= sprintf( ' imagesizes="%s"', esc_attr( html_entity_decode( $sizes, ENT_QUOTES ) ) ); } } if ( '' !== $type ) { $attrs .= sprintf( ' type="%s"', esc_attr( $type ) ); } return sprintf( '' . "\n", $attrs ); } /** * Extract a single/double-quoted attribute value from a tag. Returns '' * when the attribute is absent. */ private static function attr( string $tag, string $name ): string { if ( preg_match( '#\b' . preg_quote( $name, '#' ) . '=(["\'])(.*?)\1#is', $tag, $m ) ) { return trim( $m[2] ); } return ''; } /** * Resolve the URL/srcset/sizes the browser will actually paint for an * , seeing through JS-lazy placeholders. When `src` is a data: URI (a * builder/lazy-loader placeholder), fall back to `data-src`; likewise carry * `data-srcset`/`data-sizes` when the plain ones are absent. Returns * ['', '', ''] when there's no real raster URL to preload. (FBS-83553 H1) * * @return array{0:string,1:string,2:string} [src, srcset, sizes] */ private static function effective_image_src( string $tag ): array { $src = self::attr( $tag, 'src' ); if ( '' === $src || 0 === stripos( $src, 'data:' ) ) { $data_src = self::attr( $tag, 'data-src' ); if ( '' !== $data_src && 0 !== stripos( $data_src, 'data:' ) ) { $src = $data_src; } } if ( '' === $src || 0 === stripos( $src, 'data:' ) ) { return array( '', '', '' ); } $srcset = self::attr( $tag, 'srcset' ); if ( '' === $srcset ) { $srcset = self::attr( $tag, 'data-srcset' ); } $sizes = self::attr( $tag, 'sizes' ); if ( '' === $sizes ) { $sizes = self::attr( $tag, 'data-sizes' ); } return array( $src, $srcset, $sizes ); } /** * At/below this (px) in BOTH width and height, an image is treated as a * logo/icon/avatar rather than an LCP hero. 200px clears real content heroes * (which are typically ≥ 400px wide) while catching site logos and avatars * — including the 150×150 logo the picker used to mistakenly preload. */ private const MIN_LCP_DIMENSION = 200; /** * Class/role/filename markers that identify site chrome (logo, icon, * avatar, spinner, emoji) which should never be treated as the LCP hero, * regardless of declared size. */ private const NON_HERO_MARKERS = array( 'logo', 'icon', 'avatar', 'gravatar', 'spinner', 'emoji', 'site-icon', 'custom-logo' ); /** * Is this too small / too chrome-like to be the LCP hero? True when * either (a) it carries a logo/icon/avatar marker, (b) an explicit * `data-no-lcp` opt-out, or (c) BOTH width and height are present and both * are ≤ the threshold. Missing dimensions are NOT guessed — an image whose * size we can't read still competes. (FBS-83553 H1 "logo before hero".) */ private static function looks_too_small( string $tag ): bool { if ( false !== stripos( $tag, 'data-no-lcp' ) ) { return true; } // Marker check against class / id / src (covers "custom-logo", a // "…/logo.png" filename, role="img" avatars, etc.). $haystack = strtolower( self::attr( $tag, 'class' ) . ' ' . self::attr( $tag, 'id' ) . ' ' . self::attr( $tag, 'src' ) ); foreach ( self::NON_HERO_MARKERS as $marker ) { if ( false !== strpos( $haystack, $marker ) ) { return true; } } $w = self::attr( $tag, 'width' ); $h = self::attr( $tag, 'height' ); if ( '' === $w || '' === $h || ! is_numeric( $w ) || ! is_numeric( $h ) ) { return false; // unknown size — don't guess; let it compete. } return (int) $w <= self::MIN_LCP_DIMENSION && (int) $h <= self::MIN_LCP_DIMENSION; } /** * Promote an to the LCP element: force fetchpriority="high" and * strip any loading="lazy" so the browser loads it immediately. Both are * idempotent. `loading="lazy"` is REMOVED rather than flipped to "eager" * because eager is the default; a bare tag with fetchpriority="high" is * the canonical high-priority-image form. */ private static function promote_lcp_img( string $tag ): string { $tag = self::set_fetchpriority( $tag ); // Drop loading="lazy" (WP core adds it by default). Leave other // loading values (e.g. an explicit eager) intact — only lazy hurts. $tag = preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag ); return (string) $tag; } /** * Add fetchpriority="high" to an tag. Idempotent — an existing * fetchpriority value is normalised to high rather than duplicated. */ private static function set_fetchpriority( string $tag ): string { if ( preg_match( '#\bfetchpriority=(["\']).*?\1#i', $tag ) ) { return (string) preg_replace( '#\bfetchpriority=(["\']).*?\1#i', 'fetchpriority="high"', $tag, 1 ); } // Insert right after ". Prefers to land right * before the first stylesheet so the preloads are discovered before the * render-blocking CSS. Falls back to after , then prepend. */ private static function inject_into_head( string $html, string $hints ): string { // Before the first if there is one. if ( preg_match( '#]*rel=["\']stylesheet["\'][^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { $pos = $m[0][1]; return substr( $html, 0, $pos ) . $hints . substr( $html, $pos ); } // Otherwise right after the opening . if ( preg_match( '#]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { $pos = $m[0][1] + strlen( $m[0][0] ); return substr( $html, 0, $pos ) . "\n" . $hints . substr( $html, $pos ); } // No head at all — prepend (degenerate documents). return $hints . $html; } }