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

192 lines 5.9 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 use XSpeed\Module;
24
25 final class FontsModule extends Module {
26
27 public const SLUG = 'fonts';
28 public const TIER = self::TIER_FREE;
29 public const VERSION = '1.0.0';
30
31 public function ui_metadata(): array {
32 return array(
33 'label' => 'Fonts',
34 'icon' => 'Type',
35 'description' => 'Stop web fonts from blocking text. Adds display=swap to Google Fonts and preloads the fonts you mark critical.',
36 );
37 }
38
39 public function settings_schema(): array {
40 return array(
41 'font_display_swap' => array(
42 'type' => 'bool',
43 'default' => true,
44 'label' => 'Add font-display: swap',
45 '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.',
46 ),
47 'preload_fonts' => array(
48 'type' => 'list',
49 'default' => array(),
50 'item_type' => 'url',
51 'label' => 'Preload Font URLs',
52 '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.',
53 ),
54 );
55 }
56
57 public function boot(): void {
58 // Frontend-only rewriting. Admin / cron / AJAX / REST never
59 // render <link rel="stylesheet"> tags we should touch.
60 if ( is_admin()
61 || ( defined( 'DOING_AJAX' ) && DOING_AJAX )
62 || ( defined( 'DOING_CRON' ) && DOING_CRON )
63 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
64 ) {
65 return;
66 }
67
68 $opts = $this->get_settings();
69
70 if ( ! empty( $opts['font_display_swap'] ) ) {
71 add_filter( 'style_loader_tag', array( __CLASS__, 'inject_display_swap' ), 10, 2 );
72 }
73
74 if ( ! empty( $opts['preload_fonts'] ) ) {
75 add_action(
76 'wp_head',
77 function () {
78 echo self::render_preload_links( (array) $this->get_setting( 'preload_fonts', array() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
79 },
80 1
81 );
82 }
83 }
84
85 /**
86 * Rewrite a single <link> tag emitted by WP for a Google Fonts
87 * stylesheet so it carries display=swap. No-op for non-Google
88 * hrefs and for URLs that already declare a display value
89 * (auto / block / swap / fallback / optional).
90 *
91 * Public + static so the test suite can drive it without booting
92 * the module or hitting WordPress hook internals.
93 */
94 public static function inject_display_swap( string $tag, string $handle = '' ): string {
95 unset( $handle ); // signature contract — not used.
96
97 if ( false === stripos( $tag, 'fonts.googleapis.com' ) ) {
98 return $tag;
99 }
100
101 if ( ! preg_match( '/href=([\'"])([^\'"]+)\1/i', $tag, $m ) ) {
102 return $tag;
103 }
104
105 $href = $m[2];
106
107 // Already has a display param — leave it alone (respect the
108 // theme / plugin that set it).
109 if ( preg_match( '/[?&]display=/i', $href ) ) {
110 return $tag;
111 }
112
113 $separator = ( false === strpos( $href, '?' ) ) ? '?' : '&';
114 $new_href = $href . $separator . 'display=swap';
115
116 return str_replace( $href, $new_href, $tag );
117 }
118
119 /**
120 * Render the preload <link> markup for a list of font URLs.
121 *
122 * Pulled out as a static so tests can assert the markup directly
123 * without buffering wp_head output.
124 */
125 public static function render_preload_links( array $urls ): string {
126 $out = '';
127 foreach ( $urls as $url ) {
128 $url = is_string( $url ) ? trim( $url ) : '';
129 if ( '' === $url ) {
130 continue;
131 }
132
133 $type = self::guess_font_mime( $url );
134
135 $out .= sprintf(
136 '<link rel="preload" as="font" type="%s" href="%s" crossorigin>' . "\n",
137 esc_attr( $type ),
138 esc_url( $url )
139 );
140 }
141 return $out;
142 }
143
144 /**
145 * Map a font URL extension to its MIME. Defaults to woff2 because
146 * that's the dominant modern format; an unknown extension is
147 * almost always a fingerprinted woff2 in practice.
148 */
149 public static function guess_font_mime( string $url ): string {
150 $path = strtolower( wp_parse_url( $url, PHP_URL_PATH ) ?? '' );
151 if ( '' === $path ) {
152 $path = strtolower( $url );
153 }
154 // Plugin floor is PHP 7.4 — str_ends_with() is 8.0+. Use a
155 // substr() compare instead so the matrix's 7.4 leg passes.
156 $ends_with = static function ( string $haystack, string $needle ): bool {
157 $len = strlen( $needle );
158 return 0 !== $len && substr( $haystack, -$len ) === $needle;
159 };
160 if ( $ends_with( $path, '.woff2' ) ) {
161 return 'font/woff2';
162 }
163 if ( $ends_with( $path, '.woff' ) ) {
164 return 'font/woff';
165 }
166 if ( $ends_with( $path, '.ttf' ) ) {
167 return 'font/ttf';
168 }
169 if ( $ends_with( $path, '.otf' ) ) {
170 return 'font/otf';
171 }
172 return 'font/woff2';
173 }
174
175 public function cli_commands(): array {
176 return array(
177 array(
178 'name' => 'xspeed fonts',
179 'callback' => array( $this, 'cli_handler' ),
180 'shortdesc' => 'Show font-optimization settings.',
181 'synopsis' => array(),
182 ),
183 );
184 }
185
186 public function cli_handler( array $args, array $assoc ): void {
187 $opts = $this->get_settings();
188 \WP_CLI::log( sprintf( '%-22s %s', 'font_display_swap', ! empty( $opts['font_display_swap'] ) ? 'on' : 'off' ) );
189 \WP_CLI::log( sprintf( '%-22s %d url(s)', 'preload_fonts', count( (array) ( $opts['preload_fonts'] ?? array() ) ) ) );
190 }
191 }
192