| @@ -25,8 +25,10 @@ | ||
| 25 | 25 | declare(strict_types=1); |
| 26 | 26 | |
| 27 | 27 | namespace XSpeed\Modules\Lazy; |
| 28 | 28 | |
| 29 | +defined( 'ABSPATH' ) || exit; | |
| 30 | + | |
| 29 | 31 | use XSpeed\Lazy_Loader; |
| 30 | 32 | use XSpeed\Module; |
| 31 | 33 | |
| 32 | 34 | final class LazyModule extends Module { |
| @@ -36,11 +38,17 @@ | ||
| 36 | 38 | public const VERSION = '1.0.0'; |
| 37 | 39 | |
| 38 | 40 | public function ui_metadata(): array { |
| 39 | 41 | return array( |
| 40 | - 'label' => 'Lazy Load', | |
| 41 | - 'icon' => 'Image', | |
| 42 | - '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', | |
| 43 | 51 | ); |
| 44 | 52 | } |
| 45 | 53 | |
| 46 | 54 | public function settings_schema(): array { |
| @@ -47,22 +55,28 @@ | ||
| 47 | 55 | return array( |
| 48 | 56 | 'lazy_images' => array( |
| 49 | 57 | 'type' => 'bool', |
| 50 | 58 | 'default' => true, |
| 51 | - 'label' => 'Lazy-load Images', | |
| 52 | - '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' ), | |
| 53 | 61 | ), |
| 54 | 62 | 'lazy_iframes' => array( |
| 55 | 63 | 'type' => 'bool', |
| 56 | 64 | 'default' => true, |
| 57 | - 'label' => 'Lazy-load Iframes', | |
| 58 | - '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' ), | |
| 59 | 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 | + ), | |
| 60 | 74 | 'lazy_videos' => array( |
| 61 | 75 | 'type' => 'bool', |
| 62 | 76 | 'default' => true, |
| 63 | - 'label' => 'Lazy-load HTML5 Videos', | |
| 64 | - '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' ), | |
| 65 | 79 | ), |
| 66 | 80 | 'eager_first_n' => array( |
| 67 | 81 | 'type' => 'int', |
| 68 | 82 | 'default' => 1, |
| @@ -67,23 +81,23 @@ | ||
| 67 | 81 | 'type' => 'int', |
| 68 | 82 | 'default' => 1, |
| 69 | 83 | 'min' => 0, |
| 70 | 84 | 'max' => 10, |
| 71 | - 'label' => 'Eager-load First N Images', | |
| 72 | - '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' ), | |
| 73 | 87 | ), |
| 74 | 88 | 'add_missing_dimensions' => array( |
| 75 | 89 | 'type' => 'bool', |
| 76 | 90 | 'default' => true, |
| 77 | - 'label' => 'Add Missing Image Dimensions', | |
| 78 | - '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' ), | |
| 79 | 93 | ), |
| 80 | 94 | 'excluded_images' => array( |
| 81 | 95 | 'type' => 'list', |
| 82 | 96 | 'default' => array(), |
| 83 | 97 | 'item_type' => 'string', |
| 84 | - 'label' => 'Excluded Images', | |
| 85 | - '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' ), | |
| 86 | 100 | ), |
| 87 | 101 | ); |
| 88 | 102 | } |
| 89 | 103 | |
| @@ -104,8 +118,27 @@ | ||
| 104 | 118 | ); |
| 105 | 119 | } |
| 106 | 120 | |
| 107 | 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 { | |
| 108 | 141 | // Bail entirely on admin / feed / cron / REST — same scope as |
| 109 | 142 | // Minifier. Lazy-loading rendered HTML only matters on real |
| 110 | 143 | // frontend page renders. |
| 111 | 144 | if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { |
| @@ -111,17 +144,30 @@ | ||
| 111 | 144 | if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { |
| 112 | 145 | return; |
| 113 | 146 | } |
| 114 | 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 | + | |
| 115 | 154 | $opts = $this->get_settings(); |
| 116 | 155 | $any_enabled = ! empty( $opts['lazy_images'] ) |
| 117 | 156 | || ! empty( $opts['lazy_iframes'] ) |
| 118 | 157 | || ! empty( $opts['lazy_videos'] ) |
| 158 | + || ! empty( $opts['video_facade'] ) | |
| 119 | 159 | || ! empty( $opts['add_missing_dimensions'] ); |
| 120 | 160 | if ( ! $any_enabled ) { |
| 121 | 161 | return; |
| 122 | 162 | } |
| 123 | 163 | |
| 164 | + // Reset the eager-load budget once per page render, before any | |
| 165 | + // content filter runs, so the "first N images eager" budget is | |
| 166 | + // shared across the featured image + content + avatars rather than | |
| 167 | + // restarting on every filter pass. (FBS-82172 Bug 1) | |
| 168 | + add_action( 'template_redirect', array( Lazy_Loader::class, 'reset_state' ) ); | |
| 169 | + | |
| 124 | 170 | // Late priority so the_content runs after every other filter |
| 125 | 171 | // (shortcodes, do_blocks, embeds). Avoids rewriting tags that |
| 126 | 172 | // haven't been generated yet. |
| 127 | 173 | add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 ); |
| @@ -127,10 +173,95 @@ | ||
| 127 | 173 | add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 ); |
| 128 | 174 | add_filter( 'post_thumbnail_html', array( Lazy_Loader::class, 'process_html' ), 999 ); |
| 129 | 175 | add_filter( 'get_avatar', array( Lazy_Loader::class, 'process_html' ), 999 ); |
| 130 | 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 | + } | |
| 131 | 212 | } |
| 132 | 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 | + | |
| 133 | 264 | public function cli_commands(): array { |
| 134 | 265 | return array( |
| 135 | 266 | array( |
| 136 | 267 | 'name' => 'xspeed lazy', |
| @@ -135,8 +266,9 @@ | ||
| 135 | 266 | array( |
| 136 | 267 | 'name' => 'xspeed lazy', |
| 137 | 268 | 'callback' => array( $this, 'cli_handler' ), |
| 138 | 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.', | |
| 139 | 271 | 'synopsis' => array(), |
| 140 | 272 | ), |
| 141 | 273 | ); |
| 142 | 274 | } |
| @@ -149,6 +281,14 @@ | ||
| 149 | 281 | $display = (string) $value; |
| 150 | 282 | } |
| 151 | 283 | \WP_CLI::log( sprintf( '%-30s %s', $key, $display ) ); |
| 152 | 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(); | |
| 153 | 293 | } |
| 154 | 294 | } |