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

288 lines 9.1 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 // Above-the-fold skip: first N images get loading="eager"
117 // instead of "lazy" so the LCP image isn't deferred. Only
118 // non-excluded images consume the budget.
119 self::$image_counter++;
120 $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
121 $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
122 $tag = self::set_attr( $tag, 'decoding', 'async', true );
123 }
124
125 if ( ! empty( $opts['add_missing_dimensions'] ) ) {
126 $tag = self::ensure_dimensions( $tag );
127 }
128
129 return $tag;
130 }
131
132 private static function rewrite_iframe( array $m ): string {
133 $tag = $m[0];
134 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
135 return $tag;
136 }
137 if ( self::is_excluded( $tag, self::opts() ) ) {
138 return $tag;
139 }
140 return self::set_attr( $tag, 'loading', 'lazy' );
141 }
142
143 private static function rewrite_video( array $m ): string {
144 $tag = $m[0];
145 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
146 return $tag;
147 }
148 // HTML5 `<video>` doesn't support loading=lazy yet (Chromium
149 // won't add it before there's broad support). What we CAN do
150 // is set preload="none" so the browser doesn't pre-fetch the
151 // video bytes until play is requested — that's the actual win
152 // users want from "lazy-load videos".
153 if ( false === stripos( $tag, 'preload=' ) ) {
154 $tag = self::set_attr( $tag, 'preload', 'none' );
155 }
156 return $tag;
157 }
158
159 /**
160 * Add an attribute to an opening tag if it isn't already present.
161 * Pass $only_if_missing=false to override an existing value (e.g.
162 * flipping loading="lazy" → "eager" on the first image).
163 */
164 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
165 $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
166 if ( preg_match( $pattern, $tag ) ) {
167 if ( $only_if_missing ) {
168 return $tag;
169 }
170 return (string) preg_replace( $pattern, $name . '="' . $value . '"', $tag, 1 );
171 }
172 // Inject before the closing > (preserving self-closing `/>` if present).
173 if ( preg_match( '#(/?>)$#', $tag, $m ) ) {
174 $close = $m[1];
175 return substr( $tag, 0, -strlen( $close ) ) . ' ' . $name . '="' . $value . '"' . $close;
176 }
177 return $tag;
178 }
179
180 /**
181 * Attempt to fill in missing width / height from either an attached
182 * media library record (when class="wp-image-N") or from the local
183 * filesystem when src points at the uploads dir. Skip when we can't
184 * resolve cheaply — never block the request on a remote getimagesize.
185 */
186 private static function ensure_dimensions( string $tag ): string {
187 $has_w = (bool) preg_match( '#\bwidth\s*=#i', $tag );
188 $has_h = (bool) preg_match( '#\bheight\s*=#i', $tag );
189 if ( $has_w && $has_h ) {
190 return $tag;
191 }
192
193 // Try wp-image-<id> class first (cheapest path; one DB-cached
194 // get_post_meta call).
195 if ( preg_match( '#\bclass\s*=\s*["\']([^"\']*)["\']#i', $tag, $cm ) && preg_match( '#wp-image-(\d+)#i', $cm[1], $idm ) ) {
196 $dims = self::dimensions_for_attachment( (int) $idm[1] );
197 if ( $dims ) {
198 if ( ! $has_w ) {
199 $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
200 }
201 if ( ! $has_h ) {
202 $tag = self::set_attr( $tag, 'height', (string) $dims[1] );
203 }
204 return $tag;
205 }
206 }
207
208 // Couldn't resolve. Leave the tag alone — better no dimensions
209 // than wrong ones.
210 return $tag;
211 }
212
213 /**
214 * @return int[]|null [width, height] or null
215 */
216 private static function dimensions_for_attachment( int $attachment_id ): ?array {
217 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
218 return null;
219 }
220 $meta = wp_get_attachment_metadata( $attachment_id );
221 if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
222 return null;
223 }
224 return array( (int) $meta['width'], (int) $meta['height'] );
225 }
226
227 private static function is_excluded( string $tag, array $opts ): bool {
228 $excluded = $opts['excluded_images'] ?? array();
229 if ( ! is_array( $excluded ) || empty( $excluded ) ) {
230 return false;
231 }
232 foreach ( $excluded as $pattern ) {
233 $pattern = (string) $pattern;
234 if ( '' === $pattern ) {
235 continue;
236 }
237 if ( false !== stripos( $tag, $pattern ) ) {
238 return true;
239 }
240 }
241 return false;
242 }
243
244 /**
245 * Replace <script>, <style>, <noscript>, <pre>, <code> blocks with
246 * placeholder tokens before tag rewriting. Returns [stubbed_html,
247 * stubs_map]. Restore via restore_safe_blocks().
248 *
249 * @return array{0: string, 1: array<string,string>}
250 */
251 private static function stub_safe_blocks( string $html ): array {
252 $stubs = array();
253 $re = '#<(script|style|noscript|pre|code)\b[^>]*>.*?</\1>#is';
254 $out = preg_replace_callback(
255 $re,
256 static function ( $m ) use ( &$stubs ) {
257 $key = '<!--XSPEED_LAZY_STUB_' . count( $stubs ) . '-->';
258 $stubs[ $key ] = $m[0];
259 return $key;
260 },
261 $html
262 );
263 return array( (string) $out, $stubs );
264 }
265
266 private static function restore_safe_blocks( string $html, array $stubs ): string {
267 if ( empty( $stubs ) ) {
268 return $html;
269 }
270 return strtr( $html, $stubs );
271 }
272
273 private static function opts(): array {
274 if ( null === self::$opts ) {
275 self::$opts = Settings_Manager::get( 'lazy' );
276 }
277 return self::$opts;
278 }
279
280 /**
281 * Test-only: clear cached opts + counter between assertions.
282 */
283 public static function reset_state(): void {
284 self::$opts = null;
285 self::$image_counter = 0;
286 }
287 }
288