for a * site-defined list of font files so the LCP-critical face starts * downloading at parser-discovery time, not after the CSS parses. * * Pro adds self-hosting (OMGF-style download/serve) and subsetting — * those live in xspeed-pro and are surfaced through the manifest. * * @package XSpeed */ declare(strict_types=1); namespace XSpeed\Modules\Fonts; defined( 'ABSPATH' ) || exit; use XSpeed\Module; final class FontsModule extends Module { public const SLUG = 'fonts'; public const TIER = self::TIER_FREE; public const VERSION = '1.0.0'; public function ui_metadata(): array { return array( 'label' => __( 'Fonts', 'xspeed' ), 'icon' => 'Type', 'description' => __( 'Stop web fonts from blocking text. Adds display=swap to Google Fonts and preloads the fonts you mark critical.', 'xspeed' ), ); } public function settings_schema(): array { return array( 'font_display_swap' => array( 'type' => 'bool', 'default' => true, 'label' => __( 'Add font-display: swap', 'xspeed' ), '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' ), ), 'preload_fonts' => array( 'type' => 'list', 'default' => array(), 'item_type' => 'url', 'label' => __( 'Preload Font URLs', 'xspeed' ), 'description' => __( 'One absolute font URL per line (woff2/woff/ttf/otf). Each becomes a in the head so the browser starts downloading before the CSS parses. Use only for fonts that render above the fold.', 'xspeed' ), ), ); } public function boot(): void { /* * Deferred to `init`. This module reads its own settings to decide * what to hook, and reading settings builds settings_schema(), whose * labels are declared through __(). boot() runs on `plugins_loaded`, * before `after_setup_theme` — the point WordPress 6.7+ treats as the * earliest safe moment to translate — so doing that here fires * _load_textdomain_just_in_time on every request AND resolves the * labels against a domain that is not loaded yet. * * Everything below hooks actions that fire after `init`, so running * one hook later is equivalent. */ add_action( 'init', array( $this, 'boot_on_init' ) ); } /** * The real boot body — see boot() for why it runs on `init`. */ public function boot_on_init(): void { // Frontend-only rewriting. Admin / cron / AJAX / REST never // render tags we should touch. if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) // A builder editing screen is a front-end URL none of the above // catch; swapping font-display under it changes what the editor // measures. (#281) || \XSpeed\Builder_Editor::is_active() ) { return; } $opts = $this->get_settings(); if ( ! empty( $opts['font_display_swap'] ) ) { add_filter( 'style_loader_tag', array( __CLASS__, 'inject_display_swap' ), 10, 2 ); } if ( ! empty( $opts['preload_fonts'] ) ) { add_action( 'wp_head', function () { echo self::render_preload_links( (array) $this->get_setting( 'preload_fonts', array() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped }, 1 ); } } /** * Rewrite a single tag emitted by WP for a Google Fonts * stylesheet so it carries display=swap. No-op for non-Google * hrefs and for URLs that already declare a display value * (auto / block / swap / fallback / optional). * * Public + static so the test suite can drive it without booting * the module or hitting WordPress hook internals. */ public static function inject_display_swap( string $tag, string $handle = '' ): string { unset( $handle ); // signature contract — not used. if ( false === stripos( $tag, 'fonts.googleapis.com' ) ) { return $tag; } if ( ! preg_match( '/href=([\'"])([^\'"]+)\1/i', $tag, $m ) ) { return $tag; } $href = $m[2]; // A display param the theme set is respected ONLY when it is one of // the non-blocking choices (swap / fallback / optional) — someone // picked those deliberately and each is a defensible trade. `auto` // and `block` are the values this setting exists to remove: `auto` // IS block behavior in every engine, and it is almost never a // choice — it is the default a theme's enqueue happened to emit. // "Respecting" it turned the switch into a no-op on exactly the // sites that need it: a live text-LCP measured a 5.5s render delay // behind flatsome's `display=auto` Poppins URL while this option // was on and its label promised the opposite. WP Rocket and // LiteSpeed rewrite these too. The href here has been through // esc_url(), which encodes "&" as "&" — decode before // matching, rewrite on the ORIGINAL encoded href so str_replace // finds it in the tag verbatim. (FBS-82161) $href_decoded = html_entity_decode( $href, ENT_QUOTES | ENT_HTML5 ); if ( preg_match( '/([?&])display=(auto|block)(&|$)/i', $href_decoded ) ) { $new_href = preg_replace( '/((?:[?&]|�*38;|&#[xX]0*26;|&)display=)(?:auto|block)(?=&|$)/i', '$1swap', $href ); return str_replace( $href, $new_href, $tag ); } if ( preg_match( '/[?&]display=/i', $href_decoded ) ) { return $tag; } // Pick the separator from the DECODED url (so a "?" hidden behind an // entity is still recognised), but append to the ORIGINAL (encoded) // href so the str_replace below matches the tag verbatim. $separator = ( false === strpos( $href_decoded, '?' ) ) ? '?' : '&'; $new_href = $href . $separator . 'display=swap'; return str_replace( $href, $new_href, $tag ); } /** * Render the preload markup for a list of font URLs. * * Pulled out as a static so tests can assert the markup directly * without buffering wp_head output. */ public static function render_preload_links( array $urls ): string { $out = ''; foreach ( $urls as $url ) { $url = is_string( $url ) ? trim( $url ) : ''; if ( '' === $url ) { continue; } $type = self::guess_font_mime( $url ); $out .= sprintf( '' . "\n", esc_attr( $type ), esc_url( $url ) ); } return $out; } /** * Map a font URL extension to its MIME. Defaults to woff2 because * that's the dominant modern format; an unknown extension is * almost always a fingerprinted woff2 in practice. */ public static function guess_font_mime( string $url ): string { $path = strtolower( wp_parse_url( $url, PHP_URL_PATH ) ?? '' ); if ( '' === $path ) { $path = strtolower( $url ); } // Plugin floor is PHP 7.4 — str_ends_with() is 8.0+. Use a // substr() compare instead so the matrix's 7.4 leg passes. $ends_with = static function ( string $haystack, string $needle ): bool { $len = strlen( $needle ); return 0 !== $len && substr( $haystack, -$len ) === $needle; }; if ( $ends_with( $path, '.woff2' ) ) { return 'font/woff2'; } if ( $ends_with( $path, '.woff' ) ) { return 'font/woff'; } if ( $ends_with( $path, '.ttf' ) ) { return 'font/ttf'; } if ( $ends_with( $path, '.otf' ) ) { return 'font/otf'; } return 'font/woff2'; } public function cli_commands(): array { return array( array( 'name' => 'xspeed fonts', 'callback' => array( $this, 'cli_handler' ), 'shortdesc' => 'Show font-optimization settings.', '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.', 'synopsis' => array(), ), ); } public function cli_handler( array $args, array $assoc ): void { $opts = $this->get_settings(); \WP_CLI::log( sprintf( '%-22s %s', 'font_display_swap', ! empty( $opts['font_display_swap'] ) ? 'on' : 'off' ) ); \WP_CLI::log( sprintf( '%-22s %d url(s)', 'preload_fonts', count( (array) ( $opts['preload_fonts'] ?? array() ) ) ) ); } }