PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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 +144 -17 1.0.91.3.3 View file →
@@ -38,15 +38,16 @@
38 38 public const VERSION = '1.0.0';
39 39
40 40 public function ui_metadata(): array {
41 41 return array(
42 - 'label' => 'Media & Fonts',
43 - 'tab_label' => 'Lazy Loading', // its own tab on the Media & Fonts 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, videos, and web fonts load — lazy-loading, format optimization, and font display.',
45 + 'description' => __( 'Control how images, iframes, and videos load — lazy-loading, missing dimensions, and format optimization.', 'xspeed' ),
46 46 // Host page: Lazy Loading (this module) + Image Optimization (Pro)
47 - // + AI Suggestions (Pro) + Fonts (Free) as tabs — everything a page
48 - // loads on one page instead of separate rows (FBS-83633).
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).
49 50 'custom_panel' => 'MediaPanel',
50 51 );
51 52 }
52 53
@@ -54,22 +55,28 @@
54 55 return array(
55 56 'lazy_images' => array(
56 57 'type' => 'bool',
57 58 'default' => true,
58 - 'label' => 'Lazy-load Images',
59 - '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' ),
60 61 ),
61 62 'lazy_iframes' => array(
62 63 'type' => 'bool',
63 64 'default' => true,
64 - 'label' => 'Lazy-load Iframes',
65 - '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' ),
66 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 + ),
67 74 'lazy_videos' => array(
68 75 'type' => 'bool',
69 76 'default' => true,
70 - 'label' => 'Lazy-load HTML5 Videos',
71 - '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' ),
72 79 ),
73 80 'eager_first_n' => array(
74 81 'type' => 'int',
75 82 'default' => 1,
@@ -74,23 +81,23 @@
74 81 'type' => 'int',
75 82 'default' => 1,
76 83 'min' => 0,
77 84 'max' => 10,
78 - 'label' => 'Eager-load First N Images',
79 - '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' ),
80 87 ),
81 88 'add_missing_dimensions' => array(
82 89 'type' => 'bool',
83 90 'default' => true,
84 - 'label' => 'Add Missing Image Dimensions',
85 - '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' ),
86 93 ),
87 94 'excluded_images' => array(
88 95 'type' => 'list',
89 96 'default' => array(),
90 97 'item_type' => 'string',
91 - 'label' => 'Excluded Images',
92 - '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' ),
93 100 ),
94 101 );
95 102 }
96 103
@@ -111,8 +118,27 @@
111 118 );
112 119 }
113 120
114 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 {
115 141 // Bail entirely on admin / feed / cron / REST — same scope as
116 142 // Minifier. Lazy-loading rendered HTML only matters on real
117 143 // frontend page renders.
118 144 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
@@ -118,12 +144,19 @@
118 144 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
119 145 return;
120 146 }
121 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 +
122 154 $opts = $this->get_settings();
123 155 $any_enabled = ! empty( $opts['lazy_images'] )
124 156 || ! empty( $opts['lazy_iframes'] )
125 157 || ! empty( $opts['lazy_videos'] )
158 + || ! empty( $opts['video_facade'] )
126 159 || ! empty( $opts['add_missing_dimensions'] );
127 160 if ( ! $any_enabled ) {
128 161 return;
129 162 }
@@ -140,10 +173,95 @@
140 173 add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 );
141 174 add_filter( 'post_thumbnail_html', array( Lazy_Loader::class, 'process_html' ), 999 );
142 175 add_filter( 'get_avatar', array( Lazy_Loader::class, 'process_html' ), 999 );
143 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 + }
144 212 }
145 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 +
146 264 public function cli_commands(): array {
147 265 return array(
148 266 array(
149 267 'name' => 'xspeed lazy',
@@ -148,8 +266,9 @@
148 266 array(
149 267 'name' => 'xspeed lazy',
150 268 'callback' => array( $this, 'cli_handler' ),
151 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.',
152 271 'synopsis' => array(),
153 272 ),
154 273 );
155 274 }
@@ -162,6 +281,14 @@
162 281 $display = (string) $value;
163 282 }
164 283 \WP_CLI::log( sprintf( '%-30s %s', $key, $display ) );
165 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();
166 293 }
167 294 }