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 / modules / Lazy / LazyModule.php

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

212 lines 8.4 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',
43 'tab_label' => 'Lazy Loading', // 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.',
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',
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.',
61 ),
62 'lazy_iframes' => array(
63 'type' => 'bool',
64 'default' => true,
65 'label' => 'Lazy-load Iframes',
66 'description' => 'Add loading="lazy" to <iframe> tags. Useful for YouTube / Vimeo embeds + map widgets that pull a lot of bytes.',
67 ),
68 'video_facade' => array(
69 'type' => 'bool',
70 'default' => false,
71 'label' => 'Click-to-Play Video Facade',
72 'description' => 'Replace YouTube and Vimeo embeds with a poster image and a play button. The player only loads when a visitor clicks it, so a page with embeds no longer pays ~1MB of third-party JavaScript that most visitors never use. Falls back to the normal embed when JavaScript is off.',
73 ),
74 'lazy_videos' => array(
75 'type' => 'bool',
76 'default' => true,
77 'label' => 'Lazy-load HTML5 Videos',
78 'description' => 'Set preload="none" on self-hosted <video> tags. Browsers do not yet support loading="lazy" on video; preload="none" is the closest equivalent.',
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',
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.',
87 ),
88 'add_missing_dimensions' => array(
89 'type' => 'bool',
90 'default' => true,
91 'label' => 'Add Missing Image Dimensions',
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.',
93 ),
94 'excluded_images' => array(
95 'type' => 'list',
96 'default' => array(),
97 'item_type' => 'string',
98 'label' => 'Excluded Images',
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.',
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 // Bail entirely on admin / feed / cron / REST — same scope as
123 // Minifier. Lazy-loading rendered HTML only matters on real
124 // frontend page renders.
125 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
126 return;
127 }
128
129 $opts = $this->get_settings();
130 $any_enabled = ! empty( $opts['lazy_images'] )
131 || ! empty( $opts['lazy_iframes'] )
132 || ! empty( $opts['lazy_videos'] )
133 || ! empty( $opts['video_facade'] )
134 || ! empty( $opts['add_missing_dimensions'] );
135 if ( ! $any_enabled ) {
136 return;
137 }
138
139 // Reset the eager-load budget once per page render, before any
140 // content filter runs, so the "first N images eager" budget is
141 // shared across the featured image + content + avatars rather than
142 // restarting on every filter pass. (FBS-82172 Bug 1)
143 add_action( 'template_redirect', array( Lazy_Loader::class, 'reset_state' ) );
144
145 // Late priority so the_content runs after every other filter
146 // (shortcodes, do_blocks, embeds). Avoids rewriting tags that
147 // haven't been generated yet.
148 add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 );
149 add_filter( 'post_thumbnail_html', array( Lazy_Loader::class, 'process_html' ), 999 );
150 add_filter( 'get_avatar', array( Lazy_Loader::class, 'process_html' ), 999 );
151 add_filter( 'widget_text_content', array( Lazy_Loader::class, 'process_html' ), 999 );
152
153 // The facade's click handler is printed only on pages that actually
154 // rendered a facade — a page with no embeds should not carry the
155 // script that reveals them.
156 if ( ! empty( $opts['video_facade'] ) ) {
157 add_action( 'wp_footer', array( $this, 'print_facade_script' ), 99 );
158 // The layout rule goes in the HEAD, unconditionally, while the
159 // handler stays conditional in the footer. Whether a facade
160 // renders isn't known until the content filter has run — long
161 // after wp_head — and a layout rule that arrives in the footer
162 // fixes the gap only after the visitor has already seen it.
163 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_facade_style' ) );
164 }
165 }
166
167 /**
168 * Register the facade's layout rule as an inline style on a
169 * dependency-free handle — ~150 bytes, so a separate file would cost
170 * more than the CSS.
171 */
172 public function enqueue_facade_style(): void {
173 wp_register_style( 'xspeed-video-facade', false, array(), XSPEED_VERSION );
174 wp_enqueue_style( 'xspeed-video-facade' );
175 wp_add_inline_style( 'xspeed-video-facade', \XSpeed\Video_Facade::facade_style() );
176 }
177
178 /**
179 * Emit the click-to-play handler inline. Inline (not enqueued) because
180 * it is ~400 bytes — a separate request would cost more than the code.
181 */
182 public function print_facade_script(): void {
183 if ( ! Lazy_Loader::facade_used() ) {
184 return;
185 }
186
187 wp_print_inline_script_tag( \XSpeed\Video_Facade::facade_script(), array( 'id' => 'xspeed-video-facade' ) );
188 }
189
190 public function cli_commands(): array {
191 return array(
192 array(
193 'name' => 'xspeed lazy',
194 'callback' => array( $this, 'cli_handler' ),
195 'shortdesc' => 'Show which lazy-load toggles are active.',
196 'synopsis' => array(),
197 ),
198 );
199 }
200
201 public function cli_handler( array $args, array $assoc ): void {
202 $opts = $this->get_settings();
203 foreach ( $opts as $key => $value ) {
204 $display = is_array( $value ) ? implode( ',', $value ) : ( $value ? 'on' : ( is_numeric( $value ) ? (string) $value : 'off' ) );
205 if ( is_int( $value ) ) {
206 $display = (string) $value;
207 }
208 \WP_CLI::log( sprintf( '%-30s %s', $key, $display ) );
209 }
210 }
211 }
212