PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.0 All 28 releases
xspeed / includes / modules / ResourceHints / ResourceHintsModule.php

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

261 lines 11.5 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' => __( 'Preload the LCP hero image and preconnect to font hosts so the largest element paints sooner.', 'xspeed' ),
64 // Host page: Hints (this module) + a Speculation Rules section
65 // (SmartPredict, Pro). SmartPredict prefetches the next page in
66 // the visitor's browser — same family as preload/preconnect, so
67 // it belongs here, not under AI (FBS-83633).
68 'custom_panel' => 'ResourceHintsPanel',
69 );
70 }
71
72 public function settings_schema(): array {
73 return array(
74 'enabled' => array(
75 'type' => 'bool',
76 'default' => true,
77 'label' => __( 'Enable Resource Hints', 'xspeed' ),
78 'description' => __( 'Master switch for the LCP-image preload + preconnect resource hints. On by default — these are safe, no-config optimizations that help every theme.', 'xspeed' ),
79 ),
80 'lcp_preload' => array(
81 'type' => 'bool',
82 'default' => true,
83 'label' => __( 'Preload LCP Image', 'xspeed' ),
84 'description' => __( 'Detect the largest above-the-fold image and emit a <link rel="preload" as="image" fetchpriority="high"> in the head, plus fetchpriority="high" on the image. This is the highest-impact fix for Largest Contentful Paint — it beats any lazy-load the theme applied.', 'xspeed' ),
85 ),
86 'lcp_image_count' => array(
87 'type' => 'int',
88 'default' => 1,
89 'min' => 0,
90 'max' => 3,
91 'label' => __( 'Images to Preload', 'xspeed' ),
92 'description' => __( 'How many of the first images on the page to preload. 1 is right for most sites (the single hero). Raise it only if the fold shows a small gallery.', 'xspeed' ),
93 ),
94 'lcp_exclusions' => array(
95 'type' => 'list',
96 'default' => array(),
97 'item_type' => 'string',
98 'label' => __( 'Exclude From Preload', 'xspeed' ),
99 'description' => __( 'Substring patterns (filename or class) that, if found in an <img>, exempt it from being treated as the LCP image. Useful for tracking pixels, spacers, or a decorative first image that is not the hero.', 'xspeed' ),
100 ),
101 'preload_images' => array(
102 'type' => 'list',
103 'default' => array(),
104 'item_type' => 'string',
105 'label' => __( 'Always Preload These Images', 'xspeed' ),
106 'description' => __( 'Image URLs (full or site-relative) to preload with high priority on every page. This is the escape hatch for an LCP image the detector cannot see — most often a hero section\'s CSS background-image, which carries none of the signals the automatic pick reads. Keep it to one or two images: preloading many hands them all top network priority and the page itself loses. The first three entries are used.', 'xspeed' ),
107 ),
108 'preconnect' => array(
109 'type' => 'bool',
110 'default' => true,
111 'label' => __( 'Preconnect to Font Hosts', 'xspeed' ),
112 'description' => __( 'When Google Fonts are detected, emit <link rel="preconnect"> to fonts.googleapis.com and fonts.gstatic.com so the DNS + TLS handshake happens ahead of the font request instead of on the critical path.', 'xspeed' ),
113 ),
114 'preconnect_hosts' => array(
115 'type' => 'list',
116 'default' => array(),
117 'item_type' => 'url',
118 'label' => __( 'Extra Preconnect Hosts', 'xspeed' ),
119 'description' => __( 'One origin per line (e.g. https://cdn.example.com) to preconnect in addition to the auto-detected font hosts. Use for a CDN or third-party origin that serves above-the-fold assets.', 'xspeed' ),
120 ),
121 );
122 }
123
124 public function boot(): void {
125 /*
126 * Deferred to `init`. This module reads its own settings to decide
127 * what to hook, and reading settings builds settings_schema(), whose
128 * labels are declared through __(). boot() runs on `plugins_loaded`,
129 * before `after_setup_theme` — the point WordPress 6.7+ treats as the
130 * earliest safe moment to translate — so doing that here fires
131 * _load_textdomain_just_in_time on every request AND resolves the
132 * labels against a domain that is not loaded yet.
133 *
134 * Everything below hooks actions that fire after `init`, so running
135 * one hook later is equivalent.
136 */
137 add_action( 'init', array( $this, 'boot_on_init' ) );
138 }
139
140 /**
141 * The real boot body — see boot() for why it runs on `init`.
142 */
143 public function boot_on_init(): void {
144 // Frontend page renders only. Admin / feed / cron / AJAX / REST never
145 // produce an HTML document we should rewrite.
146 if ( is_admin()
147 || ( defined( 'DOING_AJAX' ) && DOING_AJAX )
148 || ( defined( 'DOING_CRON' ) && DOING_CRON )
149 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
150 // Builder editing screens are front-end URLs; injecting hints into
151 // the editor document helps nobody and can preload the wrong
152 // assets. (#281)
153 || \XSpeed\Builder_Editor::is_active()
154 ) {
155 return;
156 }
157
158 $opts = $this->get_settings();
159 if ( empty( $opts['enabled'] ) ) {
160 return;
161 }
162
163 $any = ! empty( $opts['lcp_preload'] ) || ! empty( $opts['preconnect'] ) || ! empty( $opts['preconnect_hosts'] );
164 if ( ! $any ) {
165 return;
166 }
167
168 // Cache-write path: transform the HTML just before it is minified and
169 // written to the cache file, so hints are baked in and survive HITs.
170 add_filter(
171 'xspeed_cache_final_html',
172 function ( $html ) {
173 return Resource_Hints_Processor::process( (string) $html, $this->processor_opts() );
174 },
175 10,
176 1
177 );
178
179 // Cache-off path: the cache filter above never fires, so buffer the
180 // page ourselves. Guarded so we don't double-buffer when the cache
181 // engine is also running (its filter handles that case).
182 if ( ! $this->cache_enabled() ) {
183 add_action(
184 'template_redirect',
185 function () {
186 if ( self::$buffering ) {
187 return;
188 }
189 self::$buffering = true;
190 ob_start(
191 function ( $buffer ) {
192 if ( strlen( (string) $buffer ) < 255 ) {
193 return $buffer;
194 }
195 return Resource_Hints_Processor::process( (string) $buffer, $this->processor_opts() );
196 }
197 );
198 },
199 9
200 );
201 }
202 }
203
204 /**
205 * Is the page cache turned on? When it is, Cache::finalize_buffer runs and
206 * our xspeed_cache_final_html filter fires — so we must NOT also ob_start.
207 */
208 private function cache_enabled(): bool {
209 $legacy = Settings_Manager::get( 'legacy' );
210 if ( is_array( $legacy ) && ! empty( $legacy['cache_enabled'] ) ) {
211 return true;
212 }
213 $opts = get_option( 'xspeed_options' );
214 return is_array( $opts ) && ! empty( $opts['cache_enabled'] );
215 }
216
217 /**
218 * Settings passed to the full-page processor: this module's own settings,
219 * plus the Lazy module's `excluded_images` list. The processor runs over the
220 * WHOLE page (via xspeed_cache_final_html), so it can reach a theme/builder
221 * hero rendered OUTSIDE the_content — which the Lazy module's the_content-
222 * scoped filters can't. Surfacing the exclusions here lets the processor
223 * strip core's loading="lazy" + set fetchpriority=high on those heroes so an
224 * excluded above-the-fold image actually loads eagerly no matter where the
225 * theme printed it. (FBS-83553 H2)
226 */
227 private function processor_opts(): array {
228 $opts = $this->get_settings();
229 if ( class_exists( '\XSpeed\Settings_Manager' ) ) {
230 $lazy = Settings_Manager::get( 'lazy' );
231 if ( is_array( $lazy ) && ! empty( $lazy['lazy_images'] ) && ! empty( $lazy['excluded_images'] ) && is_array( $lazy['excluded_images'] ) ) {
232 $opts['eager_excluded_images'] = array_values( array_filter( array_map( 'strval', $lazy['excluded_images'] ) ) );
233 }
234 }
235 return $opts;
236 }
237
238 public function cli_commands(): array {
239 return array(
240 array(
241 'name' => 'xspeed resource-hints',
242 'callback' => array( $this, 'cli_handler' ),
243 'shortdesc' => 'Show LCP-preload / preconnect resource-hint settings.',
244 '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.',
245 'synopsis' => array(),
246 ),
247 );
248 }
249
250 public function cli_handler( array $args, array $assoc ): void {
251 $opts = $this->get_settings();
252 \WP_CLI::log( sprintf( '%-20s %s', 'enabled', ! empty( $opts['enabled'] ) ? 'on' : 'off' ) );
253 \WP_CLI::log( sprintf( '%-20s %s', 'lcp_preload', ! empty( $opts['lcp_preload'] ) ? 'on' : 'off' ) );
254 \WP_CLI::log( sprintf( '%-20s %d', 'lcp_image_count', (int) ( $opts['lcp_image_count'] ?? 1 ) ) );
255 \WP_CLI::log( sprintf( '%-20s %d pattern(s)', 'lcp_exclusions', count( (array) ( $opts['lcp_exclusions'] ?? array() ) ) ) );
256 \WP_CLI::log( sprintf( '%-20s %d url(s)', 'preload_images', count( (array) ( $opts['preload_images'] ?? array() ) ) ) );
257 \WP_CLI::log( sprintf( '%-20s %s', 'preconnect', ! empty( $opts['preconnect'] ) ? 'on' : 'off' ) );
258 \WP_CLI::log( sprintf( '%-20s %d host(s)', 'preconnect_hosts', count( (array) ( $opts['preconnect_hosts'] ?? array() ) ) ) );
259 }
260 }
261