PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.8
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.8
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.0.8, at includes/class-lazy-loader.php

304 lines 10.0 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 * Main entry point: take rendered HTML, return rewritten HTML.
46 * Pure function aside from the static counters.
47 */
48 public static function process_html( string $html ): string {
49 if ( '' === $html ) {
50 return $html;
51 }
52 $opts = self::opts();
53
54 // NOTE: the eager-load budget counter is NOT reset here. process_html
55 // runs once per filter pass — the_content, post_thumbnail_html, and
56 // once per get_avatar — so resetting per call let the featured image,
57 // the first content image, AND every comment avatar each claim an
58 // "eager" slot, defeating the budget. The counter is reset once per
59 // page render via reset_state() on template_redirect, so it now
60 // accumulates across all passes as intended. (FBS-82172 Bug 1)
61
62 // Stub out <script>, <style>, <noscript>, <pre>, <code> blocks
63 // so img tags embedded in them as text examples aren't
64 // rewritten. Restore after pass.
65 [ $work, $stubs ] = self::stub_safe_blocks( $html );
66
67 // Tag matcher that respects quoted attribute values, so a ">" inside
68 // an attribute (e.g. alt="a > b") doesn't end the match early and
69 // corrupt the tag. Matches: double-quoted runs, single-quoted runs,
70 // or any non-> char — repeated up to the real closing >.
71 // (FBS-82172 Bug 3)
72 $tag_re = static function ( string $name ): string {
73 return '#<' . $name . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
74 };
75
76 if ( ! empty( $opts['lazy_images'] ) || ! empty( $opts['add_missing_dimensions'] ) ) {
77 $work = preg_replace_callback(
78 $tag_re( 'img' ),
79 array( __CLASS__, 'rewrite_img' ),
80 $work
81 );
82 }
83 if ( ! empty( $opts['lazy_iframes'] ) ) {
84 $work = preg_replace_callback(
85 $tag_re( 'iframe' ),
86 array( __CLASS__, 'rewrite_iframe' ),
87 $work
88 );
89 }
90 if ( ! empty( $opts['lazy_videos'] ) ) {
91 $work = preg_replace_callback(
92 $tag_re( 'video' ),
93 array( __CLASS__, 'rewrite_video' ),
94 $work
95 );
96 }
97
98 return self::restore_safe_blocks( $work, $stubs );
99 }
100
101 private static function rewrite_img( array $m ): string {
102 $tag = $m[0];
103 $opts = self::opts();
104
105 // Explicit skip flag, or matches an exclusion pattern: opt OUT of
106 // LAZY-LOADING only. Dimension injection (CLS protection) still
107 // applies — excluding an above-the-fold hero/logo from lazy-load is
108 // exactly when you most want its width/height kept. Previously both
109 // of these returned early, silently stripping dimensions too.
110 // (FBS-82172 Bug 2)
111 $skip_lazy = false !== stripos( $tag, 'data-skip-lazy' )
112 || false !== stripos( $tag, 'data-no-lazy' )
113 || self::is_excluded( $tag, $opts );
114
115 if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) {
116 // An EXCLUDED image is one the user marked as above-the-fold (a
117 // hero/logo) — the opposite of lazy. WordPress core adds
118 // `loading="lazy"` to images by default (since 5.5), so merely
119 // *skipping* our lazy pass would leave core's lazy attribute on
120 // the LCP hero and tank LCP. Actively make it eager +
121 // high-priority so an excluded hero loads immediately.
122 $tag = self::set_attr( $tag, 'loading', 'eager' );
123 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
124 $tag = self::set_attr( $tag, 'decoding', 'async', true );
125 } elseif ( ! empty( $opts['lazy_images'] ) ) {
126 // Above-the-fold skip: first N images get loading="eager"
127 // instead of "lazy" so the LCP image isn't deferred. Only
128 // non-excluded images consume the budget.
129 self::$image_counter++;
130 $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
131 $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
132 $tag = self::set_attr( $tag, 'decoding', 'async', true );
133 // The eager hero should also drop any core `loading="lazy"`; the
134 // set_attr above already overrode it. Give the first eager image
135 // high fetch priority so it wins the LCP race.
136 if ( $is_above_fold ) {
137 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
138 }
139 }
140
141 if ( ! empty( $opts['add_missing_dimensions'] ) ) {
142 $tag = self::ensure_dimensions( $tag );
143 }
144
145 return $tag;
146 }
147
148 private static function rewrite_iframe( array $m ): string {
149 $tag = $m[0];
150 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
151 return $tag;
152 }
153 if ( self::is_excluded( $tag, self::opts() ) ) {
154 return $tag;
155 }
156 return self::set_attr( $tag, 'loading', 'lazy' );
157 }
158
159 private static function rewrite_video( array $m ): string {
160 $tag = $m[0];
161 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
162 return $tag;
163 }
164 // HTML5 `<video>` doesn't support loading=lazy yet (Chromium
165 // won't add it before there's broad support). What we CAN do
166 // is set preload="none" so the browser doesn't pre-fetch the
167 // video bytes until play is requested — that's the actual win
168 // users want from "lazy-load videos".
169 if ( false === stripos( $tag, 'preload=' ) ) {
170 $tag = self::set_attr( $tag, 'preload', 'none' );
171 }
172 return $tag;
173 }
174
175 /**
176 * Add an attribute to an opening tag if it isn't already present.
177 * Pass $only_if_missing=false to override an existing value (e.g.
178 * flipping loading="lazy" → "eager" on the first image).
179 */
180 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
181 $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
182 if ( preg_match( $pattern, $tag ) ) {
183 if ( $only_if_missing ) {
184 return $tag;
185 }
186 return (string) preg_replace( $pattern, $name . '="' . $value . '"', $tag, 1 );
187 }
188 // Inject before the closing > (preserving self-closing `/>` if present).
189 if ( preg_match( '#(/?>)$#', $tag, $m ) ) {
190 $close = $m[1];
191 return substr( $tag, 0, -strlen( $close ) ) . ' ' . $name . '="' . $value . '"' . $close;
192 }
193 return $tag;
194 }
195
196 /**
197 * Attempt to fill in missing width / height from either an attached
198 * media library record (when class="wp-image-N") or from the local
199 * filesystem when src points at the uploads dir. Skip when we can't
200 * resolve cheaply — never block the request on a remote getimagesize.
201 */
202 private static function ensure_dimensions( string $tag ): string {
203 $has_w = (bool) preg_match( '#\bwidth\s*=#i', $tag );
204 $has_h = (bool) preg_match( '#\bheight\s*=#i', $tag );
205 if ( $has_w && $has_h ) {
206 return $tag;
207 }
208
209 // Try wp-image-<id> class first (cheapest path; one DB-cached
210 // get_post_meta call).
211 if ( preg_match( '#\bclass\s*=\s*["\']([^"\']*)["\']#i', $tag, $cm ) && preg_match( '#wp-image-(\d+)#i', $cm[1], $idm ) ) {
212 $dims = self::dimensions_for_attachment( (int) $idm[1] );
213 if ( $dims ) {
214 if ( ! $has_w ) {
215 $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
216 }
217 if ( ! $has_h ) {
218 $tag = self::set_attr( $tag, 'height', (string) $dims[1] );
219 }
220 return $tag;
221 }
222 }
223
224 // Couldn't resolve. Leave the tag alone — better no dimensions
225 // than wrong ones.
226 return $tag;
227 }
228
229 /**
230 * @return int[]|null [width, height] or null
231 */
232 private static function dimensions_for_attachment( int $attachment_id ): ?array {
233 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
234 return null;
235 }
236 $meta = wp_get_attachment_metadata( $attachment_id );
237 if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
238 return null;
239 }
240 return array( (int) $meta['width'], (int) $meta['height'] );
241 }
242
243 private static function is_excluded( string $tag, array $opts ): bool {
244 $excluded = $opts['excluded_images'] ?? array();
245 if ( ! is_array( $excluded ) || empty( $excluded ) ) {
246 return false;
247 }
248 foreach ( $excluded as $pattern ) {
249 $pattern = (string) $pattern;
250 if ( '' === $pattern ) {
251 continue;
252 }
253 if ( false !== stripos( $tag, $pattern ) ) {
254 return true;
255 }
256 }
257 return false;
258 }
259
260 /**
261 * Replace <script>, <style>, <noscript>, <pre>, <code> blocks with
262 * placeholder tokens before tag rewriting. Returns [stubbed_html,
263 * stubs_map]. Restore via restore_safe_blocks().
264 *
265 * @return array{0: string, 1: array<string,string>}
266 */
267 private static function stub_safe_blocks( string $html ): array {
268 $stubs = array();
269 $re = '#<(script|style|noscript|pre|code)\b[^>]*>.*?</\1>#is';
270 $out = preg_replace_callback(
271 $re,
272 static function ( $m ) use ( &$stubs ) {
273 $key = '<!--XSPEED_LAZY_STUB_' . count( $stubs ) . '-->';
274 $stubs[ $key ] = $m[0];
275 return $key;
276 },
277 $html
278 );
279 return array( (string) $out, $stubs );
280 }
281
282 private static function restore_safe_blocks( string $html, array $stubs ): string {
283 if ( empty( $stubs ) ) {
284 return $html;
285 }
286 return strtr( $html, $stubs );
287 }
288
289 private static function opts(): array {
290 if ( null === self::$opts ) {
291 self::$opts = Settings_Manager::get( 'lazy' );
292 }
293 return self::$opts;
294 }
295
296 /**
297 * Test-only: clear cached opts + counter between assertions.
298 */
299 public static function reset_state(): void {
300 self::$opts = null;
301 self::$image_counter = 0;
302 }
303 }
304