PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 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 All 35 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.4.1, at includes/class-resource-hints-processor.php

1,725 lines 65.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, (string) ( $opts['page_url'] ?? '' ) );
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 * @param string $page_url The page being served, for the candidate seam.
252 * @return array{0:string,1:string} [rewritten html, preload markup]
253 */
254 private static function build_lcp_preload( string $html, int $count, array $exclusions, string $page_url = '' ): array {
255 if ( $count < 1 ) {
256 return array( $html, '' );
257 }
258
259 /**
260 * Supply the LCP preload candidate for this page instead of the
261 * automatic pick.
262 *
263 * The automatic pick reads the served HTML, so it cannot see a hero set
264 * as a background in an external stylesheet, and it skips an <img> that
265 * WordPress core marked lazy. An extension that has measured the page in
266 * a browser can answer instead. Return null to leave the automatic pick
267 * in charge; return an array to replace it:
268 *
269 * - `url` (string) the image to preload; an empty string means the
270 * page has no LCP image, so nothing is preloaded;
271 * - `srcset` (string, optional) and `sizes` (string, optional), emitted
272 * as `imagesrcset` / `imagesizes`;
273 * - `kind` (string, optional) `img` or `background`, passed to the URL
274 * filters below. An image no <img> on the page carries is a
275 * background regardless;
276 * - `media` (string, optional) a media query for the preload link.
277 *
278 * Or a list of up to three such arrays, each with its own `media`, when
279 * phones and desktops paint different images: each device then preloads
280 * its own. Any `media` (also on a single answer) sets the matched <img>s
281 * to `loading="lazy"`, so a device never downloads the other one's hero.
282 *
283 * Anything else (an empty list, a URL that is not a string, a URL
284 * esc_url() refuses, a relative URL such as `hero.png`, a `media` that
285 * is not a string, over 200 characters or holding `<`, `>` or `"`) is
286 * not understood, and the automatic pick runs.
287 *
288 * The `<img>` carrying that URL, if the page has one, gets
289 * `fetchpriority="high"` and loses `loading="lazy"`. The URL filters
290 * below (`xspeed_lcp_preload_url` and friends) still apply.
291 *
292 * @param array|null $candidate Null = no opinion.
293 * @param string $page_url The page being served; empty outside a request.
294 */
295 $supplied = self::supplied_candidates( apply_filters( 'xspeed_lcp_preload_candidate', null, $page_url ) );
296 if ( null !== $supplied ) {
297 return self::build_supplied_preload( $html, $supplied );
298 }
299
300 $preload = '';
301
302 // Snapshot of already-present preload markup, for idempotency: a second
303 // pass (e.g. cache-off ob_start over an already-processed body) must not
304 // re-emit a <link> for an image we preloaded before.
305 $existing = $html;
306
307 // PASS 1 — collect every eligible <img> and score it.
308 //
309 // This used to preload the first N eligible tags in DOCUMENT ORDER.
310 // Position is not a proxy for rendered size: on real pages the first
311 // images are header chrome, breadcrumbs or badge rows, and the actual
312 // LCP element is a hero further down. Preloading the wrong image gains
313 // nothing — it just adds a high-priority request competing with the
314 // one that matters, and the feature reported success either way. The
315 // marker list and size gate were heuristics layered on top of the
316 // wrong primitive rather than replacing it. (#96)
317 $candidates = array();
318 // Markup hidden on arrival (a closed <details>, `hidden`, inline
319 // display:none) cannot paint as LCP either. Preloading the hero of a
320 // closed accordion spent the page's one High fetch on it. (#558)
321 $skip_ranges = array_merge( self::chrome_container_ranges( $html ), Lazy_Loader::hidden_ranges( $html ) );
322 // <img> indexes that can't be the LCP however they are marked: chrome,
323 // hidden, or a logo/icon. Only these give up a stray `high`. (#558)
324 $ruled_out = array();
325 if ( preg_match_all( '#<img\b[^>]*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) {
326 foreach ( $matches[0] as $index => $match ) {
327 [ $tag, $offset ] = $match;
328
329 // Skip anything the user excluded.
330 if ( self::matches_any( $tag, $exclusions ) ) {
331 continue;
332 }
333
334 // An image inside <footer>/<nav>/<aside> is site chrome by
335 // construction — a footer brand strip or FAQ illustration can
336 // never be the LCP element, whatever size it declares. On the
337 // FBS-84576 repro these decoys outranked the real hero three
338 // times on one layout.
339 if ( self::offset_in_ranges( $offset, $skip_ranges ) || Lazy_Loader::tag_is_hidden( $tag, 'img' ) ) {
340 $ruled_out[ $index ] = true;
341 continue;
342 }
343
344 // An image the theme explicitly lazy-loads is never the
345 // intended LCP — the author has already said "this can wait".
346 // Preloading it would contradict the markup and steal
347 // bandwidth from the image that matters. (FBS-84576)
348 if ( 'lazy' === strtolower( self::attr( $tag, 'loading' ) ) ) {
349 continue;
350 }
351
352 // Resolve the EFFECTIVE image URL. Page builders + JS lazy
353 // loaders park a placeholder (a data: URI or a 1px spacer) in
354 // `src` and the real URL in `data-src`, so the hero the browser
355 // actually paints is behind data-src. (FBS-83553 H1)
356 [ $src, $srcset, $sizes ] = self::effective_image_src( $tag );
357 if ( '' === $src ) {
358 continue; // no real URL (pure data-URI spacer, no data-src).
359 }
360
361 // Chrome markers / explicit opt-out / obviously-tiny images
362 // never compete. (FBS-83553 H1 "logo before hero".)
363 if ( self::looks_too_small( $tag ) ) {
364 $ruled_out[ $index ] = true;
365 continue;
366 }
367
368 $candidates[] = array(
369 'tag' => $tag,
370 'src' => $src,
371 'srcset' => $srcset,
372 'sizes' => $sizes,
373 'score' => self::weighted_score( self::lcp_score( $tag, $srcset ), $tag, $index ),
374 'order' => $index,
375 'offset' => $offset,
376 );
377 }
378 }
379
380 // PASS 1b — the same for CSS background images.
381 //
382 // On a page builder the hero is usually a background-image on the
383 // section, not an <img>, so an <img>-only candidate set never contained
384 // the element that actually paints as LCP. It preloaded whatever <img>
385 // happened to be there — measured at 0ms against the feature switched
386 // off, while spending a high-priority fetch on the critical path — or,
387 // on a page with no <img> at all, emitted nothing. (#247)
388 foreach ( self::background_candidates( $html, $exclusions, $skip_ranges ) as $bg ) {
389 $candidates[] = $bg;
390 }
391
392 // PASS 1c — <video poster="…">. A full-screen hero video paints its
393 // poster first, and that first frame IS the LCP; measured on a live
394 // page, a preloaded poster cut the LCP load delay from 1.5 s to 21 ms.
395 foreach ( self::video_poster_candidates( $html, $exclusions, $skip_ranges ) as $vp ) {
396 $candidates[] = $vp;
397 }
398
399 // PASS 1d — background rules in inline <style> blocks. Page builders
400 // put the hero's background-image in generated per-post CSS printed
401 // inline (Elementor's `.elementor-N .elementor-element-X` rules), not
402 // in a style attribute — so PASS 1b never saw the element that
403 // actually paints as LCP on five of seven measured sites. External
404 // stylesheets stay out for the same reasons as before (#247): fetching
405 // CSS from an output-buffer pass costs more than the preload saves.
406 foreach ( self::style_block_candidates( $html, $exclusions, $skip_ranges ) as $sb ) {
407 $candidates[] = $sb;
408 }
409
410 // A background video with no poster, ahead of every candidate, is the
411 // hero. Nothing in its box is preloadable, and every image after it
412 // sits lower on the page. Preloading the first of those spent the one
413 // high-priority fetch on an image below the fold, ahead of the CSS.
414 // Those images also give up a stray `high` from the lazy pass.
415 $video_at = self::background_video_offset( $html, $skip_ranges );
416 if ( null !== $video_at ) {
417 foreach ( $candidates as $i => $c ) {
418 if ( $c['offset'] > $video_at ) {
419 if ( empty( $c['background'] ) ) {
420 $ruled_out[ $c['order'] ] = true;
421 }
422 unset( $candidates[ $i ] );
423 }
424 }
425 }
426
427 if ( empty( $candidates ) ) {
428 if ( null !== $video_at ) {
429 $html = self::demote_images( $html, $ruled_out );
430 }
431 return array( $html, '' );
432 }
433
434 // Rank by score, biggest first. Document order breaks ties, so two
435 // equally-sized images (or two of unknown size) keep the previous
436 // first-wins behaviour — the change only matters when we can actually
437 // tell one is larger.
438 usort(
439 $candidates,
440 static function ( array $a, array $b ) {
441 if ( $a['score'] === $b['score'] ) {
442 return $a['order'] <=> $b['order'];
443 }
444 return $b['score'] <=> $a['score'];
445 }
446 );
447
448 $winners = array_slice( $candidates, 0, $count );
449
450 // PASS 2 — emit the preload links and promote the winning tags.
451 $chosen = array();
452 foreach ( $winners as $w ) {
453 // Idempotency: if this src is already the target of a
454 // rel="preload" as="image" link, still promote the tag but don't
455 // emit a duplicate <link>.
456 $already = (bool) preg_match(
457 '#rel=["\']preload["\'][^>]*as=["\']image["\'][^>]*' . preg_quote( $w['src'], '#' ) . '#i',
458 $existing
459 );
460 if ( ! $already ) {
461 $preload .= self::preload_link( $w['src'], $w['srcset'], $w['sizes'], empty( $w['background'] ) ? 'img' : 'background' );
462 }
463 // Only <img> winners are promoted in PASS 2 — there is no
464 // fetchpriority/loading attribute to fix on a background element,
465 // and its `order` is offset past every <img> index precisely so it
466 // can never select one for rewriting.
467 if ( empty( $w['background'] ) ) {
468 $chosen[ $w['order'] ] = true;
469 }
470 }
471
472 // Rewrite only the winning tags. Counting occurrences rather than
473 // matching on tag text, because the same markup can legitimately
474 // appear more than once on a page and only the ranked instance should
475 // be promoted.
476 //
477 // When an <img> wins, it is the page's one High image, so an image
478 // that can't be the LCP (a logo, an icon, a hidden panel, chrome)
479 // gives up any `high` it carries. That is usually the lazy pass handing
480 // its slot to the first image in the_content. An image that merely scored
481 // lower, or that the user kept out of the pick, keeps its hint: the
482 // ranking can be wrong, and core or the theme may have named the real
483 // hero. (#558)
484 $demote = ! empty( $chosen ) || null !== $video_at ? $ruled_out : array();
485 $seen = -1;
486 $html = preg_replace_callback(
487 '#<img\b[^>]*>#i',
488 static function ( array $m ) use ( &$seen, $chosen, $demote ) {
489 ++$seen;
490 if ( ! isset( $chosen[ $seen ] ) ) {
491 if ( isset( $demote[ $seen ] ) && 'high' === strtolower( self::attr( $m[0], 'fetchpriority' ) ) ) {
492 return (string) preg_replace( '#\s*(?<![-\w])fetchpriority\s*=\s*(["\']?)high\1#i', '', $m[0], 1 );
493 }
494 return $m[0];
495 }
496 // Add fetchpriority="high" AND remove any loading="lazy" the
497 // theme / WP core left on the LCP image. fetchpriority="high"
498 // with loading="lazy" is contradictory — the browser can still
499 // defer a lazy image, so preloading it while it stays lazy wins
500 // nothing. Stripping lazy is what actually lets the preload land.
501 return self::promote_lcp_img( $m[0] );
502 },
503 $html
504 );
505
506 return array( (string) $html, $preload );
507 }
508
509 /**
510 * Byte offset of the first visible background video with no poster, or
511 * null when the page has none.
512 *
513 * @param string $html Full page HTML.
514 * @param array<int,array{0:int,1:int}> $skip_ranges Chrome and hidden spans.
515 */
516 private static function background_video_offset( string $html, array $skip_ranges ): ?int {
517 $body = stripos( $html, '<body' );
518 if ( ! preg_match_all( '#<video\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, false === $body ? 0 : $body ) ) {
519 return null;
520 }
521 foreach ( $m[0] as [ $tag, $offset ] ) {
522 if ( self::offset_in_ranges( $offset, $skip_ranges ) || Lazy_Loader::tag_is_hidden( $tag, 'video' ) ) {
523 continue;
524 }
525 if ( Lazy_Loader::is_background_video_without_poster( $tag ) ) {
526 return $offset;
527 }
528 }
529 return null;
530 }
531
532 /**
533 * Strip fetchpriority="high" from the <img> tags at the given indexes.
534 *
535 * @param string $html Page HTML.
536 * @param array<int,bool> $indexes <img> indexes, in document order.
537 */
538 private static function demote_images( string $html, array $indexes ): string {
539 if ( empty( $indexes ) ) {
540 return $html;
541 }
542 $seen = -1;
543 $out = preg_replace_callback(
544 '#<img\b[^>]*>#i',
545 static function ( array $m ) use ( &$seen, $indexes ) {
546 ++$seen;
547 if ( isset( $indexes[ $seen ] ) && 'high' === strtolower( self::attr( $m[0], 'fetchpriority' ) ) ) {
548 return (string) preg_replace( '#\s*(?<![-\w])fetchpriority\s*=\s*(["\']?)high\1#i', '', $m[0], 1 );
549 }
550 return $m[0];
551 },
552 $html
553 );
554 return null === $out ? $html : $out;
555 }
556
557 /**
558 * Collect CSS `background-image` heroes as LCP candidates.
559 *
560 * Only INLINE `style` attributes are read. A background declared in an
561 * external stylesheet is invisible here by design: resolving it would mean
562 * fetching and parsing CSS from inside an output-buffer pass, and the URL a
563 * selector resolves to depends on cascade order we cannot evaluate from
564 * markup. Builders that put the hero in a generated per-post stylesheet are
565 * therefore still unserved — worth doing, but not at this cost. (#247)
566 *
567 * Scores are the element's declared pixel area so a background competes
568 * against an <img> in the SAME units — the whole point being that the
569 * bigger of the two should win regardless of which kind it is.
570 *
571 * @param string $html Full page HTML.
572 * @param string[] $exclusions Substring patterns the user excluded.
573 * @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers.
574 * @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}>
575 */
576 private static function background_candidates( string $html, array $exclusions, array $skip_ranges ): array {
577 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 ) ) {
578 return array();
579 }
580
581 $found = array();
582 foreach ( $matches[0] as $index => $match ) {
583 [ $tag, $offset ] = $match;
584
585 // The same chrome-container gate as <img>: a background painted
586 // inside <footer>/<nav>/<aside> is never the hero. (FBS-84576)
587 if ( self::offset_in_ranges( $offset, $skip_ranges ) ) {
588 continue;
589 }
590
591 $style = self::attr( $tag, 'style' );
592 if ( '' === $style || false === stripos( $style, 'background' ) ) {
593 continue;
594 }
595
596 $src = self::background_url( $style );
597 if ( '' === $src ) {
598 continue;
599 }
600
601 foreach ( $exclusions as $needle ) {
602 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
603 continue 2;
604 }
605 }
606
607 // Same chrome/opt-out gates as <img>. A logo painted as a background
608 // is no more the hero than a logo in an <img>.
609 if ( self::looks_too_small( $tag ) ) {
610 continue;
611 }
612
613 $area = self::style_area( $style );
614 if ( 0 === $area ) {
615 // Nothing readable. Deliberately non-zero for the same reason
616 // UNKNOWN_SIZE_SCORE is: an unmeasurable background must still
617 // beat nothing on a page that declares no sizes at all, while
618 // losing to anything we can actually measure.
619 $area = self::UNKNOWN_SIZE_SCORE;
620 }
621
622 $found[] = array(
623 'tag' => $tag,
624 'src' => $src,
625 'srcset' => '',
626 'sizes' => '',
627 'score' => (float) $area,
628 // Offset so a background never ties ahead of an <img> that
629 // appeared earlier in the document; ties still break on order.
630 'order' => 100000 + $index,
631 'offset' => $offset,
632 'background' => true,
633 );
634 }
635
636 return $found;
637 }
638
639 /**
640 * Collect `<video poster="…">` first frames as LCP candidates.
641 *
642 * The poster is what the viewer sees until (and unless) the video plays —
643 * on a background-video hero, delayed by the Lazy module, it is the ONLY
644 * frame the initial paint has. Scored like a background: the element's
645 * declared inline-style area, or the unknown-size floor, with the order
646 * offset past every <img> so a poster never ties ahead of one.
647 *
648 * @param string $html Full page HTML.
649 * @param string[] $exclusions Substring patterns the user excluded.
650 * @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers.
651 * @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}>
652 */
653 private static function video_poster_candidates( string $html, array $exclusions, array $skip_ranges ): array {
654 if ( ! preg_match_all( '#<video\b[^>]*\bposter\s*=\s*(["\'])(.*?)\1[^>]*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) {
655 return array();
656 }
657
658 $found = array();
659 foreach ( $matches[0] as $index => $match ) {
660 [ $tag, $offset ] = $match;
661 $src = trim( html_entity_decode( $matches[2][ $index ][0], ENT_QUOTES ) );
662 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
663 continue;
664 }
665 if ( self::offset_in_ranges( $offset, $skip_ranges ) ) {
666 continue;
667 }
668 foreach ( $exclusions as $needle ) {
669 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
670 continue 2;
671 }
672 }
673 if ( self::looks_too_small( $tag ) ) {
674 continue;
675 }
676
677 $area = self::style_area( self::attr( $tag, 'style' ) );
678 if ( 0 === $area ) {
679 $area = self::UNKNOWN_SIZE_SCORE;
680 }
681
682 $found[] = array(
683 'tag' => $tag,
684 'src' => $src,
685 'srcset' => '',
686 'sizes' => '',
687 'score' => (float) $area,
688 'order' => 100000 + $index,
689 'offset' => $offset,
690 'background' => true,
691 );
692 }
693
694 return $found;
695 }
696
697 /**
698 * How many <style>-block background rules are considered per page. The
699 * scan is linear, but each rule costs one class-lookup pass over the
700 * body, so a pathological page (thousands of generated rules) is capped
701 * rather than trusted.
702 */
703 private const STYLE_RULE_BUDGET = 40;
704
705 /**
706 * Collect background-image rules from inline <style> blocks whose
707 * selector matches an element in the body.
708 *
709 * The match is deliberately narrow: the rule's RIGHTMOST simple selector
710 * must carry a class or id, and the first element in the body bearing it
711 * (outside chrome containers) is taken as the painted element. Rules
712 * inside @media (or any other at-rule block) are skipped — a desktop-only
713 * background preloaded on mobile is a wasted high-priority fetch, and the
714 * markup gives no viewport to resolve the query against.
715 *
716 * @param string $html Full page HTML.
717 * @param string[] $exclusions Substring patterns the user excluded.
718 * @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers.
719 * @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}>
720 */
721 private static function style_block_candidates( string $html, array $exclusions, array $skip_ranges ): array {
722 if ( ! preg_match_all( '#<style\b[^>]*>(.*?)</style\s*>#is', $html, $blocks ) ) {
723 return array();
724 }
725
726 $found = array();
727 $budget = self::STYLE_RULE_BUDGET;
728 foreach ( $blocks[1] as $css ) {
729 if ( $budget <= 0 ) {
730 break;
731 }
732 $css = self::strip_at_rule_blocks( $css );
733 if ( false === stripos( $css, 'url(' ) ) {
734 continue;
735 }
736 // One flat rule at a time: selector list up to '{', body to '}'.
737 if ( ! preg_match_all( '#(?:^|})\s*([^{}]{1,512})\{([^{}]*)\}#s', $css, $rules, PREG_SET_ORDER ) ) {
738 continue;
739 }
740 foreach ( $rules as $rule ) {
741 if ( $budget <= 0 ) {
742 break 2;
743 }
744 if ( false === stripos( $rule[2], 'url(' ) ) {
745 continue;
746 }
747 $src = self::background_url( $rule[2] );
748 if ( '' === $src ) {
749 continue;
750 }
751 --$budget;
752 // First selector of the list, rightmost compound of it.
753 $selector = trim( (string) strtok( $rule[1], ',' ) );
754 $parts = preg_split( '#[\s>+~]+#', $selector );
755 $last = (string) end( $parts );
756 // The last class or id token of that compound. Pseudo-classes
757 // (:hover, ::before) mean the background is not the initial
758 // paint, so they disqualify the rule.
759 if ( false !== strpos( $last, ':' ) ) {
760 continue;
761 }
762 if ( ! preg_match( '#([.\#])([-\w]+)$#', $last, $tok ) ) {
763 continue;
764 }
765 $el = '.' === $tok[1]
766 ? self::first_element_with_class( $html, $tok[2], $skip_ranges )
767 : self::first_element_with_id( $html, $tok[2], $skip_ranges );
768 if ( null === $el ) {
769 continue;
770 }
771 [ $tag, $offset ] = $el;
772 foreach ( $exclusions as $needle ) {
773 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
774 continue 2;
775 }
776 }
777 if ( self::looks_too_small( $tag ) ) {
778 continue;
779 }
780 $area = self::style_area( self::attr( $tag, 'style' ) );
781 if ( 0 === $area ) {
782 $area = self::UNKNOWN_SIZE_SCORE;
783 }
784 $found[] = array(
785 'tag' => $tag,
786 'src' => $src,
787 'srcset' => '',
788 'sizes' => '',
789 'score' => (float) $area,
790 // Offset past the inline-style backgrounds: a rule-matched
791 // background is one inference step less certain, so it must
792 // never tie ahead of one read straight off the element.
793 'order' => 200000 + $offset,
794 'offset' => $offset,
795 'background' => true,
796 );
797 }
798 }
799
800 return $found;
801 }
802
803 /**
804 * CSS with every at-rule BLOCK (@media, @supports, @container, …) removed,
805 * by brace depth — a regex cannot pair nested braces. Flat at-rules
806 * (@import, @charset) have no block and pass through harmlessly.
807 */
808 private static function strip_at_rule_blocks( string $css ): string {
809 $out = '';
810 $len = strlen( $css );
811 $i = 0;
812 while ( $i < $len ) {
813 $at = strpos( $css, '@', $i );
814 if ( false === $at ) {
815 return $out . substr( $css, $i );
816 }
817 $brace = strpos( $css, '{', $at );
818 $semi = strpos( $css, ';', $at );
819 $out .= substr( $css, $i, $at - $i );
820 if ( false === $brace || ( false !== $semi && $semi < $brace ) ) {
821 // Flat at-rule — skip to its semicolon (or end).
822 $i = false === $semi ? $len : $semi + 1;
823 continue;
824 }
825 // Block at-rule — skip to its matching close brace.
826 $depth = 1;
827 $i = $brace + 1;
828 while ( $i < $len && $depth > 0 ) {
829 $c = $css[ $i ];
830 if ( '{' === $c ) {
831 ++$depth;
832 } elseif ( '}' === $c ) {
833 --$depth;
834 }
835 ++$i;
836 }
837 }
838 return $out;
839 }
840
841 /**
842 * The first element in the BODY carrying $class (outside chrome ranges),
843 * as [tag, offset], or null. Body-only, so a head <meta> can never match
844 * and a hit's offset is comparable with the chrome ranges.
845 *
846 * @return array{0:string,1:int}|null
847 */
848 private static function first_element_with_class( string $html, string $class, array $skip_ranges ): ?array {
849 $body = stripos( $html, '<body' );
850 $from = false === $body ? 0 : $body;
851 if ( ! preg_match_all( '#<[a-z][^>]*\bclass\s*=\s*(["\'])[^"\']*(?<![-\w])' . preg_quote( $class, '#' ) . '(?![-\w])[^"\']*\1[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, $from ) ) {
852 return null;
853 }
854 foreach ( $m[0] as $match ) {
855 if ( ! self::offset_in_ranges( $match[1], $skip_ranges ) ) {
856 return array( $match[0], $match[1] );
857 }
858 }
859 return null;
860 }
861
862 /**
863 * The first element carrying id="$id" (outside chrome ranges), as
864 * [tag, offset], or null.
865 *
866 * @return array{0:string,1:int}|null
867 */
868 private static function first_element_with_id( string $html, string $id, array $skip_ranges ): ?array {
869 $body = stripos( $html, '<body' );
870 $from = false === $body ? 0 : $body;
871 if ( ! preg_match( '#<[a-z][^>]*\bid\s*=\s*(["\'])' . preg_quote( $id, '#' ) . '\1[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, $from ) ) {
872 return null;
873 }
874 if ( self::offset_in_ranges( $m[0][1], $skip_ranges ) ) {
875 return null;
876 }
877 return array( $m[0][0], $m[0][1] );
878 }
879
880 /**
881 * Pull a real image URL out of a `background`/`background-image` declaration.
882 *
883 * Returns '' for anything with nothing to fetch: a gradient (which is a
884 * background-image but not a resource), a data: URI, or `none`.
885 */
886 private static function background_url( string $style ): string {
887 // Decode BEFORE parsing. Builders emit the url() quotes HTML-encoded
888 // inside a style attribute (url(&quot;/hero.jpg&quot;)), and `&quot;`
889 // carries a semicolon — so splitting the declaration on `;` first
890 // truncated the value to `url(&quot` and found no URL at all.
891 $style = html_entity_decode( $style, ENT_QUOTES );
892
893 if ( ! preg_match( '#background(?:-image)?\s*:\s*((?:[^;\'"]|"[^"]*"|\'[^\']*\')+)#i', $style, $decl ) ) {
894 return '';
895 }
896 if ( ! preg_match( '#url\(\s*(["\']?)(.*?)\1\s*\)#is', $decl[1], $m ) ) {
897 return '';
898 }
899 $url = trim( $m[2] );
900 if ( '' === $url || 0 === stripos( $url, 'data:' ) ) {
901 return '';
902 }
903 return $url;
904 }
905
906 /**
907 * Declared pixel area from an inline style, or 0 when it can't be read.
908 *
909 * Only px is honoured. A percentage or viewport unit resolves against a
910 * containing block we cannot see from markup, and guessing one produced the
911 * wrong winner more often than declining to.
912 */
913 private static function style_area( string $style ): int {
914 $w = self::style_px( $style, 'width' );
915 $h = self::style_px( $style, 'height' );
916 if ( $w > 0 && $h > 0 ) {
917 return $w * $h;
918 }
919 if ( $w > 0 ) {
920 return (int) round( $w * $w * self::ASSUMED_ASPECT_RATIO );
921 }
922 return 0;
923 }
924
925 /** One px-valued CSS length from an inline style, or 0. */
926 private static function style_px( string $style, string $prop ): int {
927 if ( preg_match( '#(?:^|;)\s*' . preg_quote( $prop, '#' ) . '\s*:\s*(\d+(?:\.\d+)?)px#i', $style, $m ) ) {
928 return (int) round( (float) $m[1] );
929 }
930 return 0;
931 }
932
933 /**
934 * How likely is this <img> to be the LCP element? Higher wins.
935 *
936 * Rendered area is the best available proxy, and we can only read what the
937 * markup declares:
938 *
939 * 1. `width` × `height` attributes — the real area, when present.
940 * 2. The largest `srcset` / `data-srcset` candidate width, squared into a
941 * pseudo-area. A responsive hero usually omits width/height but ships
942 * a 1600w+ candidate, which says more about its size than its
943 * position ever did.
944 * 3. Nothing readable → a neutral score, so the image still competes
945 * (matching looks_too_small()'s "don't guess" rule) but loses to any
946 * image we CAN measure as larger.
947 *
948 * @param string $tag The full <img> tag.
949 * @param string $srcset Resolved srcset (may come from data-srcset).
950 */
951 private static function lcp_score( string $tag, string $srcset ): float {
952 $w = self::attr( $tag, 'width' );
953 $h = self::attr( $tag, 'height' );
954 if ( '' !== $w && '' !== $h && is_numeric( $w ) && is_numeric( $h ) ) {
955 return (float) ( (int) $w * (int) $h );
956 }
957
958 $widest = self::widest_srcset_width( $srcset );
959 if ( $widest > 0 ) {
960 // Estimate an AREA, not a square. Squaring the width compared a
961 // pseudo-area against a real one and overstated the width-only
962 // candidate by roughly the inverse of its aspect ratio, so a
963 // 1024w sidebar thumbnail (1 048 576) beat a declared 1200×600
964 // hero (720 000) — a regression on exactly the mixed pages that
965 // document order used to get right, since the hero usually comes
966 // first. Assuming a 16:9 box keeps both sides in the same units.
967 return round( $widest * $widest * self::ASSUMED_ASPECT_RATIO );
968 }
969
970 return (float) self::UNKNOWN_SIZE_SCORE;
971 }
972
973 /**
974 * Fold the author's own priority signals and document position into an
975 * area score. (FBS-84576)
976 *
977 * Boosts are ADDITIVE, in area units, so they can rescue an image whose
978 * size the markup doesn't declare: a hero with no width/height and no
979 * `w`-descriptor srcset scores UNKNOWN_SIZE_SCORE, and multiplying that
980 * by any factor still loses to a 548×136 logo that declares itself. This
981 * is exactly the live miss — the real hero carried loading="eager"
982 * fetchpriority="high" and lost to three dimension-declaring decoys.
983 *
984 * - fetchpriority="high" is the strongest signal there is: the author
985 * (or WP core's own LCP detection) has already named this image the
986 * hero. Worth a hero-sized area.
987 * - An EXPLICIT loading="eager" is a weaker but deliberate "load me
988 * now" (the default is eager, so writing it out is a choice).
989 *
990 * Position is a light multiplicative weight — earlier is better, but the
991 * spread is capped well under 5× so it can only break near-ties, never
992 * outrank a genuinely larger image further down (the logo-vs-hero case).
993 *
994 * @param float $score Base area score from lcp_score() / style_area().
995 * @param string $tag The candidate's full tag (for the signal attrs).
996 * @param int $order Document-order index of the candidate.
997 */
998 private static function weighted_score( float $score, string $tag, int $order ): float {
999 if ( 'high' === strtolower( self::attr( $tag, 'fetchpriority' ) ) ) {
1000 $score += self::FETCHPRIORITY_HIGH_BOOST;
1001 }
1002 if ( 'eager' === strtolower( self::attr( $tag, 'loading' ) ) ) {
1003 $score += self::EAGER_BOOST;
1004 }
1005 return $score * self::position_weight( $order );
1006 }
1007
1008 /**
1009 * Document-position weight: 1.25 for the first image, easing to 1.0 by
1010 * the tenth. The whole spread is 25%, far under the 5× area difference it
1011 * must never override — it exists only to keep the old first-wins
1012 * behaviour for images we can't tell apart.
1013 */
1014 private static function position_weight( int $order ): float {
1015 return 1.0 + 0.25 * max( 0.0, 1.0 - $order / 10 );
1016 }
1017
1018 /**
1019 * Area-unit boost for fetchpriority="high" — roughly a 940×530 hero, so
1020 * an explicitly-marked image outranks any mid-page decoy even when its
1021 * own size is unreadable, while a genuinely huge unmarked image can still
1022 * beat a marked small one.
1023 */
1024 private const FETCHPRIORITY_HIGH_BOOST = 500000.0;
1025
1026 /**
1027 * Area-unit boost for an explicit loading="eager" — roughly 420×240,
1028 * enough to break ties in favour of the author's intent without letting
1029 * an eager logo outrank a plain hero.
1030 */
1031 private const EAGER_BOOST = 100000.0;
1032
1033 /**
1034 * Byte ranges of <footer>/<nav>/<aside> regions. Nesting-aware per tag
1035 * name (a nav inside a nav extends the range); an unclosed open tag
1036 * poisons through to the end of the document, which errs on the side of
1037 * not preloading — the safe direction, since a wrong preload is worse
1038 * than none. (FBS-84576)
1039 *
1040 * @return array<int,array{0:int,1:int}> [start, end] byte offsets.
1041 */
1042 private static function chrome_container_ranges( string $html ): array {
1043 $ranges = array();
1044 foreach ( array( 'footer', 'nav', 'aside' ) as $name ) {
1045 // (?=[\s/>]) rather than \b: a word boundary sits before the `-`
1046 // of a custom element, so `<nav\b` would swallow `<nav-menu>`.
1047 if ( ! preg_match_all( '#<(/?)' . $name . '(?=[\s/>])[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
1048 continue;
1049 }
1050 $depth = 0;
1051 $start = 0;
1052 foreach ( $m[0] as $i => $match ) {
1053 $closing = '' !== $m[1][ $i ][0];
1054 if ( ! $closing ) {
1055 if ( 0 === $depth ) {
1056 $start = $match[1];
1057 }
1058 ++$depth;
1059 } elseif ( $depth > 0 ) {
1060 --$depth;
1061 if ( 0 === $depth ) {
1062 $ranges[] = array( $start, $match[1] );
1063 }
1064 }
1065 }
1066 if ( $depth > 0 ) {
1067 $ranges[] = array( $start, strlen( $html ) );
1068 }
1069 }
1070 return $ranges;
1071 }
1072
1073 /**
1074 * Does a tag contain any of the given substring patterns?
1075 *
1076 * @param string[] $patterns
1077 */
1078 private static function matches_any( string $tag, array $patterns ): bool {
1079 foreach ( $patterns as $needle ) {
1080 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
1081 return true;
1082 }
1083 }
1084 return false;
1085 }
1086
1087 /** Does a byte offset fall inside any of the given [start, end] ranges? */
1088 private static function offset_in_ranges( int $offset, array $ranges ): bool {
1089 foreach ( $ranges as $range ) {
1090 // >= on the start: a closed <details> span from
1091 // Lazy_Loader::hidden_ranges() opens right at the first image
1092 // after its <summary>.
1093 if ( $offset >= $range[0] && $offset < $range[1] ) {
1094 return true;
1095 }
1096 }
1097 return false;
1098 }
1099
1100 /**
1101 * Score for an image whose size we can't read at all.
1102 *
1103 * Deliberately non-zero: an unmeasurable image must still beat nothing and
1104 * still be preloadable on a page where no image declares its size. But it
1105 * sits below a 200×200 declared area (40 000), so anything we CAN measure
1106 * as a plausible hero outranks a guess.
1107 */
1108 private const UNKNOWN_SIZE_SCORE = 1;
1109
1110 /**
1111 * Height-to-width ratio assumed when only a `w` descriptor is readable.
1112 *
1113 * 9/16 — the commonest hero/banner shape, and close enough that a
1114 * width-only candidate is compared against a declared w×h area on the
1115 * same scale rather than being systematically inflated.
1116 */
1117 private const ASSUMED_ASPECT_RATIO = 9 / 16;
1118
1119 /**
1120 * Largest `w` descriptor in a srcset, or 0 when there isn't one.
1121 *
1122 * Only `w` descriptors are read. An `x` descriptor (`hero.jpg 2x`)
1123 * describes pixel density, not layout width, so it says nothing about
1124 * rendered area.
1125 */
1126 private static function widest_srcset_width( string $srcset ): int {
1127 if ( '' === $srcset ) {
1128 return 0;
1129 }
1130 $widest = 0;
1131 foreach ( explode( ',', $srcset ) as $candidate ) {
1132 if ( preg_match( '#(\d+)w\s*$#', trim( $candidate ), $m ) ) {
1133 $widest = max( $widest, (int) $m[1] );
1134 }
1135 }
1136 return $widest;
1137 }
1138
1139 /**
1140 * Assemble one <link rel="preload" as="image" fetchpriority="high">.
1141 *
1142 * The href/srcset run through the `xspeed_lcp_preload_url` /
1143 * `xspeed_lcp_preload_srcset` filters first. This is the coordination point
1144 * with format-negotiating layers (Pro's Images module wraps the LCP <img> in
1145 * a <picture> with a WebP/AVIF <source>, so the browser paints e.g.
1146 * hero.png.webp, NOT the hero.png this preload would otherwise point at —
1147 * making the high-priority preload a wasted download while the real LCP
1148 * resource goes un-preloaded). By filtering the URL, a webp/avif layer can
1149 * redirect the preload to the format it will actually serve, WITHOUT Free
1150 * knowing that layer exists. (FBS-83553 H3)
1151 */
1152 private static function preload_link( string $src, string $srcset, string $sizes, string $kind = 'img', string $media = '' ): string {
1153 // Resolve the `type` from the ORIGINAL image URL (before rewriting), so a
1154 // negotiating layer can key off the source .jpg/.png — after rewriting,
1155 // the URL is already a .webp and the derivation would no-op.
1156 $original = $src;
1157 /**
1158 * Filter an explicit `type` for the preload link (e.g. "image/webp").
1159 * Empty = omit. A typed image preload is only fetched by browsers that
1160 * accept that type, so pairing a webp href with type="image/webp" is safe
1161 * even though the markup is baked into a shared cache file.
1162 *
1163 * @param string $type Defaults to '' (no type attribute).
1164 * @param string $src The ORIGINAL (pre-rewrite) preload URL.
1165 * @param string $kind `img` for an <img>, `background` for a CSS
1166 * background or a video poster. Only an <img> can be
1167 * wrapped in <picture>; a background is requested by
1168 * the URL its stylesheet names.
1169 */
1170 $type = (string) apply_filters( 'xspeed_lcp_preload_type', '', $original, $kind );
1171 /**
1172 * Filter the LCP preload href. Return a modern-format sibling (webp/avif)
1173 * when one will actually be served for this image.
1174 *
1175 * @param string $src The original image URL chosen for preload.
1176 * @param string $kind `img` or `background`, as for xspeed_lcp_preload_type.
1177 */
1178 $src = (string) apply_filters( 'xspeed_lcp_preload_url', $src, $kind );
1179 if ( '' !== $srcset ) {
1180 /**
1181 * @param string $srcset The original srcset chosen for preload.
1182 * @param string $kind `img` or `background`.
1183 */
1184 $srcset = (string) apply_filters( 'xspeed_lcp_preload_srcset', $srcset, $kind );
1185 }
1186
1187 $attrs = sprintf( 'href="%s"', esc_url( $src ) );
1188
1189 if ( '' !== $srcset ) {
1190 // Preserve the responsive candidate set so the browser preloads
1191 // the same file it would have chosen from the <img>.
1192 $attrs .= sprintf( ' imagesrcset="%s"', esc_attr( html_entity_decode( $srcset, ENT_QUOTES ) ) );
1193 if ( '' !== $sizes ) {
1194 $attrs .= sprintf( ' imagesizes="%s"', esc_attr( html_entity_decode( $sizes, ENT_QUOTES ) ) );
1195 }
1196 }
1197
1198 if ( '' !== $type ) {
1199 $attrs .= sprintf( ' type="%s"', esc_attr( $type ) );
1200 }
1201
1202 if ( '' !== $media ) {
1203 $attrs .= sprintf( ' media="%s"', esc_attr( $media ) );
1204 }
1205
1206 return sprintf( '<link rel="preload" as="image" %s fetchpriority="high">' . "\n", $attrs );
1207 }
1208
1209 /**
1210 * Extract a single/double-quoted attribute value from a tag. Returns ''
1211 * when the attribute is absent.
1212 */
1213 private static function attr( string $tag, string $name ): string {
1214 // Anchor on a real attribute boundary, not `\b`. A word boundary sits
1215 // between the `-` and the `w` of `data-width`, so `\bwidth=` matched
1216 // inside it: a lazy-loaded hero carrying `data-width="50"
1217 // data-height="50"` was scored 50×50 and rejected by
1218 // looks_too_small() — defeating the feature on exactly the images the
1219 // data-src/data-srcset handling exists to support. Requiring
1220 // whitespace (or the start of the string) before the name means only
1221 // a genuine attribute matches.
1222 if ( preg_match( '#(?:^|\s)' . preg_quote( $name, '#' ) . '\s*=\s*(["\'])(.*?)\1#is', $tag, $m ) ) {
1223 return trim( $m[2] );
1224 }
1225 return '';
1226 }
1227
1228 /**
1229 * Resolve the URL/srcset/sizes the browser will actually paint for an
1230 * <img>, seeing through JS-lazy placeholders. When `src` is a data: URI (a
1231 * builder/lazy-loader placeholder), fall back to `data-src`; likewise carry
1232 * `data-srcset`/`data-sizes` when the plain ones are absent. Returns
1233 * ['', '', ''] when there's no real raster URL to preload. (FBS-83553 H1)
1234 *
1235 * @return array{0:string,1:string,2:string} [src, srcset, sizes]
1236 */
1237 private static function effective_image_src( string $tag ): array {
1238 $src = self::attr( $tag, 'src' );
1239 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
1240 $data_src = self::attr( $tag, 'data-src' );
1241 if ( '' !== $data_src && 0 !== stripos( $data_src, 'data:' ) ) {
1242 $src = $data_src;
1243 }
1244 }
1245 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
1246 return array( '', '', '' );
1247 }
1248 $srcset = self::attr( $tag, 'srcset' );
1249 if ( '' === $srcset ) {
1250 $srcset = self::attr( $tag, 'data-srcset' );
1251 }
1252 $sizes = self::attr( $tag, 'sizes' );
1253 if ( '' === $sizes ) {
1254 $sizes = self::attr( $tag, 'data-sizes' );
1255 }
1256 return array( $src, $srcset, $sizes );
1257 }
1258
1259 /**
1260 * At/below this (px) in BOTH width and height, an image is treated as a
1261 * logo/icon/avatar rather than an LCP hero. 200px clears real content heroes
1262 * (which are typically ≥ 400px wide) while catching site logos and avatars
1263 * — including the 150×150 logo the picker used to mistakenly preload.
1264 */
1265 private const MIN_LCP_DIMENSION = 200;
1266
1267 /**
1268 * Class/role/filename markers that identify site chrome (logo, icon,
1269 * avatar, spinner, emoji) which should never be treated as the LCP hero,
1270 * regardless of declared size.
1271 */
1272 private const NON_HERO_MARKERS = array( 'logo', 'icon', 'avatar', 'gravatar', 'spinner', 'emoji', 'site-icon', 'custom-logo' );
1273
1274 /**
1275 * Below this declared area (px²) an image is a badge/thumb/divider, never
1276 * an LCP hero — 10 000 is a 100×100 square, or a 500×20 strip. Applied
1277 * only when BOTH dimensions are readable. (FBS-84576)
1278 */
1279 private const MIN_LCP_AREA = 10000;
1280
1281 /**
1282 * Is this <img> too small / too chrome-like to be the LCP hero? True when
1283 * either (a) it carries a logo/icon/avatar marker, (b) an explicit
1284 * `data-no-lcp` opt-out, or (c) BOTH width and height are present and
1285 * both are ≤ the dimension threshold, or their area is under
1286 * MIN_LCP_AREA. Missing dimensions are NOT guessed — an image whose size
1287 * we can't read still competes. (FBS-83553 H1 "logo before hero".)
1288 */
1289 private static function looks_too_small( string $tag ): bool {
1290 if ( false !== stripos( $tag, 'data-no-lcp' ) ) {
1291 return true;
1292 }
1293 // Marker check against class / id / src (covers "custom-logo", a
1294 // "…/logo.png" filename, role="img" avatars, etc.).
1295 $haystack = strtolower( self::attr( $tag, 'class' ) . ' ' . self::attr( $tag, 'id' ) . ' ' . self::attr( $tag, 'src' ) );
1296 foreach ( self::NON_HERO_MARKERS as $marker ) {
1297 if ( false !== strpos( $haystack, $marker ) ) {
1298 return true;
1299 }
1300 }
1301 $w = self::attr( $tag, 'width' );
1302 $h = self::attr( $tag, 'height' );
1303 if ( '' === $w || '' === $h || ! is_numeric( $w ) || ! is_numeric( $h ) ) {
1304 return false; // unknown size — don't guess; let it compete.
1305 }
1306 if ( (int) $w * (int) $h < self::MIN_LCP_AREA ) {
1307 return true;
1308 }
1309 return (int) $w <= self::MIN_LCP_DIMENSION && (int) $h <= self::MIN_LCP_DIMENSION;
1310 }
1311
1312 /** Most candidates one answer may carry (one per device class). */
1313 private const MAX_SUPPLIED_CANDIDATES = 3;
1314
1315 /**
1316 * The filter's answer as a list of well-formed candidates, or null when
1317 * it is no answer or not one this understands (then the automatic pick
1318 * runs). An empty list means "this page has no LCP image".
1319 *
1320 * @param mixed $answer What `xspeed_lcp_preload_candidate` returned.
1321 * @return array<int,array{url:string,srcset:string,sizes:string,kind:string,media:string}>|null
1322 */
1323 private static function supplied_candidates( $answer ): ?array {
1324 if ( ! is_array( $answer ) || empty( $answer ) ) {
1325 return null;
1326 }
1327 $items = array_key_exists( 'url', $answer ) ? array( $answer ) : $answer;
1328 if ( array_values( $items ) !== $items || count( $items ) > self::MAX_SUPPLIED_CANDIDATES ) {
1329 return null;
1330 }
1331 $out = array();
1332 foreach ( $items as $item ) {
1333 if ( ! is_array( $item ) || ! array_key_exists( 'url', $item ) || ! is_string( $item['url'] ) ) {
1334 return null;
1335 }
1336 $url = trim( $item['url'] );
1337 if ( '' === $url ) {
1338 // "No LCP image" only makes sense as the whole answer.
1339 if ( 1 === count( $items ) ) {
1340 return array();
1341 }
1342 return null;
1343 }
1344 // Absolute, protocol-relative or root-relative. A bare `hero.png`
1345 // passed esc_url() as `http://hero.png`: a high-priority fetch to
1346 // a host that does not exist.
1347 if ( '' === esc_url( $url ) || ! preg_match( '#^(?:https?:)?/#i', $url ) ) {
1348 return null;
1349 }
1350 // A media rule this cannot use rejects the answer. Dropping only
1351 // the rule kept the preload and sent it to every device.
1352 $media = $item['media'] ?? '';
1353 if ( ! is_string( $media ) || strlen( $media ) > 200 || preg_match( '#[<>"]#', $media ) ) {
1354 return null;
1355 }
1356 $text = static fn ( $v, int $max ): string => ( is_string( $v ) && strlen( $v ) <= $max ) ? trim( $v ) : '';
1357 $out[] = array(
1358 'url' => $url,
1359 'srcset' => $text( $item['srcset'] ?? '', 4096 ),
1360 'sizes' => $text( $item['sizes'] ?? '', 512 ),
1361 'kind' => 'background' === ( $item['kind'] ?? '' ) ? 'background' : 'img',
1362 'media' => trim( $media ),
1363 );
1364 }
1365 return $out;
1366 }
1367
1368 /**
1369 * Preload what an extension supplied, and promote the <img> it names.
1370 *
1371 * The answer is measured, not guessed, so every other <img> gives up a
1372 * stray `fetchpriority="high"`: two High images compete with the one
1373 * that paints first.
1374 *
1375 * @param string $html Page HTML.
1376 * @param array<int,array{url:string,srcset:string,sizes:string,kind:string,media:string}> $candidates From supplied_candidates().
1377 * @return array{0:string,1:string} [rewritten html, preload markup]
1378 */
1379 private static function build_supplied_preload( string $html, array $candidates ): array {
1380 // Scoped when any preload carries a media rule, even a single one:
1381 // Pro sends one desktop-only candidate when the phone's LCP is text,
1382 // and promoting that <img> made phones download a hero they hide.
1383 $scoped = '' !== implode( '', array_column( $candidates, 'media' ) );
1384 $skip = array_merge( self::chrome_container_ranges( $html ), Lazy_Loader::hidden_ranges( $html ) );
1385
1386 // Which <img> index each candidate promotes: the first one, outside
1387 // site chrome and hidden markup, whose src or one of whose srcset
1388 // URLs IS the candidate. A substring match picked an earlier image
1389 // whose srcset merely contained the name.
1390 // Two candidates may claim the same <img>: a phone and a desktop size
1391 // of one responsive image. The second one is still an <img>, not a
1392 // background.
1393 $promote = array();
1394 $matched = array();
1395 if ( preg_match_all( '#<img\b[^>]*>#i', $html, $imgs, PREG_OFFSET_CAPTURE ) ) {
1396 foreach ( $candidates as $c => $candidate ) {
1397 foreach ( $imgs[0] as $index => $match ) {
1398 [ $tag, $offset ] = $match;
1399 if ( self::offset_in_ranges( $offset, $skip ) || Lazy_Loader::tag_is_hidden( $tag, 'img' ) ) {
1400 continue;
1401 }
1402 [ $src, $img_srcset, $img_sizes ] = self::effective_image_src( $tag );
1403 if ( ! self::same_url( $src, $candidate['url'] ) && ! self::srcset_has( $img_srcset, $candidate['url'] ) ) {
1404 continue;
1405 }
1406 $promote[ $index ] = true;
1407 $matched[ $c ] = true;
1408 // The <img> may already be served as a format sibling the
1409 // answer did not name (`hero.png.webp` for `hero.png`: an
1410 // image plugin swapped it for this browser). Preload what
1411 // the <img> will fetch, or the page downloads both.
1412 if ( self::format_original( $src ) !== $src ) {
1413 $candidates[ $c ]['url'] = html_entity_decode( $src, ENT_QUOTES );
1414 $candidates[ $c ]['srcset'] = html_entity_decode( $img_srcset, ENT_QUOTES );
1415 $candidates[ $c ]['sizes'] = $img_sizes;
1416 }
1417 // An answer that names only the URL still gets the <img>'s
1418 // responsive set: without it the preload fetches the full
1419 // file and the browser then downloads the size it shows.
1420 if ( '' === $candidates[ $c ]['srcset'] && '' !== $img_srcset ) {
1421 $candidates[ $c ]['srcset'] = $img_srcset;
1422 $candidates[ $c ]['sizes'] = $img_sizes;
1423 }
1424 break;
1425 }
1426 }
1427 }
1428
1429 $seen = -1;
1430 $html = (string) preg_replace_callback(
1431 '#<img\b[^>]*>#i',
1432 static function ( array $m ) use ( &$seen, $promote, $scoped ) {
1433 ++$seen;
1434 if ( isset( $promote[ $seen ] ) ) {
1435 // Scoped to a device: make the <img> lazy, so the device
1436 // that hides it never downloads it. Keeping whatever
1437 // `loading` it had was not enough: Lazy Load's default
1438 // "load the first image straight away" had already made
1439 // it eager. The device that shows it gets it from its own
1440 // preload, which already fetched it early.
1441 return $scoped ? self::force_lazy( self::set_fetchpriority( $m[0] ) ) : self::promote_lcp_img( $m[0] );
1442 }
1443 if ( 'high' === strtolower( self::attr( $m[0], 'fetchpriority' ) ) ) {
1444 return (string) preg_replace( '#\s*(?<![-\w])fetchpriority\s*=\s*(["\']?)high\1#i', '', $m[0], 1 );
1445 }
1446 return $m[0];
1447 },
1448 $html
1449 );
1450
1451 $preload = '';
1452 foreach ( $candidates as $c => $candidate ) {
1453 // Idempotency, as for the automatic pick: a second pass over an
1454 // already-processed body must not emit the link twice. Compared
1455 // with `&amp;` decoded, the way the markup spells a query.
1456 $already = false;
1457 if ( preg_match_all( '#<link\b[^>]*rel=["\']preload["\'][^>]*>#i', $html, $links ) ) {
1458 foreach ( $links[0] as $link ) {
1459 if ( false !== strpos( html_entity_decode( $link, ENT_QUOTES ), $candidate['url'] ) ) {
1460 $already = true;
1461 break;
1462 }
1463 }
1464 }
1465 if ( $already ) {
1466 continue;
1467 }
1468 // An image the page shows through no <img> is a background,
1469 // whatever the answer said: nothing here can wrap it in <picture>.
1470 $kind = isset( $matched[ $c ] ) ? $candidate['kind'] : 'background';
1471 $preload .= self::preload_link( $candidate['url'], $candidate['srcset'], $candidate['sizes'], $kind, $candidate['media'] );
1472 }
1473 return array( $html, $preload );
1474 }
1475
1476 /** Set `loading="lazy"` on an <img>, replacing any other loading value. */
1477 private static function force_lazy( string $tag ): string {
1478 $tag = (string) preg_replace( '#\s*(?<![-\w])loading\s*=\s*(["\']?)[a-z]*\1#i', '', $tag, 1 );
1479 return (string) preg_replace( '#<img\b#i', '<img loading="lazy"', $tag, 1 );
1480 }
1481
1482 /** Two URLs name the same image: `&amp;` decoded, and a root-relative one compared by path. */
1483 private static function same_url( string $a, string $b ): bool {
1484 $a = html_entity_decode( $a, ENT_QUOTES );
1485 $b = html_entity_decode( $b, ENT_QUOTES );
1486 if ( '' === $a || '' === $b ) {
1487 return false;
1488 }
1489 if ( $a === $b ) {
1490 return true;
1491 }
1492 // A format sibling is the same image: `hero.png.webp` is `hero.png`.
1493 $a = self::format_original( $a );
1494 $b = self::format_original( $b );
1495 if ( $a === $b ) {
1496 return true;
1497 }
1498 $rel = static function ( string $u ): string {
1499 if ( 0 === strpos( $u, '/' ) && 0 !== strpos( $u, '//' ) ) {
1500 return $u;
1501 }
1502 $path = (string) wp_parse_url( $u, PHP_URL_PATH );
1503 $query = (string) wp_parse_url( $u, PHP_URL_QUERY );
1504 return $path . ( '' !== $query ? '?' . $query : '' );
1505 };
1506 return ( 0 === strpos( $a, '/' ) || 0 === strpos( $b, '/' ) ) && $rel( $a ) === $rel( $b );
1507 }
1508
1509 /**
1510 * The original of a modern-format sibling that appends its extension,
1511 * `hero.png.webp` → `hero.png`, the naming ShortPixel, Imagify and other
1512 * image plugins use. Any other URL comes back unchanged.
1513 */
1514 private static function format_original( string $url ): string {
1515 return (string) preg_replace( '#(\.(?:jpe?g|png))\.(?:webp|avif)(?=$|\?)#i', '$1', $url );
1516 }
1517
1518 /** Whether one of the URLs in a srcset IS $url (not merely contains it). */
1519 private static function srcset_has( string $srcset, string $url ): bool {
1520 foreach ( explode( ',', $srcset ) as $entry ) {
1521 $candidate = strtok( trim( $entry ), " \t\n" );
1522 if ( false !== $candidate && self::same_url( $candidate, $url ) ) {
1523 return true;
1524 }
1525 }
1526 return false;
1527 }
1528
1529 /**
1530 * Promote an <img> to the LCP element: force fetchpriority="high" and
1531 * strip any loading="lazy" so the browser loads it immediately. Both are
1532 * idempotent. `loading="lazy"` is REMOVED rather than flipped to "eager"
1533 * because eager is the default; a bare tag with fetchpriority="high" is
1534 * the canonical high-priority-image form.
1535 */
1536 private static function promote_lcp_img( string $tag ): string {
1537 $tag = self::set_fetchpriority( $tag );
1538 // Drop loading="lazy" (WP core adds it by default). Leave other
1539 // loading values (e.g. an explicit eager) intact — only lazy hurts.
1540 $tag = preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag );
1541 return (string) $tag;
1542 }
1543
1544 /**
1545 * Add fetchpriority="high" to an <img> tag. Idempotent — an existing
1546 * fetchpriority value is normalised to high rather than duplicated.
1547 */
1548 private static function set_fetchpriority( string $tag ): string {
1549 if ( preg_match( '#\bfetchpriority=(["\']).*?\1#i', $tag ) ) {
1550 return (string) preg_replace( '#\bfetchpriority=(["\']).*?\1#i', 'fetchpriority="high"', $tag, 1 );
1551 }
1552 // Insert right after "<img".
1553 return (string) preg_replace( '#<img\b#i', '<img fetchpriority="high"', $tag, 1 );
1554 }
1555
1556 /**
1557 * Inject the assembled hint markup into <head>. Prefers to land right
1558 * before the first stylesheet so the preloads are discovered before the
1559 * render-blocking CSS. Falls back to after <head>, then prepend.
1560 */
1561 private static function inject_into_head( string $html, string $hints ): string {
1562 // Only a supplied candidate's preload carries `media`. Every other
1563 // page keeps exactly the placement it had before the seam existed.
1564 if ( false !== strpos( $hints, ' media="' ) ) {
1565 $pos = self::after_viewport_offset( $html );
1566 if ( null !== $pos ) {
1567 return substr( $html, 0, $pos ) . "\n" . $hints . substr( $html, $pos );
1568 }
1569 }
1570 // Before the first <link rel="stylesheet"> if there is one.
1571 if ( preg_match( '#<link\b[^>]*rel=["\']stylesheet["\'][^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
1572 $pos = $m[0][1];
1573 return substr( $html, 0, $pos ) . $hints . substr( $html, $pos );
1574 }
1575 // Otherwise right after the opening <head ...>.
1576 if ( preg_match( '#<head\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
1577 $pos = $m[0][1] + strlen( $m[0][0] );
1578 return substr( $html, 0, $pos ) . "\n" . $hints . substr( $html, $pos );
1579 }
1580 // No head at all — prepend (degenerate documents).
1581 return $hints . $html;
1582 }
1583
1584 /**
1585 * Where a media-scoped preload goes: before the first stylesheet, as
1586 * usual, but never ahead of <meta name="viewport">. Null when the head
1587 * could not be read, and the usual placement is used instead.
1588 *
1589 * A preload's `media` is evaluated when the parser reaches the link, and
1590 * until the viewport meta is parsed a phone lays out at its default
1591 * desktop width. A `(min-width: 768px)` preload placed earlier matched on
1592 * phones too and fetched the desktop hero at high priority.
1593 *
1594 * Reads only the head, tag by tag, so a viewport meta or a stylesheet
1595 * written inside a script string, a comment or an attribute value is
1596 * never taken for a real one. That put the hints inside a script, which
1597 * then threw a SyntaxError. A pattern over the whole page did the same
1598 * job but could give up on a large inline block, and then searched the
1599 * raw markup.
1600 *
1601 * @return int|null Byte offset in $html.
1602 */
1603 private static function after_viewport_offset( string $html ) {
1604 $len = strlen( $html );
1605 $i = 0;
1606 $head_start = null;
1607 $stylesheet = null;
1608 $viewport = null;
1609 while ( $i < $len ) {
1610 $lt = strpos( $html, '<', $i );
1611 if ( false === $lt ) {
1612 break;
1613 }
1614 if ( 0 === substr_compare( $html, '<!--', $lt, 4 ) ) {
1615 // `<!-->` and `<!--->` close at once, so look from the second dash.
1616 $close = strpos( $html, '-->', $lt + 2 );
1617 if ( false === $close ) {
1618 return null;
1619 }
1620 $i = $close + 3;
1621 continue;
1622 }
1623 if ( ! preg_match( '#\G<(/?)([a-z][a-z0-9-]*)#i', $html, $t, 0, $lt ) ) {
1624 $i = $lt + 1;
1625 continue;
1626 }
1627 $gt = self::tag_end( $html, $lt + strlen( $t[0] ) );
1628 if ( null === $gt ) {
1629 return null;
1630 }
1631 $name = strtolower( $t[2] );
1632 $tag = substr( $html, $lt, $gt + 1 - $lt );
1633 $i = $gt + 1;
1634
1635 if ( '/' === $t[1] ) {
1636 if ( 'head' === $name ) {
1637 break;
1638 }
1639 continue;
1640 }
1641 if ( 'head' === $name ) {
1642 $head_start = $i;
1643 continue;
1644 }
1645 if ( 'body' === $name ) {
1646 break;
1647 }
1648 if ( in_array( $name, array( 'script', 'style', 'noscript', 'template', 'title', 'textarea' ), true ) ) {
1649 $close = stripos( $html, '</' . $name, $i );
1650 if ( false === $close ) {
1651 return null;
1652 }
1653 $i = $close;
1654 continue;
1655 }
1656 if ( null === $head_start ) {
1657 continue;
1658 }
1659 if ( 'link' !== $name && 'meta' !== $name ) {
1660 continue;
1661 }
1662 $attrs = self::tag_attrs( $tag );
1663 if ( null === $stylesheet && 'link' === $name && 'stylesheet' === strtolower( $attrs['rel'] ?? '' ) ) {
1664 if ( null !== $viewport ) {
1665 return $lt;
1666 }
1667 $stylesheet = $lt;
1668 continue;
1669 }
1670 if ( null === $viewport && 'meta' === $name && 'viewport' === strtolower( $attrs['name'] ?? '' ) ) {
1671 if ( null !== $stylesheet ) {
1672 return $i;
1673 }
1674 $viewport = $i;
1675 }
1676 }
1677 if ( null !== $stylesheet ) {
1678 return $stylesheet;
1679 }
1680 return $viewport ?? $head_start;
1681 }
1682
1683 /**
1684 * A tag's attributes, read in order so a quoted value is consumed whole:
1685 * `content="name=viewport"` is a content attribute, not a name.
1686 *
1687 * @return array<string,string> Lowercase name => trimmed value; the first of a repeated name wins.
1688 */
1689 private static function tag_attrs( string $tag ): array {
1690 $out = array();
1691 preg_match_all( '#\s([^\s=/>"\']+)(?:\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+))?#', $tag, $m, PREG_SET_ORDER );
1692 foreach ( $m as $a ) {
1693 $key = strtolower( $a[1] );
1694 if ( ! isset( $out[ $key ] ) ) {
1695 $out[ $key ] = trim( trim( $a[2] ?? '', '"\'' ) );
1696 }
1697 }
1698 return $out;
1699 }
1700
1701 /**
1702 * Offset of the `>` that ends a tag whose attributes start at $from,
1703 * stepping over quoted values so `content="a>b"` does not end it early.
1704 *
1705 * @return int|null
1706 */
1707 private static function tag_end( string $html, int $from ) {
1708 $len = strlen( $html );
1709 $quote = '';
1710 for ( $j = $from; $j < $len; $j++ ) {
1711 $c = $html[ $j ];
1712 if ( '' !== $quote ) {
1713 if ( $c === $quote ) {
1714 $quote = '';
1715 }
1716 } elseif ( '"' === $c || "'" === $c ) {
1717 $quote = $c;
1718 } elseif ( '>' === $c ) {
1719 return $j;
1720 }
1721 }
1722 return null;
1723 }
1724 }
1725