PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.5 All 32 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.6, at includes/class-resource-hints-processor.php

1,187 lines 44.6 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 // PASS 1c — <video poster="…">. A full-screen hero video paints its
333 // poster first, and that first frame IS the LCP; measured on a live
334 // page, a preloaded poster cut the LCP load delay from 1.5 s to 21 ms.
335 foreach ( self::video_poster_candidates( $html, $exclusions, $skip_ranges ) as $vp ) {
336 $candidates[] = $vp;
337 }
338
339 // PASS 1d — background rules in inline <style> blocks. Page builders
340 // put the hero's background-image in generated per-post CSS printed
341 // inline (Elementor's `.elementor-N .elementor-element-X` rules), not
342 // in a style attribute — so PASS 1b never saw the element that
343 // actually paints as LCP on five of seven measured sites. External
344 // stylesheets stay out for the same reasons as before (#247): fetching
345 // CSS from an output-buffer pass costs more than the preload saves.
346 foreach ( self::style_block_candidates( $html, $exclusions, $skip_ranges ) as $sb ) {
347 $candidates[] = $sb;
348 }
349
350 if ( empty( $candidates ) ) {
351 return array( $html, '' );
352 }
353
354 // Rank by score, biggest first. Document order breaks ties, so two
355 // equally-sized images (or two of unknown size) keep the previous
356 // first-wins behaviour — the change only matters when we can actually
357 // tell one is larger.
358 usort(
359 $candidates,
360 static function ( array $a, array $b ) {
361 if ( $a['score'] === $b['score'] ) {
362 return $a['order'] <=> $b['order'];
363 }
364 return $b['score'] <=> $a['score'];
365 }
366 );
367
368 $winners = array_slice( $candidates, 0, $count );
369
370 // PASS 2 — emit the preload links and promote the winning tags.
371 $chosen = array();
372 foreach ( $winners as $w ) {
373 // Idempotency: if this src is already the target of a
374 // rel="preload" as="image" link, still promote the tag but don't
375 // emit a duplicate <link>.
376 $already = (bool) preg_match(
377 '#rel=["\']preload["\'][^>]*as=["\']image["\'][^>]*' . preg_quote( $w['src'], '#' ) . '#i',
378 $existing
379 );
380 if ( ! $already ) {
381 $preload .= self::preload_link( $w['src'], $w['srcset'], $w['sizes'] );
382 }
383 // Only <img> winners are promoted in PASS 2 — there is no
384 // fetchpriority/loading attribute to fix on a background element,
385 // and its `order` is offset past every <img> index precisely so it
386 // can never select one for rewriting.
387 if ( empty( $w['background'] ) ) {
388 $chosen[ $w['order'] ] = true;
389 }
390 }
391
392 // Rewrite only the winning tags. Counting occurrences rather than
393 // matching on tag text, because the same markup can legitimately
394 // appear more than once on a page and only the ranked instance should
395 // be promoted.
396 $seen = -1;
397 $html = preg_replace_callback(
398 '#<img\b[^>]*>#i',
399 static function ( array $m ) use ( &$seen, $chosen ) {
400 ++$seen;
401 if ( ! isset( $chosen[ $seen ] ) ) {
402 return $m[0];
403 }
404 // Add fetchpriority="high" AND remove any loading="lazy" the
405 // theme / WP core left on the LCP image. fetchpriority="high"
406 // with loading="lazy" is contradictory — the browser can still
407 // defer a lazy image, so preloading it while it stays lazy wins
408 // nothing. Stripping lazy is what actually lets the preload land.
409 return self::promote_lcp_img( $m[0] );
410 },
411 $html
412 );
413
414 return array( (string) $html, $preload );
415 }
416
417 /**
418 * Collect CSS `background-image` heroes as LCP candidates.
419 *
420 * Only INLINE `style` attributes are read. A background declared in an
421 * external stylesheet is invisible here by design: resolving it would mean
422 * fetching and parsing CSS from inside an output-buffer pass, and the URL a
423 * selector resolves to depends on cascade order we cannot evaluate from
424 * markup. Builders that put the hero in a generated per-post stylesheet are
425 * therefore still unserved — worth doing, but not at this cost. (#247)
426 *
427 * Scores are the element's declared pixel area so a background competes
428 * against an <img> in the SAME units — the whole point being that the
429 * bigger of the two should win regardless of which kind it is.
430 *
431 * @param string $html Full page HTML.
432 * @param string[] $exclusions Substring patterns the user excluded.
433 * @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers.
434 * @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}>
435 */
436 private static function background_candidates( string $html, array $exclusions, array $skip_ranges ): array {
437 if ( ! preg_match_all( '#<(?:div|section|header|figure|a|span|li|main|article|aside)\b[^>]*\sstyle\s*=\s*(["\']).*?\1[^>]*>#is', $html, $matches, PREG_OFFSET_CAPTURE ) ) {
438 return array();
439 }
440
441 $found = array();
442 foreach ( $matches[0] as $index => $match ) {
443 [ $tag, $offset ] = $match;
444
445 // The same chrome-container gate as <img>: a background painted
446 // inside <footer>/<nav>/<aside> is never the hero. (FBS-84576)
447 if ( self::offset_in_ranges( $offset, $skip_ranges ) ) {
448 continue;
449 }
450
451 $style = self::attr( $tag, 'style' );
452 if ( '' === $style || false === stripos( $style, 'background' ) ) {
453 continue;
454 }
455
456 $src = self::background_url( $style );
457 if ( '' === $src ) {
458 continue;
459 }
460
461 foreach ( $exclusions as $needle ) {
462 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
463 continue 2;
464 }
465 }
466
467 // Same chrome/opt-out gates as <img>. A logo painted as a background
468 // is no more the hero than a logo in an <img>.
469 if ( self::looks_too_small( $tag ) ) {
470 continue;
471 }
472
473 $area = self::style_area( $style );
474 if ( 0 === $area ) {
475 // Nothing readable. Deliberately non-zero for the same reason
476 // UNKNOWN_SIZE_SCORE is: an unmeasurable background must still
477 // beat nothing on a page that declares no sizes at all, while
478 // losing to anything we can actually measure.
479 $area = self::UNKNOWN_SIZE_SCORE;
480 }
481
482 $found[] = array(
483 'tag' => $tag,
484 'src' => $src,
485 'srcset' => '',
486 'sizes' => '',
487 'score' => (float) $area,
488 // Offset so a background never ties ahead of an <img> that
489 // appeared earlier in the document; ties still break on order.
490 'order' => 100000 + $index,
491 'background' => true,
492 );
493 }
494
495 return $found;
496 }
497
498 /**
499 * Collect `<video poster="…">` first frames as LCP candidates.
500 *
501 * The poster is what the viewer sees until (and unless) the video plays —
502 * on a background-video hero, delayed by the Lazy module, it is the ONLY
503 * frame the initial paint has. Scored like a background: the element's
504 * declared inline-style area, or the unknown-size floor, with the order
505 * offset past every <img> so a poster never ties ahead of one.
506 *
507 * @param string $html Full page HTML.
508 * @param string[] $exclusions Substring patterns the user excluded.
509 * @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers.
510 * @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}>
511 */
512 private static function video_poster_candidates( string $html, array $exclusions, array $skip_ranges ): array {
513 if ( ! preg_match_all( '#<video\b[^>]*\bposter\s*=\s*(["\'])(.*?)\1[^>]*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) {
514 return array();
515 }
516
517 $found = array();
518 foreach ( $matches[0] as $index => $match ) {
519 [ $tag, $offset ] = $match;
520 $src = trim( html_entity_decode( $matches[2][ $index ][0], ENT_QUOTES ) );
521 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
522 continue;
523 }
524 if ( self::offset_in_ranges( $offset, $skip_ranges ) ) {
525 continue;
526 }
527 foreach ( $exclusions as $needle ) {
528 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
529 continue 2;
530 }
531 }
532 if ( self::looks_too_small( $tag ) ) {
533 continue;
534 }
535
536 $area = self::style_area( self::attr( $tag, 'style' ) );
537 if ( 0 === $area ) {
538 $area = self::UNKNOWN_SIZE_SCORE;
539 }
540
541 $found[] = array(
542 'tag' => $tag,
543 'src' => $src,
544 'srcset' => '',
545 'sizes' => '',
546 'score' => (float) $area,
547 'order' => 100000 + $index,
548 'background' => true,
549 );
550 }
551
552 return $found;
553 }
554
555 /**
556 * How many <style>-block background rules are considered per page. The
557 * scan is linear, but each rule costs one class-lookup pass over the
558 * body, so a pathological page (thousands of generated rules) is capped
559 * rather than trusted.
560 */
561 private const STYLE_RULE_BUDGET = 40;
562
563 /**
564 * Collect background-image rules from inline <style> blocks whose
565 * selector matches an element in the body.
566 *
567 * The match is deliberately narrow: the rule's RIGHTMOST simple selector
568 * must carry a class or id, and the first element in the body bearing it
569 * (outside chrome containers) is taken as the painted element. Rules
570 * inside @media (or any other at-rule block) are skipped — a desktop-only
571 * background preloaded on mobile is a wasted high-priority fetch, and the
572 * markup gives no viewport to resolve the query against.
573 *
574 * @param string $html Full page HTML.
575 * @param string[] $exclusions Substring patterns the user excluded.
576 * @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers.
577 * @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}>
578 */
579 private static function style_block_candidates( string $html, array $exclusions, array $skip_ranges ): array {
580 if ( ! preg_match_all( '#<style\b[^>]*>(.*?)</style\s*>#is', $html, $blocks ) ) {
581 return array();
582 }
583
584 $found = array();
585 $budget = self::STYLE_RULE_BUDGET;
586 foreach ( $blocks[1] as $css ) {
587 if ( $budget <= 0 ) {
588 break;
589 }
590 $css = self::strip_at_rule_blocks( $css );
591 if ( false === stripos( $css, 'url(' ) ) {
592 continue;
593 }
594 // One flat rule at a time: selector list up to '{', body to '}'.
595 if ( ! preg_match_all( '#(?:^|})\s*([^{}]{1,512})\{([^{}]*)\}#s', $css, $rules, PREG_SET_ORDER ) ) {
596 continue;
597 }
598 foreach ( $rules as $rule ) {
599 if ( $budget <= 0 ) {
600 break 2;
601 }
602 if ( false === stripos( $rule[2], 'url(' ) ) {
603 continue;
604 }
605 $src = self::background_url( $rule[2] );
606 if ( '' === $src ) {
607 continue;
608 }
609 --$budget;
610 // First selector of the list, rightmost compound of it.
611 $selector = trim( (string) strtok( $rule[1], ',' ) );
612 $parts = preg_split( '#[\s>+~]+#', $selector );
613 $last = (string) end( $parts );
614 // The last class or id token of that compound. Pseudo-classes
615 // (:hover, ::before) mean the background is not the initial
616 // paint, so they disqualify the rule.
617 if ( false !== strpos( $last, ':' ) ) {
618 continue;
619 }
620 if ( ! preg_match( '#([.\#])([-\w]+)$#', $last, $tok ) ) {
621 continue;
622 }
623 $el = '.' === $tok[1]
624 ? self::first_element_with_class( $html, $tok[2], $skip_ranges )
625 : self::first_element_with_id( $html, $tok[2], $skip_ranges );
626 if ( null === $el ) {
627 continue;
628 }
629 [ $tag, $offset ] = $el;
630 foreach ( $exclusions as $needle ) {
631 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
632 continue 2;
633 }
634 }
635 if ( self::looks_too_small( $tag ) ) {
636 continue;
637 }
638 $area = self::style_area( self::attr( $tag, 'style' ) );
639 if ( 0 === $area ) {
640 $area = self::UNKNOWN_SIZE_SCORE;
641 }
642 $found[] = array(
643 'tag' => $tag,
644 'src' => $src,
645 'srcset' => '',
646 'sizes' => '',
647 'score' => (float) $area,
648 // Offset past the inline-style backgrounds: a rule-matched
649 // background is one inference step less certain, so it must
650 // never tie ahead of one read straight off the element.
651 'order' => 200000 + $offset,
652 'background' => true,
653 );
654 }
655 }
656
657 return $found;
658 }
659
660 /**
661 * CSS with every at-rule BLOCK (@media, @supports, @container, …) removed,
662 * by brace depth — a regex cannot pair nested braces. Flat at-rules
663 * (@import, @charset) have no block and pass through harmlessly.
664 */
665 private static function strip_at_rule_blocks( string $css ): string {
666 $out = '';
667 $len = strlen( $css );
668 $i = 0;
669 while ( $i < $len ) {
670 $at = strpos( $css, '@', $i );
671 if ( false === $at ) {
672 return $out . substr( $css, $i );
673 }
674 $brace = strpos( $css, '{', $at );
675 $semi = strpos( $css, ';', $at );
676 $out .= substr( $css, $i, $at - $i );
677 if ( false === $brace || ( false !== $semi && $semi < $brace ) ) {
678 // Flat at-rule — skip to its semicolon (or end).
679 $i = false === $semi ? $len : $semi + 1;
680 continue;
681 }
682 // Block at-rule — skip to its matching close brace.
683 $depth = 1;
684 $i = $brace + 1;
685 while ( $i < $len && $depth > 0 ) {
686 $c = $css[ $i ];
687 if ( '{' === $c ) {
688 ++$depth;
689 } elseif ( '}' === $c ) {
690 --$depth;
691 }
692 ++$i;
693 }
694 }
695 return $out;
696 }
697
698 /**
699 * The first element in the BODY carrying $class (outside chrome ranges),
700 * as [tag, offset], or null. Body-only, so a head <meta> can never match
701 * and a hit's offset is comparable with the chrome ranges.
702 *
703 * @return array{0:string,1:int}|null
704 */
705 private static function first_element_with_class( string $html, string $class, array $skip_ranges ): ?array {
706 $body = stripos( $html, '<body' );
707 $from = false === $body ? 0 : $body;
708 if ( ! preg_match_all( '#<[a-z][^>]*\bclass\s*=\s*(["\'])[^"\']*(?<![-\w])' . preg_quote( $class, '#' ) . '(?![-\w])[^"\']*\1[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, $from ) ) {
709 return null;
710 }
711 foreach ( $m[0] as $match ) {
712 if ( ! self::offset_in_ranges( $match[1], $skip_ranges ) ) {
713 return array( $match[0], $match[1] );
714 }
715 }
716 return null;
717 }
718
719 /**
720 * The first element carrying id="$id" (outside chrome ranges), as
721 * [tag, offset], or null.
722 *
723 * @return array{0:string,1:int}|null
724 */
725 private static function first_element_with_id( string $html, string $id, array $skip_ranges ): ?array {
726 $body = stripos( $html, '<body' );
727 $from = false === $body ? 0 : $body;
728 if ( ! preg_match( '#<[a-z][^>]*\bid\s*=\s*(["\'])' . preg_quote( $id, '#' ) . '\1[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, $from ) ) {
729 return null;
730 }
731 if ( self::offset_in_ranges( $m[0][1], $skip_ranges ) ) {
732 return null;
733 }
734 return array( $m[0][0], $m[0][1] );
735 }
736
737 /**
738 * Pull a real image URL out of a `background`/`background-image` declaration.
739 *
740 * Returns '' for anything with nothing to fetch: a gradient (which is a
741 * background-image but not a resource), a data: URI, or `none`.
742 */
743 private static function background_url( string $style ): string {
744 // Decode BEFORE parsing. Builders emit the url() quotes HTML-encoded
745 // inside a style attribute (url(&quot;/hero.jpg&quot;)), and `&quot;`
746 // carries a semicolon — so splitting the declaration on `;` first
747 // truncated the value to `url(&quot` and found no URL at all.
748 $style = html_entity_decode( $style, ENT_QUOTES );
749
750 if ( ! preg_match( '#background(?:-image)?\s*:\s*((?:[^;\'"]|"[^"]*"|\'[^\']*\')+)#i', $style, $decl ) ) {
751 return '';
752 }
753 if ( ! preg_match( '#url\(\s*(["\']?)(.*?)\1\s*\)#is', $decl[1], $m ) ) {
754 return '';
755 }
756 $url = trim( $m[2] );
757 if ( '' === $url || 0 === stripos( $url, 'data:' ) ) {
758 return '';
759 }
760 return $url;
761 }
762
763 /**
764 * Declared pixel area from an inline style, or 0 when it can't be read.
765 *
766 * Only px is honoured. A percentage or viewport unit resolves against a
767 * containing block we cannot see from markup, and guessing one produced the
768 * wrong winner more often than declining to.
769 */
770 private static function style_area( string $style ): int {
771 $w = self::style_px( $style, 'width' );
772 $h = self::style_px( $style, 'height' );
773 if ( $w > 0 && $h > 0 ) {
774 return $w * $h;
775 }
776 if ( $w > 0 ) {
777 return (int) round( $w * $w * self::ASSUMED_ASPECT_RATIO );
778 }
779 return 0;
780 }
781
782 /** One px-valued CSS length from an inline style, or 0. */
783 private static function style_px( string $style, string $prop ): int {
784 if ( preg_match( '#(?:^|;)\s*' . preg_quote( $prop, '#' ) . '\s*:\s*(\d+(?:\.\d+)?)px#i', $style, $m ) ) {
785 return (int) round( (float) $m[1] );
786 }
787 return 0;
788 }
789
790 /**
791 * How likely is this <img> to be the LCP element? Higher wins.
792 *
793 * Rendered area is the best available proxy, and we can only read what the
794 * markup declares:
795 *
796 * 1. `width` × `height` attributes — the real area, when present.
797 * 2. The largest `srcset` / `data-srcset` candidate width, squared into a
798 * pseudo-area. A responsive hero usually omits width/height but ships
799 * a 1600w+ candidate, which says more about its size than its
800 * position ever did.
801 * 3. Nothing readable → a neutral score, so the image still competes
802 * (matching looks_too_small()'s "don't guess" rule) but loses to any
803 * image we CAN measure as larger.
804 *
805 * @param string $tag The full <img> tag.
806 * @param string $srcset Resolved srcset (may come from data-srcset).
807 */
808 private static function lcp_score( string $tag, string $srcset ): float {
809 $w = self::attr( $tag, 'width' );
810 $h = self::attr( $tag, 'height' );
811 if ( '' !== $w && '' !== $h && is_numeric( $w ) && is_numeric( $h ) ) {
812 return (float) ( (int) $w * (int) $h );
813 }
814
815 $widest = self::widest_srcset_width( $srcset );
816 if ( $widest > 0 ) {
817 // Estimate an AREA, not a square. Squaring the width compared a
818 // pseudo-area against a real one and overstated the width-only
819 // candidate by roughly the inverse of its aspect ratio, so a
820 // 1024w sidebar thumbnail (1 048 576) beat a declared 1200×600
821 // hero (720 000) — a regression on exactly the mixed pages that
822 // document order used to get right, since the hero usually comes
823 // first. Assuming a 16:9 box keeps both sides in the same units.
824 return round( $widest * $widest * self::ASSUMED_ASPECT_RATIO );
825 }
826
827 return (float) self::UNKNOWN_SIZE_SCORE;
828 }
829
830 /**
831 * Fold the author's own priority signals and document position into an
832 * area score. (FBS-84576)
833 *
834 * Boosts are ADDITIVE, in area units, so they can rescue an image whose
835 * size the markup doesn't declare: a hero with no width/height and no
836 * `w`-descriptor srcset scores UNKNOWN_SIZE_SCORE, and multiplying that
837 * by any factor still loses to a 548×136 logo that declares itself. This
838 * is exactly the live miss — the real hero carried loading="eager"
839 * fetchpriority="high" and lost to three dimension-declaring decoys.
840 *
841 * - fetchpriority="high" is the strongest signal there is: the author
842 * (or WP core's own LCP detection) has already named this image the
843 * hero. Worth a hero-sized area.
844 * - An EXPLICIT loading="eager" is a weaker but deliberate "load me
845 * now" (the default is eager, so writing it out is a choice).
846 *
847 * Position is a light multiplicative weight — earlier is better, but the
848 * spread is capped well under 5× so it can only break near-ties, never
849 * outrank a genuinely larger image further down (the logo-vs-hero case).
850 *
851 * @param float $score Base area score from lcp_score() / style_area().
852 * @param string $tag The candidate's full tag (for the signal attrs).
853 * @param int $order Document-order index of the candidate.
854 */
855 private static function weighted_score( float $score, string $tag, int $order ): float {
856 if ( 'high' === strtolower( self::attr( $tag, 'fetchpriority' ) ) ) {
857 $score += self::FETCHPRIORITY_HIGH_BOOST;
858 }
859 if ( 'eager' === strtolower( self::attr( $tag, 'loading' ) ) ) {
860 $score += self::EAGER_BOOST;
861 }
862 return $score * self::position_weight( $order );
863 }
864
865 /**
866 * Document-position weight: 1.25 for the first image, easing to 1.0 by
867 * the tenth. The whole spread is 25%, far under the 5× area difference it
868 * must never override — it exists only to keep the old first-wins
869 * behaviour for images we can't tell apart.
870 */
871 private static function position_weight( int $order ): float {
872 return 1.0 + 0.25 * max( 0.0, 1.0 - $order / 10 );
873 }
874
875 /**
876 * Area-unit boost for fetchpriority="high" — roughly a 940×530 hero, so
877 * an explicitly-marked image outranks any mid-page decoy even when its
878 * own size is unreadable, while a genuinely huge unmarked image can still
879 * beat a marked small one.
880 */
881 private const FETCHPRIORITY_HIGH_BOOST = 500000.0;
882
883 /**
884 * Area-unit boost for an explicit loading="eager" — roughly 420×240,
885 * enough to break ties in favour of the author's intent without letting
886 * an eager logo outrank a plain hero.
887 */
888 private const EAGER_BOOST = 100000.0;
889
890 /**
891 * Byte ranges of <footer>/<nav>/<aside> regions. Nesting-aware per tag
892 * name (a nav inside a nav extends the range); an unclosed open tag
893 * poisons through to the end of the document, which errs on the side of
894 * not preloading — the safe direction, since a wrong preload is worse
895 * than none. (FBS-84576)
896 *
897 * @return array<int,array{0:int,1:int}> [start, end] byte offsets.
898 */
899 private static function chrome_container_ranges( string $html ): array {
900 $ranges = array();
901 foreach ( array( 'footer', 'nav', 'aside' ) as $name ) {
902 // (?=[\s/>]) rather than \b: a word boundary sits before the `-`
903 // of a custom element, so `<nav\b` would swallow `<nav-menu>`.
904 if ( ! preg_match_all( '#<(/?)' . $name . '(?=[\s/>])[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
905 continue;
906 }
907 $depth = 0;
908 $start = 0;
909 foreach ( $m[0] as $i => $match ) {
910 $closing = '' !== $m[1][ $i ][0];
911 if ( ! $closing ) {
912 if ( 0 === $depth ) {
913 $start = $match[1];
914 }
915 ++$depth;
916 } elseif ( $depth > 0 ) {
917 --$depth;
918 if ( 0 === $depth ) {
919 $ranges[] = array( $start, $match[1] );
920 }
921 }
922 }
923 if ( $depth > 0 ) {
924 $ranges[] = array( $start, strlen( $html ) );
925 }
926 }
927 return $ranges;
928 }
929
930 /** Does a byte offset fall inside any of the given [start, end] ranges? */
931 private static function offset_in_ranges( int $offset, array $ranges ): bool {
932 foreach ( $ranges as $range ) {
933 if ( $offset > $range[0] && $offset < $range[1] ) {
934 return true;
935 }
936 }
937 return false;
938 }
939
940 /**
941 * Score for an image whose size we can't read at all.
942 *
943 * Deliberately non-zero: an unmeasurable image must still beat nothing and
944 * still be preloadable on a page where no image declares its size. But it
945 * sits below a 200×200 declared area (40 000), so anything we CAN measure
946 * as a plausible hero outranks a guess.
947 */
948 private const UNKNOWN_SIZE_SCORE = 1;
949
950 /**
951 * Height-to-width ratio assumed when only a `w` descriptor is readable.
952 *
953 * 9/16 — the commonest hero/banner shape, and close enough that a
954 * width-only candidate is compared against a declared w×h area on the
955 * same scale rather than being systematically inflated.
956 */
957 private const ASSUMED_ASPECT_RATIO = 9 / 16;
958
959 /**
960 * Largest `w` descriptor in a srcset, or 0 when there isn't one.
961 *
962 * Only `w` descriptors are read. An `x` descriptor (`hero.jpg 2x`)
963 * describes pixel density, not layout width, so it says nothing about
964 * rendered area.
965 */
966 private static function widest_srcset_width( string $srcset ): int {
967 if ( '' === $srcset ) {
968 return 0;
969 }
970 $widest = 0;
971 foreach ( explode( ',', $srcset ) as $candidate ) {
972 if ( preg_match( '#(\d+)w\s*$#', trim( $candidate ), $m ) ) {
973 $widest = max( $widest, (int) $m[1] );
974 }
975 }
976 return $widest;
977 }
978
979 /**
980 * Assemble one <link rel="preload" as="image" fetchpriority="high">.
981 *
982 * The href/srcset run through the `xspeed_lcp_preload_url` /
983 * `xspeed_lcp_preload_srcset` filters first. This is the coordination point
984 * with format-negotiating layers (Pro's Images module wraps the LCP <img> in
985 * a <picture> with a WebP/AVIF <source>, so the browser paints e.g.
986 * hero.png.webp, NOT the hero.png this preload would otherwise point at —
987 * making the high-priority preload a wasted download while the real LCP
988 * resource goes un-preloaded). By filtering the URL, a webp/avif layer can
989 * redirect the preload to the format it will actually serve, WITHOUT Free
990 * knowing that layer exists. (FBS-83553 H3)
991 */
992 private static function preload_link( string $src, string $srcset, string $sizes ): string {
993 // Resolve the `type` from the ORIGINAL image URL (before rewriting), so a
994 // negotiating layer can key off the source .jpg/.png — after rewriting,
995 // the URL is already a .webp and the derivation would no-op.
996 $original = $src;
997 /**
998 * Filter an explicit `type` for the preload link (e.g. "image/webp").
999 * Empty = omit. A typed image preload is only fetched by browsers that
1000 * accept that type, so pairing a webp href with type="image/webp" is safe
1001 * even though the markup is baked into a shared cache file.
1002 *
1003 * @param string $type Defaults to '' (no type attribute).
1004 * @param string $src The ORIGINAL (pre-rewrite) preload URL.
1005 */
1006 $type = (string) apply_filters( 'xspeed_lcp_preload_type', '', $original );
1007 /**
1008 * Filter the LCP preload href. Return a modern-format sibling (webp/avif)
1009 * when one will actually be served for this image.
1010 *
1011 * @param string $src The original image URL chosen for preload.
1012 */
1013 $src = (string) apply_filters( 'xspeed_lcp_preload_url', $src );
1014 if ( '' !== $srcset ) {
1015 /** @param string $srcset The original srcset chosen for preload. */
1016 $srcset = (string) apply_filters( 'xspeed_lcp_preload_srcset', $srcset );
1017 }
1018
1019 $attrs = sprintf( 'href="%s"', esc_url( $src ) );
1020
1021 if ( '' !== $srcset ) {
1022 // Preserve the responsive candidate set so the browser preloads
1023 // the same file it would have chosen from the <img>.
1024 $attrs .= sprintf( ' imagesrcset="%s"', esc_attr( html_entity_decode( $srcset, ENT_QUOTES ) ) );
1025 if ( '' !== $sizes ) {
1026 $attrs .= sprintf( ' imagesizes="%s"', esc_attr( html_entity_decode( $sizes, ENT_QUOTES ) ) );
1027 }
1028 }
1029
1030 if ( '' !== $type ) {
1031 $attrs .= sprintf( ' type="%s"', esc_attr( $type ) );
1032 }
1033
1034 return sprintf( '<link rel="preload" as="image" %s fetchpriority="high">' . "\n", $attrs );
1035 }
1036
1037 /**
1038 * Extract a single/double-quoted attribute value from a tag. Returns ''
1039 * when the attribute is absent.
1040 */
1041 private static function attr( string $tag, string $name ): string {
1042 // Anchor on a real attribute boundary, not `\b`. A word boundary sits
1043 // between the `-` and the `w` of `data-width`, so `\bwidth=` matched
1044 // inside it: a lazy-loaded hero carrying `data-width="50"
1045 // data-height="50"` was scored 50×50 and rejected by
1046 // looks_too_small() — defeating the feature on exactly the images the
1047 // data-src/data-srcset handling exists to support. Requiring
1048 // whitespace (or the start of the string) before the name means only
1049 // a genuine attribute matches.
1050 if ( preg_match( '#(?:^|\s)' . preg_quote( $name, '#' ) . '\s*=\s*(["\'])(.*?)\1#is', $tag, $m ) ) {
1051 return trim( $m[2] );
1052 }
1053 return '';
1054 }
1055
1056 /**
1057 * Resolve the URL/srcset/sizes the browser will actually paint for an
1058 * <img>, seeing through JS-lazy placeholders. When `src` is a data: URI (a
1059 * builder/lazy-loader placeholder), fall back to `data-src`; likewise carry
1060 * `data-srcset`/`data-sizes` when the plain ones are absent. Returns
1061 * ['', '', ''] when there's no real raster URL to preload. (FBS-83553 H1)
1062 *
1063 * @return array{0:string,1:string,2:string} [src, srcset, sizes]
1064 */
1065 private static function effective_image_src( string $tag ): array {
1066 $src = self::attr( $tag, 'src' );
1067 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
1068 $data_src = self::attr( $tag, 'data-src' );
1069 if ( '' !== $data_src && 0 !== stripos( $data_src, 'data:' ) ) {
1070 $src = $data_src;
1071 }
1072 }
1073 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
1074 return array( '', '', '' );
1075 }
1076 $srcset = self::attr( $tag, 'srcset' );
1077 if ( '' === $srcset ) {
1078 $srcset = self::attr( $tag, 'data-srcset' );
1079 }
1080 $sizes = self::attr( $tag, 'sizes' );
1081 if ( '' === $sizes ) {
1082 $sizes = self::attr( $tag, 'data-sizes' );
1083 }
1084 return array( $src, $srcset, $sizes );
1085 }
1086
1087 /**
1088 * At/below this (px) in BOTH width and height, an image is treated as a
1089 * logo/icon/avatar rather than an LCP hero. 200px clears real content heroes
1090 * (which are typically ≥ 400px wide) while catching site logos and avatars
1091 * — including the 150×150 logo the picker used to mistakenly preload.
1092 */
1093 private const MIN_LCP_DIMENSION = 200;
1094
1095 /**
1096 * Class/role/filename markers that identify site chrome (logo, icon,
1097 * avatar, spinner, emoji) which should never be treated as the LCP hero,
1098 * regardless of declared size.
1099 */
1100 private const NON_HERO_MARKERS = array( 'logo', 'icon', 'avatar', 'gravatar', 'spinner', 'emoji', 'site-icon', 'custom-logo' );
1101
1102 /**
1103 * Below this declared area (px²) an image is a badge/thumb/divider, never
1104 * an LCP hero — 10 000 is a 100×100 square, or a 500×20 strip. Applied
1105 * only when BOTH dimensions are readable. (FBS-84576)
1106 */
1107 private const MIN_LCP_AREA = 10000;
1108
1109 /**
1110 * Is this <img> too small / too chrome-like to be the LCP hero? True when
1111 * either (a) it carries a logo/icon/avatar marker, (b) an explicit
1112 * `data-no-lcp` opt-out, or (c) BOTH width and height are present and
1113 * both are ≤ the dimension threshold, or their area is under
1114 * MIN_LCP_AREA. Missing dimensions are NOT guessed — an image whose size
1115 * we can't read still competes. (FBS-83553 H1 "logo before hero".)
1116 */
1117 private static function looks_too_small( string $tag ): bool {
1118 if ( false !== stripos( $tag, 'data-no-lcp' ) ) {
1119 return true;
1120 }
1121 // Marker check against class / id / src (covers "custom-logo", a
1122 // "…/logo.png" filename, role="img" avatars, etc.).
1123 $haystack = strtolower( self::attr( $tag, 'class' ) . ' ' . self::attr( $tag, 'id' ) . ' ' . self::attr( $tag, 'src' ) );
1124 foreach ( self::NON_HERO_MARKERS as $marker ) {
1125 if ( false !== strpos( $haystack, $marker ) ) {
1126 return true;
1127 }
1128 }
1129 $w = self::attr( $tag, 'width' );
1130 $h = self::attr( $tag, 'height' );
1131 if ( '' === $w || '' === $h || ! is_numeric( $w ) || ! is_numeric( $h ) ) {
1132 return false; // unknown size — don't guess; let it compete.
1133 }
1134 if ( (int) $w * (int) $h < self::MIN_LCP_AREA ) {
1135 return true;
1136 }
1137 return (int) $w <= self::MIN_LCP_DIMENSION && (int) $h <= self::MIN_LCP_DIMENSION;
1138 }
1139
1140 /**
1141 * Promote an <img> to the LCP element: force fetchpriority="high" and
1142 * strip any loading="lazy" so the browser loads it immediately. Both are
1143 * idempotent. `loading="lazy"` is REMOVED rather than flipped to "eager"
1144 * because eager is the default; a bare tag with fetchpriority="high" is
1145 * the canonical high-priority-image form.
1146 */
1147 private static function promote_lcp_img( string $tag ): string {
1148 $tag = self::set_fetchpriority( $tag );
1149 // Drop loading="lazy" (WP core adds it by default). Leave other
1150 // loading values (e.g. an explicit eager) intact — only lazy hurts.
1151 $tag = preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag );
1152 return (string) $tag;
1153 }
1154
1155 /**
1156 * Add fetchpriority="high" to an <img> tag. Idempotent — an existing
1157 * fetchpriority value is normalised to high rather than duplicated.
1158 */
1159 private static function set_fetchpriority( string $tag ): string {
1160 if ( preg_match( '#\bfetchpriority=(["\']).*?\1#i', $tag ) ) {
1161 return (string) preg_replace( '#\bfetchpriority=(["\']).*?\1#i', 'fetchpriority="high"', $tag, 1 );
1162 }
1163 // Insert right after "<img".
1164 return (string) preg_replace( '#<img\b#i', '<img fetchpriority="high"', $tag, 1 );
1165 }
1166
1167 /**
1168 * Inject the assembled hint markup into <head>. Prefers to land right
1169 * before the first stylesheet so the preloads are discovered before the
1170 * render-blocking CSS. Falls back to after <head>, then prepend.
1171 */
1172 private static function inject_into_head( string $html, string $hints ): string {
1173 // Before the first <link rel="stylesheet"> if there is one.
1174 if ( preg_match( '#<link\b[^>]*rel=["\']stylesheet["\'][^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
1175 $pos = $m[0][1];
1176 return substr( $html, 0, $pos ) . $hints . substr( $html, $pos );
1177 }
1178 // Otherwise right after the opening <head ...>.
1179 if ( preg_match( '#<head\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
1180 $pos = $m[0][1] + strlen( $m[0][0] );
1181 return substr( $html, 0, $pos ) . "\n" . $hints . substr( $html, $pos );
1182 }
1183 // No head at all — prepend (degenerate documents).
1184 return $hints . $html;
1185 }
1186 }
1187