PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.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 1.1.6 1.1.7 1.1.8 All 29 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.2.4, at includes/class-resource-hints-processor.php

712 lines 27.2 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 — and emits
8 * a <link rel="preload" as="image" fetchpriority="high"> for the top N
9 * in the <head>, carrying srcset/sizes as imagesrcset/imagesizes so the
10 * browser can pick the right candidate — then adds fetchpriority="high"
11 * to the <img> itself so it beats any loading="lazy" the theme set.
12 * Ranking by size rather than document position: the first images on a
13 * real page are usually header chrome, not the hero (#96).
14 * 2. Emits <link rel="preconnect"> for detected web-font hosts
15 * (fonts.googleapis.com + fonts.gstatic.com) and any user-supplied
16 * hosts, deduped.
17 *
18 * Kept as a pure static so the test suite can drive it without booting the
19 * module or WordPress hooks (mirrors Lazy_Loader::process_html). All output
20 * is escaped at build time; callers echo the result verbatim into the body.
21 *
22 * @package XSpeed
23 */
24
25 declare(strict_types=1);
26
27 namespace XSpeed;
28
29 defined( 'ABSPATH' ) || exit;
30
31 final class Resource_Hints_Processor {
32
33 /**
34 * Transform the page HTML, injecting preload + preconnect hints.
35 *
36 * @param string $html Fully-rendered page HTML.
37 * @param array<string,mixed> $opts Preload module settings.
38 * @return string Rewritten HTML (unchanged when disabled or no match).
39 */
40 public static function process( string $html, array $opts ): string {
41 if ( empty( $opts['enabled'] ) ) {
42 return $html;
43 }
44
45 // Only touch real HTML documents. A JSON/XML/feed body that happens
46 // to reach here should pass through untouched.
47 if ( false === stripos( $html, '<html' ) && false === stripos( $html, '<body' ) && false === stripos( $html, '<head' ) ) {
48 return $html;
49 }
50
51 $hints = '';
52
53 if ( ! empty( $opts['preconnect'] ) || ! empty( $opts['preconnect_hosts'] ) ) {
54 $hints .= self::build_preconnect( $html, (array) ( $opts['preconnect_hosts'] ?? array() ), ! empty( $opts['preconnect'] ) );
55 }
56
57 if ( ! empty( $opts['lcp_preload'] ) ) {
58 $count = max( 0, (int) ( $opts['lcp_image_count'] ?? 1 ) );
59 $exclusions = array_filter( array_map( 'strval', (array) ( $opts['lcp_exclusions'] ?? array() ) ) );
60 [ $html, $preload ] = self::build_lcp_preload( $html, $count, $exclusions );
61 $hints .= $preload;
62 }
63
64 // Full-page eager promotion for Lazy-excluded heroes (FBS-83553 H2). The
65 // Lazy module only filters the_content/thumbnail/avatar/widget, so a
66 // theme/builder hero printed OUTSIDE those keeps WP core's
67 // loading="lazy". This pass runs over the whole document, so it can reach
68 // those heroes: for any <img> matching an exclusion pattern, strip lazy +
69 // set fetchpriority=high. NOTE: this mutates $html even when no <head>
70 // hints are emitted, so it must apply before the empty-$hints early-out.
71 $eager = array_filter( array_map( 'strval', (array) ( $opts['eager_excluded_images'] ?? array() ) ) );
72 if ( ! empty( $eager ) ) {
73 $html = self::promote_excluded_images( $html, $eager );
74 }
75
76 if ( '' === $hints ) {
77 return $html;
78 }
79
80 return self::inject_into_head( $html, $hints );
81 }
82
83 /**
84 * Strip core `loading="lazy"` and add `fetchpriority="high"` +
85 * `decoding="async"` on every <img> whose tag matches one of the given
86 * exclusion substrings. Mirrors what Lazy_Loader does for an excluded image
87 * inside the_content, but page-wide so heroes outside it are covered too.
88 * (FBS-83553 H2)
89 *
90 * @param string $html Full page HTML.
91 * @param string[] $exclusions Substring patterns identifying above-the-fold heroes.
92 */
93 private static function promote_excluded_images( string $html, array $exclusions ): string {
94 return (string) preg_replace_callback(
95 '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i',
96 static function ( array $m ) use ( $exclusions ) {
97 $tag = $m[0];
98 foreach ( $exclusions as $needle ) {
99 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
100 $tag = (string) preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag );
101 $tag = self::set_fetchpriority( $tag );
102 if ( ! preg_match( '#\bdecoding=#i', $tag ) ) {
103 $tag = (string) preg_replace( '#<img\b#i', '<img decoding="async"', $tag, 1 );
104 }
105 return $tag;
106 }
107 }
108 return $tag;
109 },
110 $html
111 );
112 }
113
114 /**
115 * Build preconnect <link>s for detected font hosts + user hosts.
116 * Deduped and idempotent (skips hosts already preconnected in $html).
117 *
118 * @param string $html Page HTML (scanned for font stylesheets).
119 * @param string[] $user_hosts Extra hosts to always preconnect.
120 * @param bool $auto_fonts Whether to auto-add font hosts.
121 * @return string preconnect <link> markup.
122 */
123 private static function build_preconnect( string $html, array $user_hosts, bool $auto_fonts ): string {
124 $hosts = array();
125
126 if ( $auto_fonts && false !== stripos( $html, 'fonts.googleapis.com' ) ) {
127 // The stylesheet is on googleapis; the font files stream from
128 // gstatic — preconnect both, gstatic needs crossorigin.
129 $hosts['https://fonts.googleapis.com'] = false;
130 $hosts['https://fonts.gstatic.com'] = true;
131 }
132
133 foreach ( $user_hosts as $host ) {
134 $host = trim( (string) $host );
135 if ( '' === $host ) {
136 continue;
137 }
138 // Cross-origin hosts get crossorigin by default; harmless for
139 // same-scheme document hosts and required for fonts/fetch.
140 $hosts[ untrailingslashit( $host ) ] = true;
141 }
142
143 $out = '';
144 foreach ( $hosts as $host => $crossorigin ) {
145 // Idempotency: skip a host already preconnected in the document.
146 if ( preg_match( '#rel=["\']preconnect["\'][^>]*' . preg_quote( $host, '#' ) . '#i', $html )
147 || preg_match( '#' . preg_quote( $host, '#' ) . '[^>]*rel=["\']preconnect["\']#i', $html ) ) {
148 continue;
149 }
150 $out .= sprintf(
151 '<link rel="preconnect" href="%s"%s>' . "\n",
152 esc_url( $host ),
153 $crossorigin ? ' crossorigin' : ''
154 );
155 }
156
157 return $out;
158 }
159
160 /**
161 * Find the first $count eligible <img> tags, add fetchpriority="high"
162 * to each, and return the matching <link rel=preload as=image> markup.
163 *
164 * @param string $html Page HTML.
165 * @param int $count How many top images to preload.
166 * @param string[] $exclusions Substring patterns that exempt an <img>.
167 * @return array{0:string,1:string} [rewritten html, preload markup]
168 */
169 private static function build_lcp_preload( string $html, int $count, array $exclusions ): array {
170 if ( $count < 1 ) {
171 return array( $html, '' );
172 }
173
174 $preload = '';
175
176 // Snapshot of already-present preload markup, for idempotency: a second
177 // pass (e.g. cache-off ob_start over an already-processed body) must not
178 // re-emit a <link> for an image we preloaded before.
179 $existing = $html;
180
181 // PASS 1 — collect every eligible <img> and score it.
182 //
183 // This used to preload the first N eligible tags in DOCUMENT ORDER.
184 // Position is not a proxy for rendered size: on real pages the first
185 // images are header chrome, breadcrumbs or badge rows, and the actual
186 // LCP element is a hero further down. Preloading the wrong image gains
187 // nothing — it just adds a high-priority request competing with the
188 // one that matters, and the feature reported success either way. The
189 // marker list and size gate were heuristics layered on top of the
190 // wrong primitive rather than replacing it. (#96)
191 $candidates = array();
192 if ( preg_match_all( '#<img\b[^>]*>#i', $html, $matches ) ) {
193 foreach ( $matches[0] as $index => $tag ) {
194 // Skip anything the user excluded.
195 $excluded = false;
196 foreach ( $exclusions as $needle ) {
197 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
198 $excluded = true;
199 break;
200 }
201 }
202 if ( $excluded ) {
203 continue;
204 }
205
206 // Resolve the EFFECTIVE image URL. Page builders + JS lazy
207 // loaders park a placeholder (a data: URI or a 1px spacer) in
208 // `src` and the real URL in `data-src`, so the hero the browser
209 // actually paints is behind data-src. (FBS-83553 H1)
210 [ $src, $srcset, $sizes ] = self::effective_image_src( $tag );
211 if ( '' === $src ) {
212 continue; // no real URL (pure data-URI spacer, no data-src).
213 }
214
215 // Chrome markers / explicit opt-out / obviously-tiny images
216 // never compete. (FBS-83553 H1 "logo before hero".)
217 if ( self::looks_too_small( $tag ) ) {
218 continue;
219 }
220
221 $candidates[] = array(
222 'tag' => $tag,
223 'src' => $src,
224 'srcset' => $srcset,
225 'sizes' => $sizes,
226 'score' => self::lcp_score( $tag, $srcset ),
227 'order' => $index,
228 );
229 }
230 }
231
232 // PASS 1b — the same for CSS background images.
233 //
234 // On a page builder the hero is usually a background-image on the
235 // section, not an <img>, so an <img>-only candidate set never contained
236 // the element that actually paints as LCP. It preloaded whatever <img>
237 // happened to be there — measured at 0ms against the feature switched
238 // off, while spending a high-priority fetch on the critical path — or,
239 // on a page with no <img> at all, emitted nothing. (#247)
240 foreach ( self::background_candidates( $html, $exclusions ) as $bg ) {
241 $candidates[] = $bg;
242 }
243
244 if ( empty( $candidates ) ) {
245 return array( $html, '' );
246 }
247
248 // Rank by score, biggest first. Document order breaks ties, so two
249 // equally-sized images (or two of unknown size) keep the previous
250 // first-wins behaviour — the change only matters when we can actually
251 // tell one is larger.
252 usort(
253 $candidates,
254 static function ( array $a, array $b ) {
255 if ( $a['score'] === $b['score'] ) {
256 return $a['order'] <=> $b['order'];
257 }
258 return $b['score'] <=> $a['score'];
259 }
260 );
261
262 $winners = array_slice( $candidates, 0, $count );
263
264 // PASS 2 — emit the preload links and promote the winning tags.
265 $chosen = array();
266 foreach ( $winners as $w ) {
267 // Idempotency: if this src is already the target of a
268 // rel="preload" as="image" link, still promote the tag but don't
269 // emit a duplicate <link>.
270 $already = (bool) preg_match(
271 '#rel=["\']preload["\'][^>]*as=["\']image["\'][^>]*' . preg_quote( $w['src'], '#' ) . '#i',
272 $existing
273 );
274 if ( ! $already ) {
275 $preload .= self::preload_link( $w['src'], $w['srcset'], $w['sizes'] );
276 }
277 // Only <img> winners are promoted in PASS 2 — there is no
278 // fetchpriority/loading attribute to fix on a background element,
279 // and its `order` is offset past every <img> index precisely so it
280 // can never select one for rewriting.
281 if ( empty( $w['background'] ) ) {
282 $chosen[ $w['order'] ] = true;
283 }
284 }
285
286 // Rewrite only the winning tags. Counting occurrences rather than
287 // matching on tag text, because the same markup can legitimately
288 // appear more than once on a page and only the ranked instance should
289 // be promoted.
290 $seen = -1;
291 $html = preg_replace_callback(
292 '#<img\b[^>]*>#i',
293 static function ( array $m ) use ( &$seen, $chosen ) {
294 ++$seen;
295 if ( ! isset( $chosen[ $seen ] ) ) {
296 return $m[0];
297 }
298 // Add fetchpriority="high" AND remove any loading="lazy" the
299 // theme / WP core left on the LCP image. fetchpriority="high"
300 // with loading="lazy" is contradictory — the browser can still
301 // defer a lazy image, so preloading it while it stays lazy wins
302 // nothing. Stripping lazy is what actually lets the preload land.
303 return self::promote_lcp_img( $m[0] );
304 },
305 $html
306 );
307
308 return array( (string) $html, $preload );
309 }
310
311 /**
312 * Collect CSS `background-image` heroes as LCP candidates.
313 *
314 * Only INLINE `style` attributes are read. A background declared in an
315 * external stylesheet is invisible here by design: resolving it would mean
316 * fetching and parsing CSS from inside an output-buffer pass, and the URL a
317 * selector resolves to depends on cascade order we cannot evaluate from
318 * markup. Builders that put the hero in a generated per-post stylesheet are
319 * therefore still unserved — worth doing, but not at this cost. (#247)
320 *
321 * Scores are the element's declared pixel area so a background competes
322 * against an <img> in the SAME units — the whole point being that the
323 * bigger of the two should win regardless of which kind it is.
324 *
325 * @param string $html Full page HTML.
326 * @param string[] $exclusions Substring patterns the user excluded.
327 * @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:int,order:int,background:bool}>
328 */
329 private static function background_candidates( string $html, array $exclusions ): array {
330 if ( ! preg_match_all( '#<(?:div|section|header|figure|a|span|li|main|article|aside)\b[^>]*\sstyle\s*=\s*(["\']).*?\1[^>]*>#is', $html, $matches ) ) {
331 return array();
332 }
333
334 $found = array();
335 foreach ( $matches[0] as $index => $tag ) {
336 $style = self::attr( $tag, 'style' );
337 if ( '' === $style || false === stripos( $style, 'background' ) ) {
338 continue;
339 }
340
341 $src = self::background_url( $style );
342 if ( '' === $src ) {
343 continue;
344 }
345
346 foreach ( $exclusions as $needle ) {
347 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
348 continue 2;
349 }
350 }
351
352 // Same chrome/opt-out gates as <img>. A logo painted as a background
353 // is no more the hero than a logo in an <img>.
354 if ( self::looks_too_small( $tag ) ) {
355 continue;
356 }
357
358 $area = self::style_area( $style );
359 if ( 0 === $area ) {
360 // Nothing readable. Deliberately non-zero for the same reason
361 // UNKNOWN_SIZE_SCORE is: an unmeasurable background must still
362 // beat nothing on a page that declares no sizes at all, while
363 // losing to anything we can actually measure.
364 $area = self::UNKNOWN_SIZE_SCORE;
365 }
366
367 $found[] = array(
368 'tag' => $tag,
369 'src' => $src,
370 'srcset' => '',
371 'sizes' => '',
372 'score' => $area,
373 // Offset so a background never ties ahead of an <img> that
374 // appeared earlier in the document; ties still break on order.
375 'order' => 100000 + $index,
376 'background' => true,
377 );
378 }
379
380 return $found;
381 }
382
383 /**
384 * Pull a real image URL out of a `background`/`background-image` declaration.
385 *
386 * Returns '' for anything with nothing to fetch: a gradient (which is a
387 * background-image but not a resource), a data: URI, or `none`.
388 */
389 private static function background_url( string $style ): string {
390 // Decode BEFORE parsing. Builders emit the url() quotes HTML-encoded
391 // inside a style attribute (url(&quot;/hero.jpg&quot;)), and `&quot;`
392 // carries a semicolon — so splitting the declaration on `;` first
393 // truncated the value to `url(&quot` and found no URL at all.
394 $style = html_entity_decode( $style, ENT_QUOTES );
395
396 if ( ! preg_match( '#background(?:-image)?\s*:\s*((?:[^;\'"]|"[^"]*"|\'[^\']*\')+)#i', $style, $decl ) ) {
397 return '';
398 }
399 if ( ! preg_match( '#url\(\s*(["\']?)(.*?)\1\s*\)#is', $decl[1], $m ) ) {
400 return '';
401 }
402 $url = trim( $m[2] );
403 if ( '' === $url || 0 === stripos( $url, 'data:' ) ) {
404 return '';
405 }
406 return $url;
407 }
408
409 /**
410 * Declared pixel area from an inline style, or 0 when it can't be read.
411 *
412 * Only px is honoured. A percentage or viewport unit resolves against a
413 * containing block we cannot see from markup, and guessing one produced the
414 * wrong winner more often than declining to.
415 */
416 private static function style_area( string $style ): int {
417 $w = self::style_px( $style, 'width' );
418 $h = self::style_px( $style, 'height' );
419 if ( $w > 0 && $h > 0 ) {
420 return $w * $h;
421 }
422 if ( $w > 0 ) {
423 return (int) round( $w * $w * self::ASSUMED_ASPECT_RATIO );
424 }
425 return 0;
426 }
427
428 /** One px-valued CSS length from an inline style, or 0. */
429 private static function style_px( string $style, string $prop ): int {
430 if ( preg_match( '#(?:^|;)\s*' . preg_quote( $prop, '#' ) . '\s*:\s*(\d+(?:\.\d+)?)px#i', $style, $m ) ) {
431 return (int) round( (float) $m[1] );
432 }
433 return 0;
434 }
435
436 /**
437 * How likely is this <img> to be the LCP element? Higher wins.
438 *
439 * Rendered area is the best available proxy, and we can only read what the
440 * markup declares:
441 *
442 * 1. `width` × `height` attributes — the real area, when present.
443 * 2. The largest `srcset` / `data-srcset` candidate width, squared into a
444 * pseudo-area. A responsive hero usually omits width/height but ships
445 * a 1600w+ candidate, which says more about its size than its
446 * position ever did.
447 * 3. Nothing readable → a neutral score, so the image still competes
448 * (matching looks_too_small()'s "don't guess" rule) but loses to any
449 * image we CAN measure as larger.
450 *
451 * @param string $tag The full <img> tag.
452 * @param string $srcset Resolved srcset (may come from data-srcset).
453 */
454 private static function lcp_score( string $tag, string $srcset ): int {
455 $w = self::attr( $tag, 'width' );
456 $h = self::attr( $tag, 'height' );
457 if ( '' !== $w && '' !== $h && is_numeric( $w ) && is_numeric( $h ) ) {
458 return (int) $w * (int) $h;
459 }
460
461 $widest = self::widest_srcset_width( $srcset );
462 if ( $widest > 0 ) {
463 // Estimate an AREA, not a square. Squaring the width compared a
464 // pseudo-area against a real one and overstated the width-only
465 // candidate by roughly the inverse of its aspect ratio, so a
466 // 1024w sidebar thumbnail (1 048 576) beat a declared 1200×600
467 // hero (720 000) — a regression on exactly the mixed pages that
468 // document order used to get right, since the hero usually comes
469 // first. Assuming a 16:9 box keeps both sides in the same units.
470 return (int) round( $widest * $widest * self::ASSUMED_ASPECT_RATIO );
471 }
472
473 return self::UNKNOWN_SIZE_SCORE;
474 }
475
476 /**
477 * Score for an image whose size we can't read at all.
478 *
479 * Deliberately non-zero: an unmeasurable image must still beat nothing and
480 * still be preloadable on a page where no image declares its size. But it
481 * sits below a 200×200 declared area (40 000), so anything we CAN measure
482 * as a plausible hero outranks a guess.
483 */
484 private const UNKNOWN_SIZE_SCORE = 1;
485
486 /**
487 * Height-to-width ratio assumed when only a `w` descriptor is readable.
488 *
489 * 9/16 — the commonest hero/banner shape, and close enough that a
490 * width-only candidate is compared against a declared w×h area on the
491 * same scale rather than being systematically inflated.
492 */
493 private const ASSUMED_ASPECT_RATIO = 9 / 16;
494
495 /**
496 * Largest `w` descriptor in a srcset, or 0 when there isn't one.
497 *
498 * Only `w` descriptors are read. An `x` descriptor (`hero.jpg 2x`)
499 * describes pixel density, not layout width, so it says nothing about
500 * rendered area.
501 */
502 private static function widest_srcset_width( string $srcset ): int {
503 if ( '' === $srcset ) {
504 return 0;
505 }
506 $widest = 0;
507 foreach ( explode( ',', $srcset ) as $candidate ) {
508 if ( preg_match( '#(\d+)w\s*$#', trim( $candidate ), $m ) ) {
509 $widest = max( $widest, (int) $m[1] );
510 }
511 }
512 return $widest;
513 }
514
515 /**
516 * Assemble one <link rel="preload" as="image" fetchpriority="high">.
517 *
518 * The href/srcset run through the `xspeed_lcp_preload_url` /
519 * `xspeed_lcp_preload_srcset` filters first. This is the coordination point
520 * with format-negotiating layers (Pro's Images module wraps the LCP <img> in
521 * a <picture> with a WebP/AVIF <source>, so the browser paints e.g.
522 * hero.png.webp, NOT the hero.png this preload would otherwise point at —
523 * making the high-priority preload a wasted download while the real LCP
524 * resource goes un-preloaded). By filtering the URL, a webp/avif layer can
525 * redirect the preload to the format it will actually serve, WITHOUT Free
526 * knowing that layer exists. (FBS-83553 H3)
527 */
528 private static function preload_link( string $src, string $srcset, string $sizes ): string {
529 // Resolve the `type` from the ORIGINAL image URL (before rewriting), so a
530 // negotiating layer can key off the source .jpg/.png — after rewriting,
531 // the URL is already a .webp and the derivation would no-op.
532 $original = $src;
533 /**
534 * Filter an explicit `type` for the preload link (e.g. "image/webp").
535 * Empty = omit. A typed image preload is only fetched by browsers that
536 * accept that type, so pairing a webp href with type="image/webp" is safe
537 * even though the markup is baked into a shared cache file.
538 *
539 * @param string $type Defaults to '' (no type attribute).
540 * @param string $src The ORIGINAL (pre-rewrite) preload URL.
541 */
542 $type = (string) apply_filters( 'xspeed_lcp_preload_type', '', $original );
543 /**
544 * Filter the LCP preload href. Return a modern-format sibling (webp/avif)
545 * when one will actually be served for this image.
546 *
547 * @param string $src The original image URL chosen for preload.
548 */
549 $src = (string) apply_filters( 'xspeed_lcp_preload_url', $src );
550 if ( '' !== $srcset ) {
551 /** @param string $srcset The original srcset chosen for preload. */
552 $srcset = (string) apply_filters( 'xspeed_lcp_preload_srcset', $srcset );
553 }
554
555 $attrs = sprintf( 'href="%s"', esc_url( $src ) );
556
557 if ( '' !== $srcset ) {
558 // Preserve the responsive candidate set so the browser preloads
559 // the same file it would have chosen from the <img>.
560 $attrs .= sprintf( ' imagesrcset="%s"', esc_attr( html_entity_decode( $srcset, ENT_QUOTES ) ) );
561 if ( '' !== $sizes ) {
562 $attrs .= sprintf( ' imagesizes="%s"', esc_attr( html_entity_decode( $sizes, ENT_QUOTES ) ) );
563 }
564 }
565
566 if ( '' !== $type ) {
567 $attrs .= sprintf( ' type="%s"', esc_attr( $type ) );
568 }
569
570 return sprintf( '<link rel="preload" as="image" %s fetchpriority="high">' . "\n", $attrs );
571 }
572
573 /**
574 * Extract a single/double-quoted attribute value from a tag. Returns ''
575 * when the attribute is absent.
576 */
577 private static function attr( string $tag, string $name ): string {
578 // Anchor on a real attribute boundary, not `\b`. A word boundary sits
579 // between the `-` and the `w` of `data-width`, so `\bwidth=` matched
580 // inside it: a lazy-loaded hero carrying `data-width="50"
581 // data-height="50"` was scored 50×50 and rejected by
582 // looks_too_small() — defeating the feature on exactly the images the
583 // data-src/data-srcset handling exists to support. Requiring
584 // whitespace (or the start of the string) before the name means only
585 // a genuine attribute matches.
586 if ( preg_match( '#(?:^|\s)' . preg_quote( $name, '#' ) . '\s*=\s*(["\'])(.*?)\1#is', $tag, $m ) ) {
587 return trim( $m[2] );
588 }
589 return '';
590 }
591
592 /**
593 * Resolve the URL/srcset/sizes the browser will actually paint for an
594 * <img>, seeing through JS-lazy placeholders. When `src` is a data: URI (a
595 * builder/lazy-loader placeholder), fall back to `data-src`; likewise carry
596 * `data-srcset`/`data-sizes` when the plain ones are absent. Returns
597 * ['', '', ''] when there's no real raster URL to preload. (FBS-83553 H1)
598 *
599 * @return array{0:string,1:string,2:string} [src, srcset, sizes]
600 */
601 private static function effective_image_src( string $tag ): array {
602 $src = self::attr( $tag, 'src' );
603 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
604 $data_src = self::attr( $tag, 'data-src' );
605 if ( '' !== $data_src && 0 !== stripos( $data_src, 'data:' ) ) {
606 $src = $data_src;
607 }
608 }
609 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
610 return array( '', '', '' );
611 }
612 $srcset = self::attr( $tag, 'srcset' );
613 if ( '' === $srcset ) {
614 $srcset = self::attr( $tag, 'data-srcset' );
615 }
616 $sizes = self::attr( $tag, 'sizes' );
617 if ( '' === $sizes ) {
618 $sizes = self::attr( $tag, 'data-sizes' );
619 }
620 return array( $src, $srcset, $sizes );
621 }
622
623 /**
624 * At/below this (px) in BOTH width and height, an image is treated as a
625 * logo/icon/avatar rather than an LCP hero. 200px clears real content heroes
626 * (which are typically ≥ 400px wide) while catching site logos and avatars
627 * — including the 150×150 logo the picker used to mistakenly preload.
628 */
629 private const MIN_LCP_DIMENSION = 200;
630
631 /**
632 * Class/role/filename markers that identify site chrome (logo, icon,
633 * avatar, spinner, emoji) which should never be treated as the LCP hero,
634 * regardless of declared size.
635 */
636 private const NON_HERO_MARKERS = array( 'logo', 'icon', 'avatar', 'gravatar', 'spinner', 'emoji', 'site-icon', 'custom-logo' );
637
638 /**
639 * Is this <img> too small / too chrome-like to be the LCP hero? True when
640 * either (a) it carries a logo/icon/avatar marker, (b) an explicit
641 * `data-no-lcp` opt-out, or (c) BOTH width and height are present and both
642 * are ≤ the threshold. Missing dimensions are NOT guessed — an image whose
643 * size we can't read still competes. (FBS-83553 H1 "logo before hero".)
644 */
645 private static function looks_too_small( string $tag ): bool {
646 if ( false !== stripos( $tag, 'data-no-lcp' ) ) {
647 return true;
648 }
649 // Marker check against class / id / src (covers "custom-logo", a
650 // "…/logo.png" filename, role="img" avatars, etc.).
651 $haystack = strtolower( self::attr( $tag, 'class' ) . ' ' . self::attr( $tag, 'id' ) . ' ' . self::attr( $tag, 'src' ) );
652 foreach ( self::NON_HERO_MARKERS as $marker ) {
653 if ( false !== strpos( $haystack, $marker ) ) {
654 return true;
655 }
656 }
657 $w = self::attr( $tag, 'width' );
658 $h = self::attr( $tag, 'height' );
659 if ( '' === $w || '' === $h || ! is_numeric( $w ) || ! is_numeric( $h ) ) {
660 return false; // unknown size — don't guess; let it compete.
661 }
662 return (int) $w <= self::MIN_LCP_DIMENSION && (int) $h <= self::MIN_LCP_DIMENSION;
663 }
664
665 /**
666 * Promote an <img> to the LCP element: force fetchpriority="high" and
667 * strip any loading="lazy" so the browser loads it immediately. Both are
668 * idempotent. `loading="lazy"` is REMOVED rather than flipped to "eager"
669 * because eager is the default; a bare tag with fetchpriority="high" is
670 * the canonical high-priority-image form.
671 */
672 private static function promote_lcp_img( string $tag ): string {
673 $tag = self::set_fetchpriority( $tag );
674 // Drop loading="lazy" (WP core adds it by default). Leave other
675 // loading values (e.g. an explicit eager) intact — only lazy hurts.
676 $tag = preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag );
677 return (string) $tag;
678 }
679
680 /**
681 * Add fetchpriority="high" to an <img> tag. Idempotent — an existing
682 * fetchpriority value is normalised to high rather than duplicated.
683 */
684 private static function set_fetchpriority( string $tag ): string {
685 if ( preg_match( '#\bfetchpriority=(["\']).*?\1#i', $tag ) ) {
686 return (string) preg_replace( '#\bfetchpriority=(["\']).*?\1#i', 'fetchpriority="high"', $tag, 1 );
687 }
688 // Insert right after "<img".
689 return (string) preg_replace( '#<img\b#i', '<img fetchpriority="high"', $tag, 1 );
690 }
691
692 /**
693 * Inject the assembled hint markup into <head>. Prefers to land right
694 * before the first stylesheet so the preloads are discovered before the
695 * render-blocking CSS. Falls back to after <head>, then prepend.
696 */
697 private static function inject_into_head( string $html, string $hints ): string {
698 // Before the first <link rel="stylesheet"> if there is one.
699 if ( preg_match( '#<link\b[^>]*rel=["\']stylesheet["\'][^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
700 $pos = $m[0][1];
701 return substr( $html, 0, $pos ) . $hints . substr( $html, $pos );
702 }
703 // Otherwise right after the opening <head ...>.
704 if ( preg_match( '#<head\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
705 $pos = $m[0][1] + strlen( $m[0][0] );
706 return substr( $html, 0, $pos ) . "\n" . $hints . substr( $html, $pos );
707 }
708 // No head at all — prepend (degenerate documents).
709 return $hints . $html;
710 }
711 }
712