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

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

523 lines 18.6 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 = self::apply_pass( $work, $tag_re( 'img' ), array( __CLASS__, 'rewrite_img' ) );
86 }
87 if ( ! empty( $opts['lazy_iframes'] ) ) {
88 $work = self::apply_pass( $work, $tag_re( 'iframe' ), array( __CLASS__, 'rewrite_iframe' ) );
89 }
90 // Facade runs AFTER the lazy pass, deliberately. The facade keeps the
91 // original tag inside <noscript> as the JS-less fallback, and that
92 // fallback should carry loading="lazy" too — running this first would
93 // produce an eager iframe for exactly the visitors least able to
94 // afford one.
95 //
96 // Unlike every other pass here, the facade REPLACES the element
97 // rather than injecting attributes into its opening tag — so it has
98 // to consume the whole element, `</iframe>` included. Matching the
99 // opening tag alone orphaned the closing tag outside the injected
100 // <noscript>, which broke nesting and swallowed sibling content in
101 // real browsers. The body is tempered (`(?!</?iframe\b)`) so an
102 // unclosed iframe can't make the match run on to a LATER embed's
103 // closing tag and eat everything in between; an iframe with no
104 // closing tag simply doesn't match and passes through untouched.
105 if ( ! empty( $opts['video_facade'] ) ) {
106 $work = self::apply_pass(
107 $work,
108 '#(<iframe\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?iframe\b).)*)</iframe\s*>#is',
109 array( __CLASS__, 'rewrite_iframe_facade' )
110 );
111 }
112 if ( ! empty( $opts['lazy_videos'] ) ) {
113 $work = self::apply_pass( $work, $tag_re( 'video' ), array( __CLASS__, 'rewrite_video' ) );
114 }
115
116 return self::restore_safe_blocks( $work, $stubs );
117 }
118
119 /**
120 * Run one rewrite pass, keeping the input if PCRE bails.
121 *
122 * preg_replace_callback() returns null when it hits the backtrack or
123 * recursion limit — on a large page that would otherwise blank the
124 * whole document. Returning the untouched HTML costs the optimization
125 * for that request and nothing else.
126 *
127 * @param callable $callback Rewrite callback for one match.
128 */
129 private static function apply_pass( string $html, string $pattern, callable $callback ): string {
130 $result = preg_replace_callback( $pattern, $callback, $html );
131
132 return is_string( $result ) ? $result : $html;
133 }
134
135 private static function rewrite_img( array $m ): string {
136 $tag = $m[0];
137 $opts = self::opts();
138
139 // Explicit skip flag, or matches an exclusion pattern: opt OUT of
140 // LAZY-LOADING only. Dimension injection (CLS protection) still
141 // applies — excluding an above-the-fold hero/logo from lazy-load is
142 // exactly when you most want its width/height kept. Previously both
143 // of these returned early, silently stripping dimensions too.
144 // (FBS-82172 Bug 2)
145 $skip_lazy = false !== stripos( $tag, 'data-skip-lazy' )
146 || false !== stripos( $tag, 'data-no-lazy' )
147 || self::is_excluded( $tag, $opts );
148
149 if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) {
150 // An EXCLUDED image is one the user marked as above-the-fold (a
151 // hero/logo) — the opposite of lazy. WordPress core adds
152 // `loading="lazy"` to images by default (since 5.5), so merely
153 // *skipping* our lazy pass would leave core's lazy attribute on
154 // the LCP hero and tank LCP. Actively make it eager +
155 // high-priority so an excluded hero loads immediately.
156 $tag = self::set_attr( $tag, 'loading', 'eager' );
157 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
158 $tag = self::set_attr( $tag, 'decoding', 'async', true );
159 } elseif ( ! empty( $opts['lazy_images'] ) ) {
160 // Above-the-fold skip: first N images get loading="eager"
161 // instead of "lazy" so the LCP image isn't deferred. Only
162 // non-excluded images consume the budget.
163 self::$image_counter++;
164 $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
165 $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
166 $tag = self::set_attr( $tag, 'decoding', 'async', true );
167 // The eager hero should also drop any core `loading="lazy"`; the
168 // set_attr above already overrode it. Give the first eager image
169 // high fetch priority so it wins the LCP race.
170 if ( $is_above_fold ) {
171 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
172 }
173 }
174
175 if ( ! empty( $opts['add_missing_dimensions'] ) ) {
176 $tag = self::ensure_dimensions( $tag );
177 }
178
179 return $tag;
180 }
181
182 private static function rewrite_iframe( array $m ): string {
183 $tag = $m[0];
184 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
185 return $tag;
186 }
187 if ( self::is_excluded( $tag, self::opts() ) ) {
188 return $tag;
189 }
190 return self::set_attr( $tag, 'loading', 'lazy' );
191 }
192
193 /**
194 * Swap a recognised video embed for a click-to-play facade.
195 *
196 * Passes the element through untouched unless it is a provider we can
197 * build a facade for — an unknown iframe (a map, a form, a dashboard)
198 * must never be replaced by a play button.
199 *
200 * $m[0] is the WHOLE element (`<iframe …>…</iframe>`); $m[1] is just
201 * the opening tag. Attributes are read from the opening tag, but what
202 * goes into the <noscript> fallback — and what is returned on every
203 * bail-out path — is the whole element, so the closing tag is never
204 * left stranded outside it.
205 */
206 private static function rewrite_iframe_facade( array $m ): string {
207 $element = $m[0];
208 $tag = $m[1];
209
210 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
211 return $element;
212 }
213 if ( self::is_excluded( $tag, self::opts() ) ) {
214 return $element;
215 }
216
217 if ( ! preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $src_m ) ) {
218 return $element;
219 }
220 $src = $src_m[2];
221
222 $embed = Video_Facade::parse_embed( $src );
223 if ( null === $embed ) {
224 return $element;
225 }
226
227 $title = '';
228 if ( preg_match( '#\btitle\s*=\s*(["\'])(.*?)\1#i', $tag, $title_m ) ) {
229 $title = $title_m[2];
230 }
231
232 self::$facade_used = true;
233
234 return Video_Facade::render( $element, $embed, $src, $title );
235 }
236
237 /** @var bool True once a facade has been rendered on this page. */
238 private static $facade_used = false;
239
240 /**
241 * True when this render produced at least one facade — the module uses
242 * it to decide whether the click handler is worth printing at all.
243 */
244 public static function facade_used(): bool {
245 return self::$facade_used;
246 }
247
248 private static function rewrite_video( array $m ): string {
249 $tag = $m[0];
250 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
251 return $tag;
252 }
253 // HTML5 `<video>` doesn't support loading=lazy yet (Chromium
254 // won't add it before there's broad support). What we CAN do
255 // is set preload="none" so the browser doesn't pre-fetch the
256 // video bytes until play is requested — that's the actual win
257 // users want from "lazy-load videos".
258 if ( false === stripos( $tag, 'preload=' ) ) {
259 $tag = self::set_attr( $tag, 'preload', 'none' );
260 }
261 return $tag;
262 }
263
264 /**
265 * Add an attribute to an opening tag if it isn't already present.
266 * Pass $only_if_missing=false to override an existing value (e.g.
267 * flipping loading="lazy" → "eager" on the first image).
268 */
269 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
270 $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
271 if ( preg_match( $pattern, $tag ) ) {
272 if ( $only_if_missing ) {
273 return $tag;
274 }
275 return (string) preg_replace( $pattern, $name . '="' . $value . '"', $tag, 1 );
276 }
277 // Inject before the closing > (preserving self-closing `/>` if present).
278 if ( preg_match( '#(/?>)$#', $tag, $m ) ) {
279 $close = $m[1];
280 return substr( $tag, 0, -strlen( $close ) ) . ' ' . $name . '="' . $value . '"' . $close;
281 }
282 return $tag;
283 }
284
285 /**
286 * Attempt to fill in missing width / height from either an attached
287 * media library record (when class="wp-image-N") or from the local
288 * filesystem when src points at the uploads dir. Skip when we can't
289 * resolve cheaply — never block the request on a remote getimagesize.
290 */
291 private static function ensure_dimensions( string $tag ): string {
292 $has_w = (bool) preg_match( '#\bwidth\s*=#i', $tag );
293 $has_h = (bool) preg_match( '#\bheight\s*=#i', $tag );
294 if ( $has_w && $has_h ) {
295 return $tag;
296 }
297
298 // Try wp-image-<id> class first (cheapest path; one DB-cached
299 // get_post_meta call).
300 if ( preg_match( '#\bclass\s*=\s*["\']([^"\']*)["\']#i', $tag, $cm ) && preg_match( '#wp-image-(\d+)#i', $cm[1], $idm ) ) {
301 $dims = self::dimensions_for_attachment( (int) $idm[1] );
302 if ( $dims ) {
303 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
304 }
305 }
306
307 // No wp-image-N class — page-builder markup (Essential Blocks and
308 // friends) never emits it, which is why the setting silently failed
309 // on those images (issue #37). Resolve from the src instead, but only
310 // when the tag doesn't already tell us it renders at some other size:
311 // stamping the intrinsic file size onto a responsive or CSS-sized
312 // image would CREATE the layout shift this feature exists to remove.
313 if ( ! self::has_constrained_render( $tag ) && preg_match( '#\bsrc\s*=\s*["\']([^"\']+)["\']#i', $tag, $sm ) ) {
314 $dims = self::dimensions_for_src( $sm[1] );
315 if ( $dims ) {
316 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
317 }
318 }
319
320 // Couldn't resolve. Leave the tag alone — better no dimensions
321 // than wrong ones.
322 return $tag;
323 }
324
325 /**
326 * True when the tag says it renders at a size other than the file's
327 * intrinsic one — a `srcset`/`sizes` pair (the browser picks a
328 * candidate) or an inline width/height style.
329 *
330 * Only guards the src-suffix fallback. The `wp-image-N` path stays
331 * unguarded: attachment metadata is authoritative, and WordPress'
332 * own `wp_filter_content_tags()` adds dimensions to responsive
333 * images the same way. Pure — unit-tested.
334 */
335 public static function has_constrained_render( string $tag ): bool {
336 if ( preg_match( '#\bsrcset\s*=#i', $tag ) || preg_match( '#\bsizes\s*=#i', $tag ) ) {
337 return true;
338 }
339 if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
340 // width/height in the inline style wins over the attribute, so
341 // the file's intrinsic size would disagree with the layout.
342 return 1 === preg_match( '#(?:^|;)\s*(?:max-)?(?:width|height)\s*:#i', $m[1] );
343 }
344 return false;
345 }
346
347 /** @param int[] $dims [width, height]. */
348 private static function apply_dimensions( string $tag, array $dims, bool $has_w, bool $has_h ): string {
349 if ( ! $has_w ) {
350 $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
351 }
352 if ( ! $has_h ) {
353 $tag = self::set_attr( $tag, 'height', (string) $dims[1] );
354 }
355 return $tag;
356 }
357
358 /**
359 * WordPress names resized files `<name>-WxH.<ext>` — when the suffix is
360 * present it IS the rendered size, resolvable with zero I/O (works for
361 * CDN-hosted copies too). Pure — unit-tested.
362 *
363 * @return int[]|null [width, height] or null.
364 */
365 public static function parse_size_suffix( string $src ): ?array {
366 $path = (string) preg_replace( '/[?#].*$/', '', $src );
367 if ( preg_match( '#-(\d{1,4})x(\d{1,4})\.(?:jpe?g|png|gif|webp|avif)$#i', $path, $m ) ) {
368 $w = (int) $m[1];
369 $h = (int) $m[2];
370 if ( $w > 0 && $h > 0 ) {
371 return array( $w, $h );
372 }
373 }
374 return null;
375 }
376
377 /**
378 * Resolve dimensions from an image URL, cheapest first:
379 * 1. `-WxH` filename suffix (no I/O).
380 * 2. Intrinsic size of the local file when src is under uploads
381 * (getimagesize on the header — no remote fetches, ever).
382 * 3. Attachment lookup by URL (uploads-hosted src only).
383 * Results — including failures — are cached per URL in a bounded
384 * transient so each image pays the lookup once, not per pageview.
385 *
386 * @return int[]|null [width, height] or null.
387 */
388 private static function dimensions_for_src( string $src ): ?array {
389 $suffix = self::parse_size_suffix( $src );
390 if ( $suffix ) {
391 return $suffix;
392 }
393
394 if ( ! function_exists( 'wp_get_upload_dir' ) || ! function_exists( 'get_transient' ) ) {
395 return null;
396 }
397 $uploads = wp_get_upload_dir();
398 $baseurl = isset( $uploads['baseurl'] ) ? (string) $uploads['baseurl'] : '';
399 $basedir = isset( $uploads['basedir'] ) ? (string) $uploads['basedir'] : '';
400 if ( '' === $baseurl || '' === $basedir || 0 !== strpos( $src, $baseurl ) ) {
401 return null; // External image — never fetch remotely for a size.
402 }
403
404 if ( null === self::$src_dims_cache ) {
405 $stored = get_transient( 'xspeed_img_dims' );
406 self::$src_dims_cache = is_array( $stored ) ? $stored : array();
407 }
408 $key = md5( $src );
409 if ( array_key_exists( $key, self::$src_dims_cache ) ) {
410 $hit = self::$src_dims_cache[ $key ];
411 return is_array( $hit ) ? $hit : null; // 0 = cached failure.
412 }
413
414 $dims = null;
415 $relative = (string) preg_replace( '/[?#].*$/', '', substr( $src, strlen( $baseurl ) ) );
416 if ( false === strpos( $relative, '..' ) ) {
417 $file = $basedir . $relative;
418 if ( is_file( $file ) ) {
419 $size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-image/corrupt file must degrade to null, not warn.
420 if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
421 $dims = array( (int) $size[0], (int) $size[1] );
422 }
423 }
424 }
425
426 // File not on disk (offloaded originals) — one DB lookup by URL.
427 if ( null === $dims && function_exists( 'attachment_url_to_postid' ) ) {
428 $id = (int) attachment_url_to_postid( $src );
429 if ( $id > 0 ) {
430 $dims = self::dimensions_for_attachment( $id );
431 }
432 }
433
434 // Cache success AND failure (0), bounded so the blob can't grow
435 // unbounded on media-heavy sites.
436 if ( count( self::$src_dims_cache ) >= 500 ) {
437 self::$src_dims_cache = array_slice( self::$src_dims_cache, 250, null, true );
438 }
439 self::$src_dims_cache[ $key ] = null === $dims ? 0 : $dims;
440 if ( function_exists( 'set_transient' ) ) {
441 set_transient( 'xspeed_img_dims', self::$src_dims_cache, DAY_IN_SECONDS );
442 }
443 return $dims;
444 }
445
446 /**
447 * @return int[]|null [width, height] or null
448 */
449 private static function dimensions_for_attachment( int $attachment_id ): ?array {
450 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
451 return null;
452 }
453 $meta = wp_get_attachment_metadata( $attachment_id );
454 if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
455 return null;
456 }
457 return array( (int) $meta['width'], (int) $meta['height'] );
458 }
459
460 private static function is_excluded( string $tag, array $opts ): bool {
461 $excluded = $opts['excluded_images'] ?? array();
462 if ( ! is_array( $excluded ) || empty( $excluded ) ) {
463 return false;
464 }
465 foreach ( $excluded as $pattern ) {
466 $pattern = (string) $pattern;
467 if ( '' === $pattern ) {
468 continue;
469 }
470 if ( false !== stripos( $tag, $pattern ) ) {
471 return true;
472 }
473 }
474 return false;
475 }
476
477 /**
478 * Replace <script>, <style>, <noscript>, <pre>, <code> blocks with
479 * placeholder tokens before tag rewriting. Returns [stubbed_html,
480 * stubs_map]. Restore via restore_safe_blocks().
481 *
482 * @return array{0: string, 1: array<string,string>}
483 */
484 private static function stub_safe_blocks( string $html ): array {
485 $stubs = array();
486 $re = '#<(script|style|noscript|pre|code)\b[^>]*>.*?</\1>#is';
487 $out = preg_replace_callback(
488 $re,
489 static function ( $m ) use ( &$stubs ) {
490 $key = '<!--XSPEED_LAZY_STUB_' . count( $stubs ) . '-->';
491 $stubs[ $key ] = $m[0];
492 return $key;
493 },
494 $html
495 );
496 return array( (string) $out, $stubs );
497 }
498
499 private static function restore_safe_blocks( string $html, array $stubs ): string {
500 if ( empty( $stubs ) ) {
501 return $html;
502 }
503 return strtr( $html, $stubs );
504 }
505
506 private static function opts(): array {
507 if ( null === self::$opts ) {
508 self::$opts = Settings_Manager::get( 'lazy' );
509 }
510 return self::$opts;
511 }
512
513 /**
514 * Test-only: clear cached opts + counter between assertions.
515 */
516 public static function reset_state(): void {
517 self::$opts = null;
518 self::$image_counter = 0;
519 self::$src_dims_cache = null;
520 self::$facade_used = false;
521 }
522 }
523