queue as $handle ) {
if ( ! isset( $wp_styles->registered[ $handle ] ) ) {
continue;
}
$style = $wp_styles->registered[ $handle ];
// Skip if no source.
if ( empty( $style->src ) ) {
continue;
}
// Skip excluded handles.
if ( in_array( $handle, $exclusions, true ) ) {
continue;
}
// Skip external stylesheets (different domain).
$src = $style->src;
if ( strpos( $src, '//' ) === 0 ) {
$src = 'https:' . $src;
}
$site_url = site_url();
if ( strpos( $src, $site_url ) === false && strpos( $src, '/' ) !== 0 ) {
continue;
}
if ( $should_minify ) {
$this->process_stylesheet( $handle, $style, $cache_version );
}
}
}
/**
* Process a single stylesheet: minify and create cached version.
*
* @param string $handle Style handle.
* @param _WP_Dependency $style Style dependency object.
* @param string $cache_version Cache version.
*/
private function process_stylesheet( $handle, $style, $cache_version ) {
$src = $style->src;
// Convert URL to file path.
$file_path = $this->url_to_path( $src );
if ( ! $file_path || ! file_exists( $file_path ) ) {
return; // Cannot read file — serve original.
}
// Generate cache file path.
$hash = md5( $handle . $file_path . $cache_version . filemtime( $file_path ) );
$cache_file = wpsco_get_cache_dir( 'css/' . $handle . '.' . substr( $hash, 0, 8 ) . '.min.css' );
$cache_url = wpsco_get_cache_url( 'css/' . $handle . '.' . substr( $hash, 0, 8 ) . '.min.css' );
$css_ver = ! empty( $style->ver ) ? $style->ver : ( ! empty( $cache_version ) ? $cache_version : WPSCO_VERSION );
// Check if cached version exists and is valid.
if ( file_exists( $cache_file ) ) {
// Serve cached version.
wp_deregister_style( $handle );
wp_register_style( $handle, $cache_url, $style->deps, $css_ver, $style->args );
wp_enqueue_style( $handle );
return;
}
// Read original CSS.
$css = wpsco_read_file( $file_path );
if ( false === $css ) {
return; // Read failed — serve original.
}
// Minify.
$minified = $this->minify_css( $css );
if ( false === $minified || empty( $minified ) ) {
wpsco_log( sprintf( 'CSS minification failed for %s — serving original.', $handle ), 'warning' );
return; // Minification failed — serve original.
}
// Fix relative URLs in CSS.
$minified = $this->fix_relative_urls( $minified, dirname( $src ) );
// Write cached file via safe WordPress Filesystem API.
$written = wpsco_write_file( $cache_file, $minified );
if ( false === $written ) {
wpsco_log( sprintf( 'Failed to write CSS cache for %s.', $handle ), 'error' );
return; // Write failed — serve original.
}
// Replace with cached version.
wp_deregister_style( $handle );
wp_register_style( $handle, $cache_url, $style->deps, $css_ver, $style->args );
wp_enqueue_style( $handle );
}
/**
* Minify CSS content.
*
* Uses a safe, robust minification approach that preserves:
* - CSS variables (custom properties)
* - calc() expressions
* - @media, @supports, @keyframes
* - Gradients, URLs, data URIs
* - Escaped characters
* - Modern CSS features
*
* @param string $css CSS content.
* @return string|false Minified CSS or false on failure.
*/
public function minify_css( $css ) {
if ( empty( $css ) ) {
return '';
}
try {
// Step 1: Preserve strings and important content.
$preserved = array();
$index = 0;
// Preserve data URIs and url() content.
$css = preg_replace_callback(
'/url\s*\(\s*([\'"]?)(.+?)\1\s*\)/is',
function ( $matches ) use ( &$preserved, &$index ) {
$placeholder = '___WPSCO_URL_' . $index . '___';
$preserved[ $placeholder ] = $matches[0];
$index++;
return $placeholder;
},
$css
);
// Preserve strings (single and double quoted).
$css = preg_replace_callback(
'/("(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\')/s',
function ( $matches ) use ( &$preserved, &$index ) {
$placeholder = '___WPSCO_STR_' . $index . '___';
$preserved[ $placeholder ] = $matches[0];
$index++;
return $placeholder;
},
$css
);
// Preserve calc() expressions.
$css = preg_replace_callback(
'/calc\s*\([^)]*(?:\([^)]*\)[^)]*)*\)/is',
function ( $matches ) use ( &$preserved, &$index ) {
$placeholder = '___WPSCO_CALC_' . $index . '___';
$preserved[ $placeholder ] = $matches[0];
$index++;
return $placeholder;
},
$css
);
// Step 2: Remove comments (but keep /*! important */ comments).
$css = preg_replace( '/\/\*(?!\!)[^*]*\*+(?:[^/*][^*]*\*+)*\//', '', $css );
// Step 3: Remove unnecessary whitespace.
$css = preg_replace( '/\s+/', ' ', $css );
// Step 4: Remove spaces around specific characters.
$css = preg_replace( '/\s*([{}:;,>~+])\s*/', '$1', $css );
// Step 5: Remove trailing semicolons before closing braces.
$css = str_replace( ';}', '}', $css );
// Step 6: Remove empty rules.
$css = preg_replace( '/[^{}]+\{\s*\}/', '', $css );
// Step 6b: Inject font-display: swap into @font-face rules if missing.
if ( wpsco_get_setting( 'font_display_swap', true ) ) {
$css = preg_replace_callback(
'/@font-face\s*\{([^}]+)\}/is',
function ( $matches ) {
if ( stripos( $matches[1], 'font-display' ) === false ) {
return '@font-face{font-display:swap;' . $matches[1] . '}';
}
return $matches[0];
},
$css
);
}
// Step 7: Restore preserved content.
foreach ( $preserved as $placeholder => $original ) {
$css = str_replace( $placeholder, $original, $css );
}
// Step 8: Final trim.
$css = trim( $css );
return $css;
} catch ( \Exception $e ) {
wpsco_log( 'CSS minification error: ' . $e->getMessage(), 'error' );
return false;
}
}
/**
* Fix relative URLs in CSS content.
*
* @param string $css CSS content.
* @param string $base_url Base URL of the original CSS file.
* @return string CSS with corrected URLs.
*/
private function fix_relative_urls( $css, $base_url ) {
return preg_replace_callback(
'/url\s*\(\s*([\'"]?)(?!(?:data|https?|\/\/):)([^\'"\)]+)\1\s*\)/i',
function ( $matches ) use ( $base_url ) {
$url = $matches[2];
if ( strpos( $url, '/' ) === 0 ) {
return $matches[0]; // Absolute path — leave as-is.
}
$resolved = trailingslashit( $base_url ) . $url;
return 'url(' . $matches[1] . $resolved . $matches[1] . ')';
},
$css
);
}
/**
* Enqueue critical CSS in the head using WordPress core style APIs.
*/
public function enqueue_critical_css() {
if ( is_admin() ) {
return;
}
$critical_css = $this->get_critical_css();
if ( ! empty( $critical_css ) ) {
wp_register_style( 'speednest-critical-css', false, array(), WPSCO_VERSION );
wp_enqueue_style( 'speednest-critical-css' );
wp_add_inline_style( 'speednest-critical-css', $critical_css );
}
}
/**
* Backward-compatible inline critical CSS method.
*/
public function inline_critical_css() {
$this->enqueue_critical_css();
}
/**
* Get critical CSS for the current page.
*
* @return string
*/
private function get_critical_css() {
$page_type = $this->get_page_type();
$cache_file = wpsco_get_cache_dir( 'critical-css/' . $page_type . '.css' );
if ( file_exists( $cache_file ) ) {
return wpsco_read_file( $cache_file );
}
return '';
}
/**
* Get the current page type for critical CSS.
*
* @return string
*/
private function get_page_type() {
if ( is_front_page() || is_home() ) {
return 'front-page';
}
if ( is_singular( 'post' ) ) {
return 'single-post';
}
if ( is_singular( 'page' ) ) {
return 'single-page';
}
if ( is_singular( 'product' ) ) {
return 'single-product';
}
if ( is_archive() ) {
return 'archive';
}
if ( is_search() ) {
return 'search';
}
return 'default';
}
/**
* Maybe load a stylesheet asynchronously using rel="preload".
*
* @param string $tag HTML link tag.
* @param string $handle Style handle.
* @param string $href Style URL.
* @param string $media Style media attribute.
* @return string
*/
public function maybe_async_style( $tag, $handle, $href, $media ) {
if ( is_admin() || ! wpsco_get_setting( 'css_async', false ) ) {
return $tag;
}
$exclusions = (array) wpsco_get_setting( 'css_exclusions', array() );
if ( in_array( $handle, $exclusions, true ) ) {
return $tag;
}
// Never async core layout, theme, or Elementor styles to prevent layout shifts and responsive hiding failures.
$protected_styles = array(
'elementor',
'elementor-frontend',
'elementor-post',
'elementor-kit',
'elementskit',
'ekit-responsive',
'ekit-widget-styles',
'responsive',
'header',
'logo',
'header-footer',
'theme',
'style.css',
'main.css',
'woocommerce',
'wc-',
'bootstrap',
);
foreach ( $protected_styles as $prot ) {
if ( stripos( $handle, $prot ) !== false || stripos( $href, $prot ) !== false ) {
return $tag;
}
}
if ( strpos( $tag, 'media="print"' ) !== false || strpos( $tag, 'rel="preload"' ) !== false ) {
return $tag;
}
$clean_tag = preg_replace( '/\s*media=["\'][^"\']*["\']/i', '', $tag );
return str_replace( '' . $tag . '';
}
/**
* Convert a URL to a local file path.
*
* @param string $url URL.
* @return string|false File path or false.
*/
private function url_to_path( $url ) {
return wpsco_url_to_path( $url );
}
/**
* Generate critical CSS for a URL (manual trigger).
*
* @param string $url Target URL.
* @param string $page_type Page type identifier.
* @return bool
*/
public function generate_critical_css( $url, $page_type = 'default' ) {
// Fetch the page HTML.
$response = wp_remote_get( $url, array(
'timeout' => 30,
'user-agent' => 'WPSpeedCacheOptimizer/CriticalCSS',
) );
if ( is_wp_error( $response ) ) {
wpsco_log( 'Critical CSS generation failed for ' . $url . ': ' . $response->get_error_message(), 'error' );
return false;
}
$html = wp_remote_retrieve_body( $response );
if ( empty( $html ) ) {
return false;
}
// Extract linked CSS files.
$css_content = '';
preg_match_all( '/]+rel=["\']stylesheet["\'][^>]*href=["\']([^"\']+)["\'][^>]*>/i', $html, $matches );
if ( ! empty( $matches[1] ) ) {
foreach ( $matches[1] as $css_url ) {
$css_response = wp_remote_get( $css_url, array( 'timeout' => 10 ) );
if ( ! is_wp_error( $css_response ) ) {
$css_content .= wp_remote_retrieve_body( $css_response ) . "\n";
}
}
}
// Extract above-the-fold critical CSS (simplified heuristic).
$critical = $this->extract_critical_selectors( $css_content, $html );
if ( empty( $critical ) ) {
return false;
}
// Save critical CSS using safe WordPress Filesystem API.
$cache_file = wpsco_get_cache_dir( 'critical-css/' . sanitize_file_name( $page_type ) . '.css' );
$minified = $this->minify_css( $critical );
wpsco_write_file( $cache_file, $minified );
wpsco_log( sprintf( 'Critical CSS generated for %s (%s)', $url, $page_type ) );
return true;
}
/**
* Extract critical CSS selectors from full CSS based on HTML content.
*
* This is a safe, conservative extraction that includes:
* - All @font-face rules
* - All CSS custom properties (:root)
* - All @keyframes
* - Selectors matching elements found in the HTML
*
* @param string $css Full CSS content.
* @param string $html HTML content.
* @return string Critical CSS.
*/
private function extract_critical_selectors( $css, $html ) {
$critical = '';
// Always include @font-face, :root, @keyframes.
preg_match_all( '/@font-face\s*\{[^}]*\}/is', $css, $font_matches );
if ( ! empty( $font_matches[0] ) ) {
$critical .= implode( "\n", $font_matches[0] ) . "\n";
}
preg_match_all( '/:root\s*\{[^}]*\}/is', $css, $root_matches );
if ( ! empty( $root_matches[0] ) ) {
$critical .= implode( "\n", $root_matches[0] ) . "\n";
}
preg_match_all( '/@keyframes\s+[^{]+\{(?:[^{}]*\{[^}]*\})*[^}]*\}/is', $css, $keyframe_matches );
if ( ! empty( $keyframe_matches[0] ) ) {
$critical .= implode( "\n", $keyframe_matches[0] ) . "\n";
}
// Extract class names and IDs from HTML.
$classes = array();
$ids = array();
preg_match_all( '/class=["\']([^"\']+)["\']/', $html, $class_matches );
if ( ! empty( $class_matches[1] ) ) {
foreach ( $class_matches[1] as $class_list ) {
$classes = array_merge( $classes, explode( ' ', $class_list ) );
}
}
preg_match_all( '/id=["\']([^"\']+)["\']/', $html, $id_matches );
if ( ! empty( $id_matches[1] ) ) {
$ids = $id_matches[1];
}
$classes = array_unique( array_filter( $classes ) );
$ids = array_unique( array_filter( $ids ) );
// Extract tag names.
$common_tags = array( 'html', 'body', 'header', 'nav', 'main', 'h1', 'h2', 'h3', 'p', 'a', 'img', 'div', 'section', 'article', 'ul', 'li', 'button', 'input', 'form' );
// Match CSS rules that reference these selectors.
// Split CSS into rules (simplified).
preg_match_all( '/([^{}]+)\{([^}]*)\}/s', $css, $rules );
if ( ! empty( $rules[1] ) ) {
$count = 0;
foreach ( $rules[1] as $i => $selector ) {
if ( $count > 500 ) {
break; // Limit critical CSS size.
}
$selector = trim( $selector );
// Skip @media wrappers (handled separately).
if ( strpos( $selector, '@' ) === 0 ) {
continue;
}
$matched = false;
// Check tag selectors.
foreach ( $common_tags as $tag ) {
if ( preg_match( '/(?:^|[\s,>~+])' . preg_quote( $tag, '/' ) . '(?:[\s,.:#{>\[~+]|$)/i', $selector ) ) {
$matched = true;
break;
}
}
// Check class selectors.
if ( ! $matched ) {
foreach ( $classes as $class ) {
if ( strpos( $selector, '.' . $class ) !== false ) {
$matched = true;
break;
}
}
}
// Check ID selectors.
if ( ! $matched ) {
foreach ( $ids as $id ) {
if ( strpos( $selector, '#' . $id ) !== false ) {
$matched = true;
break;
}
}
}
if ( $matched ) {
$critical .= $selector . '{' . $rules[2][ $i ] . "}\n";
$count++;
}
}
}
return $critical;
}
}