PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 All 32 releases
xspeed / includes / class-lazy-loader.php

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

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