PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 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 All 35 releases
xspeed / includes / modules / Fonts / FontsModule.php

FontsModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.1, at includes/modules/Fonts/FontsModule.php

297 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Fonts module — keeps web-font loading from blocking text render.
4 *
5 * Two Free behaviors (FEATURES.md §Font Optimization rows 1 + 4):
6 * - Appends `display=swap` to Google Fonts stylesheet URLs so the
7 * browser paints text immediately in a fallback face while the
8 * web font downloads. Removes the FOIT window.
9 * - Emits <link rel="preload" as="font" crossorigin> for a
10 * site-defined list of font files so the LCP-critical face starts
11 * downloading at parser-discovery time, not after the CSS parses.
12 *
13 * Pro adds self-hosting (OMGF-style download/serve) and subsetting —
14 * those live in xspeed-pro and are surfaced through the manifest.
15 *
16 * @package XSpeed
17 */
18
19 declare(strict_types=1);
20
21 namespace XSpeed\Modules\Fonts;
22
23 defined( 'ABSPATH' ) || exit;
24
25 use XSpeed\Module;
26
27 final class FontsModule extends Module {
28
29 public const SLUG = 'fonts';
30 public const TIER = self::TIER_FREE;
31 public const VERSION = '1.0.0';
32
33 public function ui_metadata(): array {
34 return array(
35 'label' => __( 'Fonts', 'xspeed' ),
36 'icon' => 'Type',
37 'description' => __( 'Shows text right away while web fonts load, and preloads key fonts.', 'xspeed' ),
38 'group' => 'performance',
39 );
40 }
41
42 public function settings_schema(): array {
43 return array(
44 'font_display_swap' => array(
45 'type' => 'bool',
46 'default' => true,
47 'label' => __( 'Show text while fonts load', 'xspeed' ),
48 'description' => __( 'Text in Google Fonts, and in fonts added to WordPress itself, shows at once in a standard font, then switches when the web font arrives.', 'xspeed' ),
49 ),
50 'preload_fonts' => array(
51 'type' => 'list',
52 'default' => array(),
53 'item_type' => 'url',
54 'label' => __( 'Fonts to preload', 'xspeed' ),
55 'description' => __( 'One full font file URL per line (woff2, woff, ttf or otf). The browser fetches these first, so list only fonts used at the top of the page.', 'xspeed' ),
56 ),
57 );
58 }
59
60 public function boot(): void {
61 /*
62 * Deferred to `init`. This module reads its own settings to decide
63 * what to hook, and reading settings builds settings_schema(), whose
64 * labels are declared through __(). boot() runs on `plugins_loaded`,
65 * before `after_setup_theme` — the point WordPress 6.7+ treats as the
66 * earliest safe moment to translate — so doing that here fires
67 * _load_textdomain_just_in_time on every request AND resolves the
68 * labels against a domain that is not loaded yet.
69 *
70 * Everything below hooks actions that fire after `init`, so running
71 * one hook later is equivalent.
72 */
73 add_action( 'init', array( $this, 'boot_on_init' ) );
74 }
75
76 /**
77 * The real boot body — see boot() for why it runs on `init`.
78 */
79 public function boot_on_init(): void {
80 // Frontend-only rewriting. Admin / cron / AJAX / REST never
81 // render <link rel="stylesheet"> tags we should touch.
82 if ( is_admin()
83 || ( defined( 'DOING_AJAX' ) && DOING_AJAX )
84 || ( defined( 'DOING_CRON' ) && DOING_CRON )
85 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
86 // A builder editing screen is a front-end URL none of the above
87 // catch; swapping font-display under it changes what the editor
88 // measures. (#281)
89 || \XSpeed\Builder_Editor::is_active()
90 ) {
91 return;
92 }
93
94 $opts = $this->get_settings();
95
96 if ( ! empty( $opts['font_display_swap'] ) ) {
97 add_filter( 'style_loader_tag', array( __CLASS__, 'inject_display_swap' ), 10, 2 );
98
99 // Fonts added through the Font Library or theme.json are printed
100 // by core with font-display: fallback, and core offers no filter
101 // for it. Fallback hides the text for up to 100ms while the font
102 // loads; when that text is the LCP, the hero paints after the
103 // font instead of at first paint. Measured on a live text hero:
104 // LCP landed ~85ms after FCP with fallback and on FCP with swap,
105 // CLS unchanged, and PageSpeed mobile moved between 81 and 90
106 // depending on which side of that window the font fell. So core's
107 // own printer runs with swap as the default. Only an exact
108 // priority-50 hook is replaced: anything that already moved or
109 // removed it is left alone.
110 if ( function_exists( 'wp_print_font_faces' )
111 && class_exists( '\WP_Font_Face_Resolver' )
112 && 50 === has_action( 'wp_head', 'wp_print_font_faces' )
113 ) {
114 remove_action( 'wp_head', 'wp_print_font_faces', 50 );
115 add_action( 'wp_head', array( __CLASS__, 'print_font_faces_swap' ), 50 );
116 }
117 }
118
119 if ( ! empty( $opts['preload_fonts'] ) ) {
120 add_action(
121 'wp_head',
122 function () {
123 echo self::render_preload_links( (array) $this->get_setting( 'preload_fonts', array() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
124 },
125 1
126 );
127 }
128 }
129
130 /**
131 * Rewrite a single <link> tag emitted by WP for a Google Fonts
132 * stylesheet so it carries display=swap. No-op for non-Google
133 * hrefs and for URLs that already declare a display value
134 * (auto / block / swap / fallback / optional).
135 *
136 * Public + static so the test suite can drive it without booting
137 * the module or hitting WordPress hook internals.
138 */
139 public static function inject_display_swap( string $tag, string $handle = '' ): string {
140 unset( $handle ); // signature contract — not used.
141
142 if ( false === stripos( $tag, 'fonts.googleapis.com' ) ) {
143 return $tag;
144 }
145
146 if ( ! preg_match( '/href=([\'"])([^\'"]+)\1/i', $tag, $m ) ) {
147 return $tag;
148 }
149
150 $href = $m[2];
151
152 // A display param the theme set is respected ONLY when it is one of
153 // the non-blocking choices (swap / fallback / optional) — someone
154 // picked those deliberately and each is a defensible trade. `auto`
155 // and `block` are the values this setting exists to remove: `auto`
156 // IS block behavior in every engine, and it is almost never a
157 // choice — it is the default a theme's enqueue happened to emit.
158 // "Respecting" it turned the switch into a no-op on exactly the
159 // sites that need it: a live text-LCP measured a 5.5s render delay
160 // behind flatsome's `display=auto` Poppins URL while this option
161 // was on and its label promised the opposite. WP Rocket and
162 // LiteSpeed rewrite these too. The href here has been through
163 // esc_url(), which encodes "&" as "&#038;" — decode before
164 // matching, rewrite on the ORIGINAL encoded href so str_replace
165 // finds it in the tag verbatim. (FBS-82161)
166 $href_decoded = html_entity_decode( $href, ENT_QUOTES | ENT_HTML5 );
167 if ( preg_match( '/([?&])display=(auto|block)(&|$)/i', $href_decoded ) ) {
168 $new_href = preg_replace( '/((?:[?&]|&#0*38;|&#[xX]0*26;|&amp;)display=)(?:auto|block)(?=&|$)/i', '$1swap', $href );
169
170 return str_replace( $href, $new_href, $tag );
171 }
172 if ( preg_match( '/[?&]display=/i', $href_decoded ) ) {
173 return $tag;
174 }
175
176 // Pick the separator from the DECODED url (so a "?" hidden behind an
177 // entity is still recognised), but append to the ORIGINAL (encoded)
178 // href so the str_replace below matches the tag verbatim.
179 $separator = ( false === strpos( $href_decoded, '?' ) ) ? '?' : '&';
180 $new_href = $href . $separator . 'display=swap';
181
182 return str_replace( $href, $new_href, $tag );
183 }
184
185 /**
186 * Print core's font faces as wp_print_font_faces() does, with swap as
187 * the font-display default.
188 */
189 public static function print_font_faces_swap(): void {
190 $fonts = \WP_Font_Face_Resolver::get_fonts_from_theme_json();
191 if ( empty( $fonts ) ) {
192 return;
193 }
194 // WordPress 6.4+. Only hooked when the function exists (see
195 // boot_on_init()), called by name so a 6.0 floor stays compatible.
196 call_user_func( 'wp_print_font_faces', self::default_display_swap( $fonts ) );
197 }
198
199 /**
200 * Give every font face without its own font-display a swap one.
201 *
202 * The resolver only sets font-display when a theme.json fontFace
203 * declares fontDisplay, so a face that has one was chosen on purpose
204 * and keeps it. Public + static so the test suite can drive it.
205 *
206 * @param array<int|string,mixed> $fonts Font families, each a list of faces.
207 * @return array<int|string,mixed>
208 */
209 public static function default_display_swap( array $fonts ): array {
210 foreach ( $fonts as $family => $faces ) {
211 if ( ! is_array( $faces ) ) {
212 continue;
213 }
214 foreach ( $faces as $i => $face ) {
215 if ( is_array( $face ) && ! isset( $face['font-display'] ) ) {
216 $fonts[ $family ][ $i ]['font-display'] = 'swap';
217 }
218 }
219 }
220 return $fonts;
221 }
222
223 /**
224 * Render the preload <link> markup for a list of font URLs.
225 *
226 * Pulled out as a static so tests can assert the markup directly
227 * without buffering wp_head output.
228 */
229 public static function render_preload_links( array $urls ): string {
230 $out = '';
231 foreach ( $urls as $url ) {
232 $url = is_string( $url ) ? trim( $url ) : '';
233 if ( '' === $url ) {
234 continue;
235 }
236
237 $type = self::guess_font_mime( $url );
238
239 $out .= sprintf(
240 '<link rel="preload" as="font" type="%s" href="%s" crossorigin>' . "\n",
241 esc_attr( $type ),
242 esc_url( $url )
243 );
244 }
245 return $out;
246 }
247
248 /**
249 * Map a font URL extension to its MIME. Defaults to woff2 because
250 * that's the dominant modern format; an unknown extension is
251 * almost always a fingerprinted woff2 in practice.
252 */
253 public static function guess_font_mime( string $url ): string {
254 $path = strtolower( wp_parse_url( $url, PHP_URL_PATH ) ?? '' );
255 if ( '' === $path ) {
256 $path = strtolower( $url );
257 }
258 // Plugin floor is PHP 7.4 — str_ends_with() is 8.0+. Use a
259 // substr() compare instead so the matrix's 7.4 leg passes.
260 $ends_with = static function ( string $haystack, string $needle ): bool {
261 $len = strlen( $needle );
262 return 0 !== $len && substr( $haystack, -$len ) === $needle;
263 };
264 if ( $ends_with( $path, '.woff2' ) ) {
265 return 'font/woff2';
266 }
267 if ( $ends_with( $path, '.woff' ) ) {
268 return 'font/woff';
269 }
270 if ( $ends_with( $path, '.ttf' ) ) {
271 return 'font/ttf';
272 }
273 if ( $ends_with( $path, '.otf' ) ) {
274 return 'font/otf';
275 }
276 return 'font/woff2';
277 }
278
279 public function cli_commands(): array {
280 return array(
281 array(
282 'name' => 'xspeed fonts',
283 'callback' => array( $this, 'cli_handler' ),
284 'shortdesc' => 'Show font-optimization settings.',
285 'ai_hint' => 'How are web fonts being optimized (font-display swap, preloading, local hosting)? Use for questions about invisible text while loading (FOIT/FOUT) or render-blocking fonts.',
286 'synopsis' => array(),
287 ),
288 );
289 }
290
291 public function cli_handler( array $args, array $assoc ): void {
292 $opts = $this->get_settings();
293 \WP_CLI::log( sprintf( '%-22s %s', 'font_display_swap', ! empty( $opts['font_display_swap'] ) ? 'on' : 'off' ) );
294 \WP_CLI::log( sprintf( '%-22s %d url(s)', 'preload_fonts', count( (array) ( $opts['preload_fonts'] ?? array() ) ) ) );
295 }
296 }
297