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

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