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

276 lines 8.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 // Reset per-call so each filter invocation starts a fresh
55 // "first N images go eager" budget. The_content fires once
56 // per post in the loop; new post → reset counter.
57 self::$image_counter = 0;
58
59 // Stub out <script>, <style>, <noscript>, <pre>, <code> blocks
60 // so img tags embedded in them as text examples aren't
61 // rewritten. Restore after pass.
62 [ $work, $stubs ] = self::stub_safe_blocks( $html );
63
64 if ( ! empty( $opts['lazy_images'] ) || ! empty( $opts['add_missing_dimensions'] ) ) {
65 $work = preg_replace_callback(
66 '#<img\b[^>]*>#i',
67 array( __CLASS__, 'rewrite_img' ),
68 $work
69 );
70 }
71 if ( ! empty( $opts['lazy_iframes'] ) ) {
72 $work = preg_replace_callback(
73 '#<iframe\b[^>]*>#i',
74 array( __CLASS__, 'rewrite_iframe' ),
75 $work
76 );
77 }
78 if ( ! empty( $opts['lazy_videos'] ) ) {
79 $work = preg_replace_callback(
80 '#<video\b[^>]*>#i',
81 array( __CLASS__, 'rewrite_video' ),
82 $work
83 );
84 }
85
86 return self::restore_safe_blocks( $work, $stubs );
87 }
88
89 private static function rewrite_img( array $m ): string {
90 $tag = $m[0];
91 $opts = self::opts();
92
93 // Bail if explicit skip flag present.
94 if ( false !== stripos( $tag, 'data-skip-lazy' ) || false !== stripos( $tag, 'data-no-lazy' ) ) {
95 return $tag;
96 }
97
98 // Honor excluded substring patterns (src or class match).
99 if ( self::is_excluded( $tag, $opts ) ) {
100 return $tag;
101 }
102
103 // Above-the-fold skip: first N images get loading="eager"
104 // instead of "lazy" so the LCP image isn't deferred.
105 self::$image_counter++;
106 $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
107
108 if ( ! empty( $opts['lazy_images'] ) ) {
109 $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
110 $tag = self::set_attr( $tag, 'decoding', 'async', true );
111 }
112
113 if ( ! empty( $opts['add_missing_dimensions'] ) ) {
114 $tag = self::ensure_dimensions( $tag );
115 }
116
117 return $tag;
118 }
119
120 private static function rewrite_iframe( array $m ): string {
121 $tag = $m[0];
122 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
123 return $tag;
124 }
125 if ( self::is_excluded( $tag, self::opts() ) ) {
126 return $tag;
127 }
128 return self::set_attr( $tag, 'loading', 'lazy' );
129 }
130
131 private static function rewrite_video( array $m ): string {
132 $tag = $m[0];
133 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
134 return $tag;
135 }
136 // HTML5 `<video>` doesn't support loading=lazy yet (Chromium
137 // won't add it before there's broad support). What we CAN do
138 // is set preload="none" so the browser doesn't pre-fetch the
139 // video bytes until play is requested — that's the actual win
140 // users want from "lazy-load videos".
141 if ( false === stripos( $tag, 'preload=' ) ) {
142 $tag = self::set_attr( $tag, 'preload', 'none' );
143 }
144 return $tag;
145 }
146
147 /**
148 * Add an attribute to an opening tag if it isn't already present.
149 * Pass $only_if_missing=false to override an existing value (e.g.
150 * flipping loading="lazy" → "eager" on the first image).
151 */
152 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
153 $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
154 if ( preg_match( $pattern, $tag ) ) {
155 if ( $only_if_missing ) {
156 return $tag;
157 }
158 return (string) preg_replace( $pattern, $name . '="' . $value . '"', $tag, 1 );
159 }
160 // Inject before the closing > (preserving self-closing `/>` if present).
161 if ( preg_match( '#(/?>)$#', $tag, $m ) ) {
162 $close = $m[1];
163 return substr( $tag, 0, -strlen( $close ) ) . ' ' . $name . '="' . $value . '"' . $close;
164 }
165 return $tag;
166 }
167
168 /**
169 * Attempt to fill in missing width / height from either an attached
170 * media library record (when class="wp-image-N") or from the local
171 * filesystem when src points at the uploads dir. Skip when we can't
172 * resolve cheaply — never block the request on a remote getimagesize.
173 */
174 private static function ensure_dimensions( string $tag ): string {
175 $has_w = (bool) preg_match( '#\bwidth\s*=#i', $tag );
176 $has_h = (bool) preg_match( '#\bheight\s*=#i', $tag );
177 if ( $has_w && $has_h ) {
178 return $tag;
179 }
180
181 // Try wp-image-<id> class first (cheapest path; one DB-cached
182 // get_post_meta call).
183 if ( preg_match( '#\bclass\s*=\s*["\']([^"\']*)["\']#i', $tag, $cm ) && preg_match( '#wp-image-(\d+)#i', $cm[1], $idm ) ) {
184 $dims = self::dimensions_for_attachment( (int) $idm[1] );
185 if ( $dims ) {
186 if ( ! $has_w ) {
187 $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
188 }
189 if ( ! $has_h ) {
190 $tag = self::set_attr( $tag, 'height', (string) $dims[1] );
191 }
192 return $tag;
193 }
194 }
195
196 // Couldn't resolve. Leave the tag alone — better no dimensions
197 // than wrong ones.
198 return $tag;
199 }
200
201 /**
202 * @return int[]|null [width, height] or null
203 */
204 private static function dimensions_for_attachment( int $attachment_id ): ?array {
205 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
206 return null;
207 }
208 $meta = wp_get_attachment_metadata( $attachment_id );
209 if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
210 return null;
211 }
212 return array( (int) $meta['width'], (int) $meta['height'] );
213 }
214
215 private static function is_excluded( string $tag, array $opts ): bool {
216 $excluded = $opts['excluded_images'] ?? array();
217 if ( ! is_array( $excluded ) || empty( $excluded ) ) {
218 return false;
219 }
220 foreach ( $excluded as $pattern ) {
221 $pattern = (string) $pattern;
222 if ( '' === $pattern ) {
223 continue;
224 }
225 if ( false !== stripos( $tag, $pattern ) ) {
226 return true;
227 }
228 }
229 return false;
230 }
231
232 /**
233 * Replace <script>, <style>, <noscript>, <pre>, <code> blocks with
234 * placeholder tokens before tag rewriting. Returns [stubbed_html,
235 * stubs_map]. Restore via restore_safe_blocks().
236 *
237 * @return array{0: string, 1: array<string,string>}
238 */
239 private static function stub_safe_blocks( string $html ): array {
240 $stubs = array();
241 $re = '#<(script|style|noscript|pre|code)\b[^>]*>.*?</\1>#is';
242 $out = preg_replace_callback(
243 $re,
244 static function ( $m ) use ( &$stubs ) {
245 $key = '<!--XSPEED_LAZY_STUB_' . count( $stubs ) . '-->';
246 $stubs[ $key ] = $m[0];
247 return $key;
248 },
249 $html
250 );
251 return array( (string) $out, $stubs );
252 }
253
254 private static function restore_safe_blocks( string $html, array $stubs ): string {
255 if ( empty( $stubs ) ) {
256 return $html;
257 }
258 return strtr( $html, $stubs );
259 }
260
261 private static function opts(): array {
262 if ( null === self::$opts ) {
263 self::$opts = Settings_Manager::get( 'lazy' );
264 }
265 return self::$opts;
266 }
267
268 /**
269 * Test-only: clear cached opts + counter between assertions.
270 */
271 public static function reset_state(): void {
272 self::$opts = null;
273 self::$image_counter = 0;
274 }
275 }
276