| 1 |
<?php |
| 2 |
/** |
| 3 |
* Metasync_CSS_Minifier |
| 4 |
* Regex-based CSS minification engine. |
| 5 |
* Hooks into style_loader_tag to rewrite enqueued stylesheets to cached minified versions. |
| 6 |
* |
| 7 |
* @package Search Atlas SEO |
| 8 |
* @copyright Copyright (C) 2021-2025, Search Atlas Group - support@searchatlas.com |
| 9 |
* @since 2.5.23 |
| 10 |
*/ |
| 11 |
|
| 12 |
if (!defined('ABSPATH')) { |
| 13 |
exit; |
| 14 |
} |
| 15 |
|
| 16 |
class Metasync_CSS_Minifier { |
| 17 |
|
| 18 |
/** @var array */ |
| 19 |
private $settings; |
| 20 |
|
| 21 |
/** @var array Handles to exclude from minification. */ |
| 22 |
private $excluded_handles = []; |
| 23 |
|
| 24 |
public function __construct(array $settings) { |
| 25 |
$this->settings = $settings; |
| 26 |
$this->excluded_handles = array_filter( |
| 27 |
array_map('trim', explode(',', $settings['css_exclude_handles'] ?? '')) |
| 28 |
); |
| 29 |
|
| 30 |
add_filter('style_loader_tag', [$this, 'maybe_minify_style'], 999, 4); |
| 31 |
} |
| 32 |
|
| 33 |
/** |
| 34 |
* Filter: attempt to minify the stylesheet and rewrite the tag to use the cached version. |
| 35 |
* |
| 36 |
* @param string $tag The full <link> tag HTML. |
| 37 |
* @param string $handle The style's registered handle. |
| 38 |
* @param string $href The stylesheet's source URL. |
| 39 |
* @param string $media The stylesheet's media attribute. |
| 40 |
* @return string Modified or original tag. |
| 41 |
*/ |
| 42 |
public function maybe_minify_style(string $tag, string $handle, string $href, string $media): string { |
| 43 |
// Skip admin, login, excluded handles |
| 44 |
if (is_admin() || $this->is_excluded($handle) || $this->is_login_page()) { |
| 45 |
return $tag; |
| 46 |
} |
| 47 |
|
| 48 |
// Only process local files |
| 49 |
$local_path = $this->url_to_local_path($href); |
| 50 |
if (!$local_path || !file_exists($local_path)) { |
| 51 |
return $tag; |
| 52 |
} |
| 53 |
|
| 54 |
// Skip already-minified files |
| 55 |
if (preg_match('/\.min\.css$/i', $local_path)) { |
| 56 |
return $tag; |
| 57 |
} |
| 58 |
|
| 59 |
// Check cache |
| 60 |
$cached = Metasync_Minification_Cache::get_cached_file($handle, 'css', $local_path); |
| 61 |
if ($cached) { |
| 62 |
return str_replace($href, $cached['url'], $tag); |
| 63 |
} |
| 64 |
|
| 65 |
// Read and minify |
| 66 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 67 |
$content = file_get_contents($local_path); |
| 68 |
if ($content === false || strlen($content) === 0) { |
| 69 |
return $tag; |
| 70 |
} |
| 71 |
|
| 72 |
$minified = self::minify($content); |
| 73 |
|
| 74 |
// Store in cache |
| 75 |
$cached = Metasync_Minification_Cache::store_cached_file($handle, 'css', $local_path, $minified); |
| 76 |
if ($cached) { |
| 77 |
return str_replace($href, $cached['url'], $tag); |
| 78 |
} |
| 79 |
|
| 80 |
return $tag; |
| 81 |
} |
| 82 |
|
| 83 |
/** |
| 84 |
* Minify a CSS string using regex-based transformations. |
| 85 |
* |
| 86 |
* @param string $css Raw CSS content. |
| 87 |
* @return string Minified CSS. |
| 88 |
*/ |
| 89 |
public static function minify(string $css): string { |
| 90 |
// 1. Remove CSS comments |
| 91 |
$css = preg_replace('/\/\*[\s\S]*?\*\//', '', $css); |
| 92 |
|
| 93 |
// 2. Collapse whitespace |
| 94 |
$css = preg_replace('/\s+/', ' ', $css); |
| 95 |
|
| 96 |
// 3. Remove spaces around { } : ; , |
| 97 |
$css = preg_replace('/\s*\{\s*/', '{', $css); |
| 98 |
$css = preg_replace('/\s*\}\s*/', '}', $css); |
| 99 |
$css = preg_replace('/\s*:\s*/', ':', $css); |
| 100 |
$css = preg_replace('/\s*;\s*/', ';', $css); |
| 101 |
$css = preg_replace('/\s*,\s*/', ',', $css); |
| 102 |
|
| 103 |
// 4. Remove trailing semicolons before } |
| 104 |
$css = str_replace(';}', '}', $css); |
| 105 |
|
| 106 |
// 5. Remove units after zero (0px -> 0), but not 0% (used in keyframes/gradients) |
| 107 |
$css = preg_replace('/(?<=[\s:,])0(?:px|em|rem|ex|ch|vw|vh|vmin|vmax|cm|mm|in|pt|pc)\b/', '0', $css); |
| 108 |
|
| 109 |
return trim($css); |
| 110 |
} |
| 111 |
|
| 112 |
/** |
| 113 |
* Check if a handle is excluded. |
| 114 |
*/ |
| 115 |
private function is_excluded(string $handle): bool { |
| 116 |
return in_array($handle, $this->excluded_handles, true); |
| 117 |
} |
| 118 |
|
| 119 |
/** |
| 120 |
* Convert a URL to a local filesystem path. |
| 121 |
* |
| 122 |
* @param string $url The stylesheet URL. |
| 123 |
* @return string|false Local path or false if not local. |
| 124 |
*/ |
| 125 |
private function url_to_local_path(string $url) { |
| 126 |
// Remove query string |
| 127 |
$url = strtok($url, '?'); |
| 128 |
|
| 129 |
// Handle protocol-relative URLs |
| 130 |
if (strpos($url, '//') === 0) { |
| 131 |
$url = 'https:' . $url; |
| 132 |
} |
| 133 |
|
| 134 |
$site_url = site_url(); |
| 135 |
$content_url = content_url(); |
| 136 |
$abspath = ABSPATH; |
| 137 |
|
| 138 |
// Check if the URL is local |
| 139 |
if (strpos($url, $site_url) === 0) { |
| 140 |
$relative = str_replace($site_url, '', $url); |
| 141 |
return $abspath . ltrim($relative, '/'); |
| 142 |
} |
| 143 |
|
| 144 |
if (strpos($url, $content_url) === 0) { |
| 145 |
$relative = str_replace($content_url, '', $url); |
| 146 |
return WP_CONTENT_DIR . '/' . ltrim($relative, '/'); |
| 147 |
} |
| 148 |
|
| 149 |
// Check for relative URLs |
| 150 |
if (strpos($url, '/wp-content/') === 0 || strpos($url, '/wp-includes/') === 0) { |
| 151 |
return $abspath . ltrim($url, '/'); |
| 152 |
} |
| 153 |
|
| 154 |
return false; |
| 155 |
} |
| 156 |
|
| 157 |
/** |
| 158 |
* Check if current request is the login page. |
| 159 |
*/ |
| 160 |
private function is_login_page(): bool { |
| 161 |
return in_array($GLOBALS['pagenow'] ?? '', ['wp-login.php', 'wp-register.php'], true); |
| 162 |
} |
| 163 |
} |
| 164 |
|