| @@ -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; |
| @@ -642,9 +875,13 @@ | ||
| 642 | 875 | JS; |
| 643 | 876 | } |
| 644 | 877 | |
| 645 | 878 | private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string { |
| 646 | - $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i'; | |
| 879 | + // Lookbehind, not `\b`: writing `width` onto a tag carrying | |
| 880 | + // `data-width="800"` matched the DATA attribute and rewrote it to the | |
| 881 | + // file's intrinsic size — corrupting a slider's own configuration and | |
| 882 | + // leaving the tag with no real width at all. (#333 review round 3) | |
| 883 | + $pattern = '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i'; | |
| 647 | 884 | if ( preg_match( $pattern, $tag ) ) { |
| 648 | 885 | if ( $only_if_missing ) { |
| 649 | 886 | return $tag; |
| 650 | 887 | } |
| @@ -664,10 +901,19 @@ | ||
| 664 | 901 | * filesystem when src points at the uploads dir. Skip when we can't |
| 665 | 902 | * resolve cheaply — never block the request on a remote getimagesize. |
| 666 | 903 | */ |
| 667 | 904 | private static function ensure_dimensions( string $tag ): string { |
| 668 | - $has_w = (bool) preg_match( '#\bwidth\s*=#i', $tag ); | |
| 669 | - $has_h = (bool) preg_match( '#\bheight\s*=#i', $tag ); | |
| 905 | + // `\b` sits between `-` and `w`, so a bare `\bwidth=` also matched | |
| 906 | + // `data-width=` — a slider's own metadata, not a rendered dimension. | |
| 907 | + // The tag then looked half-sized: apply_dimensions() derived the other | |
| 908 | + // dimension from the ratio and wrote ONLY that, so a tag carrying | |
| 909 | + // `data-width="800"` came out with `height="533"` and no width and | |
| 910 | + // laid out at 41x30. Harmless while the URL never resolved; this | |
| 911 | + // branch made it resolve, which is what exposed it. Half a pair is | |
| 912 | + // worse than none, as the docblock below already says. | |
| 913 | + // (#333 review round 3, issue 2) | |
| 914 | + $has_w = (bool) preg_match( '#(?<![-\w])width\s*=#i', $tag ); | |
| 915 | + $has_h = (bool) preg_match( '#(?<![-\w])height\s*=#i', $tag ); | |
| 670 | 916 | if ( $has_w && $has_h ) { |
| 671 | 917 | return $tag; |
| 672 | 918 | } |
| 673 | 919 | |
| @@ -685,12 +931,15 @@ | ||
| 685 | 931 | // on those images (issue #37). Resolve from the src instead, but only |
| 686 | 932 | // when the tag doesn't already tell us it renders at some other size: |
| 687 | 933 | // stamping the intrinsic file size onto a responsive or CSS-sized |
| 688 | 934 | // image would CREATE the layout shift this feature exists to remove. |
| 689 | - if ( ! self::has_constrained_render( $tag ) && preg_match( '#\bsrc\s*=\s*["\']([^"\']+)["\']#i', $tag, $sm ) ) { | |
| 690 | - $dims = self::dimensions_for_src( $sm[1] ); | |
| 691 | - if ( $dims ) { | |
| 692 | - return self::apply_dimensions( $tag, $dims, $has_w, $has_h ); | |
| 935 | + if ( ! self::has_constrained_render( $tag ) ) { | |
| 936 | + $url = self::resolvable_image_url( $tag ); | |
| 937 | + if ( '' !== $url ) { | |
| 938 | + $dims = self::dimensions_for_src( $url ); | |
| 939 | + if ( $dims ) { | |
| 940 | + return self::apply_dimensions( $tag, $dims, $has_w, $has_h ); | |
| 941 | + } | |
| 693 | 942 | } |
| 694 | 943 | } |
| 695 | 944 | |
| 696 | 945 | // Couldn't resolve. Leave the tag alone — better no dimensions |
| @@ -698,8 +947,189 @@ | ||
| 698 | 947 | return $tag; |
| 699 | 948 | } |
| 700 | 949 | |
| 701 | 950 | /** |
| 951 | + * The URL to measure an image by: its real `src`, or the lazy-loading | |
| 952 | + * attribute holding the URL when `src` is absent or a placeholder. | |
| 953 | + * | |
| 954 | + * Page-builder sliders (Essential Blocks among them) ship the image with | |
| 955 | + * NO `src` at all — the URL lives in `data-lazy`, and their own JS moves | |
| 956 | + * it across at runtime. Resolving only from `src` left every one of those | |
| 957 | + * images without dimensions (issue #328, the miss that #37 did not cover: | |
| 958 | + * that one was about the missing `wp-image-N` class, this one is about the | |
| 959 | + * URL not being in `src` in the first place). | |
| 960 | + * | |
| 961 | + * A placeholder `src` — a data: URI or the 1x1 spacer GIF these libraries | |
| 962 | + * use — is treated as absent: measuring it would stamp the spacer's size | |
| 963 | + * onto the tag and CREATE a layout shift. | |
| 964 | + * | |
| 965 | + * Note the explicit `(?<![-\w])src` boundary. `\bsrc=` also matches the | |
| 966 | + * tail of `data-src=` and `data-lazy-src=` (a hyphen is a non-word | |
| 967 | + * character, so `\b` sits between `-` and `s`), which is why those two | |
| 968 | + * attributes happened to work before this method existed while `data-lazy` | |
| 969 | + * and `data-original` did not. Relying on that accident meant the URL a | |
| 970 | + * tag was measured by depended on how its attribute was spelled. | |
| 971 | + * | |
| 972 | + * Pure — unit-tested — EXCEPT when `$may_measure` is true and every | |
| 973 | + * candidate was refused by name, which is the one branch that touches the | |
| 974 | + * filesystem. Callers that are themselves arranging a measurement pass | |
| 975 | + * false; see the note at that branch. | |
| 976 | + * | |
| 977 | + * @param string $tag The <img> tag. | |
| 978 | + * @param bool $may_measure Whether a name-refused URL may be settled by | |
| 979 | + * reading the file. False for the warm-up | |
| 980 | + * collector, which would otherwise deadlock. | |
| 981 | + */ | |
| 982 | + public static function resolvable_image_url( string $tag, bool $may_measure = true ): string { | |
| 983 | + $src = ''; | |
| 984 | + $named_out = ''; | |
| 985 | + if ( preg_match( '#(?<![-\w])src\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) { | |
| 986 | + $src = trim( $m[1] ); | |
| 987 | + if ( '' !== $src && ! self::is_placeholder_src( $src ) ) { | |
| 988 | + return $src; | |
| 989 | + } | |
| 990 | + } | |
| 991 | + | |
| 992 | + foreach ( array( 'data-lazy', 'data-src', 'data-lazy-src', 'data-original' ) as $attr ) { | |
| 993 | + // Anchor with a negative lookbehind, not `\b` and not | |
| 994 | + // whitespace. `\b` sits between `-` and `d`, so a bare | |
| 995 | + // `\bdata-src=` also matched the TAIL of `x-data-src=` and took | |
| 996 | + // the wrong image's URL — worse than no size, because it reserves | |
| 997 | + // a wrongly shaped box and CAUSES the shift. | |
| 998 | + // | |
| 999 | + // Requiring whitespace instead was my first fix and it was wrong: | |
| 1000 | + // attributes are not always separated by one (`alt="31"srcset=` | |
| 1001 | + // is valid), so that anchor silently stopped matching and handed | |
| 1002 | + // back a size for a tag the guard should have skipped. The | |
| 1003 | + // lookbehind rejects the same prefixed decoys without depending on | |
| 1004 | + // spacing, and is what `src` already uses two methods below. | |
| 1005 | + // (#333 review rounds 2 and 3, issue 1) | |
| 1006 | + if ( preg_match( '#(?<![-\w])' . preg_quote( $attr, '#' ) . '\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) { | |
| 1007 | + $url = trim( $m[1] ); | |
| 1008 | + if ( '' === $url ) { | |
| 1009 | + continue; | |
| 1010 | + } | |
| 1011 | + if ( ! self::is_placeholder_src( $url ) ) { | |
| 1012 | + return $url; | |
| 1013 | + } | |
| 1014 | + // Refused on its NAME. Remember it — if nothing else in the | |
| 1015 | + // tag resolves, the file itself gets the final say below. | |
| 1016 | + if ( '' === $named_out ) { | |
| 1017 | + $named_out = $url; | |
| 1018 | + } | |
| 1019 | + } | |
| 1020 | + } | |
| 1021 | + | |
| 1022 | + // Every candidate was refused on its NAME alone. A name is a guess; | |
| 1023 | + // the file is the fact. Someone who uploads a photograph called | |
| 1024 | + // `placeholder.jpg` — an entirely ordinary thing to find in a media | |
| 1025 | + // library — got no dimensions at all, and neither did any of the | |
| 1026 | + // copies WordPress generates from it, so the layout shift this | |
| 1027 | + // feature removes came straight back for those images with nothing on | |
| 1028 | + // screen to explain why. (#333 review round 2, issue 1) | |
| 1029 | + // | |
| 1030 | + // Only reached when nothing else in the tag resolved, so the cost is a | |
| 1031 | + // lookup that was about to be skipped entirely, never an extra one. | |
| 1032 | + // A genuine stand-in fails this test on its own merits: a data: URI | |
| 1033 | + // never gets here, and a 1x1 spacer measures 1x1. | |
| 1034 | + // | |
| 1035 | + // The lazy attribute is preferred over `src`, matching the order | |
| 1036 | + // above: when a tag carries both, the lazy one names the real image | |
| 1037 | + // and `src` holds the stand-in. | |
| 1038 | + // The warm-up collector passes false here, and must. Deciding this by | |
| 1039 | + // MEASURING is circular for the caller whose whole job is to arrange | |
| 1040 | + // the measurement: remote lookups are gated off until `$warming` is | |
| 1041 | + // true, `$warming` only becomes true inside warm_dimensions(), and | |
| 1042 | + // warm_dimensions() is never reached because this returned ''. A | |
| 1043 | + // remote `placeholder.jpg` — a real photograph on a CDN — was warmable | |
| 1044 | + // before this branch and stopped being, with a failure cached against | |
| 1045 | + // it for good measure. The collector takes the URL the tag offers and | |
| 1046 | + // lets warm_dimensions() be the thing that decides. | |
| 1047 | + // (#333 review round 3, issue 3) | |
| 1048 | + if ( ! $may_measure ) { | |
| 1049 | + return '' !== $named_out ? $named_out : $src; | |
| 1050 | + } | |
| 1051 | + | |
| 1052 | + foreach ( array( $named_out, $src ) as $candidate ) { | |
| 1053 | + if ( '' !== $candidate && self::is_real_image( $candidate ) ) { | |
| 1054 | + return $candidate; | |
| 1055 | + } | |
| 1056 | + } | |
| 1057 | + | |
| 1058 | + return ''; | |
| 1059 | + } | |
| 1060 | + | |
| 1061 | + /** | |
| 1062 | + * Does this URL resolve to something too big to be a lazy-load stand-in? | |
| 1063 | + * | |
| 1064 | + * The stand-ins this guards against are 1x1 spacers and inline data: URIs. | |
| 1065 | + * Anything with real extent is a real image, whatever it is called — which | |
| 1066 | + * is what lets a photograph named `placeholder.jpg` keep its dimensions | |
| 1067 | + * while `spacer.gif` still loses them. | |
| 1068 | + * | |
| 1069 | + * Deliberately conservative: an unresolvable URL returns false, so the | |
| 1070 | + * name-based verdict stands and the tag is left alone. Better no | |
| 1071 | + * dimensions than wrong ones. Uses the same resolver (and therefore the | |
| 1072 | + * same cache) as the normal path, so this costs no extra lookup. | |
| 1073 | + */ | |
| 1074 | + private static function is_real_image( string $src ): bool { | |
| 1075 | + $dims = self::dimensions_for_src( $src ); | |
| 1076 | + if ( ! is_array( $dims ) ) { | |
| 1077 | + return false; | |
| 1078 | + } | |
| 1079 | + // Indexed [ width, height ] — the shape apply_dimensions() consumes. | |
| 1080 | + $w = isset( $dims[0] ) ? (int) $dims[0] : 0; | |
| 1081 | + $h = isset( $dims[1] ) ? (int) $dims[1] : 0; | |
| 1082 | + | |
| 1083 | + // A few pixels either way is still a spacer — some libraries ship a | |
| 1084 | + // 2x2 or 4x4 rather than a true 1x1. Anything above that has extent a | |
| 1085 | + // stand-in does not. | |
| 1086 | + return $w > 4 && $h > 4; | |
| 1087 | + } | |
| 1088 | + | |
| 1089 | + /** | |
| 1090 | + * True for the stand-in a lazy-loader parks in `src` until its JS swaps | |
| 1091 | + * the real URL in: an inline data: URI, or a `spacer`/`blank`/`placeholder` | |
| 1092 | + * asset. Measuring one of these would stamp the spacer's dimensions onto | |
| 1093 | + * the tag. Pure — unit-tested. | |
| 1094 | + * | |
| 1095 | + * Matched on the WHOLE filename stem, not a word inside it. A word-boundary | |
| 1096 | + * search anywhere in the last segment caught every real image whose name | |
| 1097 | + * merely contains one of these ordinary words — `blank-space-cover.png`, | |
| 1098 | + * `placeholder-portrait.png`, `spacer-hero-banner.jpg` — and silently | |
| 1099 | + * stopped sizing them, which brings back the very layout shift this | |
| 1100 | + * feature exists to prevent, with nothing on screen to explain it | |
| 1101 | + * (#333 review, issue 1). | |
| 1102 | + * | |
| 1103 | + * A real stand-in is named for what it is and nothing else: `blank.gif`, | |
| 1104 | + * `spacer.png`, `lazy-loader.svg`, optionally with a dimension or version | |
| 1105 | + * suffix (`blank-1x1.gif`, `[email protected]`). A descriptive tail is what | |
| 1106 | + * separates a photograph from a spacer, so the tail is what decides. | |
| 1107 | + */ | |
| 1108 | + public static function is_placeholder_src( string $src ): bool { | |
| 1109 | + if ( 0 === stripos( $src, 'data:' ) ) { | |
| 1110 | + return true; | |
| 1111 | + } | |
| 1112 | + | |
| 1113 | + // Last path segment, without the query string or fragment — | |
| 1114 | + // `?v=placeholder` is a cache-buster on a real image, not a name. | |
| 1115 | + // Plain string work on purpose: this method is pure and unit-tested | |
| 1116 | + // with no WordPress loaded, so wp_parse_url() is not available. | |
| 1117 | + $path = strtok( $src, '?#' ); | |
| 1118 | + if ( ! is_string( $path ) || '' === $path ) { | |
| 1119 | + $path = $src; | |
| 1120 | + } | |
| 1121 | + $name = strtolower( basename( $path ) ); | |
| 1122 | + | |
| 1123 | + // Drop the extension, then any trailing dimension/DPR/version marker. | |
| 1124 | + $stem = preg_replace( '#\.[a-z0-9]+$#', '', $name ); | |
| 1125 | + $stem = (string) preg_replace( '#[-_@]?(?:\d+x\d+|\d+x|x\d+|v\d+|\d+)$#', '', (string) $stem ); | |
| 1126 | + $stem = trim( $stem, '-_.' ); | |
| 1127 | + | |
| 1128 | + return 1 === preg_match( '#^(?:spacer|blank|placeholder|lazy-?loader|transparent|pixel|dummy)$#', $stem ); | |
| 1129 | + } | |
| 1130 | + | |
| 1131 | + /** | |
| 702 | 1132 | * True when the tag already declares itself the LCP image via |
| 703 | 1133 | * `fetchpriority="high"`. |
| 704 | 1134 | * |
| 705 | 1135 | * Only "high" counts. `fetchpriority="low"` and `="auto"` say the opposite |
| @@ -720,9 +1150,22 @@ | ||
| 720 | 1150 | * own `wp_filter_content_tags()` adds dimensions to responsive |
| 721 | 1151 | * images the same way. Pure — unit-tested. |
| 722 | 1152 | */ |
| 723 | 1153 | public static function has_constrained_render( string $tag ): bool { |
| 724 | - if ( preg_match( '#\bsrcset\s*=#i', $tag ) || preg_match( '#\bsizes\s*=#i', $tag ) ) { | |
| 1154 | + // `\b` sits between `-` and `s`, so a bare \bsrcset also matched | |
| 1155 | + // `data-srcset` — a lazy attribute the browser has NOT applied yet. | |
| 1156 | + // That made an unset attribute suppress dimensions on exactly the | |
| 1157 | + // slider images this feature exists to size, for the same | |
| 1158 | + // accidental-text-match reason the URL lookup moved away from | |
| 1159 | + // (#333 review, issue 3). | |
| 1160 | + // | |
| 1161 | + // The anchor is a negative lookbehind rather than "start or | |
| 1162 | + // whitespace": HTML does not require a space between attributes, so | |
| 1163 | + // `alt="31"srcset="..."` slipped past a whitespace anchor and this | |
| 1164 | + // guard stopped firing — the tag then got the file's intrinsic size | |
| 1165 | + // stamped on it while the browser rendered a differently-shaped | |
| 1166 | + // srcset candidate. (#333 review round 3, issue 1) | |
| 1167 | + if ( preg_match( '#(?<![-\w])(?:srcset|sizes)\s*=#i', $tag ) ) { | |
| 725 | 1168 | return true; |
| 726 | 1169 | } |
| 727 | 1170 | if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) { |
| 728 | 1171 | // width/height in the inline style wins over the attribute, so |
| @@ -792,9 +1235,12 @@ | ||
| 792 | 1235 | private static function attr_int( string $tag, string $name ): int { |
| 793 | 1236 | // The value must be ENTIRELY digits. Matching a leading run would read |
| 794 | 1237 | // `width="50%"` as 50 and scale from a percentage as though it were |
| 795 | 1238 | // pixels — inventing a box rather than declining to guess. |
| 796 | - if ( ! preg_match( '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(?:"(\d+)"|\'(\d+)\'|(\d+)(?=[\s/>]))#i', $tag, $m ) ) { | |
| 1239 | + // Lookbehind for the same reason as set_attr(): `\bwidth=` also reads | |
| 1240 | + // `data-width=`, so a slider's own metadata was scaled from as though | |
| 1241 | + // it were a rendered dimension. | |
| 1242 | + if ( ! preg_match( '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(?:"(\d+)"|\'(\d+)\'|(\d+)(?=[\s/>]))#i', $tag, $m ) ) { | |
| 797 | 1243 | return 0; |
| 798 | 1244 | } |
| 799 | 1245 | $value = '' !== ( $m[1] ?? '' ) ? $m[1] : ( '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' ) ); |
| 800 | 1246 | return (int) $value; |
| @@ -1176,8 +1622,9 @@ | ||
| 1176 | 1622 | */ |
| 1177 | 1623 | public static function reset_state(): void { |
| 1178 | 1624 | self::$opts = null; |
| 1179 | 1625 | self::$image_counter = 0; |
| 1626 | + self::$background_counter = 0; | |
| 1180 | 1627 | self::$src_dims_cache = null; |
| 1181 | 1628 | self::$facade_used = false; |
| 1182 | 1629 | self::$warming = false; |
| 1183 | 1630 | // Both gate whether the autoplay restorer is printed. Left set, one |