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

569 lines 22.0 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 if ( empty( $candidates ) ) {
233 return array( $html, '' );
234 }
235
236 // Rank by score, biggest first. Document order breaks ties, so two
237 // equally-sized images (or two of unknown size) keep the previous
238 // first-wins behaviour — the change only matters when we can actually
239 // tell one is larger.
240 usort(
241 $candidates,
242 static function ( array $a, array $b ) {
243 if ( $a['score'] === $b['score'] ) {
244 return $a['order'] <=> $b['order'];
245 }
246 return $b['score'] <=> $a['score'];
247 }
248 );
249
250 $winners = array_slice( $candidates, 0, $count );
251
252 // PASS 2 — emit the preload links and promote the winning tags.
253 $chosen = array();
254 foreach ( $winners as $w ) {
255 // Idempotency: if this src is already the target of a
256 // rel="preload" as="image" link, still promote the tag but don't
257 // emit a duplicate <link>.
258 $already = (bool) preg_match(
259 '#rel=["\']preload["\'][^>]*as=["\']image["\'][^>]*' . preg_quote( $w['src'], '#' ) . '#i',
260 $existing
261 );
262 if ( ! $already ) {
263 $preload .= self::preload_link( $w['src'], $w['srcset'], $w['sizes'] );
264 }
265 $chosen[ $w['order'] ] = true;
266 }
267
268 // Rewrite only the winning tags. Counting occurrences rather than
269 // matching on tag text, because the same markup can legitimately
270 // appear more than once on a page and only the ranked instance should
271 // be promoted.
272 $seen = -1;
273 $html = preg_replace_callback(
274 '#<img\b[^>]*>#i',
275 static function ( array $m ) use ( &$seen, $chosen ) {
276 ++$seen;
277 if ( ! isset( $chosen[ $seen ] ) ) {
278 return $m[0];
279 }
280 // Add fetchpriority="high" AND remove any loading="lazy" the
281 // theme / WP core left on the LCP image. fetchpriority="high"
282 // with loading="lazy" is contradictory — the browser can still
283 // defer a lazy image, so preloading it while it stays lazy wins
284 // nothing. Stripping lazy is what actually lets the preload land.
285 return self::promote_lcp_img( $m[0] );
286 },
287 $html
288 );
289
290 return array( (string) $html, $preload );
291 }
292
293 /**
294 * How likely is this <img> to be the LCP element? Higher wins.
295 *
296 * Rendered area is the best available proxy, and we can only read what the
297 * markup declares:
298 *
299 * 1. `width` × `height` attributes — the real area, when present.
300 * 2. The largest `srcset` / `data-srcset` candidate width, squared into a
301 * pseudo-area. A responsive hero usually omits width/height but ships
302 * a 1600w+ candidate, which says more about its size than its
303 * position ever did.
304 * 3. Nothing readable → a neutral score, so the image still competes
305 * (matching looks_too_small()'s "don't guess" rule) but loses to any
306 * image we CAN measure as larger.
307 *
308 * @param string $tag The full <img> tag.
309 * @param string $srcset Resolved srcset (may come from data-srcset).
310 */
311 private static function lcp_score( string $tag, string $srcset ): int {
312 $w = self::attr( $tag, 'width' );
313 $h = self::attr( $tag, 'height' );
314 if ( '' !== $w && '' !== $h && is_numeric( $w ) && is_numeric( $h ) ) {
315 return (int) $w * (int) $h;
316 }
317
318 $widest = self::widest_srcset_width( $srcset );
319 if ( $widest > 0 ) {
320 // Estimate an AREA, not a square. Squaring the width compared a
321 // pseudo-area against a real one and overstated the width-only
322 // candidate by roughly the inverse of its aspect ratio, so a
323 // 1024w sidebar thumbnail (1 048 576) beat a declared 1200×600
324 // hero (720 000) — a regression on exactly the mixed pages that
325 // document order used to get right, since the hero usually comes
326 // first. Assuming a 16:9 box keeps both sides in the same units.
327 return (int) round( $widest * $widest * self::ASSUMED_ASPECT_RATIO );
328 }
329
330 return self::UNKNOWN_SIZE_SCORE;
331 }
332
333 /**
334 * Score for an image whose size we can't read at all.
335 *
336 * Deliberately non-zero: an unmeasurable image must still beat nothing and
337 * still be preloadable on a page where no image declares its size. But it
338 * sits below a 200×200 declared area (40 000), so anything we CAN measure
339 * as a plausible hero outranks a guess.
340 */
341 private const UNKNOWN_SIZE_SCORE = 1;
342
343 /**
344 * Height-to-width ratio assumed when only a `w` descriptor is readable.
345 *
346 * 9/16 — the commonest hero/banner shape, and close enough that a
347 * width-only candidate is compared against a declared w×h area on the
348 * same scale rather than being systematically inflated.
349 */
350 private const ASSUMED_ASPECT_RATIO = 9 / 16;
351
352 /**
353 * Largest `w` descriptor in a srcset, or 0 when there isn't one.
354 *
355 * Only `w` descriptors are read. An `x` descriptor (`hero.jpg 2x`)
356 * describes pixel density, not layout width, so it says nothing about
357 * rendered area.
358 */
359 private static function widest_srcset_width( string $srcset ): int {
360 if ( '' === $srcset ) {
361 return 0;
362 }
363 $widest = 0;
364 foreach ( explode( ',', $srcset ) as $candidate ) {
365 if ( preg_match( '#(\d+)w\s*$#', trim( $candidate ), $m ) ) {
366 $widest = max( $widest, (int) $m[1] );
367 }
368 }
369 return $widest;
370 }
371
372 /**
373 * Assemble one <link rel="preload" as="image" fetchpriority="high">.
374 *
375 * The href/srcset run through the `xspeed_lcp_preload_url` /
376 * `xspeed_lcp_preload_srcset` filters first. This is the coordination point
377 * with format-negotiating layers (Pro's Images module wraps the LCP <img> in
378 * a <picture> with a WebP/AVIF <source>, so the browser paints e.g.
379 * hero.png.webp, NOT the hero.png this preload would otherwise point at —
380 * making the high-priority preload a wasted download while the real LCP
381 * resource goes un-preloaded). By filtering the URL, a webp/avif layer can
382 * redirect the preload to the format it will actually serve, WITHOUT Free
383 * knowing that layer exists. (FBS-83553 H3)
384 */
385 private static function preload_link( string $src, string $srcset, string $sizes ): string {
386 // Resolve the `type` from the ORIGINAL image URL (before rewriting), so a
387 // negotiating layer can key off the source .jpg/.png — after rewriting,
388 // the URL is already a .webp and the derivation would no-op.
389 $original = $src;
390 /**
391 * Filter an explicit `type` for the preload link (e.g. "image/webp").
392 * Empty = omit. A typed image preload is only fetched by browsers that
393 * accept that type, so pairing a webp href with type="image/webp" is safe
394 * even though the markup is baked into a shared cache file.
395 *
396 * @param string $type Defaults to '' (no type attribute).
397 * @param string $src The ORIGINAL (pre-rewrite) preload URL.
398 */
399 $type = (string) apply_filters( 'xspeed_lcp_preload_type', '', $original );
400 /**
401 * Filter the LCP preload href. Return a modern-format sibling (webp/avif)
402 * when one will actually be served for this image.
403 *
404 * @param string $src The original image URL chosen for preload.
405 */
406 $src = (string) apply_filters( 'xspeed_lcp_preload_url', $src );
407 if ( '' !== $srcset ) {
408 /** @param string $srcset The original srcset chosen for preload. */
409 $srcset = (string) apply_filters( 'xspeed_lcp_preload_srcset', $srcset );
410 }
411
412 $attrs = sprintf( 'href="%s"', esc_url( $src ) );
413
414 if ( '' !== $srcset ) {
415 // Preserve the responsive candidate set so the browser preloads
416 // the same file it would have chosen from the <img>.
417 $attrs .= sprintf( ' imagesrcset="%s"', esc_attr( html_entity_decode( $srcset, ENT_QUOTES ) ) );
418 if ( '' !== $sizes ) {
419 $attrs .= sprintf( ' imagesizes="%s"', esc_attr( html_entity_decode( $sizes, ENT_QUOTES ) ) );
420 }
421 }
422
423 if ( '' !== $type ) {
424 $attrs .= sprintf( ' type="%s"', esc_attr( $type ) );
425 }
426
427 return sprintf( '<link rel="preload" as="image" %s fetchpriority="high">' . "\n", $attrs );
428 }
429
430 /**
431 * Extract a single/double-quoted attribute value from a tag. Returns ''
432 * when the attribute is absent.
433 */
434 private static function attr( string $tag, string $name ): string {
435 // Anchor on a real attribute boundary, not `\b`. A word boundary sits
436 // between the `-` and the `w` of `data-width`, so `\bwidth=` matched
437 // inside it: a lazy-loaded hero carrying `data-width="50"
438 // data-height="50"` was scored 50×50 and rejected by
439 // looks_too_small() — defeating the feature on exactly the images the
440 // data-src/data-srcset handling exists to support. Requiring
441 // whitespace (or the start of the string) before the name means only
442 // a genuine attribute matches.
443 if ( preg_match( '#(?:^|\s)' . preg_quote( $name, '#' ) . '\s*=\s*(["\'])(.*?)\1#is', $tag, $m ) ) {
444 return trim( $m[2] );
445 }
446 return '';
447 }
448
449 /**
450 * Resolve the URL/srcset/sizes the browser will actually paint for an
451 * <img>, seeing through JS-lazy placeholders. When `src` is a data: URI (a
452 * builder/lazy-loader placeholder), fall back to `data-src`; likewise carry
453 * `data-srcset`/`data-sizes` when the plain ones are absent. Returns
454 * ['', '', ''] when there's no real raster URL to preload. (FBS-83553 H1)
455 *
456 * @return array{0:string,1:string,2:string} [src, srcset, sizes]
457 */
458 private static function effective_image_src( string $tag ): array {
459 $src = self::attr( $tag, 'src' );
460 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
461 $data_src = self::attr( $tag, 'data-src' );
462 if ( '' !== $data_src && 0 !== stripos( $data_src, 'data:' ) ) {
463 $src = $data_src;
464 }
465 }
466 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
467 return array( '', '', '' );
468 }
469 $srcset = self::attr( $tag, 'srcset' );
470 if ( '' === $srcset ) {
471 $srcset = self::attr( $tag, 'data-srcset' );
472 }
473 $sizes = self::attr( $tag, 'sizes' );
474 if ( '' === $sizes ) {
475 $sizes = self::attr( $tag, 'data-sizes' );
476 }
477 return array( $src, $srcset, $sizes );
478 }
479
480 /**
481 * At/below this (px) in BOTH width and height, an image is treated as a
482 * logo/icon/avatar rather than an LCP hero. 200px clears real content heroes
483 * (which are typically ≥ 400px wide) while catching site logos and avatars
484 * — including the 150×150 logo the picker used to mistakenly preload.
485 */
486 private const MIN_LCP_DIMENSION = 200;
487
488 /**
489 * Class/role/filename markers that identify site chrome (logo, icon,
490 * avatar, spinner, emoji) which should never be treated as the LCP hero,
491 * regardless of declared size.
492 */
493 private const NON_HERO_MARKERS = array( 'logo', 'icon', 'avatar', 'gravatar', 'spinner', 'emoji', 'site-icon', 'custom-logo' );
494
495 /**
496 * Is this <img> too small / too chrome-like to be the LCP hero? True when
497 * either (a) it carries a logo/icon/avatar marker, (b) an explicit
498 * `data-no-lcp` opt-out, or (c) BOTH width and height are present and both
499 * are ≤ the threshold. Missing dimensions are NOT guessed — an image whose
500 * size we can't read still competes. (FBS-83553 H1 "logo before hero".)
501 */
502 private static function looks_too_small( string $tag ): bool {
503 if ( false !== stripos( $tag, 'data-no-lcp' ) ) {
504 return true;
505 }
506 // Marker check against class / id / src (covers "custom-logo", a
507 // "…/logo.png" filename, role="img" avatars, etc.).
508 $haystack = strtolower( self::attr( $tag, 'class' ) . ' ' . self::attr( $tag, 'id' ) . ' ' . self::attr( $tag, 'src' ) );
509 foreach ( self::NON_HERO_MARKERS as $marker ) {
510 if ( false !== strpos( $haystack, $marker ) ) {
511 return true;
512 }
513 }
514 $w = self::attr( $tag, 'width' );
515 $h = self::attr( $tag, 'height' );
516 if ( '' === $w || '' === $h || ! is_numeric( $w ) || ! is_numeric( $h ) ) {
517 return false; // unknown size — don't guess; let it compete.
518 }
519 return (int) $w <= self::MIN_LCP_DIMENSION && (int) $h <= self::MIN_LCP_DIMENSION;
520 }
521
522 /**
523 * Promote an <img> to the LCP element: force fetchpriority="high" and
524 * strip any loading="lazy" so the browser loads it immediately. Both are
525 * idempotent. `loading="lazy"` is REMOVED rather than flipped to "eager"
526 * because eager is the default; a bare tag with fetchpriority="high" is
527 * the canonical high-priority-image form.
528 */
529 private static function promote_lcp_img( string $tag ): string {
530 $tag = self::set_fetchpriority( $tag );
531 // Drop loading="lazy" (WP core adds it by default). Leave other
532 // loading values (e.g. an explicit eager) intact — only lazy hurts.
533 $tag = preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag );
534 return (string) $tag;
535 }
536
537 /**
538 * Add fetchpriority="high" to an <img> tag. Idempotent — an existing
539 * fetchpriority value is normalised to high rather than duplicated.
540 */
541 private static function set_fetchpriority( string $tag ): string {
542 if ( preg_match( '#\bfetchpriority=(["\']).*?\1#i', $tag ) ) {
543 return (string) preg_replace( '#\bfetchpriority=(["\']).*?\1#i', 'fetchpriority="high"', $tag, 1 );
544 }
545 // Insert right after "<img".
546 return (string) preg_replace( '#<img\b#i', '<img fetchpriority="high"', $tag, 1 );
547 }
548
549 /**
550 * Inject the assembled hint markup into <head>. Prefers to land right
551 * before the first stylesheet so the preloads are discovered before the
552 * render-blocking CSS. Falls back to after <head>, then prepend.
553 */
554 private static function inject_into_head( string $html, string $hints ): string {
555 // Before the first <link rel="stylesheet"> if there is one.
556 if ( preg_match( '#<link\b[^>]*rel=["\']stylesheet["\'][^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
557 $pos = $m[0][1];
558 return substr( $html, 0, $pos ) . $hints . substr( $html, $pos );
559 }
560 // Otherwise right after the opening <head ...>.
561 if ( preg_match( '#<head\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
562 $pos = $m[0][1] + strlen( $m[0][0] );
563 return substr( $html, 0, $pos ) . "\n" . $hints . substr( $html, $pos );
564 }
565 // No head at all — prepend (degenerate documents).
566 return $hints . $html;
567 }
568 }
569