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

234 lines 9.8 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 // Builder editing screens are front-end URLs; injecting hints into
125 // the editor document helps nobody and can preload the wrong
126 // assets. (#281)
127 || \XSpeed\Builder_Editor::is_active()
128 ) {
129 return;
130 }
131
132 $opts = $this->get_settings();
133 if ( empty( $opts['enabled'] ) ) {
134 return;
135 }
136
137 $any = ! empty( $opts['lcp_preload'] ) || ! empty( $opts['preconnect'] ) || ! empty( $opts['preconnect_hosts'] );
138 if ( ! $any ) {
139 return;
140 }
141
142 // Cache-write path: transform the HTML just before it is minified and
143 // written to the cache file, so hints are baked in and survive HITs.
144 add_filter(
145 'xspeed_cache_final_html',
146 function ( $html ) {
147 return Resource_Hints_Processor::process( (string) $html, $this->processor_opts() );
148 },
149 10,
150 1
151 );
152
153 // Cache-off path: the cache filter above never fires, so buffer the
154 // page ourselves. Guarded so we don't double-buffer when the cache
155 // engine is also running (its filter handles that case).
156 if ( ! $this->cache_enabled() ) {
157 add_action(
158 'template_redirect',
159 function () {
160 if ( self::$buffering ) {
161 return;
162 }
163 self::$buffering = true;
164 ob_start(
165 function ( $buffer ) {
166 if ( strlen( (string) $buffer ) < 255 ) {
167 return $buffer;
168 }
169 return Resource_Hints_Processor::process( (string) $buffer, $this->processor_opts() );
170 }
171 );
172 },
173 9
174 );
175 }
176 }
177
178 /**
179 * Is the page cache turned on? When it is, Cache::finalize_buffer runs and
180 * our xspeed_cache_final_html filter fires — so we must NOT also ob_start.
181 */
182 private function cache_enabled(): bool {
183 $legacy = Settings_Manager::get( 'legacy' );
184 if ( is_array( $legacy ) && ! empty( $legacy['cache_enabled'] ) ) {
185 return true;
186 }
187 $opts = get_option( 'xspeed_options' );
188 return is_array( $opts ) && ! empty( $opts['cache_enabled'] );
189 }
190
191 /**
192 * Settings passed to the full-page processor: this module's own settings,
193 * plus the Lazy module's `excluded_images` list. The processor runs over the
194 * WHOLE page (via xspeed_cache_final_html), so it can reach a theme/builder
195 * hero rendered OUTSIDE the_content — which the Lazy module's the_content-
196 * scoped filters can't. Surfacing the exclusions here lets the processor
197 * strip core's loading="lazy" + set fetchpriority=high on those heroes so an
198 * excluded above-the-fold image actually loads eagerly no matter where the
199 * theme printed it. (FBS-83553 H2)
200 */
201 private function processor_opts(): array {
202 $opts = $this->get_settings();
203 if ( class_exists( '\XSpeed\Settings_Manager' ) ) {
204 $lazy = Settings_Manager::get( 'lazy' );
205 if ( is_array( $lazy ) && ! empty( $lazy['lazy_images'] ) && ! empty( $lazy['excluded_images'] ) && is_array( $lazy['excluded_images'] ) ) {
206 $opts['eager_excluded_images'] = array_values( array_filter( array_map( 'strval', $lazy['excluded_images'] ) ) );
207 }
208 }
209 return $opts;
210 }
211
212 public function cli_commands(): array {
213 return array(
214 array(
215 'name' => 'xspeed resource-hints',
216 'callback' => array( $this, 'cli_handler' ),
217 'shortdesc' => 'Show LCP-preload / preconnect resource-hint settings.',
218 '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.',
219 'synopsis' => array(),
220 ),
221 );
222 }
223
224 public function cli_handler( array $args, array $assoc ): void {
225 $opts = $this->get_settings();
226 \WP_CLI::log( sprintf( '%-20s %s', 'enabled', ! empty( $opts['enabled'] ) ? 'on' : 'off' ) );
227 \WP_CLI::log( sprintf( '%-20s %s', 'lcp_preload', ! empty( $opts['lcp_preload'] ) ? 'on' : 'off' ) );
228 \WP_CLI::log( sprintf( '%-20s %d', 'lcp_image_count', (int) ( $opts['lcp_image_count'] ?? 1 ) ) );
229 \WP_CLI::log( sprintf( '%-20s %d pattern(s)', 'lcp_exclusions', count( (array) ( $opts['lcp_exclusions'] ?? array() ) ) ) );
230 \WP_CLI::log( sprintf( '%-20s %s', 'preconnect', ! empty( $opts['preconnect'] ) ? 'on' : 'off' ) );
231 \WP_CLI::log( sprintf( '%-20s %d host(s)', 'preconnect_hosts', count( (array) ( $opts['preconnect_hosts'] ?? array() ) ) ) );
232 }
233 }
234