PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
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 / ResourceHints / ResourceHintsModule.php

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

229 lines 9.3 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',
61 'tab_label' => 'Hints', // 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.',
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',
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.',
79 ),
80 'lcp_preload' => array(
81 'type' => 'bool',
82 'default' => true,
83 'label' => 'Preload LCP Image',
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.',
85 ),
86 'lcp_image_count' => array(
87 'type' => 'int',
88 'default' => 1,
89 'min' => 0,
90 'max' => 3,
91 'label' => 'Images to Preload',
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.',
93 ),
94 'lcp_exclusions' => array(
95 'type' => 'list',
96 'default' => array(),
97 'item_type' => 'string',
98 'label' => 'Exclude From Preload',
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.',
100 ),
101 'preconnect' => array(
102 'type' => 'bool',
103 'default' => true,
104 'label' => 'Preconnect to Font Hosts',
105 '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.',
106 ),
107 'preconnect_hosts' => array(
108 'type' => 'list',
109 'default' => array(),
110 'item_type' => 'url',
111 'label' => 'Extra Preconnect Hosts',
112 '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.',
113 ),
114 );
115 }
116
117 public function boot(): void {
118 // Frontend page renders only. Admin / feed / cron / AJAX / REST never
119 // produce an HTML document we should rewrite.
120 if ( is_admin()
121 || ( defined( 'DOING_AJAX' ) && DOING_AJAX )
122 || ( defined( 'DOING_CRON' ) && DOING_CRON )
123 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
124 ) {
125 return;
126 }
127
128 $opts = $this->get_settings();
129 if ( empty( $opts['enabled'] ) ) {
130 return;
131 }
132
133 $any = ! empty( $opts['lcp_preload'] ) || ! empty( $opts['preconnect'] ) || ! empty( $opts['preconnect_hosts'] );
134 if ( ! $any ) {
135 return;
136 }
137
138 // Cache-write path: transform the HTML just before it is minified and
139 // written to the cache file, so hints are baked in and survive HITs.
140 add_filter(
141 'xspeed_cache_final_html',
142 function ( $html ) {
143 return Resource_Hints_Processor::process( (string) $html, $this->processor_opts() );
144 },
145 10,
146 1
147 );
148
149 // Cache-off path: the cache filter above never fires, so buffer the
150 // page ourselves. Guarded so we don't double-buffer when the cache
151 // engine is also running (its filter handles that case).
152 if ( ! $this->cache_enabled() ) {
153 add_action(
154 'template_redirect',
155 function () {
156 if ( self::$buffering ) {
157 return;
158 }
159 self::$buffering = true;
160 ob_start(
161 function ( $buffer ) {
162 if ( strlen( (string) $buffer ) < 255 ) {
163 return $buffer;
164 }
165 return Resource_Hints_Processor::process( (string) $buffer, $this->processor_opts() );
166 }
167 );
168 },
169 9
170 );
171 }
172 }
173
174 /**
175 * Is the page cache turned on? When it is, Cache::finalize_buffer runs and
176 * our xspeed_cache_final_html filter fires — so we must NOT also ob_start.
177 */
178 private function cache_enabled(): bool {
179 $legacy = Settings_Manager::get( 'legacy' );
180 if ( is_array( $legacy ) && ! empty( $legacy['cache_enabled'] ) ) {
181 return true;
182 }
183 $opts = get_option( 'xspeed_options' );
184 return is_array( $opts ) && ! empty( $opts['cache_enabled'] );
185 }
186
187 /**
188 * Settings passed to the full-page processor: this module's own settings,
189 * plus the Lazy module's `excluded_images` list. The processor runs over the
190 * WHOLE page (via xspeed_cache_final_html), so it can reach a theme/builder
191 * hero rendered OUTSIDE the_content — which the Lazy module's the_content-
192 * scoped filters can't. Surfacing the exclusions here lets the processor
193 * strip core's loading="lazy" + set fetchpriority=high on those heroes so an
194 * excluded above-the-fold image actually loads eagerly no matter where the
195 * theme printed it. (FBS-83553 H2)
196 */
197 private function processor_opts(): array {
198 $opts = $this->get_settings();
199 if ( class_exists( '\XSpeed\Settings_Manager' ) ) {
200 $lazy = Settings_Manager::get( 'lazy' );
201 if ( is_array( $lazy ) && ! empty( $lazy['lazy_images'] ) && ! empty( $lazy['excluded_images'] ) && is_array( $lazy['excluded_images'] ) ) {
202 $opts['eager_excluded_images'] = array_values( array_filter( array_map( 'strval', $lazy['excluded_images'] ) ) );
203 }
204 }
205 return $opts;
206 }
207
208 public function cli_commands(): array {
209 return array(
210 array(
211 'name' => 'xspeed resource-hints',
212 'callback' => array( $this, 'cli_handler' ),
213 'shortdesc' => 'Show LCP-preload / preconnect resource-hint settings.',
214 'synopsis' => array(),
215 ),
216 );
217 }
218
219 public function cli_handler( array $args, array $assoc ): void {
220 $opts = $this->get_settings();
221 \WP_CLI::log( sprintf( '%-20s %s', 'enabled', ! empty( $opts['enabled'] ) ? 'on' : 'off' ) );
222 \WP_CLI::log( sprintf( '%-20s %s', 'lcp_preload', ! empty( $opts['lcp_preload'] ) ? 'on' : 'off' ) );
223 \WP_CLI::log( sprintf( '%-20s %d', 'lcp_image_count', (int) ( $opts['lcp_image_count'] ?? 1 ) ) );
224 \WP_CLI::log( sprintf( '%-20s %d pattern(s)', 'lcp_exclusions', count( (array) ( $opts['lcp_exclusions'] ?? array() ) ) ) );
225 \WP_CLI::log( sprintf( '%-20s %s', 'preconnect', ! empty( $opts['preconnect'] ) ? 'on' : 'off' ) );
226 \WP_CLI::log( sprintf( '%-20s %d host(s)', 'preconnect_hosts', count( (array) ( $opts['preconnect_hosts'] ?? array() ) ) ) );
227 }
228 }
229