PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.2
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
xspeed / includes / modules / Lazy / LazyModule.php

LazyModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.2, at includes/modules/Lazy/LazyModule.php

211 lines 8.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Lazy module — defers img / iframe / video loading via native browser
4 * lazy-load attributes. Also auto-fills missing image dimensions to
5 * prevent CLS.
6 *
7 * What WordPress core does already (since 5.5):
8 * - Adds loading="lazy" to the_content images.
9 *
10 * What this module adds:
11 * - First N images get loading="eager" so the LCP isn't deferred.
12 * - Adds decoding="async" (core doesn't).
13 * - Lazy-loads iframes (core's iframe lazy was reverted).
14 * - preload="none" on <video> (closest thing to native video lazy).
15 * - Auto-fills missing width / height attributes (best CLS win).
16 * - Excludes by substring patterns (src or class match) — useful for
17 * hero banner classes, logo files, etc.
18 *
19 * Tier: Free per FEATURES.md "Images" §1-6 (LiteSpeed parity — all
20 * Free in LS Cache).
21 *
22 * @package XSpeed
23 */
24
25 declare(strict_types=1);
26
27 namespace XSpeed\Modules\Lazy;
28
29 defined( 'ABSPATH' ) || exit;
30
31 use XSpeed\Lazy_Loader;
32 use XSpeed\Module;
33
34 final class LazyModule extends Module {
35
36 public const SLUG = 'lazy';
37 public const TIER = self::TIER_FREE;
38 public const VERSION = '1.0.0';
39
40 public function ui_metadata(): array {
41 return array(
42 'label' => 'Media & Fonts',
43 'tab_label' => 'Lazy Loading', // its own tab on the Media & Fonts page
44 'icon' => 'Image',
45 'description' => 'Control how images, iframes, videos, and web fonts load — lazy-loading, format optimization, and font display.',
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).
49 'custom_panel' => 'MediaPanel',
50 );
51 }
52
53 public function settings_schema(): array {
54 return array(
55 'lazy_images' => array(
56 'type' => 'bool',
57 '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.',
60 ),
61 'lazy_iframes' => array(
62 'type' => 'bool',
63 '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.',
66 ),
67 'video_facade' => array(
68 'type' => 'bool',
69 'default' => false,
70 'label' => 'Click-to-Play Video Facade',
71 '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 ),
73 'lazy_videos' => array(
74 'type' => 'bool',
75 'default' => true,
76 'label' => 'Lazy-load HTML5 Videos',
77 '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 ),
79 'eager_first_n' => array(
80 'type' => 'int',
81 'default' => 1,
82 'min' => 0,
83 'max' => 10,
84 'label' => 'Eager-load First N Images',
85 '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.',
86 ),
87 'add_missing_dimensions' => array(
88 'type' => 'bool',
89 'default' => true,
90 'label' => 'Add Missing Image Dimensions',
91 '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.',
92 ),
93 'excluded_images' => array(
94 'type' => 'list',
95 'default' => array(),
96 'item_type' => 'string',
97 'label' => 'Excluded Images',
98 '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.',
99 ),
100 );
101 }
102
103 public function conflicts(): array {
104 return array(
105 array(
106 'plugin' => 'wp-smushit/wp-smush.php',
107 'feature' => 'images.lazyload',
108 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_WARN,
109 'reason' => 'Smush also offers lazy-loading; running both can cause double-rewriting.',
110 ),
111 array(
112 'plugin' => 'a3-lazy-load/a3-lazy-load.php',
113 'feature' => 'images.lazyload',
114 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_REFUSE,
115 'reason' => 'a3 Lazy Load is a dedicated lazy-load plugin; disable it before enabling xSpeed lazy-load.',
116 ),
117 );
118 }
119
120 public function boot(): void {
121 // Bail entirely on admin / feed / cron / REST — same scope as
122 // Minifier. Lazy-loading rendered HTML only matters on real
123 // frontend page renders.
124 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
125 return;
126 }
127
128 $opts = $this->get_settings();
129 $any_enabled = ! empty( $opts['lazy_images'] )
130 || ! empty( $opts['lazy_iframes'] )
131 || ! empty( $opts['lazy_videos'] )
132 || ! empty( $opts['video_facade'] )
133 || ! empty( $opts['add_missing_dimensions'] );
134 if ( ! $any_enabled ) {
135 return;
136 }
137
138 // Reset the eager-load budget once per page render, before any
139 // content filter runs, so the "first N images eager" budget is
140 // shared across the featured image + content + avatars rather than
141 // restarting on every filter pass. (FBS-82172 Bug 1)
142 add_action( 'template_redirect', array( Lazy_Loader::class, 'reset_state' ) );
143
144 // Late priority so the_content runs after every other filter
145 // (shortcodes, do_blocks, embeds). Avoids rewriting tags that
146 // haven't been generated yet.
147 add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 );
148 add_filter( 'post_thumbnail_html', array( Lazy_Loader::class, 'process_html' ), 999 );
149 add_filter( 'get_avatar', array( Lazy_Loader::class, 'process_html' ), 999 );
150 add_filter( 'widget_text_content', array( Lazy_Loader::class, 'process_html' ), 999 );
151
152 // The facade's click handler is printed only on pages that actually
153 // rendered a facade — a page with no embeds should not carry the
154 // script that reveals them.
155 if ( ! empty( $opts['video_facade'] ) ) {
156 add_action( 'wp_footer', array( $this, 'print_facade_script' ), 99 );
157 // The layout rule goes in the HEAD, unconditionally, while the
158 // handler stays conditional in the footer. Whether a facade
159 // renders isn't known until the content filter has run — long
160 // after wp_head — and a layout rule that arrives in the footer
161 // fixes the gap only after the visitor has already seen it.
162 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_facade_style' ) );
163 }
164 }
165
166 /**
167 * Register the facade's layout rule as an inline style on a
168 * dependency-free handle — ~150 bytes, so a separate file would cost
169 * more than the CSS.
170 */
171 public function enqueue_facade_style(): void {
172 wp_register_style( 'xspeed-video-facade', false, array(), XSPEED_VERSION );
173 wp_enqueue_style( 'xspeed-video-facade' );
174 wp_add_inline_style( 'xspeed-video-facade', \XSpeed\Video_Facade::facade_style() );
175 }
176
177 /**
178 * Emit the click-to-play handler inline. Inline (not enqueued) because
179 * it is ~400 bytes — a separate request would cost more than the code.
180 */
181 public function print_facade_script(): void {
182 if ( ! Lazy_Loader::facade_used() ) {
183 return;
184 }
185
186 wp_print_inline_script_tag( \XSpeed\Video_Facade::facade_script(), array( 'id' => 'xspeed-video-facade' ) );
187 }
188
189 public function cli_commands(): array {
190 return array(
191 array(
192 'name' => 'xspeed lazy',
193 'callback' => array( $this, 'cli_handler' ),
194 'shortdesc' => 'Show which lazy-load toggles are active.',
195 'synopsis' => array(),
196 ),
197 );
198 }
199
200 public function cli_handler( array $args, array $assoc ): void {
201 $opts = $this->get_settings();
202 foreach ( $opts as $key => $value ) {
203 $display = is_array( $value ) ? implode( ',', $value ) : ( $value ? 'on' : ( is_numeric( $value ) ? (string) $value : 'off' ) );
204 if ( is_int( $value ) ) {
205 $display = (string) $value;
206 }
207 \WP_CLI::log( sprintf( '%-30s %s', $key, $display ) );
208 }
209 }
210 }
211