PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.0 All 28 releases
xspeed / includes / class-lazy-loader.php

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

1,404 lines 55.5 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 // A page builder's video block renders no <video> server-side, so
126 // the pass above sees nothing to rewrite. Note that such markup is
127 // here anyway, so the restorer ships and can defer the element the
128 // block's own script creates. (See detect_attribute_video().)
129 self::detect_attribute_video( $work );
130 }
131 // Self-hosted <video> facade — after the lazy pass for the same
132 // reason as the iframe facade above: the original element lands in
133 // <noscript> as the JS-less fallback, and that copy should carry
134 // preload="none" too. Same whole-element, tempered match so an
135 // unclosed <video> passes through rather than eating siblings.
136 if ( ! empty( $opts['video_facade'] ) ) {
137 $work = self::apply_pass(
138 $work,
139 '#(<video\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?video\b).)*)</video\s*>#is',
140 array( __CLASS__, 'rewrite_video_facade' )
141 );
142 }
143
144 return self::restore_safe_blocks( $work, $stubs );
145 }
146
147 /**
148 * Run one rewrite pass, keeping the input if PCRE bails.
149 *
150 * preg_replace_callback() returns null when it hits the backtrack or
151 * recursion limit — on a large page that would otherwise blank the
152 * whole document. Returning the untouched HTML costs the optimization
153 * for that request and nothing else.
154 *
155 * @param callable $callback Rewrite callback for one match.
156 */
157 private static function apply_pass( string $html, string $pattern, callable $callback ): string {
158 $result = preg_replace_callback( $pattern, $callback, $html );
159
160 return is_string( $result ) ? $result : $html;
161 }
162
163 private static function rewrite_img( array $m ): string {
164 $tag = $m[0];
165 $opts = self::opts();
166
167 // Explicit skip flag, or matches an exclusion pattern: opt OUT of
168 // LAZY-LOADING only. Dimension injection (CLS protection) still
169 // applies — excluding an above-the-fold hero/logo from lazy-load is
170 // exactly when you most want its width/height kept. Previously both
171 // of these returned early, silently stripping dimensions too.
172 // (FBS-82172 Bug 2)
173 //
174 // `fetchpriority="high"` joins that set: an image carrying it has been
175 // declared the LCP element by whoever rendered it — WordPress core, the
176 // theme, a page builder, or our own Resource_Hints_Processor. Lazy-
177 // loading it contradicts that declaration, because the tag would then
178 // tell the browser to fetch at top priority AND that it may defer the
179 // fetch indefinitely. Browsers resolve that in favour of the deferral,
180 // so the hero arrives late and any layout sized from it (a Kadence hero
181 // row, for example) reflows when it finally paints — the "sometimes
182 // broken, sometimes fine" symptom, because it depends on paint timing.
183 // Treat the hint as authoritative and keep the image eager. (#269)
184 $skip_lazy = false !== stripos( $tag, 'data-skip-lazy' )
185 || false !== stripos( $tag, 'data-no-lazy' )
186 || self::has_high_fetchpriority( $tag )
187 || self::is_excluded( $tag, $opts );
188
189 if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) {
190 // An EXCLUDED image is one the user marked as above-the-fold (a
191 // hero/logo) — the opposite of lazy. WordPress core adds
192 // `loading="lazy"` to images by default (since 5.5), so merely
193 // *skipping* our lazy pass would leave core's lazy attribute on
194 // the LCP hero and tank LCP. Actively make it eager +
195 // high-priority so an excluded hero loads immediately.
196 $tag = self::set_attr( $tag, 'loading', 'eager' );
197 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
198 $tag = self::set_attr( $tag, 'decoding', 'async', true );
199 } elseif ( ! empty( $opts['lazy_images'] ) ) {
200 // Above-the-fold skip: first N images get loading="eager"
201 // instead of "lazy" so the LCP image isn't deferred. Only
202 // non-excluded images consume the budget.
203 self::$image_counter++;
204 $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
205 $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
206 $tag = self::set_attr( $tag, 'decoding', 'async', true );
207 // The eager hero should also drop any core `loading="lazy"`; the
208 // set_attr above already overrode it. Give the first eager image
209 // high fetch priority so it wins the LCP race.
210 if ( $is_above_fold ) {
211 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
212 }
213 }
214
215 if ( ! empty( $opts['add_missing_dimensions'] ) ) {
216 $tag = self::ensure_dimensions( $tag );
217 }
218
219 return $tag;
220 }
221
222 private static function rewrite_iframe( array $m ): string {
223 $tag = $m[0];
224 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
225 return $tag;
226 }
227 if ( self::is_excluded( $tag, self::opts() ) ) {
228 return $tag;
229 }
230 return self::set_attr( $tag, 'loading', 'lazy' );
231 }
232
233 /**
234 * Swap a recognised video embed for a click-to-play facade.
235 *
236 * Passes the element through untouched unless it is a provider we can
237 * build a facade for — an unknown iframe (a map, a form, a dashboard)
238 * must never be replaced by a play button.
239 *
240 * $m[0] is the WHOLE element (`<iframe …>…</iframe>`); $m[1] is just
241 * the opening tag. Attributes are read from the opening tag, but what
242 * goes into the <noscript> fallback — and what is returned on every
243 * bail-out path — is the whole element, so the closing tag is never
244 * left stranded outside it.
245 */
246 private static function rewrite_iframe_facade( array $m ): string {
247 $element = $m[0];
248 $tag = $m[1];
249
250 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
251 return $element;
252 }
253 if ( self::is_excluded( $tag, self::opts() ) ) {
254 return $element;
255 }
256
257 if ( ! preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $src_m ) ) {
258 return $element;
259 }
260 $src = $src_m[2];
261
262 $embed = Video_Facade::parse_embed( $src );
263 if ( null === $embed ) {
264 return $element;
265 }
266
267 $title = '';
268 if ( preg_match( '#\btitle\s*=\s*(["\'])(.*?)\1#i', $tag, $title_m ) ) {
269 $title = $title_m[2];
270 }
271
272 self::$facade_used = true;
273
274 return Video_Facade::render( $element, $embed, $src, $title );
275 }
276
277 /** @var bool True once a facade has been rendered on this page. */
278 private static $facade_used = false;
279
280 /**
281 * True when this render produced at least one facade — the module uses
282 * it to decide whether the click handler is worth printing at all.
283 */
284 public static function facade_used(): bool {
285 return self::$facade_used;
286 }
287
288 private static function rewrite_video( array $m ): string {
289 $tag = $m[0];
290 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
291 return $tag;
292 }
293 /*
294 * An autoplaying video is the one case preload="none" cannot help:
295 * browsers fetch an autoplay source regardless of preload, because
296 * the author asked for it to start on its own. Setting the attribute
297 * would only make the markup lie about what happens.
298 *
299 * But "starts on its own" does not mean "must download before the
300 * visitor has scrolled anywhere near it". A page of nine autoplay
301 * demo clips pulled 44 MB on load and held the browser's loading
302 * indicator open for 33 s, while none of them were on screen.
303 *
304 * So defer the SOURCE and restore it when the element reaches the
305 * viewport, which is the first moment autoplay is meant to be
306 * visible anyway. The author's choice is honoured — the video still
307 * plays by itself — it simply costs nothing until it can be seen.
308 */
309 if ( preg_match( '#\sautoplay(?=[\s/>=])#i', $tag ) ) {
310 return self::defer_autoplay_source( $tag );
311 }
312 // HTML5 `<video>` doesn't support loading=lazy yet (Chromium
313 // won't add it before there's broad support). What we CAN do
314 // is set preload="none" so the browser doesn't pre-fetch the
315 // video bytes until play is requested — that's the actual win
316 // users want from "lazy-load videos".
317 //
318 // OVERRIDE an existing value rather than bailing on it: players
319 // that ship preload="auto" or "metadata" (Elementor's video
320 // widget, most block themes) are exactly the case this setting
321 // exists for, and skipping them made it a no-op right where it
322 // mattered. (#309 — a 924KB MP4 transferred in full on every run
323 // with this setting on.)
324 return self::set_attr( $tag, 'preload', 'none' );
325 }
326
327 /**
328 * Swap a self-hosted <video> for the click-to-play facade.
329 *
330 * The easy case of the facade, not the hard one: no third-party player
331 * to defer, and the element usually already carries a real poster
332 * frame. Bails out — element returned untouched — whenever the facade
333 * would be worse than the video:
334 *
335 * - `autoplay` is a deliberate author choice (a hero background); a
336 * play button in its place changes the page, not just its weight.
337 * - no `poster` means the facade renders as a blank black box, which
338 * is worse than the preload="none" the lazy pass already applied.
339 * - no resolvable source means there is nothing to play on click.
340 *
341 * $m[0] is the whole element, $m[1] the opening tag — same contract as
342 * rewrite_iframe_facade() above, and the same rule: every bail-out
343 * path returns the WHOLE element so the closing tag is never stranded.
344 */
345 private static function rewrite_video_facade( array $m ): string {
346 $element = $m[0];
347 $tag = $m[1];
348
349 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
350 return $element;
351 }
352 if ( preg_match( '#\sautoplay(?=[\s/>=])#i', $tag ) ) {
353 return $element;
354 }
355 if ( self::is_excluded( $tag, self::opts() ) ) {
356 return $element;
357 }
358
359 if ( ! preg_match( '#\bposter\s*=\s*(["\'])(.*?)\1#i', $tag, $poster_m ) || '' === trim( $poster_m[2] ) ) {
360 return $element;
361 }
362 $poster = $poster_m[2];
363
364 // Source: the src attribute, else the first <source src="…"> child.
365 $src = '';
366 if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $src_m ) ) {
367 $src = $src_m[2];
368 } elseif ( preg_match( '#<source\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1#i', $m[2], $src_m ) ) {
369 $src = $src_m[2];
370 }
371 if ( '' === trim( $src ) ) {
372 return $element;
373 }
374
375 $title = '';
376 if ( preg_match( '#\btitle\s*=\s*(["\'])(.*?)\1#i', $tag, $title_m ) ) {
377 $title = $title_m[2];
378 }
379
380 self::$facade_used = true;
381
382 return Video_Facade::render_native( $element, $src, $poster, $title );
383 }
384
385 /**
386 * Add an attribute to an opening tag if it isn't already present.
387 * Pass $only_if_missing=false to override an existing value (e.g.
388 * flipping loading="lazy" → "eager" on the first image).
389 */
390 /**
391 * Hold an autoplay video's bytes until the element reaches the viewport.
392 *
393 * `preload="none"` is ignored for autoplay, so the only way to stop the
394 * download is to take the source away and give it back later. We move
395 * `src` to `data-xspeed-src` and drop `autoplay` — a `<video>` with no
396 * resolvable source fetches nothing — then the script below restores
397 * both when the element scrolls into view.
398 *
399 * Restoring `autoplay` rather than calling play() matters: play() from
400 * a non-user gesture is refused unless the video is muted, and returns
401 * a promise whose rejection most callers never handle. Setting the
402 * attribute lets the browser apply its own autoplay policy exactly as
403 * it would have on load.
404 *
405 * `<source>` children are handled too, since a video with multiple
406 * formats carries no `src` of its own.
407 *
408 * Marked with a data attribute rather than a class so a theme's CSS
409 * cannot accidentally select — or style away — the deferred state.
410 */
411 private static function defer_autoplay_source( string $tag ): string {
412 // Already processed (a second pass, or another plugin got there).
413 if ( false !== stripos( $tag, 'data-xspeed-src' ) ) {
414 return $tag;
415 }
416
417 $deferred = false;
418
419 // The element's own src, when it has one.
420 if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $m ) && '' !== trim( $m[2] ) ) {
421 $tag = (string) preg_replace(
422 '#\bsrc\s*=\s*(["\'])(.*?)\1#i',
423 'data-xspeed-src="' . esc_attr( $m[2] ) . '"',
424 $tag,
425 1
426 );
427 $deferred = true;
428 }
429
430 if ( ! $deferred ) {
431 // No src of its own — the <source> children carry it, and those
432 // are outside this opening tag. Mark the element so the script
433 // knows to move them, and let it do the work in the DOM where
434 // the children are actually reachable.
435 $tag = self::set_attr( $tag, 'data-xspeed-defer-sources', '1' );
436 }
437
438 // Without this the browser starts fetching the moment a source is
439 // restored, which is what we want — but it must not autoplay before
440 // then, and it must not report itself as autoplaying meanwhile.
441 $tag = (string) preg_replace( '#\sautoplay(?=[\s/>=])#i', ' data-xspeed-autoplay="1"', $tag, 1 );
442
443 // preload="none" as well: belt and braces for the window between
444 // parse and the observer attaching.
445 $tag = self::set_attr( $tag, 'preload', 'none' );
446
447 self::$deferred_autoplay = true;
448
449 return $tag;
450 }
451
452 /**
453 * Did this response defer at least one autoplay video? Gates the script
454 * so a page with no such video ships no extra bytes.
455 *
456 * @var bool
457 */
458 private static $deferred_autoplay = false;
459
460 /** Whether the viewport script needs to be injected into this response. */
461 public static function needs_autoplay_script(): bool {
462 return self::$deferred_autoplay || self::$has_deferred_video_markup;
463 }
464
465 /**
466 * Page-builder video blocks that render NO <video> tag server-side.
467 *
468 * Essential Blocks' advanced-video, and widgets shaped like it, ship a
469 * plain <div> carrying the file URL in an attribute and let their own JS
470 * build the player after load. The PHP pass cannot rewrite what is not
471 * there, so a page of nine such blocks was completely untouched — which
472 * is exactly the 44 MB case this feature exists for.
473 *
474 * We deliberately do NOT rewrite those attributes. They belong to
475 * another plugin, whose script reads them on init; renaming one is how
476 * you get a player that silently never appears. Instead we note that
477 * such markup is present so the restorer ships, and let its
478 * MutationObserver catch the <video> the block creates — at which point
479 * it is an ordinary element we can defer like any other.
480 *
481 * @var bool
482 */
483 private static $has_deferred_video_markup = false;
484
485 /**
486 * Does this HTML carry a video URL in an attribute rather than a tag?
487 *
488 * Matched on the URL, not on any one plugin's attribute name: `data-url`
489 * is Essential Blocks, but `data-src`, `data-video-url` and others are
490 * equally common, and a rule keyed to one vendor would miss the rest.
491 */
492 private static function detect_attribute_video( string $html ): void {
493 if ( self::$has_deferred_video_markup ) {
494 return;
495 }
496 if ( preg_match( '#\sdata-[\w-]+\s*=\s*(["\'])[^"\']*\.(?:mp4|webm|m4v|ogv|mov)(?:\?[^"\']*)?\1#i', $html ) ) {
497 self::$has_deferred_video_markup = true;
498 }
499 }
500
501 /**
502 * Restore the source when the video reaches the viewport.
503 *
504 * Dependency-free and tiny, matching Video_Facade::facade_script(). The
505 * rootMargin starts the fetch slightly before the element is visible so
506 * playback begins without a visible stall.
507 */
508 public static function autoplay_script(): string {
509 return <<<'JS'
510 (function(){
511 var S='video[data-xspeed-src],video[data-xspeed-defer-sources]';
512
513 /*
514 * Intercept the ASSIGNMENT, because observing the DOM is always too late.
515 *
516 * Measured on a live page: a builder's video player creates nine elements
517 * and sets `src` BEFORE inserting them, so a MutationObserver watching for
518 * insertions saw zero of them — and the browser had already begun fetching
519 * by the time any observer could run. The order is: setAttribute('src'),
520 * then setAttribute('preload','auto'), then insert. Only the first of those
521 * matters, and it happens off-DOM.
522 *
523 * So wrap the two ways a source can be set on a media element and hold the
524 * value instead of applying it. Nothing else can start a download: a
525 * <video> with no resolvable source fetches nothing. The value is stored on
526 * the element and handed back by go() when it reaches the viewport.
527 *
528 * Scoped to <video> only. <audio> is small and usually deliberate, and
529 * touching it would change behaviour nobody complained about.
530 */
531 try{
532 var VP=window.HTMLMediaElement&&HTMLMediaElement.prototype;
533 var SD=VP&&Object.getOwnPropertyDescriptor(VP,'src');
534 var hold=function(el,val){
535 if(el.tagName!=='VIDEO')return false;
536 if(el.getAttribute('data-xspeed-loaded'))return false; // released: let it through
537 if(!val)return false;
538 el.setAttribute('data-xspeed-src',String(val));
539 el.setAttribute('data-xspeed-adopted','1');
540 return true;
541 };
542 if(SD&&SD.set){
543 Object.defineProperty(VP,'src',{configurable:true,enumerable:SD.enumerable,
544 get:function(){return SD.get.call(this);},
545 set:function(v){if(hold(this,v))return;return SD.set.call(this,v);}});
546 }
547 var SA=Element.prototype.setAttribute;
548 Element.prototype.setAttribute=function(n,v){
549 if(n==='src'&&hold(this,v))return;
550 // An eager preload on a held video would re-arm the fetch the moment a
551 // source comes back; keep it at none until we release it deliberately.
552 if(n==='preload'&&this.tagName==='VIDEO'&&this.getAttribute('data-xspeed-src')&&v!=='none')
553 return SA.call(this,'preload','none');
554 return SA.call(this,n,v);
555 };
556 }catch(e){}
557 function go(v){
558 if(v.getAttribute('data-xspeed-loaded'))return;
559 v.setAttribute('data-xspeed-loaded','1');
560 var s=v.getAttribute('data-xspeed-src');
561 if(s){v.setAttribute('src',s);v.removeAttribute('data-xspeed-src');}
562 if(v.getAttribute('data-xspeed-defer-sources')){
563 var c=v.querySelectorAll('source[data-xspeed-src]');
564 for(var i=0;i<c.length;i++){c[i].setAttribute('src',c[i].getAttribute('data-xspeed-src'));c[i].removeAttribute('data-xspeed-src');}
565 v.removeAttribute('data-xspeed-defer-sources');
566 }
567
568 if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');}
569 v.removeAttribute('preload');
570 // load() picks up the sources we just restored; without it a <video>
571 // that has already failed to resolve a source will not retry.
572 if(v.load)v.load();
573 }
574 // A multi-format <video> carries no src of its own — the <source> children
575 // do, and those sit outside the opening tag PHP rewrote. Strip them here,
576 // as early as this script runs, then restore on intersect like the rest.
577 function strip(){
578 var d=document.querySelectorAll('video[data-xspeed-defer-sources]');
579 for(var i=0;i<d.length;i++){
580 if(d[i].getAttribute('data-xspeed-loaded'))continue;
581 var c=d[i].querySelectorAll('source[src]');
582 for(var j=0;j<c.length;j++){c[j].setAttribute('data-xspeed-src',c[j].getAttribute('src'));c[j].removeAttribute('src');}
583 if(c.length&&d[i].load)d[i].load();
584 }
585 }
586 // A page-builder block builds its <video> after load, so PHP never saw it
587 // and it arrives with a live src and autoplay already set. Defer it here,
588 // the same way the server would have, BEFORE the browser gets far into
589 // fetching it. Only autoplay videos: anything else is already covered by
590 // preload="none" and taking a source from a user-controlled player would
591 // break its own play button.
592 function adopt(){
593 // Any JS-built <video> that would fetch on sight — NOT just autoplay.
594 // Measured on a live page: a builder's video block creates nine elements
595 // with autoplay=false and preload="auto", so an autoplay-only selector
596 // skipped every one of them and 40 MB still downloaded. preload="auto" is
597 // the same eager-fetch instruction by another name, and the server pass
598 // would have rewritten it to "none" had the element existed in the HTML.
599 var a=document.querySelectorAll('video[autoplay]:not([data-xspeed-loaded]):not([data-xspeed-adopted]),video[preload="auto"]:not([data-xspeed-loaded]):not([data-xspeed-adopted]),video[preload="metadata"]:not([data-xspeed-loaded]):not([data-xspeed-adopted])');
600 for(var i=0;i<a.length;i++){
601 var v=a[i];
602 v.setAttribute('data-xspeed-adopted','1');
603 var auto=v.hasAttribute('autoplay');
604 var s=v.getAttribute('src');
605 if(s){v.setAttribute('data-xspeed-src',s);v.removeAttribute('src');}
606 var c=v.querySelectorAll('source[src]');
607 for(var j=0;j<c.length;j++){c[j].setAttribute('data-xspeed-src',c[j].getAttribute('src'));c[j].removeAttribute('src');}
608 if(c.length)v.setAttribute('data-xspeed-defer-sources','1');
609 // Only remember autoplay for the ones that actually had it — restoring it
610 // on a video the author left click-to-play would start playback nobody
611 // asked for.
612 if(auto){v.removeAttribute('autoplay');v.setAttribute('data-xspeed-autoplay','1');}
613 v.setAttribute('preload','none');
614 if(v.load)v.load();
615 }
616 }
617 function scan(){
618 strip();
619 adopt();
620 var v=document.querySelectorAll(S);
621 if(!('IntersectionObserver'in window)){for(var i=0;i<v.length;i++)go(v[i]);return;}
622 var o=new IntersectionObserver(function(es){
623 for(var i=0;i<es.length;i++){if(es[i].isIntersecting){go(es[i].target);o.unobserve(es[i].target);}}
624 },{rootMargin:'200px'});
625 for(var j=0;j<v.length;j++)o.observe(v[j]);
626 }
627 if(document.readyState!=='loading')scan();else document.addEventListener('DOMContentLoaded',scan);
628 // Players that build their <video> after load (page-builder video blocks)
629 // must be caught the INSTANT the element lands. A debounce loses the race:
630 // the browser begins fetching as soon as a src is set, so by the time a
631 // timer fires the bytes are already committed. adopt() is idempotent and
632 // cheap (one guarded querySelectorAll), so run it synchronously on every
633 // mutation and only debounce the fuller scan that attaches observers.
634 if(window.MutationObserver){
635 var t;
636 new MutationObserver(function(){
637 adopt();
638 clearTimeout(t);t=setTimeout(scan,200);
639 }).observe(document.documentElement,{childList:true,subtree:true});
640 }
641 })();
642 JS;
643 }
644
645 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
646 // Lookbehind, not `\b`: writing `width` onto a tag carrying
647 // `data-width="800"` matched the DATA attribute and rewrote it to the
648 // file's intrinsic size — corrupting a slider's own configuration and
649 // leaving the tag with no real width at all. (#333 review round 3)
650 $pattern = '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
651 if ( preg_match( $pattern, $tag ) ) {
652 if ( $only_if_missing ) {
653 return $tag;
654 }
655 return (string) preg_replace( $pattern, $name . '="' . $value . '"', $tag, 1 );
656 }
657 // Inject before the closing > (preserving self-closing `/>` if present).
658 if ( preg_match( '#(/?>)$#', $tag, $m ) ) {
659 $close = $m[1];
660 return substr( $tag, 0, -strlen( $close ) ) . ' ' . $name . '="' . $value . '"' . $close;
661 }
662 return $tag;
663 }
664
665 /**
666 * Attempt to fill in missing width / height from either an attached
667 * media library record (when class="wp-image-N") or from the local
668 * filesystem when src points at the uploads dir. Skip when we can't
669 * resolve cheaply — never block the request on a remote getimagesize.
670 */
671 private static function ensure_dimensions( string $tag ): string {
672 // `\b` sits between `-` and `w`, so a bare `\bwidth=` also matched
673 // `data-width=` — a slider's own metadata, not a rendered dimension.
674 // The tag then looked half-sized: apply_dimensions() derived the other
675 // dimension from the ratio and wrote ONLY that, so a tag carrying
676 // `data-width="800"` came out with `height="533"` and no width and
677 // laid out at 41x30. Harmless while the URL never resolved; this
678 // branch made it resolve, which is what exposed it. Half a pair is
679 // worse than none, as the docblock below already says.
680 // (#333 review round 3, issue 2)
681 $has_w = (bool) preg_match( '#(?<![-\w])width\s*=#i', $tag );
682 $has_h = (bool) preg_match( '#(?<![-\w])height\s*=#i', $tag );
683 if ( $has_w && $has_h ) {
684 return $tag;
685 }
686
687 // Try wp-image-<id> class first (cheapest path; one DB-cached
688 // get_post_meta call).
689 if ( preg_match( '#\bclass\s*=\s*["\']([^"\']*)["\']#i', $tag, $cm ) && preg_match( '#wp-image-(\d+)#i', $cm[1], $idm ) ) {
690 $dims = self::dimensions_for_attachment( (int) $idm[1] );
691 if ( $dims ) {
692 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
693 }
694 }
695
696 // No wp-image-N class — page-builder markup (Essential Blocks and
697 // friends) never emits it, which is why the setting silently failed
698 // on those images (issue #37). Resolve from the src instead, but only
699 // when the tag doesn't already tell us it renders at some other size:
700 // stamping the intrinsic file size onto a responsive or CSS-sized
701 // image would CREATE the layout shift this feature exists to remove.
702 if ( ! self::has_constrained_render( $tag ) ) {
703 $url = self::resolvable_image_url( $tag );
704 if ( '' !== $url ) {
705 $dims = self::dimensions_for_src( $url );
706 if ( $dims ) {
707 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
708 }
709 }
710 }
711
712 // Couldn't resolve. Leave the tag alone — better no dimensions
713 // than wrong ones.
714 return $tag;
715 }
716
717 /**
718 * The URL to measure an image by: its real `src`, or the lazy-loading
719 * attribute holding the URL when `src` is absent or a placeholder.
720 *
721 * Page-builder sliders (Essential Blocks among them) ship the image with
722 * NO `src` at all — the URL lives in `data-lazy`, and their own JS moves
723 * it across at runtime. Resolving only from `src` left every one of those
724 * images without dimensions (issue #328, the miss that #37 did not cover:
725 * that one was about the missing `wp-image-N` class, this one is about the
726 * URL not being in `src` in the first place).
727 *
728 * A placeholder `src` — a data: URI or the 1x1 spacer GIF these libraries
729 * use — is treated as absent: measuring it would stamp the spacer's size
730 * onto the tag and CREATE a layout shift.
731 *
732 * Note the explicit `(?<![-\w])src` boundary. `\bsrc=` also matches the
733 * tail of `data-src=` and `data-lazy-src=` (a hyphen is a non-word
734 * character, so `\b` sits between `-` and `s`), which is why those two
735 * attributes happened to work before this method existed while `data-lazy`
736 * and `data-original` did not. Relying on that accident meant the URL a
737 * tag was measured by depended on how its attribute was spelled.
738 *
739 * Pure — unit-tested — EXCEPT when `$may_measure` is true and every
740 * candidate was refused by name, which is the one branch that touches the
741 * filesystem. Callers that are themselves arranging a measurement pass
742 * false; see the note at that branch.
743 *
744 * @param string $tag The <img> tag.
745 * @param bool $may_measure Whether a name-refused URL may be settled by
746 * reading the file. False for the warm-up
747 * collector, which would otherwise deadlock.
748 */
749 public static function resolvable_image_url( string $tag, bool $may_measure = true ): string {
750 $src = '';
751 $named_out = '';
752 if ( preg_match( '#(?<![-\w])src\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
753 $src = trim( $m[1] );
754 if ( '' !== $src && ! self::is_placeholder_src( $src ) ) {
755 return $src;
756 }
757 }
758
759 foreach ( array( 'data-lazy', 'data-src', 'data-lazy-src', 'data-original' ) as $attr ) {
760 // Anchor with a negative lookbehind, not `\b` and not
761 // whitespace. `\b` sits between `-` and `d`, so a bare
762 // `\bdata-src=` also matched the TAIL of `x-data-src=` and took
763 // the wrong image's URL — worse than no size, because it reserves
764 // a wrongly shaped box and CAUSES the shift.
765 //
766 // Requiring whitespace instead was my first fix and it was wrong:
767 // attributes are not always separated by one (`alt="31"srcset=`
768 // is valid), so that anchor silently stopped matching and handed
769 // back a size for a tag the guard should have skipped. The
770 // lookbehind rejects the same prefixed decoys without depending on
771 // spacing, and is what `src` already uses two methods below.
772 // (#333 review rounds 2 and 3, issue 1)
773 if ( preg_match( '#(?<![-\w])' . preg_quote( $attr, '#' ) . '\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
774 $url = trim( $m[1] );
775 if ( '' === $url ) {
776 continue;
777 }
778 if ( ! self::is_placeholder_src( $url ) ) {
779 return $url;
780 }
781 // Refused on its NAME. Remember it — if nothing else in the
782 // tag resolves, the file itself gets the final say below.
783 if ( '' === $named_out ) {
784 $named_out = $url;
785 }
786 }
787 }
788
789 // Every candidate was refused on its NAME alone. A name is a guess;
790 // the file is the fact. Someone who uploads a photograph called
791 // `placeholder.jpg` — an entirely ordinary thing to find in a media
792 // library — got no dimensions at all, and neither did any of the
793 // copies WordPress generates from it, so the layout shift this
794 // feature removes came straight back for those images with nothing on
795 // screen to explain why. (#333 review round 2, issue 1)
796 //
797 // Only reached when nothing else in the tag resolved, so the cost is a
798 // lookup that was about to be skipped entirely, never an extra one.
799 // A genuine stand-in fails this test on its own merits: a data: URI
800 // never gets here, and a 1x1 spacer measures 1x1.
801 //
802 // The lazy attribute is preferred over `src`, matching the order
803 // above: when a tag carries both, the lazy one names the real image
804 // and `src` holds the stand-in.
805 // The warm-up collector passes false here, and must. Deciding this by
806 // MEASURING is circular for the caller whose whole job is to arrange
807 // the measurement: remote lookups are gated off until `$warming` is
808 // true, `$warming` only becomes true inside warm_dimensions(), and
809 // warm_dimensions() is never reached because this returned ''. A
810 // remote `placeholder.jpg` — a real photograph on a CDN — was warmable
811 // before this branch and stopped being, with a failure cached against
812 // it for good measure. The collector takes the URL the tag offers and
813 // lets warm_dimensions() be the thing that decides.
814 // (#333 review round 3, issue 3)
815 if ( ! $may_measure ) {
816 return '' !== $named_out ? $named_out : $src;
817 }
818
819 foreach ( array( $named_out, $src ) as $candidate ) {
820 if ( '' !== $candidate && self::is_real_image( $candidate ) ) {
821 return $candidate;
822 }
823 }
824
825 return '';
826 }
827
828 /**
829 * Does this URL resolve to something too big to be a lazy-load stand-in?
830 *
831 * The stand-ins this guards against are 1x1 spacers and inline data: URIs.
832 * Anything with real extent is a real image, whatever it is called — which
833 * is what lets a photograph named `placeholder.jpg` keep its dimensions
834 * while `spacer.gif` still loses them.
835 *
836 * Deliberately conservative: an unresolvable URL returns false, so the
837 * name-based verdict stands and the tag is left alone. Better no
838 * dimensions than wrong ones. Uses the same resolver (and therefore the
839 * same cache) as the normal path, so this costs no extra lookup.
840 */
841 private static function is_real_image( string $src ): bool {
842 $dims = self::dimensions_for_src( $src );
843 if ( ! is_array( $dims ) ) {
844 return false;
845 }
846 // Indexed [ width, height ] — the shape apply_dimensions() consumes.
847 $w = isset( $dims[0] ) ? (int) $dims[0] : 0;
848 $h = isset( $dims[1] ) ? (int) $dims[1] : 0;
849
850 // A few pixels either way is still a spacer — some libraries ship a
851 // 2x2 or 4x4 rather than a true 1x1. Anything above that has extent a
852 // stand-in does not.
853 return $w > 4 && $h > 4;
854 }
855
856 /**
857 * True for the stand-in a lazy-loader parks in `src` until its JS swaps
858 * the real URL in: an inline data: URI, or a `spacer`/`blank`/`placeholder`
859 * asset. Measuring one of these would stamp the spacer's dimensions onto
860 * the tag. Pure — unit-tested.
861 *
862 * Matched on the WHOLE filename stem, not a word inside it. A word-boundary
863 * search anywhere in the last segment caught every real image whose name
864 * merely contains one of these ordinary words — `blank-space-cover.png`,
865 * `placeholder-portrait.png`, `spacer-hero-banner.jpg` — and silently
866 * stopped sizing them, which brings back the very layout shift this
867 * feature exists to prevent, with nothing on screen to explain it
868 * (#333 review, issue 1).
869 *
870 * A real stand-in is named for what it is and nothing else: `blank.gif`,
871 * `spacer.png`, `lazy-loader.svg`, optionally with a dimension or version
872 * suffix (`blank-1x1.gif`, `spacer@2x.png`). A descriptive tail is what
873 * separates a photograph from a spacer, so the tail is what decides.
874 */
875 public static function is_placeholder_src( string $src ): bool {
876 if ( 0 === stripos( $src, 'data:' ) ) {
877 return true;
878 }
879
880 // Last path segment, without the query string or fragment —
881 // `?v=placeholder` is a cache-buster on a real image, not a name.
882 // Plain string work on purpose: this method is pure and unit-tested
883 // with no WordPress loaded, so wp_parse_url() is not available.
884 $path = strtok( $src, '?#' );
885 if ( ! is_string( $path ) || '' === $path ) {
886 $path = $src;
887 }
888 $name = strtolower( basename( $path ) );
889
890 // Drop the extension, then any trailing dimension/DPR/version marker.
891 $stem = preg_replace( '#\.[a-z0-9]+$#', '', $name );
892 $stem = (string) preg_replace( '#[-_@]?(?:\d+x\d+|\d+x|x\d+|v\d+|\d+)$#', '', (string) $stem );
893 $stem = trim( $stem, '-_.' );
894
895 return 1 === preg_match( '#^(?:spacer|blank|placeholder|lazy-?loader|transparent|pixel|dummy)$#', $stem );
896 }
897
898 /**
899 * True when the tag already declares itself the LCP image via
900 * `fetchpriority="high"`.
901 *
902 * Only "high" counts. `fetchpriority="low"` and `="auto"` say the opposite
903 * (or nothing), and an image marked low-priority is a perfectly good
904 * lazy-load candidate. Pure — unit-tested.
905 */
906 public static function has_high_fetchpriority( string $tag ): bool {
907 return 1 === preg_match( '#\bfetchpriority\s*=\s*["\']?high\b#i', $tag );
908 }
909
910 /**
911 * True when the tag says it renders at a size other than the file's
912 * intrinsic one — a `srcset`/`sizes` pair (the browser picks a
913 * candidate) or an inline width/height style.
914 *
915 * Only guards the src-suffix fallback. The `wp-image-N` path stays
916 * unguarded: attachment metadata is authoritative, and WordPress'
917 * own `wp_filter_content_tags()` adds dimensions to responsive
918 * images the same way. Pure — unit-tested.
919 */
920 public static function has_constrained_render( string $tag ): bool {
921 // `\b` sits between `-` and `s`, so a bare \bsrcset also matched
922 // `data-srcset` — a lazy attribute the browser has NOT applied yet.
923 // That made an unset attribute suppress dimensions on exactly the
924 // slider images this feature exists to size, for the same
925 // accidental-text-match reason the URL lookup moved away from
926 // (#333 review, issue 3).
927 //
928 // The anchor is a negative lookbehind rather than "start or
929 // whitespace": HTML does not require a space between attributes, so
930 // `alt="31"srcset="..."` slipped past a whitespace anchor and this
931 // guard stopped firing — the tag then got the file's intrinsic size
932 // stamped on it while the browser rendered a differently-shaped
933 // srcset candidate. (#333 review round 3, issue 1)
934 if ( preg_match( '#(?<![-\w])(?:srcset|sizes)\s*=#i', $tag ) ) {
935 return true;
936 }
937 if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
938 // width/height in the inline style wins over the attribute, so
939 // the file's intrinsic size would disagree with the layout.
940 return 1 === preg_match( '#(?:^|;)\s*(?:max-)?(?:width|height)\s*:#i', $m[1] );
941 }
942 return false;
943 }
944
945 /** @param int[] $dims [width, height]. */
946 private static function apply_dimensions( string $tag, array $dims, bool $has_w, bool $has_h ): string {
947 // One dimension already present: derive the other from the file's
948 // real aspect ratio rather than stamping its intrinsic size.
949 //
950 // A tag that says width="300" on a 1200x800 file renders 300x200. If
951 // we wrote height="800" the browser would reserve a box two and a
952 // half times too tall, then snap when the image painted — CREATING
953 // the shift this feature exists to remove. Scaling keeps the reserved
954 // box the shape the image will actually be.
955 if ( $has_w !== $has_h ) {
956 if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
957 return $tag;
958 }
959 $from = $has_w ? 'width' : 'height';
960 $declared = self::attr_int( $tag, $from );
961 // A declared value we cannot read in pixels (`50%`, `auto`) means
962 // we do not know the rendered size, so there is no ratio to scale
963 // from. Stamping the intrinsic size here is exactly the bug this
964 // branch exists to avoid, so the tag is left alone.
965 if ( $declared <= 0 ) {
966 return $tag;
967 }
968 if ( $has_w ) {
969 $height = (int) round( $dims[1] * $declared / $dims[0] );
970 return $height > 0 ? self::set_attr( $tag, 'height', (string) $height ) : $tag;
971 }
972 $width = (int) round( $dims[0] * $declared / $dims[1] );
973 return $width > 0 ? self::set_attr( $tag, 'width', (string) $width ) : $tag;
974 }
975
976 // A header that reported 0 for either side is not a measurement. Half
977 // a dimension pair is worse than none: the browser reserves a box of
978 // the wrong shape and still shifts when the real image lands.
979 if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
980 return $tag;
981 }
982
983 if ( ! $has_w ) {
984 $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
985 }
986 if ( ! $has_h ) {
987 $tag = self::set_attr( $tag, 'height', (string) $dims[1] );
988 }
989 return $tag;
990 }
991
992 /**
993 * Read one numeric attribute off a tag.
994 *
995 * Returns 0 for anything that is not a plain number — `width="50%"` and
996 * `width="auto"` are CSS-ish values whose pixel size we do not know, and
997 * scaling from them would invent a box rather than reserve one.
998 *
999 * @param string $tag The tag.
1000 * @param string $name Attribute name.
1001 */
1002 private static function attr_int( string $tag, string $name ): int {
1003 // The value must be ENTIRELY digits. Matching a leading run would read
1004 // `width="50%"` as 50 and scale from a percentage as though it were
1005 // pixels — inventing a box rather than declining to guess.
1006 // Lookbehind for the same reason as set_attr(): `\bwidth=` also reads
1007 // `data-width=`, so a slider's own metadata was scaled from as though
1008 // it were a rendered dimension.
1009 if ( ! preg_match( '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(?:"(\d+)"|\'(\d+)\'|(\d+)(?=[\s/>]))#i', $tag, $m ) ) {
1010 return 0;
1011 }
1012 $value = '' !== ( $m[1] ?? '' ) ? $m[1] : ( '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' ) );
1013 return (int) $value;
1014 }
1015
1016 /**
1017 * WordPress names resized files `<name>-WxH.<ext>` — when the suffix is
1018 * present it IS the rendered size, resolvable with zero I/O (works for
1019 * CDN-hosted copies too). Pure — unit-tested.
1020 *
1021 * @return int[]|null [width, height] or null.
1022 */
1023 public static function parse_size_suffix( string $src ): ?array {
1024 $path = (string) preg_replace( '/[?#].*$/', '', $src );
1025 if ( preg_match( '#-(\d{1,4})x(\d{1,4})\.(?:jpe?g|png|gif|webp|avif)$#i', $path, $m ) ) {
1026 $w = (int) $m[1];
1027 $h = (int) $m[2];
1028 if ( $w > 0 && $h > 0 ) {
1029 return array( $w, $h );
1030 }
1031 }
1032 return null;
1033 }
1034
1035 /**
1036 * Intrinsic size of an image hosted on another domain.
1037 *
1038 * An image the site does not host is still an image whose dimensions
1039 * decide whether the page jumps while it loads. Refusing to look them up
1040 * was leaving real layout shift unfixed on any site that embeds media from
1041 * a CDN, a sister site, or a shared asset host — and telling the owner to
1042 * go and edit their content, which is not a fix a caching plugin should be
1043 * proud of.
1044 *
1045 * The reason for the old refusal was sound but too broad: a page render
1046 * must never block on somebody else's server. So this fetches only the
1047 * first few KB — enough for the header of every format WordPress
1048 * supports — with a short timeout, and caches the answer (successes AND
1049 * failures) so a URL is fetched once rather than once per pageview.
1050 *
1051 * By default it runs only when something has already warmed the cache
1052 * off-request (the preloader, a cron pass, WP-CLI). A visitor's request
1053 * therefore never waits on it. A site that would rather pay the cost
1054 * inline can opt in:
1055 *
1056 * add_filter( 'xspeed_lazy_remote_dimensions_inline', '__return_true' );
1057 *
1058 * and one that wants nothing fetched from other hosts at all can opt out:
1059 *
1060 * add_filter( 'xspeed_lazy_remote_dimensions', '__return_false' );
1061 *
1062 * @param string $src Absolute URL on another host.
1063 * @return int[]|null [width, height] or null when it cannot be resolved.
1064 */
1065 private static function remote_dimensions( string $src ): ?array {
1066 /**
1067 * Whether to resolve dimensions for images on other hosts at all.
1068 *
1069 * @param bool $enabled Default true.
1070 * @param string $src The image URL.
1071 */
1072 if ( ! apply_filters( 'xspeed_lazy_remote_dimensions', true, $src ) ) {
1073 return null;
1074 }
1075
1076 if ( ! function_exists( 'wp_remote_get' ) ) {
1077 return null;
1078 }
1079
1080 // Only http(s). A data: or blob: src has no server to ask.
1081 if ( ! preg_match( '#^https?://#i', $src ) ) {
1082 return null;
1083 }
1084
1085 /**
1086 * Whether a front-end request may perform the fetch itself.
1087 *
1088 * Off by default: the whole point of the cache is that a visitor
1089 * never waits on another host. Warm passes (cron, preloader, CLI)
1090 * set this true for themselves.
1091 *
1092 * @param bool $inline Default false.
1093 */
1094 $inline = (bool) apply_filters( 'xspeed_lazy_remote_dimensions_inline', self::$warming );
1095 if ( ! $inline ) {
1096 return null;
1097 }
1098
1099 // 32KB covers the header of JPEG, PNG, GIF, WebP and AVIF. Range is a
1100 // request, not a guarantee — a server that ignores it sends the whole
1101 // file, which the timeout still bounds.
1102 $resp = wp_remote_get(
1103 $src,
1104 array(
1105 'timeout' => 5,
1106 'headers' => array( 'Range' => 'bytes=0-32767' ),
1107 'user-agent' => 'xSpeed/dimension-probe',
1108 )
1109 );
1110 if ( is_wp_error( $resp ) ) {
1111 return null;
1112 }
1113 $code = (int) wp_remote_retrieve_response_code( $resp );
1114 if ( 200 !== $code && 206 !== $code ) {
1115 return null;
1116 }
1117
1118 $body = (string) wp_remote_retrieve_body( $resp );
1119 if ( '' === $body ) {
1120 return null;
1121 }
1122
1123 // getimagesizefromstring reads the header out of the bytes we already
1124 // have — no second request, no temp file.
1125 $size = @getimagesizefromstring( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a truncated or non-image body must degrade to null, not warn.
1126 if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
1127 return array( (int) $size[0], (int) $size[1] );
1128 }
1129 return null;
1130 }
1131
1132 /**
1133 * Resolve dimensions from an image URL, cheapest first:
1134 * 1. `-WxH` filename suffix (no I/O).
1135 * 2. Intrinsic size of the local file when src is under uploads
1136 * (getimagesize on the header — no remote fetches, ever).
1137 * 3. Attachment lookup by URL (uploads-hosted src only).
1138 * Results — including failures — are cached per URL in a bounded
1139 * transient so each image pays the lookup once, not per pageview.
1140 *
1141 * @return int[]|null [width, height] or null.
1142 */
1143 private static function dimensions_for_src( string $src ): ?array {
1144 $suffix = self::parse_size_suffix( $src );
1145 if ( $suffix ) {
1146 return $suffix;
1147 }
1148
1149 if ( ! function_exists( 'wp_get_upload_dir' ) || ! function_exists( 'get_transient' ) ) {
1150 return null;
1151 }
1152 $uploads = wp_get_upload_dir();
1153 $baseurl = isset( $uploads['baseurl'] ) ? (string) $uploads['baseurl'] : '';
1154 $basedir = isset( $uploads['basedir'] ) ? (string) $uploads['basedir'] : '';
1155
1156 // The cache is consulted BEFORE the local/remote split, so a remote
1157 // image pays its lookup once for the life of the transient rather
1158 // than once per page render.
1159 if ( null === self::$src_dims_cache ) {
1160 $stored = get_transient( 'xspeed_img_dims' );
1161 self::$src_dims_cache = is_array( $stored ) ? $stored : array();
1162 }
1163 $key = md5( $src );
1164 if ( array_key_exists( $key, self::$src_dims_cache ) ) {
1165 $hit = self::$src_dims_cache[ $key ];
1166 if ( is_array( $hit ) ) {
1167 return $hit;
1168 }
1169 // A cached FAILURE, not a cached answer. A front-end render
1170 // honours it — that is the whole point, one failed lookup must
1171 // not cost a request on every pageview. A warm pass does NOT:
1172 // it was asked to resolve these, nothing is waiting on it, and
1173 // the usual reason for a failure is a moment of bad luck rather
1174 // than an image that can never be measured.
1175 //
1176 // Without this, one slow response poisoned a URL for the life of
1177 // the transient. It happened on a real site: 15 images cached as
1178 // failures, and every later warm returned "resolved: 0" while the
1179 // page kept shifting.
1180 if ( ! self::$warming || ! self::failure_is_retryable( $hit ) ) {
1181 return null;
1182 }
1183 }
1184
1185 $is_local = '' !== $baseurl && '' !== $basedir && 0 === strpos( $src, $baseurl );
1186
1187 $dims = null;
1188
1189 if ( $is_local ) {
1190 $relative = (string) preg_replace( '/[?#].*$/', '', substr( $src, strlen( $baseurl ) ) );
1191 if ( false === strpos( $relative, '..' ) ) {
1192 $file = $basedir . $relative;
1193 if ( is_file( $file ) ) {
1194 $size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-image/corrupt file must degrade to null, not warn.
1195 if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
1196 $dims = array( (int) $size[0], (int) $size[1] );
1197 }
1198 }
1199 }
1200
1201 // File not on disk (offloaded originals) — one DB lookup by URL.
1202 if ( null === $dims && function_exists( 'attachment_url_to_postid' ) ) {
1203 $id = (int) attachment_url_to_postid( $src );
1204 if ( $id > 0 ) {
1205 $dims = self::dimensions_for_attachment( $id );
1206 }
1207 }
1208 } else {
1209 $dims = self::remote_dimensions( $src );
1210 }
1211
1212 // Cache success AND failure (0), bounded so the blob can't grow
1213 // unbounded on media-heavy sites.
1214 if ( count( self::$src_dims_cache ) >= 500 ) {
1215 self::$src_dims_cache = array_slice( self::$src_dims_cache, 250, null, true );
1216 }
1217 // A resolved size is permanent — the file's intrinsic dimensions do
1218 // not change under the same URL. A failure is a snapshot of one
1219 // moment, so it is stored as a TIMESTAMP rather than a bare 0 and
1220 // stops counting after a while. Storing both the same way is what let
1221 // a transient blip look identical to "this can never be measured".
1222 self::$src_dims_cache[ $key ] = null === $dims ? time() : $dims;
1223 if ( function_exists( 'set_transient' ) ) {
1224 set_transient( 'xspeed_img_dims', self::$src_dims_cache, DAY_IN_SECONDS );
1225 }
1226 return $dims;
1227 }
1228
1229 /**
1230 * @return int[]|null [width, height] or null
1231 */
1232 private static function dimensions_for_attachment( int $attachment_id ): ?array {
1233 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
1234 return null;
1235 }
1236 $meta = wp_get_attachment_metadata( $attachment_id );
1237 if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
1238 return null;
1239 }
1240 return array( (int) $meta['width'], (int) $meta['height'] );
1241 }
1242
1243 private static function is_excluded( string $tag, array $opts ): bool {
1244 $excluded = $opts['excluded_images'] ?? array();
1245 if ( ! is_array( $excluded ) || empty( $excluded ) ) {
1246 return false;
1247 }
1248 foreach ( $excluded as $pattern ) {
1249 $pattern = (string) $pattern;
1250 if ( '' === $pattern ) {
1251 continue;
1252 }
1253 if ( false !== stripos( $tag, $pattern ) ) {
1254 return true;
1255 }
1256 }
1257 return false;
1258 }
1259
1260 /**
1261 * Replace <script>, <style>, <noscript>, <pre>, <code> blocks with
1262 * placeholder tokens before tag rewriting. Returns [stubbed_html,
1263 * stubs_map]. Restore via restore_safe_blocks().
1264 *
1265 * @return array{0: string, 1: array<string,string>}
1266 */
1267 private static function stub_safe_blocks( string $html ): array {
1268 $stubs = array();
1269 $re = '#<(script|style|noscript|pre|code)\b[^>]*>.*?</\1>#is';
1270 $out = preg_replace_callback(
1271 $re,
1272 static function ( $m ) use ( &$stubs ) {
1273 $key = '<!--XSPEED_LAZY_STUB_' . count( $stubs ) . '-->';
1274 $stubs[ $key ] = $m[0];
1275 return $key;
1276 },
1277 $html
1278 );
1279 return array( (string) $out, $stubs );
1280 }
1281
1282 private static function restore_safe_blocks( string $html, array $stubs ): string {
1283 if ( empty( $stubs ) ) {
1284 return $html;
1285 }
1286 return strtr( $html, $stubs );
1287 }
1288
1289 private static function opts(): array {
1290 if ( null === self::$opts ) {
1291 self::$opts = Settings_Manager::get( 'lazy' );
1292 }
1293 return self::$opts;
1294 }
1295
1296 /**
1297 * How long a failed lookup is trusted before a warm pass tries again.
1298 *
1299 * Long enough that a genuinely unmeasurable URL is not re-fetched on every
1300 * crawl, short enough that an outage does not cost a day of layout shift.
1301 */
1302 private const FAILURE_RETRY_AFTER = 900; // 15 minutes.
1303
1304 /**
1305 * Whether a stored failure is old enough to be worth retrying.
1306 *
1307 * Legacy entries were written as a bare `0` with no timestamp. Those are
1308 * always retryable: they predate this distinction, and one extra request
1309 * for each is a far better outcome than leaving a site permanently unable
1310 * to resolve images it could resolve today.
1311 *
1312 * @param mixed $entry Stored cache value.
1313 */
1314 private static function failure_is_retryable( $entry ): bool {
1315 if ( ! is_int( $entry ) || $entry <= 0 ) {
1316 return true; // legacy `0`, or nonsense — retry.
1317 }
1318 return ( time() - $entry ) >= self::FAILURE_RETRY_AFTER;
1319 }
1320
1321 /**
1322 * Whether this URL's dimensions are already known (or known-unresolvable).
1323 *
1324 * Lets a caller skip URLs that would cost nothing to look up, so a bounded
1325 * batch spends its budget on images it has not seen. Without this a capped
1326 * collector re-picks the same first N images every pass — they are always
1327 * in the same DOM order — and anything past the cap is never resolved at
1328 * all, however many times the crawl runs.
1329 *
1330 * Reads the cache only; never fetches.
1331 *
1332 * @param string $src Absolute image URL.
1333 */
1334 public static function dimensions_known( string $src ): bool {
1335 if ( ! function_exists( 'get_transient' ) ) {
1336 return false;
1337 }
1338 if ( null === self::$src_dims_cache ) {
1339 $stored = get_transient( 'xspeed_img_dims' );
1340 self::$src_dims_cache = is_array( $stored ) ? $stored : array();
1341 }
1342 $key = md5( $src );
1343 if ( ! array_key_exists( $key, self::$src_dims_cache ) ) {
1344 return false;
1345 }
1346 $hit = self::$src_dims_cache[ $key ];
1347 if ( is_array( $hit ) ) {
1348 return true;
1349 }
1350 // A failure that has aged out is NOT known — reporting it as known
1351 // would make the crawl skip the one URL that has become worth
1352 // retrying.
1353 return ! self::failure_is_retryable( $hit );
1354 }
1355
1356 /**
1357 * Resolve and cache dimensions for a batch of image URLs.
1358 *
1359 * Meant for anything running OFF a visitor's request — the preloader
1360 * crawling the sitemap, a cron pass, `wp xspeed lazy warm-dimensions`.
1361 * Once warmed, the front end serves the dimensions from cache, so the
1362 * layout shift is fixed without a single visitor waiting on another host.
1363 *
1364 * @param string[] $urls Absolute image URLs.
1365 * @return int How many were resolved.
1366 */
1367 public static function warm_dimensions( array $urls ): int {
1368 $resolved = 0;
1369 self::$warming = true;
1370 try {
1371 foreach ( array_unique( $urls ) as $url ) {
1372 if ( ! is_string( $url ) || '' === $url ) {
1373 continue;
1374 }
1375 if ( self::dimensions_for_src( $url ) ) {
1376 $resolved++;
1377 }
1378 }
1379 } finally {
1380 // In a finally so a throw mid-batch cannot leave the flag set and
1381 // silently turn every later front-end render into a fetcher.
1382 self::$warming = false;
1383 }
1384 return $resolved;
1385 }
1386
1387 /**
1388 * Test-only: clear cached opts + counter between assertions.
1389 */
1390 public static function reset_state(): void {
1391 self::$opts = null;
1392 self::$image_counter = 0;
1393 self::$src_dims_cache = null;
1394 self::$facade_used = false;
1395 self::$warming = false;
1396 // Both gate whether the autoplay restorer is printed. Left set, one
1397 // page carrying a video would make every later response in the same
1398 // process ship the script — and, worse for the preloader, a warmed
1399 // page could inherit a decision made for a different URL.
1400 self::$deferred_autoplay = false;
1401 self::$has_deferred_video_markup = false;
1402 }
1403 }
1404