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, ' $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 = ''; $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( '//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 '' . "\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 '' . "\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 = ''; 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( '#(?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( '/]+)>/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( ']+)>/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( '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 '' . "\n"; echo '' . "\n"; // Preload with swap to deferred stylesheet echo '' . "\n"; // Noscript fallback echo '' . "\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 = '/]+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 = '' . "\n"; $replacement .= '' . "\n"; $replacement .= '' . "\n"; $replacement .= '' . "\n"; } else { $replacement = '' . "\n"; } // Insert after $html = preg_replace( '/]*)>/i', '' . "\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