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-footer-images.php

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

300 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Footer_Images — lets the browser pick a smaller file for images printed in
4 * the footer, and lazy-loads them.
5 *
6 * Core runs wp_filter_content_tags() over post content and template parts,
7 * never over what plugins print in the footer. A popup builder that renders
8 * its blocks there (Kadence Conversions, for one) ships every image at full
9 * size with no srcset. Its popup sits in the viewport, scaled to zero and
10 * hidden, so loading="lazy" alone changes nothing: the browser still loads
11 * each image straight away. srcset plus sizes="auto" is what helps. The
12 * browser then picks the file that fits the box the image is laid out in,
13 * and a 1036px original drawn in a 190px column comes down as the 194px
14 * medium size instead.
15 *
16 * Every rendered box stays the size it was: no width or height attribute
17 * is written, and the file's size reaches the browser through CSS any
18 * theme rule overrides (see add_srcset()).
19 *
20 * The footer only. The header and the hero are where the LCP image lives,
21 * and lazy-loading that is the regression this class must never cause.
22 *
23 * @package XSpeed
24 */
25
26 declare(strict_types=1);
27
28 namespace XSpeed;
29
30 defined( 'ABSPATH' ) || exit;
31
32 final class Footer_Images {
33
34 /**
35 * Output-buffer level of the footer buffer: null before it opens, -1
36 * once it has been handled, so a second wp_footer can't open another.
37 *
38 * @var int|null
39 */
40 private static $level = null;
41
42 /**
43 * File sizes given out in this pass, "width-height" => [width, height].
44 *
45 * @var array<string,array{0:int,1:int}>
46 */
47 private static $ratios = array();
48
49 /**
50 * Open the footer buffer. Hooked on both get_footer, so a classic
51 * theme's footer template is covered, and wp_footer, for block themes
52 * that never fire get_footer. Whichever comes first opens it.
53 */
54 public static function start(): void {
55 if ( null !== self::$level ) {
56 return;
57 }
58 if ( function_exists( 'amp_is_request' ) && amp_is_request() ) {
59 self::$level = -1;
60 return;
61 }
62 ob_start( array( __CLASS__, 'passthrough' ) );
63 self::$level = ob_get_level();
64 }
65
66 /**
67 * Output-buffer callback that changes nothing. It names the buffer, so
68 * finish() can tell ours from one another plugin swapped in at the
69 * same level.
70 *
71 * @param string $buffer Buffer contents.
72 */
73 public static function passthrough( string $buffer ): string {
74 return $buffer;
75 }
76
77 /**
78 * Close the footer buffer and print it rewritten.
79 *
80 * If another plugin opened a buffer inside ours and left it open, closed
81 * ours, or swapped its own in at the same level, the top buffer is not
82 * ours. Closing it would swallow or reorder that plugin's output, so we
83 * leave everything as it is and PHP flushes ours, untouched, at shutdown.
84 */
85 public static function finish(): void {
86 if ( null === self::$level || -1 === self::$level ) {
87 return;
88 }
89 $level = self::$level;
90 self::$level = -1;
91 $status = ob_get_status();
92 if ( ob_get_level() !== $level || ( $status['name'] ?? '' ) !== __CLASS__ . '::passthrough' ) {
93 return;
94 }
95 $html = ob_get_clean();
96 echo self::process( is_string( $html ) ? $html : '' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already-rendered page markup, only attributes added.
97 }
98
99 /** Forget the buffer state, once per page render. */
100 public static function reset_state(): void {
101 self::$level = null;
102 self::$ratios = array();
103 }
104
105 /**
106 * Add srcset/sizes and loading="lazy" to the footer's images.
107 *
108 * Script, style, template and similar blocks are left alone: an <img>
109 * inside a JS string would break the script if we wrote double quotes
110 * into it.
111 *
112 * @param string $html Footer markup.
113 */
114 public static function process( string $html ): string {
115 if ( false === stripos( $html, '<img' ) ) {
116 return $html;
117 }
118
119 $stubs = array();
120 $safe = preg_replace_callback(
121 '#<(script|style|noscript|template|textarea|pre|code)\b[^>]*>.*?</\1\s*>#is',
122 static function ( array $m ) use ( &$stubs ): string {
123 $key = '<!--XSPEED_FOOTER_STUB_' . count( $stubs ) . '-->';
124 $stubs[ $key ] = $m[0];
125 return $key;
126 },
127 $html
128 );
129 if ( ! is_string( $safe ) ) {
130 return $html;
131 }
132
133 $pattern = '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
134 if ( ! preg_match_all( $pattern, $safe, $tags ) ) {
135 return $html;
136 }
137
138 // One query for every attachment instead of one per image, as
139 // wp_filter_content_tags() does.
140 $ids = array();
141 foreach ( $tags[0] as $tag ) {
142 $id = self::attachment_id( $tag );
143 if ( $id > 0 && ! self::has_attr( $tag, 'srcset' ) ) {
144 $ids[] = $id;
145 }
146 }
147 if ( $ids && function_exists( '_prime_post_caches' ) ) {
148 _prime_post_caches( array_unique( $ids ), false, true );
149 }
150
151 self::$ratios = array();
152 $opts = Settings_Manager::get( 'lazy' );
153 $out = preg_replace_callback(
154 $pattern,
155 static function ( array $m ) use ( $opts ): string {
156 return self::rewrite_img( $m[0], is_array( $opts ) ? $opts : array() );
157 },
158 $safe
159 );
160 if ( ! is_string( $out ) ) {
161 return $html;
162 }
163
164 $out = self::ratio_style() . $out;
165 return $stubs ? strtr( $out, $stubs ) : $out;
166 }
167
168 /**
169 * Only an attachment image that gets a srcset is touched. Lazy-loading
170 * on its own buys nothing in the footer (see the class comment) and
171 * would stop a hidden tracking pixel from ever loading (#558), so every
172 * other image is left exactly as printed.
173 *
174 * @param array<string,mixed> $opts Lazy settings.
175 */
176 private static function rewrite_img( string $tag, array $opts ): string {
177 $id = self::attachment_id( $tag );
178 if ( $id < 1
179 || false !== stripos( $tag, 'data-skip-lazy' )
180 || false !== stripos( $tag, 'data-no-lazy' )
181 || Lazy_Loader::has_high_fetchpriority( $tag )
182 || Lazy_Loader::tag_is_hidden( $tag, 'img' )
183 // Width and height attributes are left as they are, and a tag
184 // that has either is skipped: every way of completing one
185 // changes how some theme lays it out.
186 || self::has_attr( $tag, 'srcset' )
187 || self::has_attr( $tag, 'sizes' )
188 || self::has_attr( $tag, 'width' )
189 || self::has_attr( $tag, 'height' )
190 ) {
191 return $tag;
192 }
193 // sizes="auto" is only valid on a lazy image.
194 if ( self::has_attr( $tag, 'loading' ) && ! preg_match( '#\sloading\s*=\s*["\']?lazy\b#i', $tag ) ) {
195 return $tag;
196 }
197 foreach ( (array) ( $opts['excluded_images'] ?? array() ) as $pattern ) {
198 $pattern = (string) $pattern;
199 if ( '' !== $pattern && false !== stripos( $tag, $pattern ) ) {
200 return $tag;
201 }
202 }
203
204 $out = self::add_srcset( $tag, $id );
205 if ( $out === $tag || self::has_attr( $tag, 'loading' ) ) {
206 return $out;
207 }
208 return (string) preg_replace( '#^<img\b#i', '<img loading="lazy"', $out, 1 );
209 }
210
211 /**
212 * Add core's srcset with sizes="auto, {width}px" and a ratio key.
213 *
214 * sizes="auto" makes Chrome lay the image out with size containment:
215 * the file's own size stops counting and core's CSS reserves 3000x1500
216 * instead. Measured on a footer image: 360x556 became 360x1500. A
217 * width or height attribute would fix that but breaks a theme that
218 * sizes the image on one axis (#556). So the file's size goes in
219 * through CSS instead (see ratio_style()), and the "{width}px" after
220 * "auto" is what a browser without sizes="auto" uses: the file's own
221 * width, so it lays the image out exactly as before.
222 */
223 private static function add_srcset( string $tag, int $id ): string {
224 if ( ! function_exists( 'wp_calculate_image_srcset' ) || ! function_exists( 'wp_image_src_get_dimensions' )
225 || ! preg_match( '#\ssrc\s*=\s*(["\'])([^"\']+)\1#i', $tag, $src ) ) {
226 return $tag;
227 }
228 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own opt-out for sizes="auto", honoured, not defined here.
229 if ( ! apply_filters( 'wp_img_tag_add_auto_sizes', true ) ) {
230 return $tag;
231 }
232 $meta = wp_get_attachment_metadata( $id );
233 if ( ! is_array( $meta ) ) {
234 return $tag;
235 }
236 $dims = wp_image_src_get_dimensions( $src[2], $meta, $id );
237 if ( ! is_array( $dims ) || (int) $dims[0] < 1 || (int) $dims[1] < 1 ) {
238 return $tag;
239 }
240 $width = (int) $dims[0];
241 $height = (int) $dims[1];
242
243 // Nothing wider than the file the page already asked for, so a
244 // browser never downloads more than it did before: a 663px src with
245 // the 1036px original in its srcset fetched the original. Core's own
246 // cap does it, so URLs are never parsed (a CDN URL can hold commas).
247 // Core checks the src against the attachment's files and returns
248 // false when they don't match (an edited image, a CDN URL, a
249 // placeholder src), and when fewer than two files are left.
250 $cap = static function ( $max ) use ( $width ) {
251 return min( (int) $max, $width );
252 };
253 add_filter( 'max_srcset_image_width', $cap, PHP_INT_MAX );
254 $srcset = wp_calculate_image_srcset( array( $width, $height ), $src[2], $meta, $id );
255 remove_filter( 'max_srcset_image_width', $cap, PHP_INT_MAX );
256 if ( ! is_string( $srcset ) || '' === $srcset ) {
257 return $tag;
258 }
259
260 $ratio = $width . '-' . $height;
261 self::$ratios[ $ratio ] = array( $width, $height );
262
263 return (string) preg_replace(
264 '#^<img\b#i',
265 '<img srcset="' . esc_attr( $srcset ) . '" sizes="auto, ' . $width . 'px" data-xspeed-ar="' . $ratio . '"',
266 $tag,
267 1
268 );
269 }
270
271 /**
272 * The file's size for each image given srcset.
273 *
274 * aspect-ratio sits in :where(), at zero specificity, so a theme's own
275 * width, height or aspect-ratio rule still wins. contain-intrinsic-size
276 * has to beat core's own 3000x1500 rule, so it carries the same
277 * specificity and wins by coming later in the page.
278 */
279 private static function ratio_style(): string {
280 if ( ! self::$ratios ) {
281 return '';
282 }
283 $css = '';
284 foreach ( self::$ratios as $ratio => $dims ) {
285 $sel = 'img[data-xspeed-ar="' . $ratio . '"]';
286 $css .= ':where(' . $sel . '){aspect-ratio:' . $dims[0] . '/' . $dims[1] . '}'
287 . $sel . '{contain-intrinsic-size:' . $dims[0] . 'px ' . $dims[1] . 'px}';
288 }
289 return '<style id="xspeed-footer-img">' . $css . '</style>';
290 }
291
292 private static function attachment_id( string $tag ): int {
293 return preg_match( '#(?<![-\w])class\s*=\s*["\'][^"\']*\bwp-image-(\d+)\b#i', $tag, $m ) ? (int) $m[1] : 0;
294 }
295
296 private static function has_attr( string $tag, string $name ): bool {
297 return 1 === preg_match( '#\s' . preg_quote( $name, '#' ) . '\s*=#i', $tag );
298 }
299 }
300