PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.1
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 / Fonts / FontsModule.php

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

239 lines 8.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' => __( 'Stop web fonts from blocking text. Adds display=swap to Google Fonts and preloads the fonts you mark critical.', 'xspeed' ),
38 );
39 }
40
41 public function settings_schema(): array {
42 return array(
43 'font_display_swap' => array(
44 'type' => 'bool',
45 'default' => true,
46 'label' => __( 'Add font-display: swap', 'xspeed' ),
47 'description' => __( 'Append display=swap to Google Fonts URLs so text renders immediately in a fallback face while the web font loads. Blocking values already on the URL (auto, block) are rewritten to swap; a deliberate non-blocking choice (fallback, optional) is left alone.', 'xspeed' ),
48 ),
49 'preload_fonts' => array(
50 'type' => 'list',
51 'default' => array(),
52 'item_type' => 'url',
53 'label' => __( 'Preload Font URLs', 'xspeed' ),
54 'description' => __( 'One absolute font URL per line (woff2/woff/ttf/otf). Each becomes a <link rel="preload" as="font" crossorigin> in the head so the browser starts downloading before the CSS parses. Use only for fonts that render above the fold.', 'xspeed' ),
55 ),
56 );
57 }
58
59 public function boot(): void {
60 /*
61 * Deferred to `init`. This module reads its own settings to decide
62 * what to hook, and reading settings builds settings_schema(), whose
63 * labels are declared through __(). boot() runs on `plugins_loaded`,
64 * before `after_setup_theme` — the point WordPress 6.7+ treats as the
65 * earliest safe moment to translate — so doing that here fires
66 * _load_textdomain_just_in_time on every request AND resolves the
67 * labels against a domain that is not loaded yet.
68 *
69 * Everything below hooks actions that fire after `init`, so running
70 * one hook later is equivalent.
71 */
72 add_action( 'init', array( $this, 'boot_on_init' ) );
73 }
74
75 /**
76 * The real boot body — see boot() for why it runs on `init`.
77 */
78 public function boot_on_init(): void {
79 // Frontend-only rewriting. Admin / cron / AJAX / REST never
80 // render <link rel="stylesheet"> tags we should touch.
81 if ( is_admin()
82 || ( defined( 'DOING_AJAX' ) && DOING_AJAX )
83 || ( defined( 'DOING_CRON' ) && DOING_CRON )
84 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
85 // A builder editing screen is a front-end URL none of the above
86 // catch; swapping font-display under it changes what the editor
87 // measures. (#281)
88 || \XSpeed\Builder_Editor::is_active()
89 ) {
90 return;
91 }
92
93 $opts = $this->get_settings();
94
95 if ( ! empty( $opts['font_display_swap'] ) ) {
96 add_filter( 'style_loader_tag', array( __CLASS__, 'inject_display_swap' ), 10, 2 );
97 }
98
99 if ( ! empty( $opts['preload_fonts'] ) ) {
100 add_action(
101 'wp_head',
102 function () {
103 echo self::render_preload_links( (array) $this->get_setting( 'preload_fonts', array() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
104 },
105 1
106 );
107 }
108 }
109
110 /**
111 * Rewrite a single <link> tag emitted by WP for a Google Fonts
112 * stylesheet so it carries display=swap. No-op for non-Google
113 * hrefs and for URLs that already declare a display value
114 * (auto / block / swap / fallback / optional).
115 *
116 * Public + static so the test suite can drive it without booting
117 * the module or hitting WordPress hook internals.
118 */
119 public static function inject_display_swap( string $tag, string $handle = '' ): string {
120 unset( $handle ); // signature contract — not used.
121
122 if ( false === stripos( $tag, 'fonts.googleapis.com' ) ) {
123 return $tag;
124 }
125
126 if ( ! preg_match( '/href=([\'"])([^\'"]+)\1/i', $tag, $m ) ) {
127 return $tag;
128 }
129
130 $href = $m[2];
131
132 // A display param the theme set is respected ONLY when it is one of
133 // the non-blocking choices (swap / fallback / optional) — someone
134 // picked those deliberately and each is a defensible trade. `auto`
135 // and `block` are the values this setting exists to remove: `auto`
136 // IS block behavior in every engine, and it is almost never a
137 // choice — it is the default a theme's enqueue happened to emit.
138 // "Respecting" it turned the switch into a no-op on exactly the
139 // sites that need it: a live text-LCP measured a 5.5s render delay
140 // behind flatsome's `display=auto` Poppins URL while this option
141 // was on and its label promised the opposite. WP Rocket and
142 // LiteSpeed rewrite these too. The href here has been through
143 // esc_url(), which encodes "&" as "&#038;" — decode before
144 // matching, rewrite on the ORIGINAL encoded href so str_replace
145 // finds it in the tag verbatim. (FBS-82161)
146 $href_decoded = html_entity_decode( $href, ENT_QUOTES | ENT_HTML5 );
147 if ( preg_match( '/([?&])display=(auto|block)(&|$)/i', $href_decoded ) ) {
148 $new_href = preg_replace( '/((?:[?&]|&#0*38;|&#[xX]0*26;|&amp;)display=)(?:auto|block)(?=&|$)/i', '$1swap', $href );
149
150 return str_replace( $href, $new_href, $tag );
151 }
152 if ( preg_match( '/[?&]display=/i', $href_decoded ) ) {
153 return $tag;
154 }
155
156 // Pick the separator from the DECODED url (so a "?" hidden behind an
157 // entity is still recognised), but append to the ORIGINAL (encoded)
158 // href so the str_replace below matches the tag verbatim.
159 $separator = ( false === strpos( $href_decoded, '?' ) ) ? '?' : '&';
160 $new_href = $href . $separator . 'display=swap';
161
162 return str_replace( $href, $new_href, $tag );
163 }
164
165 /**
166 * Render the preload <link> markup for a list of font URLs.
167 *
168 * Pulled out as a static so tests can assert the markup directly
169 * without buffering wp_head output.
170 */
171 public static function render_preload_links( array $urls ): string {
172 $out = '';
173 foreach ( $urls as $url ) {
174 $url = is_string( $url ) ? trim( $url ) : '';
175 if ( '' === $url ) {
176 continue;
177 }
178
179 $type = self::guess_font_mime( $url );
180
181 $out .= sprintf(
182 '<link rel="preload" as="font" type="%s" href="%s" crossorigin>' . "\n",
183 esc_attr( $type ),
184 esc_url( $url )
185 );
186 }
187 return $out;
188 }
189
190 /**
191 * Map a font URL extension to its MIME. Defaults to woff2 because
192 * that's the dominant modern format; an unknown extension is
193 * almost always a fingerprinted woff2 in practice.
194 */
195 public static function guess_font_mime( string $url ): string {
196 $path = strtolower( wp_parse_url( $url, PHP_URL_PATH ) ?? '' );
197 if ( '' === $path ) {
198 $path = strtolower( $url );
199 }
200 // Plugin floor is PHP 7.4 — str_ends_with() is 8.0+. Use a
201 // substr() compare instead so the matrix's 7.4 leg passes.
202 $ends_with = static function ( string $haystack, string $needle ): bool {
203 $len = strlen( $needle );
204 return 0 !== $len && substr( $haystack, -$len ) === $needle;
205 };
206 if ( $ends_with( $path, '.woff2' ) ) {
207 return 'font/woff2';
208 }
209 if ( $ends_with( $path, '.woff' ) ) {
210 return 'font/woff';
211 }
212 if ( $ends_with( $path, '.ttf' ) ) {
213 return 'font/ttf';
214 }
215 if ( $ends_with( $path, '.otf' ) ) {
216 return 'font/otf';
217 }
218 return 'font/woff2';
219 }
220
221 public function cli_commands(): array {
222 return array(
223 array(
224 'name' => 'xspeed fonts',
225 'callback' => array( $this, 'cli_handler' ),
226 'shortdesc' => 'Show font-optimization settings.',
227 '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.',
228 'synopsis' => array(),
229 ),
230 );
231 }
232
233 public function cli_handler( array $args, array $assoc ): void {
234 $opts = $this->get_settings();
235 \WP_CLI::log( sprintf( '%-22s %s', 'font_display_swap', ! empty( $opts['font_display_swap'] ) ? 'on' : 'off' ) );
236 \WP_CLI::log( sprintf( '%-22s %d url(s)', 'preload_fonts', count( (array) ( $opts['preload_fonts'] ?? array() ) ) ) );
237 }
238 }
239