| 1 |
<?php |
| 2 |
/** |
| 3 |
* Resource Hints processor — pure HTML transformer for resource hints. |
| 4 |
* |
| 5 |
* Given a fully-rendered page and the Preload module's options, it: |
| 6 |
* 1. Ranks every eligible <img> by the largest declared size it can read — |
| 7 |
* width×height attributes, else the widest srcset candidate — and emits |
| 8 |
* a <link rel="preload" as="image" fetchpriority="high"> for the top N |
| 9 |
* in the <head>, carrying srcset/sizes as imagesrcset/imagesizes so the |
| 10 |
* browser can pick the right candidate — then adds fetchpriority="high" |
| 11 |
* to the <img> itself so it beats any loading="lazy" the theme set. |
| 12 |
* Ranking by size rather than document position: the first images on a |
| 13 |
* real page are usually header chrome, not the hero (#96). |
| 14 |
* 2. Emits <link rel="preconnect"> for detected web-font hosts |
| 15 |
* (fonts.googleapis.com + fonts.gstatic.com) and any user-supplied |
| 16 |
* hosts, deduped. |
| 17 |
* |
| 18 |
* Kept as a pure static so the test suite can drive it without booting the |
| 19 |
* module or WordPress hooks (mirrors Lazy_Loader::process_html). All output |
| 20 |
* is escaped at build time; callers echo the result verbatim into the body. |
| 21 |
* |
| 22 |
* @package XSpeed |
| 23 |
*/ |
| 24 |
|
| 25 |
declare(strict_types=1); |
| 26 |
|
| 27 |
namespace XSpeed; |
| 28 |
|
| 29 |
defined( 'ABSPATH' ) || exit; |
| 30 |
|
| 31 |
final class Resource_Hints_Processor { |
| 32 |
|
| 33 |
/** |
| 34 |
* Transform the page HTML, injecting preload + preconnect hints. |
| 35 |
* |
| 36 |
* @param string $html Fully-rendered page HTML. |
| 37 |
* @param array<string,mixed> $opts Preload module settings. |
| 38 |
* @return string Rewritten HTML (unchanged when disabled or no match). |
| 39 |
*/ |
| 40 |
public static function process( string $html, array $opts ): string { |
| 41 |
if ( empty( $opts['enabled'] ) ) { |
| 42 |
return $html; |
| 43 |
} |
| 44 |
|
| 45 |
// Only touch real HTML documents. A JSON/XML/feed body that happens |
| 46 |
// to reach here should pass through untouched. |
| 47 |
if ( false === stripos( $html, '<html' ) && false === stripos( $html, '<body' ) && false === stripos( $html, '<head' ) ) { |
| 48 |
return $html; |
| 49 |
} |
| 50 |
|
| 51 |
$hints = ''; |
| 52 |
|
| 53 |
if ( ! empty( $opts['preconnect'] ) || ! empty( $opts['preconnect_hosts'] ) ) { |
| 54 |
$hints .= self::build_preconnect( $html, (array) ( $opts['preconnect_hosts'] ?? array() ), ! empty( $opts['preconnect'] ) ); |
| 55 |
} |
| 56 |
|
| 57 |
if ( ! empty( $opts['lcp_preload'] ) ) { |
| 58 |
$count = max( 0, (int) ( $opts['lcp_image_count'] ?? 1 ) ); |
| 59 |
$exclusions = array_filter( array_map( 'strval', (array) ( $opts['lcp_exclusions'] ?? array() ) ) ); |
| 60 |
[ $html, $preload ] = self::build_lcp_preload( $html, $count, $exclusions ); |
| 61 |
$hints .= $preload; |
| 62 |
} |
| 63 |
|
| 64 |
// Full-page eager promotion for Lazy-excluded heroes (FBS-83553 H2). The |
| 65 |
// Lazy module only filters the_content/thumbnail/avatar/widget, so a |
| 66 |
// theme/builder hero printed OUTSIDE those keeps WP core's |
| 67 |
// loading="lazy". This pass runs over the whole document, so it can reach |
| 68 |
// those heroes: for any <img> matching an exclusion pattern, strip lazy + |
| 69 |
// set fetchpriority=high. NOTE: this mutates $html even when no <head> |
| 70 |
// hints are emitted, so it must apply before the empty-$hints early-out. |
| 71 |
$eager = array_filter( array_map( 'strval', (array) ( $opts['eager_excluded_images'] ?? array() ) ) ); |
| 72 |
if ( ! empty( $eager ) ) { |
| 73 |
$html = self::promote_excluded_images( $html, $eager ); |
| 74 |
} |
| 75 |
|
| 76 |
if ( '' === $hints ) { |
| 77 |
return $html; |
| 78 |
} |
| 79 |
|
| 80 |
return self::inject_into_head( $html, $hints ); |
| 81 |
} |
| 82 |
|
| 83 |
/** |
| 84 |
* Strip core `loading="lazy"` and add `fetchpriority="high"` + |
| 85 |
* `decoding="async"` on every <img> whose tag matches one of the given |
| 86 |
* exclusion substrings. Mirrors what Lazy_Loader does for an excluded image |
| 87 |
* inside the_content, but page-wide so heroes outside it are covered too. |
| 88 |
* (FBS-83553 H2) |
| 89 |
* |
| 90 |
* @param string $html Full page HTML. |
| 91 |
* @param string[] $exclusions Substring patterns identifying above-the-fold heroes. |
| 92 |
*/ |
| 93 |
private static function promote_excluded_images( string $html, array $exclusions ): string { |
| 94 |
return (string) preg_replace_callback( |
| 95 |
'#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', |
| 96 |
static function ( array $m ) use ( $exclusions ) { |
| 97 |
$tag = $m[0]; |
| 98 |
foreach ( $exclusions as $needle ) { |
| 99 |
if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { |
| 100 |
$tag = (string) preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag ); |
| 101 |
$tag = self::set_fetchpriority( $tag ); |
| 102 |
if ( ! preg_match( '#\bdecoding=#i', $tag ) ) { |
| 103 |
$tag = (string) preg_replace( '#<img\b#i', '<img decoding="async"', $tag, 1 ); |
| 104 |
} |
| 105 |
return $tag; |
| 106 |
} |
| 107 |
} |
| 108 |
return $tag; |
| 109 |
}, |
| 110 |
$html |
| 111 |
); |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* Build preconnect <link>s for detected font hosts + user hosts. |
| 116 |
* Deduped and idempotent (skips hosts already preconnected in $html). |
| 117 |
* |
| 118 |
* @param string $html Page HTML (scanned for font stylesheets). |
| 119 |
* @param string[] $user_hosts Extra hosts to always preconnect. |
| 120 |
* @param bool $auto_fonts Whether to auto-add font hosts. |
| 121 |
* @return string preconnect <link> markup. |
| 122 |
*/ |
| 123 |
private static function build_preconnect( string $html, array $user_hosts, bool $auto_fonts ): string { |
| 124 |
$hosts = array(); |
| 125 |
|
| 126 |
if ( $auto_fonts && false !== stripos( $html, 'fonts.googleapis.com' ) ) { |
| 127 |
// The stylesheet is on googleapis; the font files stream from |
| 128 |
// gstatic — preconnect both, gstatic needs crossorigin. |
| 129 |
$hosts['https://fonts.googleapis.com'] = false; |
| 130 |
$hosts['https://fonts.gstatic.com'] = true; |
| 131 |
} |
| 132 |
|
| 133 |
foreach ( $user_hosts as $host ) { |
| 134 |
$host = trim( (string) $host ); |
| 135 |
if ( '' === $host ) { |
| 136 |
continue; |
| 137 |
} |
| 138 |
// Cross-origin hosts get crossorigin by default; harmless for |
| 139 |
// same-scheme document hosts and required for fonts/fetch. |
| 140 |
$hosts[ untrailingslashit( $host ) ] = true; |
| 141 |
} |
| 142 |
|
| 143 |
$out = ''; |
| 144 |
foreach ( $hosts as $host => $crossorigin ) { |
| 145 |
// Idempotency: skip a host already preconnected in the document. |
| 146 |
if ( preg_match( '#rel=["\']preconnect["\'][^>]*' . preg_quote( $host, '#' ) . '#i', $html ) |
| 147 |
|| preg_match( '#' . preg_quote( $host, '#' ) . '[^>]*rel=["\']preconnect["\']#i', $html ) ) { |
| 148 |
continue; |
| 149 |
} |
| 150 |
$out .= sprintf( |
| 151 |
'<link rel="preconnect" href="%s"%s>' . "\n", |
| 152 |
esc_url( $host ), |
| 153 |
$crossorigin ? ' crossorigin' : '' |
| 154 |
); |
| 155 |
} |
| 156 |
|
| 157 |
return $out; |
| 158 |
} |
| 159 |
|
| 160 |
/** |
| 161 |
* Find the first $count eligible <img> tags, add fetchpriority="high" |
| 162 |
* to each, and return the matching <link rel=preload as=image> markup. |
| 163 |
* |
| 164 |
* @param string $html Page HTML. |
| 165 |
* @param int $count How many top images to preload. |
| 166 |
* @param string[] $exclusions Substring patterns that exempt an <img>. |
| 167 |
* @return array{0:string,1:string} [rewritten html, preload markup] |
| 168 |
*/ |
| 169 |
private static function build_lcp_preload( string $html, int $count, array $exclusions ): array { |
| 170 |
if ( $count < 1 ) { |
| 171 |
return array( $html, '' ); |
| 172 |
} |
| 173 |
|
| 174 |
$preload = ''; |
| 175 |
|
| 176 |
// Snapshot of already-present preload markup, for idempotency: a second |
| 177 |
// pass (e.g. cache-off ob_start over an already-processed body) must not |
| 178 |
// re-emit a <link> for an image we preloaded before. |
| 179 |
$existing = $html; |
| 180 |
|
| 181 |
// PASS 1 — collect every eligible <img> and score it. |
| 182 |
// |
| 183 |
// This used to preload the first N eligible tags in DOCUMENT ORDER. |
| 184 |
// Position is not a proxy for rendered size: on real pages the first |
| 185 |
// images are header chrome, breadcrumbs or badge rows, and the actual |
| 186 |
// LCP element is a hero further down. Preloading the wrong image gains |
| 187 |
// nothing — it just adds a high-priority request competing with the |
| 188 |
// one that matters, and the feature reported success either way. The |
| 189 |
// marker list and size gate were heuristics layered on top of the |
| 190 |
// wrong primitive rather than replacing it. (#96) |
| 191 |
$candidates = array(); |
| 192 |
if ( preg_match_all( '#<img\b[^>]*>#i', $html, $matches ) ) { |
| 193 |
foreach ( $matches[0] as $index => $tag ) { |
| 194 |
// Skip anything the user excluded. |
| 195 |
$excluded = false; |
| 196 |
foreach ( $exclusions as $needle ) { |
| 197 |
if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { |
| 198 |
$excluded = true; |
| 199 |
break; |
| 200 |
} |
| 201 |
} |
| 202 |
if ( $excluded ) { |
| 203 |
continue; |
| 204 |
} |
| 205 |
|
| 206 |
// Resolve the EFFECTIVE image URL. Page builders + JS lazy |
| 207 |
// loaders park a placeholder (a data: URI or a 1px spacer) in |
| 208 |
// `src` and the real URL in `data-src`, so the hero the browser |
| 209 |
// actually paints is behind data-src. (FBS-83553 H1) |
| 210 |
[ $src, $srcset, $sizes ] = self::effective_image_src( $tag ); |
| 211 |
if ( '' === $src ) { |
| 212 |
continue; // no real URL (pure data-URI spacer, no data-src). |
| 213 |
} |
| 214 |
|
| 215 |
// Chrome markers / explicit opt-out / obviously-tiny images |
| 216 |
// never compete. (FBS-83553 H1 "logo before hero".) |
| 217 |
if ( self::looks_too_small( $tag ) ) { |
| 218 |
continue; |
| 219 |
} |
| 220 |
|
| 221 |
$candidates[] = array( |
| 222 |
'tag' => $tag, |
| 223 |
'src' => $src, |
| 224 |
'srcset' => $srcset, |
| 225 |
'sizes' => $sizes, |
| 226 |
'score' => self::lcp_score( $tag, $srcset ), |
| 227 |
'order' => $index, |
| 228 |
); |
| 229 |
} |
| 230 |
} |
| 231 |
|
| 232 |
if ( empty( $candidates ) ) { |
| 233 |
return array( $html, '' ); |
| 234 |
} |
| 235 |
|
| 236 |
// Rank by score, biggest first. Document order breaks ties, so two |
| 237 |
// equally-sized images (or two of unknown size) keep the previous |
| 238 |
// first-wins behaviour — the change only matters when we can actually |
| 239 |
// tell one is larger. |
| 240 |
usort( |
| 241 |
$candidates, |
| 242 |
static function ( array $a, array $b ) { |
| 243 |
if ( $a['score'] === $b['score'] ) { |
| 244 |
return $a['order'] <=> $b['order']; |
| 245 |
} |
| 246 |
return $b['score'] <=> $a['score']; |
| 247 |
} |
| 248 |
); |
| 249 |
|
| 250 |
$winners = array_slice( $candidates, 0, $count ); |
| 251 |
|
| 252 |
// PASS 2 — emit the preload links and promote the winning tags. |
| 253 |
$chosen = array(); |
| 254 |
foreach ( $winners as $w ) { |
| 255 |
// Idempotency: if this src is already the target of a |
| 256 |
// rel="preload" as="image" link, still promote the tag but don't |
| 257 |
// emit a duplicate <link>. |
| 258 |
$already = (bool) preg_match( |
| 259 |
'#rel=["\']preload["\'][^>]*as=["\']image["\'][^>]*' . preg_quote( $w['src'], '#' ) . '#i', |
| 260 |
$existing |
| 261 |
); |
| 262 |
if ( ! $already ) { |
| 263 |
$preload .= self::preload_link( $w['src'], $w['srcset'], $w['sizes'] ); |
| 264 |
} |
| 265 |
$chosen[ $w['order'] ] = true; |
| 266 |
} |
| 267 |
|
| 268 |
// Rewrite only the winning tags. Counting occurrences rather than |
| 269 |
// matching on tag text, because the same markup can legitimately |
| 270 |
// appear more than once on a page and only the ranked instance should |
| 271 |
// be promoted. |
| 272 |
$seen = -1; |
| 273 |
$html = preg_replace_callback( |
| 274 |
'#<img\b[^>]*>#i', |
| 275 |
static function ( array $m ) use ( &$seen, $chosen ) { |
| 276 |
++$seen; |
| 277 |
if ( ! isset( $chosen[ $seen ] ) ) { |
| 278 |
return $m[0]; |
| 279 |
} |
| 280 |
// Add fetchpriority="high" AND remove any loading="lazy" the |
| 281 |
// theme / WP core left on the LCP image. fetchpriority="high" |
| 282 |
// with loading="lazy" is contradictory — the browser can still |
| 283 |
// defer a lazy image, so preloading it while it stays lazy wins |
| 284 |
// nothing. Stripping lazy is what actually lets the preload land. |
| 285 |
return self::promote_lcp_img( $m[0] ); |
| 286 |
}, |
| 287 |
$html |
| 288 |
); |
| 289 |
|
| 290 |
return array( (string) $html, $preload ); |
| 291 |
} |
| 292 |
|
| 293 |
/** |
| 294 |
* How likely is this <img> to be the LCP element? Higher wins. |
| 295 |
* |
| 296 |
* Rendered area is the best available proxy, and we can only read what the |
| 297 |
* markup declares: |
| 298 |
* |
| 299 |
* 1. `width` × `height` attributes — the real area, when present. |
| 300 |
* 2. The largest `srcset` / `data-srcset` candidate width, squared into a |
| 301 |
* pseudo-area. A responsive hero usually omits width/height but ships |
| 302 |
* a 1600w+ candidate, which says more about its size than its |
| 303 |
* position ever did. |
| 304 |
* 3. Nothing readable → a neutral score, so the image still competes |
| 305 |
* (matching looks_too_small()'s "don't guess" rule) but loses to any |
| 306 |
* image we CAN measure as larger. |
| 307 |
* |
| 308 |
* @param string $tag The full <img> tag. |
| 309 |
* @param string $srcset Resolved srcset (may come from data-srcset). |
| 310 |
*/ |
| 311 |
private static function lcp_score( string $tag, string $srcset ): int { |
| 312 |
$w = self::attr( $tag, 'width' ); |
| 313 |
$h = self::attr( $tag, 'height' ); |
| 314 |
if ( '' !== $w && '' !== $h && is_numeric( $w ) && is_numeric( $h ) ) { |
| 315 |
return (int) $w * (int) $h; |
| 316 |
} |
| 317 |
|
| 318 |
$widest = self::widest_srcset_width( $srcset ); |
| 319 |
if ( $widest > 0 ) { |
| 320 |
// Estimate an AREA, not a square. Squaring the width compared a |
| 321 |
// pseudo-area against a real one and overstated the width-only |
| 322 |
// candidate by roughly the inverse of its aspect ratio, so a |
| 323 |
// 1024w sidebar thumbnail (1 048 576) beat a declared 1200×600 |
| 324 |
// hero (720 000) — a regression on exactly the mixed pages that |
| 325 |
// document order used to get right, since the hero usually comes |
| 326 |
// first. Assuming a 16:9 box keeps both sides in the same units. |
| 327 |
return (int) round( $widest * $widest * self::ASSUMED_ASPECT_RATIO ); |
| 328 |
} |
| 329 |
|
| 330 |
return self::UNKNOWN_SIZE_SCORE; |
| 331 |
} |
| 332 |
|
| 333 |
/** |
| 334 |
* Score for an image whose size we can't read at all. |
| 335 |
* |
| 336 |
* Deliberately non-zero: an unmeasurable image must still beat nothing and |
| 337 |
* still be preloadable on a page where no image declares its size. But it |
| 338 |
* sits below a 200×200 declared area (40 000), so anything we CAN measure |
| 339 |
* as a plausible hero outranks a guess. |
| 340 |
*/ |
| 341 |
private const UNKNOWN_SIZE_SCORE = 1; |
| 342 |
|
| 343 |
/** |
| 344 |
* Height-to-width ratio assumed when only a `w` descriptor is readable. |
| 345 |
* |
| 346 |
* 9/16 — the commonest hero/banner shape, and close enough that a |
| 347 |
* width-only candidate is compared against a declared w×h area on the |
| 348 |
* same scale rather than being systematically inflated. |
| 349 |
*/ |
| 350 |
private const ASSUMED_ASPECT_RATIO = 9 / 16; |
| 351 |
|
| 352 |
/** |
| 353 |
* Largest `w` descriptor in a srcset, or 0 when there isn't one. |
| 354 |
* |
| 355 |
* Only `w` descriptors are read. An `x` descriptor (`hero.jpg 2x`) |
| 356 |
* describes pixel density, not layout width, so it says nothing about |
| 357 |
* rendered area. |
| 358 |
*/ |
| 359 |
private static function widest_srcset_width( string $srcset ): int { |
| 360 |
if ( '' === $srcset ) { |
| 361 |
return 0; |
| 362 |
} |
| 363 |
$widest = 0; |
| 364 |
foreach ( explode( ',', $srcset ) as $candidate ) { |
| 365 |
if ( preg_match( '#(\d+)w\s*$#', trim( $candidate ), $m ) ) { |
| 366 |
$widest = max( $widest, (int) $m[1] ); |
| 367 |
} |
| 368 |
} |
| 369 |
return $widest; |
| 370 |
} |
| 371 |
|
| 372 |
/** |
| 373 |
* Assemble one <link rel="preload" as="image" fetchpriority="high">. |
| 374 |
* |
| 375 |
* The href/srcset run through the `xspeed_lcp_preload_url` / |
| 376 |
* `xspeed_lcp_preload_srcset` filters first. This is the coordination point |
| 377 |
* with format-negotiating layers (Pro's Images module wraps the LCP <img> in |
| 378 |
* a <picture> with a WebP/AVIF <source>, so the browser paints e.g. |
| 379 |
* hero.png.webp, NOT the hero.png this preload would otherwise point at — |
| 380 |
* making the high-priority preload a wasted download while the real LCP |
| 381 |
* resource goes un-preloaded). By filtering the URL, a webp/avif layer can |
| 382 |
* redirect the preload to the format it will actually serve, WITHOUT Free |
| 383 |
* knowing that layer exists. (FBS-83553 H3) |
| 384 |
*/ |
| 385 |
private static function preload_link( string $src, string $srcset, string $sizes ): string { |
| 386 |
// Resolve the `type` from the ORIGINAL image URL (before rewriting), so a |
| 387 |
// negotiating layer can key off the source .jpg/.png — after rewriting, |
| 388 |
// the URL is already a .webp and the derivation would no-op. |
| 389 |
$original = $src; |
| 390 |
/** |
| 391 |
* Filter an explicit `type` for the preload link (e.g. "image/webp"). |
| 392 |
* Empty = omit. A typed image preload is only fetched by browsers that |
| 393 |
* accept that type, so pairing a webp href with type="image/webp" is safe |
| 394 |
* even though the markup is baked into a shared cache file. |
| 395 |
* |
| 396 |
* @param string $type Defaults to '' (no type attribute). |
| 397 |
* @param string $src The ORIGINAL (pre-rewrite) preload URL. |
| 398 |
*/ |
| 399 |
$type = (string) apply_filters( 'xspeed_lcp_preload_type', '', $original ); |
| 400 |
/** |
| 401 |
* Filter the LCP preload href. Return a modern-format sibling (webp/avif) |
| 402 |
* when one will actually be served for this image. |
| 403 |
* |
| 404 |
* @param string $src The original image URL chosen for preload. |
| 405 |
*/ |
| 406 |
$src = (string) apply_filters( 'xspeed_lcp_preload_url', $src ); |
| 407 |
if ( '' !== $srcset ) { |
| 408 |
/** @param string $srcset The original srcset chosen for preload. */ |
| 409 |
$srcset = (string) apply_filters( 'xspeed_lcp_preload_srcset', $srcset ); |
| 410 |
} |
| 411 |
|
| 412 |
$attrs = sprintf( 'href="%s"', esc_url( $src ) ); |
| 413 |
|
| 414 |
if ( '' !== $srcset ) { |
| 415 |
// Preserve the responsive candidate set so the browser preloads |
| 416 |
// the same file it would have chosen from the <img>. |
| 417 |
$attrs .= sprintf( ' imagesrcset="%s"', esc_attr( html_entity_decode( $srcset, ENT_QUOTES ) ) ); |
| 418 |
if ( '' !== $sizes ) { |
| 419 |
$attrs .= sprintf( ' imagesizes="%s"', esc_attr( html_entity_decode( $sizes, ENT_QUOTES ) ) ); |
| 420 |
} |
| 421 |
} |
| 422 |
|
| 423 |
if ( '' !== $type ) { |
| 424 |
$attrs .= sprintf( ' type="%s"', esc_attr( $type ) ); |
| 425 |
} |
| 426 |
|
| 427 |
return sprintf( '<link rel="preload" as="image" %s fetchpriority="high">' . "\n", $attrs ); |
| 428 |
} |
| 429 |
|
| 430 |
/** |
| 431 |
* Extract a single/double-quoted attribute value from a tag. Returns '' |
| 432 |
* when the attribute is absent. |
| 433 |
*/ |
| 434 |
private static function attr( string $tag, string $name ): string { |
| 435 |
// Anchor on a real attribute boundary, not `\b`. A word boundary sits |
| 436 |
// between the `-` and the `w` of `data-width`, so `\bwidth=` matched |
| 437 |
// inside it: a lazy-loaded hero carrying `data-width="50" |
| 438 |
// data-height="50"` was scored 50×50 and rejected by |
| 439 |
// looks_too_small() — defeating the feature on exactly the images the |
| 440 |
// data-src/data-srcset handling exists to support. Requiring |
| 441 |
// whitespace (or the start of the string) before the name means only |
| 442 |
// a genuine attribute matches. |
| 443 |
if ( preg_match( '#(?:^|\s)' . preg_quote( $name, '#' ) . '\s*=\s*(["\'])(.*?)\1#is', $tag, $m ) ) { |
| 444 |
return trim( $m[2] ); |
| 445 |
} |
| 446 |
return ''; |
| 447 |
} |
| 448 |
|
| 449 |
/** |
| 450 |
* Resolve the URL/srcset/sizes the browser will actually paint for an |
| 451 |
* <img>, seeing through JS-lazy placeholders. When `src` is a data: URI (a |
| 452 |
* builder/lazy-loader placeholder), fall back to `data-src`; likewise carry |
| 453 |
* `data-srcset`/`data-sizes` when the plain ones are absent. Returns |
| 454 |
* ['', '', ''] when there's no real raster URL to preload. (FBS-83553 H1) |
| 455 |
* |
| 456 |
* @return array{0:string,1:string,2:string} [src, srcset, sizes] |
| 457 |
*/ |
| 458 |
private static function effective_image_src( string $tag ): array { |
| 459 |
$src = self::attr( $tag, 'src' ); |
| 460 |
if ( '' === $src || 0 === stripos( $src, 'data:' ) ) { |
| 461 |
$data_src = self::attr( $tag, 'data-src' ); |
| 462 |
if ( '' !== $data_src && 0 !== stripos( $data_src, 'data:' ) ) { |
| 463 |
$src = $data_src; |
| 464 |
} |
| 465 |
} |
| 466 |
if ( '' === $src || 0 === stripos( $src, 'data:' ) ) { |
| 467 |
return array( '', '', '' ); |
| 468 |
} |
| 469 |
$srcset = self::attr( $tag, 'srcset' ); |
| 470 |
if ( '' === $srcset ) { |
| 471 |
$srcset = self::attr( $tag, 'data-srcset' ); |
| 472 |
} |
| 473 |
$sizes = self::attr( $tag, 'sizes' ); |
| 474 |
if ( '' === $sizes ) { |
| 475 |
$sizes = self::attr( $tag, 'data-sizes' ); |
| 476 |
} |
| 477 |
return array( $src, $srcset, $sizes ); |
| 478 |
} |
| 479 |
|
| 480 |
/** |
| 481 |
* At/below this (px) in BOTH width and height, an image is treated as a |
| 482 |
* logo/icon/avatar rather than an LCP hero. 200px clears real content heroes |
| 483 |
* (which are typically ≥ 400px wide) while catching site logos and avatars |
| 484 |
* — including the 150×150 logo the picker used to mistakenly preload. |
| 485 |
*/ |
| 486 |
private const MIN_LCP_DIMENSION = 200; |
| 487 |
|
| 488 |
/** |
| 489 |
* Class/role/filename markers that identify site chrome (logo, icon, |
| 490 |
* avatar, spinner, emoji) which should never be treated as the LCP hero, |
| 491 |
* regardless of declared size. |
| 492 |
*/ |
| 493 |
private const NON_HERO_MARKERS = array( 'logo', 'icon', 'avatar', 'gravatar', 'spinner', 'emoji', 'site-icon', 'custom-logo' ); |
| 494 |
|
| 495 |
/** |
| 496 |
* Is this <img> too small / too chrome-like to be the LCP hero? True when |
| 497 |
* either (a) it carries a logo/icon/avatar marker, (b) an explicit |
| 498 |
* `data-no-lcp` opt-out, or (c) BOTH width and height are present and both |
| 499 |
* are ≤ the threshold. Missing dimensions are NOT guessed — an image whose |
| 500 |
* size we can't read still competes. (FBS-83553 H1 "logo before hero".) |
| 501 |
*/ |
| 502 |
private static function looks_too_small( string $tag ): bool { |
| 503 |
if ( false !== stripos( $tag, 'data-no-lcp' ) ) { |
| 504 |
return true; |
| 505 |
} |
| 506 |
// Marker check against class / id / src (covers "custom-logo", a |
| 507 |
// "…/logo.png" filename, role="img" avatars, etc.). |
| 508 |
$haystack = strtolower( self::attr( $tag, 'class' ) . ' ' . self::attr( $tag, 'id' ) . ' ' . self::attr( $tag, 'src' ) ); |
| 509 |
foreach ( self::NON_HERO_MARKERS as $marker ) { |
| 510 |
if ( false !== strpos( $haystack, $marker ) ) { |
| 511 |
return true; |
| 512 |
} |
| 513 |
} |
| 514 |
$w = self::attr( $tag, 'width' ); |
| 515 |
$h = self::attr( $tag, 'height' ); |
| 516 |
if ( '' === $w || '' === $h || ! is_numeric( $w ) || ! is_numeric( $h ) ) { |
| 517 |
return false; // unknown size — don't guess; let it compete. |
| 518 |
} |
| 519 |
return (int) $w <= self::MIN_LCP_DIMENSION && (int) $h <= self::MIN_LCP_DIMENSION; |
| 520 |
} |
| 521 |
|
| 522 |
/** |
| 523 |
* Promote an <img> to the LCP element: force fetchpriority="high" and |
| 524 |
* strip any loading="lazy" so the browser loads it immediately. Both are |
| 525 |
* idempotent. `loading="lazy"` is REMOVED rather than flipped to "eager" |
| 526 |
* because eager is the default; a bare tag with fetchpriority="high" is |
| 527 |
* the canonical high-priority-image form. |
| 528 |
*/ |
| 529 |
private static function promote_lcp_img( string $tag ): string { |
| 530 |
$tag = self::set_fetchpriority( $tag ); |
| 531 |
// Drop loading="lazy" (WP core adds it by default). Leave other |
| 532 |
// loading values (e.g. an explicit eager) intact — only lazy hurts. |
| 533 |
$tag = preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag ); |
| 534 |
return (string) $tag; |
| 535 |
} |
| 536 |
|
| 537 |
/** |
| 538 |
* Add fetchpriority="high" to an <img> tag. Idempotent — an existing |
| 539 |
* fetchpriority value is normalised to high rather than duplicated. |
| 540 |
*/ |
| 541 |
private static function set_fetchpriority( string $tag ): string { |
| 542 |
if ( preg_match( '#\bfetchpriority=(["\']).*?\1#i', $tag ) ) { |
| 543 |
return (string) preg_replace( '#\bfetchpriority=(["\']).*?\1#i', 'fetchpriority="high"', $tag, 1 ); |
| 544 |
} |
| 545 |
// Insert right after "<img". |
| 546 |
return (string) preg_replace( '#<img\b#i', '<img fetchpriority="high"', $tag, 1 ); |
| 547 |
} |
| 548 |
|
| 549 |
/** |
| 550 |
* Inject the assembled hint markup into <head>. Prefers to land right |
| 551 |
* before the first stylesheet so the preloads are discovered before the |
| 552 |
* render-blocking CSS. Falls back to after <head>, then prepend. |
| 553 |
*/ |
| 554 |
private static function inject_into_head( string $html, string $hints ): string { |
| 555 |
// Before the first <link rel="stylesheet"> if there is one. |
| 556 |
if ( preg_match( '#<link\b[^>]*rel=["\']stylesheet["\'][^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { |
| 557 |
$pos = $m[0][1]; |
| 558 |
return substr( $html, 0, $pos ) . $hints . substr( $html, $pos ); |
| 559 |
} |
| 560 |
// Otherwise right after the opening <head ...>. |
| 561 |
if ( preg_match( '#<head\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { |
| 562 |
$pos = $m[0][1] + strlen( $m[0][0] ); |
| 563 |
return substr( $html, 0, $pos ) . "\n" . $hints . substr( $html, $pos ); |
| 564 |
} |
| 565 |
// No head at all — prepend (degenerate documents). |
| 566 |
return $hints . $html; |
| 567 |
} |
| 568 |
} |
| 569 |
|