get_all_settings(); if ( ! empty( $settings['google_fonts_remove'] ) ) { add_action( 'wp_enqueue_scripts', array( $this, 'remove_google_fonts' ), 100 ); add_filter( 'style_loader_src', array( $this, 'block_google_fonts_src' ), 100 ); add_action( 'template_redirect', array( $this, 'buffer_remove_head_links' ), 1 ); return; } if ( ! empty( $settings['google_fonts_optimize'] ) || ! empty( $settings['google_fonts_local'] ) ) { add_filter( 'style_loader_src', array( $this, 'add_display_param' ), 20 ); if ( empty( $settings['google_fonts_local'] ) ) { add_filter( 'wp_resource_hints', array( $this, 'preconnect_resource_hints' ), 10, 2 ); } if ( ! empty( $settings['google_fonts_preload'] ) && empty( $settings['google_fonts_local'] ) ) { add_filter( 'wp_preload_resources', array( $this, 'preload_fonts' ) ); } if ( ! empty( $settings['font_preload_list'] ) ) { add_filter( 'wp_preload_resources', array( $this, 'preload_custom_fonts' ) ); } if ( ! empty( $settings['google_fonts_local'] ) ) { add_filter( 'style_loader_src', array( $this, 'maybe_local_css' ), 25 ); add_action( 'template_redirect', array( $this, 'buffer_local_head_links' ), 1 ); add_filter( 'wp_preload_resources', array( $this, 'preload_local_font_files' ) ); } } // Admin tools always registered from Plugin; clear via admin-post. add_action( 'admin_post_qwsc_clear_local_fonts', array( $this, 'handle_clear_local_fonts' ) ); } public function remove_google_fonts() { if ( ! \QWSpeedCache\Plugin::should_optimize() ) { return; } global $wp_styles; if ( empty( $wp_styles->registered ) ) { return; } foreach ( $wp_styles->registered as $handle => $obj ) { if ( ! empty( $obj->src ) && self::is_google_fonts_url( $obj->src ) ) { wp_dequeue_style( $handle ); wp_deregister_style( $handle ); } } } public function block_google_fonts_src( $src ) { if ( ! \QWSpeedCache\Plugin::should_optimize() ) { return $src; } return self::is_google_fonts_url( $src ) ? false : $src; } /** Start a managed buffer for removing hardcoded Google Fonts links. */ public function buffer_remove_head_links() { $this->start_managed_buffer( 'filter_removed_font_links' ); } /** @param string $html HTML markup. */ public function filter_removed_font_links( $html ) { if ( ! is_string( $html ) ) { return $html; } $html = preg_replace( '/]+fonts\.googleapis\.com[^>]*>/i', '', $html ); return preg_replace( '/]+fonts\.gstatic\.com[^>]*>/i', '', $html ); } /** * Only flush the buffer we own, never a different plugin's output buffer. * * @param string $callback Method on this object. */ private function start_managed_buffer( $callback ) { if ( ! \QWSpeedCache\Plugin::should_optimize() ) { return; } \QWSpeedCache\Support\OutputBuffer::start( array( $this, $callback ) ); } public function add_display_param( $src ) { if ( ! \QWSpeedCache\Plugin::should_optimize() ) { return $src; } if ( ! self::is_google_fonts_url( $src ) ) { return $src; } $settings = \QWSpeedCache\Plugin::instance()->get_all_settings(); $display = isset( $settings['font_display'] ) ? sanitize_key( $settings['font_display'] ) : 'swap'; $allowed = array( 'auto', 'block', 'swap', 'fallback', 'optional' ); if ( ! in_array( $display, $allowed, true ) ) { $display = 'swap'; } if ( false !== strpos( $src, 'display=' ) ) { $src = remove_query_arg( 'display', $src ); } return add_query_arg( 'display', $display, $src ); } /** * Add Google Fonts connections through WordPress resource hints. * * @param array $urls Existing hints. * @param string $relation_type Hint relation. * @return array */ public function preconnect_resource_hints( $urls, $relation_type ) { if ( ! \QWSpeedCache\Plugin::should_optimize() ) { return $urls; } if ( 'preconnect' !== $relation_type ) { return $urls; } $settings = \QWSpeedCache\Plugin::instance()->get_all_settings(); if ( ! empty( $settings['google_fonts_local'] ) ) { return $urls; } $urls[] = 'https://fonts.googleapis.com'; $urls[] = array( 'href' => 'https://fonts.gstatic.com', 'crossorigin' => 'anonymous' ); return $urls; } /** * Register Google Fonts stylesheet preloads with WordPress, rather than * printing link elements directly into the document head. * * @param array $preloads Existing preload resources. * @return array */ public function preload_fonts( $preloads ) { if ( ! \QWSpeedCache\Plugin::should_optimize() ) { return $preloads; } global $wp_styles; if ( empty( $wp_styles->registered ) ) { return $preloads; } foreach ( $wp_styles->queue as $handle ) { if ( empty( $wp_styles->registered[ $handle ]->src ) ) { continue; } $src = $wp_styles->registered[ $handle ]->src; if ( self::is_google_fonts_url( $src ) && wp_http_validate_url( $src ) ) { $preloads[] = array( 'href' => esc_url_raw( $src ), 'as' => 'style' ); } } return $preloads; } /** * Register configured custom font preloads with WordPress. * * @param array $preloads Existing preload resources. * @return array */ public function preload_custom_fonts( $preloads ) { if ( ! \QWSpeedCache\Plugin::should_optimize() ) { return $preloads; } $settings = \QWSpeedCache\Plugin::instance()->get_all_settings(); $lines = array_filter( array_map( 'trim', explode( "\n", (string) ( $settings['font_preload_list'] ?? '' ) ) ) ); foreach ( $lines as $url ) { if ( 0 === strpos( $url, '#' ) || ! wp_http_validate_url( $url ) ) { continue; } $type = 'font/woff2'; if ( preg_match( '/\.woff2?(\?|$)/i', $url, $m ) ) { $type = false !== stripos( $m[0], 'woff2' ) ? 'font/woff2' : 'font/woff'; } $preloads[] = array( 'href' => esc_url_raw( $url ), 'as' => 'font', 'type' => $type, 'crossorigin' => 'anonymous', ); } return $preloads; } /** * Whether URL is Google Fonts CSS or file host. * * @param string $url URL. * @return bool */ public static function is_google_fonts_url( $url ) { if ( ! is_string( $url ) || '' === $url || strlen( $url ) > 2048 ) { return false; } // Only recognize real Google Fonts hosts, never lookalike hostnames or userinfo. $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) || empty( $parts['scheme'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return false; } return 'https' === strtolower( (string) $parts['scheme'] ) && in_array( strtolower( (string) $parts['host'] ), array( 'fonts.googleapis.com', 'fonts.gstatic.com' ), true ); } /** * Extract font file URLs from Google CSS. * * @param string $css CSS body. * @return string[] Absolute https URLs (unique). */ public static function extract_font_file_urls( $css ) { $out = array(); if ( ! is_string( $css ) || '' === $css ) { return $out; } if ( ! preg_match_all( '/url\(\s*[\'"]?([^\'"\)]+)[\'"]?\s*\)/i', $css, $m ) ) { return $out; } foreach ( $m[1] as $u ) { $u = trim( $u ); if ( '' === $u || 0 === strpos( $u, 'data:' ) ) { continue; } if ( 0 === strpos( $u, '//' ) ) { $u = 'https:' . $u; } if ( 0 !== strpos( $u, 'https://' ) && 0 !== strpos( $u, 'http://' ) ) { continue; } if ( ! self::is_google_fonts_url( $u ) ) { continue; } $out[] = $u; } return array_values( array_unique( $out ) ); } /** * Rewrite font URLs in CSS using a map remote => local path or URL. * * @param string $css CSS. * @param array $map Remote URL => local URL. * @return string */ public static function rewrite_font_urls( $css, array $map ) { if ( empty( $map ) ) { return $css; } // Longest first to avoid partial replacements. uksort( $map, function ( $a, $b ) { return strlen( $b ) - strlen( $a ); } ); foreach ( $map as $remote => $local ) { $css = str_replace( $remote, $local, $css ); // Protocol-relative variants. if ( 0 === strpos( $remote, 'https:' ) ) { $css = str_replace( substr( $remote, 6 ), $local, $css ); } } return $css; } /** * Cache directory for font CSS and files. * * @return string Absolute path with trailing slash. */ public static function cache_dir() { $dir = WP_CONTENT_DIR . '/cache/qw-speed-cache/fonts/'; if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); } $files = $dir . 'files/'; if ( ! is_dir( $files ) ) { wp_mkdir_p( $files ); } return $dir; } /** * Public URL for a relative file under the fonts cache dir. * * @param string $rel Relative path e.g. hash.css or files/x.woff2. * @return string */ public static function cache_url( $rel ) { return content_url( 'cache/qw-speed-cache/fonts/' . ltrim( $rel, '/' ) ); } /** * Safe local filename extension from remote font URL. * * @param string $url Remote URL. * @return string Extension including dot. */ public static function font_extension( $url ) { $path = (string) ( wp_parse_url( $url, PHP_URL_PATH ) ); $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); $ok = array( 'woff2', 'woff', 'ttf', 'otf', 'eot' ); if ( ! in_array( $ext, $ok, true ) ) { return '.woff2'; } return '.' . $ext; } /** * Serve locally cached Google CSS; download CSS + font files and rewrite URLs. * * @param string $src Stylesheet src. * @return string */ public function maybe_local_css( $src ) { if ( ! \QWSpeedCache\Plugin::should_optimize() ) { return $src; } return self::localize_stylesheet( $src ); } /** * Download + cache Google Fonts CSS and font files; return local URL. * * @param string $src Stylesheet src. * @return string */ public static function localize_stylesheet( $src ) { if ( ! self::is_google_fonts_url( $src ) || 'fonts.googleapis.com' !== strtolower( (string) wp_parse_url( $src, PHP_URL_HOST ) ) ) { return $src; } // Normalize with display param already applied by earlier filter. $hash = md5( $src ); $dir = self::cache_dir(); $css_file = $dir . $hash . '.css'; if ( file_exists( $css_file ) && filesize( $css_file ) > 0 ) { return self::cache_url( $hash . '.css' ); } $response = wp_safe_remote_get( $src, array( 'timeout' => 10, 'sslverify' => true, 'limit_response_size' => 1048576, 'headers' => array( // Prefer woff2-capable CSS. 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', ), ) ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return $src; } $body = (string) wp_remote_retrieve_body( $response ); if ( '' === $body || strlen( $body ) > 1048576 ) { return $src; } $remote_fonts = self::extract_font_file_urls( $body ); $map = array(); $n = 0; foreach ( $remote_fonts as $font_url ) { if ( $n >= self::MAX_FONT_FILES ) { break; } $ext = self::font_extension( $font_url ); $font_hash = md5( $font_url ); $rel = 'files/' . $font_hash . $ext; $local = $dir . $rel; if ( ! file_exists( $local ) || filesize( $local ) < 1 ) { $fr = wp_safe_remote_get( $font_url, array( 'timeout' => 15, 'sslverify' => true, 'limit_response_size' => 5242880, ) ); if ( is_wp_error( $fr ) || 200 !== (int) wp_remote_retrieve_response_code( $fr ) ) { continue; } $bin = (string) wp_remote_retrieve_body( $fr ); if ( '' === $bin || strlen( $bin ) > 5242880 ) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents @file_put_contents( $local, $bin, LOCK_EX ); } if ( file_exists( $local ) && filesize( $local ) > 0 ) { $map[ $font_url ] = self::cache_url( $rel ); $n++; } } $body = self::rewrite_font_urls( $body, $map ); $settings = \QWSpeedCache\Plugin::instance()->get_all_settings(); $display = isset( $settings['font_display'] ) ? $settings['font_display'] : 'swap'; $body = self::ensure_font_display( $body, $display ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents @file_put_contents( $css_file, $body, LOCK_EX ); if ( file_exists( $css_file ) && filesize( $css_file ) > 0 ) { return self::cache_url( $hash . '.css' ); } return $src; } /** * Rewrite hardcoded Google Fonts tags in HTML to local CSS when available. */ public function buffer_local_head_links() { $this->start_managed_buffer( 'filter_local_font_links' ); } /** @param string $html HTML markup. */ public function filter_local_font_links( $html ) { if ( ! is_string( $html ) || false === stripos( $html, 'fonts.googleapis.com' ) ) { return $html; } return preg_replace_callback( '/]+)>/i', static function ( $m ) { $tag = $m[0]; if ( false === stripos( $tag, 'fonts.googleapis.com' ) ) { return $tag; } if ( ! preg_match( '/href=[\'"]([^\'"]+)[\'"]/i', $tag, $hm ) ) { return $tag; } $local = self::localize_stylesheet( $hm[1] ); if ( $local === $hm[1] || false !== strpos( $local, 'fonts.googleapis.com' ) ) { return $tag; } return str_replace( $hm[1], esc_url( $local ), $tag ); }, $html ); } /** * Preload locally cached .woff2 files (first few) for faster first paint. */ /** * Append locally hosted font files to WordPress's preload resource list. * * @param array $preloads Existing resources. * @return array */ public function preload_local_font_files( $preloads ) { if ( ! \QWSpeedCache\Plugin::should_optimize() ) { return $preloads; } if ( ! is_array( $preloads ) ) { $preloads = array(); } $dir = self::cache_dir() . 'files/'; if ( ! is_dir( $dir ) ) { return $preloads; } $files = glob( $dir . '*.woff2' ) ?: array(); $n = 0; foreach ( $files as $file ) { if ( $n >= 6 ) { break; } if ( ! is_file( $file ) || ! is_readable( $file ) ) { continue; } $url = self::cache_url( 'files/' . basename( $file ) ); if ( ! wp_http_validate_url( $url ) ) { continue; } $preloads[] = array( 'href' => esc_url_raw( $url ), 'as' => 'font', 'type' => 'font/woff2', 'crossorigin' => 'anonymous', ); $n++; } return $preloads; } /** * Ensure font-display is present on @font-face in local CSS body. * * @param string $css CSS. * @param string $display Value. * @return string */ public static function ensure_font_display( $css, $display = 'swap' ) { $display = sanitize_key( $display ); $allowed = array( 'auto', 'block', 'swap', 'fallback', 'optional' ); if ( ! in_array( $display, $allowed, true ) ) { $display = 'swap'; } // Inject into @font-face blocks missing font-display. return preg_replace_callback( '/@font-face\s*\{([^}]*)\}/i', function ( $m ) use ( $display ) { $body = $m[1]; if ( false !== stripos( $body, 'font-display' ) ) { return $m[0]; } return '@font-face{' . $body . 'font-display:' . $display . ';}'; }, (string) $css ); } /** * Cache status for admin UI. * * @return array{css:int,files:int,bytes:int} */ public static function cache_status() { $dir = self::cache_dir(); $css = 0; $files = 0; $bytes = 0; foreach ( glob( $dir . '*.css' ) ?: array() as $f ) { $css++; $bytes += (int) filesize( $f ); } foreach ( glob( $dir . 'files/*' ) ?: array() as $f ) { if ( is_file( $f ) ) { $files++; $bytes += (int) filesize( $f ); } } return array( 'css' => $css, 'files' => $files, 'bytes' => $bytes, ); } /** * Delete local Google Fonts cache. */ public static function clear_cache() { $dir = self::cache_dir(); foreach ( glob( $dir . '*.css' ) ?: array() as $f ) { if ( file_exists( $f ) ) { wp_delete_file( $f ); } } foreach ( glob( $dir . 'files/*' ) ?: array() as $f ) { if ( is_file( $f ) ) { if ( file_exists( $f ) ) { wp_delete_file( $f ); } } } } public function handle_clear_local_fonts() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Forbidden', 'qw-optimize' ) ); } check_admin_referer( 'qwsc_clear_local_fonts' ); self::clear_cache(); wp_safe_redirect( add_query_arg( array( 'page' => 'qw-speed-cache-critical', 'fonts_cleared' => '1' ), admin_url( 'admin.php' ) ) ); exit; } }