| @@ -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,64 @@ | ||
| 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 with a poster image and a play button. The player only loads when a visitor clicks it, so a page with embeds no longer pays ~1MB of third-party JavaScript that most visitors never use. 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. 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. Autoplaying videos are left alone.', 'xspeed' ), | |
| 79 | 80 | ), |
| 81 | + 'lazy_background_images' => array( | |
| 82 | + 'type' => 'bool', | |
| 83 | + 'default' => false, | |
| 84 | + 'label' => __( 'Lazy-load background images', 'xspeed' ), | |
| 85 | + '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' ), | |
| 86 | + ), | |
| 80 | 87 | 'eager_first_n' => array( |
| 81 | 88 | 'type' => 'int', |
| 82 | 89 | 'default' => 1, |
| 83 | 90 | 'min' => 0, |
| 84 | 91 | '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.', | |
| 92 | + 'label' => __( 'Images to load right away', 'xspeed' ), | |
| 93 | + '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' ), | |
| 94 | + 'advanced' => true, | |
| 95 | + // Both counters read this number (class-lazy-loader.php), so it | |
| 96 | + // shows while either kind of lazy loading is on. | |
| 97 | + 'dependsOn' => array( | |
| 98 | + 'any' => array( | |
| 99 | + array( 'field' => 'lazy_images' ), | |
| 100 | + array( 'field' => 'lazy_background_images' ), | |
| 101 | + ), | |
| 102 | + ), | |
| 87 | 103 | ), |
| 88 | 104 | 'add_missing_dimensions' => array( |
| 89 | 105 | 'type' => 'bool', |
| 90 | 106 | '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.', | |
| 107 | + 'label' => __( 'Add missing image sizes', 'xspeed' ), | |
| 108 | + '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 | 109 | ), |
| 94 | 110 | 'excluded_images' => array( |
| 95 | 111 | 'type' => 'list', |
| 96 | 112 | 'default' => array(), |
| 97 | 113 | '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.', | |
| 114 | + 'label' => __( 'Excluded images', 'xspeed' ), | |
| 115 | + 'description' => __( 'Images 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 | 116 | ), |
| 101 | 117 | ); |
| 102 | 118 | } |
| 103 | 119 | |
| @@ -118,8 +134,27 @@ | ||
| 118 | 134 | ); |
| 119 | 135 | } |
| 120 | 136 | |
| 121 | 137 | public function boot(): void { |
| 138 | + /* | |
| 139 | + * Deferred to `init`. This module reads its own settings to decide | |
| 140 | + * what to hook, and reading settings builds settings_schema(), whose | |
| 141 | + * labels are declared through __(). boot() runs on `plugins_loaded`, | |
| 142 | + * before `after_setup_theme` — the point WordPress 6.7+ treats as the | |
| 143 | + * earliest safe moment to translate — so doing that here fires | |
| 144 | + * _load_textdomain_just_in_time on every request AND resolves the | |
| 145 | + * labels against a domain that is not loaded yet. | |
| 146 | + * | |
| 147 | + * Everything below hooks actions that fire after `init`, so running | |
| 148 | + * one hook later is equivalent. | |
| 149 | + */ | |
| 150 | + add_action( 'init', array( $this, 'boot_on_init' ) ); | |
| 151 | + } | |
| 152 | + | |
| 153 | + /** | |
| 154 | + * The real boot body — see boot() for why it runs on `init`. | |
| 155 | + */ | |
| 156 | + public function boot_on_init(): void { | |
| 122 | 157 | // Bail entirely on admin / feed / cron / REST — same scope as |
| 123 | 158 | // Minifier. Lazy-loading rendered HTML only matters on real |
| 124 | 159 | // frontend page renders. |
| 125 | 160 | if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { |
| @@ -125,13 +160,20 @@ | ||
| 125 | 160 | if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { |
| 126 | 161 | return; |
| 127 | 162 | } |
| 128 | 163 | |
| 164 | + // Never lazy-load inside a builder editor: the builder measures and | |
| 165 | + // positions elements it expects to be loaded. (#281) | |
| 166 | + if ( \XSpeed\Builder_Editor::is_active() ) { | |
| 167 | + return; | |
| 168 | + } | |
| 169 | + | |
| 129 | 170 | $opts = $this->get_settings(); |
| 130 | 171 | $any_enabled = ! empty( $opts['lazy_images'] ) |
| 131 | 172 | || ! empty( $opts['lazy_iframes'] ) |
| 132 | 173 | || ! empty( $opts['lazy_videos'] ) |
| 133 | 174 | || ! empty( $opts['video_facade'] ) |
| 175 | + || ! empty( $opts['lazy_background_images'] ) | |
| 134 | 176 | || ! empty( $opts['add_missing_dimensions'] ); |
| 135 | 177 | if ( ! $any_enabled ) { |
| 136 | 178 | return; |
| 137 | 179 | } |
| @@ -160,9 +202,44 @@ | ||
| 160 | 202 | // renders isn't known until the content filter has run — long |
| 161 | 203 | // after wp_head — and a layout rule that arrives in the footer |
| 162 | 204 | // fixes the gap only after the visitor has already seen it. |
| 163 | 205 | add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_facade_style' ) ); |
| 206 | + // Embeds that never touch the HTML: a builder widget builds its | |
| 207 | + // YouTube iframe from script, so the buffer pass has nothing to | |
| 208 | + // rewrite. Head, priority 1, for the same reason as the autoplay | |
| 209 | + // restorer below — it must be listening before the widget's | |
| 210 | + // script sets a src and commits the fetch. | |
| 211 | + add_action( 'wp_head', array( $this, 'print_observer_script' ), 1 ); | |
| 164 | 212 | } |
| 213 | + | |
| 214 | + // Head, because the rule that holds a background back has to apply | |
| 215 | + // before first paint, or the browser has already requested it. | |
| 216 | + if ( ! empty( $opts['lazy_background_images'] ) ) { | |
| 217 | + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_background_style' ) ); | |
| 218 | + add_action( 'wp_head', array( $this, 'print_background_script' ), 1 ); | |
| 219 | + } | |
| 220 | + | |
| 221 | + // Same conditional-footer treatment for the autoplay restorer: it is | |
| 222 | + // only printed on a response that actually deferred one. | |
| 223 | + if ( ! empty( $opts['lazy_videos'] ) ) { | |
| 224 | + /* | |
| 225 | + * HEAD, not footer — and as early as anything can run. | |
| 226 | + * | |
| 227 | + * A page-builder video block creates its <video> from its own | |
| 228 | + * script. Ours has to be listening BEFORE that happens: printed | |
| 229 | + * in the footer it loaded after the block's script had already | |
| 230 | + * built the player and started the fetch, so the bytes were | |
| 231 | + * committed before we could defer them. Measured on a live page, | |
| 232 | + * our restorer sat ~17KB after the first block script in the | |
| 233 | + * document, and the videos still downloaded on load. | |
| 234 | + * | |
| 235 | + * It costs ~1KB inline and installs only observers, so running it | |
| 236 | + * early is cheap; a MutationObserver on documentElement catches | |
| 237 | + * every <video> the moment it is inserted, whichever script did | |
| 238 | + * the inserting. | |
| 239 | + */ | |
| 240 | + add_action( 'wp_head', array( $this, 'print_autoplay_script' ), 1 ); | |
| 241 | + } | |
| 165 | 242 | } |
| 166 | 243 | |
| 167 | 244 | /** |
| 168 | 245 | * Register the facade's layout rule as an inline style on a |
| @@ -174,20 +251,77 @@ | ||
| 174 | 251 | wp_enqueue_style( 'xspeed-video-facade' ); |
| 175 | 252 | wp_add_inline_style( 'xspeed-video-facade', \XSpeed\Video_Facade::facade_style() ); |
| 176 | 253 | } |
| 177 | 254 | |
| 255 | + public function enqueue_background_style(): void { | |
| 256 | + wp_register_style( 'xspeed-lazy-bg', false, array(), XSPEED_VERSION ); | |
| 257 | + wp_enqueue_style( 'xspeed-lazy-bg' ); | |
| 258 | + wp_add_inline_style( 'xspeed-lazy-bg', Lazy_Loader::background_style() ); | |
| 259 | + } | |
| 260 | + | |
| 178 | 261 | /** |
| 262 | + * data-xs-nodelay: Delay JS would otherwise hold this back until the | |
| 263 | + * first interaction, and every held background would stay blank. | |
| 264 | + */ | |
| 265 | + public function print_background_script(): void { | |
| 266 | + wp_print_inline_script_tag( | |
| 267 | + Lazy_Loader::background_script(), | |
| 268 | + array( | |
| 269 | + 'id' => 'xspeed-lazy-bg', | |
| 270 | + 'data-xs-nodelay' => true, | |
| 271 | + ) | |
| 272 | + ); | |
| 273 | + } | |
| 274 | + | |
| 275 | + /** | |
| 179 | 276 | * Emit the click-to-play handler inline. Inline (not enqueued) because |
| 180 | 277 | * it is ~400 bytes — a separate request would cost more than the code. |
| 181 | 278 | */ |
| 182 | 279 | public function print_facade_script(): void { |
| 183 | - if ( ! Lazy_Loader::facade_used() ) { | |
| 184 | - return; | |
| 185 | - } | |
| 280 | + // No longer gated on facade_used(): the observer script can build a | |
| 281 | + // facade for a JS-injected embed on a page where the server pass | |
| 282 | + // rendered none, and a facade without its click handler is a play | |
| 283 | + // button that plays nothing. ~400 bytes on facade-less pages is the | |
| 284 | + // cost of never shipping that. | |
| 285 | + wp_print_inline_script_tag( \XSpeed\Video_Facade::facade_script(), array( 'id' => 'xspeed-video-facade' ) ); | |
| 286 | + } | |
| 186 | 287 | |
| 187 | - wp_print_inline_script_tag( \XSpeed\Video_Facade::facade_script(), array( 'id' => 'xspeed-video-facade' ) ); | |
| 288 | + /** | |
| 289 | + * Emit the interceptor for JS-injected embeds (see | |
| 290 | + * Video_Facade::observer_script() for the mechanism and why the | |
| 291 | + * footer is too late). | |
| 292 | + */ | |
| 293 | + public function print_observer_script(): void { | |
| 294 | + wp_print_inline_script_tag( \XSpeed\Video_Facade::observer_script(), array( 'id' => 'xspeed-video-facade-observer' ) ); | |
| 188 | 295 | } |
| 189 | 296 | |
| 297 | + /** | |
| 298 | + * Emit the viewport restorer for deferred AUTOPLAY videos. | |
| 299 | + * | |
| 300 | + * Separate from the facade script because the two are independent: a | |
| 301 | + * page can defer an autoplay hero without any click-to-play facade on | |
| 302 | + * it, and vice versa. Both are gated on having actually rewritten | |
| 303 | + * something, so a page with no video ships neither. | |
| 304 | + */ | |
| 305 | + public function print_autoplay_script(): void { | |
| 306 | + /* | |
| 307 | + * Deliberately NOT gated on needs_autoplay_script(). | |
| 308 | + * | |
| 309 | + * That flag is only meaningful after the content filter has run, and | |
| 310 | + * this prints in wp_head — long before. The facade script above can | |
| 311 | + * afford to be conditional because it only has to be present by the | |
| 312 | + * time a human clicks; this one has to be listening before another | |
| 313 | + * plugin's script builds a <video> and starts fetching it, which | |
| 314 | + * happens well before wp_footer. | |
| 315 | + * | |
| 316 | + * The cost of being unconditional is ~1KB inline on pages with no | |
| 317 | + * video, and the script installs observers only — it does no work | |
| 318 | + * and touches nothing when it finds no autoplay video. That is a | |
| 319 | + * better trade than missing the one case the feature exists for. | |
| 320 | + */ | |
| 321 | + wp_print_inline_script_tag( Lazy_Loader::autoplay_script(), array( 'id' => 'xspeed-lazy-autoplay' ) ); | |
| 322 | + } | |
| 323 | + | |
| 190 | 324 | public function cli_commands(): array { |
| 191 | 325 | return array( |
| 192 | 326 | array( |
| 193 | 327 | 'name' => 'xspeed lazy', |
| @@ -192,8 +326,9 @@ | ||
| 192 | 326 | array( |
| 193 | 327 | 'name' => 'xspeed lazy', |
| 194 | 328 | 'callback' => array( $this, 'cli_handler' ), |
| 195 | 329 | 'shortdesc' => 'Show which lazy-load toggles are active.', |
| 330 | + '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.', | |
| 196 | 331 | 'synopsis' => array(), |
| 197 | 332 | ), |
| 198 | 333 | ); |
| 199 | 334 | } |
| @@ -206,6 +341,14 @@ | ||
| 206 | 341 | $display = (string) $value; |
| 207 | 342 | } |
| 208 | 343 | \WP_CLI::log( sprintf( '%-30s %s', $key, $display ) ); |
| 209 | 344 | } |
| 345 | + } | |
| 346 | + | |
| 347 | + /** | |
| 348 | + * Lazy has no master switch -- it is on when any of lazy_images / | |
| 349 | + * lazy_iframes / video_facade / lazy_videos is set. (#363) | |
| 350 | + */ | |
| 351 | + public function is_active(): ?bool { | |
| 352 | + return $this->any_bool_flag_on(); | |
| 210 | 353 | } |
| 211 | 354 | } |