1234 lines
40 KiB
PHP
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";
|
|
}
|
|
}
|
|
}
|