PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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
← All changes | includes/modules/Fonts/FontsModule.php +67 -14 1.0.21.3.2 View file →
@@ -19,8 +19,10 @@
19 19 declare(strict_types=1);
20 20
21 21 namespace XSpeed\Modules\Fonts;
22 22
23 +defined( 'ABSPATH' ) || exit;
24 +
23 25 use XSpeed\Module;
24 26
25 27 final class FontsModule extends Module {
26 28
@@ -29,11 +31,11 @@
29 31 public const VERSION = '1.0.0';
30 32
31 33 public function ui_metadata(): array {
32 34 return array(
33 - 'label' => 'Fonts',
35 + 'label' => __( 'Fonts', 'xspeed' ),
34 36 'icon' => 'Type',
35 - 'description' => 'Stop web fonts from blocking text. Adds display=swap to Google Fonts and preloads the fonts you mark critical.',
37 + 'description' => __( 'Stop web fonts from blocking text. Adds display=swap to Google Fonts and preloads the fonts you mark critical.', 'xspeed' ),
36 38 );
37 39 }
38 40
39 41 public function settings_schema(): array {
@@ -40,22 +42,41 @@
40 42 return array(
41 43 'font_display_swap' => array(
42 44 'type' => 'bool',
43 45 '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 + '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' ),
46 48 ),
47 49 'preload_fonts' => array(
48 50 'type' => 'list',
49 51 'default' => array(),
50 52 '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 + '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' ),
53 55 ),
54 56 );
55 57 }
56 58
57 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 {
58 79 // Frontend-only rewriting. Admin / cron / AJAX / REST never
59 80 // render <link rel="stylesheet"> tags we should touch.
60 81 if ( is_admin()
61 82 || ( defined( 'DOING_AJAX' ) && DOING_AJAX )
@@ -60,8 +81,12 @@
60 81 if ( is_admin()
61 82 || ( defined( 'DOING_AJAX' ) && DOING_AJAX )
62 83 || ( defined( 'DOING_CRON' ) && DOING_CRON )
63 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()
64 89 ) {
65 90 return;
66 91 }
67 92
@@ -103,15 +128,36 @@
103 128 }
104 129
105 130 $href = $m[2];
106 131
107 - // Already has a display param — leave it alone (respect the
108 - // theme / plugin that set it).
109 - if ( preg_match( '/[?&]display=/i', $href ) ) {
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 ) ) {
110 153 return $tag;
111 154 }
112 155
113 - $separator = ( false === strpos( $href, '?' ) ) ? '?' : '&';
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, '?' ) ) ? '?' : '&';
114 160 $new_href = $href . $separator . 'display=swap';
115 161
116 162 return str_replace( $href, $new_href, $tag );
117 163 }
@@ -150,18 +196,24 @@
150 196 $path = strtolower( wp_parse_url( $url, PHP_URL_PATH ) ?? '' );
151 197 if ( '' === $path ) {
152 198 $path = strtolower( $url );
153 199 }
154 - if ( str_ends_with( $path, '.woff2' ) ) {
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' ) ) {
155 207 return 'font/woff2';
156 208 }
157 - if ( str_ends_with( $path, '.woff' ) ) {
209 + if ( $ends_with( $path, '.woff' ) ) {
158 210 return 'font/woff';
159 211 }
160 - if ( str_ends_with( $path, '.ttf' ) ) {
212 + if ( $ends_with( $path, '.ttf' ) ) {
161 213 return 'font/ttf';
162 214 }
163 - if ( str_ends_with( $path, '.otf' ) ) {
215 + if ( $ends_with( $path, '.otf' ) ) {
164 216 return 'font/otf';
165 217 }
166 218 return 'font/woff2';
167 219 }
@@ -171,8 +223,9 @@
171 223 array(
172 224 'name' => 'xspeed fonts',
173 225 'callback' => array( $this, 'cli_handler' ),
174 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.',
175 228 'synopsis' => array(),
176 229 ),
177 230 );
178 231 }