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 / Fonts / FontsModule.php

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

210 lines 7.0 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',
36 'icon' => 'Type',
37 'description' => 'Stop web fonts from blocking text. Adds display=swap to Google Fonts and preloads the fonts you mark critical.',
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',
47 'description' => 'Append display=swap to Google Fonts URLs so text renders immediately in a fallback face while the web font loads. No effect on URLs that already declare a display value.',
48 ),
49 'preload_fonts' => array(
50 'type' => 'list',
51 'default' => array(),
52 'item_type' => 'url',
53 'label' => 'Preload Font URLs',
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.',
55 ),
56 );
57 }
58
59 public function boot(): void {
60 // Frontend-only rewriting. Admin / cron / AJAX / REST never
61 // render <link rel="stylesheet"> tags we should touch.
62 if ( is_admin()
63 || ( defined( 'DOING_AJAX' ) && DOING_AJAX )
64 || ( defined( 'DOING_CRON' ) && DOING_CRON )
65 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
66 // A builder editing screen is a front-end URL none of the above
67 // catch; swapping font-display under it changes what the editor
68 // measures. (#281)
69 || \XSpeed\Builder_Editor::is_active()
70 ) {
71 return;
72 }
73
74 $opts = $this->get_settings();
75
76 if ( ! empty( $opts['font_display_swap'] ) ) {
77 add_filter( 'style_loader_tag', array( __CLASS__, 'inject_display_swap' ), 10, 2 );
78 }
79
80 if ( ! empty( $opts['preload_fonts'] ) ) {
81 add_action(
82 'wp_head',
83 function () {
84 echo self::render_preload_links( (array) $this->get_setting( 'preload_fonts', array() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
85 },
86 1
87 );
88 }
89 }
90
91 /**
92 * Rewrite a single <link> tag emitted by WP for a Google Fonts
93 * stylesheet so it carries display=swap. No-op for non-Google
94 * hrefs and for URLs that already declare a display value
95 * (auto / block / swap / fallback / optional).
96 *
97 * Public + static so the test suite can drive it without booting
98 * the module or hitting WordPress hook internals.
99 */
100 public static function inject_display_swap( string $tag, string $handle = '' ): string {
101 unset( $handle ); // signature contract — not used.
102
103 if ( false === stripos( $tag, 'fonts.googleapis.com' ) ) {
104 return $tag;
105 }
106
107 if ( ! preg_match( '/href=([\'"])([^\'"]+)\1/i', $tag, $m ) ) {
108 return $tag;
109 }
110
111 $href = $m[2];
112
113 // Already has a display param — leave it alone (respect the theme /
114 // plugin that set it). The href here has been through esc_url(),
115 // which encodes "&" as the entity "&#038;", so a real URL like
116 // ...?family=Roboto&display=optional arrives as
117 // ...?family=Roboto&#038;display=optional — the char before
118 // "display=" is then ";" (tail of the entity), not "&", and the old
119 // [?&]display= guard missed it, double-appending a second display.
120 // Decode entities before the check so it matches either form.
121 // (FBS-82161)
122 $href_decoded = html_entity_decode( $href, ENT_QUOTES | ENT_HTML5 );
123 if ( preg_match( '/[?&]display=/i', $href_decoded ) ) {
124 return $tag;
125 }
126
127 // Pick the separator from the DECODED url (so a "?" hidden behind an
128 // entity is still recognised), but append to the ORIGINAL (encoded)
129 // href so the str_replace below matches the tag verbatim.
130 $separator = ( false === strpos( $href_decoded, '?' ) ) ? '?' : '&';
131 $new_href = $href . $separator . 'display=swap';
132
133 return str_replace( $href, $new_href, $tag );
134 }
135
136 /**
137 * Render the preload <link> markup for a list of font URLs.
138 *
139 * Pulled out as a static so tests can assert the markup directly
140 * without buffering wp_head output.
141 */
142 public static function render_preload_links( array $urls ): string {
143 $out = '';
144 foreach ( $urls as $url ) {
145 $url = is_string( $url ) ? trim( $url ) : '';
146 if ( '' === $url ) {
147 continue;
148 }
149
150 $type = self::guess_font_mime( $url );
151
152 $out .= sprintf(
153 '<link rel="preload" as="font" type="%s" href="%s" crossorigin>' . "\n",
154 esc_attr( $type ),
155 esc_url( $url )
156 );
157 }
158 return $out;
159 }
160
161 /**
162 * Map a font URL extension to its MIME. Defaults to woff2 because
163 * that's the dominant modern format; an unknown extension is
164 * almost always a fingerprinted woff2 in practice.
165 */
166 public static function guess_font_mime( string $url ): string {
167 $path = strtolower( wp_parse_url( $url, PHP_URL_PATH ) ?? '' );
168 if ( '' === $path ) {
169 $path = strtolower( $url );
170 }
171 // Plugin floor is PHP 7.4 — str_ends_with() is 8.0+. Use a
172 // substr() compare instead so the matrix's 7.4 leg passes.
173 $ends_with = static function ( string $haystack, string $needle ): bool {
174 $len = strlen( $needle );
175 return 0 !== $len && substr( $haystack, -$len ) === $needle;
176 };
177 if ( $ends_with( $path, '.woff2' ) ) {
178 return 'font/woff2';
179 }
180 if ( $ends_with( $path, '.woff' ) ) {
181 return 'font/woff';
182 }
183 if ( $ends_with( $path, '.ttf' ) ) {
184 return 'font/ttf';
185 }
186 if ( $ends_with( $path, '.otf' ) ) {
187 return 'font/otf';
188 }
189 return 'font/woff2';
190 }
191
192 public function cli_commands(): array {
193 return array(
194 array(
195 'name' => 'xspeed fonts',
196 'callback' => array( $this, 'cli_handler' ),
197 'shortdesc' => 'Show font-optimization settings.',
198 '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.',
199 'synopsis' => array(),
200 ),
201 );
202 }
203
204 public function cli_handler( array $args, array $assoc ): void {
205 $opts = $this->get_settings();
206 \WP_CLI::log( sprintf( '%-22s %s', 'font_display_swap', ! empty( $opts['font_display_swap'] ) ? 'on' : 'off' ) );
207 \WP_CLI::log( sprintf( '%-22s %d url(s)', 'preload_fonts', count( (array) ( $opts['preload_fonts'] ?? array() ) ) ) );
208 }
209 }
210