PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
1.3.6 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 All 32 releases
← All changes | includes/modules/Lazy/LazyModule.php +197 -15 1.0.8 → 1.3.6 View file →
@@ -38,11 +38,17 @@
38 38 public const VERSION = '1.0.0';
39 39
40 40 public function ui_metadata(): array {
41 41 return array(
42 - 'label' => 'Lazy Load',
43 - 'icon' => 'Image',
44 - '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',
45 51 );
46 52 }
47 53
48 54 public function settings_schema(): array {
@@ -49,43 +55,55 @@
49 55 return array(
50 56 'lazy_images' => array(
51 57 'type' => 'bool',
52 58 'default' => true,
53 - 'label' => 'Lazy-load Images',
54 - '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' ),
55 61 ),
56 62 'lazy_iframes' => array(
57 63 'type' => 'bool',
58 64 'default' => true,
59 - 'label' => 'Lazy-load Iframes',
60 - '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' ),
61 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 + ),
62 74 'lazy_videos' => array(
63 75 'type' => 'bool',
64 76 'default' => true,
65 - 'label' => 'Lazy-load HTML5 Videos',
66 - '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' ),
67 79 ),
80 + 'lazy_background_images' => array(
81 + 'type' => 'bool',
82 + 'default' => false,
83 + 'label' => __( 'Lazy-load Background Images', 'xspeed' ),
84 + 'description' => __( 'Hold back background images set in an element\'s inline style (a Cover or Group block, a page-builder section) until the element is near the screen. Uses a small script; visitors without JavaScript get every background as usual. The first N backgrounds on the page load straight away, like images. Backgrounds set in a stylesheet are not affected.', 'xspeed' ),
85 + ),
68 86 'eager_first_n' => array(
69 87 'type' => 'int',
70 88 'default' => 1,
71 89 'min' => 0,
72 90 'max' => 10,
73 - 'label' => 'Eager-load First N Images',
74 - '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.',
91 + 'label' => __( 'Eager-load First N Images', 'xspeed' ),
92 + '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. Lazy-loaded background images use the same number.', 'xspeed' ),
75 93 ),
76 94 'add_missing_dimensions' => array(
77 95 'type' => 'bool',
78 96 'default' => true,
79 - 'label' => 'Add Missing Image Dimensions',
80 - '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.',
97 + 'label' => __( 'Add Missing Image Dimensions', 'xspeed' ),
98 + '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' ),
81 99 ),
82 100 'excluded_images' => array(
83 101 'type' => 'list',
84 102 'default' => array(),
85 103 'item_type' => 'string',
86 - 'label' => 'Excluded Images',
87 - '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.',
104 + 'label' => __( 'Excluded Images', 'xspeed' ),
105 + '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' ),
88 106 ),
89 107 );
90 108 }
91 109
@@ -106,8 +124,27 @@
106 124 );
107 125 }
108 126
109 127 public function boot(): void {
128 + /*
129 + * Deferred to `init`. This module reads its own settings to decide
130 + * what to hook, and reading settings builds settings_schema(), whose
131 + * labels are declared through __(). boot() runs on `plugins_loaded`,
132 + * before `after_setup_theme` — the point WordPress 6.7+ treats as the
133 + * earliest safe moment to translate — so doing that here fires
134 + * _load_textdomain_just_in_time on every request AND resolves the
135 + * labels against a domain that is not loaded yet.
136 + *
137 + * Everything below hooks actions that fire after `init`, so running
138 + * one hook later is equivalent.
139 + */
140 + add_action( 'init', array( $this, 'boot_on_init' ) );
141 + }
142 +
143 + /**
144 + * The real boot body — see boot() for why it runs on `init`.
145 + */
146 + public function boot_on_init(): void {
110 147 // Bail entirely on admin / feed / cron / REST — same scope as
111 148 // Minifier. Lazy-loading rendered HTML only matters on real
112 149 // frontend page renders.
113 150 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
@@ -113,12 +150,20 @@
113 150 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
114 151 return;
115 152 }
116 153
154 + // Never lazy-load inside a builder editor: the builder measures and
155 + // positions elements it expects to be loaded. (#281)
156 + if ( \XSpeed\Builder_Editor::is_active() ) {
157 + return;
158 + }
159 +
117 160 $opts = $this->get_settings();
118 161 $any_enabled = ! empty( $opts['lazy_images'] )
119 162 || ! empty( $opts['lazy_iframes'] )
120 163 || ! empty( $opts['lazy_videos'] )
164 + || ! empty( $opts['video_facade'] )
165 + || ! empty( $opts['lazy_background_images'] )
121 166 || ! empty( $opts['add_missing_dimensions'] );
122 167 if ( ! $any_enabled ) {
123 168 return;
124 169 }
@@ -135,10 +180,138 @@
135 180 add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 );
136 181 add_filter( 'post_thumbnail_html', array( Lazy_Loader::class, 'process_html' ), 999 );
137 182 add_filter( 'get_avatar', array( Lazy_Loader::class, 'process_html' ), 999 );
138 183 add_filter( 'widget_text_content', array( Lazy_Loader::class, 'process_html' ), 999 );
184 +
185 + // The facade's click handler is printed only on pages that actually
186 + // rendered a facade — a page with no embeds should not carry the
187 + // script that reveals them.
188 + if ( ! empty( $opts['video_facade'] ) ) {
189 + add_action( 'wp_footer', array( $this, 'print_facade_script' ), 99 );
190 + // The layout rule goes in the HEAD, unconditionally, while the
191 + // handler stays conditional in the footer. Whether a facade
192 + // renders isn't known until the content filter has run — long
193 + // after wp_head — and a layout rule that arrives in the footer
194 + // fixes the gap only after the visitor has already seen it.
195 + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_facade_style' ) );
196 + // Embeds that never touch the HTML: a builder widget builds its
197 + // YouTube iframe from script, so the buffer pass has nothing to
198 + // rewrite. Head, priority 1, for the same reason as the autoplay
199 + // restorer below — it must be listening before the widget's
200 + // script sets a src and commits the fetch.
201 + add_action( 'wp_head', array( $this, 'print_observer_script' ), 1 );
202 + }
203 +
204 + // Head, because the rule that holds a background back has to apply
205 + // before first paint, or the browser has already requested it.
206 + if ( ! empty( $opts['lazy_background_images'] ) ) {
207 + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_background_style' ) );
208 + add_action( 'wp_head', array( $this, 'print_background_script' ), 1 );
209 + }
210 +
211 + // Same conditional-footer treatment for the autoplay restorer: it is
212 + // only printed on a response that actually deferred one.
213 + if ( ! empty( $opts['lazy_videos'] ) ) {
214 + /*
215 + * HEAD, not footer — and as early as anything can run.
216 + *
217 + * A page-builder video block creates its <video> from its own
218 + * script. Ours has to be listening BEFORE that happens: printed
219 + * in the footer it loaded after the block's script had already
220 + * built the player and started the fetch, so the bytes were
221 + * committed before we could defer them. Measured on a live page,
222 + * our restorer sat ~17KB after the first block script in the
223 + * document, and the videos still downloaded on load.
224 + *
225 + * It costs ~1KB inline and installs only observers, so running it
226 + * early is cheap; a MutationObserver on documentElement catches
227 + * every <video> the moment it is inserted, whichever script did
228 + * the inserting.
229 + */
230 + add_action( 'wp_head', array( $this, 'print_autoplay_script' ), 1 );
231 + }
139 232 }
140 233
234 + /**
235 + * Register the facade's layout rule as an inline style on a
236 + * dependency-free handle — ~150 bytes, so a separate file would cost
237 + * more than the CSS.
238 + */
239 + public function enqueue_facade_style(): void {
240 + wp_register_style( 'xspeed-video-facade', false, array(), XSPEED_VERSION );
241 + wp_enqueue_style( 'xspeed-video-facade' );
242 + wp_add_inline_style( 'xspeed-video-facade', \XSpeed\Video_Facade::facade_style() );
243 + }
244 +
245 + public function enqueue_background_style(): void {
246 + wp_register_style( 'xspeed-lazy-bg', false, array(), XSPEED_VERSION );
247 + wp_enqueue_style( 'xspeed-lazy-bg' );
248 + wp_add_inline_style( 'xspeed-lazy-bg', Lazy_Loader::background_style() );
249 + }
250 +
251 + /**
252 + * data-xs-nodelay: Delay JS would otherwise hold this back until the
253 + * first interaction, and every held background would stay blank.
254 + */
255 + public function print_background_script(): void {
256 + wp_print_inline_script_tag(
257 + Lazy_Loader::background_script(),
258 + array(
259 + 'id' => 'xspeed-lazy-bg',
260 + 'data-xs-nodelay' => true,
261 + )
262 + );
263 + }
264 +
265 + /**
266 + * Emit the click-to-play handler inline. Inline (not enqueued) because
267 + * it is ~400 bytes — a separate request would cost more than the code.
268 + */
269 + public function print_facade_script(): void {
270 + // No longer gated on facade_used(): the observer script can build a
271 + // facade for a JS-injected embed on a page where the server pass
272 + // rendered none, and a facade without its click handler is a play
273 + // button that plays nothing. ~400 bytes on facade-less pages is the
274 + // cost of never shipping that.
275 + wp_print_inline_script_tag( \XSpeed\Video_Facade::facade_script(), array( 'id' => 'xspeed-video-facade' ) );
276 + }
277 +
278 + /**
279 + * Emit the interceptor for JS-injected embeds (see
280 + * Video_Facade::observer_script() for the mechanism and why the
281 + * footer is too late).
282 + */
283 + public function print_observer_script(): void {
284 + wp_print_inline_script_tag( \XSpeed\Video_Facade::observer_script(), array( 'id' => 'xspeed-video-facade-observer' ) );
285 + }
286 +
287 + /**
288 + * Emit the viewport restorer for deferred AUTOPLAY videos.
289 + *
290 + * Separate from the facade script because the two are independent: a
291 + * page can defer an autoplay hero without any click-to-play facade on
292 + * it, and vice versa. Both are gated on having actually rewritten
293 + * something, so a page with no video ships neither.
294 + */
295 + public function print_autoplay_script(): void {
296 + /*
297 + * Deliberately NOT gated on needs_autoplay_script().
298 + *
299 + * That flag is only meaningful after the content filter has run, and
300 + * this prints in wp_head — long before. The facade script above can
301 + * afford to be conditional because it only has to be present by the
302 + * time a human clicks; this one has to be listening before another
303 + * plugin's script builds a <video> and starts fetching it, which
304 + * happens well before wp_footer.
305 + *
306 + * The cost of being unconditional is ~1KB inline on pages with no
307 + * video, and the script installs observers only — it does no work
308 + * and touches nothing when it finds no autoplay video. That is a
309 + * better trade than missing the one case the feature exists for.
310 + */
311 + wp_print_inline_script_tag( Lazy_Loader::autoplay_script(), array( 'id' => 'xspeed-lazy-autoplay' ) );
312 + }
313 +
141 314 public function cli_commands(): array {
142 315 return array(
143 316 array(
144 317 'name' => 'xspeed lazy',
@@ -143,8 +316,9 @@
143 316 array(
144 317 'name' => 'xspeed lazy',
145 318 'callback' => array( $this, 'cli_handler' ),
146 319 'shortdesc' => 'Show which lazy-load toggles are active.',
320 + '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.',
147 321 'synopsis' => array(),
148 322 ),
149 323 );
150 324 }
@@ -157,6 +331,14 @@
157 331 $display = (string) $value;
158 332 }
159 333 \WP_CLI::log( sprintf( '%-30s %s', $key, $display ) );
160 334 }
335 + }
336 +
337 + /**
338 + * Lazy has no master switch -- it is on when any of lazy_images /
339 + * lazy_iframes / video_facade / lazy_videos is set. (#363)
340 + */
341 + public function is_active(): ?bool {
342 + return $this->any_bool_flag_on();
161 343 }
162 344 }