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

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