PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 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 All 35 releases
← All changes | includes/modules/Lazy/LazyModule.php +125 -22 1.2.4 → 1.4.1 View file →
@@ -38,12 +38,13 @@
38 38 public const VERSION = '1.0.0';
39 39
40 40 public function ui_metadata(): array {
41 41 return array(
42 - 'label' => 'Media Optimization',
43 - 'tab_label' => 'Lazy Loading', // its own tab on the Media Optimization page
42 + 'label' => __( 'Media Optimization', 'xspeed' ),
43 + 'tab_label' => __( 'Lazy Loading', 'xspeed' ), // its own tab on the Media Optimization page
44 44 'icon' => 'Image',
45 - 'description' => 'Control how images, iframes, and videos load — lazy-loading, missing dimensions, and format optimization.',
45 + 'description' => __( 'Loads images, videos and embeds only when visitors scroll to them.', 'xspeed' ),
46 + 'group' => 'performance',
46 47 // Host page: Lazy Loading (this module) + Image Optimization (Pro)
47 48 // + AI Suggestions (Pro) as tabs — everything a page loads on one
48 49 // page instead of separate rows (FBS-83633). Fonts is NOT here: it
49 50 // has its own Optimization card (#86).
@@ -55,49 +56,71 @@
55 56 return array(
56 57 'lazy_images' => array(
57 58 'type' => 'bool',
58 59 '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.',
60 + 'label' => __( 'Lazy-load images', 'xspeed' ),
61 + 'description' => __( 'Images load when the visitor scrolls near them. The first image on the page still loads right away.', 'xspeed' ),
61 62 ),
62 63 'lazy_iframes' => array(
63 64 'type' => 'bool',
64 65 '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.',
66 + 'label' => __( 'Lazy-load embeds', 'xspeed' ),
67 + 'description' => __( 'Embedded content such as YouTube videos and maps loads when the visitor scrolls near it.', 'xspeed' ),
67 68 ),
68 69 'video_facade' => array(
69 70 'type' => 'bool',
70 71 'default' => false,
71 - 'label' => 'Click-to-Play Video Facade',
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.',
72 + 'label' => __( 'Click-to-play videos', 'xspeed' ),
73 + 'description' => __( 'Shows a preview image with a play button in place of YouTube, Vimeo and your own videos that have a poster image. The video loads only when clicked. Autoplaying videos are left alone.', 'xspeed' ),
73 74 ),
74 75 'lazy_videos' => array(
75 76 'type' => 'bool',
76 77 'default' => true,
77 - 'label' => 'Lazy-load HTML5 Videos',
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.',
78 + 'label' => __( 'Lazy-load your own videos', 'xspeed' ),
79 + 'description' => __( 'Videos uploaded to your site download only when played. An autoplaying video still plays by itself, but its file waits until the video is near the screen. Add data-skip-lazy to a video to leave it alone.', 'xspeed' ),
79 80 ),
81 + 'video_after_load' => array(
82 + 'type' => 'bool',
83 + 'default' => false,
84 + 'label' => __( 'Start autoplay videos after page load', 'xspeed' ),
85 + 'description' => __( 'An autoplaying video, such as a hero background, waits until the page has finished loading, even when it is on screen from the start. Visitors see the poster first, and the video no longer competes with the page\'s images, styles and scripts. Needs "Lazy-load your own videos".', 'xspeed' ),
86 + 'dependsOn' => array( 'field' => 'lazy_videos' ),
87 + ),
88 + 'lazy_background_images' => array(
89 + 'type' => 'bool',
90 + 'default' => false,
91 + 'label' => __( 'Lazy-load background images', 'xspeed' ),
92 + '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' ),
93 + ),
80 94 'eager_first_n' => array(
81 95 'type' => 'int',
82 96 'default' => 1,
83 97 'min' => 0,
84 98 '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.',
99 + 'label' => __( 'Images to load right away', 'xspeed' ),
100 + 'description' => __( 'How many images at the top of the page skip lazy loading. Only the first of them also loads at high priority, and images in hidden or closed sections are not counted. Lazy-loaded background images use the same number. 1 suits most sites; 0 lazy-loads every image.', 'xspeed' ),
101 + 'advanced' => true,
102 + // Both counters read this number (class-lazy-loader.php), so it
103 + // shows while either kind of lazy loading is on.
104 + 'dependsOn' => array(
105 + 'any' => array(
106 + array( 'field' => 'lazy_images' ),
107 + array( 'field' => 'lazy_background_images' ),
108 + ),
109 + ),
87 110 ),
88 111 'add_missing_dimensions' => array(
89 112 'type' => 'bool',
90 113 '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.',
114 + 'label' => __( 'Add missing image sizes', 'xspeed' ),
115 + 'description' => __( 'Adds width and height to images that lack them, using the media library. This stops the page from jumping as images load.', 'xspeed' ),
93 116 ),
94 117 'excluded_images' => array(
95 118 'type' => 'list',
96 119 'default' => array(),
97 120 '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.',
121 + 'label' => __( 'Excluded images', 'xspeed' ),
122 + 'description' => __( 'Images, videos and embeds whose tag contains a line here, such as a class or file name, are never lazy-loaded. Useful for logos and hero images.', 'xspeed' ),
100 123 ),
101 124 );
102 125 }
103 126
@@ -118,8 +141,27 @@
118 141 );
119 142 }
120 143
121 144 public function boot(): void {
145 + /*
146 + * Deferred to `init`. This module reads its own settings to decide
147 + * what to hook, and reading settings builds settings_schema(), whose
148 + * labels are declared through __(). boot() runs on `plugins_loaded`,
149 + * before `after_setup_theme` — the point WordPress 6.7+ treats as the
150 + * earliest safe moment to translate — so doing that here fires
151 + * _load_textdomain_just_in_time on every request AND resolves the
152 + * labels against a domain that is not loaded yet.
153 + *
154 + * Everything below hooks actions that fire after `init`, so running
155 + * one hook later is equivalent.
156 + */
157 + add_action( 'init', array( $this, 'boot_on_init' ) );
158 + }
159 +
160 + /**
161 + * The real boot body — see boot() for why it runs on `init`.
162 + */
163 + public function boot_on_init(): void {
122 164 // Bail entirely on admin / feed / cron / REST — same scope as
123 165 // Minifier. Lazy-loading rendered HTML only matters on real
124 166 // frontend page renders.
125 167 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
@@ -136,8 +178,9 @@
136 178 $any_enabled = ! empty( $opts['lazy_images'] )
137 179 || ! empty( $opts['lazy_iframes'] )
138 180 || ! empty( $opts['lazy_videos'] )
139 181 || ! empty( $opts['video_facade'] )
182 + || ! empty( $opts['lazy_background_images'] )
140 183 || ! empty( $opts['add_missing_dimensions'] );
141 184 if ( ! $any_enabled ) {
142 185 return;
143 186 }
@@ -155,8 +198,22 @@
155 198 add_filter( 'post_thumbnail_html', array( Lazy_Loader::class, 'process_html' ), 999 );
156 199 add_filter( 'get_avatar', array( Lazy_Loader::class, 'process_html' ), 999 );
157 200 add_filter( 'widget_text_content', array( Lazy_Loader::class, 'process_html' ), 999 );
158 201
202 + /**
203 + * Whether media-library images printed in the footer get srcset
204 + * with sizes="auto" and loading="lazy". Core never filters that
205 + * markup, so a footer popup ships its images at full size.
206 + *
207 + * @param bool $enabled Default true while lazy-loading images is on.
208 + */
209 + if ( ! empty( $opts['lazy_images'] ) && apply_filters( 'xspeed_lazy_footer_images', true ) ) {
210 + add_action( 'template_redirect', array( \XSpeed\Footer_Images::class, 'reset_state' ) );
211 + add_action( 'get_footer', array( \XSpeed\Footer_Images::class, 'start' ), PHP_INT_MAX, 0 );
212 + add_action( 'wp_footer', array( \XSpeed\Footer_Images::class, 'start' ), PHP_INT_MIN, 0 );
213 + add_action( 'wp_footer', array( \XSpeed\Footer_Images::class, 'finish' ), PHP_INT_MAX, 0 );
214 + }
215 +
159 216 // The facade's click handler is printed only on pages that actually
160 217 // rendered a facade — a page with no embeds should not carry the
161 218 // script that reveals them.
162 219 if ( ! empty( $opts['video_facade'] ) ) {
@@ -166,10 +223,23 @@
166 223 // renders isn't known until the content filter has run — long
167 224 // after wp_head — and a layout rule that arrives in the footer
168 225 // fixes the gap only after the visitor has already seen it.
169 226 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_facade_style' ) );
227 + // Embeds that never touch the HTML: a builder widget builds its
228 + // YouTube iframe from script, so the buffer pass has nothing to
229 + // rewrite. Head, priority 1, for the same reason as the autoplay
230 + // restorer below — it must be listening before the widget's
231 + // script sets a src and commits the fetch.
232 + add_action( 'wp_head', array( $this, 'print_observer_script' ), 1 );
170 233 }
171 234
235 + // Head, because the rule that holds a background back has to apply
236 + // before first paint, or the browser has already requested it.
237 + if ( ! empty( $opts['lazy_background_images'] ) ) {
238 + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_background_style' ) );
239 + add_action( 'wp_head', array( $this, 'print_background_script' ), 1 );
240 + }
241 +
172 242 // Same conditional-footer treatment for the autoplay restorer: it is
173 243 // only printed on a response that actually deferred one.
174 244 if ( ! empty( $opts['lazy_videos'] ) ) {
175 245 /*
@@ -202,18 +272,48 @@
202 272 wp_enqueue_style( 'xspeed-video-facade' );
203 273 wp_add_inline_style( 'xspeed-video-facade', \XSpeed\Video_Facade::facade_style() );
204 274 }
205 275
276 + public function enqueue_background_style(): void {
277 + wp_register_style( 'xspeed-lazy-bg', false, array(), XSPEED_VERSION );
278 + wp_enqueue_style( 'xspeed-lazy-bg' );
279 + wp_add_inline_style( 'xspeed-lazy-bg', Lazy_Loader::background_style() );
280 + }
281 +
206 282 /**
283 + * data-xs-nodelay: Delay JS would otherwise hold this back until the
284 + * first interaction, and every held background would stay blank.
285 + */
286 + public function print_background_script(): void {
287 + wp_print_inline_script_tag(
288 + Lazy_Loader::background_script(),
289 + array(
290 + 'id' => 'xspeed-lazy-bg',
291 + 'data-xs-nodelay' => true,
292 + )
293 + );
294 + }
295 +
296 + /**
207 297 * Emit the click-to-play handler inline. Inline (not enqueued) because
208 298 * it is ~400 bytes — a separate request would cost more than the code.
209 299 */
210 300 public function print_facade_script(): void {
211 - if ( ! Lazy_Loader::facade_used() ) {
212 - return;
213 - }
301 + // No longer gated on facade_used(): the observer script can build a
302 + // facade for a JS-injected embed on a page where the server pass
303 + // rendered none, and a facade without its click handler is a play
304 + // button that plays nothing. ~400 bytes on facade-less pages is the
305 + // cost of never shipping that.
306 + wp_print_inline_script_tag( \XSpeed\Video_Facade::facade_script(), array( 'id' => 'xspeed-video-facade' ) );
307 + }
214 308
215 - wp_print_inline_script_tag( \XSpeed\Video_Facade::facade_script(), array( 'id' => 'xspeed-video-facade' ) );
309 + /**
310 + * Emit the interceptor for JS-injected embeds (see
311 + * Video_Facade::observer_script() for the mechanism and why the
312 + * footer is too late).
313 + */
314 + public function print_observer_script(): void {
315 + wp_print_inline_script_tag( \XSpeed\Video_Facade::observer_script(), array( 'id' => 'xspeed-video-facade-observer' ) );
216 316 }
217 317
218 318 /**
219 319 * Emit the viewport restorer for deferred AUTOPLAY videos.
@@ -238,9 +338,12 @@
238 338 * video, and the script installs observers only — it does no work
239 339 * and touches nothing when it finds no autoplay video. That is a
240 340 * better trade than missing the one case the feature exists for.
241 341 */
242 - wp_print_inline_script_tag( Lazy_Loader::autoplay_script(), array( 'id' => 'xspeed-lazy-autoplay' ) );
342 + $settings = $this->get_settings();
343 + $after_load = ! empty( $settings['video_after_load'] );
344 + $excluded = is_array( $settings['excluded_images'] ?? null ) ? $settings['excluded_images'] : array();
345 + wp_print_inline_script_tag( Lazy_Loader::autoplay_script( $after_load, $excluded ), array( 'id' => 'xspeed-lazy-autoplay' ) );
243 346 }
244 347
245 348 public function cli_commands(): array {
246 349 return array(