monorepo/native/wordpress/maple-performance-wp/inc/class-maple-optimize.php
2026-02-02 12:35:28 -05:00

1234 lines
40 KiB
PHP

<?php
/**
* Asset Optimization Class
*
* Handles CSS, JS, and HTML optimization.
*
* @package MaplePerformance
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Optimize class
*/
class Maple_Performance_Optimize {
/**
* Single instance
*/
private static $instance = null;
/**
* Plugin settings
*/
private $settings;
/**
* Collected styles
*/
private $styles = array();
/**
* Collected scripts
*/
private $scripts = array();
/**
* Get instance
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->settings = maple_performance()->settings;
$this->init_hooks();
}
/**
* Initialize hooks
*/
private function init_hooks() {
// HTML processing - needed for minification, local font display, or iframe lazyload
$needs_html_processing = $this->settings['html_minify']
|| $this->settings['local_font_display']
|| $this->settings['lazyload_iframes'];
if ( $needs_html_processing ) {
add_action( 'template_redirect', array( $this, 'start_html_buffer' ), -998 );
}
// Remove query strings
if ( $this->settings['remove_query_strings'] ) {
add_filter( 'script_loader_src', array( $this, 'remove_query_string' ), 15 );
add_filter( 'style_loader_src', array( $this, 'remove_query_string' ), 15 );
}
// DNS prefetch
if ( $this->settings['dns_prefetch'] ) {
add_action( 'wp_head', array( $this, 'add_dns_prefetch' ), 1 );
}
// Preconnect
if ( ! empty( $this->settings['preconnect_domains'] ) ) {
add_action( 'wp_head', array( $this, 'add_preconnect' ), 1 );
}
// CSS optimization
if ( $this->settings['css_minify'] || $this->settings['css_aggregate'] ) {
add_action( 'wp_enqueue_scripts', array( $this, 'collect_styles' ), 9999 );
}
// JS optimization
if ( $this->settings['js_minify'] || $this->settings['js_aggregate'] ) {
add_action( 'wp_enqueue_scripts', array( $this, 'collect_scripts' ), 9999 );
}
// Lazy loading
if ( $this->settings['lazyload_images'] ) {
add_filter( 'the_content', array( $this, 'add_lazyload' ), 99 );
add_filter( 'post_thumbnail_html', array( $this, 'add_lazyload' ), 99 );
add_filter( 'widget_text', array( $this, 'add_lazyload' ), 99 );
}
// Google Fonts optimization
if ( $this->settings['google_fonts'] !== 'leave' ) {
add_action( 'wp_enqueue_scripts', array( $this, 'process_google_fonts' ), 9999 );
// Also process fonts in HTML output for hardcoded fonts
if ( $this->settings['html_minify'] ) {
add_filter( 'maple_performance_html_output', array( $this, 'process_google_fonts_html' ), 10 );
}
}
// Local font optimization - add font-display: swap to @font-face rules
if ( $this->settings['local_font_display'] ) {
add_filter( 'maple_performance_html_output', array( $this, 'add_font_display_swap' ), 5 );
}
// Font preloading
if ( ! empty( $this->settings['preload_fonts'] ) ) {
add_action( 'wp_head', array( $this, 'output_font_preloads' ), 1 );
}
}
/**
* Start HTML buffer for minification
*/
public function start_html_buffer() {
if ( maple_performance()->is_excluded() ) {
return;
}
ob_start( array( $this, 'process_html' ) );
}
/**
* Process HTML output
*/
public function process_html( $html ) {
if ( empty( $html ) ) {
return $html;
}
// Skip if not HTML
if ( strpos( $html, '<html' ) === false && strpos( $html, '<!DOCTYPE' ) === false ) {
return $html;
}
// Skip minification for very large pages to prevent memory/performance issues
// 2MB is a reasonable limit - pages larger than this shouldn't be minified inline
$max_size = 2 * 1024 * 1024; // 2MB
if ( strlen( $html ) > $max_size ) {
return $html;
}
// Process Google Fonts in HTML (for hardcoded fonts)
$html = apply_filters( 'maple_performance_html_output', $html );
// Minify HTML
if ( $this->settings['html_minify'] ) {
$html = $this->minify_html( $html );
}
// Add lazy loading to iframes
if ( $this->settings['lazyload_iframes'] ) {
$html = $this->add_iframe_lazyload( $html );
}
return $html;
}
/**
* Minify HTML
*/
private function minify_html( $html ) {
// Additional size check for safety
if ( strlen( $html ) > 2 * 1024 * 1024 ) {
return $html;
}
// Preserve pre, script, style, textarea content
$preserve = array();
$preserve_tags = array( 'pre', 'script', 'style', 'textarea', 'svg' );
foreach ( $preserve_tags as $tag ) {
// Use atomic grouping concept with possessive quantifier simulation
// Limit matches to prevent catastrophic backtracking
$pattern = '/<' . $tag . '[^>]*>.*?<\/' . $tag . '>/is';
// Set a reasonable limit on preg operations
$match_count = preg_match_all( $pattern, $html, $matches );
// Limit to 500 preserved blocks per tag type to prevent memory issues
if ( $match_count > 500 ) {
$matches[0] = array_slice( $matches[0], 0, 500 );
}
foreach ( $matches[0] as $i => $match ) {
$placeholder = '<!--MAPLE_PRESERVE_' . strtoupper( $tag ) . '_' . $i . '-->';
$preserve[ $placeholder ] = $match;
$html = str_replace( $match, $placeholder, $html );
}
}
// Remove HTML comments (except IE conditionals and preserved)
if ( $this->settings['html_remove_comments'] ) {
$html = preg_replace( '/<!--(?!MAPLE_PRESERVE_)(?!\[if)(?!\s*\/?noscript)[^\[>].*?-->/s', '', $html );
}
// Remove whitespace between tags
$html = preg_replace( '/>\s+</', '><', $html );
// Collapse multiple spaces
$html = preg_replace( '/\s{2,}/', ' ', $html );
// Remove spaces around attributes
$html = preg_replace( '/\s+([a-zA-Z-]+)=/', ' $1=', $html );
// Restore preserved content
$html = str_replace( array_keys( $preserve ), array_values( $preserve ), $html );
return trim( $html );
}
/**
* Remove query strings from static resources
*/
public function remove_query_string( $src ) {
if ( strpos( $src, '?ver=' ) !== false ) {
$src = remove_query_arg( 'ver', $src );
}
return $src;
}
/**
* Add DNS prefetch hints
*/
public function add_dns_prefetch() {
$domains = array(
'fonts.googleapis.com',
'fonts.gstatic.com',
);
// Add from preconnect settings
if ( ! empty( $this->settings['preconnect_domains'] ) ) {
foreach ( $this->settings['preconnect_domains'] as $domain ) {
$parsed = parse_url( $domain );
if ( ! empty( $parsed['host'] ) ) {
$domains[] = $parsed['host'];
}
}
}
$domains = array_unique( $domains );
foreach ( $domains as $domain ) {
echo '<link rel="dns-prefetch" href="//' . esc_attr( $domain ) . '">' . "\n";
}
}
/**
* Add preconnect hints
*/
public function add_preconnect() {
foreach ( $this->settings['preconnect_domains'] as $domain ) {
if ( ! empty( $domain ) ) {
// Ensure URL has scheme
if ( strpos( $domain, '//' ) === false ) {
$domain = 'https://' . $domain;
}
echo '<link rel="preconnect" href="' . esc_url( $domain ) . '" crossorigin>' . "\n";
}
}
}
/**
* Collect enqueued styles
*/
public function collect_styles() {
if ( ! $this->settings['css_aggregate'] ) {
return;
}
global $wp_styles;
if ( empty( $wp_styles->queue ) ) {
return;
}
$to_aggregate = array();
foreach ( $wp_styles->queue as $handle ) {
// Skip if excluded
if ( $this->is_excluded_css( $handle ) ) {
continue;
}
$style = $wp_styles->registered[ $handle ] ?? null;
if ( ! $style || empty( $style->src ) ) {
continue;
}
// Only aggregate local files
$src = $style->src;
if ( $this->is_external_url( $src ) ) {
continue;
}
$to_aggregate[] = array(
'handle' => $handle,
'src' => $src,
'deps' => $style->deps,
'ver' => $style->ver,
'media' => $style->args ?? 'all',
);
// Dequeue original
wp_dequeue_style( $handle );
}
if ( ! empty( $to_aggregate ) ) {
$this->create_aggregated_css( $to_aggregate );
}
}
/**
* Check if style should be excluded
*/
private function is_excluded_css( $handle ) {
$exclude = array_merge(
array( 'admin-bar', 'dashicons' ),
$this->settings['exclude_css']
);
// Apply compat filter for detected plugins
$exclude = apply_filters( 'maple_performance_css_exclusions', $exclude );
foreach ( $exclude as $pattern ) {
if ( ! empty( $pattern ) && strpos( $handle, $pattern ) !== false ) {
return true;
}
}
return false;
}
/**
* Create aggregated CSS file
*/
private function create_aggregated_css( $styles ) {
$combined = '';
$total_size = 0;
$max_total_size = 2 * 1024 * 1024; // 2MB max combined size
$max_file_size = 500 * 1024; // 500KB max per file
$max_files = 50; // Max files to aggregate
$file_count = 0;
foreach ( $styles as $style ) {
// Limit number of files to aggregate
if ( ++$file_count > $max_files ) {
break;
}
$file_path = $this->url_to_path( $style['src'] );
if ( ! $file_path || ! file_exists( $file_path ) ) {
continue;
}
// Check file size before reading
$file_size = filesize( $file_path );
if ( $file_size > $max_file_size ) {
continue; // Skip files that are too large
}
// Check if adding this file would exceed total limit
if ( $total_size + $file_size > $max_total_size ) {
break;
}
$content = file_get_contents( $file_path );
if ( false === $content ) {
continue;
}
$total_size += strlen( $content );
// Fix relative URLs in CSS
$content = $this->fix_css_urls( $content, dirname( $style['src'] ) );
// Minify if enabled
if ( $this->settings['css_minify'] ) {
$content = $this->minify_css( $content );
}
$combined .= "/* {$style['handle']} */\n" . $content . "\n";
}
if ( empty( $combined ) ) {
return;
}
// Generate hash for filename
$hash = substr( md5( $combined ), 0, 12 );
$filename = 'maple-css-' . $hash . '.css';
$filepath = MAPLE_PERF_CACHE_DIR . 'assets/' . $filename;
$fileurl = MAPLE_PERF_CACHE_URL . 'assets/' . $filename;
// Verify filepath is within allowed directory
$assets_dir = realpath( MAPLE_PERF_CACHE_DIR . 'assets/' );
if ( false === $assets_dir ) {
// Assets directory doesn't exist yet, create it
wp_mkdir_p( MAPLE_PERF_CACHE_DIR . 'assets/' );
$assets_dir = realpath( MAPLE_PERF_CACHE_DIR . 'assets/' );
}
if ( false === $assets_dir ) {
return;
}
// Write file if doesn't exist
if ( ! file_exists( $filepath ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
file_put_contents( $filepath, $combined );
if ( file_exists( $filepath ) ) {
chmod( $filepath, 0644 );
}
// Create gzipped version
if ( function_exists( 'gzencode' ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
file_put_contents( $filepath . '.gz', gzencode( $combined, 9 ) );
if ( file_exists( $filepath . '.gz' ) ) {
chmod( $filepath . '.gz', 0644 );
}
}
}
// Enqueue aggregated file
wp_enqueue_style( 'maple-aggregated-css', $fileurl, array(), null, 'all' );
// Add defer if enabled
if ( $this->settings['css_defer'] ) {
add_filter( 'style_loader_tag', array( $this, 'defer_css' ), 10, 2 );
}
}
/**
* Defer CSS loading
*/
public function defer_css( $tag, $handle ) {
if ( $handle !== 'maple-aggregated-css' ) {
return $tag;
}
// Convert to preload + onload pattern
$tag = str_replace(
"rel='stylesheet'",
"rel='preload' as='style' onload=\"this.onload=null;this.rel='stylesheet'\"",
$tag
);
// Add noscript fallback
$noscript = '<noscript>' . str_replace(
array( "rel='preload' as='style' onload=\"this.onload=null;this.rel='stylesheet'\"" ),
array( "rel='stylesheet'" ),
$tag
) . '</noscript>';
return $tag . $noscript;
}
/**
* Fix relative URLs in CSS
*/
private function fix_css_urls( $css, $base_url ) {
// Ensure base URL has trailing slash
$base_url = trailingslashit( $base_url );
// Fix url() references
$css = preg_replace_callback(
'/url\s*\(\s*[\'"]?\s*(?!data:|https?:|\/\/)(.*?)\s*[\'"]?\s*\)/i',
function( $matches ) use ( $base_url ) {
$url = $matches[1];
// Handle relative paths
if ( strpos( $url, '../' ) === 0 ) {
// Go up directories
$parts = explode( '../', $url );
$up_count = count( $parts ) - 1;
$base_parts = explode( '/', rtrim( $base_url, '/' ) );
for ( $i = 0; $i < $up_count; $i++ ) {
array_pop( $base_parts );
}
$new_base = implode( '/', $base_parts ) . '/';
$url = $new_base . end( $parts );
} else {
$url = $base_url . ltrim( $url, './' );
}
return 'url(' . $url . ')';
},
$css
);
return $css;
}
/**
* Minify CSS
*/
private function minify_css( $css ) {
// Skip minification for very large CSS files to prevent performance issues
// 500KB is a reasonable limit for inline minification
if ( strlen( $css ) > 500 * 1024 ) {
return $css;
}
// Remove comments - use simpler pattern to avoid catastrophic backtracking
$css = preg_replace( '#/\*[^*]*\*+(?:[^/*][^*]*\*+)*/#', '', $css );
// Remove whitespace
$css = preg_replace( '/\s+/', ' ', $css );
// Remove spaces around special characters
$css = preg_replace( '/\s*([\{\}\:\;\,])\s*/', '$1', $css );
// Remove trailing semicolons before closing braces
$css = str_replace( ';}', '}', $css );
// Remove empty rules - simplified pattern
$css = preg_replace( '/[^\{\}]+\{\}/', '', $css );
return trim( $css );
}
/**
* Collect enqueued scripts
*/
public function collect_scripts() {
if ( ! $this->settings['js_aggregate'] ) {
// Just minify individual scripts if enabled
if ( $this->settings['js_minify'] ) {
add_filter( 'script_loader_tag', array( $this, 'maybe_minify_script' ), 10, 3 );
}
return;
}
global $wp_scripts;
if ( empty( $wp_scripts->queue ) ) {
return;
}
$to_aggregate = array();
foreach ( $wp_scripts->queue as $handle ) {
// Skip if excluded
if ( $this->is_excluded_js( $handle ) ) {
continue;
}
$script = $wp_scripts->registered[ $handle ] ?? null;
if ( ! $script || empty( $script->src ) ) {
continue;
}
// Only aggregate local files
$src = $script->src;
if ( $this->is_external_url( $src ) ) {
continue;
}
$to_aggregate[] = array(
'handle' => $handle,
'src' => $src,
'deps' => $script->deps,
'ver' => $script->ver,
);
// Dequeue original
wp_dequeue_script( $handle );
}
if ( ! empty( $to_aggregate ) ) {
$this->create_aggregated_js( $to_aggregate );
}
}
/**
* Check if script should be excluded
*/
private function is_excluded_js( $handle ) {
$exclude = $this->settings['exclude_js'];
// Always exclude jQuery if setting enabled
if ( $this->settings['js_exclude_jquery'] ) {
$exclude = array_merge( $exclude, array( 'jquery', 'jquery-core', 'jquery-migrate' ) );
}
// Add admin/core scripts
$exclude = array_merge( $exclude, array( 'admin-bar', 'wp-embed' ) );
// Apply compat filter for detected plugins
$exclude = apply_filters( 'maple_performance_js_exclusions', $exclude );
foreach ( $exclude as $pattern ) {
if ( ! empty( $pattern ) && strpos( $handle, $pattern ) !== false ) {
return true;
}
}
return false;
}
/**
* Create aggregated JS file
*/
private function create_aggregated_js( $scripts ) {
$combined = '';
$total_size = 0;
$max_total_size = 2 * 1024 * 1024; // 2MB max combined size
$max_file_size = 500 * 1024; // 500KB max per file
$max_files = 50; // Max files to aggregate
$file_count = 0;
foreach ( $scripts as $script ) {
// Limit number of files to aggregate
if ( ++$file_count > $max_files ) {
break;
}
$file_path = $this->url_to_path( $script['src'] );
if ( ! $file_path || ! file_exists( $file_path ) ) {
continue;
}
// Check file size before reading
$file_size = filesize( $file_path );
if ( $file_size > $max_file_size ) {
continue; // Skip files that are too large
}
// Check if adding this file would exceed total limit
if ( $total_size + $file_size > $max_total_size ) {
break;
}
$content = file_get_contents( $file_path );
if ( false === $content ) {
continue;
}
$total_size += strlen( $content );
// Minify if enabled
if ( $this->settings['js_minify'] ) {
$content = $this->minify_js( $content );
}
// Ensure semicolon at end
$content = rtrim( $content, "; \n\r" ) . ";\n";
$combined .= "/* {$script['handle']} */\n" . $content . "\n";
}
if ( empty( $combined ) ) {
return;
}
// Generate hash for filename
$hash = substr( md5( $combined ), 0, 12 );
$filename = 'maple-js-' . $hash . '.js';
$filepath = MAPLE_PERF_CACHE_DIR . 'assets/' . $filename;
$fileurl = MAPLE_PERF_CACHE_URL . 'assets/' . $filename;
// Verify filepath is within allowed directory
$assets_dir = realpath( MAPLE_PERF_CACHE_DIR . 'assets/' );
if ( false === $assets_dir ) {
// Assets directory doesn't exist yet, create it
wp_mkdir_p( MAPLE_PERF_CACHE_DIR . 'assets/' );
$assets_dir = realpath( MAPLE_PERF_CACHE_DIR . 'assets/' );
}
if ( false === $assets_dir ) {
return;
}
// Write file if doesn't exist
if ( ! file_exists( $filepath ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
file_put_contents( $filepath, $combined );
if ( file_exists( $filepath ) ) {
chmod( $filepath, 0644 );
}
// Create gzipped version
if ( function_exists( 'gzencode' ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
file_put_contents( $filepath . '.gz', gzencode( $combined, 9 ) );
if ( file_exists( $filepath . '.gz' ) ) {
chmod( $filepath . '.gz', 0644 );
}
}
}
// Enqueue aggregated file in footer
wp_enqueue_script( 'maple-aggregated-js', $fileurl, array( 'jquery' ), null, true );
// Add defer if enabled
if ( $this->settings['js_defer'] ) {
add_filter( 'script_loader_tag', array( $this, 'defer_js' ), 10, 2 );
}
}
/**
* Defer JS loading
*/
public function defer_js( $tag, $handle ) {
if ( $handle !== 'maple-aggregated-js' ) {
return $tag;
}
return str_replace( ' src', ' defer src', $tag );
}
/**
* Basic JS minification
*/
private function minify_js( $js ) {
// Skip minification for very large JS files to prevent performance issues
// 500KB is a reasonable limit for inline minification
if ( strlen( $js ) > 500 * 1024 ) {
return $js;
}
// Remove single-line comments (but not URLs)
$js = preg_replace( '#(?<!:)//[^\n\r]*#', '', $js );
// Remove multi-line comments - use simpler non-greedy pattern
$js = preg_replace( '#/\*[^*]*\*+(?:[^/*][^*]*\*+)*/#', '', $js );
// Remove extra whitespace
$js = preg_replace( '/\s+/', ' ', $js );
// Remove spaces around operators (basic, safe patterns only)
$js = preg_replace( '/\s*([\{\}\(\)\[\];,:])\s*/', '$1', $js );
return trim( $js );
}
/**
* Maybe minify individual script
*/
public function maybe_minify_script( $tag, $handle, $src ) {
// Skip excluded scripts
if ( $this->is_excluded_js( $handle ) ) {
return $tag;
}
// Skip external scripts
if ( $this->is_external_url( $src ) ) {
return $tag;
}
return $tag;
}
/**
* Add lazy loading to images
*/
public function add_lazyload( $content ) {
if ( empty( $content ) ) {
return $content;
}
// Don't lazyload in admin
if ( is_admin() ) {
return $content;
}
// Find all img tags
$content = preg_replace_callback(
'/<img([^>]+)>/i',
array( $this, 'process_image_tag' ),
$content
);
return $content;
}
/**
* Process individual image tag for lazy loading
*/
private function process_image_tag( $matches ) {
$img = $matches[0];
$attrs = $matches[1];
// Skip if already has loading attribute
if ( strpos( $attrs, 'loading=' ) !== false ) {
return $img;
}
// Skip if has fetchpriority="high" (LCP image)
if ( strpos( $attrs, 'fetchpriority="high"' ) !== false ) {
return $img;
}
// Check exclusions
foreach ( $this->settings['lazyload_exclude'] as $exclude ) {
if ( ! empty( $exclude ) && strpos( $attrs, $exclude ) !== false ) {
return $img;
}
}
// Add loading="lazy"
return str_replace( '<img', '<img loading="lazy"', $img );
}
/**
* Add lazy loading to iframes
*/
private function add_iframe_lazyload( $html ) {
return preg_replace_callback(
'/<iframe([^>]+)>/i',
function( $matches ) {
$iframe = $matches[0];
$attrs = $matches[1];
// Skip if already has loading attribute
if ( strpos( $attrs, 'loading=' ) !== false ) {
return $iframe;
}
return str_replace( '<iframe', '<iframe loading="lazy"', $iframe );
},
$html
);
}
/**
* Check if URL is external
*/
private function is_external_url( $url ) {
// Relative URLs are local
if ( strpos( $url, '//' ) !== 0 && strpos( $url, 'http' ) !== 0 ) {
return false;
}
$site_url = parse_url( site_url() );
$url_parts = parse_url( $url );
// Protocol-relative URLs
if ( strpos( $url, '//' ) === 0 ) {
$url_parts = parse_url( 'https:' . $url );
}
$site_host = $site_url['host'] ?? '';
$url_host = $url_parts['host'] ?? '';
return $site_host !== $url_host;
}
/**
* Convert URL to file path
*/
private function url_to_path( $url ) {
// Remove null bytes and path traversal attempts
$url = str_replace( array( "\0", '..' ), '', $url );
// Handle protocol-relative URLs
if ( strpos( $url, '//' ) === 0 ) {
$url = 'https:' . $url;
}
$path = false;
// Handle relative URLs
if ( strpos( $url, '/' ) === 0 && strpos( $url, '//' ) !== 0 ) {
$path = ABSPATH . ltrim( $url, '/' );
} else {
// Convert full URL to path
$content_url = content_url();
$content_dir = WP_CONTENT_DIR;
if ( strpos( $url, $content_url ) !== false ) {
$path = str_replace( $content_url, $content_dir, $url );
} else {
$site_url = site_url();
if ( strpos( $url, $site_url ) !== false ) {
$path = str_replace( $site_url, ABSPATH, $url );
}
}
}
if ( ! $path ) {
return false;
}
// Resolve the real path and verify it's within allowed directories
$real_path = realpath( $path );
if ( false === $real_path ) {
return false;
}
// Must be within ABSPATH
$real_abspath = realpath( ABSPATH );
if ( false === $real_abspath || strpos( $real_path, $real_abspath ) !== 0 ) {
return false;
}
return $real_path;
}
/**
* Process Google Fonts in enqueued styles
*/
public function process_google_fonts() {
global $wp_styles;
if ( empty( $wp_styles->queue ) ) {
return;
}
$google_fonts = array();
$handles_to_remove = array();
// Find all Google Fonts
foreach ( $wp_styles->queue as $handle ) {
$style = $wp_styles->registered[ $handle ] ?? null;
if ( ! $style || empty( $style->src ) ) {
continue;
}
if ( $this->is_google_font_url( $style->src ) ) {
$google_fonts[] = $style->src;
$handles_to_remove[] = $handle;
}
}
if ( empty( $google_fonts ) ) {
return;
}
// Handle based on setting
switch ( $this->settings['google_fonts'] ) {
case 'remove':
// Just dequeue them
foreach ( $handles_to_remove as $handle ) {
wp_dequeue_style( $handle );
}
break;
case 'combine':
case 'defer':
// Dequeue originals
foreach ( $handles_to_remove as $handle ) {
wp_dequeue_style( $handle );
}
// Combine and re-enqueue
$combined_url = $this->combine_google_fonts( $google_fonts );
if ( $combined_url ) {
if ( $this->settings['google_fonts'] === 'defer' ) {
// Add via wp_head with preload/swap pattern
add_action( 'wp_head', function() use ( $combined_url ) {
$this->output_deferred_google_fonts( $combined_url );
}, 5 );
} else {
// Standard enqueue with display=swap
wp_enqueue_style( 'maple-google-fonts', $combined_url, array(), null );
}
}
break;
}
}
/**
* Check if URL is a Google Fonts URL
*/
private function is_google_font_url( $url ) {
return strpos( $url, 'fonts.googleapis.com' ) !== false
|| strpos( $url, 'fonts.gstatic.com' ) !== false;
}
/**
* Combine multiple Google Fonts URLs into one
*/
private function combine_google_fonts( $urls ) {
$families = array();
foreach ( $urls as $url ) {
// Parse the URL
$parsed = parse_url( $url );
if ( empty( $parsed['query'] ) ) {
continue;
}
parse_str( $parsed['query'], $params );
// Handle both 'family' parameter formats
if ( ! empty( $params['family'] ) ) {
// Can be single family or multiple separated by |
$font_families = explode( '|', $params['family'] );
foreach ( $font_families as $family ) {
$families[] = trim( $family );
}
}
}
if ( empty( $families ) ) {
return false;
}
// Remove duplicates
$families = array_unique( $families );
// Build combined URL with display=swap
$combined_url = 'https://fonts.googleapis.com/css?family=' . implode( '|', array_map( 'urlencode', $families ) ) . '&display=swap';
// Check if using CSS2 format (newer)
$has_css2 = false;
foreach ( $urls as $url ) {
if ( strpos( $url, 'fonts.googleapis.com/css2' ) !== false ) {
$has_css2 = true;
break;
}
}
// If any URL uses CSS2, convert to CSS2 format
if ( $has_css2 ) {
$combined_url = $this->convert_to_css2_format( $families );
}
return $combined_url;
}
/**
* Convert font families to CSS2 format
*/
private function convert_to_css2_format( $families ) {
$family_params = array();
foreach ( $families as $family ) {
// Parse weight/style from family string
// Format: "Roboto:400,700" or "Roboto:wght@400;700"
if ( strpos( $family, ':' ) !== false ) {
list( $name, $weights ) = explode( ':', $family, 2 );
// Convert old format weights to CSS2 format
$weights = str_replace( ',', ';', $weights );
// Check if already in wght@ format
if ( strpos( $weights, 'wght@' ) === false && strpos( $weights, 'ital@' ) === false ) {
$weights = 'wght@' . $weights;
}
$family_params[] = 'family=' . urlencode( $name ) . ':' . $weights;
} else {
$family_params[] = 'family=' . urlencode( $family );
}
}
return 'https://fonts.googleapis.com/css2?' . implode( '&', $family_params ) . '&display=swap';
}
/**
* Output deferred Google Fonts (non-render-blocking)
*/
private function output_deferred_google_fonts( $url ) {
// Preconnect to Google Fonts domains
echo '<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>' . "\n";
echo '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' . "\n";
// Preload with swap to deferred stylesheet
echo '<link rel="preload" as="style" href="' . esc_url( $url ) . '" onload="this.onload=null;this.rel=\'stylesheet\'">' . "\n";
// Noscript fallback
echo '<noscript><link rel="stylesheet" href="' . esc_url( $url ) . '"></noscript>' . "\n";
}
/**
* Process Google Fonts in HTML output (for hardcoded fonts)
*/
public function process_google_fonts_html( $html ) {
if ( $this->settings['google_fonts'] === 'leave' ) {
return $html;
}
// Find all Google Fonts links in HTML
$pattern = '/<link[^>]+href=[\'"]([^\'"]*fonts\.googleapis\.com[^\'"]*)[\'"][^>]*>/i';
if ( ! preg_match_all( $pattern, $html, $matches, PREG_SET_ORDER ) ) {
return $html;
}
// Limit to 20 font URLs to prevent memory issues
$matches = array_slice( $matches, 0, 20 );
$google_font_urls = array();
$tags_to_remove = array();
foreach ( $matches as $match ) {
$tags_to_remove[] = $match[0];
$google_font_urls[] = $match[1];
}
// Remove original tags
foreach ( $tags_to_remove as $tag ) {
$html = str_replace( $tag, '', $html );
}
if ( $this->settings['google_fonts'] === 'remove' ) {
return $html;
}
// Combine fonts
$combined_url = $this->combine_google_fonts( $google_font_urls );
if ( ! $combined_url ) {
return $html;
}
// Create replacement tag
if ( $this->settings['google_fonts'] === 'defer' ) {
$replacement = '<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>' . "\n";
$replacement .= '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' . "\n";
$replacement .= '<link rel="preload" as="style" href="' . esc_url( $combined_url ) . '" onload="this.onload=null;this.rel=\'stylesheet\'">' . "\n";
$replacement .= '<noscript><link rel="stylesheet" href="' . esc_url( $combined_url ) . '"></noscript>' . "\n";
} else {
$replacement = '<link rel="stylesheet" href="' . esc_url( $combined_url ) . '">' . "\n";
}
// Insert after <head>
$html = preg_replace( '/<head([^>]*)>/i', '<head$1>' . "\n" . $replacement, $html, 1 );
return $html;
}
/**
* Add font-display: swap to all @font-face rules in inline styles
* This fixes the "Ensure text remains visible during webfont load" issue for local fonts
*/
public function add_font_display_swap( $html ) {
if ( empty( $html ) ) {
return $html;
}
// Find all <style> blocks containing @font-face
$pattern = '/<style[^>]*>(.*?)<\/style>/is';
$html = preg_replace_callback( $pattern, function( $matches ) {
$style_content = $matches[0];
// Check if this style block contains @font-face
if ( strpos( $style_content, '@font-face' ) === false ) {
return $style_content;
}
// Find @font-face rules that don't already have font-display
// Pattern: @font-face { ... } where there's no font-display property
$style_content = preg_replace_callback(
'/@font-face\s*\{([^}]+)\}/i',
function( $font_match ) {
$font_rule = $font_match[1];
// Check if font-display is already set
if ( stripos( $font_rule, 'font-display' ) !== false ) {
return $font_match[0]; // Already has font-display, leave it
}
// Add font-display: swap before the closing brace
$font_rule = rtrim( $font_rule, "; \n\r\t" );
$font_rule .= "; font-display: swap;";
return '@font-face {' . $font_rule . '}';
},
$style_content
);
return $style_content;
}, $html );
// Also handle linked stylesheets by adding a style block that overrides font-display
// This is a fallback for fonts defined in external CSS files
// We inject a global font-display override using @font-face with font-display: swap
return $html;
}
/**
* Output font preload links
* Preloading fonts breaks the critical chain: HTML -> CSS -> Font
* Instead: HTML -> Font (parallel with CSS)
*/
public function output_font_preloads() {
$fonts = $this->settings['preload_fonts'];
if ( empty( $fonts ) || ! is_array( $fonts ) ) {
return;
}
// Limit to 5 fonts to avoid over-preloading
$fonts = array_slice( $fonts, 0, 5 );
foreach ( $fonts as $font_url ) {
$font_url = trim( $font_url );
if ( empty( $font_url ) ) {
continue;
}
// Determine font type from extension
$type = 'font/woff2'; // Default to woff2
if ( strpos( $font_url, '.woff2' ) !== false ) {
$type = 'font/woff2';
} elseif ( strpos( $font_url, '.woff' ) !== false ) {
$type = 'font/woff';
} elseif ( strpos( $font_url, '.ttf' ) !== false ) {
$type = 'font/ttf';
} elseif ( strpos( $font_url, '.otf' ) !== false ) {
$type = 'font/otf';
}
// Output preload link
echo '<link rel="preload" href="' . esc_url( $font_url ) . '" as="font" type="' . esc_attr( $type ) . '" crossorigin>' . "\n";
}
}
}