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

431 lines 16.9 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. Finds the first N above-the-fold <img> (the LCP candidate) and emits
7 * a <link rel="preload" as="image" fetchpriority="high"> for each in
8 * the <head>, carrying srcset/sizes as imagesrcset/imagesizes so the
9 * browser can pick the right candidate — then adds fetchpriority="high"
10 * to the <img> itself so it beats any loading="lazy" the theme set.
11 * 2. Emits <link rel="preconnect"> for detected web-font hosts
12 * (fonts.googleapis.com + fonts.gstatic.com) and any user-supplied
13 * hosts, deduped.
14 *
15 * Kept as a pure static so the test suite can drive it without booting the
16 * module or WordPress hooks (mirrors Lazy_Loader::process_html). All output
17 * is escaped at build time; callers echo the result verbatim into the body.
18 *
19 * @package XSpeed
20 */
21
22 declare(strict_types=1);
23
24 namespace XSpeed;
25
26 defined( 'ABSPATH' ) || exit;
27
28 final class Resource_Hints_Processor {
29
30 /**
31 * Transform the page HTML, injecting preload + preconnect hints.
32 *
33 * @param string $html Fully-rendered page HTML.
34 * @param array<string,mixed> $opts Preload module settings.
35 * @return string Rewritten HTML (unchanged when disabled or no match).
36 */
37 public static function process( string $html, array $opts ): string {
38 if ( empty( $opts['enabled'] ) ) {
39 return $html;
40 }
41
42 // Only touch real HTML documents. A JSON/XML/feed body that happens
43 // to reach here should pass through untouched.
44 if ( false === stripos( $html, '<html' ) && false === stripos( $html, '<body' ) && false === stripos( $html, '<head' ) ) {
45 return $html;
46 }
47
48 $hints = '';
49
50 if ( ! empty( $opts['preconnect'] ) || ! empty( $opts['preconnect_hosts'] ) ) {
51 $hints .= self::build_preconnect( $html, (array) ( $opts['preconnect_hosts'] ?? array() ), ! empty( $opts['preconnect'] ) );
52 }
53
54 if ( ! empty( $opts['lcp_preload'] ) ) {
55 $count = max( 0, (int) ( $opts['lcp_image_count'] ?? 1 ) );
56 $exclusions = array_filter( array_map( 'strval', (array) ( $opts['lcp_exclusions'] ?? array() ) ) );
57 [ $html, $preload ] = self::build_lcp_preload( $html, $count, $exclusions );
58 $hints .= $preload;
59 }
60
61 // Full-page eager promotion for Lazy-excluded heroes (FBS-83553 H2). The
62 // Lazy module only filters the_content/thumbnail/avatar/widget, so a
63 // theme/builder hero printed OUTSIDE those keeps WP core's
64 // loading="lazy". This pass runs over the whole document, so it can reach
65 // those heroes: for any <img> matching an exclusion pattern, strip lazy +
66 // set fetchpriority=high. NOTE: this mutates $html even when no <head>
67 // hints are emitted, so it must apply before the empty-$hints early-out.
68 $eager = array_filter( array_map( 'strval', (array) ( $opts['eager_excluded_images'] ?? array() ) ) );
69 if ( ! empty( $eager ) ) {
70 $html = self::promote_excluded_images( $html, $eager );
71 }
72
73 if ( '' === $hints ) {
74 return $html;
75 }
76
77 return self::inject_into_head( $html, $hints );
78 }
79
80 /**
81 * Strip core `loading="lazy"` and add `fetchpriority="high"` +
82 * `decoding="async"` on every <img> whose tag matches one of the given
83 * exclusion substrings. Mirrors what Lazy_Loader does for an excluded image
84 * inside the_content, but page-wide so heroes outside it are covered too.
85 * (FBS-83553 H2)
86 *
87 * @param string $html Full page HTML.
88 * @param string[] $exclusions Substring patterns identifying above-the-fold heroes.
89 */
90 private static function promote_excluded_images( string $html, array $exclusions ): string {
91 return (string) preg_replace_callback(
92 '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i',
93 static function ( array $m ) use ( $exclusions ) {
94 $tag = $m[0];
95 foreach ( $exclusions as $needle ) {
96 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
97 $tag = (string) preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag );
98 $tag = self::set_fetchpriority( $tag );
99 if ( ! preg_match( '#\bdecoding=#i', $tag ) ) {
100 $tag = (string) preg_replace( '#<img\b#i', '<img decoding="async"', $tag, 1 );
101 }
102 return $tag;
103 }
104 }
105 return $tag;
106 },
107 $html
108 );
109 }
110
111 /**
112 * Build preconnect <link>s for detected font hosts + user hosts.
113 * Deduped and idempotent (skips hosts already preconnected in $html).
114 *
115 * @param string $html Page HTML (scanned for font stylesheets).
116 * @param string[] $user_hosts Extra hosts to always preconnect.
117 * @param bool $auto_fonts Whether to auto-add font hosts.
118 * @return string preconnect <link> markup.
119 */
120 private static function build_preconnect( string $html, array $user_hosts, bool $auto_fonts ): string {
121 $hosts = array();
122
123 if ( $auto_fonts && false !== stripos( $html, 'fonts.googleapis.com' ) ) {
124 // The stylesheet is on googleapis; the font files stream from
125 // gstatic — preconnect both, gstatic needs crossorigin.
126 $hosts['https://fonts.googleapis.com'] = false;
127 $hosts['https://fonts.gstatic.com'] = true;
128 }
129
130 foreach ( $user_hosts as $host ) {
131 $host = trim( (string) $host );
132 if ( '' === $host ) {
133 continue;
134 }
135 // Cross-origin hosts get crossorigin by default; harmless for
136 // same-scheme document hosts and required for fonts/fetch.
137 $hosts[ untrailingslashit( $host ) ] = true;
138 }
139
140 $out = '';
141 foreach ( $hosts as $host => $crossorigin ) {
142 // Idempotency: skip a host already preconnected in the document.
143 if ( preg_match( '#rel=["\']preconnect["\'][^>]*' . preg_quote( $host, '#' ) . '#i', $html )
144 || preg_match( '#' . preg_quote( $host, '#' ) . '[^>]*rel=["\']preconnect["\']#i', $html ) ) {
145 continue;
146 }
147 $out .= sprintf(
148 '<link rel="preconnect" href="%s"%s>' . "\n",
149 esc_url( $host ),
150 $crossorigin ? ' crossorigin' : ''
151 );
152 }
153
154 return $out;
155 }
156
157 /**
158 * Find the first $count eligible <img> tags, add fetchpriority="high"
159 * to each, and return the matching <link rel=preload as=image> markup.
160 *
161 * @param string $html Page HTML.
162 * @param int $count How many top images to preload.
163 * @param string[] $exclusions Substring patterns that exempt an <img>.
164 * @return array{0:string,1:string} [rewritten html, preload markup]
165 */
166 private static function build_lcp_preload( string $html, int $count, array $exclusions ): array {
167 if ( $count < 1 ) {
168 return array( $html, '' );
169 }
170
171 $preload = '';
172 $done = 0;
173
174 // Snapshot of already-present preload markup, for idempotency: a second
175 // pass (e.g. cache-off ob_start over an already-processed body) must not
176 // re-emit a <link> for an image we preloaded before.
177 $existing = $html;
178
179 // Walk <img> tags in document order. preg_replace_callback lets us
180 // rewrite the tag (add fetchpriority) and harvest the src in one pass.
181 $html = preg_replace_callback(
182 '#<img\b[^>]*>#i',
183 static function ( array $m ) use ( &$done, &$preload, $count, $exclusions, $existing ) {
184 $tag = $m[0];
185 if ( $done >= $count ) {
186 return $tag;
187 }
188
189 // Skip anything the user excluded.
190 foreach ( $exclusions as $needle ) {
191 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
192 return $tag;
193 }
194 }
195
196 // Resolve the EFFECTIVE image URL. Page builders + JS lazy
197 // loaders park a placeholder (a data: URI or a 1px spacer) in
198 // `src` and the real URL in `data-src`, so the hero the browser
199 // actually paints is behind data-src. Reading `src` alone made
200 // the LCP picker skip the real hero and fasten onto a later plain
201 // <img> decoy. Prefer data-src when src is a placeholder.
202 // (FBS-83553 H1)
203 [ $src, $srcset, $sizes ] = self::effective_image_src( $tag );
204 if ( '' === $src ) {
205 return $tag; // no real URL (pure data-URI spacer, no data-src).
206 }
207
208 // Size gate: never spend the LCP preload budget on an image that
209 // is obviously not the hero — a logo/icon/avatar. When explicit
210 // width & height are on the tag and it's small in BOTH dimensions,
211 // skip it and keep looking. Missing dimensions → don't guess, let
212 // it through. (FBS-83553 H1 "logo before hero".)
213 if ( self::looks_too_small( $tag ) ) {
214 return $tag;
215 }
216
217 // Idempotency: if this src is already the target of a
218 // rel="preload" as="image" link, count it as done (so the
219 // budget is respected) but don't emit a duplicate <link>.
220 $already = (bool) preg_match(
221 '#rel=["\']preload["\'][^>]*as=["\']image["\'][^>]*' . preg_quote( $src, '#' ) . '#i',
222 $existing
223 );
224 if ( ! $already ) {
225 $preload .= self::preload_link( $src, $srcset, $sizes );
226 }
227 $done++;
228
229 // Add fetchpriority="high" AND remove any loading="lazy" the
230 // theme / WP core left on the LCP image. fetchpriority="high"
231 // with loading="lazy" is contradictory — the browser can still
232 // defer a lazy image, so preloading it while it stays lazy wins
233 // nothing. Stripping lazy is what actually lets the preload land.
234 return self::promote_lcp_img( $tag );
235 },
236 $html
237 );
238
239 return array( (string) $html, $preload );
240 }
241
242 /**
243 * Assemble one <link rel="preload" as="image" fetchpriority="high">.
244 *
245 * The href/srcset run through the `xspeed_lcp_preload_url` /
246 * `xspeed_lcp_preload_srcset` filters first. This is the coordination point
247 * with format-negotiating layers (Pro's Images module wraps the LCP <img> in
248 * a <picture> with a WebP/AVIF <source>, so the browser paints e.g.
249 * hero.png.webp, NOT the hero.png this preload would otherwise point at —
250 * making the high-priority preload a wasted download while the real LCP
251 * resource goes un-preloaded). By filtering the URL, a webp/avif layer can
252 * redirect the preload to the format it will actually serve, WITHOUT Free
253 * knowing that layer exists. (FBS-83553 H3)
254 */
255 private static function preload_link( string $src, string $srcset, string $sizes ): string {
256 // Resolve the `type` from the ORIGINAL image URL (before rewriting), so a
257 // negotiating layer can key off the source .jpg/.png — after rewriting,
258 // the URL is already a .webp and the derivation would no-op.
259 $original = $src;
260 /**
261 * Filter an explicit `type` for the preload link (e.g. "image/webp").
262 * Empty = omit. A typed image preload is only fetched by browsers that
263 * accept that type, so pairing a webp href with type="image/webp" is safe
264 * even though the markup is baked into a shared cache file.
265 *
266 * @param string $type Defaults to '' (no type attribute).
267 * @param string $src The ORIGINAL (pre-rewrite) preload URL.
268 */
269 $type = (string) apply_filters( 'xspeed_lcp_preload_type', '', $original );
270 /**
271 * Filter the LCP preload href. Return a modern-format sibling (webp/avif)
272 * when one will actually be served for this image.
273 *
274 * @param string $src The original image URL chosen for preload.
275 */
276 $src = (string) apply_filters( 'xspeed_lcp_preload_url', $src );
277 if ( '' !== $srcset ) {
278 /** @param string $srcset The original srcset chosen for preload. */
279 $srcset = (string) apply_filters( 'xspeed_lcp_preload_srcset', $srcset );
280 }
281
282 $attrs = sprintf( 'href="%s"', esc_url( $src ) );
283
284 if ( '' !== $srcset ) {
285 // Preserve the responsive candidate set so the browser preloads
286 // the same file it would have chosen from the <img>.
287 $attrs .= sprintf( ' imagesrcset="%s"', esc_attr( html_entity_decode( $srcset, ENT_QUOTES ) ) );
288 if ( '' !== $sizes ) {
289 $attrs .= sprintf( ' imagesizes="%s"', esc_attr( html_entity_decode( $sizes, ENT_QUOTES ) ) );
290 }
291 }
292
293 if ( '' !== $type ) {
294 $attrs .= sprintf( ' type="%s"', esc_attr( $type ) );
295 }
296
297 return sprintf( '<link rel="preload" as="image" %s fetchpriority="high">' . "\n", $attrs );
298 }
299
300 /**
301 * Extract a single/double-quoted attribute value from a tag. Returns ''
302 * when the attribute is absent.
303 */
304 private static function attr( string $tag, string $name ): string {
305 if ( preg_match( '#\b' . preg_quote( $name, '#' ) . '=(["\'])(.*?)\1#is', $tag, $m ) ) {
306 return trim( $m[2] );
307 }
308 return '';
309 }
310
311 /**
312 * Resolve the URL/srcset/sizes the browser will actually paint for an
313 * <img>, seeing through JS-lazy placeholders. When `src` is a data: URI (a
314 * builder/lazy-loader placeholder), fall back to `data-src`; likewise carry
315 * `data-srcset`/`data-sizes` when the plain ones are absent. Returns
316 * ['', '', ''] when there's no real raster URL to preload. (FBS-83553 H1)
317 *
318 * @return array{0:string,1:string,2:string} [src, srcset, sizes]
319 */
320 private static function effective_image_src( string $tag ): array {
321 $src = self::attr( $tag, 'src' );
322 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
323 $data_src = self::attr( $tag, 'data-src' );
324 if ( '' !== $data_src && 0 !== stripos( $data_src, 'data:' ) ) {
325 $src = $data_src;
326 }
327 }
328 if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
329 return array( '', '', '' );
330 }
331 $srcset = self::attr( $tag, 'srcset' );
332 if ( '' === $srcset ) {
333 $srcset = self::attr( $tag, 'data-srcset' );
334 }
335 $sizes = self::attr( $tag, 'sizes' );
336 if ( '' === $sizes ) {
337 $sizes = self::attr( $tag, 'data-sizes' );
338 }
339 return array( $src, $srcset, $sizes );
340 }
341
342 /**
343 * At/below this (px) in BOTH width and height, an image is treated as a
344 * logo/icon/avatar rather than an LCP hero. 200px clears real content heroes
345 * (which are typically ≥ 400px wide) while catching site logos and avatars
346 * — including the 150×150 logo the picker used to mistakenly preload.
347 */
348 private const MIN_LCP_DIMENSION = 200;
349
350 /**
351 * Class/role/filename markers that identify site chrome (logo, icon,
352 * avatar, spinner, emoji) which should never be treated as the LCP hero,
353 * regardless of declared size.
354 */
355 private const NON_HERO_MARKERS = array( 'logo', 'icon', 'avatar', 'gravatar', 'spinner', 'emoji', 'site-icon', 'custom-logo' );
356
357 /**
358 * Is this <img> too small / too chrome-like to be the LCP hero? True when
359 * either (a) it carries a logo/icon/avatar marker, (b) an explicit
360 * `data-no-lcp` opt-out, or (c) BOTH width and height are present and both
361 * are ≤ the threshold. Missing dimensions are NOT guessed — an image whose
362 * size we can't read still competes. (FBS-83553 H1 "logo before hero".)
363 */
364 private static function looks_too_small( string $tag ): bool {
365 if ( false !== stripos( $tag, 'data-no-lcp' ) ) {
366 return true;
367 }
368 // Marker check against class / id / src (covers "custom-logo", a
369 // "…/logo.png" filename, role="img" avatars, etc.).
370 $haystack = strtolower( self::attr( $tag, 'class' ) . ' ' . self::attr( $tag, 'id' ) . ' ' . self::attr( $tag, 'src' ) );
371 foreach ( self::NON_HERO_MARKERS as $marker ) {
372 if ( false !== strpos( $haystack, $marker ) ) {
373 return true;
374 }
375 }
376 $w = self::attr( $tag, 'width' );
377 $h = self::attr( $tag, 'height' );
378 if ( '' === $w || '' === $h || ! is_numeric( $w ) || ! is_numeric( $h ) ) {
379 return false; // unknown size — don't guess; let it compete.
380 }
381 return (int) $w <= self::MIN_LCP_DIMENSION && (int) $h <= self::MIN_LCP_DIMENSION;
382 }
383
384 /**
385 * Promote an <img> to the LCP element: force fetchpriority="high" and
386 * strip any loading="lazy" so the browser loads it immediately. Both are
387 * idempotent. `loading="lazy"` is REMOVED rather than flipped to "eager"
388 * because eager is the default; a bare tag with fetchpriority="high" is
389 * the canonical high-priority-image form.
390 */
391 private static function promote_lcp_img( string $tag ): string {
392 $tag = self::set_fetchpriority( $tag );
393 // Drop loading="lazy" (WP core adds it by default). Leave other
394 // loading values (e.g. an explicit eager) intact — only lazy hurts.
395 $tag = preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag );
396 return (string) $tag;
397 }
398
399 /**
400 * Add fetchpriority="high" to an <img> tag. Idempotent — an existing
401 * fetchpriority value is normalised to high rather than duplicated.
402 */
403 private static function set_fetchpriority( string $tag ): string {
404 if ( preg_match( '#\bfetchpriority=(["\']).*?\1#i', $tag ) ) {
405 return (string) preg_replace( '#\bfetchpriority=(["\']).*?\1#i', 'fetchpriority="high"', $tag, 1 );
406 }
407 // Insert right after "<img".
408 return (string) preg_replace( '#<img\b#i', '<img fetchpriority="high"', $tag, 1 );
409 }
410
411 /**
412 * Inject the assembled hint markup into <head>. Prefers to land right
413 * before the first stylesheet so the preloads are discovered before the
414 * render-blocking CSS. Falls back to after <head>, then prepend.
415 */
416 private static function inject_into_head( string $html, string $hints ): string {
417 // Before the first <link rel="stylesheet"> if there is one.
418 if ( preg_match( '#<link\b[^>]*rel=["\']stylesheet["\'][^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
419 $pos = $m[0][1];
420 return substr( $html, 0, $pos ) . $hints . substr( $html, $pos );
421 }
422 // Otherwise right after the opening <head ...>.
423 if ( preg_match( '#<head\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
424 $pos = $m[0][1] + strlen( $m[0][0] );
425 return substr( $html, 0, $pos ) . "\n" . $hints . substr( $html, $pos );
426 }
427 // No head at all — prepend (degenerate documents).
428 return $hints . $html;
429 }
430 }
431