| 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 — boosted by |
| 8 |
* the author's own priority signals (fetchpriority="high", an explicit |
| 9 |
* loading="eager") and lightly weighted by document position, and emits |
| 10 |
* a <link rel="preload" as="image" fetchpriority="high"> for the top N |
| 11 |
* in the <head>, carrying srcset/sizes as imagesrcset/imagesizes so the |
| 12 |
* browser can pick the right candidate — then adds fetchpriority="high" |
| 13 |
* to the <img> itself. Ranking by size rather than document position: |
| 14 |
* the first images on a real page are usually header chrome, not the |
| 15 |
* hero (#96). Images inside <footer>/<nav>/<aside>, images the theme |
| 16 |
* explicitly lazy-loads, and tiny images never compete (FBS-84576). |
| 17 |
* 2. Emits <link rel="preconnect"> for detected web-font hosts |
| 18 |
* (fonts.googleapis.com + fonts.gstatic.com) and any user-supplied |
| 19 |
* hosts, deduped. |
| 20 |
* |
| 21 |
* Kept as a pure static so the test suite can drive it without booting the |
| 22 |
* module or WordPress hooks (mirrors Lazy_Loader::process_html). All output |
| 23 |
* is escaped at build time; callers echo the result verbatim into the body. |
| 24 |
* |
| 25 |
* @package XSpeed |
| 26 |
*/ |
| 27 |
|
| 28 |
declare(strict_types=1); |
| 29 |
|
| 30 |
namespace XSpeed; |
| 31 |
|
| 32 |
defined( 'ABSPATH' ) || exit; |
| 33 |
|
| 34 |
final class Resource_Hints_Processor { |
| 35 |
|
| 36 |
/** |
| 37 |
* Transform the page HTML, injecting preload + preconnect hints. |
| 38 |
* |
| 39 |
* @param string $html Fully-rendered page HTML. |
| 40 |
* @param array<string,mixed> $opts Preload module settings. |
| 41 |
* @return string Rewritten HTML (unchanged when disabled or no match). |
| 42 |
*/ |
| 43 |
public static function process( string $html, array $opts ): string { |
| 44 |
if ( empty( $opts['enabled'] ) ) { |
| 45 |
return $html; |
| 46 |
} |
| 47 |
|
| 48 |
// Only touch real HTML documents. A JSON/XML/feed body that happens |
| 49 |
// to reach here should pass through untouched. |
| 50 |
if ( false === stripos( $html, '<html' ) && false === stripos( $html, '<body' ) && false === stripos( $html, '<head' ) ) { |
| 51 |
return $html; |
| 52 |
} |
| 53 |
|
| 54 |
$hints = ''; |
| 55 |
|
| 56 |
if ( ! empty( $opts['preconnect'] ) || ! empty( $opts['preconnect_hosts'] ) ) { |
| 57 |
$hints .= self::build_preconnect( $html, (array) ( $opts['preconnect_hosts'] ?? array() ), ! empty( $opts['preconnect'] ) ); |
| 58 |
} |
| 59 |
|
| 60 |
if ( ! empty( $opts['lcp_preload'] ) ) { |
| 61 |
$count = max( 0, (int) ( $opts['lcp_image_count'] ?? 1 ) ); |
| 62 |
$exclusions = array_filter( array_map( 'strval', (array) ( $opts['lcp_exclusions'] ?? array() ) ) ); |
| 63 |
[ $html, $preload ] = self::build_lcp_preload( $html, $count, $exclusions ); |
| 64 |
$hints .= $preload; |
| 65 |
} |
| 66 |
|
| 67 |
// The manual list runs AFTER the automatic pick so it can deduplicate |
| 68 |
// against it: a URL both name should carry ONE hint, not two. It exists |
| 69 |
// for the image the detector cannot see — most often a hero section's |
| 70 |
// CSS background-image, where the LCP element is a <div> with none of |
| 71 |
// the width/height/fetchpriority signals the scorer reads. On the site |
| 72 |
// that surfaced this, 3.3s of a 5.3s mobile LCP was pure discovery |
| 73 |
// delay for exactly such an image. (FBS-84578) |
| 74 |
$manual = array_filter( array_map( 'strval', (array) ( $opts['preload_images'] ?? array() ) ) ); |
| 75 |
if ( ! empty( $manual ) ) { |
| 76 |
$hints .= self::build_manual_image_preloads( $manual, $hints ); |
| 77 |
} |
| 78 |
|
| 79 |
// Full-page eager promotion for Lazy-excluded heroes (FBS-83553 H2). The |
| 80 |
// Lazy module only filters the_content/thumbnail/avatar/widget, so a |
| 81 |
// theme/builder hero printed OUTSIDE those keeps WP core's |
| 82 |
// loading="lazy". This pass runs over the whole document, so it can reach |
| 83 |
// those heroes: for any <img> matching an exclusion pattern, strip lazy + |
| 84 |
// set fetchpriority=high. NOTE: this mutates $html even when no <head> |
| 85 |
// hints are emitted, so it must apply before the empty-$hints early-out. |
| 86 |
$eager = array_filter( array_map( 'strval', (array) ( $opts['eager_excluded_images'] ?? array() ) ) ); |
| 87 |
if ( ! empty( $eager ) ) { |
| 88 |
$html = self::promote_excluded_images( $html, $eager ); |
| 89 |
} |
| 90 |
|
| 91 |
if ( '' === $hints ) { |
| 92 |
return $html; |
| 93 |
} |
| 94 |
|
| 95 |
return self::inject_into_head( $html, $hints ); |
| 96 |
} |
| 97 |
|
| 98 |
/** |
| 99 |
* How many entries of the manual preload list are honoured. Preloading |
| 100 |
* competes with the page for its top network priority — a long list |
| 101 |
* inverts the benefit, so the cap is deliberately small. |
| 102 |
*/ |
| 103 |
private const MAX_MANUAL_PRELOADS = 3; |
| 104 |
|
| 105 |
/** |
| 106 |
* One `<link rel="preload" as="image">` per manual list entry. |
| 107 |
* |
| 108 |
* Entries are full URLs or site-relative paths. Anything that is neither |
| 109 |
* (a data: URI, a bare word) is skipped rather than guessed at, and a URL |
| 110 |
* the automatic pick already emitted is skipped too — one hint per image, |
| 111 |
* whoever names it first. |
| 112 |
* |
| 113 |
* @param array<int,string> $urls The configured list. |
| 114 |
* @param string $existing_hints Hints already built this request. |
| 115 |
*/ |
| 116 |
private static function build_manual_image_preloads( array $urls, string $existing_hints ): string { |
| 117 |
/** |
| 118 |
* Filter the manual image-preload list before it is emitted. |
| 119 |
* |
| 120 |
* @param array<int,string> $urls Configured URLs, in panel order. |
| 121 |
*/ |
| 122 |
$urls = (array) apply_filters( 'xspeed_preload_images', $urls ); |
| 123 |
|
| 124 |
$out = ''; |
| 125 |
$seen = array(); |
| 126 |
foreach ( $urls as $url ) { |
| 127 |
$url = trim( (string) $url ); |
| 128 |
if ( '' === $url ) { |
| 129 |
continue; |
| 130 |
} |
| 131 |
// A full URL or a site-relative path; nothing else is guessable. |
| 132 |
$is_absolute = (bool) preg_match( '#^https?://#i', $url ); |
| 133 |
$is_relative = '' !== $url && '/' === $url[0] && ( strlen( $url ) < 2 || '/' !== $url[1] ); |
| 134 |
if ( ! $is_absolute && ! $is_relative ) { |
| 135 |
continue; |
| 136 |
} |
| 137 |
$href = esc_url( $url ); |
| 138 |
if ( '' === $href || isset( $seen[ $href ] ) || false !== strpos( $existing_hints, 'href="' . $href . '"' ) ) { |
| 139 |
continue; |
| 140 |
} |
| 141 |
$seen[ $href ] = true; |
| 142 |
$out .= '<link rel="preload" as="image" href="' . $href . '" fetchpriority="high">'; |
| 143 |
if ( count( $seen ) >= self::MAX_MANUAL_PRELOADS ) { |
| 144 |
break; |
| 145 |
} |
| 146 |
} |
| 147 |
|
| 148 |
return $out; |
| 149 |
} |
| 150 |
|
| 151 |
/** |
| 152 |
* Strip core `loading="lazy"` and add `fetchpriority="high"` + |
| 153 |
* `decoding="async"` on every <img> whose tag matches one of the given |
| 154 |
* exclusion substrings. Mirrors what Lazy_Loader does for an excluded image |
| 155 |
* inside the_content, but page-wide so heroes outside it are covered too. |
| 156 |
* (FBS-83553 H2) |
| 157 |
* |
| 158 |
* @param string $html Full page HTML. |
| 159 |
* @param string[] $exclusions Substring patterns identifying above-the-fold heroes. |
| 160 |
*/ |
| 161 |
private static function promote_excluded_images( string $html, array $exclusions ): string { |
| 162 |
return (string) preg_replace_callback( |
| 163 |
'#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', |
| 164 |
static function ( array $m ) use ( $exclusions ) { |
| 165 |
$tag = $m[0]; |
| 166 |
foreach ( $exclusions as $needle ) { |
| 167 |
if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { |
| 168 |
$tag = (string) preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag ); |
| 169 |
$tag = self::set_fetchpriority( $tag ); |
| 170 |
if ( ! preg_match( '#\bdecoding=#i', $tag ) ) { |
| 171 |
$tag = (string) preg_replace( '#<img\b#i', '<img decoding="async"', $tag, 1 ); |
| 172 |
} |
| 173 |
return $tag; |
| 174 |
} |
| 175 |
} |
| 176 |
return $tag; |
| 177 |
}, |
| 178 |
$html |
| 179 |
); |
| 180 |
} |
| 181 |
|
| 182 |
/** |
| 183 |
* Build preconnect <link>s for detected font hosts + user hosts. |
| 184 |
* Deduped and idempotent (skips hosts already preconnected in $html). |
| 185 |
* |
| 186 |
* @param string $html Page HTML (scanned for font stylesheets). |
| 187 |
* @param string[] $user_hosts Extra hosts to always preconnect. |
| 188 |
* @param bool $auto_fonts Whether to auto-add font hosts. |
| 189 |
* @return string preconnect <link> markup. |
| 190 |
*/ |
| 191 |
private static function build_preconnect( string $html, array $user_hosts, bool $auto_fonts ): string { |
| 192 |
$hosts = array(); |
| 193 |
|
| 194 |
if ( $auto_fonts && false !== stripos( $html, 'fonts.googleapis.com' ) ) { |
| 195 |
// The stylesheet is on googleapis; the font files stream from |
| 196 |
// gstatic — preconnect both, gstatic needs crossorigin. |
| 197 |
$hosts['https://fonts.googleapis.com'] = false; |
| 198 |
$hosts['https://fonts.gstatic.com'] = true; |
| 199 |
} |
| 200 |
|
| 201 |
foreach ( $user_hosts as $host ) { |
| 202 |
$host = trim( (string) $host ); |
| 203 |
if ( '' === $host ) { |
| 204 |
continue; |
| 205 |
} |
| 206 |
// Cross-origin hosts get crossorigin by default; harmless for |
| 207 |
// same-scheme document hosts and required for fonts/fetch. |
| 208 |
$hosts[ untrailingslashit( $host ) ] = true; |
| 209 |
} |
| 210 |
|
| 211 |
$out = ''; |
| 212 |
foreach ( $hosts as $host => $crossorigin ) { |
| 213 |
// Idempotency: skip a host already preconnected in the document. |
| 214 |
if ( preg_match( '#rel=["\']preconnect["\'][^>]*' . preg_quote( $host, '#' ) . '#i', $html ) |
| 215 |
|| preg_match( '#' . preg_quote( $host, '#' ) . '[^>]*rel=["\']preconnect["\']#i', $html ) ) { |
| 216 |
continue; |
| 217 |
} |
| 218 |
$out .= sprintf( |
| 219 |
'<link rel="preconnect" href="%s"%s>' . "\n", |
| 220 |
esc_url( $host ), |
| 221 |
$crossorigin ? ' crossorigin' : '' |
| 222 |
); |
| 223 |
} |
| 224 |
|
| 225 |
return $out; |
| 226 |
} |
| 227 |
|
| 228 |
/** |
| 229 |
* Find the first $count eligible <img> tags, add fetchpriority="high" |
| 230 |
* to each, and return the matching <link rel=preload as=image> markup. |
| 231 |
* |
| 232 |
* @param string $html Page HTML. |
| 233 |
* @param int $count How many top images to preload. |
| 234 |
* @param string[] $exclusions Substring patterns that exempt an <img>. |
| 235 |
* @return array{0:string,1:string} [rewritten html, preload markup] |
| 236 |
*/ |
| 237 |
private static function build_lcp_preload( string $html, int $count, array $exclusions ): array { |
| 238 |
if ( $count < 1 ) { |
| 239 |
return array( $html, '' ); |
| 240 |
} |
| 241 |
|
| 242 |
$preload = ''; |
| 243 |
|
| 244 |
// Snapshot of already-present preload markup, for idempotency: a second |
| 245 |
// pass (e.g. cache-off ob_start over an already-processed body) must not |
| 246 |
// re-emit a <link> for an image we preloaded before. |
| 247 |
$existing = $html; |
| 248 |
|
| 249 |
// PASS 1 — collect every eligible <img> and score it. |
| 250 |
// |
| 251 |
// This used to preload the first N eligible tags in DOCUMENT ORDER. |
| 252 |
// Position is not a proxy for rendered size: on real pages the first |
| 253 |
// images are header chrome, breadcrumbs or badge rows, and the actual |
| 254 |
// LCP element is a hero further down. Preloading the wrong image gains |
| 255 |
// nothing — it just adds a high-priority request competing with the |
| 256 |
// one that matters, and the feature reported success either way. The |
| 257 |
// marker list and size gate were heuristics layered on top of the |
| 258 |
// wrong primitive rather than replacing it. (#96) |
| 259 |
$candidates = array(); |
| 260 |
$skip_ranges = self::chrome_container_ranges( $html ); |
| 261 |
if ( preg_match_all( '#<img\b[^>]*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) { |
| 262 |
foreach ( $matches[0] as $index => $match ) { |
| 263 |
[ $tag, $offset ] = $match; |
| 264 |
|
| 265 |
// Skip anything the user excluded. |
| 266 |
$excluded = false; |
| 267 |
foreach ( $exclusions as $needle ) { |
| 268 |
if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { |
| 269 |
$excluded = true; |
| 270 |
break; |
| 271 |
} |
| 272 |
} |
| 273 |
if ( $excluded ) { |
| 274 |
continue; |
| 275 |
} |
| 276 |
|
| 277 |
// An image inside <footer>/<nav>/<aside> is site chrome by |
| 278 |
// construction — a footer brand strip or FAQ illustration can |
| 279 |
// never be the LCP element, whatever size it declares. On the |
| 280 |
// FBS-84576 repro these decoys outranked the real hero three |
| 281 |
// times on one layout. |
| 282 |
if ( self::offset_in_ranges( $offset, $skip_ranges ) ) { |
| 283 |
continue; |
| 284 |
} |
| 285 |
|
| 286 |
// An image the theme explicitly lazy-loads is never the |
| 287 |
// intended LCP — the author has already said "this can wait". |
| 288 |
// Preloading it would contradict the markup and steal |
| 289 |
// bandwidth from the image that matters. (FBS-84576) |
| 290 |
if ( 'lazy' === strtolower( self::attr( $tag, 'loading' ) ) ) { |
| 291 |
continue; |
| 292 |
} |
| 293 |
|
| 294 |
// Resolve the EFFECTIVE image URL. Page builders + JS lazy |
| 295 |
// loaders park a placeholder (a data: URI or a 1px spacer) in |
| 296 |
// `src` and the real URL in `data-src`, so the hero the browser |
| 297 |
// actually paints is behind data-src. (FBS-83553 H1) |
| 298 |
[ $src, $srcset, $sizes ] = self::effective_image_src( $tag ); |
| 299 |
if ( '' === $src ) { |
| 300 |
continue; // no real URL (pure data-URI spacer, no data-src). |
| 301 |
} |
| 302 |
|
| 303 |
// Chrome markers / explicit opt-out / obviously-tiny images |
| 304 |
// never compete. (FBS-83553 H1 "logo before hero".) |
| 305 |
if ( self::looks_too_small( $tag ) ) { |
| 306 |
continue; |
| 307 |
} |
| 308 |
|
| 309 |
$candidates[] = array( |
| 310 |
'tag' => $tag, |
| 311 |
'src' => $src, |
| 312 |
'srcset' => $srcset, |
| 313 |
'sizes' => $sizes, |
| 314 |
'score' => self::weighted_score( self::lcp_score( $tag, $srcset ), $tag, $index ), |
| 315 |
'order' => $index, |
| 316 |
); |
| 317 |
} |
| 318 |
} |
| 319 |
|
| 320 |
// PASS 1b — the same for CSS background images. |
| 321 |
// |
| 322 |
// On a page builder the hero is usually a background-image on the |
| 323 |
// section, not an <img>, so an <img>-only candidate set never contained |
| 324 |
// the element that actually paints as LCP. It preloaded whatever <img> |
| 325 |
// happened to be there — measured at 0ms against the feature switched |
| 326 |
// off, while spending a high-priority fetch on the critical path — or, |
| 327 |
// on a page with no <img> at all, emitted nothing. (#247) |
| 328 |
foreach ( self::background_candidates( $html, $exclusions, $skip_ranges ) as $bg ) { |
| 329 |
$candidates[] = $bg; |
| 330 |
} |
| 331 |
|
| 332 |
// PASS 1c — <video poster="…">. A full-screen hero video paints its |
| 333 |
// poster first, and that first frame IS the LCP; measured on a live |
| 334 |
// page, a preloaded poster cut the LCP load delay from 1.5 s to 21 ms. |
| 335 |
foreach ( self::video_poster_candidates( $html, $exclusions, $skip_ranges ) as $vp ) { |
| 336 |
$candidates[] = $vp; |
| 337 |
} |
| 338 |
|
| 339 |
// PASS 1d — background rules in inline <style> blocks. Page builders |
| 340 |
// put the hero's background-image in generated per-post CSS printed |
| 341 |
// inline (Elementor's `.elementor-N .elementor-element-X` rules), not |
| 342 |
// in a style attribute — so PASS 1b never saw the element that |
| 343 |
// actually paints as LCP on five of seven measured sites. External |
| 344 |
// stylesheets stay out for the same reasons as before (#247): fetching |
| 345 |
// CSS from an output-buffer pass costs more than the preload saves. |
| 346 |
foreach ( self::style_block_candidates( $html, $exclusions, $skip_ranges ) as $sb ) { |
| 347 |
$candidates[] = $sb; |
| 348 |
} |
| 349 |
|
| 350 |
if ( empty( $candidates ) ) { |
| 351 |
return array( $html, '' ); |
| 352 |
} |
| 353 |
|
| 354 |
// Rank by score, biggest first. Document order breaks ties, so two |
| 355 |
// equally-sized images (or two of unknown size) keep the previous |
| 356 |
// first-wins behaviour — the change only matters when we can actually |
| 357 |
// tell one is larger. |
| 358 |
usort( |
| 359 |
$candidates, |
| 360 |
static function ( array $a, array $b ) { |
| 361 |
if ( $a['score'] === $b['score'] ) { |
| 362 |
return $a['order'] <=> $b['order']; |
| 363 |
} |
| 364 |
return $b['score'] <=> $a['score']; |
| 365 |
} |
| 366 |
); |
| 367 |
|
| 368 |
$winners = array_slice( $candidates, 0, $count ); |
| 369 |
|
| 370 |
// PASS 2 — emit the preload links and promote the winning tags. |
| 371 |
$chosen = array(); |
| 372 |
foreach ( $winners as $w ) { |
| 373 |
// Idempotency: if this src is already the target of a |
| 374 |
// rel="preload" as="image" link, still promote the tag but don't |
| 375 |
// emit a duplicate <link>. |
| 376 |
$already = (bool) preg_match( |
| 377 |
'#rel=["\']preload["\'][^>]*as=["\']image["\'][^>]*' . preg_quote( $w['src'], '#' ) . '#i', |
| 378 |
$existing |
| 379 |
); |
| 380 |
if ( ! $already ) { |
| 381 |
$preload .= self::preload_link( $w['src'], $w['srcset'], $w['sizes'] ); |
| 382 |
} |
| 383 |
// Only <img> winners are promoted in PASS 2 — there is no |
| 384 |
// fetchpriority/loading attribute to fix on a background element, |
| 385 |
// and its `order` is offset past every <img> index precisely so it |
| 386 |
// can never select one for rewriting. |
| 387 |
if ( empty( $w['background'] ) ) { |
| 388 |
$chosen[ $w['order'] ] = true; |
| 389 |
} |
| 390 |
} |
| 391 |
|
| 392 |
// Rewrite only the winning tags. Counting occurrences rather than |
| 393 |
// matching on tag text, because the same markup can legitimately |
| 394 |
// appear more than once on a page and only the ranked instance should |
| 395 |
// be promoted. |
| 396 |
$seen = -1; |
| 397 |
$html = preg_replace_callback( |
| 398 |
'#<img\b[^>]*>#i', |
| 399 |
static function ( array $m ) use ( &$seen, $chosen ) { |
| 400 |
++$seen; |
| 401 |
if ( ! isset( $chosen[ $seen ] ) ) { |
| 402 |
return $m[0]; |
| 403 |
} |
| 404 |
// Add fetchpriority="high" AND remove any loading="lazy" the |
| 405 |
// theme / WP core left on the LCP image. fetchpriority="high" |
| 406 |
// with loading="lazy" is contradictory — the browser can still |
| 407 |
// defer a lazy image, so preloading it while it stays lazy wins |
| 408 |
// nothing. Stripping lazy is what actually lets the preload land. |
| 409 |
return self::promote_lcp_img( $m[0] ); |
| 410 |
}, |
| 411 |
$html |
| 412 |
); |
| 413 |
|
| 414 |
return array( (string) $html, $preload ); |
| 415 |
} |
| 416 |
|
| 417 |
/** |
| 418 |
* Collect CSS `background-image` heroes as LCP candidates. |
| 419 |
* |
| 420 |
* Only INLINE `style` attributes are read. A background declared in an |
| 421 |
* external stylesheet is invisible here by design: resolving it would mean |
| 422 |
* fetching and parsing CSS from inside an output-buffer pass, and the URL a |
| 423 |
* selector resolves to depends on cascade order we cannot evaluate from |
| 424 |
* markup. Builders that put the hero in a generated per-post stylesheet are |
| 425 |
* therefore still unserved — worth doing, but not at this cost. (#247) |
| 426 |
* |
| 427 |
* Scores are the element's declared pixel area so a background competes |
| 428 |
* against an <img> in the SAME units — the whole point being that the |
| 429 |
* bigger of the two should win regardless of which kind it is. |
| 430 |
* |
| 431 |
* @param string $html Full page HTML. |
| 432 |
* @param string[] $exclusions Substring patterns the user excluded. |
| 433 |
* @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers. |
| 434 |
* @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}> |
| 435 |
*/ |
| 436 |
private static function background_candidates( string $html, array $exclusions, array $skip_ranges ): array { |
| 437 |
if ( ! preg_match_all( '#<(?:div|section|header|figure|a|span|li|main|article|aside)\b[^>]*\sstyle\s*=\s*(["\']).*?\1[^>]*>#is', $html, $matches, PREG_OFFSET_CAPTURE ) ) { |
| 438 |
return array(); |
| 439 |
} |
| 440 |
|
| 441 |
$found = array(); |
| 442 |
foreach ( $matches[0] as $index => $match ) { |
| 443 |
[ $tag, $offset ] = $match; |
| 444 |
|
| 445 |
// The same chrome-container gate as <img>: a background painted |
| 446 |
// inside <footer>/<nav>/<aside> is never the hero. (FBS-84576) |
| 447 |
if ( self::offset_in_ranges( $offset, $skip_ranges ) ) { |
| 448 |
continue; |
| 449 |
} |
| 450 |
|
| 451 |
$style = self::attr( $tag, 'style' ); |
| 452 |
if ( '' === $style || false === stripos( $style, 'background' ) ) { |
| 453 |
continue; |
| 454 |
} |
| 455 |
|
| 456 |
$src = self::background_url( $style ); |
| 457 |
if ( '' === $src ) { |
| 458 |
continue; |
| 459 |
} |
| 460 |
|
| 461 |
foreach ( $exclusions as $needle ) { |
| 462 |
if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { |
| 463 |
continue 2; |
| 464 |
} |
| 465 |
} |
| 466 |
|
| 467 |
// Same chrome/opt-out gates as <img>. A logo painted as a background |
| 468 |
// is no more the hero than a logo in an <img>. |
| 469 |
if ( self::looks_too_small( $tag ) ) { |
| 470 |
continue; |
| 471 |
} |
| 472 |
|
| 473 |
$area = self::style_area( $style ); |
| 474 |
if ( 0 === $area ) { |
| 475 |
// Nothing readable. Deliberately non-zero for the same reason |
| 476 |
// UNKNOWN_SIZE_SCORE is: an unmeasurable background must still |
| 477 |
// beat nothing on a page that declares no sizes at all, while |
| 478 |
// losing to anything we can actually measure. |
| 479 |
$area = self::UNKNOWN_SIZE_SCORE; |
| 480 |
} |
| 481 |
|
| 482 |
$found[] = array( |
| 483 |
'tag' => $tag, |
| 484 |
'src' => $src, |
| 485 |
'srcset' => '', |
| 486 |
'sizes' => '', |
| 487 |
'score' => (float) $area, |
| 488 |
// Offset so a background never ties ahead of an <img> that |
| 489 |
// appeared earlier in the document; ties still break on order. |
| 490 |
'order' => 100000 + $index, |
| 491 |
'background' => true, |
| 492 |
); |
| 493 |
} |
| 494 |
|
| 495 |
return $found; |
| 496 |
} |
| 497 |
|
| 498 |
/** |
| 499 |
* Collect `<video poster="…">` first frames as LCP candidates. |
| 500 |
* |
| 501 |
* The poster is what the viewer sees until (and unless) the video plays — |
| 502 |
* on a background-video hero, delayed by the Lazy module, it is the ONLY |
| 503 |
* frame the initial paint has. Scored like a background: the element's |
| 504 |
* declared inline-style area, or the unknown-size floor, with the order |
| 505 |
* offset past every <img> so a poster never ties ahead of one. |
| 506 |
* |
| 507 |
* @param string $html Full page HTML. |
| 508 |
* @param string[] $exclusions Substring patterns the user excluded. |
| 509 |
* @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers. |
| 510 |
* @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}> |
| 511 |
*/ |
| 512 |
private static function video_poster_candidates( string $html, array $exclusions, array $skip_ranges ): array { |
| 513 |
if ( ! preg_match_all( '#<video\b[^>]*\bposter\s*=\s*(["\'])(.*?)\1[^>]*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) { |
| 514 |
return array(); |
| 515 |
} |
| 516 |
|
| 517 |
$found = array(); |
| 518 |
foreach ( $matches[0] as $index => $match ) { |
| 519 |
[ $tag, $offset ] = $match; |
| 520 |
$src = trim( html_entity_decode( $matches[2][ $index ][0], ENT_QUOTES ) ); |
| 521 |
if ( '' === $src || 0 === stripos( $src, 'data:' ) ) { |
| 522 |
continue; |
| 523 |
} |
| 524 |
if ( self::offset_in_ranges( $offset, $skip_ranges ) ) { |
| 525 |
continue; |
| 526 |
} |
| 527 |
foreach ( $exclusions as $needle ) { |
| 528 |
if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { |
| 529 |
continue 2; |
| 530 |
} |
| 531 |
} |
| 532 |
if ( self::looks_too_small( $tag ) ) { |
| 533 |
continue; |
| 534 |
} |
| 535 |
|
| 536 |
$area = self::style_area( self::attr( $tag, 'style' ) ); |
| 537 |
if ( 0 === $area ) { |
| 538 |
$area = self::UNKNOWN_SIZE_SCORE; |
| 539 |
} |
| 540 |
|
| 541 |
$found[] = array( |
| 542 |
'tag' => $tag, |
| 543 |
'src' => $src, |
| 544 |
'srcset' => '', |
| 545 |
'sizes' => '', |
| 546 |
'score' => (float) $area, |
| 547 |
'order' => 100000 + $index, |
| 548 |
'background' => true, |
| 549 |
); |
| 550 |
} |
| 551 |
|
| 552 |
return $found; |
| 553 |
} |
| 554 |
|
| 555 |
/** |
| 556 |
* How many <style>-block background rules are considered per page. The |
| 557 |
* scan is linear, but each rule costs one class-lookup pass over the |
| 558 |
* body, so a pathological page (thousands of generated rules) is capped |
| 559 |
* rather than trusted. |
| 560 |
*/ |
| 561 |
private const STYLE_RULE_BUDGET = 40; |
| 562 |
|
| 563 |
/** |
| 564 |
* Collect background-image rules from inline <style> blocks whose |
| 565 |
* selector matches an element in the body. |
| 566 |
* |
| 567 |
* The match is deliberately narrow: the rule's RIGHTMOST simple selector |
| 568 |
* must carry a class or id, and the first element in the body bearing it |
| 569 |
* (outside chrome containers) is taken as the painted element. Rules |
| 570 |
* inside @media (or any other at-rule block) are skipped — a desktop-only |
| 571 |
* background preloaded on mobile is a wasted high-priority fetch, and the |
| 572 |
* markup gives no viewport to resolve the query against. |
| 573 |
* |
| 574 |
* @param string $html Full page HTML. |
| 575 |
* @param string[] $exclusions Substring patterns the user excluded. |
| 576 |
* @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers. |
| 577 |
* @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}> |
| 578 |
*/ |
| 579 |
private static function style_block_candidates( string $html, array $exclusions, array $skip_ranges ): array { |
| 580 |
if ( ! preg_match_all( '#<style\b[^>]*>(.*?)</style\s*>#is', $html, $blocks ) ) { |
| 581 |
return array(); |
| 582 |
} |
| 583 |
|
| 584 |
$found = array(); |
| 585 |
$budget = self::STYLE_RULE_BUDGET; |
| 586 |
foreach ( $blocks[1] as $css ) { |
| 587 |
if ( $budget <= 0 ) { |
| 588 |
break; |
| 589 |
} |
| 590 |
$css = self::strip_at_rule_blocks( $css ); |
| 591 |
if ( false === stripos( $css, 'url(' ) ) { |
| 592 |
continue; |
| 593 |
} |
| 594 |
// One flat rule at a time: selector list up to '{', body to '}'. |
| 595 |
if ( ! preg_match_all( '#(?:^|})\s*([^{}]{1,512})\{([^{}]*)\}#s', $css, $rules, PREG_SET_ORDER ) ) { |
| 596 |
continue; |
| 597 |
} |
| 598 |
foreach ( $rules as $rule ) { |
| 599 |
if ( $budget <= 0 ) { |
| 600 |
break 2; |
| 601 |
} |
| 602 |
if ( false === stripos( $rule[2], 'url(' ) ) { |
| 603 |
continue; |
| 604 |
} |
| 605 |
$src = self::background_url( $rule[2] ); |
| 606 |
if ( '' === $src ) { |
| 607 |
continue; |
| 608 |
} |
| 609 |
--$budget; |
| 610 |
// First selector of the list, rightmost compound of it. |
| 611 |
$selector = trim( (string) strtok( $rule[1], ',' ) ); |
| 612 |
$parts = preg_split( '#[\s>+~]+#', $selector ); |
| 613 |
$last = (string) end( $parts ); |
| 614 |
// The last class or id token of that compound. Pseudo-classes |
| 615 |
// (:hover, ::before) mean the background is not the initial |
| 616 |
// paint, so they disqualify the rule. |
| 617 |
if ( false !== strpos( $last, ':' ) ) { |
| 618 |
continue; |
| 619 |
} |
| 620 |
if ( ! preg_match( '#([.\#])([-\w]+)$#', $last, $tok ) ) { |
| 621 |
continue; |
| 622 |
} |
| 623 |
$el = '.' === $tok[1] |
| 624 |
? self::first_element_with_class( $html, $tok[2], $skip_ranges ) |
| 625 |
: self::first_element_with_id( $html, $tok[2], $skip_ranges ); |
| 626 |
if ( null === $el ) { |
| 627 |
continue; |
| 628 |
} |
| 629 |
[ $tag, $offset ] = $el; |
| 630 |
foreach ( $exclusions as $needle ) { |
| 631 |
if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { |
| 632 |
continue 2; |
| 633 |
} |
| 634 |
} |
| 635 |
if ( self::looks_too_small( $tag ) ) { |
| 636 |
continue; |
| 637 |
} |
| 638 |
$area = self::style_area( self::attr( $tag, 'style' ) ); |
| 639 |
if ( 0 === $area ) { |
| 640 |
$area = self::UNKNOWN_SIZE_SCORE; |
| 641 |
} |
| 642 |
$found[] = array( |
| 643 |
'tag' => $tag, |
| 644 |
'src' => $src, |
| 645 |
'srcset' => '', |
| 646 |
'sizes' => '', |
| 647 |
'score' => (float) $area, |
| 648 |
// Offset past the inline-style backgrounds: a rule-matched |
| 649 |
// background is one inference step less certain, so it must |
| 650 |
// never tie ahead of one read straight off the element. |
| 651 |
'order' => 200000 + $offset, |
| 652 |
'background' => true, |
| 653 |
); |
| 654 |
} |
| 655 |
} |
| 656 |
|
| 657 |
return $found; |
| 658 |
} |
| 659 |
|
| 660 |
/** |
| 661 |
* CSS with every at-rule BLOCK (@media, @supports, @container, …) removed, |
| 662 |
* by brace depth — a regex cannot pair nested braces. Flat at-rules |
| 663 |
* (@import, @charset) have no block and pass through harmlessly. |
| 664 |
*/ |
| 665 |
private static function strip_at_rule_blocks( string $css ): string { |
| 666 |
$out = ''; |
| 667 |
$len = strlen( $css ); |
| 668 |
$i = 0; |
| 669 |
while ( $i < $len ) { |
| 670 |
$at = strpos( $css, '@', $i ); |
| 671 |
if ( false === $at ) { |
| 672 |
return $out . substr( $css, $i ); |
| 673 |
} |
| 674 |
$brace = strpos( $css, '{', $at ); |
| 675 |
$semi = strpos( $css, ';', $at ); |
| 676 |
$out .= substr( $css, $i, $at - $i ); |
| 677 |
if ( false === $brace || ( false !== $semi && $semi < $brace ) ) { |
| 678 |
// Flat at-rule — skip to its semicolon (or end). |
| 679 |
$i = false === $semi ? $len : $semi + 1; |
| 680 |
continue; |
| 681 |
} |
| 682 |
// Block at-rule — skip to its matching close brace. |
| 683 |
$depth = 1; |
| 684 |
$i = $brace + 1; |
| 685 |
while ( $i < $len && $depth > 0 ) { |
| 686 |
$c = $css[ $i ]; |
| 687 |
if ( '{' === $c ) { |
| 688 |
++$depth; |
| 689 |
} elseif ( '}' === $c ) { |
| 690 |
--$depth; |
| 691 |
} |
| 692 |
++$i; |
| 693 |
} |
| 694 |
} |
| 695 |
return $out; |
| 696 |
} |
| 697 |
|
| 698 |
/** |
| 699 |
* The first element in the BODY carrying $class (outside chrome ranges), |
| 700 |
* as [tag, offset], or null. Body-only, so a head <meta> can never match |
| 701 |
* and a hit's offset is comparable with the chrome ranges. |
| 702 |
* |
| 703 |
* @return array{0:string,1:int}|null |
| 704 |
*/ |
| 705 |
private static function first_element_with_class( string $html, string $class, array $skip_ranges ): ?array { |
| 706 |
$body = stripos( $html, '<body' ); |
| 707 |
$from = false === $body ? 0 : $body; |
| 708 |
if ( ! preg_match_all( '#<[a-z][^>]*\bclass\s*=\s*(["\'])[^"\']*(?<![-\w])' . preg_quote( $class, '#' ) . '(?![-\w])[^"\']*\1[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, $from ) ) { |
| 709 |
return null; |
| 710 |
} |
| 711 |
foreach ( $m[0] as $match ) { |
| 712 |
if ( ! self::offset_in_ranges( $match[1], $skip_ranges ) ) { |
| 713 |
return array( $match[0], $match[1] ); |
| 714 |
} |
| 715 |
} |
| 716 |
return null; |
| 717 |
} |
| 718 |
|
| 719 |
/** |
| 720 |
* The first element carrying id="$id" (outside chrome ranges), as |
| 721 |
* [tag, offset], or null. |
| 722 |
* |
| 723 |
* @return array{0:string,1:int}|null |
| 724 |
*/ |
| 725 |
private static function first_element_with_id( string $html, string $id, array $skip_ranges ): ?array { |
| 726 |
$body = stripos( $html, '<body' ); |
| 727 |
$from = false === $body ? 0 : $body; |
| 728 |
if ( ! preg_match( '#<[a-z][^>]*\bid\s*=\s*(["\'])' . preg_quote( $id, '#' ) . '\1[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, $from ) ) { |
| 729 |
return null; |
| 730 |
} |
| 731 |
if ( self::offset_in_ranges( $m[0][1], $skip_ranges ) ) { |
| 732 |
return null; |
| 733 |
} |
| 734 |
return array( $m[0][0], $m[0][1] ); |
| 735 |
} |
| 736 |
|
| 737 |
/** |
| 738 |
* Pull a real image URL out of a `background`/`background-image` declaration. |
| 739 |
* |
| 740 |
* Returns '' for anything with nothing to fetch: a gradient (which is a |
| 741 |
* background-image but not a resource), a data: URI, or `none`. |
| 742 |
*/ |
| 743 |
private static function background_url( string $style ): string { |
| 744 |
// Decode BEFORE parsing. Builders emit the url() quotes HTML-encoded |
| 745 |
// inside a style attribute (url("/hero.jpg")), and `"` |
| 746 |
// carries a semicolon — so splitting the declaration on `;` first |
| 747 |
// truncated the value to `url("` and found no URL at all. |
| 748 |
$style = html_entity_decode( $style, ENT_QUOTES ); |
| 749 |
|
| 750 |
if ( ! preg_match( '#background(?:-image)?\s*:\s*((?:[^;\'"]|"[^"]*"|\'[^\']*\')+)#i', $style, $decl ) ) { |
| 751 |
return ''; |
| 752 |
} |
| 753 |
if ( ! preg_match( '#url\(\s*(["\']?)(.*?)\1\s*\)#is', $decl[1], $m ) ) { |
| 754 |
return ''; |
| 755 |
} |
| 756 |
$url = trim( $m[2] ); |
| 757 |
if ( '' === $url || 0 === stripos( $url, 'data:' ) ) { |
| 758 |
return ''; |
| 759 |
} |
| 760 |
return $url; |
| 761 |
} |
| 762 |
|
| 763 |
/** |
| 764 |
* Declared pixel area from an inline style, or 0 when it can't be read. |
| 765 |
* |
| 766 |
* Only px is honoured. A percentage or viewport unit resolves against a |
| 767 |
* containing block we cannot see from markup, and guessing one produced the |
| 768 |
* wrong winner more often than declining to. |
| 769 |
*/ |
| 770 |
private static function style_area( string $style ): int { |
| 771 |
$w = self::style_px( $style, 'width' ); |
| 772 |
$h = self::style_px( $style, 'height' ); |
| 773 |
if ( $w > 0 && $h > 0 ) { |
| 774 |
return $w * $h; |
| 775 |
} |
| 776 |
if ( $w > 0 ) { |
| 777 |
return (int) round( $w * $w * self::ASSUMED_ASPECT_RATIO ); |
| 778 |
} |
| 779 |
return 0; |
| 780 |
} |
| 781 |
|
| 782 |
/** One px-valued CSS length from an inline style, or 0. */ |
| 783 |
private static function style_px( string $style, string $prop ): int { |
| 784 |
if ( preg_match( '#(?:^|;)\s*' . preg_quote( $prop, '#' ) . '\s*:\s*(\d+(?:\.\d+)?)px#i', $style, $m ) ) { |
| 785 |
return (int) round( (float) $m[1] ); |
| 786 |
} |
| 787 |
return 0; |
| 788 |
} |
| 789 |
|
| 790 |
/** |
| 791 |
* How likely is this <img> to be the LCP element? Higher wins. |
| 792 |
* |
| 793 |
* Rendered area is the best available proxy, and we can only read what the |
| 794 |
* markup declares: |
| 795 |
* |
| 796 |
* 1. `width` × `height` attributes — the real area, when present. |
| 797 |
* 2. The largest `srcset` / `data-srcset` candidate width, squared into a |
| 798 |
* pseudo-area. A responsive hero usually omits width/height but ships |
| 799 |
* a 1600w+ candidate, which says more about its size than its |
| 800 |
* position ever did. |
| 801 |
* 3. Nothing readable → a neutral score, so the image still competes |
| 802 |
* (matching looks_too_small()'s "don't guess" rule) but loses to any |
| 803 |
* image we CAN measure as larger. |
| 804 |
* |
| 805 |
* @param string $tag The full <img> tag. |
| 806 |
* @param string $srcset Resolved srcset (may come from data-srcset). |
| 807 |
*/ |
| 808 |
private static function lcp_score( string $tag, string $srcset ): float { |
| 809 |
$w = self::attr( $tag, 'width' ); |
| 810 |
$h = self::attr( $tag, 'height' ); |
| 811 |
if ( '' !== $w && '' !== $h && is_numeric( $w ) && is_numeric( $h ) ) { |
| 812 |
return (float) ( (int) $w * (int) $h ); |
| 813 |
} |
| 814 |
|
| 815 |
$widest = self::widest_srcset_width( $srcset ); |
| 816 |
if ( $widest > 0 ) { |
| 817 |
// Estimate an AREA, not a square. Squaring the width compared a |
| 818 |
// pseudo-area against a real one and overstated the width-only |
| 819 |
// candidate by roughly the inverse of its aspect ratio, so a |
| 820 |
// 1024w sidebar thumbnail (1 048 576) beat a declared 1200×600 |
| 821 |
// hero (720 000) — a regression on exactly the mixed pages that |
| 822 |
// document order used to get right, since the hero usually comes |
| 823 |
// first. Assuming a 16:9 box keeps both sides in the same units. |
| 824 |
return round( $widest * $widest * self::ASSUMED_ASPECT_RATIO ); |
| 825 |
} |
| 826 |
|
| 827 |
return (float) self::UNKNOWN_SIZE_SCORE; |
| 828 |
} |
| 829 |
|
| 830 |
/** |
| 831 |
* Fold the author's own priority signals and document position into an |
| 832 |
* area score. (FBS-84576) |
| 833 |
* |
| 834 |
* Boosts are ADDITIVE, in area units, so they can rescue an image whose |
| 835 |
* size the markup doesn't declare: a hero with no width/height and no |
| 836 |
* `w`-descriptor srcset scores UNKNOWN_SIZE_SCORE, and multiplying that |
| 837 |
* by any factor still loses to a 548×136 logo that declares itself. This |
| 838 |
* is exactly the live miss — the real hero carried loading="eager" |
| 839 |
* fetchpriority="high" and lost to three dimension-declaring decoys. |
| 840 |
* |
| 841 |
* - fetchpriority="high" is the strongest signal there is: the author |
| 842 |
* (or WP core's own LCP detection) has already named this image the |
| 843 |
* hero. Worth a hero-sized area. |
| 844 |
* - An EXPLICIT loading="eager" is a weaker but deliberate "load me |
| 845 |
* now" (the default is eager, so writing it out is a choice). |
| 846 |
* |
| 847 |
* Position is a light multiplicative weight — earlier is better, but the |
| 848 |
* spread is capped well under 5× so it can only break near-ties, never |
| 849 |
* outrank a genuinely larger image further down (the logo-vs-hero case). |
| 850 |
* |
| 851 |
* @param float $score Base area score from lcp_score() / style_area(). |
| 852 |
* @param string $tag The candidate's full tag (for the signal attrs). |
| 853 |
* @param int $order Document-order index of the candidate. |
| 854 |
*/ |
| 855 |
private static function weighted_score( float $score, string $tag, int $order ): float { |
| 856 |
if ( 'high' === strtolower( self::attr( $tag, 'fetchpriority' ) ) ) { |
| 857 |
$score += self::FETCHPRIORITY_HIGH_BOOST; |
| 858 |
} |
| 859 |
if ( 'eager' === strtolower( self::attr( $tag, 'loading' ) ) ) { |
| 860 |
$score += self::EAGER_BOOST; |
| 861 |
} |
| 862 |
return $score * self::position_weight( $order ); |
| 863 |
} |
| 864 |
|
| 865 |
/** |
| 866 |
* Document-position weight: 1.25 for the first image, easing to 1.0 by |
| 867 |
* the tenth. The whole spread is 25%, far under the 5× area difference it |
| 868 |
* must never override — it exists only to keep the old first-wins |
| 869 |
* behaviour for images we can't tell apart. |
| 870 |
*/ |
| 871 |
private static function position_weight( int $order ): float { |
| 872 |
return 1.0 + 0.25 * max( 0.0, 1.0 - $order / 10 ); |
| 873 |
} |
| 874 |
|
| 875 |
/** |
| 876 |
* Area-unit boost for fetchpriority="high" — roughly a 940×530 hero, so |
| 877 |
* an explicitly-marked image outranks any mid-page decoy even when its |
| 878 |
* own size is unreadable, while a genuinely huge unmarked image can still |
| 879 |
* beat a marked small one. |
| 880 |
*/ |
| 881 |
private const FETCHPRIORITY_HIGH_BOOST = 500000.0; |
| 882 |
|
| 883 |
/** |
| 884 |
* Area-unit boost for an explicit loading="eager" — roughly 420×240, |
| 885 |
* enough to break ties in favour of the author's intent without letting |
| 886 |
* an eager logo outrank a plain hero. |
| 887 |
*/ |
| 888 |
private const EAGER_BOOST = 100000.0; |
| 889 |
|
| 890 |
/** |
| 891 |
* Byte ranges of <footer>/<nav>/<aside> regions. Nesting-aware per tag |
| 892 |
* name (a nav inside a nav extends the range); an unclosed open tag |
| 893 |
* poisons through to the end of the document, which errs on the side of |
| 894 |
* not preloading — the safe direction, since a wrong preload is worse |
| 895 |
* than none. (FBS-84576) |
| 896 |
* |
| 897 |
* @return array<int,array{0:int,1:int}> [start, end] byte offsets. |
| 898 |
*/ |
| 899 |
private static function chrome_container_ranges( string $html ): array { |
| 900 |
$ranges = array(); |
| 901 |
foreach ( array( 'footer', 'nav', 'aside' ) as $name ) { |
| 902 |
// (?=[\s/>]) rather than \b: a word boundary sits before the `-` |
| 903 |
// of a custom element, so `<nav\b` would swallow `<nav-menu>`. |
| 904 |
if ( ! preg_match_all( '#<(/?)' . $name . '(?=[\s/>])[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { |
| 905 |
continue; |
| 906 |
} |
| 907 |
$depth = 0; |
| 908 |
$start = 0; |
| 909 |
foreach ( $m[0] as $i => $match ) { |
| 910 |
$closing = '' !== $m[1][ $i ][0]; |
| 911 |
if ( ! $closing ) { |
| 912 |
if ( 0 === $depth ) { |
| 913 |
$start = $match[1]; |
| 914 |
} |
| 915 |
++$depth; |
| 916 |
} elseif ( $depth > 0 ) { |
| 917 |
--$depth; |
| 918 |
if ( 0 === $depth ) { |
| 919 |
$ranges[] = array( $start, $match[1] ); |
| 920 |
} |
| 921 |
} |
| 922 |
} |
| 923 |
if ( $depth > 0 ) { |
| 924 |
$ranges[] = array( $start, strlen( $html ) ); |
| 925 |
} |
| 926 |
} |
| 927 |
return $ranges; |
| 928 |
} |
| 929 |
|
| 930 |
/** Does a byte offset fall inside any of the given [start, end] ranges? */ |
| 931 |
private static function offset_in_ranges( int $offset, array $ranges ): bool { |
| 932 |
foreach ( $ranges as $range ) { |
| 933 |
if ( $offset > $range[0] && $offset < $range[1] ) { |
| 934 |
return true; |
| 935 |
} |
| 936 |
} |
| 937 |
return false; |
| 938 |
} |
| 939 |
|
| 940 |
/** |
| 941 |
* Score for an image whose size we can't read at all. |
| 942 |
* |
| 943 |
* Deliberately non-zero: an unmeasurable image must still beat nothing and |
| 944 |
* still be preloadable on a page where no image declares its size. But it |
| 945 |
* sits below a 200×200 declared area (40 000), so anything we CAN measure |
| 946 |
* as a plausible hero outranks a guess. |
| 947 |
*/ |
| 948 |
private const UNKNOWN_SIZE_SCORE = 1; |
| 949 |
|
| 950 |
/** |
| 951 |
* Height-to-width ratio assumed when only a `w` descriptor is readable. |
| 952 |
* |
| 953 |
* 9/16 — the commonest hero/banner shape, and close enough that a |
| 954 |
* width-only candidate is compared against a declared w×h area on the |
| 955 |
* same scale rather than being systematically inflated. |
| 956 |
*/ |
| 957 |
private const ASSUMED_ASPECT_RATIO = 9 / 16; |
| 958 |
|
| 959 |
/** |
| 960 |
* Largest `w` descriptor in a srcset, or 0 when there isn't one. |
| 961 |
* |
| 962 |
* Only `w` descriptors are read. An `x` descriptor (`hero.jpg 2x`) |
| 963 |
* describes pixel density, not layout width, so it says nothing about |
| 964 |
* rendered area. |
| 965 |
*/ |
| 966 |
private static function widest_srcset_width( string $srcset ): int { |
| 967 |
if ( '' === $srcset ) { |
| 968 |
return 0; |
| 969 |
} |
| 970 |
$widest = 0; |
| 971 |
foreach ( explode( ',', $srcset ) as $candidate ) { |
| 972 |
if ( preg_match( '#(\d+)w\s*$#', trim( $candidate ), $m ) ) { |
| 973 |
$widest = max( $widest, (int) $m[1] ); |
| 974 |
} |
| 975 |
} |
| 976 |
return $widest; |
| 977 |
} |
| 978 |
|
| 979 |
/** |
| 980 |
* Assemble one <link rel="preload" as="image" fetchpriority="high">. |
| 981 |
* |
| 982 |
* The href/srcset run through the `xspeed_lcp_preload_url` / |
| 983 |
* `xspeed_lcp_preload_srcset` filters first. This is the coordination point |
| 984 |
* with format-negotiating layers (Pro's Images module wraps the LCP <img> in |
| 985 |
* a <picture> with a WebP/AVIF <source>, so the browser paints e.g. |
| 986 |
* hero.png.webp, NOT the hero.png this preload would otherwise point at — |
| 987 |
* making the high-priority preload a wasted download while the real LCP |
| 988 |
* resource goes un-preloaded). By filtering the URL, a webp/avif layer can |
| 989 |
* redirect the preload to the format it will actually serve, WITHOUT Free |
| 990 |
* knowing that layer exists. (FBS-83553 H3) |
| 991 |
*/ |
| 992 |
private static function preload_link( string $src, string $srcset, string $sizes ): string { |
| 993 |
// Resolve the `type` from the ORIGINAL image URL (before rewriting), so a |
| 994 |
// negotiating layer can key off the source .jpg/.png — after rewriting, |
| 995 |
// the URL is already a .webp and the derivation would no-op. |
| 996 |
$original = $src; |
| 997 |
/** |
| 998 |
* Filter an explicit `type` for the preload link (e.g. "image/webp"). |
| 999 |
* Empty = omit. A typed image preload is only fetched by browsers that |
| 1000 |
* accept that type, so pairing a webp href with type="image/webp" is safe |
| 1001 |
* even though the markup is baked into a shared cache file. |
| 1002 |
* |
| 1003 |
* @param string $type Defaults to '' (no type attribute). |
| 1004 |
* @param string $src The ORIGINAL (pre-rewrite) preload URL. |
| 1005 |
*/ |
| 1006 |
$type = (string) apply_filters( 'xspeed_lcp_preload_type', '', $original ); |
| 1007 |
/** |
| 1008 |
* Filter the LCP preload href. Return a modern-format sibling (webp/avif) |
| 1009 |
* when one will actually be served for this image. |
| 1010 |
* |
| 1011 |
* @param string $src The original image URL chosen for preload. |
| 1012 |
*/ |
| 1013 |
$src = (string) apply_filters( 'xspeed_lcp_preload_url', $src ); |
| 1014 |
if ( '' !== $srcset ) { |
| 1015 |
/** @param string $srcset The original srcset chosen for preload. */ |
| 1016 |
$srcset = (string) apply_filters( 'xspeed_lcp_preload_srcset', $srcset ); |
| 1017 |
} |
| 1018 |
|
| 1019 |
$attrs = sprintf( 'href="%s"', esc_url( $src ) ); |
| 1020 |
|
| 1021 |
if ( '' !== $srcset ) { |
| 1022 |
// Preserve the responsive candidate set so the browser preloads |
| 1023 |
// the same file it would have chosen from the <img>. |
| 1024 |
$attrs .= sprintf( ' imagesrcset="%s"', esc_attr( html_entity_decode( $srcset, ENT_QUOTES ) ) ); |
| 1025 |
if ( '' !== $sizes ) { |
| 1026 |
$attrs .= sprintf( ' imagesizes="%s"', esc_attr( html_entity_decode( $sizes, ENT_QUOTES ) ) ); |
| 1027 |
} |
| 1028 |
} |
| 1029 |
|
| 1030 |
if ( '' !== $type ) { |
| 1031 |
$attrs .= sprintf( ' type="%s"', esc_attr( $type ) ); |
| 1032 |
} |
| 1033 |
|
| 1034 |
return sprintf( '<link rel="preload" as="image" %s fetchpriority="high">' . "\n", $attrs ); |
| 1035 |
} |
| 1036 |
|
| 1037 |
/** |
| 1038 |
* Extract a single/double-quoted attribute value from a tag. Returns '' |
| 1039 |
* when the attribute is absent. |
| 1040 |
*/ |
| 1041 |
private static function attr( string $tag, string $name ): string { |
| 1042 |
// Anchor on a real attribute boundary, not `\b`. A word boundary sits |
| 1043 |
// between the `-` and the `w` of `data-width`, so `\bwidth=` matched |
| 1044 |
// inside it: a lazy-loaded hero carrying `data-width="50" |
| 1045 |
// data-height="50"` was scored 50×50 and rejected by |
| 1046 |
// looks_too_small() — defeating the feature on exactly the images the |
| 1047 |
// data-src/data-srcset handling exists to support. Requiring |
| 1048 |
// whitespace (or the start of the string) before the name means only |
| 1049 |
// a genuine attribute matches. |
| 1050 |
if ( preg_match( '#(?:^|\s)' . preg_quote( $name, '#' ) . '\s*=\s*(["\'])(.*?)\1#is', $tag, $m ) ) { |
| 1051 |
return trim( $m[2] ); |
| 1052 |
} |
| 1053 |
return ''; |
| 1054 |
} |
| 1055 |
|
| 1056 |
/** |
| 1057 |
* Resolve the URL/srcset/sizes the browser will actually paint for an |
| 1058 |
* <img>, seeing through JS-lazy placeholders. When `src` is a data: URI (a |
| 1059 |
* builder/lazy-loader placeholder), fall back to `data-src`; likewise carry |
| 1060 |
* `data-srcset`/`data-sizes` when the plain ones are absent. Returns |
| 1061 |
* ['', '', ''] when there's no real raster URL to preload. (FBS-83553 H1) |
| 1062 |
* |
| 1063 |
* @return array{0:string,1:string,2:string} [src, srcset, sizes] |
| 1064 |
*/ |
| 1065 |
private static function effective_image_src( string $tag ): array { |
| 1066 |
$src = self::attr( $tag, 'src' ); |
| 1067 |
if ( '' === $src || 0 === stripos( $src, 'data:' ) ) { |
| 1068 |
$data_src = self::attr( $tag, 'data-src' ); |
| 1069 |
if ( '' !== $data_src && 0 !== stripos( $data_src, 'data:' ) ) { |
| 1070 |
$src = $data_src; |
| 1071 |
} |
| 1072 |
} |
| 1073 |
if ( '' === $src || 0 === stripos( $src, 'data:' ) ) { |
| 1074 |
return array( '', '', '' ); |
| 1075 |
} |
| 1076 |
$srcset = self::attr( $tag, 'srcset' ); |
| 1077 |
if ( '' === $srcset ) { |
| 1078 |
$srcset = self::attr( $tag, 'data-srcset' ); |
| 1079 |
} |
| 1080 |
$sizes = self::attr( $tag, 'sizes' ); |
| 1081 |
if ( '' === $sizes ) { |
| 1082 |
$sizes = self::attr( $tag, 'data-sizes' ); |
| 1083 |
} |
| 1084 |
return array( $src, $srcset, $sizes ); |
| 1085 |
} |
| 1086 |
|
| 1087 |
/** |
| 1088 |
* At/below this (px) in BOTH width and height, an image is treated as a |
| 1089 |
* logo/icon/avatar rather than an LCP hero. 200px clears real content heroes |
| 1090 |
* (which are typically ≥ 400px wide) while catching site logos and avatars |
| 1091 |
* — including the 150×150 logo the picker used to mistakenly preload. |
| 1092 |
*/ |
| 1093 |
private const MIN_LCP_DIMENSION = 200; |
| 1094 |
|
| 1095 |
/** |
| 1096 |
* Class/role/filename markers that identify site chrome (logo, icon, |
| 1097 |
* avatar, spinner, emoji) which should never be treated as the LCP hero, |
| 1098 |
* regardless of declared size. |
| 1099 |
*/ |
| 1100 |
private const NON_HERO_MARKERS = array( 'logo', 'icon', 'avatar', 'gravatar', 'spinner', 'emoji', 'site-icon', 'custom-logo' ); |
| 1101 |
|
| 1102 |
/** |
| 1103 |
* Below this declared area (px²) an image is a badge/thumb/divider, never |
| 1104 |
* an LCP hero — 10 000 is a 100×100 square, or a 500×20 strip. Applied |
| 1105 |
* only when BOTH dimensions are readable. (FBS-84576) |
| 1106 |
*/ |
| 1107 |
private const MIN_LCP_AREA = 10000; |
| 1108 |
|
| 1109 |
/** |
| 1110 |
* Is this <img> too small / too chrome-like to be the LCP hero? True when |
| 1111 |
* either (a) it carries a logo/icon/avatar marker, (b) an explicit |
| 1112 |
* `data-no-lcp` opt-out, or (c) BOTH width and height are present and |
| 1113 |
* both are ≤ the dimension threshold, or their area is under |
| 1114 |
* MIN_LCP_AREA. Missing dimensions are NOT guessed — an image whose size |
| 1115 |
* we can't read still competes. (FBS-83553 H1 "logo before hero".) |
| 1116 |
*/ |
| 1117 |
private static function looks_too_small( string $tag ): bool { |
| 1118 |
if ( false !== stripos( $tag, 'data-no-lcp' ) ) { |
| 1119 |
return true; |
| 1120 |
} |
| 1121 |
// Marker check against class / id / src (covers "custom-logo", a |
| 1122 |
// "…/logo.png" filename, role="img" avatars, etc.). |
| 1123 |
$haystack = strtolower( self::attr( $tag, 'class' ) . ' ' . self::attr( $tag, 'id' ) . ' ' . self::attr( $tag, 'src' ) ); |
| 1124 |
foreach ( self::NON_HERO_MARKERS as $marker ) { |
| 1125 |
if ( false !== strpos( $haystack, $marker ) ) { |
| 1126 |
return true; |
| 1127 |
} |
| 1128 |
} |
| 1129 |
$w = self::attr( $tag, 'width' ); |
| 1130 |
$h = self::attr( $tag, 'height' ); |
| 1131 |
if ( '' === $w || '' === $h || ! is_numeric( $w ) || ! is_numeric( $h ) ) { |
| 1132 |
return false; // unknown size — don't guess; let it compete. |
| 1133 |
} |
| 1134 |
if ( (int) $w * (int) $h < self::MIN_LCP_AREA ) { |
| 1135 |
return true; |
| 1136 |
} |
| 1137 |
return (int) $w <= self::MIN_LCP_DIMENSION && (int) $h <= self::MIN_LCP_DIMENSION; |
| 1138 |
} |
| 1139 |
|
| 1140 |
/** |
| 1141 |
* Promote an <img> to the LCP element: force fetchpriority="high" and |
| 1142 |
* strip any loading="lazy" so the browser loads it immediately. Both are |
| 1143 |
* idempotent. `loading="lazy"` is REMOVED rather than flipped to "eager" |
| 1144 |
* because eager is the default; a bare tag with fetchpriority="high" is |
| 1145 |
* the canonical high-priority-image form. |
| 1146 |
*/ |
| 1147 |
private static function promote_lcp_img( string $tag ): string { |
| 1148 |
$tag = self::set_fetchpriority( $tag ); |
| 1149 |
// Drop loading="lazy" (WP core adds it by default). Leave other |
| 1150 |
// loading values (e.g. an explicit eager) intact — only lazy hurts. |
| 1151 |
$tag = preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag ); |
| 1152 |
return (string) $tag; |
| 1153 |
} |
| 1154 |
|
| 1155 |
/** |
| 1156 |
* Add fetchpriority="high" to an <img> tag. Idempotent — an existing |
| 1157 |
* fetchpriority value is normalised to high rather than duplicated. |
| 1158 |
*/ |
| 1159 |
private static function set_fetchpriority( string $tag ): string { |
| 1160 |
if ( preg_match( '#\bfetchpriority=(["\']).*?\1#i', $tag ) ) { |
| 1161 |
return (string) preg_replace( '#\bfetchpriority=(["\']).*?\1#i', 'fetchpriority="high"', $tag, 1 ); |
| 1162 |
} |
| 1163 |
// Insert right after "<img". |
| 1164 |
return (string) preg_replace( '#<img\b#i', '<img fetchpriority="high"', $tag, 1 ); |
| 1165 |
} |
| 1166 |
|
| 1167 |
/** |
| 1168 |
* Inject the assembled hint markup into <head>. Prefers to land right |
| 1169 |
* before the first stylesheet so the preloads are discovered before the |
| 1170 |
* render-blocking CSS. Falls back to after <head>, then prepend. |
| 1171 |
*/ |
| 1172 |
private static function inject_into_head( string $html, string $hints ): string { |
| 1173 |
// Before the first <link rel="stylesheet"> if there is one. |
| 1174 |
if ( preg_match( '#<link\b[^>]*rel=["\']stylesheet["\'][^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { |
| 1175 |
$pos = $m[0][1]; |
| 1176 |
return substr( $html, 0, $pos ) . $hints . substr( $html, $pos ); |
| 1177 |
} |
| 1178 |
// Otherwise right after the opening <head ...>. |
| 1179 |
if ( preg_match( '#<head\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { |
| 1180 |
$pos = $m[0][1] + strlen( $m[0][0] ); |
| 1181 |
return substr( $html, 0, $pos ) . "\n" . $hints . substr( $html, $pos ); |
| 1182 |
} |
| 1183 |
// No head at all — prepend (degenerate documents). |
| 1184 |
return $hints . $html; |
| 1185 |
} |
| 1186 |
} |
| 1187 |
|