PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
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 1.1.3 1.1.4 All 33 releases
xspeed / includes / modules / Fonts / FontsModule.php

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

240 lines 8.3 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' => __( 'Google Fonts text 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
100 if ( ! empty( $opts['preload_fonts'] ) ) {
101 add_action(
102 'wp_head',
103 function () {
104 echo self::render_preload_links( (array) $this->get_setting( 'preload_fonts', array() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
105 },
106 1
107 );
108 }
109 }
110
111 /**
112 * Rewrite a single <link> tag emitted by WP for a Google Fonts
113 * stylesheet so it carries display=swap. No-op for non-Google
114 * hrefs and for URLs that already declare a display value
115 * (auto / block / swap / fallback / optional).
116 *
117 * Public + static so the test suite can drive it without booting
118 * the module or hitting WordPress hook internals.
119 */
120 public static function inject_display_swap( string $tag, string $handle = '' ): string {
121 unset( $handle ); // signature contract — not used.
122
123 if ( false === stripos( $tag, 'fonts.googleapis.com' ) ) {
124 return $tag;
125 }
126
127 if ( ! preg_match( '/href=([\'"])([^\'"]+)\1/i', $tag, $m ) ) {
128 return $tag;
129 }
130
131 $href = $m[2];
132
133 // A display param the theme set is respected ONLY when it is one of
134 // the non-blocking choices (swap / fallback / optional) — someone
135 // picked those deliberately and each is a defensible trade. `auto`
136 // and `block` are the values this setting exists to remove: `auto`
137 // IS block behavior in every engine, and it is almost never a
138 // choice — it is the default a theme's enqueue happened to emit.
139 // "Respecting" it turned the switch into a no-op on exactly the
140 // sites that need it: a live text-LCP measured a 5.5s render delay
141 // behind flatsome's `display=auto` Poppins URL while this option
142 // was on and its label promised the opposite. WP Rocket and
143 // LiteSpeed rewrite these too. The href here has been through
144 // esc_url(), which encodes "&" as "&#038;" — decode before
145 // matching, rewrite on the ORIGINAL encoded href so str_replace
146 // finds it in the tag verbatim. (FBS-82161)
147 $href_decoded = html_entity_decode( $href, ENT_QUOTES | ENT_HTML5 );
148 if ( preg_match( '/([?&])display=(auto|block)(&|$)/i', $href_decoded ) ) {
149 $new_href = preg_replace( '/((?:[?&]|&#0*38;|&#[xX]0*26;|&amp;)display=)(?:auto|block)(?=&|$)/i', '$1swap', $href );
150
151 return str_replace( $href, $new_href, $tag );
152 }
153 if ( preg_match( '/[?&]display=/i', $href_decoded ) ) {
154 return $tag;
155 }
156
157 // Pick the separator from the DECODED url (so a "?" hidden behind an
158 // entity is still recognised), but append to the ORIGINAL (encoded)
159 // href so the str_replace below matches the tag verbatim.
160 $separator = ( false === strpos( $href_decoded, '?' ) ) ? '?' : '&';
161 $new_href = $href . $separator . 'display=swap';
162
163 return str_replace( $href, $new_href, $tag );
164 }
165
166 /**
167 * Render the preload <link> markup for a list of font URLs.
168 *
169 * Pulled out as a static so tests can assert the markup directly
170 * without buffering wp_head output.
171 */
172 public static function render_preload_links( array $urls ): string {
173 $out = '';
174 foreach ( $urls as $url ) {
175 $url = is_string( $url ) ? trim( $url ) : '';
176 if ( '' === $url ) {
177 continue;
178 }
179
180 $type = self::guess_font_mime( $url );
181
182 $out .= sprintf(
183 '<link rel="preload" as="font" type="%s" href="%s" crossorigin>' . "\n",
184 esc_attr( $type ),
185 esc_url( $url )
186 );
187 }
188 return $out;
189 }
190
191 /**
192 * Map a font URL extension to its MIME. Defaults to woff2 because
193 * that's the dominant modern format; an unknown extension is
194 * almost always a fingerprinted woff2 in practice.
195 */
196 public static function guess_font_mime( string $url ): string {
197 $path = strtolower( wp_parse_url( $url, PHP_URL_PATH ) ?? '' );
198 if ( '' === $path ) {
199 $path = strtolower( $url );
200 }
201 // Plugin floor is PHP 7.4 — str_ends_with() is 8.0+. Use a
202 // substr() compare instead so the matrix's 7.4 leg passes.
203 $ends_with = static function ( string $haystack, string $needle ): bool {
204 $len = strlen( $needle );
205 return 0 !== $len && substr( $haystack, -$len ) === $needle;
206 };
207 if ( $ends_with( $path, '.woff2' ) ) {
208 return 'font/woff2';
209 }
210 if ( $ends_with( $path, '.woff' ) ) {
211 return 'font/woff';
212 }
213 if ( $ends_with( $path, '.ttf' ) ) {
214 return 'font/ttf';
215 }
216 if ( $ends_with( $path, '.otf' ) ) {
217 return 'font/otf';
218 }
219 return 'font/woff2';
220 }
221
222 public function cli_commands(): array {
223 return array(
224 array(
225 'name' => 'xspeed fonts',
226 'callback' => array( $this, 'cli_handler' ),
227 'shortdesc' => 'Show font-optimization settings.',
228 '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.',
229 'synopsis' => array(),
230 ),
231 );
232 }
233
234 public function cli_handler( array $args, array $assoc ): void {
235 $opts = $this->get_settings();
236 \WP_CLI::log( sprintf( '%-22s %s', 'font_display_swap', ! empty( $opts['font_display_swap'] ) ? 'on' : 'off' ) );
237 \WP_CLI::log( sprintf( '%-22s %d url(s)', 'preload_fonts', count( (array) ( $opts['preload_fonts'] ?? array() ) ) ) );
238 }
239 }
240