PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 1.2.1 All 27 releases
xspeed / includes / class-resource-hints-processor.php

class-resource-hints-processor.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.0, at includes/class-resource-hints-processor.php

930 lines 35.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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(&quot;/hero.jpg&quot;)), and `&quot;`
489 // carries a semicolon — so splitting the declaration on `;` first
490 // truncated the value to `url(&quot` 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