PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 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 All 33 releases
xspeed / includes / modules / ResourceHints / ResourceHintsModule.php

ResourceHintsModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.7, at includes/modules/ResourceHints/ResourceHintsModule.php

270 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Resource Hints module — LCP-image preload + preconnect.
4 *
5 * NB: distinct from the Preloader module (crawler / cache warming). This one
6 * rewrites a page's HTML with browser resource hints (preload, preconnect);
7 * the Preloader warms the server-side page cache. Different layer, different
8 * metric — this moves LCP/FCP, the crawler moves TTFB. See docs/notes.
9 *
10 * The single biggest lever on Largest Contentful Paint is telling the
11 * browser to fetch the hero image immediately, in the <head>, instead of
12 * waiting for CSS + layout to discover it (and past any loading="lazy" the
13 * theme set). WP Rocket's LCP edge in the GTmetrix comparison came entirely
14 * from this. Page-builder heroes (Elementor etc.) are rendered outside
15 * the_content, so the Lazy module's eager-first-N never sees them — this
16 * module works on the full page buffer instead.
17 *
18 * Two Free behaviors (FEATURES.md rows 115 basic preload, 125/356 preconnect):
19 * - LCP image preload: <link rel="preload" as="image" fetchpriority="high">
20 * for the first above-the-fold <img>, plus fetchpriority="high" on the tag.
21 * - Preconnect: <link rel="preconnect"> for detected font hosts + a user list.
22 *
23 * Runs on the cache-write path via the xspeed_cache_final_html filter so the
24 * hints are baked into the cached HTML and replayed on every HIT (a wp_head
25 * hook would never fire on a HIT — the drop-in short-circuits before PHP).
26 * When page caching is OFF it buffers the page itself at template_redirect.
27 *
28 * LCP detection sees through JS-lazy heroes (real URL in data-src) and skips
29 * logos/icons via a size + marker gate, so the preload targets the actual
30 * hero rather than the first plain <img> in the DOM. Format-negotiating layers
31 * (e.g. Pro's Images module, which wraps the LCP <img> in a <picture> with a
32 * WebP/AVIF <source>) coordinate through the `xspeed_lcp_preload_url` /
33 * `xspeed_lcp_preload_srcset` / `xspeed_lcp_preload_type` filters so the
34 * high-priority preload points at the format actually served. (FBS-83553)
35 *
36 * @package XSpeed
37 */
38
39 declare(strict_types=1);
40
41 namespace XSpeed\Modules\ResourceHints;
42
43 defined( 'ABSPATH' ) || exit;
44
45 use XSpeed\Module;
46 use XSpeed\Resource_Hints_Processor;
47 use XSpeed\Settings_Manager;
48
49 final class ResourceHintsModule extends Module {
50
51 public const SLUG = 'resource-hints';
52 public const TIER = self::TIER_FREE;
53 public const VERSION = '1.0.0';
54
55 /** Guards the cache-off buffer so it processes exactly once per request. */
56 private static $buffering = false;
57
58 public function ui_metadata(): array {
59 return array(
60 'label' => __( 'Resource Hints', 'xspeed' ),
61 'tab_label' => __( 'Hints', 'xspeed' ), // its own tab on the Resource Hints page
62 'icon' => 'Zap',
63 'description' => __( 'Tells the browser to fetch the main image and fonts early.', 'xspeed' ),
64 'group' => 'performance',
65 // Host page: Hints (this module) + a Speculation Rules section
66 // (SmartPredict, Pro). SmartPredict prefetches the next page in
67 // the visitor's browser — same family as preload/preconnect, so
68 // it belongs here, not under AI (FBS-83633).
69 'custom_panel' => 'ResourceHintsPanel',
70 );
71 }
72
73 public function settings_schema(): array {
74 return array(
75 'enabled' => array(
76 'type' => 'bool',
77 'default' => true,
78 'label' => __( 'Enable resource hints', 'xspeed' ),
79 'description' => __( 'Turns on all the hints below. They are safe on every theme, so this is on by default.', 'xspeed' ),
80 ),
81 'lcp_preload' => array(
82 'type' => 'bool',
83 'default' => true,
84 'label' => __( 'Preload the main image', 'xspeed' ),
85 'description' => __( 'Finds the largest image at the top of the page and tells the browser to fetch it first. This usually gives the biggest LCP gain.', 'xspeed' ),
86 'dependsOn' => array( 'field' => 'enabled' ),
87 ),
88 'lcp_image_count' => array(
89 'type' => 'int',
90 'default' => 1,
91 'min' => 0,
92 'max' => 3,
93 'label' => __( 'Images to preload', 'xspeed' ),
94 'description' => __( 'How many images at the top of the page to preload. 1 suits most sites; raise it only if the top shows a small gallery.', 'xspeed' ),
95 'advanced' => true,
96 'dependsOn' => array( 'field' => 'lcp_preload' ),
97 ),
98 'lcp_exclusions' => array(
99 'type' => 'list',
100 'default' => array(),
101 'item_type' => 'string',
102 'label' => __( 'Excluded from preload', 'xspeed' ),
103 'description' => __( 'Images whose tag contains a line here, such as a file name or class, are never picked as the main image. Useful for tracking pixels and spacers.', 'xspeed' ),
104 'dependsOn' => array( 'field' => 'lcp_preload' ),
105 ),
106 'preload_images' => array(
107 'type' => 'list',
108 'default' => array(),
109 'item_type' => 'string',
110 'label' => __( 'Always preload these images', 'xspeed' ),
111 'description' => __( 'Image URLs to fetch first on every page, such as a hero background set in CSS. Keep it to one or two; only the first three are used.', 'xspeed' ),
112 'dependsOn' => array( 'field' => 'enabled' ),
113 ),
114 'preconnect' => array(
115 'type' => 'bool',
116 'default' => true,
117 'label' => __( 'Connect early to Google Fonts', 'xspeed' ),
118 'description' => __( 'When the page uses Google Fonts, the browser connects to Google\'s servers early, so fonts arrive sooner.', 'xspeed' ),
119 'dependsOn' => array( 'field' => 'enabled' ),
120 ),
121 'preconnect_hosts' => array(
122 'type' => 'list',
123 'default' => array(),
124 'item_type' => 'url',
125 'label' => __( 'Other domains to connect early', 'xspeed' ),
126 'description' => __( 'One address per line, such as https://cdn.example.com. Use for a CDN or other domain that serves files at the top of the page.', 'xspeed' ),
127 'advanced' => true,
128 'dependsOn' => array( 'field' => 'enabled' ),
129 ),
130 );
131 }
132
133 public function boot(): void {
134 /*
135 * Deferred to `init`. This module reads its own settings to decide
136 * what to hook, and reading settings builds settings_schema(), whose
137 * labels are declared through __(). boot() runs on `plugins_loaded`,
138 * before `after_setup_theme` — the point WordPress 6.7+ treats as the
139 * earliest safe moment to translate — so doing that here fires
140 * _load_textdomain_just_in_time on every request AND resolves the
141 * labels against a domain that is not loaded yet.
142 *
143 * Everything below hooks actions that fire after `init`, so running
144 * one hook later is equivalent.
145 */
146 add_action( 'init', array( $this, 'boot_on_init' ) );
147 }
148
149 /**
150 * The real boot body — see boot() for why it runs on `init`.
151 */
152 public function boot_on_init(): void {
153 // Frontend page renders only. Admin / feed / cron / AJAX / REST never
154 // produce an HTML document we should rewrite.
155 if ( is_admin()
156 || ( defined( 'DOING_AJAX' ) && DOING_AJAX )
157 || ( defined( 'DOING_CRON' ) && DOING_CRON )
158 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
159 // Builder editing screens are front-end URLs; injecting hints into
160 // the editor document helps nobody and can preload the wrong
161 // assets. (#281)
162 || \XSpeed\Builder_Editor::is_active()
163 ) {
164 return;
165 }
166
167 $opts = $this->get_settings();
168 if ( empty( $opts['enabled'] ) ) {
169 return;
170 }
171
172 $any = ! empty( $opts['lcp_preload'] ) || ! empty( $opts['preconnect'] ) || ! empty( $opts['preconnect_hosts'] );
173 if ( ! $any ) {
174 return;
175 }
176
177 // Cache-write path: transform the HTML just before it is minified and
178 // written to the cache file, so hints are baked in and survive HITs.
179 add_filter(
180 'xspeed_cache_final_html',
181 function ( $html ) {
182 return Resource_Hints_Processor::process( (string) $html, $this->processor_opts() );
183 },
184 10,
185 1
186 );
187
188 // Cache-off path: the cache filter above never fires, so buffer the
189 // page ourselves. Guarded so we don't double-buffer when the cache
190 // engine is also running (its filter handles that case).
191 if ( ! $this->cache_enabled() ) {
192 add_action(
193 'template_redirect',
194 function () {
195 if ( self::$buffering ) {
196 return;
197 }
198 self::$buffering = true;
199 ob_start(
200 function ( $buffer ) {
201 if ( strlen( (string) $buffer ) < 255 ) {
202 return $buffer;
203 }
204 return Resource_Hints_Processor::process( (string) $buffer, $this->processor_opts() );
205 }
206 );
207 },
208 9
209 );
210 }
211 }
212
213 /**
214 * Is the page cache turned on? When it is, Cache::finalize_buffer runs and
215 * our xspeed_cache_final_html filter fires — so we must NOT also ob_start.
216 */
217 private function cache_enabled(): bool {
218 $legacy = Settings_Manager::get( 'legacy' );
219 if ( is_array( $legacy ) && ! empty( $legacy['cache_enabled'] ) ) {
220 return true;
221 }
222 $opts = get_option( 'xspeed_options' );
223 return is_array( $opts ) && ! empty( $opts['cache_enabled'] );
224 }
225
226 /**
227 * Settings passed to the full-page processor: this module's own settings,
228 * plus the Lazy module's `excluded_images` list. The processor runs over the
229 * WHOLE page (via xspeed_cache_final_html), so it can reach a theme/builder
230 * hero rendered OUTSIDE the_content — which the Lazy module's the_content-
231 * scoped filters can't. Surfacing the exclusions here lets the processor
232 * strip core's loading="lazy" + set fetchpriority=high on those heroes so an
233 * excluded above-the-fold image actually loads eagerly no matter where the
234 * theme printed it. (FBS-83553 H2)
235 */
236 private function processor_opts(): array {
237 $opts = $this->get_settings();
238 if ( class_exists( '\XSpeed\Settings_Manager' ) ) {
239 $lazy = Settings_Manager::get( 'lazy' );
240 if ( is_array( $lazy ) && ! empty( $lazy['lazy_images'] ) && ! empty( $lazy['excluded_images'] ) && is_array( $lazy['excluded_images'] ) ) {
241 $opts['eager_excluded_images'] = array_values( array_filter( array_map( 'strval', $lazy['excluded_images'] ) ) );
242 }
243 }
244 return $opts;
245 }
246
247 public function cli_commands(): array {
248 return array(
249 array(
250 'name' => 'xspeed resource-hints',
251 'callback' => array( $this, 'cli_handler' ),
252 'shortdesc' => 'Show LCP-preload / preconnect resource-hint settings.',
253 'ai_hint' => 'Browser resource hints — preload, prefetch, preconnect — for the resources that block first paint. Use for LCP problems or "preconnect to required origins" in PageSpeed. Not the same as the Preloader, which warms the page cache.',
254 'synopsis' => array(),
255 ),
256 );
257 }
258
259 public function cli_handler( array $args, array $assoc ): void {
260 $opts = $this->get_settings();
261 \WP_CLI::log( sprintf( '%-20s %s', 'enabled', ! empty( $opts['enabled'] ) ? 'on' : 'off' ) );
262 \WP_CLI::log( sprintf( '%-20s %s', 'lcp_preload', ! empty( $opts['lcp_preload'] ) ? 'on' : 'off' ) );
263 \WP_CLI::log( sprintf( '%-20s %d', 'lcp_image_count', (int) ( $opts['lcp_image_count'] ?? 1 ) ) );
264 \WP_CLI::log( sprintf( '%-20s %d pattern(s)', 'lcp_exclusions', count( (array) ( $opts['lcp_exclusions'] ?? array() ) ) ) );
265 \WP_CLI::log( sprintf( '%-20s %d url(s)', 'preload_images', count( (array) ( $opts['preload_images'] ?? array() ) ) ) );
266 \WP_CLI::log( sprintf( '%-20s %s', 'preconnect', ! empty( $opts['preconnect'] ) ? 'on' : 'off' ) );
267 \WP_CLI::log( sprintf( '%-20s %d host(s)', 'preconnect_hosts', count( (array) ( $opts['preconnect_hosts'] ?? array() ) ) ) );
268 }
269 }
270