| @@ -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 |