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 +458 -11 1.2.4 → 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;
@@ -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