PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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-lazy-loader.php

class-lazy-loader.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.2.0, at includes/class-lazy-loader.php

808 lines 29.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Lazy_Loader — rewrites img / iframe / video tags in rendered HTML to
4 * add native `loading="lazy"` (or "eager" for above-the-fold) plus
5 * `decoding="async"` on images. Also auto-adds missing width/height
6 * attributes to prevent CLS.
7 *
8 * Why regex instead of DOMDocument:
9 * - DOMDocument forces a full HTML5 parse round trip per filter call;
10 * on a content-heavy post that's measurably slow. Regex over the
11 * specific tags is ~10× faster.
12 * - We don't need full DOM understanding — every rewrite is a tag-
13 * local attribute injection. Regex is sufficient + predictable.
14 * - Edge cases (img inside HTML comments, img in <script>) are rare
15 * in real post content; we leave those alone with a pre-pass that
16 * stubs out script / style / pre blocks before rewriting.
17 *
18 * @package XSpeed
19 */
20
21 declare(strict_types=1);
22
23 namespace XSpeed;
24
25 defined( 'ABSPATH' ) || exit;
26
27 final class Lazy_Loader {
28
29 /**
30 * In-process counter for above-the-fold skipping. Reset by
31 * process_html on every call so a fresh post starts at 0.
32 *
33 * @var int
34 */
35 private static $image_counter = 0;
36
37 /**
38 * Settings cache (one read per request).
39 *
40 * @var array|null
41 */
42 private static $opts = null;
43
44 /**
45 * Per-URL dimension cache (md5(src) => [w,h] | 0 for known-failure),
46 * hydrated from the `xspeed_img_dims` transient once per request.
47 *
48 * @var array<string,mixed>|null
49 */
50 private static $src_dims_cache = null;
51
52 /**
53 * True while a background pass is resolving dimensions.
54 *
55 * Front-end renders read the cache and never fetch; a warm pass is the
56 * one thing allowed to pay the network cost, because no visitor is
57 * waiting on it.
58 *
59 * @var bool
60 */
61 private static $warming = false;
62
63 /**
64 * Main entry point: take rendered HTML, return rewritten HTML.
65 * Pure function aside from the static counters.
66 */
67 public static function process_html( string $html ): string {
68 if ( '' === $html ) {
69 return $html;
70 }
71 $opts = self::opts();
72
73 // NOTE: the eager-load budget counter is NOT reset here. process_html
74 // runs once per filter pass — the_content, post_thumbnail_html, and
75 // once per get_avatar — so resetting per call let the featured image,
76 // the first content image, AND every comment avatar each claim an
77 // "eager" slot, defeating the budget. The counter is reset once per
78 // page render via reset_state() on template_redirect, so it now
79 // accumulates across all passes as intended. (FBS-82172 Bug 1)
80
81 // Stub out <script>, <style>, <noscript>, <pre>, <code> blocks
82 // so img tags embedded in them as text examples aren't
83 // rewritten. Restore after pass.
84 [ $work, $stubs ] = self::stub_safe_blocks( $html );
85
86 // Tag matcher that respects quoted attribute values, so a ">" inside
87 // an attribute (e.g. alt="a > b") doesn't end the match early and
88 // corrupt the tag. Matches: double-quoted runs, single-quoted runs,
89 // or any non-> char — repeated up to the real closing >.
90 // (FBS-82172 Bug 3)
91 $tag_re = static function ( string $name ): string {
92 return '#<' . $name . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
93 };
94
95 if ( ! empty( $opts['lazy_images'] ) || ! empty( $opts['add_missing_dimensions'] ) ) {
96 $work = self::apply_pass( $work, $tag_re( 'img' ), array( __CLASS__, 'rewrite_img' ) );
97 }
98 if ( ! empty( $opts['lazy_iframes'] ) ) {
99 $work = self::apply_pass( $work, $tag_re( 'iframe' ), array( __CLASS__, 'rewrite_iframe' ) );
100 }
101 // Facade runs AFTER the lazy pass, deliberately. The facade keeps the
102 // original tag inside <noscript> as the JS-less fallback, and that
103 // fallback should carry loading="lazy" too — running this first would
104 // produce an eager iframe for exactly the visitors least able to
105 // afford one.
106 //
107 // Unlike every other pass here, the facade REPLACES the element
108 // rather than injecting attributes into its opening tag — so it has
109 // to consume the whole element, `</iframe>` included. Matching the
110 // opening tag alone orphaned the closing tag outside the injected
111 // <noscript>, which broke nesting and swallowed sibling content in
112 // real browsers. The body is tempered (`(?!</?iframe\b)`) so an
113 // unclosed iframe can't make the match run on to a LATER embed's
114 // closing tag and eat everything in between; an iframe with no
115 // closing tag simply doesn't match and passes through untouched.
116 if ( ! empty( $opts['video_facade'] ) ) {
117 $work = self::apply_pass(
118 $work,
119 '#(<iframe\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?iframe\b).)*)</iframe\s*>#is',
120 array( __CLASS__, 'rewrite_iframe_facade' )
121 );
122 }
123 if ( ! empty( $opts['lazy_videos'] ) ) {
124 $work = self::apply_pass( $work, $tag_re( 'video' ), array( __CLASS__, 'rewrite_video' ) );
125 }
126
127 return self::restore_safe_blocks( $work, $stubs );
128 }
129
130 /**
131 * Run one rewrite pass, keeping the input if PCRE bails.
132 *
133 * preg_replace_callback() returns null when it hits the backtrack or
134 * recursion limit — on a large page that would otherwise blank the
135 * whole document. Returning the untouched HTML costs the optimization
136 * for that request and nothing else.
137 *
138 * @param callable $callback Rewrite callback for one match.
139 */
140 private static function apply_pass( string $html, string $pattern, callable $callback ): string {
141 $result = preg_replace_callback( $pattern, $callback, $html );
142
143 return is_string( $result ) ? $result : $html;
144 }
145
146 private static function rewrite_img( array $m ): string {
147 $tag = $m[0];
148 $opts = self::opts();
149
150 // Explicit skip flag, or matches an exclusion pattern: opt OUT of
151 // LAZY-LOADING only. Dimension injection (CLS protection) still
152 // applies — excluding an above-the-fold hero/logo from lazy-load is
153 // exactly when you most want its width/height kept. Previously both
154 // of these returned early, silently stripping dimensions too.
155 // (FBS-82172 Bug 2)
156 $skip_lazy = false !== stripos( $tag, 'data-skip-lazy' )
157 || false !== stripos( $tag, 'data-no-lazy' )
158 || self::is_excluded( $tag, $opts );
159
160 if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) {
161 // An EXCLUDED image is one the user marked as above-the-fold (a
162 // hero/logo) — the opposite of lazy. WordPress core adds
163 // `loading="lazy"` to images by default (since 5.5), so merely
164 // *skipping* our lazy pass would leave core's lazy attribute on
165 // the LCP hero and tank LCP. Actively make it eager +
166 // high-priority so an excluded hero loads immediately.
167 $tag = self::set_attr( $tag, 'loading', 'eager' );
168 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
169 $tag = self::set_attr( $tag, 'decoding', 'async', true );
170 } elseif ( ! empty( $opts['lazy_images'] ) ) {
171 // Above-the-fold skip: first N images get loading="eager"
172 // instead of "lazy" so the LCP image isn't deferred. Only
173 // non-excluded images consume the budget.
174 self::$image_counter++;
175 $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
176 $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
177 $tag = self::set_attr( $tag, 'decoding', 'async', true );
178 // The eager hero should also drop any core `loading="lazy"`; the
179 // set_attr above already overrode it. Give the first eager image
180 // high fetch priority so it wins the LCP race.
181 if ( $is_above_fold ) {
182 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
183 }
184 }
185
186 if ( ! empty( $opts['add_missing_dimensions'] ) ) {
187 $tag = self::ensure_dimensions( $tag );
188 }
189
190 return $tag;
191 }
192
193 private static function rewrite_iframe( array $m ): string {
194 $tag = $m[0];
195 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
196 return $tag;
197 }
198 if ( self::is_excluded( $tag, self::opts() ) ) {
199 return $tag;
200 }
201 return self::set_attr( $tag, 'loading', 'lazy' );
202 }
203
204 /**
205 * Swap a recognised video embed for a click-to-play facade.
206 *
207 * Passes the element through untouched unless it is a provider we can
208 * build a facade for — an unknown iframe (a map, a form, a dashboard)
209 * must never be replaced by a play button.
210 *
211 * $m[0] is the WHOLE element (`<iframe …>…</iframe>`); $m[1] is just
212 * the opening tag. Attributes are read from the opening tag, but what
213 * goes into the <noscript> fallback — and what is returned on every
214 * bail-out path — is the whole element, so the closing tag is never
215 * left stranded outside it.
216 */
217 private static function rewrite_iframe_facade( array $m ): string {
218 $element = $m[0];
219 $tag = $m[1];
220
221 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
222 return $element;
223 }
224 if ( self::is_excluded( $tag, self::opts() ) ) {
225 return $element;
226 }
227
228 if ( ! preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $src_m ) ) {
229 return $element;
230 }
231 $src = $src_m[2];
232
233 $embed = Video_Facade::parse_embed( $src );
234 if ( null === $embed ) {
235 return $element;
236 }
237
238 $title = '';
239 if ( preg_match( '#\btitle\s*=\s*(["\'])(.*?)\1#i', $tag, $title_m ) ) {
240 $title = $title_m[2];
241 }
242
243 self::$facade_used = true;
244
245 return Video_Facade::render( $element, $embed, $src, $title );
246 }
247
248 /** @var bool True once a facade has been rendered on this page. */
249 private static $facade_used = false;
250
251 /**
252 * True when this render produced at least one facade — the module uses
253 * it to decide whether the click handler is worth printing at all.
254 */
255 public static function facade_used(): bool {
256 return self::$facade_used;
257 }
258
259 private static function rewrite_video( array $m ): string {
260 $tag = $m[0];
261 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
262 return $tag;
263 }
264 // HTML5 `<video>` doesn't support loading=lazy yet (Chromium
265 // won't add it before there's broad support). What we CAN do
266 // is set preload="none" so the browser doesn't pre-fetch the
267 // video bytes until play is requested — that's the actual win
268 // users want from "lazy-load videos".
269 if ( false === stripos( $tag, 'preload=' ) ) {
270 $tag = self::set_attr( $tag, 'preload', 'none' );
271 }
272 return $tag;
273 }
274
275 /**
276 * Add an attribute to an opening tag if it isn't already present.
277 * Pass $only_if_missing=false to override an existing value (e.g.
278 * flipping loading="lazy" → "eager" on the first image).
279 */
280 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
281 $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
282 if ( preg_match( $pattern, $tag ) ) {
283 if ( $only_if_missing ) {
284 return $tag;
285 }
286 return (string) preg_replace( $pattern, $name . '="' . $value . '"', $tag, 1 );
287 }
288 // Inject before the closing > (preserving self-closing `/>` if present).
289 if ( preg_match( '#(/?>)$#', $tag, $m ) ) {
290 $close = $m[1];
291 return substr( $tag, 0, -strlen( $close ) ) . ' ' . $name . '="' . $value . '"' . $close;
292 }
293 return $tag;
294 }
295
296 /**
297 * Attempt to fill in missing width / height from either an attached
298 * media library record (when class="wp-image-N") or from the local
299 * filesystem when src points at the uploads dir. Skip when we can't
300 * resolve cheaply — never block the request on a remote getimagesize.
301 */
302 private static function ensure_dimensions( string $tag ): string {
303 $has_w = (bool) preg_match( '#\bwidth\s*=#i', $tag );
304 $has_h = (bool) preg_match( '#\bheight\s*=#i', $tag );
305 if ( $has_w && $has_h ) {
306 return $tag;
307 }
308
309 // Try wp-image-<id> class first (cheapest path; one DB-cached
310 // get_post_meta call).
311 if ( preg_match( '#\bclass\s*=\s*["\']([^"\']*)["\']#i', $tag, $cm ) && preg_match( '#wp-image-(\d+)#i', $cm[1], $idm ) ) {
312 $dims = self::dimensions_for_attachment( (int) $idm[1] );
313 if ( $dims ) {
314 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
315 }
316 }
317
318 // No wp-image-N class — page-builder markup (Essential Blocks and
319 // friends) never emits it, which is why the setting silently failed
320 // on those images (issue #37). Resolve from the src instead, but only
321 // when the tag doesn't already tell us it renders at some other size:
322 // stamping the intrinsic file size onto a responsive or CSS-sized
323 // image would CREATE the layout shift this feature exists to remove.
324 if ( ! self::has_constrained_render( $tag ) && preg_match( '#\bsrc\s*=\s*["\']([^"\']+)["\']#i', $tag, $sm ) ) {
325 $dims = self::dimensions_for_src( $sm[1] );
326 if ( $dims ) {
327 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
328 }
329 }
330
331 // Couldn't resolve. Leave the tag alone — better no dimensions
332 // than wrong ones.
333 return $tag;
334 }
335
336 /**
337 * True when the tag says it renders at a size other than the file's
338 * intrinsic one — a `srcset`/`sizes` pair (the browser picks a
339 * candidate) or an inline width/height style.
340 *
341 * Only guards the src-suffix fallback. The `wp-image-N` path stays
342 * unguarded: attachment metadata is authoritative, and WordPress'
343 * own `wp_filter_content_tags()` adds dimensions to responsive
344 * images the same way. Pure — unit-tested.
345 */
346 public static function has_constrained_render( string $tag ): bool {
347 if ( preg_match( '#\bsrcset\s*=#i', $tag ) || preg_match( '#\bsizes\s*=#i', $tag ) ) {
348 return true;
349 }
350 if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
351 // width/height in the inline style wins over the attribute, so
352 // the file's intrinsic size would disagree with the layout.
353 return 1 === preg_match( '#(?:^|;)\s*(?:max-)?(?:width|height)\s*:#i', $m[1] );
354 }
355 return false;
356 }
357
358 /** @param int[] $dims [width, height]. */
359 private static function apply_dimensions( string $tag, array $dims, bool $has_w, bool $has_h ): string {
360 // One dimension already present: derive the other from the file's
361 // real aspect ratio rather than stamping its intrinsic size.
362 //
363 // A tag that says width="300" on a 1200x800 file renders 300x200. If
364 // we wrote height="800" the browser would reserve a box two and a
365 // half times too tall, then snap when the image painted — CREATING
366 // the shift this feature exists to remove. Scaling keeps the reserved
367 // box the shape the image will actually be.
368 if ( $has_w !== $has_h ) {
369 if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
370 return $tag;
371 }
372 $from = $has_w ? 'width' : 'height';
373 $declared = self::attr_int( $tag, $from );
374 // A declared value we cannot read in pixels (`50%`, `auto`) means
375 // we do not know the rendered size, so there is no ratio to scale
376 // from. Stamping the intrinsic size here is exactly the bug this
377 // branch exists to avoid, so the tag is left alone.
378 if ( $declared <= 0 ) {
379 return $tag;
380 }
381 if ( $has_w ) {
382 $height = (int) round( $dims[1] * $declared / $dims[0] );
383 return $height > 0 ? self::set_attr( $tag, 'height', (string) $height ) : $tag;
384 }
385 $width = (int) round( $dims[0] * $declared / $dims[1] );
386 return $width > 0 ? self::set_attr( $tag, 'width', (string) $width ) : $tag;
387 }
388
389 // A header that reported 0 for either side is not a measurement. Half
390 // a dimension pair is worse than none: the browser reserves a box of
391 // the wrong shape and still shifts when the real image lands.
392 if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
393 return $tag;
394 }
395
396 if ( ! $has_w ) {
397 $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
398 }
399 if ( ! $has_h ) {
400 $tag = self::set_attr( $tag, 'height', (string) $dims[1] );
401 }
402 return $tag;
403 }
404
405 /**
406 * Read one numeric attribute off a tag.
407 *
408 * Returns 0 for anything that is not a plain number — `width="50%"` and
409 * `width="auto"` are CSS-ish values whose pixel size we do not know, and
410 * scaling from them would invent a box rather than reserve one.
411 *
412 * @param string $tag The tag.
413 * @param string $name Attribute name.
414 */
415 private static function attr_int( string $tag, string $name ): int {
416 // The value must be ENTIRELY digits. Matching a leading run would read
417 // `width="50%"` as 50 and scale from a percentage as though it were
418 // pixels — inventing a box rather than declining to guess.
419 if ( ! preg_match( '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(?:"(\d+)"|\'(\d+)\'|(\d+)(?=[\s/>]))#i', $tag, $m ) ) {
420 return 0;
421 }
422 $value = '' !== ( $m[1] ?? '' ) ? $m[1] : ( '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' ) );
423 return (int) $value;
424 }
425
426 /**
427 * WordPress names resized files `<name>-WxH.<ext>` — when the suffix is
428 * present it IS the rendered size, resolvable with zero I/O (works for
429 * CDN-hosted copies too). Pure — unit-tested.
430 *
431 * @return int[]|null [width, height] or null.
432 */
433 public static function parse_size_suffix( string $src ): ?array {
434 $path = (string) preg_replace( '/[?#].*$/', '', $src );
435 if ( preg_match( '#-(\d{1,4})x(\d{1,4})\.(?:jpe?g|png|gif|webp|avif)$#i', $path, $m ) ) {
436 $w = (int) $m[1];
437 $h = (int) $m[2];
438 if ( $w > 0 && $h > 0 ) {
439 return array( $w, $h );
440 }
441 }
442 return null;
443 }
444
445 /**
446 * Intrinsic size of an image hosted on another domain.
447 *
448 * An image the site does not host is still an image whose dimensions
449 * decide whether the page jumps while it loads. Refusing to look them up
450 * was leaving real layout shift unfixed on any site that embeds media from
451 * a CDN, a sister site, or a shared asset host — and telling the owner to
452 * go and edit their content, which is not a fix a caching plugin should be
453 * proud of.
454 *
455 * The reason for the old refusal was sound but too broad: a page render
456 * must never block on somebody else's server. So this fetches only the
457 * first few KB — enough for the header of every format WordPress
458 * supports — with a short timeout, and caches the answer (successes AND
459 * failures) so a URL is fetched once rather than once per pageview.
460 *
461 * By default it runs only when something has already warmed the cache
462 * off-request (the preloader, a cron pass, WP-CLI). A visitor's request
463 * therefore never waits on it. A site that would rather pay the cost
464 * inline can opt in:
465 *
466 * add_filter( 'xspeed_lazy_remote_dimensions_inline', '__return_true' );
467 *
468 * and one that wants nothing fetched from other hosts at all can opt out:
469 *
470 * add_filter( 'xspeed_lazy_remote_dimensions', '__return_false' );
471 *
472 * @param string $src Absolute URL on another host.
473 * @return int[]|null [width, height] or null when it cannot be resolved.
474 */
475 private static function remote_dimensions( string $src ): ?array {
476 /**
477 * Whether to resolve dimensions for images on other hosts at all.
478 *
479 * @param bool $enabled Default true.
480 * @param string $src The image URL.
481 */
482 if ( ! apply_filters( 'xspeed_lazy_remote_dimensions', true, $src ) ) {
483 return null;
484 }
485
486 if ( ! function_exists( 'wp_remote_get' ) ) {
487 return null;
488 }
489
490 // Only http(s). A data: or blob: src has no server to ask.
491 if ( ! preg_match( '#^https?://#i', $src ) ) {
492 return null;
493 }
494
495 /**
496 * Whether a front-end request may perform the fetch itself.
497 *
498 * Off by default: the whole point of the cache is that a visitor
499 * never waits on another host. Warm passes (cron, preloader, CLI)
500 * set this true for themselves.
501 *
502 * @param bool $inline Default false.
503 */
504 $inline = (bool) apply_filters( 'xspeed_lazy_remote_dimensions_inline', self::$warming );
505 if ( ! $inline ) {
506 return null;
507 }
508
509 // 32KB covers the header of JPEG, PNG, GIF, WebP and AVIF. Range is a
510 // request, not a guarantee — a server that ignores it sends the whole
511 // file, which the timeout still bounds.
512 $resp = wp_remote_get(
513 $src,
514 array(
515 'timeout' => 5,
516 'headers' => array( 'Range' => 'bytes=0-32767' ),
517 'user-agent' => 'xSpeed/dimension-probe',
518 )
519 );
520 if ( is_wp_error( $resp ) ) {
521 return null;
522 }
523 $code = (int) wp_remote_retrieve_response_code( $resp );
524 if ( 200 !== $code && 206 !== $code ) {
525 return null;
526 }
527
528 $body = (string) wp_remote_retrieve_body( $resp );
529 if ( '' === $body ) {
530 return null;
531 }
532
533 // getimagesizefromstring reads the header out of the bytes we already
534 // have — no second request, no temp file.
535 $size = @getimagesizefromstring( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a truncated or non-image body must degrade to null, not warn.
536 if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
537 return array( (int) $size[0], (int) $size[1] );
538 }
539 return null;
540 }
541
542 /**
543 * Resolve dimensions from an image URL, cheapest first:
544 * 1. `-WxH` filename suffix (no I/O).
545 * 2. Intrinsic size of the local file when src is under uploads
546 * (getimagesize on the header — no remote fetches, ever).
547 * 3. Attachment lookup by URL (uploads-hosted src only).
548 * Results — including failures — are cached per URL in a bounded
549 * transient so each image pays the lookup once, not per pageview.
550 *
551 * @return int[]|null [width, height] or null.
552 */
553 private static function dimensions_for_src( string $src ): ?array {
554 $suffix = self::parse_size_suffix( $src );
555 if ( $suffix ) {
556 return $suffix;
557 }
558
559 if ( ! function_exists( 'wp_get_upload_dir' ) || ! function_exists( 'get_transient' ) ) {
560 return null;
561 }
562 $uploads = wp_get_upload_dir();
563 $baseurl = isset( $uploads['baseurl'] ) ? (string) $uploads['baseurl'] : '';
564 $basedir = isset( $uploads['basedir'] ) ? (string) $uploads['basedir'] : '';
565
566 // The cache is consulted BEFORE the local/remote split, so a remote
567 // image pays its lookup once for the life of the transient rather
568 // than once per page render.
569 if ( null === self::$src_dims_cache ) {
570 $stored = get_transient( 'xspeed_img_dims' );
571 self::$src_dims_cache = is_array( $stored ) ? $stored : array();
572 }
573 $key = md5( $src );
574 if ( array_key_exists( $key, self::$src_dims_cache ) ) {
575 $hit = self::$src_dims_cache[ $key ];
576 if ( is_array( $hit ) ) {
577 return $hit;
578 }
579 // A cached FAILURE, not a cached answer. A front-end render
580 // honours it — that is the whole point, one failed lookup must
581 // not cost a request on every pageview. A warm pass does NOT:
582 // it was asked to resolve these, nothing is waiting on it, and
583 // the usual reason for a failure is a moment of bad luck rather
584 // than an image that can never be measured.
585 //
586 // Without this, one slow response poisoned a URL for the life of
587 // the transient. It happened on a real site: 15 images cached as
588 // failures, and every later warm returned "resolved: 0" while the
589 // page kept shifting.
590 if ( ! self::$warming || ! self::failure_is_retryable( $hit ) ) {
591 return null;
592 }
593 }
594
595 $is_local = '' !== $baseurl && '' !== $basedir && 0 === strpos( $src, $baseurl );
596
597 $dims = null;
598
599 if ( $is_local ) {
600 $relative = (string) preg_replace( '/[?#].*$/', '', substr( $src, strlen( $baseurl ) ) );
601 if ( false === strpos( $relative, '..' ) ) {
602 $file = $basedir . $relative;
603 if ( is_file( $file ) ) {
604 $size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-image/corrupt file must degrade to null, not warn.
605 if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
606 $dims = array( (int) $size[0], (int) $size[1] );
607 }
608 }
609 }
610
611 // File not on disk (offloaded originals) — one DB lookup by URL.
612 if ( null === $dims && function_exists( 'attachment_url_to_postid' ) ) {
613 $id = (int) attachment_url_to_postid( $src );
614 if ( $id > 0 ) {
615 $dims = self::dimensions_for_attachment( $id );
616 }
617 }
618 } else {
619 $dims = self::remote_dimensions( $src );
620 }
621
622 // Cache success AND failure (0), bounded so the blob can't grow
623 // unbounded on media-heavy sites.
624 if ( count( self::$src_dims_cache ) >= 500 ) {
625 self::$src_dims_cache = array_slice( self::$src_dims_cache, 250, null, true );
626 }
627 // A resolved size is permanent — the file's intrinsic dimensions do
628 // not change under the same URL. A failure is a snapshot of one
629 // moment, so it is stored as a TIMESTAMP rather than a bare 0 and
630 // stops counting after a while. Storing both the same way is what let
631 // a transient blip look identical to "this can never be measured".
632 self::$src_dims_cache[ $key ] = null === $dims ? time() : $dims;
633 if ( function_exists( 'set_transient' ) ) {
634 set_transient( 'xspeed_img_dims', self::$src_dims_cache, DAY_IN_SECONDS );
635 }
636 return $dims;
637 }
638
639 /**
640 * @return int[]|null [width, height] or null
641 */
642 private static function dimensions_for_attachment( int $attachment_id ): ?array {
643 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
644 return null;
645 }
646 $meta = wp_get_attachment_metadata( $attachment_id );
647 if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
648 return null;
649 }
650 return array( (int) $meta['width'], (int) $meta['height'] );
651 }
652
653 private static function is_excluded( string $tag, array $opts ): bool {
654 $excluded = $opts['excluded_images'] ?? array();
655 if ( ! is_array( $excluded ) || empty( $excluded ) ) {
656 return false;
657 }
658 foreach ( $excluded as $pattern ) {
659 $pattern = (string) $pattern;
660 if ( '' === $pattern ) {
661 continue;
662 }
663 if ( false !== stripos( $tag, $pattern ) ) {
664 return true;
665 }
666 }
667 return false;
668 }
669
670 /**
671 * Replace <script>, <style>, <noscript>, <pre>, <code> blocks with
672 * placeholder tokens before tag rewriting. Returns [stubbed_html,
673 * stubs_map]. Restore via restore_safe_blocks().
674 *
675 * @return array{0: string, 1: array<string,string>}
676 */
677 private static function stub_safe_blocks( string $html ): array {
678 $stubs = array();
679 $re = '#<(script|style|noscript|pre|code)\b[^>]*>.*?</\1>#is';
680 $out = preg_replace_callback(
681 $re,
682 static function ( $m ) use ( &$stubs ) {
683 $key = '<!--XSPEED_LAZY_STUB_' . count( $stubs ) . '-->';
684 $stubs[ $key ] = $m[0];
685 return $key;
686 },
687 $html
688 );
689 return array( (string) $out, $stubs );
690 }
691
692 private static function restore_safe_blocks( string $html, array $stubs ): string {
693 if ( empty( $stubs ) ) {
694 return $html;
695 }
696 return strtr( $html, $stubs );
697 }
698
699 private static function opts(): array {
700 if ( null === self::$opts ) {
701 self::$opts = Settings_Manager::get( 'lazy' );
702 }
703 return self::$opts;
704 }
705
706 /**
707 * How long a failed lookup is trusted before a warm pass tries again.
708 *
709 * Long enough that a genuinely unmeasurable URL is not re-fetched on every
710 * crawl, short enough that an outage does not cost a day of layout shift.
711 */
712 private const FAILURE_RETRY_AFTER = 900; // 15 minutes.
713
714 /**
715 * Whether a stored failure is old enough to be worth retrying.
716 *
717 * Legacy entries were written as a bare `0` with no timestamp. Those are
718 * always retryable: they predate this distinction, and one extra request
719 * for each is a far better outcome than leaving a site permanently unable
720 * to resolve images it could resolve today.
721 *
722 * @param mixed $entry Stored cache value.
723 */
724 private static function failure_is_retryable( $entry ): bool {
725 if ( ! is_int( $entry ) || $entry <= 0 ) {
726 return true; // legacy `0`, or nonsense — retry.
727 }
728 return ( time() - $entry ) >= self::FAILURE_RETRY_AFTER;
729 }
730
731 /**
732 * Whether this URL's dimensions are already known (or known-unresolvable).
733 *
734 * Lets a caller skip URLs that would cost nothing to look up, so a bounded
735 * batch spends its budget on images it has not seen. Without this a capped
736 * collector re-picks the same first N images every pass — they are always
737 * in the same DOM order — and anything past the cap is never resolved at
738 * all, however many times the crawl runs.
739 *
740 * Reads the cache only; never fetches.
741 *
742 * @param string $src Absolute image URL.
743 */
744 public static function dimensions_known( string $src ): bool {
745 if ( ! function_exists( 'get_transient' ) ) {
746 return false;
747 }
748 if ( null === self::$src_dims_cache ) {
749 $stored = get_transient( 'xspeed_img_dims' );
750 self::$src_dims_cache = is_array( $stored ) ? $stored : array();
751 }
752 $key = md5( $src );
753 if ( ! array_key_exists( $key, self::$src_dims_cache ) ) {
754 return false;
755 }
756 $hit = self::$src_dims_cache[ $key ];
757 if ( is_array( $hit ) ) {
758 return true;
759 }
760 // A failure that has aged out is NOT known — reporting it as known
761 // would make the crawl skip the one URL that has become worth
762 // retrying.
763 return ! self::failure_is_retryable( $hit );
764 }
765
766 /**
767 * Resolve and cache dimensions for a batch of image URLs.
768 *
769 * Meant for anything running OFF a visitor's request — the preloader
770 * crawling the sitemap, a cron pass, `wp xspeed lazy warm-dimensions`.
771 * Once warmed, the front end serves the dimensions from cache, so the
772 * layout shift is fixed without a single visitor waiting on another host.
773 *
774 * @param string[] $urls Absolute image URLs.
775 * @return int How many were resolved.
776 */
777 public static function warm_dimensions( array $urls ): int {
778 $resolved = 0;
779 self::$warming = true;
780 try {
781 foreach ( array_unique( $urls ) as $url ) {
782 if ( ! is_string( $url ) || '' === $url ) {
783 continue;
784 }
785 if ( self::dimensions_for_src( $url ) ) {
786 $resolved++;
787 }
788 }
789 } finally {
790 // In a finally so a throw mid-batch cannot leave the flag set and
791 // silently turn every later front-end render into a fetcher.
792 self::$warming = false;
793 }
794 return $resolved;
795 }
796
797 /**
798 * Test-only: clear cached opts + counter between assertions.
799 */
800 public static function reset_state(): void {
801 self::$opts = null;
802 self::$image_counter = 0;
803 self::$src_dims_cache = null;
804 self::$facade_used = false;
805 self::$warming = false;
806 }
807 }
808