PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 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 All 33 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.7, at includes/class-resource-hints-processor.php

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