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

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

1,986 lines 79.2 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 // data-no-lazy is the other common opt-out spelling; the restorer
719 // honours both for players that build their <video> from script.
720 if ( false !== stripos( $tag, 'data-skip-lazy' ) || false !== stripos( $tag, 'data-no-lazy' ) ) {
721 return $tag;
722 }
723 if ( self::is_excluded( $tag, self::opts() ) ) {
724 return $tag;
725 }
726 /*
727 * An autoplaying video is the one case preload="none" cannot help:
728 * browsers fetch an autoplay source regardless of preload, because
729 * the author asked for it to start on its own. Setting the attribute
730 * would only make the markup lie about what happens.
731 *
732 * But "starts on its own" does not mean "must download before the
733 * visitor has scrolled anywhere near it". A page of nine autoplay
734 * demo clips pulled 44 MB on load and held the browser's loading
735 * indicator open for 33 s, while none of them were on screen.
736 *
737 * So defer the SOURCE and restore it when the element reaches the
738 * viewport, which is the first moment autoplay is meant to be
739 * visible anyway. The author's choice is honoured — the video still
740 * plays by itself — it simply costs nothing until it can be seen.
741 */
742 if ( preg_match( '#\sautoplay(?=[\s/>=])#i', $tag ) ) {
743 return self::defer_autoplay_source( $tag );
744 }
745 // HTML5 `<video>` doesn't support loading=lazy yet (Chromium
746 // won't add it before there's broad support). What we CAN do
747 // is set preload="none" so the browser doesn't pre-fetch the
748 // video bytes until play is requested — that's the actual win
749 // users want from "lazy-load videos".
750 //
751 // OVERRIDE an existing value rather than bailing on it: players
752 // that ship preload="auto" or "metadata" (Elementor's video
753 // widget, most block themes) are exactly the case this setting
754 // exists for, and skipping them made it a no-op right where it
755 // mattered. (#309 — a 924KB MP4 transferred in full on every run
756 // with this setting on.)
757 return self::set_attr( $tag, 'preload', 'none' );
758 }
759
760 /**
761 * Swap a self-hosted <video> for the click-to-play facade.
762 *
763 * The easy case of the facade, not the hard one: no third-party player
764 * to defer, and the element usually already carries a real poster
765 * frame. Bails out — element returned untouched — whenever the facade
766 * would be worse than the video:
767 *
768 * - `autoplay` is a deliberate author choice (a hero background); a
769 * play button in its place changes the page, not just its weight.
770 * - no `poster` means the facade renders as a blank black box, which
771 * is worse than the preload="none" the lazy pass already applied.
772 * - no resolvable source means there is nothing to play on click.
773 *
774 * $m[0] is the whole element, $m[1] the opening tag — same contract as
775 * rewrite_iframe_facade() above, and the same rule: every bail-out
776 * path returns the WHOLE element so the closing tag is never stranded.
777 */
778 private static function rewrite_video_facade( array $m ): string {
779 $element = $m[0];
780 $tag = $m[1];
781
782 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
783 return $element;
784 }
785 // The lazy pass runs first and renames `autoplay` to
786 // data-xspeed-autoplay when it defers the source, so check both.
787 // Checking only the attribute turned every deferred autoplay video
788 // with a poster (a Kadence row background, a hero) into a play
789 // button.
790 if ( preg_match( '#\s(?:data-xspeed-)?autoplay(?=[\s/>=])#i', $tag ) ) {
791 return $element;
792 }
793 if ( self::is_excluded( $tag, self::opts() ) ) {
794 return $element;
795 }
796
797 if ( ! preg_match( '#\bposter\s*=\s*(["\'])(.*?)\1#i', $tag, $poster_m ) || '' === trim( $poster_m[2] ) ) {
798 return $element;
799 }
800 $poster = $poster_m[2];
801
802 // Source: the src attribute, else the first <source src="…"> child.
803 $src = '';
804 if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $src_m ) ) {
805 $src = $src_m[2];
806 } elseif ( preg_match( '#<source\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1#i', $m[2], $src_m ) ) {
807 $src = $src_m[2];
808 }
809 if ( '' === trim( $src ) ) {
810 return $element;
811 }
812
813 $title = '';
814 if ( preg_match( '#\btitle\s*=\s*(["\'])(.*?)\1#i', $tag, $title_m ) ) {
815 $title = $title_m[2];
816 }
817
818 self::$facade_used = true;
819
820 return Video_Facade::render_native( $element, $src, $poster, $title );
821 }
822
823 /**
824 * Add an attribute to an opening tag if it isn't already present.
825 * Pass $only_if_missing=false to override an existing value (e.g.
826 * flipping loading="lazy" → "eager" on the first image).
827 */
828 /**
829 * Hold an autoplay video's bytes until the element reaches the viewport.
830 *
831 * `preload="none"` is ignored for autoplay, so the only way to stop the
832 * download is to take the source away and give it back later. We move
833 * `src` to `data-xspeed-src` and drop `autoplay` — a `<video>` with no
834 * resolvable source fetches nothing — then the script below restores
835 * both when the element scrolls into view.
836 *
837 * Restoring `autoplay` rather than calling play() matters: play() from
838 * a non-user gesture is refused unless the video is muted, and returns
839 * a promise whose rejection most callers never handle. Setting the
840 * attribute lets the browser apply its own autoplay policy exactly as
841 * it would have on load.
842 *
843 * `<source>` children are handled too, since a video with multiple
844 * formats carries no `src` of its own.
845 *
846 * Marked with a data attribute rather than a class so a theme's CSS
847 * cannot accidentally select — or style away — the deferred state.
848 */
849 private static function defer_autoplay_source( string $tag ): string {
850 // Already processed (a second pass, or another plugin got there).
851 if ( false !== stripos( $tag, 'data-xspeed-src' ) ) {
852 return $tag;
853 }
854
855 // A background video waits for the visitor's first interaction, not
856 // just the viewport. It is decoration, and on a hero it is in the
857 // viewport at once, so the viewport rule loaded it immediately and its
858 // first frame became the LCP. Held back, the hero text is the LCP and
859 // the video starts on the first scroll, tap, key or mouse move.
860 // Checked before autoplay is renamed below.
861 if ( self::is_background_video_without_poster( $tag ) ) {
862 /**
863 * Whether a background video waits for the first interaction.
864 *
865 * Return false to load it when it reaches the viewport instead,
866 * like any other autoplay video.
867 *
868 * @param bool $wait Whether the video waits. Default true.
869 * @param string $tag The <video> opening tag.
870 */
871 if ( (bool) apply_filters( 'xspeed_lazy_background_video_waits_for_interaction', true, $tag ) ) {
872 $tag = self::set_attr( $tag, 'data-xspeed-wait', 'interaction' );
873 }
874 }
875
876 $deferred = false;
877
878 // The element's own src, when it has one.
879 if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $m ) && '' !== trim( $m[2] ) ) {
880 $tag = (string) preg_replace(
881 '#\bsrc\s*=\s*(["\'])(.*?)\1#i',
882 'data-xspeed-src="' . esc_attr( $m[2] ) . '"',
883 $tag,
884 1
885 );
886 $deferred = true;
887 }
888
889 if ( ! $deferred ) {
890 // No src of its own — the <source> children carry it, and those
891 // are outside this opening tag. Mark the element so the script
892 // knows to move them, and let it do the work in the DOM where
893 // the children are actually reachable.
894 $tag = self::set_attr( $tag, 'data-xspeed-defer-sources', '1' );
895 }
896
897 // Without this the browser starts fetching the moment a source is
898 // restored, which is what we want — but it must not autoplay before
899 // then, and it must not report itself as autoplaying meanwhile.
900 $tag = (string) preg_replace( '#\sautoplay(?=[\s/>=])#i', ' data-xspeed-autoplay="1"', $tag, 1 );
901
902 // preload="none" as well: belt and braces for the window between
903 // parse and the observer attaching.
904 $tag = self::set_attr( $tag, 'preload', 'none' );
905
906 self::$deferred_autoplay = true;
907
908 return $tag;
909 }
910
911 /**
912 * Whether a <video> opening tag is a decorative background with no poster.
913 *
914 * Autoplay, muted, looping and without controls is how every builder
915 * marks a background video: nobody watches it, it sits behind the hero
916 * text. With no poster, nothing paints in its box until the first frame
917 * decodes, so on a hero the video's first frame becomes the page's LCP.
918 * On the measured site that was a 5 MB MP4 and a 3.5–3.9 s mobile LCP,
919 * against 2.3 s with the video out of the way.
920 *
921 * Reads autoplay in both spellings: the author's `autoplay`, and the
922 * `data-xspeed-autoplay` the lazy pass leaves when it defers the source.
923 * Resource Hints sees the tag after that pass has run.
924 *
925 * @param string $tag A <video> opening tag.
926 */
927 public static function is_background_video_without_poster( string $tag ): bool {
928 $has = static function ( string $name ) use ( $tag ): bool {
929 return (bool) preg_match( '#\s' . $name . '(?=[\s/>=])#i', $tag );
930 };
931 if ( ! $has( 'autoplay' ) && ! $has( 'data-xspeed-autoplay' ) ) {
932 return false;
933 }
934 if ( ! $has( 'muted' ) || ! $has( 'loop' ) || $has( 'controls' ) ) {
935 return false;
936 }
937 return ! preg_match( '#\sposter\s*=\s*(?:["\']\s*)?[^"\'\s>]#i', $tag );
938 }
939
940 /**
941 * Did this response defer at least one autoplay video? Gates the script
942 * so a page with no such video ships no extra bytes.
943 *
944 * @var bool
945 */
946 private static $deferred_autoplay = false;
947
948 /** Whether the viewport script needs to be injected into this response. */
949 public static function needs_autoplay_script(): bool {
950 return self::$deferred_autoplay || self::$has_deferred_video_markup;
951 }
952
953 /**
954 * Page-builder video blocks that render NO <video> tag server-side.
955 *
956 * Essential Blocks' advanced-video, and widgets shaped like it, ship a
957 * plain <div> carrying the file URL in an attribute and let their own JS
958 * build the player after load. The PHP pass cannot rewrite what is not
959 * there, so a page of nine such blocks was completely untouched — which
960 * is exactly the 44 MB case this feature exists for.
961 *
962 * We deliberately do NOT rewrite those attributes. They belong to
963 * another plugin, whose script reads them on init; renaming one is how
964 * you get a player that silently never appears. Instead we note that
965 * such markup is present so the restorer ships, and let its
966 * MutationObserver catch the <video> the block creates — at which point
967 * it is an ordinary element we can defer like any other.
968 *
969 * @var bool
970 */
971 private static $has_deferred_video_markup = false;
972
973 /**
974 * Does this HTML carry a video URL in an attribute rather than a tag?
975 *
976 * Matched on the URL, not on any one plugin's attribute name: `data-url`
977 * is Essential Blocks, but `data-src`, `data-video-url` and others are
978 * equally common, and a rule keyed to one vendor would miss the rest.
979 */
980 private static function detect_attribute_video( string $html ): void {
981 if ( self::$has_deferred_video_markup ) {
982 return;
983 }
984 if ( preg_match( '#\sdata-[\w-]+\s*=\s*(["\'])[^"\']*\.(?:mp4|webm|m4v|ogv|mov)(?:\?[^"\']*)?\1#i', $html ) ) {
985 self::$has_deferred_video_markup = true;
986 }
987 }
988
989 /**
990 * Restore the source when the video reaches the viewport.
991 *
992 * Dependency-free and tiny, matching Video_Facade::facade_script(). The
993 * rootMargin starts the fetch slightly before the element is visible so
994 * playback begins without a visible stall.
995 *
996 * @param bool $after_load Hold autoplay videos until `load` even when they
997 * are on screen from the start (video_after_load).
998 * A hero video otherwise starts downloading with
999 * the page and competes with everything the first
1000 * paint needs; lab tools also count its bytes
1001 * toward LCP because the request starts before it.
1002 * @param string[] $excluded Excluded Images patterns. The server pass
1003 * leaves a matching <video> alone, but it is still
1004 * a video[autoplay] to adopt(), so the script has
1005 * to know them too, and they are the only route
1006 * for a video a player builds in script.
1007 */
1008 public static function autoplay_script( bool $after_load = false, array $excluded = array() ): string {
1009 $script = <<<'JS'
1010 (function(){
1011 var S='video[data-xspeed-src],video[data-xspeed-defer-sources]';
1012 var AL=0,Q=[],X=[];
1013 // Autoplay the author asked for, whether the server pass has renamed it yet
1014 // or not. A player that builds its video in script sets the real attribute.
1015 function ap(v){return v.hasAttribute('autoplay')||v.hasAttribute('data-xspeed-autoplay');}
1016 // Matched like the server pass: a case-insensitive substring of the opening
1017 // tag, plus the source being set, which is not in the tag yet when a player
1018 // assigns it.
1019 function excl(v,val){
1020 if(!X.length)return false;
1021 var t=(v.outerHTML.split('>')[0]+' '+(val||'')).toLowerCase();
1022 for(var i=0;i<X.length;i++){if(t.indexOf(X[i])>=0)return true;}
1023 return false;
1024 }
1025
1026 /*
1027 * Intercept the ASSIGNMENT, because observing the DOM is always too late.
1028 *
1029 * Measured on a live page: a builder's video player creates nine elements
1030 * and sets `src` BEFORE inserting them, so a MutationObserver watching for
1031 * insertions saw zero of them — and the browser had already begun fetching
1032 * by the time any observer could run. The order is: setAttribute('src'),
1033 * then setAttribute('preload','auto'), then insert. Only the first of those
1034 * matters, and it happens off-DOM.
1035 *
1036 * So wrap the two ways a source can be set on a media element and hold the
1037 * value instead of applying it. Nothing else can start a download: a
1038 * <video> with no resolvable source fetches nothing. The value is stored on
1039 * the element and handed back by go() when it reaches the viewport.
1040 *
1041 * Scoped to <video> only. <audio> is small and usually deliberate, and
1042 * touching it would change behaviour nobody complained about.
1043 */
1044 try{
1045 var VP=window.HTMLMediaElement&&HTMLMediaElement.prototype;
1046 var SD=VP&&Object.getOwnPropertyDescriptor(VP,'src');
1047 var hold=function(el,val){
1048 if(el.tagName!=='VIDEO')return false;
1049 if(el.getAttribute('data-xspeed-loaded'))return false; // released: let it through
1050 if(!val)return false;
1051 if(el.hasAttribute('data-skip-lazy')||el.hasAttribute('data-no-lazy')||excl(el,val))return false;
1052 // Already on screen: a player is loading it because it is visible (#548).
1053 if(el.isConnected&&near(el)&&!(AL&&document.readyState!=='complete'&&ap(el)))return false;
1054 el.setAttribute('data-xspeed-src',String(val));
1055 el.setAttribute('data-xspeed-adopted','1');
1056 return true;
1057 };
1058 if(SD&&SD.set){
1059 Object.defineProperty(VP,'src',{configurable:true,enumerable:SD.enumerable,
1060 get:function(){return SD.get.call(this);},
1061 set:function(v){if(hold(this,v))return;return SD.set.call(this,v);}});
1062 }
1063 var SA=Element.prototype.setAttribute;
1064 Element.prototype.setAttribute=function(n,v){
1065 if(n==='src'&&hold(this,v))return;
1066 // An eager preload on a held video would re-arm the fetch the moment a
1067 // source comes back; keep it at none until we release it deliberately.
1068 if(n==='preload'&&this.tagName==='VIDEO'&&this.getAttribute('data-xspeed-src')&&v!=='none')
1069 return SA.call(this,'preload','none');
1070 return SA.call(this,n,v);
1071 };
1072 }catch(e){}
1073 function go(v){
1074 if(v.getAttribute('data-xspeed-loaded'))return;
1075 // video_after_load: an autoplay video that is on screen before `load` waits
1076 // for it. readyState is already 'complete' inside the load handler, so the
1077 // replay below goes straight through.
1078 // A video built in script keeps its real autoplay attribute when hold()
1079 // takes its source, so check both spellings.
1080 if(AL&&document.readyState!=='complete'&&ap(v)){if(Q.indexOf(v)<0)Q.push(v);return;}
1081 v.setAttribute('data-xspeed-loaded','1');
1082 var r=0,s=v.getAttribute('data-xspeed-src');
1083 if(s){v.setAttribute('src',s);v.removeAttribute('data-xspeed-src');r=1;}
1084 if(v.getAttribute('data-xspeed-defer-sources')){
1085 var c=v.querySelectorAll('source[data-xspeed-src]');
1086 for(var i=0;i<c.length;i++){c[i].setAttribute('src',c[i].getAttribute('data-xspeed-src'));c[i].removeAttribute('data-xspeed-src');r=1;}
1087 v.removeAttribute('data-xspeed-defer-sources');
1088 }
1089
1090 if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');}
1091 v.removeAttribute('preload');
1092 // load() picks up the sources we just restored; without it a <video>
1093 // that has already failed to resolve a source will not retry. Only when
1094 // something was restored: a player that set its own src on the element
1095 // meanwhile (Elementor's background video) is already fetching it, and
1096 // load() would abort that request and start it again.
1097 if(v.load&&(r||!v.getAttribute('src')))v.load();
1098 }
1099 // A multi-format <video> carries no src of its own — the <source> children
1100 // do, and those sit outside the opening tag PHP rewrote. Strip them here,
1101 // as early as this script runs, then restore on intersect like the rest.
1102 function strip(){
1103 var d=document.querySelectorAll('video[data-xspeed-defer-sources]');
1104 for(var i=0;i<d.length;i++){
1105 if(d[i].getAttribute('data-xspeed-loaded'))continue;
1106 var c=d[i].querySelectorAll('source[src]');
1107 for(var j=0;j<c.length;j++){c[j].setAttribute('data-xspeed-src',c[j].getAttribute('src'));c[j].removeAttribute('src');}
1108 if(c.length&&d[i].load)d[i].load();
1109 }
1110 }
1111 // A page-builder block builds its <video> after load, so PHP never saw it
1112 // and it arrives with a live src and autoplay already set. Defer it here,
1113 // the same way the server would have, BEFORE the browser gets far into
1114 // fetching it. Only autoplay videos: anything else is already covered by
1115 // preload="none" and taking a source from a user-controlled player would
1116 // break its own play button.
1117 function adopt(){
1118 // Any JS-built <video> that would fetch on sight — NOT just autoplay.
1119 // Measured on a live page: a builder's video block creates nine elements
1120 // with autoplay=false and preload="auto", so an autoplay-only selector
1121 // skipped every one of them and 40 MB still downloaded. preload="auto" is
1122 // the same eager-fetch instruction by another name, and the server pass
1123 // would have rewritten it to "none" had the element existed in the HTML.
1124 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])');
1125 for(var i=0;i<a.length;i++){
1126 var v=a[i];
1127 v.setAttribute('data-xspeed-adopted','1');
1128 // The author's opt-outs and Excluded Images, same as the server pass.
1129 if(v.hasAttribute('data-skip-lazy')||v.hasAttribute('data-no-lazy')||excl(v))continue;
1130 // A visible video already in or near the viewport is being started because
1131 // it is visible — a player with its own lazy loader sets autoplay or preload
1132 // at exactly that moment (Essential Blocks 6.4.7+). Taking its source then
1133 // aborted the request and rejected play(). Hold it only when the site asked
1134 // for autoplay to wait until load and load has not happened yet. (#548)
1135 if(near(v)&&!(AL&&document.readyState!=='complete'&&ap(v)))continue;
1136 var auto=v.hasAttribute('autoplay');
1137 var s=v.getAttribute('src');
1138 if(s){v.setAttribute('data-xspeed-src',s);v.removeAttribute('src');}
1139 var c=v.querySelectorAll('source[src]');
1140 for(var j=0;j<c.length;j++){c[j].setAttribute('data-xspeed-src',c[j].getAttribute('src'));c[j].removeAttribute('src');}
1141 if(c.length)v.setAttribute('data-xspeed-defer-sources','1');
1142 // Only remember autoplay for the ones that actually had it — restoring it
1143 // on a video the author left click-to-play would start playback nobody
1144 // asked for.
1145 if(auto){v.removeAttribute('autoplay');v.setAttribute('data-xspeed-autoplay','1');}
1146 v.setAttribute('preload','none');
1147 if(v.load)v.load();
1148 // Watch it now, not on the next full scan: a page whose DOM never goes
1149 // quiet (typed headings, counters, chat widgets) could otherwise keep a
1150 // held video blank long after it scrolled into view. (#548)
1151 watch(v);
1152 }
1153 }
1154 // A background video (data-xspeed-wait) that reaches the viewport is parked
1155 // here until the visitor first scrolls, taps, types or moves the mouse, then
1156 // every parked one starts together. See defer_autoplay_source().
1157 var I=false,P=[],E=['pointerdown','pointermove','touchstart','keydown','wheel','scroll'];
1158 function interacted(){
1159 if(I)return;I=true;
1160 for(var i=0;i<E.length;i++)removeEventListener(E[i],interacted,true);
1161 for(var j=0;j<P.length;j++)go(P[j]);
1162 P=[];
1163 }
1164 for(var k=0;k<E.length;k++)addEventListener(E[k],interacted,{capture:true,passive:true});
1165 function reach(v){if(!I&&v.getAttribute('data-xspeed-wait'))P.push(v);else go(v);}
1166 // In or within 200px of the viewport, and laid out (a hidden popup's video
1167 // measures 0x0 at the top of the page and must not count as visible).
1168 function near(v){
1169 var r=v.getBoundingClientRect();
1170 if(!r.width&&!r.height)return false;
1171 var h=window.innerHeight||document.documentElement.clientHeight;
1172 return r.bottom>-200&&r.top<h+200;
1173 }
1174 // One observer for every held video, created on first use.
1175 var o;
1176 function io(){
1177 if(o!==undefined)return o;
1178 o=('IntersectionObserver'in window)?new IntersectionObserver(function(es){
1179 for(var i=0;i<es.length;i++){if(es[i].isIntersecting){o.unobserve(es[i].target);reach(es[i].target);}}
1180 },{rootMargin:'200px'}):null;
1181 return o;
1182 }
1183 function watch(v){var ob=io();if(ob)ob.observe(v);else reach(v);}
1184 function scan(){
1185 strip();
1186 adopt();
1187 var v=document.querySelectorAll(S);
1188 for(var j=0;j<v.length;j++)watch(v[j]);
1189 }
1190 if(document.readyState!=='loading')scan();else document.addEventListener('DOMContentLoaded',scan);
1191 // Players that build their <video> after load (page-builder video blocks)
1192 // must be caught the INSTANT the element lands. A timer loses the race: the
1193 // browser begins fetching as soon as a src is set. adopt() is idempotent and
1194 // cheap (one guarded querySelectorAll), so run it synchronously on every
1195 // mutation. The fuller scan is THROTTLED, not debounced: a debounce reset by
1196 // every mutation never fired on a page that keeps changing. (#548)
1197 if(window.MutationObserver){
1198 var t=0;
1199 new MutationObserver(function(){
1200 adopt();
1201 if(!t)t=setTimeout(function(){t=0;scan();},200);
1202 }).observe(document.documentElement,{childList:true,subtree:true});
1203 }
1204 if(AL)window.addEventListener('load',function(){
1205 var d=0;function rel(){if(d)return;d=1;var q=Q;Q=[];for(var i=0;i<q.length;i++)go(q[i]);}
1206 if(window.requestAnimationFrame)requestAnimationFrame(function(){requestAnimationFrame(rel);});
1207 setTimeout(rel,1500);
1208 });
1209 })();
1210 JS;
1211 $patterns = array();
1212 foreach ( $excluded as $pattern ) {
1213 $pattern = strtolower( trim( (string) $pattern ) );
1214 if ( '' !== $pattern ) {
1215 $patterns[] = $pattern;
1216 }
1217 }
1218 $vars = 'var AL=' . ( $after_load ? '1' : '0' ) . ',Q=[],X=' . wp_json_encode( array_values( array_unique( $patterns ) ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';';
1219 return str_replace( 'var AL=0,Q=[],X=[];', $vars, $script );
1220 }
1221
1222 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
1223 // Lookbehind, not `\b`: writing `width` onto a tag carrying
1224 // `data-width="800"` matched the DATA attribute and rewrote it to the
1225 // file's intrinsic size — corrupting a slider's own configuration and
1226 // leaving the tag with no real width at all. (#333 review round 3)
1227 $pattern = '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
1228 if ( preg_match( $pattern, $tag ) ) {
1229 if ( $only_if_missing ) {
1230 return $tag;
1231 }
1232 return (string) preg_replace( $pattern, $name . '="' . $value . '"', $tag, 1 );
1233 }
1234 // Inject before the closing > (preserving self-closing `/>` if present).
1235 if ( preg_match( '#(/?>)$#', $tag, $m ) ) {
1236 $close = $m[1];
1237 return substr( $tag, 0, -strlen( $close ) ) . ' ' . $name . '="' . $value . '"' . $close;
1238 }
1239 return $tag;
1240 }
1241
1242 /**
1243 * Attempt to fill in missing width / height from either an attached
1244 * media library record (when class="wp-image-N") or from the local
1245 * filesystem when src points at the uploads dir. Skip when we can't
1246 * resolve cheaply — never block the request on a remote getimagesize.
1247 */
1248 private static function ensure_dimensions( string $tag ): string {
1249 // `\b` sits between `-` and `w`, so a bare `\bwidth=` also matched
1250 // `data-width=` — a slider's own metadata, not a rendered dimension.
1251 // The tag then looked half-sized: apply_dimensions() derived the other
1252 // dimension from the ratio and wrote ONLY that, so a tag carrying
1253 // `data-width="800"` came out with `height="533"` and no width and
1254 // laid out at 41x30. Harmless while the URL never resolved; this
1255 // branch made it resolve, which is what exposed it. Half a pair is
1256 // worse than none, as the docblock below already says.
1257 // (#333 review round 3, issue 2)
1258 $has_w = (bool) preg_match( '#(?<![-\w])width\s*=#i', $tag );
1259 $has_h = (bool) preg_match( '#(?<![-\w])height\s*=#i', $tag );
1260 if ( $has_w && $has_h ) {
1261 return $tag;
1262 }
1263
1264 // Try wp-image-<id> class first (cheapest path; one DB-cached
1265 // get_post_meta call).
1266 if ( preg_match( '#\bclass\s*=\s*["\']([^"\']*)["\']#i', $tag, $cm ) && preg_match( '#wp-image-(\d+)#i', $cm[1], $idm ) ) {
1267 $dims = self::dimensions_for_attachment( (int) $idm[1] );
1268 if ( $dims ) {
1269 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
1270 }
1271 }
1272
1273 // No wp-image-N class — page-builder markup (Essential Blocks and
1274 // friends) never emits it, which is why the setting silently failed
1275 // on those images (issue #37). Resolve from the src instead, but only
1276 // when the tag doesn't already tell us it renders at some other size:
1277 // stamping the intrinsic file size onto a responsive or CSS-sized
1278 // image would CREATE the layout shift this feature exists to remove.
1279 if ( ! self::has_constrained_render( $tag ) ) {
1280 $url = self::resolvable_image_url( $tag );
1281 if ( '' !== $url ) {
1282 $dims = self::dimensions_for_src( $url );
1283 if ( $dims ) {
1284 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
1285 }
1286 }
1287 }
1288
1289 // Couldn't resolve. Leave the tag alone — better no dimensions
1290 // than wrong ones.
1291 return $tag;
1292 }
1293
1294 /**
1295 * The URL to measure an image by: its real `src`, or the lazy-loading
1296 * attribute holding the URL when `src` is absent or a placeholder.
1297 *
1298 * Page-builder sliders (Essential Blocks among them) ship the image with
1299 * NO `src` at all — the URL lives in `data-lazy`, and their own JS moves
1300 * it across at runtime. Resolving only from `src` left every one of those
1301 * images without dimensions (issue #328, the miss that #37 did not cover:
1302 * that one was about the missing `wp-image-N` class, this one is about the
1303 * URL not being in `src` in the first place).
1304 *
1305 * A placeholder `src` — a data: URI or the 1x1 spacer GIF these libraries
1306 * use — is treated as absent: measuring it would stamp the spacer's size
1307 * onto the tag and CREATE a layout shift.
1308 *
1309 * Note the explicit `(?<![-\w])src` boundary. `\bsrc=` also matches the
1310 * tail of `data-src=` and `data-lazy-src=` (a hyphen is a non-word
1311 * character, so `\b` sits between `-` and `s`), which is why those two
1312 * attributes happened to work before this method existed while `data-lazy`
1313 * and `data-original` did not. Relying on that accident meant the URL a
1314 * tag was measured by depended on how its attribute was spelled.
1315 *
1316 * Pure — unit-tested — EXCEPT when `$may_measure` is true and every
1317 * candidate was refused by name, which is the one branch that touches the
1318 * filesystem. Callers that are themselves arranging a measurement pass
1319 * false; see the note at that branch.
1320 *
1321 * @param string $tag The <img> tag.
1322 * @param bool $may_measure Whether a name-refused URL may be settled by
1323 * reading the file. False for the warm-up
1324 * collector, which would otherwise deadlock.
1325 */
1326 public static function resolvable_image_url( string $tag, bool $may_measure = true ): string {
1327 $src = '';
1328 $named_out = '';
1329 if ( preg_match( '#(?<![-\w])src\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
1330 $src = trim( $m[1] );
1331 if ( '' !== $src && ! self::is_placeholder_src( $src ) ) {
1332 return $src;
1333 }
1334 }
1335
1336 foreach ( array( 'data-lazy', 'data-src', 'data-lazy-src', 'data-original' ) as $attr ) {
1337 // Anchor with a negative lookbehind, not `\b` and not
1338 // whitespace. `\b` sits between `-` and `d`, so a bare
1339 // `\bdata-src=` also matched the TAIL of `x-data-src=` and took
1340 // the wrong image's URL — worse than no size, because it reserves
1341 // a wrongly shaped box and CAUSES the shift.
1342 //
1343 // Requiring whitespace instead was my first fix and it was wrong:
1344 // attributes are not always separated by one (`alt="31"srcset=`
1345 // is valid), so that anchor silently stopped matching and handed
1346 // back a size for a tag the guard should have skipped. The
1347 // lookbehind rejects the same prefixed decoys without depending on
1348 // spacing, and is what `src` already uses two methods below.
1349 // (#333 review rounds 2 and 3, issue 1)
1350 if ( preg_match( '#(?<![-\w])' . preg_quote( $attr, '#' ) . '\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
1351 $url = trim( $m[1] );
1352 if ( '' === $url ) {
1353 continue;
1354 }
1355 if ( ! self::is_placeholder_src( $url ) ) {
1356 return $url;
1357 }
1358 // Refused on its NAME. Remember it — if nothing else in the
1359 // tag resolves, the file itself gets the final say below.
1360 if ( '' === $named_out ) {
1361 $named_out = $url;
1362 }
1363 }
1364 }
1365
1366 // Every candidate was refused on its NAME alone. A name is a guess;
1367 // the file is the fact. Someone who uploads a photograph called
1368 // `placeholder.jpg` — an entirely ordinary thing to find in a media
1369 // library — got no dimensions at all, and neither did any of the
1370 // copies WordPress generates from it, so the layout shift this
1371 // feature removes came straight back for those images with nothing on
1372 // screen to explain why. (#333 review round 2, issue 1)
1373 //
1374 // Only reached when nothing else in the tag resolved, so the cost is a
1375 // lookup that was about to be skipped entirely, never an extra one.
1376 // A genuine stand-in fails this test on its own merits: a data: URI
1377 // never gets here, and a 1x1 spacer measures 1x1.
1378 //
1379 // The lazy attribute is preferred over `src`, matching the order
1380 // above: when a tag carries both, the lazy one names the real image
1381 // and `src` holds the stand-in.
1382 // The warm-up collector passes false here, and must. Deciding this by
1383 // MEASURING is circular for the caller whose whole job is to arrange
1384 // the measurement: remote lookups are gated off until `$warming` is
1385 // true, `$warming` only becomes true inside warm_dimensions(), and
1386 // warm_dimensions() is never reached because this returned ''. A
1387 // remote `placeholder.jpg` — a real photograph on a CDN — was warmable
1388 // before this branch and stopped being, with a failure cached against
1389 // it for good measure. The collector takes the URL the tag offers and
1390 // lets warm_dimensions() be the thing that decides.
1391 // (#333 review round 3, issue 3)
1392 if ( ! $may_measure ) {
1393 return '' !== $named_out ? $named_out : $src;
1394 }
1395
1396 foreach ( array( $named_out, $src ) as $candidate ) {
1397 if ( '' !== $candidate && self::is_real_image( $candidate ) ) {
1398 return $candidate;
1399 }
1400 }
1401
1402 return '';
1403 }
1404
1405 /**
1406 * Does this URL resolve to something too big to be a lazy-load stand-in?
1407 *
1408 * The stand-ins this guards against are 1x1 spacers and inline data: URIs.
1409 * Anything with real extent is a real image, whatever it is called — which
1410 * is what lets a photograph named `placeholder.jpg` keep its dimensions
1411 * while `spacer.gif` still loses them.
1412 *
1413 * Deliberately conservative: an unresolvable URL returns false, so the
1414 * name-based verdict stands and the tag is left alone. Better no
1415 * dimensions than wrong ones. Uses the same resolver (and therefore the
1416 * same cache) as the normal path, so this costs no extra lookup.
1417 */
1418 private static function is_real_image( string $src ): bool {
1419 $dims = self::dimensions_for_src( $src );
1420 if ( ! is_array( $dims ) ) {
1421 return false;
1422 }
1423 // Indexed [ width, height ] — the shape apply_dimensions() consumes.
1424 $w = isset( $dims[0] ) ? (int) $dims[0] : 0;
1425 $h = isset( $dims[1] ) ? (int) $dims[1] : 0;
1426
1427 // A few pixels either way is still a spacer — some libraries ship a
1428 // 2x2 or 4x4 rather than a true 1x1. Anything above that has extent a
1429 // stand-in does not.
1430 return $w > 4 && $h > 4;
1431 }
1432
1433 /**
1434 * True for the stand-in a lazy-loader parks in `src` until its JS swaps
1435 * the real URL in: an inline data: URI, or a `spacer`/`blank`/`placeholder`
1436 * asset. Measuring one of these would stamp the spacer's dimensions onto
1437 * the tag. Pure — unit-tested.
1438 *
1439 * Matched on the WHOLE filename stem, not a word inside it. A word-boundary
1440 * search anywhere in the last segment caught every real image whose name
1441 * merely contains one of these ordinary words — `blank-space-cover.png`,
1442 * `placeholder-portrait.png`, `spacer-hero-banner.jpg` — and silently
1443 * stopped sizing them, which brings back the very layout shift this
1444 * feature exists to prevent, with nothing on screen to explain it
1445 * (#333 review, issue 1).
1446 *
1447 * A real stand-in is named for what it is and nothing else: `blank.gif`,
1448 * `spacer.png`, `lazy-loader.svg`, optionally with a dimension or version
1449 * suffix (`blank-1x1.gif`, `[email protected]`). A descriptive tail is what
1450 * separates a photograph from a spacer, so the tail is what decides.
1451 */
1452 public static function is_placeholder_src( string $src ): bool {
1453 if ( 0 === stripos( $src, 'data:' ) ) {
1454 return true;
1455 }
1456
1457 // Last path segment, without the query string or fragment —
1458 // `?v=placeholder` is a cache-buster on a real image, not a name.
1459 // Plain string work on purpose: this method is pure and unit-tested
1460 // with no WordPress loaded, so wp_parse_url() is not available.
1461 $path = strtok( $src, '?#' );
1462 if ( ! is_string( $path ) || '' === $path ) {
1463 $path = $src;
1464 }
1465 $name = strtolower( basename( $path ) );
1466
1467 // Drop the extension, then any trailing dimension/DPR/version marker.
1468 $stem = preg_replace( '#\.[a-z0-9]+$#', '', $name );
1469 $stem = (string) preg_replace( '#[-_@]?(?:\d+x\d+|\d+x|x\d+|v\d+|\d+)$#', '', (string) $stem );
1470 $stem = trim( $stem, '-_.' );
1471
1472 return 1 === preg_match( '#^(?:spacer|blank|placeholder|lazy-?loader|transparent|pixel|dummy)$#', $stem );
1473 }
1474
1475 /**
1476 * True when the tag already declares itself the LCP image via
1477 * `fetchpriority="high"`.
1478 *
1479 * Only "high" counts. `fetchpriority="low"` and `="auto"` say the opposite
1480 * (or nothing), and an image marked low-priority is a perfectly good
1481 * lazy-load candidate. Pure — unit-tested.
1482 */
1483 public static function has_high_fetchpriority( string $tag ): bool {
1484 return 1 === preg_match( '#\bfetchpriority\s*=\s*["\']?high\b#i', $tag );
1485 }
1486
1487 /**
1488 * True when the tag says it renders at a size other than the file's
1489 * intrinsic one — a `srcset`/`sizes` pair (the browser picks a
1490 * candidate) or an inline width/height style.
1491 *
1492 * Only guards the src-suffix fallback. The `wp-image-N` path stays
1493 * unguarded: attachment metadata is authoritative, and WordPress'
1494 * own `wp_filter_content_tags()` adds dimensions to responsive
1495 * images the same way. Pure — unit-tested.
1496 */
1497 public static function has_constrained_render( string $tag ): bool {
1498 // `\b` sits between `-` and `s`, so a bare \bsrcset also matched
1499 // `data-srcset` — a lazy attribute the browser has NOT applied yet.
1500 // That made an unset attribute suppress dimensions on exactly the
1501 // slider images this feature exists to size, for the same
1502 // accidental-text-match reason the URL lookup moved away from
1503 // (#333 review, issue 3).
1504 //
1505 // The anchor is a negative lookbehind rather than "start or
1506 // whitespace": HTML does not require a space between attributes, so
1507 // `alt="31"srcset="..."` slipped past a whitespace anchor and this
1508 // guard stopped firing — the tag then got the file's intrinsic size
1509 // stamped on it while the browser rendered a differently-shaped
1510 // srcset candidate. (#333 review round 3, issue 1)
1511 if ( preg_match( '#(?<![-\w])(?:srcset|sizes)\s*=#i', $tag ) ) {
1512 return true;
1513 }
1514 if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
1515 // width/height in the inline style wins over the attribute, so
1516 // the file's intrinsic size would disagree with the layout.
1517 return 1 === preg_match( '#(?:^|;)\s*(?:max-)?(?:width|height)\s*:#i', $m[1] );
1518 }
1519 return false;
1520 }
1521
1522 /** @param int[] $dims [width, height]. */
1523 private static function apply_dimensions( string $tag, array $dims, bool $has_w, bool $has_h ): string {
1524 // One dimension already present: derive the other from the file's
1525 // real aspect ratio rather than stamping its intrinsic size.
1526 //
1527 // A tag that says width="300" on a 1200x800 file renders 300x200. If
1528 // we wrote height="800" the browser would reserve a box two and a
1529 // half times too tall, then snap when the image painted — CREATING
1530 // the shift this feature exists to remove. Scaling keeps the reserved
1531 // box the shape the image will actually be.
1532 if ( $has_w !== $has_h ) {
1533 if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
1534 return $tag;
1535 }
1536 $from = $has_w ? 'width' : 'height';
1537 $declared = self::attr_int( $tag, $from );
1538 // A declared value we cannot read in pixels (`50%`, `auto`) means
1539 // we do not know the rendered size, so there is no ratio to scale
1540 // from. Stamping the intrinsic size here is exactly the bug this
1541 // branch exists to avoid, so the tag is left alone.
1542 if ( $declared <= 0 ) {
1543 return $tag;
1544 }
1545 if ( $has_w ) {
1546 $height = (int) round( $dims[1] * $declared / $dims[0] );
1547 return $height > 0 ? self::set_attr( $tag, 'height', (string) $height ) : $tag;
1548 }
1549 $width = (int) round( $dims[0] * $declared / $dims[1] );
1550 return $width > 0 ? self::set_attr( $tag, 'width', (string) $width ) : $tag;
1551 }
1552
1553 // A header that reported 0 for either side is not a measurement. Half
1554 // a dimension pair is worse than none: the browser reserves a box of
1555 // the wrong shape and still shifts when the real image lands.
1556 if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
1557 return $tag;
1558 }
1559
1560 if ( ! $has_w ) {
1561 $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
1562 }
1563 if ( ! $has_h ) {
1564 $tag = self::set_attr( $tag, 'height', (string) $dims[1] );
1565 }
1566 return $tag;
1567 }
1568
1569 /**
1570 * Read one numeric attribute off a tag.
1571 *
1572 * Returns 0 for anything that is not a plain number — `width="50%"` and
1573 * `width="auto"` are CSS-ish values whose pixel size we do not know, and
1574 * scaling from them would invent a box rather than reserve one.
1575 *
1576 * @param string $tag The tag.
1577 * @param string $name Attribute name.
1578 */
1579 private static function attr_int( string $tag, string $name ): int {
1580 // The value must be ENTIRELY digits. Matching a leading run would read
1581 // `width="50%"` as 50 and scale from a percentage as though it were
1582 // pixels — inventing a box rather than declining to guess.
1583 // Lookbehind for the same reason as set_attr(): `\bwidth=` also reads
1584 // `data-width=`, so a slider's own metadata was scaled from as though
1585 // it were a rendered dimension.
1586 if ( ! preg_match( '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(?:"(\d+)"|\'(\d+)\'|(\d+)(?=[\s/>]))#i', $tag, $m ) ) {
1587 return 0;
1588 }
1589 $value = '' !== ( $m[1] ?? '' ) ? $m[1] : ( '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' ) );
1590 return (int) $value;
1591 }
1592
1593 /**
1594 * WordPress names resized files `<name>-WxH.<ext>` — when the suffix is
1595 * present it IS the rendered size, resolvable with zero I/O (works for
1596 * CDN-hosted copies too). Pure — unit-tested.
1597 *
1598 * @return int[]|null [width, height] or null.
1599 */
1600 public static function parse_size_suffix( string $src ): ?array {
1601 $path = (string) preg_replace( '/[?#].*$/', '', $src );
1602 if ( preg_match( '#-(\d{1,4})x(\d{1,4})\.(?:jpe?g|png|gif|webp|avif)$#i', $path, $m ) ) {
1603 $w = (int) $m[1];
1604 $h = (int) $m[2];
1605 if ( $w > 0 && $h > 0 ) {
1606 return array( $w, $h );
1607 }
1608 }
1609 return null;
1610 }
1611
1612 /**
1613 * Intrinsic size of an image hosted on another domain.
1614 *
1615 * An image the site does not host is still an image whose dimensions
1616 * decide whether the page jumps while it loads. Refusing to look them up
1617 * was leaving real layout shift unfixed on any site that embeds media from
1618 * a CDN, a sister site, or a shared asset host — and telling the owner to
1619 * go and edit their content, which is not a fix a caching plugin should be
1620 * proud of.
1621 *
1622 * The reason for the old refusal was sound but too broad: a page render
1623 * must never block on somebody else's server. So this fetches only the
1624 * first few KB — enough for the header of every format WordPress
1625 * supports — with a short timeout, and caches the answer (successes AND
1626 * failures) so a URL is fetched once rather than once per pageview.
1627 *
1628 * By default it runs only when something has already warmed the cache
1629 * off-request (the preloader, a cron pass, WP-CLI). A visitor's request
1630 * therefore never waits on it. A site that would rather pay the cost
1631 * inline can opt in:
1632 *
1633 * add_filter( 'xspeed_lazy_remote_dimensions_inline', '__return_true' );
1634 *
1635 * and one that wants nothing fetched from other hosts at all can opt out:
1636 *
1637 * add_filter( 'xspeed_lazy_remote_dimensions', '__return_false' );
1638 *
1639 * @param string $src Absolute URL on another host.
1640 * @return int[]|null [width, height] or null when it cannot be resolved.
1641 */
1642 private static function remote_dimensions( string $src ): ?array {
1643 /**
1644 * Whether to resolve dimensions for images on other hosts at all.
1645 *
1646 * @param bool $enabled Default true.
1647 * @param string $src The image URL.
1648 */
1649 if ( ! apply_filters( 'xspeed_lazy_remote_dimensions', true, $src ) ) {
1650 return null;
1651 }
1652
1653 if ( ! function_exists( 'wp_remote_get' ) ) {
1654 return null;
1655 }
1656
1657 // Only http(s). A data: or blob: src has no server to ask.
1658 if ( ! preg_match( '#^https?://#i', $src ) ) {
1659 return null;
1660 }
1661
1662 /**
1663 * Whether a front-end request may perform the fetch itself.
1664 *
1665 * Off by default: the whole point of the cache is that a visitor
1666 * never waits on another host. Warm passes (cron, preloader, CLI)
1667 * set this true for themselves.
1668 *
1669 * @param bool $inline Default false.
1670 */
1671 $inline = (bool) apply_filters( 'xspeed_lazy_remote_dimensions_inline', self::$warming );
1672 if ( ! $inline ) {
1673 return null;
1674 }
1675
1676 // 32KB covers the header of JPEG, PNG, GIF, WebP and AVIF. Range is a
1677 // request, not a guarantee — a server that ignores it sends the whole
1678 // file, which the timeout still bounds.
1679 $resp = wp_remote_get(
1680 $src,
1681 array(
1682 'timeout' => 5,
1683 'headers' => array( 'Range' => 'bytes=0-32767' ),
1684 'user-agent' => 'xSpeed/dimension-probe',
1685 )
1686 );
1687 if ( is_wp_error( $resp ) ) {
1688 return null;
1689 }
1690 $code = (int) wp_remote_retrieve_response_code( $resp );
1691 if ( 200 !== $code && 206 !== $code ) {
1692 return null;
1693 }
1694
1695 $body = (string) wp_remote_retrieve_body( $resp );
1696 if ( '' === $body ) {
1697 return null;
1698 }
1699
1700 // getimagesizefromstring reads the header out of the bytes we already
1701 // have — no second request, no temp file.
1702 $size = @getimagesizefromstring( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a truncated or non-image body must degrade to null, not warn.
1703 if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
1704 return array( (int) $size[0], (int) $size[1] );
1705 }
1706 return null;
1707 }
1708
1709 /**
1710 * Resolve dimensions from an image URL, cheapest first:
1711 * 1. `-WxH` filename suffix (no I/O).
1712 * 2. Intrinsic size of the local file when src is under uploads
1713 * (getimagesize on the header — no remote fetches, ever).
1714 * 3. Attachment lookup by URL (uploads-hosted src only).
1715 * Results — including failures — are cached per URL in a bounded
1716 * transient so each image pays the lookup once, not per pageview.
1717 *
1718 * @return int[]|null [width, height] or null.
1719 */
1720 private static function dimensions_for_src( string $src ): ?array {
1721 $suffix = self::parse_size_suffix( $src );
1722 if ( $suffix ) {
1723 return $suffix;
1724 }
1725
1726 if ( ! function_exists( 'wp_get_upload_dir' ) || ! function_exists( 'get_transient' ) ) {
1727 return null;
1728 }
1729 $uploads = wp_get_upload_dir();
1730 $baseurl = isset( $uploads['baseurl'] ) ? (string) $uploads['baseurl'] : '';
1731 $basedir = isset( $uploads['basedir'] ) ? (string) $uploads['basedir'] : '';
1732
1733 // The cache is consulted BEFORE the local/remote split, so a remote
1734 // image pays its lookup once for the life of the transient rather
1735 // than once per page render.
1736 if ( null === self::$src_dims_cache ) {
1737 $stored = get_transient( 'xspeed_img_dims' );
1738 self::$src_dims_cache = is_array( $stored ) ? $stored : array();
1739 }
1740 $key = md5( $src );
1741 if ( array_key_exists( $key, self::$src_dims_cache ) ) {
1742 $hit = self::$src_dims_cache[ $key ];
1743 if ( is_array( $hit ) ) {
1744 return $hit;
1745 }
1746 // A cached FAILURE, not a cached answer. A front-end render
1747 // honours it — that is the whole point, one failed lookup must
1748 // not cost a request on every pageview. A warm pass does NOT:
1749 // it was asked to resolve these, nothing is waiting on it, and
1750 // the usual reason for a failure is a moment of bad luck rather
1751 // than an image that can never be measured.
1752 //
1753 // Without this, one slow response poisoned a URL for the life of
1754 // the transient. It happened on a real site: 15 images cached as
1755 // failures, and every later warm returned "resolved: 0" while the
1756 // page kept shifting.
1757 if ( ! self::$warming || ! self::failure_is_retryable( $hit ) ) {
1758 return null;
1759 }
1760 }
1761
1762 $is_local = '' !== $baseurl && '' !== $basedir && 0 === strpos( $src, $baseurl );
1763
1764 $dims = null;
1765
1766 if ( $is_local ) {
1767 $relative = (string) preg_replace( '/[?#].*$/', '', substr( $src, strlen( $baseurl ) ) );
1768 if ( false === strpos( $relative, '..' ) ) {
1769 $file = $basedir . $relative;
1770 if ( is_file( $file ) ) {
1771 $size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-image/corrupt file must degrade to null, not warn.
1772 if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
1773 $dims = array( (int) $size[0], (int) $size[1] );
1774 }
1775 }
1776 }
1777
1778 // File not on disk (offloaded originals) — one DB lookup by URL.
1779 if ( null === $dims && function_exists( 'attachment_url_to_postid' ) ) {
1780 $id = (int) attachment_url_to_postid( $src );
1781 if ( $id > 0 ) {
1782 $dims = self::dimensions_for_attachment( $id );
1783 }
1784 }
1785 } else {
1786 $dims = self::remote_dimensions( $src );
1787 }
1788
1789 // Cache success AND failure (0), bounded so the blob can't grow
1790 // unbounded on media-heavy sites.
1791 if ( count( self::$src_dims_cache ) >= 500 ) {
1792 self::$src_dims_cache = array_slice( self::$src_dims_cache, 250, null, true );
1793 }
1794 // A resolved size is permanent — the file's intrinsic dimensions do
1795 // not change under the same URL. A failure is a snapshot of one
1796 // moment, so it is stored as a TIMESTAMP rather than a bare 0 and
1797 // stops counting after a while. Storing both the same way is what let
1798 // a transient blip look identical to "this can never be measured".
1799 self::$src_dims_cache[ $key ] = null === $dims ? time() : $dims;
1800 if ( function_exists( 'set_transient' ) ) {
1801 set_transient( 'xspeed_img_dims', self::$src_dims_cache, DAY_IN_SECONDS );
1802 }
1803 return $dims;
1804 }
1805
1806 /**
1807 * @return int[]|null [width, height] or null
1808 */
1809 private static function dimensions_for_attachment( int $attachment_id ): ?array {
1810 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
1811 return null;
1812 }
1813 $meta = wp_get_attachment_metadata( $attachment_id );
1814 if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
1815 return null;
1816 }
1817 return array( (int) $meta['width'], (int) $meta['height'] );
1818 }
1819
1820 private static function is_excluded( string $tag, array $opts ): bool {
1821 $excluded = $opts['excluded_images'] ?? array();
1822 if ( ! is_array( $excluded ) || empty( $excluded ) ) {
1823 return false;
1824 }
1825 foreach ( $excluded as $pattern ) {
1826 $pattern = (string) $pattern;
1827 if ( '' === $pattern ) {
1828 continue;
1829 }
1830 if ( false !== stripos( $tag, $pattern ) ) {
1831 return true;
1832 }
1833 }
1834 return false;
1835 }
1836
1837 /**
1838 * Replace <script>, <style>, <noscript>, <pre>, <code> blocks with
1839 * placeholder tokens before tag rewriting. Returns [stubbed_html,
1840 * stubs_map]. Restore via restore_safe_blocks().
1841 *
1842 * @return array{0: string, 1: array<string,string>}
1843 */
1844 private static function stub_safe_blocks( string $html ): array {
1845 $stubs = array();
1846 $re = '#<(script|style|noscript|pre|code)\b[^>]*>.*?</\1>#is';
1847 $out = preg_replace_callback(
1848 $re,
1849 static function ( $m ) use ( &$stubs ) {
1850 $key = '<!--XSPEED_LAZY_STUB_' . count( $stubs ) . '-->';
1851 $stubs[ $key ] = $m[0];
1852 return $key;
1853 },
1854 $html
1855 );
1856 return array( (string) $out, $stubs );
1857 }
1858
1859 private static function restore_safe_blocks( string $html, array $stubs ): string {
1860 if ( empty( $stubs ) ) {
1861 return $html;
1862 }
1863 return strtr( $html, $stubs );
1864 }
1865
1866 private static function opts(): array {
1867 if ( null === self::$opts ) {
1868 self::$opts = Settings_Manager::get( 'lazy' );
1869 }
1870 return self::$opts;
1871 }
1872
1873 /**
1874 * How long a failed lookup is trusted before a warm pass tries again.
1875 *
1876 * Long enough that a genuinely unmeasurable URL is not re-fetched on every
1877 * crawl, short enough that an outage does not cost a day of layout shift.
1878 */
1879 private const FAILURE_RETRY_AFTER = 900; // 15 minutes.
1880
1881 /**
1882 * Whether a stored failure is old enough to be worth retrying.
1883 *
1884 * Legacy entries were written as a bare `0` with no timestamp. Those are
1885 * always retryable: they predate this distinction, and one extra request
1886 * for each is a far better outcome than leaving a site permanently unable
1887 * to resolve images it could resolve today.
1888 *
1889 * @param mixed $entry Stored cache value.
1890 */
1891 private static function failure_is_retryable( $entry ): bool {
1892 if ( ! is_int( $entry ) || $entry <= 0 ) {
1893 return true; // legacy `0`, or nonsense — retry.
1894 }
1895 return ( time() - $entry ) >= self::FAILURE_RETRY_AFTER;
1896 }
1897
1898 /**
1899 * Whether this URL's dimensions are already known (or known-unresolvable).
1900 *
1901 * Lets a caller skip URLs that would cost nothing to look up, so a bounded
1902 * batch spends its budget on images it has not seen. Without this a capped
1903 * collector re-picks the same first N images every pass — they are always
1904 * in the same DOM order — and anything past the cap is never resolved at
1905 * all, however many times the crawl runs.
1906 *
1907 * Reads the cache only; never fetches.
1908 *
1909 * @param string $src Absolute image URL.
1910 */
1911 public static function dimensions_known( string $src ): bool {
1912 if ( ! function_exists( 'get_transient' ) ) {
1913 return false;
1914 }
1915 if ( null === self::$src_dims_cache ) {
1916 $stored = get_transient( 'xspeed_img_dims' );
1917 self::$src_dims_cache = is_array( $stored ) ? $stored : array();
1918 }
1919 $key = md5( $src );
1920 if ( ! array_key_exists( $key, self::$src_dims_cache ) ) {
1921 return false;
1922 }
1923 $hit = self::$src_dims_cache[ $key ];
1924 if ( is_array( $hit ) ) {
1925 return true;
1926 }
1927 // A failure that has aged out is NOT known — reporting it as known
1928 // would make the crawl skip the one URL that has become worth
1929 // retrying.
1930 return ! self::failure_is_retryable( $hit );
1931 }
1932
1933 /**
1934 * Resolve and cache dimensions for a batch of image URLs.
1935 *
1936 * Meant for anything running OFF a visitor's request — the preloader
1937 * crawling the sitemap, a cron pass, `wp xspeed lazy warm-dimensions`.
1938 * Once warmed, the front end serves the dimensions from cache, so the
1939 * layout shift is fixed without a single visitor waiting on another host.
1940 *
1941 * @param string[] $urls Absolute image URLs.
1942 * @return int How many were resolved.
1943 */
1944 public static function warm_dimensions( array $urls ): int {
1945 $resolved = 0;
1946 self::$warming = true;
1947 try {
1948 foreach ( array_unique( $urls ) as $url ) {
1949 if ( ! is_string( $url ) || '' === $url ) {
1950 continue;
1951 }
1952 if ( self::dimensions_for_src( $url ) ) {
1953 $resolved++;
1954 }
1955 }
1956 } finally {
1957 // In a finally so a throw mid-batch cannot leave the flag set and
1958 // silently turn every later front-end render into a fetcher.
1959 self::$warming = false;
1960 }
1961 return $resolved;
1962 }
1963
1964 /**
1965 * Test-only: clear cached opts + counter between assertions.
1966 */
1967 public static function reset_state(): void {
1968 self::$opts = null;
1969 self::$image_counter = 0;
1970 self::$priority_claimed = false;
1971 self::$hidden_ranges = array();
1972 self::$in_hidden = false;
1973 self::$priority_reserved = false;
1974 self::$background_counter = 0;
1975 self::$src_dims_cache = null;
1976 self::$facade_used = false;
1977 self::$warming = false;
1978 // Both gate whether the autoplay restorer is printed. Left set, one
1979 // page carrying a video would make every later response in the same
1980 // process ship the script — and, worse for the preloader, a warmed
1981 // page could inherit a decision made for a different URL.
1982 self::$deferred_autoplay = false;
1983 self::$has_deferred_video_markup = false;
1984 }
1985 }
1986