PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 All 32 releases
← All changes | includes/class-lazy-loader.php +236 -2 1.3.0 → 1.3.6 View file →
@@ -34,8 +34,18 @@
34 34 */
35 35 private static $image_counter = 0;
36 36
37 37 /**
38 + * Eager budget for inline-style backgrounds. Separate from images: a
39 + * hero is either an <img> or a background, and the passes run one after
40 + * the other, so one shared counter would hand the budget to whichever
41 + * pass runs first rather than to what sits first on the page.
42 + *
43 + * @var int
44 + */
45 + private static $background_counter = 0;
46 +
47 + /**
38 48 * Settings cache (one read per request).
39 49 *
40 50 * @var array|null
41 51 */
@@ -97,8 +107,13 @@
97 107 }
98 108 if ( ! empty( $opts['lazy_iframes'] ) ) {
99 109 $work = self::apply_pass( $work, $tag_re( 'iframe' ), array( __CLASS__, 'rewrite_iframe' ) );
100 110 }
111 + // Every opening tag is a candidate, so skip the pass on content with
112 + // no url( at all, which is most of it.
113 + if ( ! empty( $opts['lazy_background_images'] ) && false !== stripos( $work, 'url(' ) ) {
114 + $work = self::apply_pass( $work, '#<[a-z][a-z0-9-]*\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', array( __CLASS__, 'rewrite_background' ) );
115 + }
101 116 // Facade runs AFTER the lazy pass, deliberately. The facade keeps the
102 117 // original tag inside <noscript> as the JS-less fallback, and that
103 118 // fallback should carry loading="lazy" too — running this first would
104 119 // produce an eager iframe for exactly the visitors least able to
@@ -113,9 +128,9 @@
113 128 // unclosed iframe can't make the match run on to a LATER embed's
114 129 // closing tag and eat everything in between; an iframe with no
115 130 // closing tag simply doesn't match and passes through untouched.
116 131 if ( ! empty( $opts['video_facade'] ) ) {
117 - $work = self::apply_pass(
132 + $work = self::apply_facade_pass(
118 133 $work,
119 134 '#(<iframe\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?iframe\b).)*)</iframe\s*>#is',
120 135 array( __CLASS__, 'rewrite_iframe_facade' )
121 136 );
@@ -133,9 +148,9 @@
133 148 // <noscript> as the JS-less fallback, and that copy should carry
134 149 // preload="none" too. Same whole-element, tempered match so an
135 150 // unclosed <video> passes through rather than eating siblings.
136 151 if ( ! empty( $opts['video_facade'] ) ) {
137 - $work = self::apply_pass(
152 + $work = self::apply_facade_pass(
138 153 $work,
139 154 '#(<video\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?video\b).)*)</video\s*>#is',
140 155 array( __CLASS__, 'rewrite_video_facade' )
141 156 );
@@ -144,8 +159,131 @@
144 159 return self::restore_safe_blocks( $work, $stubs );
145 160 }
146 161
147 162 /**
163 + * Hidden popup/lightbox templates a facade must never replace into.
164 + *
165 + * A lightbox plugin ships its player iframe in a hidden template div
166 + * and moves that markup into the popup when clicked. Facading the
167 + * template swaps its iframe for the play button, and the popup then
168 + * opens around a button its own CSS only sizes for an iframe — a blank
169 + * modal (found live: EmbedPress's "See it in action", an Essential
170 + * Addons lightbox whose Magnific popup opened empty). The template
171 + * iframe already carries loading="lazy" from the pass above, and a
172 + * hidden lazy iframe never loads until shown — so skipping the facade
173 + * here costs nothing on page load.
174 + */
175 + private const POPUP_TEMPLATE_CLASSES = array(
176 + 'eael-lightbox-popup-window', // Essential Addons lightbox template.
177 + 'mfp-hide', // Magnific Popup inline template.
178 + 'lity-hide', // Lity inline template.
179 + );
180 +
181 + /**
182 + * A facade pass that leaves popup-template containers alone.
183 + *
184 + * Same PCRE-bail contract as apply_pass(). Ranges are byte spans in
185 + * $html; PREG_OFFSET_CAPTURE offsets refer to the original subject, so
186 + * earlier replacements never shift the comparison.
187 + *
188 + * @param callable $callback Rewrite callback taking plain string matches.
189 + */
190 + private static function apply_facade_pass( string $html, string $pattern, callable $callback ): string {
191 + $ranges = self::popup_template_ranges( $html );
192 + if ( empty( $ranges ) ) {
193 + return self::apply_pass( $html, $pattern, $callback );
194 + }
195 +
196 + $result = preg_replace_callback(
197 + $pattern,
198 + static function ( array $m ) use ( $callback, $ranges ): string {
199 + $offset = (int) $m[0][1];
200 + foreach ( $ranges as $range ) {
201 + if ( $offset >= $range[0] && $offset < $range[1] ) {
202 + return (string) $m[0][0]; // Inside a template: untouched.
203 + }
204 + }
205 + return (string) call_user_func(
206 + $callback,
207 + array_map( static fn( $group ): string => (string) $group[0], $m )
208 + );
209 + },
210 + $html,
211 + -1,
212 + $count,
213 + PREG_OFFSET_CAPTURE
214 + );
215 +
216 + return is_string( $result ) ? $result : $html;
217 + }
218 +
219 + /**
220 + * Byte spans of every popup-template container in $html.
221 + *
222 + * The span is found by counting the container's own tag name to its
223 + * balancing close — templates are plain nested divs, so a same-tag
224 + * depth count is enough; a container whose close is never found is
225 + * dropped rather than guessed at (its iframes stay facade-eligible,
226 + * the pre-fix behavior).
227 + *
228 + * @return array<int, array{0:int, 1:int}>
229 + */
230 + private static function popup_template_ranges( string $html ): array {
231 + /**
232 + * Filter the class names marking a hidden popup/lightbox template
233 + * whose contents the video facade must leave alone.
234 + *
235 + * @param string[] $classes
236 + */
237 + $classes = (array) apply_filters( 'xspeed_video_facade_popup_classes', self::POPUP_TEMPLATE_CLASSES );
238 + $classes = array_values(
239 + array_filter(
240 + array_map( 'strval', $classes ),
241 + static fn( string $c ): bool => (bool) preg_match( '/^[A-Za-z0-9_-]+$/', $c )
242 + )
243 + );
244 + if ( empty( $classes ) ) {
245 + return array();
246 + }
247 +
248 + $pattern = '#<(div|section|span|aside)\b[^>]*\bclass\s*=\s*(["\'])[^"\']*(?<![A-Za-z0-9_-])(?:'
249 + . implode( '|', array_map( 'preg_quote', $classes ) )
250 + . ')(?![A-Za-z0-9_-])[^"\']*\2[^>]*>#i';
251 + if ( ! preg_match_all( $pattern, $html, $m, PREG_OFFSET_CAPTURE ) ) {
252 + return array();
253 + }
254 +
255 + $ranges = array();
256 + foreach ( $m[0] as $i => $hit ) {
257 + $start = (int) $hit[1];
258 + $end = self::same_tag_end( $html, $start, strtolower( (string) $m[1][ $i ][0] ) );
259 + if ( null !== $end ) {
260 + $ranges[] = array( $start, $end );
261 + }
262 + }
263 + return $ranges;
264 + }
265 +
266 + /**
267 + * The offset just past the close tag balancing the open tag at $offset,
268 + * counting only $tag's own opens and closes. Null when it never closes.
269 + */
270 + private static function same_tag_end( string $html, int $offset, string $tag ): ?int {
271 + $depth = 0;
272 + $cursor = $offset;
273 + $re = '#<(/?)' . preg_quote( $tag, '#' ) . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
274 + while ( preg_match( $re, $html, $t, PREG_OFFSET_CAPTURE, $cursor ) ) {
275 + $cursor = (int) $t[0][1] + strlen( (string) $t[0][0] );
276 + if ( '' === $t[1][0] ) {
277 + ++$depth;
278 + } elseif ( --$depth <= 0 ) {
279 + return $cursor;
280 + }
281 + }
282 + return null;
283 + }
284 +
285 + /**
148 286 * Run one rewrite pass, keeping the input if PCRE bails.
149 287 *
150 288 * preg_replace_callback() returns null when it hits the backtrack or
151 289 * recursion limit — on a large page that would otherwise blank the
@@ -218,8 +356,103 @@
218 356
219 357 return $tag;
220 358 }
221 359
360 + /** Class that holds an element's background back until it nears the viewport. */
361 + public const LAZY_BG_CLASS = 'xspeed-lazy-bg';
362 +
363 + private static function rewrite_background( array $m ): string {
364 + $tag = $m[0];
365 + if ( false === stripos( $tag, 'url(' ) ) {
366 + return $tag;
367 + }
368 + if ( ! preg_match( '#\sstyle\s*=\s*(?:"([^"]*)"|\'([^\']*)\')#i', $tag, $sm ) ) {
369 + return $tag;
370 + }
371 + $style = html_entity_decode( isset( $sm[2] ) && '' !== $sm[2] ? $sm[2] : $sm[1], ENT_QUOTES );
372 + if ( ! self::has_deferrable_background( $style ) ) {
373 + return $tag;
374 + }
375 + $opts = self::opts();
376 + if ( false !== stripos( $tag, 'data-skip-lazy' )
377 + || false !== stripos( $tag, 'data-no-lazy' )
378 + || self::is_excluded( $tag, $opts ) ) {
379 + return $tag;
380 + }
381 + self::$background_counter++;
382 + if ( self::$background_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) ) ) {
383 + return $tag;
384 + }
385 + return self::add_class( $tag, self::LAZY_BG_CLASS );
386 + }
387 +
388 + /**
389 + * Whether an inline style sets a background image we can hold back.
390 + *
391 + * The hold is a stylesheet rule with !important, which beats a normal
392 + * inline declaration but loses to an inline !important one; those are
393 + * left alone. data: URIs cost no request, so there is nothing to save.
394 + */
395 + public static function has_deferrable_background( string $style ): bool {
396 + $found = false;
397 + foreach ( explode( ';', $style ) as $decl ) {
398 + if ( ! preg_match( '#^\s*background(?:-image)?\s*:(.*)$#is', $decl, $dm ) ) {
399 + continue;
400 + }
401 + $value = $dm[1];
402 + if ( false !== stripos( $value, '!important' ) ) {
403 + return false;
404 + }
405 + if ( preg_match( '#url\(\s*[\'"]?(?!data:)[^)\s\'"]#i', $value ) ) {
406 + $found = true;
407 + }
408 + }
409 + return $found;
410 + }
411 +
412 + private static function add_class( string $tag, string $class ): string {
413 + if ( preg_match( '#\sclass\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>=`]+))#i', $tag, $cm, PREG_OFFSET_CAPTURE ) ) {
414 + // An unquoted value (class=hero) is rewritten as a quoted one.
415 + // Adding a second class attribute would lose it: browsers keep
416 + // the first of two duplicates.
417 + if ( isset( $cm[3] ) && -1 !== $cm[3][1] ) {
418 + $group = $cm[3];
419 + return substr( $tag, 0, $group[1] ) . '"' . $group[0] . ' ' . $class . '"' . substr( $tag, $group[1] + strlen( $group[0] ) );
420 + }
421 + $group = isset( $cm[2] ) && -1 !== $cm[2][1] ? $cm[2] : $cm[1];
422 + $value = trim( $group[0] . ' ' . $class );
423 + return substr( $tag, 0, $group[1] ) . $value . substr( $tag, $group[1] + strlen( $group[0] ) );
424 + }
425 + return (string) preg_replace( '#^<([a-z][a-z0-9-]*)#i', '<$1 class="' . $class . '"', $tag, 1 );
426 + }
427 +
428 + /**
429 + * Scoped to a class the script puts on <html>, so without JavaScript the
430 + * rule never matches and every background loads normally.
431 + */
432 + public static function background_style(): string {
433 + return '.xspeed-lazy-bg-js .' . self::LAZY_BG_CLASS . '{background-image:none!important}';
434 + }
435 +
436 + /**
437 + * The MutationObserver is what keeps a background from staying blank:
438 + * carousel clones, infinite scroll and builder re-renders insert
439 + * elements carrying the class after DOMContentLoaded, and a one-time
440 + * scan never saw them.
441 + */
442 + public static function background_script(): string {
443 + return <<<'JS'
444 +(function(d,w){var h=d.documentElement,C='xspeed-lazy-bg';h.className+=' xspeed-lazy-bg-js';
445 +function show(el){el.classList.remove(C);}
446 +var io='IntersectionObserver' in w?new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){show(e.target);io.unobserve(e.target);}});},{rootMargin:'300px 0px'}):null;
447 +function watch(el){if(io)io.observe(el);else show(el);}
448 +function scan(r){if(r.classList&&r.classList.contains(C))watch(r);if(r.querySelectorAll){var l=r.querySelectorAll('.'+C);for(var i=0;i<l.length;i++)watch(l[i]);}}
449 +if('MutationObserver' in w)new MutationObserver(function(ms){for(var j=0;j<ms.length;j++){var a=ms[j].addedNodes;for(var i=0;i<a.length;i++)if(a[i].nodeType===1)scan(a[i]);}}).observe(h,{childList:true,subtree:true});
450 +function run(){scan(d);}
451 +if(d.readyState==='loading')d.addEventListener('DOMContentLoaded',run);else run();})(document,window);
452 +JS;
453 + }
454 +
222 455 private static function rewrite_iframe( array $m ): string {
223 456 $tag = $m[0];
224 457 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
225 458 return $tag;
@@ -1389,8 +1622,9 @@
1389 1622 */
1390 1623 public static function reset_state(): void {
1391 1624 self::$opts = null;
1392 1625 self::$image_counter = 0;
1626 + self::$background_counter = 0;
1393 1627 self::$src_dims_cache = null;
1394 1628 self::$facade_used = false;
1395 1629 self::$warming = false;
1396 1630 // Both gate whether the autoplay restorer is printed. Left set, one