PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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
← All changes | includes/modules/Lazy/LazyModule.php +155 -15 1.0.21.3.2 View file →
@@ -25,8 +25,10 @@
25 25 declare(strict_types=1);
26 26
27 27 namespace XSpeed\Modules\Lazy;
28 28
29 +defined( 'ABSPATH' ) || exit;
30 +
29 31 use XSpeed\Lazy_Loader;
30 32 use XSpeed\Module;
31 33
32 34 final class LazyModule extends Module {
@@ -36,11 +38,17 @@
36 38 public const VERSION = '1.0.0';
37 39
38 40 public function ui_metadata(): array {
39 41 return array(
40 - 'label' => 'Lazy Load',
41 - 'icon' => 'Image',
42 - 'description' => 'Defer images / iframes / videos until they scroll into view, and auto-add missing dimensions to prevent CLS.',
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',
43 51 );
44 52 }
45 53
46 54 public function settings_schema(): array {
@@ -47,22 +55,28 @@
47 55 return array(
48 56 'lazy_images' => array(
49 57 'type' => 'bool',
50 58 'default' => true,
51 - 'label' => 'Lazy-load Images',
52 - '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.',
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' ),
53 61 ),
54 62 'lazy_iframes' => array(
55 63 'type' => 'bool',
56 64 'default' => true,
57 - 'label' => 'Lazy-load Iframes',
58 - 'description' => 'Add loading="lazy" to <iframe> tags. Useful for YouTube / Vimeo embeds + map widgets that pull a lot of bytes.',
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' ),
59 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 + ),
60 74 'lazy_videos' => array(
61 75 'type' => 'bool',
62 76 'default' => true,
63 - 'label' => 'Lazy-load HTML5 Videos',
64 - 'description' => 'Set preload="none" on self-hosted <video> tags. Browsers do not yet support loading="lazy" on video; preload="none" is the closest equivalent.',
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' ),
65 79 ),
66 80 'eager_first_n' => array(
67 81 'type' => 'int',
68 82 'default' => 1,
@@ -67,23 +81,23 @@
67 81 'type' => 'int',
68 82 'default' => 1,
69 83 'min' => 0,
70 84 'max' => 10,
71 - 'label' => 'Eager-load First N Images',
72 - '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.',
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' ),
73 87 ),
74 88 'add_missing_dimensions' => array(
75 89 'type' => 'bool',
76 90 'default' => true,
77 - 'label' => 'Add Missing Image Dimensions',
78 - '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.',
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' ),
79 93 ),
80 94 'excluded_images' => array(
81 95 'type' => 'list',
82 96 'default' => array(),
83 97 'item_type' => 'string',
84 - 'label' => 'Excluded Images',
85 - '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.',
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' ),
86 100 ),
87 101 );
88 102 }
89 103
@@ -104,8 +118,27 @@
104 118 );
105 119 }
106 120
107 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 {
108 141 // Bail entirely on admin / feed / cron / REST — same scope as
109 142 // Minifier. Lazy-loading rendered HTML only matters on real
110 143 // frontend page renders.
111 144 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
@@ -111,17 +144,30 @@
111 144 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
112 145 return;
113 146 }
114 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 +
115 154 $opts = $this->get_settings();
116 155 $any_enabled = ! empty( $opts['lazy_images'] )
117 156 || ! empty( $opts['lazy_iframes'] )
118 157 || ! empty( $opts['lazy_videos'] )
158 + || ! empty( $opts['video_facade'] )
119 159 || ! empty( $opts['add_missing_dimensions'] );
120 160 if ( ! $any_enabled ) {
121 161 return;
122 162 }
123 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 +
124 170 // Late priority so the_content runs after every other filter
125 171 // (shortcodes, do_blocks, embeds). Avoids rewriting tags that
126 172 // haven't been generated yet.
127 173 add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 );
@@ -127,10 +173,95 @@
127 173 add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 );
128 174 add_filter( 'post_thumbnail_html', array( Lazy_Loader::class, 'process_html' ), 999 );
129 175 add_filter( 'get_avatar', array( Lazy_Loader::class, 'process_html' ), 999 );
130 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 + }
190 +
191 + // Same conditional-footer treatment for the autoplay restorer: it is
192 + // only printed on a response that actually deferred one.
193 + if ( ! empty( $opts['lazy_videos'] ) ) {
194 + /*
195 + * HEAD, not footer — and as early as anything can run.
196 + *
197 + * A page-builder video block creates its <video> from its own
198 + * script. Ours has to be listening BEFORE that happens: printed
199 + * in the footer it loaded after the block's script had already
200 + * built the player and started the fetch, so the bytes were
201 + * committed before we could defer them. Measured on a live page,
202 + * our restorer sat ~17KB after the first block script in the
203 + * document, and the videos still downloaded on load.
204 + *
205 + * It costs ~1KB inline and installs only observers, so running it
206 + * early is cheap; a MutationObserver on documentElement catches
207 + * every <video> the moment it is inserted, whichever script did
208 + * the inserting.
209 + */
210 + add_action( 'wp_head', array( $this, 'print_autoplay_script' ), 1 );
211 + }
131 212 }
132 213
214 + /**
215 + * Register the facade's layout rule as an inline style on a
216 + * dependency-free handle — ~150 bytes, so a separate file would cost
217 + * more than the CSS.
218 + */
219 + public function enqueue_facade_style(): void {
220 + wp_register_style( 'xspeed-video-facade', false, array(), XSPEED_VERSION );
221 + wp_enqueue_style( 'xspeed-video-facade' );
222 + wp_add_inline_style( 'xspeed-video-facade', \XSpeed\Video_Facade::facade_style() );
223 + }
224 +
225 + /**
226 + * Emit the click-to-play handler inline. Inline (not enqueued) because
227 + * it is ~400 bytes — a separate request would cost more than the code.
228 + */
229 + public function print_facade_script(): void {
230 + if ( ! Lazy_Loader::facade_used() ) {
231 + return;
232 + }
233 +
234 + wp_print_inline_script_tag( \XSpeed\Video_Facade::facade_script(), array( 'id' => 'xspeed-video-facade' ) );
235 + }
236 +
237 + /**
238 + * Emit the viewport restorer for deferred AUTOPLAY videos.
239 + *
240 + * Separate from the facade script because the two are independent: a
241 + * page can defer an autoplay hero without any click-to-play facade on
242 + * it, and vice versa. Both are gated on having actually rewritten
243 + * something, so a page with no video ships neither.
244 + */
245 + public function print_autoplay_script(): void {
246 + /*
247 + * Deliberately NOT gated on needs_autoplay_script().
248 + *
249 + * That flag is only meaningful after the content filter has run, and
250 + * this prints in wp_head — long before. The facade script above can
251 + * afford to be conditional because it only has to be present by the
252 + * time a human clicks; this one has to be listening before another
253 + * plugin's script builds a <video> and starts fetching it, which
254 + * happens well before wp_footer.
255 + *
256 + * The cost of being unconditional is ~1KB inline on pages with no
257 + * video, and the script installs observers only — it does no work
258 + * and touches nothing when it finds no autoplay video. That is a
259 + * better trade than missing the one case the feature exists for.
260 + */
261 + wp_print_inline_script_tag( Lazy_Loader::autoplay_script(), array( 'id' => 'xspeed-lazy-autoplay' ) );
262 + }
263 +
133 264 public function cli_commands(): array {
134 265 return array(
135 266 array(
136 267 'name' => 'xspeed lazy',
@@ -135,8 +266,9 @@
135 266 array(
136 267 'name' => 'xspeed lazy',
137 268 'callback' => array( $this, 'cli_handler' ),
138 269 'shortdesc' => 'Show which lazy-load toggles are active.',
270 + '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.',
139 271 'synopsis' => array(),
140 272 ),
141 273 );
142 274 }
@@ -149,6 +281,14 @@
149 281 $display = (string) $value;
150 282 }
151 283 \WP_CLI::log( sprintf( '%-30s %s', $key, $display ) );
152 284 }
285 + }
286 +
287 + /**
288 + * Lazy has no master switch -- it is on when any of lazy_images /
289 + * lazy_iframes / video_facade / lazy_videos is set. (#363)
290 + */
291 + public function is_active(): ?bool {
292 + return $this->any_bool_flag_on();
153 293 }
154 294 }