PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.5
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.5
1.3.5 1.3.4 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 All 31 releases
xspeed / includes / modules / Lazy / LazyModule.php

LazyModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.5, at includes/modules/Lazy/LazyModule.php

311 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Lazy module — defers img / iframe / video loading via native browser
4 * lazy-load attributes. Also auto-fills missing image dimensions to
5 * prevent CLS.
6 *
7 * What WordPress core does already (since 5.5):
8 * - Adds loading="lazy" to the_content images.
9 *
10 * What this module adds:
11 * - First N images get loading="eager" so the LCP isn't deferred.
12 * - Adds decoding="async" (core doesn't).
13 * - Lazy-loads iframes (core's iframe lazy was reverted).
14 * - preload="none" on <video> (closest thing to native video lazy).
15 * - Auto-fills missing width / height attributes (best CLS win).
16 * - Excludes by substring patterns (src or class match) — useful for
17 * hero banner classes, logo files, etc.
18 *
19 * Tier: Free per FEATURES.md "Images" §1-6 (LiteSpeed parity — all
20 * Free in LS Cache).
21 *
22 * @package XSpeed
23 */
24
25 declare(strict_types=1);
26
27 namespace XSpeed\Modules\Lazy;
28
29 defined( 'ABSPATH' ) || exit;
30
31 use XSpeed\Lazy_Loader;
32 use XSpeed\Module;
33
34 final class LazyModule extends Module {
35
36 public const SLUG = 'lazy';
37 public const TIER = self::TIER_FREE;
38 public const VERSION = '1.0.0';
39
40 public function ui_metadata(): array {
41 return array(
42 'label' => __( 'Media Optimization', 'xspeed' ),
43 'tab_label' => __( 'Lazy Loading', 'xspeed' ), // its own tab on the Media Optimization page
44 'icon' => 'Image',
45 'description' => __( 'Control how images, iframes, and videos load — lazy-loading, missing dimensions, and format optimization.', 'xspeed' ),
46 // Host page: Lazy Loading (this module) + Image Optimization (Pro)
47 // + AI Suggestions (Pro) as tabs — everything a page loads on one
48 // page instead of separate rows (FBS-83633). Fonts is NOT here: it
49 // has its own Optimization card (#86).
50 'custom_panel' => 'MediaPanel',
51 );
52 }
53
54 public function settings_schema(): array {
55 return array(
56 'lazy_images' => array(
57 'type' => 'bool',
58 'default' => true,
59 'label' => __( 'Lazy-load Images', 'xspeed' ),
60 'description' => __( 'Add loading="lazy" + decoding="async" to <img> tags in post content. The first images on the page get loading="eager" so the LCP image is not deferred.', 'xspeed' ),
61 ),
62 'lazy_iframes' => array(
63 'type' => 'bool',
64 'default' => true,
65 'label' => __( 'Lazy-load Iframes', 'xspeed' ),
66 'description' => __( 'Add loading="lazy" to <iframe> tags. Useful for YouTube / Vimeo embeds + map widgets that pull a lot of bytes.', 'xspeed' ),
67 ),
68 'video_facade' => array(
69 'type' => 'bool',
70 'default' => false,
71 'label' => __( 'Click-to-Play Video Facade', 'xspeed' ),
72 'description' => __( 'Replace YouTube and Vimeo embeds — and self-hosted <video> tags that have a poster — with the poster image and a play button. The video only loads when a visitor clicks it, so a page with embeds no longer pays ~1MB of third-party JavaScript, or the full weight of a hosted video file, for visitors who never press play. Autoplaying videos are left alone. Falls back to the normal embed when JavaScript is off.', 'xspeed' ),
73 ),
74 'lazy_videos' => array(
75 'type' => 'bool',
76 'default' => true,
77 'label' => __( 'Lazy-load HTML5 Videos', 'xspeed' ),
78 'description' => __( 'Set preload="none" on self-hosted <video> tags, overriding a player\'s own preload="auto"/"metadata". Autoplaying videos are left alone — they need their bytes regardless. Browsers do not yet support loading="lazy" on video; preload="none" is the closest equivalent.', 'xspeed' ),
79 ),
80 'eager_first_n' => array(
81 'type' => 'int',
82 'default' => 1,
83 'min' => 0,
84 'max' => 10,
85 'label' => __( 'Eager-load First N Images', 'xspeed' ),
86 'description' => __( 'How many images at the top of the post get loading="eager". 1 is usually right (the LCP hero image). 0 to lazy-load everything.', 'xspeed' ),
87 ),
88 'add_missing_dimensions' => array(
89 'type' => 'bool',
90 'default' => true,
91 'label' => __( 'Add Missing Image Dimensions', 'xspeed' ),
92 'description' => __( 'When an <img class="wp-image-N"> has no width/height, look the values up from the media library and inject them. Prevents the page-layout shift that hurts CLS scores.', 'xspeed' ),
93 ),
94 'excluded_images' => array(
95 'type' => 'list',
96 'default' => array(),
97 'item_type' => 'string',
98 'label' => __( 'Excluded Images', 'xspeed' ),
99 'description' => __( 'Substring patterns that, if found anywhere in the <img> / <iframe> tag (typically a class or filename), exempt that element from lazy-loading. Useful for hero / logo / sprite images. Or add data-skip-lazy to the tag directly.', 'xspeed' ),
100 ),
101 );
102 }
103
104 public function conflicts(): array {
105 return array(
106 array(
107 'plugin' => 'wp-smushit/wp-smush.php',
108 'feature' => 'images.lazyload',
109 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_WARN,
110 'reason' => 'Smush also offers lazy-loading; running both can cause double-rewriting.',
111 ),
112 array(
113 'plugin' => 'a3-lazy-load/a3-lazy-load.php',
114 'feature' => 'images.lazyload',
115 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_REFUSE,
116 'reason' => 'a3 Lazy Load is a dedicated lazy-load plugin; disable it before enabling xSpeed lazy-load.',
117 ),
118 );
119 }
120
121 public function boot(): void {
122 /*
123 * Deferred to `init`. This module reads its own settings to decide
124 * what to hook, and reading settings builds settings_schema(), whose
125 * labels are declared through __(). boot() runs on `plugins_loaded`,
126 * before `after_setup_theme` — the point WordPress 6.7+ treats as the
127 * earliest safe moment to translate — so doing that here fires
128 * _load_textdomain_just_in_time on every request AND resolves the
129 * labels against a domain that is not loaded yet.
130 *
131 * Everything below hooks actions that fire after `init`, so running
132 * one hook later is equivalent.
133 */
134 add_action( 'init', array( $this, 'boot_on_init' ) );
135 }
136
137 /**
138 * The real boot body — see boot() for why it runs on `init`.
139 */
140 public function boot_on_init(): void {
141 // Bail entirely on admin / feed / cron / REST — same scope as
142 // Minifier. Lazy-loading rendered HTML only matters on real
143 // frontend page renders.
144 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
145 return;
146 }
147
148 // Never lazy-load inside a builder editor: the builder measures and
149 // positions elements it expects to be loaded. (#281)
150 if ( \XSpeed\Builder_Editor::is_active() ) {
151 return;
152 }
153
154 $opts = $this->get_settings();
155 $any_enabled = ! empty( $opts['lazy_images'] )
156 || ! empty( $opts['lazy_iframes'] )
157 || ! empty( $opts['lazy_videos'] )
158 || ! empty( $opts['video_facade'] )
159 || ! empty( $opts['add_missing_dimensions'] );
160 if ( ! $any_enabled ) {
161 return;
162 }
163
164 // Reset the eager-load budget once per page render, before any
165 // content filter runs, so the "first N images eager" budget is
166 // shared across the featured image + content + avatars rather than
167 // restarting on every filter pass. (FBS-82172 Bug 1)
168 add_action( 'template_redirect', array( Lazy_Loader::class, 'reset_state' ) );
169
170 // Late priority so the_content runs after every other filter
171 // (shortcodes, do_blocks, embeds). Avoids rewriting tags that
172 // haven't been generated yet.
173 add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 );
174 add_filter( 'post_thumbnail_html', array( Lazy_Loader::class, 'process_html' ), 999 );
175 add_filter( 'get_avatar', array( Lazy_Loader::class, 'process_html' ), 999 );
176 add_filter( 'widget_text_content', array( Lazy_Loader::class, 'process_html' ), 999 );
177
178 // The facade's click handler is printed only on pages that actually
179 // rendered a facade — a page with no embeds should not carry the
180 // script that reveals them.
181 if ( ! empty( $opts['video_facade'] ) ) {
182 add_action( 'wp_footer', array( $this, 'print_facade_script' ), 99 );
183 // The layout rule goes in the HEAD, unconditionally, while the
184 // handler stays conditional in the footer. Whether a facade
185 // renders isn't known until the content filter has run — long
186 // after wp_head — and a layout rule that arrives in the footer
187 // fixes the gap only after the visitor has already seen it.
188 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_facade_style' ) );
189 // Embeds that never touch the HTML: a builder widget builds its
190 // YouTube iframe from script, so the buffer pass has nothing to
191 // rewrite. Head, priority 1, for the same reason as the autoplay
192 // restorer below — it must be listening before the widget's
193 // script sets a src and commits the fetch.
194 add_action( 'wp_head', array( $this, 'print_observer_script' ), 1 );
195 }
196
197 // Same conditional-footer treatment for the autoplay restorer: it is
198 // only printed on a response that actually deferred one.
199 if ( ! empty( $opts['lazy_videos'] ) ) {
200 /*
201 * HEAD, not footer — and as early as anything can run.
202 *
203 * A page-builder video block creates its <video> from its own
204 * script. Ours has to be listening BEFORE that happens: printed
205 * in the footer it loaded after the block's script had already
206 * built the player and started the fetch, so the bytes were
207 * committed before we could defer them. Measured on a live page,
208 * our restorer sat ~17KB after the first block script in the
209 * document, and the videos still downloaded on load.
210 *
211 * It costs ~1KB inline and installs only observers, so running it
212 * early is cheap; a MutationObserver on documentElement catches
213 * every <video> the moment it is inserted, whichever script did
214 * the inserting.
215 */
216 add_action( 'wp_head', array( $this, 'print_autoplay_script' ), 1 );
217 }
218 }
219
220 /**
221 * Register the facade's layout rule as an inline style on a
222 * dependency-free handle — ~150 bytes, so a separate file would cost
223 * more than the CSS.
224 */
225 public function enqueue_facade_style(): void {
226 wp_register_style( 'xspeed-video-facade', false, array(), XSPEED_VERSION );
227 wp_enqueue_style( 'xspeed-video-facade' );
228 wp_add_inline_style( 'xspeed-video-facade', \XSpeed\Video_Facade::facade_style() );
229 }
230
231 /**
232 * Emit the click-to-play handler inline. Inline (not enqueued) because
233 * it is ~400 bytes — a separate request would cost more than the code.
234 */
235 public function print_facade_script(): void {
236 // No longer gated on facade_used(): the observer script can build a
237 // facade for a JS-injected embed on a page where the server pass
238 // rendered none, and a facade without its click handler is a play
239 // button that plays nothing. ~400 bytes on facade-less pages is the
240 // cost of never shipping that.
241 wp_print_inline_script_tag( \XSpeed\Video_Facade::facade_script(), array( 'id' => 'xspeed-video-facade' ) );
242 }
243
244 /**
245 * Emit the interceptor for JS-injected embeds (see
246 * Video_Facade::observer_script() for the mechanism and why the
247 * footer is too late).
248 */
249 public function print_observer_script(): void {
250 wp_print_inline_script_tag( \XSpeed\Video_Facade::observer_script(), array( 'id' => 'xspeed-video-facade-observer' ) );
251 }
252
253 /**
254 * Emit the viewport restorer for deferred AUTOPLAY videos.
255 *
256 * Separate from the facade script because the two are independent: a
257 * page can defer an autoplay hero without any click-to-play facade on
258 * it, and vice versa. Both are gated on having actually rewritten
259 * something, so a page with no video ships neither.
260 */
261 public function print_autoplay_script(): void {
262 /*
263 * Deliberately NOT gated on needs_autoplay_script().
264 *
265 * That flag is only meaningful after the content filter has run, and
266 * this prints in wp_head — long before. The facade script above can
267 * afford to be conditional because it only has to be present by the
268 * time a human clicks; this one has to be listening before another
269 * plugin's script builds a <video> and starts fetching it, which
270 * happens well before wp_footer.
271 *
272 * The cost of being unconditional is ~1KB inline on pages with no
273 * video, and the script installs observers only — it does no work
274 * and touches nothing when it finds no autoplay video. That is a
275 * better trade than missing the one case the feature exists for.
276 */
277 wp_print_inline_script_tag( Lazy_Loader::autoplay_script(), array( 'id' => 'xspeed-lazy-autoplay' ) );
278 }
279
280 public function cli_commands(): array {
281 return array(
282 array(
283 'name' => 'xspeed lazy',
284 'callback' => array( $this, 'cli_handler' ),
285 'shortdesc' => 'Show which lazy-load toggles are active.',
286 'ai_hint' => 'Which lazy-loading and image optimizations are on (images, iframes, missing width/height)? Use for questions about images loading too early, layout shift (CLS), or offscreen images flagged by PageSpeed.',
287 'synopsis' => array(),
288 ),
289 );
290 }
291
292 public function cli_handler( array $args, array $assoc ): void {
293 $opts = $this->get_settings();
294 foreach ( $opts as $key => $value ) {
295 $display = is_array( $value ) ? implode( ',', $value ) : ( $value ? 'on' : ( is_numeric( $value ) ? (string) $value : 'off' ) );
296 if ( is_int( $value ) ) {
297 $display = (string) $value;
298 }
299 \WP_CLI::log( sprintf( '%-30s %s', $key, $display ) );
300 }
301 }
302
303 /**
304 * Lazy has no master switch -- it is on when any of lazy_images /
305 * lazy_iframes / video_facade / lazy_videos is set. (#363)
306 */
307 public function is_active(): ?bool {
308 return $this->any_bool_flag_on();
309 }
310 }
311