PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.1
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 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-lazy-loader.php

class-lazy-loader.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.1, at includes/class-lazy-loader.php

441 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Lazy_Loader — rewrites img / iframe / video tags in rendered HTML to
4 * add native `loading="lazy"` (or "eager" for above-the-fold) plus
5 * `decoding="async"` on images. Also auto-adds missing width/height
6 * attributes to prevent CLS.
7 *
8 * Why regex instead of DOMDocument:
9 * - DOMDocument forces a full HTML5 parse round trip per filter call;
10 * on a content-heavy post that's measurably slow. Regex over the
11 * specific tags is ~10× faster.
12 * - We don't need full DOM understanding — every rewrite is a tag-
13 * local attribute injection. Regex is sufficient + predictable.
14 * - Edge cases (img inside HTML comments, img in <script>) are rare
15 * in real post content; we leave those alone with a pre-pass that
16 * stubs out script / style / pre blocks before rewriting.
17 *
18 * @package XSpeed
19 */
20
21 declare(strict_types=1);
22
23 namespace XSpeed;
24
25 defined( 'ABSPATH' ) || exit;
26
27 final class Lazy_Loader {
28
29 /**
30 * In-process counter for above-the-fold skipping. Reset by
31 * process_html on every call so a fresh post starts at 0.
32 *
33 * @var int
34 */
35 private static $image_counter = 0;
36
37 /**
38 * Settings cache (one read per request).
39 *
40 * @var array|null
41 */
42 private static $opts = null;
43
44 /**
45 * Per-URL dimension cache (md5(src) => [w,h] | 0 for known-failure),
46 * hydrated from the `xspeed_img_dims` transient once per request.
47 *
48 * @var array<string,mixed>|null
49 */
50 private static $src_dims_cache = null;
51
52 /**
53 * Main entry point: take rendered HTML, return rewritten HTML.
54 * Pure function aside from the static counters.
55 */
56 public static function process_html( string $html ): string {
57 if ( '' === $html ) {
58 return $html;
59 }
60 $opts = self::opts();
61
62 // NOTE: the eager-load budget counter is NOT reset here. process_html
63 // runs once per filter pass — the_content, post_thumbnail_html, and
64 // once per get_avatar — so resetting per call let the featured image,
65 // the first content image, AND every comment avatar each claim an
66 // "eager" slot, defeating the budget. The counter is reset once per
67 // page render via reset_state() on template_redirect, so it now
68 // accumulates across all passes as intended. (FBS-82172 Bug 1)
69
70 // Stub out <script>, <style>, <noscript>, <pre>, <code> blocks
71 // so img tags embedded in them as text examples aren't
72 // rewritten. Restore after pass.
73 [ $work, $stubs ] = self::stub_safe_blocks( $html );
74
75 // Tag matcher that respects quoted attribute values, so a ">" inside
76 // an attribute (e.g. alt="a > b") doesn't end the match early and
77 // corrupt the tag. Matches: double-quoted runs, single-quoted runs,
78 // or any non-> char — repeated up to the real closing >.
79 // (FBS-82172 Bug 3)
80 $tag_re = static function ( string $name ): string {
81 return '#<' . $name . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
82 };
83
84 if ( ! empty( $opts['lazy_images'] ) || ! empty( $opts['add_missing_dimensions'] ) ) {
85 $work = preg_replace_callback(
86 $tag_re( 'img' ),
87 array( __CLASS__, 'rewrite_img' ),
88 $work
89 );
90 }
91 if ( ! empty( $opts['lazy_iframes'] ) ) {
92 $work = preg_replace_callback(
93 $tag_re( 'iframe' ),
94 array( __CLASS__, 'rewrite_iframe' ),
95 $work
96 );
97 }
98 if ( ! empty( $opts['lazy_videos'] ) ) {
99 $work = preg_replace_callback(
100 $tag_re( 'video' ),
101 array( __CLASS__, 'rewrite_video' ),
102 $work
103 );
104 }
105
106 return self::restore_safe_blocks( $work, $stubs );
107 }
108
109 private static function rewrite_img( array $m ): string {
110 $tag = $m[0];
111 $opts = self::opts();
112
113 // Explicit skip flag, or matches an exclusion pattern: opt OUT of
114 // LAZY-LOADING only. Dimension injection (CLS protection) still
115 // applies — excluding an above-the-fold hero/logo from lazy-load is
116 // exactly when you most want its width/height kept. Previously both
117 // of these returned early, silently stripping dimensions too.
118 // (FBS-82172 Bug 2)
119 $skip_lazy = false !== stripos( $tag, 'data-skip-lazy' )
120 || false !== stripos( $tag, 'data-no-lazy' )
121 || self::is_excluded( $tag, $opts );
122
123 if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) {
124 // An EXCLUDED image is one the user marked as above-the-fold (a
125 // hero/logo) — the opposite of lazy. WordPress core adds
126 // `loading="lazy"` to images by default (since 5.5), so merely
127 // *skipping* our lazy pass would leave core's lazy attribute on
128 // the LCP hero and tank LCP. Actively make it eager +
129 // high-priority so an excluded hero loads immediately.
130 $tag = self::set_attr( $tag, 'loading', 'eager' );
131 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
132 $tag = self::set_attr( $tag, 'decoding', 'async', true );
133 } elseif ( ! empty( $opts['lazy_images'] ) ) {
134 // Above-the-fold skip: first N images get loading="eager"
135 // instead of "lazy" so the LCP image isn't deferred. Only
136 // non-excluded images consume the budget.
137 self::$image_counter++;
138 $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
139 $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
140 $tag = self::set_attr( $tag, 'decoding', 'async', true );
141 // The eager hero should also drop any core `loading="lazy"`; the
142 // set_attr above already overrode it. Give the first eager image
143 // high fetch priority so it wins the LCP race.
144 if ( $is_above_fold ) {
145 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
146 }
147 }
148
149 if ( ! empty( $opts['add_missing_dimensions'] ) ) {
150 $tag = self::ensure_dimensions( $tag );
151 }
152
153 return $tag;
154 }
155
156 private static function rewrite_iframe( array $m ): string {
157 $tag = $m[0];
158 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
159 return $tag;
160 }
161 if ( self::is_excluded( $tag, self::opts() ) ) {
162 return $tag;
163 }
164 return self::set_attr( $tag, 'loading', 'lazy' );
165 }
166
167 private static function rewrite_video( array $m ): string {
168 $tag = $m[0];
169 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
170 return $tag;
171 }
172 // HTML5 `<video>` doesn't support loading=lazy yet (Chromium
173 // won't add it before there's broad support). What we CAN do
174 // is set preload="none" so the browser doesn't pre-fetch the
175 // video bytes until play is requested — that's the actual win
176 // users want from "lazy-load videos".
177 if ( false === stripos( $tag, 'preload=' ) ) {
178 $tag = self::set_attr( $tag, 'preload', 'none' );
179 }
180 return $tag;
181 }
182
183 /**
184 * Add an attribute to an opening tag if it isn't already present.
185 * Pass $only_if_missing=false to override an existing value (e.g.
186 * flipping loading="lazy" → "eager" on the first image).
187 */
188 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
189 $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
190 if ( preg_match( $pattern, $tag ) ) {
191 if ( $only_if_missing ) {
192 return $tag;
193 }
194 return (string) preg_replace( $pattern, $name . '="' . $value . '"', $tag, 1 );
195 }
196 // Inject before the closing > (preserving self-closing `/>` if present).
197 if ( preg_match( '#(/?>)$#', $tag, $m ) ) {
198 $close = $m[1];
199 return substr( $tag, 0, -strlen( $close ) ) . ' ' . $name . '="' . $value . '"' . $close;
200 }
201 return $tag;
202 }
203
204 /**
205 * Attempt to fill in missing width / height from either an attached
206 * media library record (when class="wp-image-N") or from the local
207 * filesystem when src points at the uploads dir. Skip when we can't
208 * resolve cheaply — never block the request on a remote getimagesize.
209 */
210 private static function ensure_dimensions( string $tag ): string {
211 $has_w = (bool) preg_match( '#\bwidth\s*=#i', $tag );
212 $has_h = (bool) preg_match( '#\bheight\s*=#i', $tag );
213 if ( $has_w && $has_h ) {
214 return $tag;
215 }
216
217 // Try wp-image-<id> class first (cheapest path; one DB-cached
218 // get_post_meta call).
219 if ( preg_match( '#\bclass\s*=\s*["\']([^"\']*)["\']#i', $tag, $cm ) && preg_match( '#wp-image-(\d+)#i', $cm[1], $idm ) ) {
220 $dims = self::dimensions_for_attachment( (int) $idm[1] );
221 if ( $dims ) {
222 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
223 }
224 }
225
226 // No wp-image-N class — page-builder markup (Essential Blocks and
227 // friends) never emits it, which is why the setting silently failed
228 // on those images (issue #37). Resolve from the src instead, but only
229 // when the tag doesn't already tell us it renders at some other size:
230 // stamping the intrinsic file size onto a responsive or CSS-sized
231 // image would CREATE the layout shift this feature exists to remove.
232 if ( ! self::has_constrained_render( $tag ) && preg_match( '#\bsrc\s*=\s*["\']([^"\']+)["\']#i', $tag, $sm ) ) {
233 $dims = self::dimensions_for_src( $sm[1] );
234 if ( $dims ) {
235 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
236 }
237 }
238
239 // Couldn't resolve. Leave the tag alone — better no dimensions
240 // than wrong ones.
241 return $tag;
242 }
243
244 /**
245 * True when the tag says it renders at a size other than the file's
246 * intrinsic one — a `srcset`/`sizes` pair (the browser picks a
247 * candidate) or an inline width/height style.
248 *
249 * Only guards the src-suffix fallback. The `wp-image-N` path stays
250 * unguarded: attachment metadata is authoritative, and WordPress'
251 * own `wp_filter_content_tags()` adds dimensions to responsive
252 * images the same way. Pure — unit-tested.
253 */
254 public static function has_constrained_render( string $tag ): bool {
255 if ( preg_match( '#\bsrcset\s*=#i', $tag ) || preg_match( '#\bsizes\s*=#i', $tag ) ) {
256 return true;
257 }
258 if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
259 // width/height in the inline style wins over the attribute, so
260 // the file's intrinsic size would disagree with the layout.
261 return 1 === preg_match( '#(?:^|;)\s*(?:max-)?(?:width|height)\s*:#i', $m[1] );
262 }
263 return false;
264 }
265
266 /** @param int[] $dims [width, height]. */
267 private static function apply_dimensions( string $tag, array $dims, bool $has_w, bool $has_h ): string {
268 if ( ! $has_w ) {
269 $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
270 }
271 if ( ! $has_h ) {
272 $tag = self::set_attr( $tag, 'height', (string) $dims[1] );
273 }
274 return $tag;
275 }
276
277 /**
278 * WordPress names resized files `<name>-WxH.<ext>` — when the suffix is
279 * present it IS the rendered size, resolvable with zero I/O (works for
280 * CDN-hosted copies too). Pure — unit-tested.
281 *
282 * @return int[]|null [width, height] or null.
283 */
284 public static function parse_size_suffix( string $src ): ?array {
285 $path = (string) preg_replace( '/[?#].*$/', '', $src );
286 if ( preg_match( '#-(\d{1,4})x(\d{1,4})\.(?:jpe?g|png|gif|webp|avif)$#i', $path, $m ) ) {
287 $w = (int) $m[1];
288 $h = (int) $m[2];
289 if ( $w > 0 && $h > 0 ) {
290 return array( $w, $h );
291 }
292 }
293 return null;
294 }
295
296 /**
297 * Resolve dimensions from an image URL, cheapest first:
298 * 1. `-WxH` filename suffix (no I/O).
299 * 2. Intrinsic size of the local file when src is under uploads
300 * (getimagesize on the header — no remote fetches, ever).
301 * 3. Attachment lookup by URL (uploads-hosted src only).
302 * Results — including failures — are cached per URL in a bounded
303 * transient so each image pays the lookup once, not per pageview.
304 *
305 * @return int[]|null [width, height] or null.
306 */
307 private static function dimensions_for_src( string $src ): ?array {
308 $suffix = self::parse_size_suffix( $src );
309 if ( $suffix ) {
310 return $suffix;
311 }
312
313 if ( ! function_exists( 'wp_get_upload_dir' ) || ! function_exists( 'get_transient' ) ) {
314 return null;
315 }
316 $uploads = wp_get_upload_dir();
317 $baseurl = isset( $uploads['baseurl'] ) ? (string) $uploads['baseurl'] : '';
318 $basedir = isset( $uploads['basedir'] ) ? (string) $uploads['basedir'] : '';
319 if ( '' === $baseurl || '' === $basedir || 0 !== strpos( $src, $baseurl ) ) {
320 return null; // External image — never fetch remotely for a size.
321 }
322
323 if ( null === self::$src_dims_cache ) {
324 $stored = get_transient( 'xspeed_img_dims' );
325 self::$src_dims_cache = is_array( $stored ) ? $stored : array();
326 }
327 $key = md5( $src );
328 if ( array_key_exists( $key, self::$src_dims_cache ) ) {
329 $hit = self::$src_dims_cache[ $key ];
330 return is_array( $hit ) ? $hit : null; // 0 = cached failure.
331 }
332
333 $dims = null;
334 $relative = (string) preg_replace( '/[?#].*$/', '', substr( $src, strlen( $baseurl ) ) );
335 if ( false === strpos( $relative, '..' ) ) {
336 $file = $basedir . $relative;
337 if ( is_file( $file ) ) {
338 $size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-image/corrupt file must degrade to null, not warn.
339 if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
340 $dims = array( (int) $size[0], (int) $size[1] );
341 }
342 }
343 }
344
345 // File not on disk (offloaded originals) — one DB lookup by URL.
346 if ( null === $dims && function_exists( 'attachment_url_to_postid' ) ) {
347 $id = (int) attachment_url_to_postid( $src );
348 if ( $id > 0 ) {
349 $dims = self::dimensions_for_attachment( $id );
350 }
351 }
352
353 // Cache success AND failure (0), bounded so the blob can't grow
354 // unbounded on media-heavy sites.
355 if ( count( self::$src_dims_cache ) >= 500 ) {
356 self::$src_dims_cache = array_slice( self::$src_dims_cache, 250, null, true );
357 }
358 self::$src_dims_cache[ $key ] = null === $dims ? 0 : $dims;
359 if ( function_exists( 'set_transient' ) ) {
360 set_transient( 'xspeed_img_dims', self::$src_dims_cache, DAY_IN_SECONDS );
361 }
362 return $dims;
363 }
364
365 /**
366 * @return int[]|null [width, height] or null
367 */
368 private static function dimensions_for_attachment( int $attachment_id ): ?array {
369 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
370 return null;
371 }
372 $meta = wp_get_attachment_metadata( $attachment_id );
373 if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
374 return null;
375 }
376 return array( (int) $meta['width'], (int) $meta['height'] );
377 }
378
379 private static function is_excluded( string $tag, array $opts ): bool {
380 $excluded = $opts['excluded_images'] ?? array();
381 if ( ! is_array( $excluded ) || empty( $excluded ) ) {
382 return false;
383 }
384 foreach ( $excluded as $pattern ) {
385 $pattern = (string) $pattern;
386 if ( '' === $pattern ) {
387 continue;
388 }
389 if ( false !== stripos( $tag, $pattern ) ) {
390 return true;
391 }
392 }
393 return false;
394 }
395
396 /**
397 * Replace <script>, <style>, <noscript>, <pre>, <code> blocks with
398 * placeholder tokens before tag rewriting. Returns [stubbed_html,
399 * stubs_map]. Restore via restore_safe_blocks().
400 *
401 * @return array{0: string, 1: array<string,string>}
402 */
403 private static function stub_safe_blocks( string $html ): array {
404 $stubs = array();
405 $re = '#<(script|style|noscript|pre|code)\b[^>]*>.*?</\1>#is';
406 $out = preg_replace_callback(
407 $re,
408 static function ( $m ) use ( &$stubs ) {
409 $key = '<!--XSPEED_LAZY_STUB_' . count( $stubs ) . '-->';
410 $stubs[ $key ] = $m[0];
411 return $key;
412 },
413 $html
414 );
415 return array( (string) $out, $stubs );
416 }
417
418 private static function restore_safe_blocks( string $html, array $stubs ): string {
419 if ( empty( $stubs ) ) {
420 return $html;
421 }
422 return strtr( $html, $stubs );
423 }
424
425 private static function opts(): array {
426 if ( null === self::$opts ) {
427 self::$opts = Settings_Manager::get( 'lazy' );
428 }
429 return self::$opts;
430 }
431
432 /**
433 * Test-only: clear cached opts + counter between assertions.
434 */
435 public static function reset_state(): void {
436 self::$opts = null;
437 self::$image_counter = 0;
438 self::$src_dims_cache = null;
439 }
440 }
441