| 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 |
if ( empty( $candidates ) ) { |
| 333 |
return array( $html, '' ); |
| 334 |
} |
| 335 |
|
| 336 |
// Rank by score, biggest first. Document order breaks ties, so two |
| 337 |
// equally-sized images (or two of unknown size) keep the previous |
| 338 |
// first-wins behaviour — the change only matters when we can actually |
| 339 |
// tell one is larger. |
| 340 |
usort( |
| 341 |
$candidates, |
| 342 |
static function ( array $a, array $b ) { |
| 343 |
if ( $a['score'] === $b['score'] ) { |
| 344 |
return $a['order'] <=> $b['order']; |
| 345 |
} |
| 346 |
return $b['score'] <=> $a['score']; |
| 347 |
} |
| 348 |
); |
| 349 |
|
| 350 |
$winners = array_slice( $candidates, 0, $count ); |
| 351 |
|
| 352 |
// PASS 2 — emit the preload links and promote the winning tags. |
| 353 |
$chosen = array(); |
| 354 |
foreach ( $winners as $w ) { |
| 355 |
// Idempotency: if this src is already the target of a |
| 356 |
// rel="preload" as="image" link, still promote the tag but don't |
| 357 |
// emit a duplicate <link>. |
| 358 |
$already = (bool) preg_match( |
| 359 |
'#rel=["\']preload["\'][^>]*as=["\']image["\'][^>]*' . preg_quote( $w['src'], '#' ) . '#i', |
| 360 |
$existing |
| 361 |
); |
| 362 |
if ( ! $already ) { |
| 363 |
$preload .= self::preload_link( $w['src'], $w['srcset'], $w['sizes'] ); |
| 364 |
} |
| 365 |
// Only <img> winners are promoted in PASS 2 — there is no |
| 366 |
// fetchpriority/loading attribute to fix on a background element, |
| 367 |
// and its `order` is offset past every <img> index precisely so it |
| 368 |
// can never select one for rewriting. |
| 369 |
if ( empty( $w['background'] ) ) { |
| 370 |
$chosen[ $w['order'] ] = true; |
| 371 |
} |
| 372 |
} |
| 373 |
|
| 374 |
// Rewrite only the winning tags. Counting occurrences rather than |
| 375 |
// matching on tag text, because the same markup can legitimately |
| 376 |
// appear more than once on a page and only the ranked instance should |
| 377 |
// be promoted. |
| 378 |
$seen = -1; |
| 379 |
$html = preg_replace_callback( |
| 380 |
'#<img\b[^>]*>#i', |
| 381 |
static function ( array $m ) use ( &$seen, $chosen ) { |
| 382 |
++$seen; |
| 383 |
if ( ! isset( $chosen[ $seen ] ) ) { |
| 384 |
return $m[0]; |
| 385 |
} |
| 386 |
// Add fetchpriority="high" AND remove any loading="lazy" the |
| 387 |
// theme / WP core left on the LCP image. fetchpriority="high" |
| 388 |
// with loading="lazy" is contradictory — the browser can still |
| 389 |
// defer a lazy image, so preloading it while it stays lazy wins |
| 390 |
// nothing. Stripping lazy is what actually lets the preload land. |
| 391 |
return self::promote_lcp_img( $m[0] ); |
| 392 |
}, |
| 393 |
$html |
| 394 |
); |
| 395 |
|
| 396 |
return array( (string) $html, $preload ); |
| 397 |
} |
| 398 |
|
| 399 |
/** |
| 400 |
* Collect CSS `background-image` heroes as LCP candidates. |
| 401 |
* |
| 402 |
* Only INLINE `style` attributes are read. A background declared in an |
| 403 |
* external stylesheet is invisible here by design: resolving it would mean |
| 404 |
* fetching and parsing CSS from inside an output-buffer pass, and the URL a |
| 405 |
* selector resolves to depends on cascade order we cannot evaluate from |
| 406 |
* markup. Builders that put the hero in a generated per-post stylesheet are |
| 407 |
* therefore still unserved — worth doing, but not at this cost. (#247) |
| 408 |
* |
| 409 |
* Scores are the element's declared pixel area so a background competes |
| 410 |
* against an <img> in the SAME units — the whole point being that the |
| 411 |
* bigger of the two should win regardless of which kind it is. |
| 412 |
* |
| 413 |
* @param string $html Full page HTML. |
| 414 |
* @param string[] $exclusions Substring patterns the user excluded. |
| 415 |
* @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers. |
| 416 |
* @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}> |
| 417 |
*/ |
| 418 |
private static function background_candidates( string $html, array $exclusions, array $skip_ranges ): array { |
| 419 |
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 ) ) { |
| 420 |
return array(); |
| 421 |
} |
| 422 |
|
| 423 |
$found = array(); |
| 424 |
foreach ( $matches[0] as $index => $match ) { |
| 425 |
[ $tag, $offset ] = $match; |
| 426 |
|
| 427 |
// The same chrome-container gate as <img>: a background painted |
| 428 |
// inside <footer>/<nav>/<aside> is never the hero. (FBS-84576) |
| 429 |
if ( self::offset_in_ranges( $offset, $skip_ranges ) ) { |
| 430 |
continue; |
| 431 |
} |
| 432 |
|
| 433 |
$style = self::attr( $tag, 'style' ); |
| 434 |
if ( '' === $style || false === stripos( $style, 'background' ) ) { |
| 435 |
continue; |
| 436 |
} |
| 437 |
|
| 438 |
$src = self::background_url( $style ); |
| 439 |
if ( '' === $src ) { |
| 440 |
continue; |
| 441 |
} |
| 442 |
|
| 443 |
foreach ( $exclusions as $needle ) { |
| 444 |
if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { |
| 445 |
continue 2; |
| 446 |
} |
| 447 |
} |
| 448 |
|
| 449 |
// Same chrome/opt-out gates as <img>. A logo painted as a background |
| 450 |
// is no more the hero than a logo in an <img>. |
| 451 |
if ( self::looks_too_small( $tag ) ) { |
| 452 |
continue; |
| 453 |
} |
| 454 |
|
| 455 |
$area = self::style_area( $style ); |
| 456 |
if ( 0 === $area ) { |
| 457 |
// Nothing readable. Deliberately non-zero for the same reason |
| 458 |
// UNKNOWN_SIZE_SCORE is: an unmeasurable background must still |
| 459 |
// beat nothing on a page that declares no sizes at all, while |
| 460 |
// losing to anything we can actually measure. |
| 461 |
$area = self::UNKNOWN_SIZE_SCORE; |
| 462 |
} |
| 463 |
|
| 464 |
$found[] = array( |
| 465 |
'tag' => $tag, |
| 466 |
'src' => $src, |
| 467 |
'srcset' => '', |
| 468 |
'sizes' => '', |
| 469 |
'score' => (float) $area, |
| 470 |
// Offset so a background never ties ahead of an <img> that |
| 471 |
// appeared earlier in the document; ties still break on order. |
| 472 |
'order' => 100000 + $index, |
| 473 |
'background' => true, |
| 474 |
); |
| 475 |
} |
| 476 |
|
| 477 |
return $found; |
| 478 |
} |
| 479 |
|
| 480 |
/** |
| 481 |
* Pull a real image URL out of a `background`/`background-image` declaration. |
| 482 |
* |
| 483 |
* Returns '' for anything with nothing to fetch: a gradient (which is a |
| 484 |
* background-image but not a resource), a data: URI, or `none`. |
| 485 |
*/ |
| 486 |
private static function background_url( string $style ): string { |
| 487 |
// Decode BEFORE parsing. Builders emit the url() quotes HTML-encoded |
| 488 |
// inside a style attribute (url("/hero.jpg")), and `"` |
| 489 |
// carries a semicolon — so splitting the declaration on `;` first |
| 490 |
// truncated the value to `url("` and found no URL at all. |
| 491 |
$style = html_entity_decode( $style, ENT_QUOTES ); |
| 492 |
|
| 493 |
if ( ! preg_match( '#background(?:-image)?\s*:\s*((?:[^;\'"]|"[^"]*"|\'[^\']*\')+)#i', $style, $decl ) ) { |
| 494 |
return ''; |
| 495 |
} |
| 496 |
if ( ! preg_match( '#url\(\s*(["\']?)(.*?)\1\s*\)#is', $decl[1], $m ) ) { |
| 497 |
return ''; |
| 498 |
} |
| 499 |
$url = trim( $m[2] ); |
| 500 |
if ( '' === $url || 0 === stripos( $url, 'data:' ) ) { |
| 501 |
return ''; |
| 502 |
} |
| 503 |
return $url; |
| 504 |
} |
| 505 |
|
| 506 |
/** |
| 507 |
* Declared pixel area from an inline style, or 0 when it can't be read. |
| 508 |
* |
| 509 |
* Only px is honoured. A percentage or viewport unit resolves against a |
| 510 |
* containing block we cannot see from markup, and guessing one produced the |
| 511 |
* wrong winner more often than declining to. |
| 512 |
*/ |
| 513 |
private static function style_area( string $style ): int { |
| 514 |
$w = self::style_px( $style, 'width' ); |
| 515 |
$h = self::style_px( $style, 'height' ); |
| 516 |
if ( $w > 0 && $h > 0 ) { |
| 517 |
return $w * $h; |
| 518 |
} |
| 519 |
if ( $w > 0 ) { |
| 520 |
return (int) round( $w * $w * self::ASSUMED_ASPECT_RATIO ); |
| 521 |
} |
| 522 |
return 0; |
| 523 |
} |
| 524 |
|
| 525 |
/** One px-valued CSS length from an inline style, or 0. */ |
| 526 |
private static function style_px( string $style, string $prop ): int { |
| 527 |
if ( preg_match( '#(?:^|;)\s*' . preg_quote( $prop, '#' ) . '\s*:\s*(\d+(?:\.\d+)?)px#i', $style, $m ) ) { |
| 528 |
return (int) round( (float) $m[1] ); |
| 529 |
} |
| 530 |
return 0; |
| 531 |
} |
| 532 |
|
| 533 |
/** |
| 534 |
* How likely is this <img> to be the LCP element? Higher wins. |
| 535 |
* |
| 536 |
* Rendered area is the best available proxy, and we can only read what the |
| 537 |
* markup declares: |
| 538 |
* |
| 539 |
* 1. `width` × `height` attributes — the real area, when present. |
| 540 |
* 2. The largest `srcset` / `data-srcset` candidate width, squared into a |
| 541 |
* pseudo-area. A responsive hero usually omits width/height but ships |
| 542 |
* a 1600w+ candidate, which says more about its size than its |
| 543 |
* position ever did. |
| 544 |
* 3. Nothing readable → a neutral score, so the image still competes |
| 545 |
* (matching looks_too_small()'s "don't guess" rule) but loses to any |
| 546 |
* image we CAN measure as larger. |
| 547 |
* |
| 548 |
* @param string $tag The full <img> tag. |
| 549 |
* @param string $srcset Resolved srcset (may come from data-srcset). |
| 550 |
*/ |
| 551 |
private static function lcp_score( string $tag, string $srcset ): float { |
| 552 |
$w = self::attr( $tag, 'width' ); |
| 553 |
$h = self::attr( $tag, 'height' ); |
| 554 |
if ( '' !== $w && '' !== $h && is_numeric( $w ) && is_numeric( $h ) ) { |
| 555 |
return (float) ( (int) $w * (int) $h ); |
| 556 |
} |
| 557 |
|
| 558 |
$widest = self::widest_srcset_width( $srcset ); |
| 559 |
if ( $widest > 0 ) { |
| 560 |
// Estimate an AREA, not a square. Squaring the width compared a |
| 561 |
// pseudo-area against a real one and overstated the width-only |
| 562 |
// candidate by roughly the inverse of its aspect ratio, so a |
| 563 |
// 1024w sidebar thumbnail (1 048 576) beat a declared 1200×600 |
| 564 |
// hero (720 000) — a regression on exactly the mixed pages that |
| 565 |
// document order used to get right, since the hero usually comes |
| 566 |
// first. Assuming a 16:9 box keeps both sides in the same units. |
| 567 |
return round( $widest * $widest * self::ASSUMED_ASPECT_RATIO ); |
| 568 |
} |
| 569 |
|
| 570 |
return (float) self::UNKNOWN_SIZE_SCORE; |
| 571 |
} |
| 572 |
|
| 573 |
/** |
| 574 |
* Fold the author's own priority signals and document position into an |
| 575 |
* area score. (FBS-84576) |
| 576 |
* |
| 577 |
* Boosts are ADDITIVE, in area units, so they can rescue an image whose |
| 578 |
* size the markup doesn't declare: a hero with no width/height and no |
| 579 |
* `w`-descriptor srcset scores UNKNOWN_SIZE_SCORE, and multiplying that |
| 580 |
* by any factor still loses to a 548×136 logo that declares itself. This |
| 581 |
* is exactly the live miss — the real hero carried loading="eager" |
| 582 |
* fetchpriority="high" and lost to three dimension-declaring decoys. |
| 583 |
* |
| 584 |
* - fetchpriority="high" is the strongest signal there is: the author |
| 585 |
* (or WP core's own LCP detection) has already named this image the |
| 586 |
* hero. Worth a hero-sized area. |
| 587 |
* - An EXPLICIT loading="eager" is a weaker but deliberate "load me |
| 588 |
* now" (the default is eager, so writing it out is a choice). |
| 589 |
* |
| 590 |
* Position is a light multiplicative weight — earlier is better, but the |
| 591 |
* spread is capped well under 5× so it can only break near-ties, never |
| 592 |
* outrank a genuinely larger image further down (the logo-vs-hero case). |
| 593 |
* |
| 594 |
* @param float $score Base area score from lcp_score() / style_area(). |
| 595 |
* @param string $tag The candidate's full tag (for the signal attrs). |
| 596 |
* @param int $order Document-order index of the candidate. |
| 597 |
*/ |
| 598 |
private static function weighted_score( float $score, string $tag, int $order ): float { |
| 599 |
if ( 'high' === strtolower( self::attr( $tag, 'fetchpriority' ) ) ) { |
| 600 |
$score += self::FETCHPRIORITY_HIGH_BOOST; |
| 601 |
} |
| 602 |
if ( 'eager' === strtolower( self::attr( $tag, 'loading' ) ) ) { |
| 603 |
$score += self::EAGER_BOOST; |
| 604 |
} |
| 605 |
return $score * self::position_weight( $order ); |
| 606 |
} |
| 607 |
|
| 608 |
/** |
| 609 |
* Document-position weight: 1.25 for the first image, easing to 1.0 by |
| 610 |
* the tenth. The whole spread is 25%, far under the 5× area difference it |
| 611 |
* must never override — it exists only to keep the old first-wins |
| 612 |
* behaviour for images we can't tell apart. |
| 613 |
*/ |
| 614 |
private static function position_weight( int $order ): float { |
| 615 |
return 1.0 + 0.25 * max( 0.0, 1.0 - $order / 10 ); |
| 616 |
} |
| 617 |
|
| 618 |
/** |
| 619 |
* Area-unit boost for fetchpriority="high" — roughly a 940×530 hero, so |
| 620 |
* an explicitly-marked image outranks any mid-page decoy even when its |
| 621 |
* own size is unreadable, while a genuinely huge unmarked image can still |
| 622 |
* beat a marked small one. |
| 623 |
*/ |
| 624 |
private const FETCHPRIORITY_HIGH_BOOST = 500000.0; |
| 625 |
|
| 626 |
/** |
| 627 |
* Area-unit boost for an explicit loading="eager" — roughly 420×240, |
| 628 |
* enough to break ties in favour of the author's intent without letting |
| 629 |
* an eager logo outrank a plain hero. |
| 630 |
*/ |
| 631 |
private const EAGER_BOOST = 100000.0; |
| 632 |
|
| 633 |
/** |
| 634 |
* Byte ranges of <footer>/<nav>/<aside> regions. Nesting-aware per tag |
| 635 |
* name (a nav inside a nav extends the range); an unclosed open tag |
| 636 |
* poisons through to the end of the document, which errs on the side of |
| 637 |
* not preloading — the safe direction, since a wrong preload is worse |
| 638 |
* than none. (FBS-84576) |
| 639 |
* |
| 640 |
* @return array<int,array{0:int,1:int}> [start, end] byte offsets. |
| 641 |
*/ |
| 642 |
private static function chrome_container_ranges( string $html ): array { |
| 643 |
$ranges = array(); |
| 644 |
foreach ( array( 'footer', 'nav', 'aside' ) as $name ) { |
| 645 |
// (?=[\s/>]) rather than \b: a word boundary sits before the `-` |
| 646 |
// of a custom element, so `<nav\b` would swallow `<nav-menu>`. |
| 647 |
if ( ! preg_match_all( '#<(/?)' . $name . '(?=[\s/>])[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { |
| 648 |
continue; |
| 649 |
} |
| 650 |
$depth = 0; |
| 651 |
$start = 0; |
| 652 |
foreach ( $m[0] as $i => $match ) { |
| 653 |
$closing = '' !== $m[1][ $i ][0]; |
| 654 |
if ( ! $closing ) { |
| 655 |
if ( 0 === $depth ) { |
| 656 |
$start = $match[1]; |
| 657 |
} |
| 658 |
++$depth; |
| 659 |
} elseif ( $depth > 0 ) { |
| 660 |
--$depth; |
| 661 |
if ( 0 === $depth ) { |
| 662 |
$ranges[] = array( $start, $match[1] ); |
| 663 |
} |
| 664 |
} |
| 665 |
} |
| 666 |
if ( $depth > 0 ) { |
| 667 |
$ranges[] = array( $start, strlen( $html ) ); |
| 668 |
} |
| 669 |
} |
| 670 |
return $ranges; |
| 671 |
} |
| 672 |
|
| 673 |
/** Does a byte offset fall inside any of the given [start, end] ranges? */ |
| 674 |
private static function offset_in_ranges( int $offset, array $ranges ): bool { |
| 675 |
foreach ( $ranges as $range ) { |
| 676 |
if ( $offset > $range[0] && $offset < $range[1] ) { |
| 677 |
return true; |
| 678 |
} |
| 679 |
} |
| 680 |
return false; |
| 681 |
} |
| 682 |
|
| 683 |
/** |
| 684 |
* Score for an image whose size we can't read at all. |
| 685 |
* |
| 686 |
* Deliberately non-zero: an unmeasurable image must still beat nothing and |
| 687 |
* still be preloadable on a page where no image declares its size. But it |
| 688 |
* sits below a 200×200 declared area (40 000), so anything we CAN measure |
| 689 |
* as a plausible hero outranks a guess. |
| 690 |
*/ |
| 691 |
private const UNKNOWN_SIZE_SCORE = 1; |
| 692 |
|
| 693 |
/** |
| 694 |
* Height-to-width ratio assumed when only a `w` descriptor is readable. |
| 695 |
* |
| 696 |
* 9/16 — the commonest hero/banner shape, and close enough that a |
| 697 |
* width-only candidate is compared against a declared w×h area on the |
| 698 |
* same scale rather than being systematically inflated. |
| 699 |
*/ |
| 700 |
private const ASSUMED_ASPECT_RATIO = 9 / 16; |
| 701 |
|
| 702 |
/** |
| 703 |
* Largest `w` descriptor in a srcset, or 0 when there isn't one. |
| 704 |
* |
| 705 |
* Only `w` descriptors are read. An `x` descriptor (`hero.jpg 2x`) |
| 706 |
* describes pixel density, not layout width, so it says nothing about |
| 707 |
* rendered area. |
| 708 |
*/ |
| 709 |
private static function widest_srcset_width( string $srcset ): int { |
| 710 |
if ( '' === $srcset ) { |
| 711 |
return 0; |
| 712 |
} |
| 713 |
$widest = 0; |
| 714 |
foreach ( explode( ',', $srcset ) as $candidate ) { |
| 715 |
if ( preg_match( '#(\d+)w\s*$#', trim( $candidate ), $m ) ) { |
| 716 |
$widest = max( $widest, (int) $m[1] ); |
| 717 |
} |
| 718 |
} |
| 719 |
return $widest; |
| 720 |
} |
| 721 |
|
| 722 |
/** |
| 723 |
* Assemble one <link rel="preload" as="image" fetchpriority="high">. |
| 724 |
* |
| 725 |
* The href/srcset run through the `xspeed_lcp_preload_url` / |
| 726 |
* `xspeed_lcp_preload_srcset` filters first. This is the coordination point |
| 727 |
* with format-negotiating layers (Pro's Images module wraps the LCP <img> in |
| 728 |
* a <picture> with a WebP/AVIF <source>, so the browser paints e.g. |
| 729 |
* hero.png.webp, NOT the hero.png this preload would otherwise point at — |
| 730 |
* making the high-priority preload a wasted download while the real LCP |
| 731 |
* resource goes un-preloaded). By filtering the URL, a webp/avif layer can |
| 732 |
* redirect the preload to the format it will actually serve, WITHOUT Free |
| 733 |
* knowing that layer exists. (FBS-83553 H3) |
| 734 |
*/ |
| 735 |
private static function preload_link( string $src, string $srcset, string $sizes ): string { |
| 736 |
// Resolve the `type` from the ORIGINAL image URL (before rewriting), so a |
| 737 |
// negotiating layer can key off the source .jpg/.png — after rewriting, |
| 738 |
// the URL is already a .webp and the derivation would no-op. |
| 739 |
$original = $src; |
| 740 |
/** |
| 741 |
* Filter an explicit `type` for the preload link (e.g. "image/webp"). |
| 742 |
* Empty = omit. A typed image preload is only fetched by browsers that |
| 743 |
* accept that type, so pairing a webp href with type="image/webp" is safe |
| 744 |
* even though the markup is baked into a shared cache file. |
| 745 |
* |
| 746 |
* @param string $type Defaults to '' (no type attribute). |
| 747 |
* @param string $src The ORIGINAL (pre-rewrite) preload URL. |
| 748 |
*/ |
| 749 |
$type = (string) apply_filters( 'xspeed_lcp_preload_type', '', $original ); |
| 750 |
/** |
| 751 |
* Filter the LCP preload href. Return a modern-format sibling (webp/avif) |
| 752 |
* when one will actually be served for this image. |
| 753 |
* |
| 754 |
* @param string $src The original image URL chosen for preload. |
| 755 |
*/ |
| 756 |
$src = (string) apply_filters( 'xspeed_lcp_preload_url', $src ); |
| 757 |
if ( '' !== $srcset ) { |
| 758 |
/** @param string $srcset The original srcset chosen for preload. */ |
| 759 |
$srcset = (string) apply_filters( 'xspeed_lcp_preload_srcset', $srcset ); |
| 760 |
} |
| 761 |
|
| 762 |
$attrs = sprintf( 'href="%s"', esc_url( $src ) ); |
| 763 |
|
| 764 |
if ( '' !== $srcset ) { |
| 765 |
// Preserve the responsive candidate set so the browser preloads |
| 766 |
// the same file it would have chosen from the <img>. |
| 767 |
$attrs .= sprintf( ' imagesrcset="%s"', esc_attr( html_entity_decode( $srcset, ENT_QUOTES ) ) ); |
| 768 |
if ( '' !== $sizes ) { |
| 769 |
$attrs .= sprintf( ' imagesizes="%s"', esc_attr( html_entity_decode( $sizes, ENT_QUOTES ) ) ); |
| 770 |
} |
| 771 |
} |
| 772 |
|
| 773 |
if ( '' !== $type ) { |
| 774 |
$attrs .= sprintf( ' type="%s"', esc_attr( $type ) ); |
| 775 |
} |
| 776 |
|
| 777 |
return sprintf( '<link rel="preload" as="image" %s fetchpriority="high">' . "\n", $attrs ); |
| 778 |
} |
| 779 |
|
| 780 |
/** |
| 781 |
* Extract a single/double-quoted attribute value from a tag. Returns '' |
| 782 |
* when the attribute is absent. |
| 783 |
*/ |
| 784 |
private static function attr( string $tag, string $name ): string { |
| 785 |
// Anchor on a real attribute boundary, not `\b`. A word boundary sits |
| 786 |
// between the `-` and the `w` of `data-width`, so `\bwidth=` matched |
| 787 |
// inside it: a lazy-loaded hero carrying `data-width="50" |
| 788 |
// data-height="50"` was scored 50×50 and rejected by |
| 789 |
// looks_too_small() — defeating the feature on exactly the images the |
| 790 |
// data-src/data-srcset handling exists to support. Requiring |
| 791 |
// whitespace (or the start of the string) before the name means only |
| 792 |
// a genuine attribute matches. |
| 793 |
if ( preg_match( '#(?:^|\s)' . preg_quote( $name, '#' ) . '\s*=\s*(["\'])(.*?)\1#is', $tag, $m ) ) { |
| 794 |
return trim( $m[2] ); |
| 795 |
} |
| 796 |
return ''; |
| 797 |
} |
| 798 |
|
| 799 |
/** |
| 800 |
* Resolve the URL/srcset/sizes the browser will actually paint for an |
| 801 |
* <img>, seeing through JS-lazy placeholders. When `src` is a data: URI (a |
| 802 |
* builder/lazy-loader placeholder), fall back to `data-src`; likewise carry |
| 803 |
* `data-srcset`/`data-sizes` when the plain ones are absent. Returns |
| 804 |
* ['', '', ''] when there's no real raster URL to preload. (FBS-83553 H1) |
| 805 |
* |
| 806 |
* @return array{0:string,1:string,2:string} [src, srcset, sizes] |
| 807 |
*/ |
| 808 |
private static function effective_image_src( string $tag ): array { |
| 809 |
$src = self::attr( $tag, 'src' ); |
| 810 |
if ( '' === $src || 0 === stripos( $src, 'data:' ) ) { |
| 811 |
$data_src = self::attr( $tag, 'data-src' ); |
| 812 |
if ( '' !== $data_src && 0 !== stripos( $data_src, 'data:' ) ) { |
| 813 |
$src = $data_src; |
| 814 |
} |
| 815 |
} |
| 816 |
if ( '' === $src || 0 === stripos( $src, 'data:' ) ) { |
| 817 |
return array( '', '', '' ); |
| 818 |
} |
| 819 |
$srcset = self::attr( $tag, 'srcset' ); |
| 820 |
if ( '' === $srcset ) { |
| 821 |
$srcset = self::attr( $tag, 'data-srcset' ); |
| 822 |
} |
| 823 |
$sizes = self::attr( $tag, 'sizes' ); |
| 824 |
if ( '' === $sizes ) { |
| 825 |
$sizes = self::attr( $tag, 'data-sizes' ); |
| 826 |
} |
| 827 |
return array( $src, $srcset, $sizes ); |
| 828 |
} |
| 829 |
|
| 830 |
/** |
| 831 |
* At/below this (px) in BOTH width and height, an image is treated as a |
| 832 |
* logo/icon/avatar rather than an LCP hero. 200px clears real content heroes |
| 833 |
* (which are typically ≥ 400px wide) while catching site logos and avatars |
| 834 |
* — including the 150×150 logo the picker used to mistakenly preload. |
| 835 |
*/ |
| 836 |
private const MIN_LCP_DIMENSION = 200; |
| 837 |
|
| 838 |
/** |
| 839 |
* Class/role/filename markers that identify site chrome (logo, icon, |
| 840 |
* avatar, spinner, emoji) which should never be treated as the LCP hero, |
| 841 |
* regardless of declared size. |
| 842 |
*/ |
| 843 |
private const NON_HERO_MARKERS = array( 'logo', 'icon', 'avatar', 'gravatar', 'spinner', 'emoji', 'site-icon', 'custom-logo' ); |
| 844 |
|
| 845 |
/** |
| 846 |
* Below this declared area (px²) an image is a badge/thumb/divider, never |
| 847 |
* an LCP hero — 10 000 is a 100×100 square, or a 500×20 strip. Applied |
| 848 |
* only when BOTH dimensions are readable. (FBS-84576) |
| 849 |
*/ |
| 850 |
private const MIN_LCP_AREA = 10000; |
| 851 |
|
| 852 |
/** |
| 853 |
* Is this <img> too small / too chrome-like to be the LCP hero? True when |
| 854 |
* either (a) it carries a logo/icon/avatar marker, (b) an explicit |
| 855 |
* `data-no-lcp` opt-out, or (c) BOTH width and height are present and |
| 856 |
* both are ≤ the dimension threshold, or their area is under |
| 857 |
* MIN_LCP_AREA. Missing dimensions are NOT guessed — an image whose size |
| 858 |
* we can't read still competes. (FBS-83553 H1 "logo before hero".) |
| 859 |
*/ |
| 860 |
private static function looks_too_small( string $tag ): bool { |
| 861 |
if ( false !== stripos( $tag, 'data-no-lcp' ) ) { |
| 862 |
return true; |
| 863 |
} |
| 864 |
// Marker check against class / id / src (covers "custom-logo", a |
| 865 |
// "…/logo.png" filename, role="img" avatars, etc.). |
| 866 |
$haystack = strtolower( self::attr( $tag, 'class' ) . ' ' . self::attr( $tag, 'id' ) . ' ' . self::attr( $tag, 'src' ) ); |
| 867 |
foreach ( self::NON_HERO_MARKERS as $marker ) { |
| 868 |
if ( false !== strpos( $haystack, $marker ) ) { |
| 869 |
return true; |
| 870 |
} |
| 871 |
} |
| 872 |
$w = self::attr( $tag, 'width' ); |
| 873 |
$h = self::attr( $tag, 'height' ); |
| 874 |
if ( '' === $w || '' === $h || ! is_numeric( $w ) || ! is_numeric( $h ) ) { |
| 875 |
return false; // unknown size — don't guess; let it compete. |
| 876 |
} |
| 877 |
if ( (int) $w * (int) $h < self::MIN_LCP_AREA ) { |
| 878 |
return true; |
| 879 |
} |
| 880 |
return (int) $w <= self::MIN_LCP_DIMENSION && (int) $h <= self::MIN_LCP_DIMENSION; |
| 881 |
} |
| 882 |
|
| 883 |
/** |
| 884 |
* Promote an <img> to the LCP element: force fetchpriority="high" and |
| 885 |
* strip any loading="lazy" so the browser loads it immediately. Both are |
| 886 |
* idempotent. `loading="lazy"` is REMOVED rather than flipped to "eager" |
| 887 |
* because eager is the default; a bare tag with fetchpriority="high" is |
| 888 |
* the canonical high-priority-image form. |
| 889 |
*/ |
| 890 |
private static function promote_lcp_img( string $tag ): string { |
| 891 |
$tag = self::set_fetchpriority( $tag ); |
| 892 |
// Drop loading="lazy" (WP core adds it by default). Leave other |
| 893 |
// loading values (e.g. an explicit eager) intact — only lazy hurts. |
| 894 |
$tag = preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag ); |
| 895 |
return (string) $tag; |
| 896 |
} |
| 897 |
|
| 898 |
/** |
| 899 |
* Add fetchpriority="high" to an <img> tag. Idempotent — an existing |
| 900 |
* fetchpriority value is normalised to high rather than duplicated. |
| 901 |
*/ |
| 902 |
private static function set_fetchpriority( string $tag ): string { |
| 903 |
if ( preg_match( '#\bfetchpriority=(["\']).*?\1#i', $tag ) ) { |
| 904 |
return (string) preg_replace( '#\bfetchpriority=(["\']).*?\1#i', 'fetchpriority="high"', $tag, 1 ); |
| 905 |
} |
| 906 |
// Insert right after "<img". |
| 907 |
return (string) preg_replace( '#<img\b#i', '<img fetchpriority="high"', $tag, 1 ); |
| 908 |
} |
| 909 |
|
| 910 |
/** |
| 911 |
* Inject the assembled hint markup into <head>. Prefers to land right |
| 912 |
* before the first stylesheet so the preloads are discovered before the |
| 913 |
* render-blocking CSS. Falls back to after <head>, then prepend. |
| 914 |
*/ |
| 915 |
private static function inject_into_head( string $html, string $hints ): string { |
| 916 |
// Before the first <link rel="stylesheet"> if there is one. |
| 917 |
if ( preg_match( '#<link\b[^>]*rel=["\']stylesheet["\'][^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { |
| 918 |
$pos = $m[0][1]; |
| 919 |
return substr( $html, 0, $pos ) . $hints . substr( $html, $pos ); |
| 920 |
} |
| 921 |
// Otherwise right after the opening <head ...>. |
| 922 |
if ( preg_match( '#<head\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { |
| 923 |
$pos = $m[0][1] + strlen( $m[0][0] ); |
| 924 |
return substr( $html, 0, $pos ) . "\n" . $hints . substr( $html, $pos ); |
| 925 |
} |
| 926 |
// No head at all — prepend (degenerate documents). |
| 927 |
return $hints . $html; |
| 928 |
} |
| 929 |
} |
| 930 |
|