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

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

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