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 +147 -15 1.0.71.3.3 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,22 +55,28 @@
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 ),
68 80 'eager_first_n' => array(
69 81 'type' => 'int',
70 82 'default' => 1,
@@ -69,23 +81,23 @@
69 81 'type' => 'int',
70 82 'default' => 1,
71 83 'min' => 0,
72 84 '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.',
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' ),
75 87 ),
76 88 'add_missing_dimensions' => array(
77 89 'type' => 'bool',
78 90 '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.',
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' ),
81 93 ),
82 94 'excluded_images' => array(
83 95 'type' => 'list',
84 96 'default' => array(),
85 97 '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.',
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' ),
88 100 ),
89 101 );
90 102 }
91 103
@@ -106,8 +118,27 @@
106 118 );
107 119 }
108 120
109 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 {
110 141 // Bail entirely on admin / feed / cron / REST — same scope as
111 142 // Minifier. Lazy-loading rendered HTML only matters on real
112 143 // frontend page renders.
113 144 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
@@ -113,12 +144,19 @@
113 144 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
114 145 return;
115 146 }
116 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 +
117 154 $opts = $this->get_settings();
118 155 $any_enabled = ! empty( $opts['lazy_images'] )
119 156 || ! empty( $opts['lazy_iframes'] )
120 157 || ! empty( $opts['lazy_videos'] )
158 + || ! empty( $opts['video_facade'] )
121 159 || ! empty( $opts['add_missing_dimensions'] );
122 160 if ( ! $any_enabled ) {
123 161 return;
124 162 }
@@ -135,10 +173,95 @@
135 173 add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 );
136 174 add_filter( 'post_thumbnail_html', array( Lazy_Loader::class, 'process_html' ), 999 );
137 175 add_filter( 'get_avatar', array( Lazy_Loader::class, 'process_html' ), 999 );
138 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 + }
139 212 }
140 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 +
141 264 public function cli_commands(): array {
142 265 return array(
143 266 array(
144 267 'name' => 'xspeed lazy',
@@ -143,8 +266,9 @@
143 266 array(
144 267 'name' => 'xspeed lazy',
145 268 'callback' => array( $this, 'cli_handler' ),
146 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.',
147 271 'synopsis' => array(),
148 272 ),
149 273 );
150 274 }
@@ -157,6 +281,14 @@
157 281 $display = (string) $value;
158 282 }
159 283 \WP_CLI::log( sprintf( '%-30s %s', $key, $display ) );
160 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();
161 293 }
162 294 }