| 1 |
<?php |
| 2 |
/** |
| 3 |
* Metasync_JS_Minifier |
| 4 |
* Conservative regex-based JS minification engine (no AST parsing). |
| 5 |
* Hooks into script_loader_tag to rewrite enqueued scripts 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_JS_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['js_exclude_handles'] ?? '')) |
| 28 |
); |
| 29 |
|
| 30 |
add_filter('script_loader_tag', [$this, 'maybe_minify_script'], 998, 3); |
| 31 |
} |
| 32 |
|
| 33 |
/** |
| 34 |
* Filter: attempt to minify the script and rewrite the tag to use the cached version. |
| 35 |
* |
| 36 |
* @param string $tag The full <script> tag HTML. |
| 37 |
* @param string $handle The script's registered handle. |
| 38 |
* @param string $src The script's source URL. |
| 39 |
* @return string Modified or original tag. |
| 40 |
*/ |
| 41 |
public function maybe_minify_script(string $tag, string $handle, string $src): string { |
| 42 |
// Skip admin, login, excluded handles, inline scripts |
| 43 |
if (is_admin() || $this->is_excluded($handle) || $this->is_login_page() || empty($src)) { |
| 44 |
return $tag; |
| 45 |
} |
| 46 |
|
| 47 |
// Only process local files |
| 48 |
$local_path = $this->url_to_local_path($src); |
| 49 |
if (!$local_path || !file_exists($local_path)) { |
| 50 |
return $tag; |
| 51 |
} |
| 52 |
|
| 53 |
// Skip already-minified files |
| 54 |
if (preg_match('/\.min\.js$/i', $local_path)) { |
| 55 |
return $tag; |
| 56 |
} |
| 57 |
|
| 58 |
// Skip ES module files — relocating them breaks relative import paths |
| 59 |
if ($this->is_es_module($tag, $local_path)) { |
| 60 |
return $tag; |
| 61 |
} |
| 62 |
|
| 63 |
// Check cache |
| 64 |
$cached = Metasync_Minification_Cache::get_cached_file($handle, 'js', $local_path); |
| 65 |
if ($cached) { |
| 66 |
return str_replace($src, $cached['url'], $tag); |
| 67 |
} |
| 68 |
|
| 69 |
// Read and minify |
| 70 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 71 |
$content = file_get_contents($local_path); |
| 72 |
if ($content === false || strlen($content) === 0) { |
| 73 |
return $tag; |
| 74 |
} |
| 75 |
|
| 76 |
$minified = self::minify($content); |
| 77 |
|
| 78 |
// Store in cache |
| 79 |
$cached = Metasync_Minification_Cache::store_cached_file($handle, 'js', $local_path, $minified); |
| 80 |
if ($cached) { |
| 81 |
return str_replace($src, $cached['url'], $tag); |
| 82 |
} |
| 83 |
|
| 84 |
return $tag; |
| 85 |
} |
| 86 |
|
| 87 |
/** |
| 88 |
* Minify a JavaScript string using conservative regex-based transformations. |
| 89 |
* |
| 90 |
* @param string $js Raw JavaScript content. |
| 91 |
* @return string Minified JavaScript. |
| 92 |
*/ |
| 93 |
public static function minify(string $js): string { |
| 94 |
// 1. Remove multi-line comments (but preserve conditional compilation comments /*! ... */) |
| 95 |
$js = preg_replace('/\/\*(?!![\s\S]*?\*\/)[\s\S]*?\*\//', '', $js); |
| 96 |
|
| 97 |
// 2. Remove single-line comments (careful with URLs and regex literals) |
| 98 |
// Only remove // comments that are not inside strings or URLs |
| 99 |
$js = preg_replace('/(?<![:\"\'\\\])\/\/[^\n]*/', '', $js); |
| 100 |
|
| 101 |
// 3. Remove trailing whitespace per line |
| 102 |
$js = preg_replace('/[ \t]+$/m', '', $js); |
| 103 |
|
| 104 |
// 4. Collapse multiple blank lines into one |
| 105 |
$js = preg_replace('/\n{3,}/', "\n\n", $js); |
| 106 |
|
| 107 |
// 5. Remove leading whitespace on lines (conservative: only spaces/tabs at start of line) |
| 108 |
$js = preg_replace('/^\s+/m', '', $js); |
| 109 |
|
| 110 |
// 6. Collapse multiple spaces into one (within lines) |
| 111 |
$js = preg_replace('/[ \t]+/', ' ', $js); |
| 112 |
|
| 113 |
// 7. Remove spaces around operators (conservative set only) |
| 114 |
$js = preg_replace('/\s*([{};,])\s*/', '$1', $js); |
| 115 |
|
| 116 |
// 8. Remove empty lines |
| 117 |
$js = preg_replace('/^\s*$/m', '', $js); |
| 118 |
$js = preg_replace('/\n+/', "\n", $js); |
| 119 |
|
| 120 |
return trim($js); |
| 121 |
} |
| 122 |
|
| 123 |
/** |
| 124 |
* Check if a script is an ES module (uses import/export with relative paths). |
| 125 |
* Relocating ES modules to the cache directory breaks their relative imports. |
| 126 |
*/ |
| 127 |
private function is_es_module(string $tag, string $local_path): bool { |
| 128 |
// Check if the script tag has type="module" |
| 129 |
if (preg_match('/type\s*=\s*["\']module["\']/', $tag)) { |
| 130 |
return true; |
| 131 |
} |
| 132 |
|
| 133 |
// Read beginning of file to check for ES module import/export syntax |
| 134 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 135 |
$head = file_get_contents($local_path, false, null, 0, 2048); |
| 136 |
if ($head === false) { |
| 137 |
return false; |
| 138 |
} |
| 139 |
|
| 140 |
// Detect relative import paths like: from"./js/..." or from'../something' |
| 141 |
if (preg_match('/\bimport\b.+?\bfrom\s*["\']\.\.?\//', $head)) { |
| 142 |
return true; |
| 143 |
} |
| 144 |
|
| 145 |
return false; |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* Check if a handle is excluded. |
| 150 |
*/ |
| 151 |
private function is_excluded(string $handle): bool { |
| 152 |
return in_array($handle, $this->excluded_handles, true); |
| 153 |
} |
| 154 |
|
| 155 |
/** |
| 156 |
* Convert a URL to a local filesystem path. |
| 157 |
* |
| 158 |
* @param string $url The script URL. |
| 159 |
* @return string|false Local path or false if not local. |
| 160 |
*/ |
| 161 |
private function url_to_local_path(string $url) { |
| 162 |
$url = strtok($url, '?'); |
| 163 |
|
| 164 |
if (strpos($url, '//') === 0) { |
| 165 |
$url = 'https:' . $url; |
| 166 |
} |
| 167 |
|
| 168 |
$site_url = site_url(); |
| 169 |
$content_url = content_url(); |
| 170 |
$abspath = ABSPATH; |
| 171 |
|
| 172 |
if (strpos($url, $site_url) === 0) { |
| 173 |
$relative = str_replace($site_url, '', $url); |
| 174 |
return $abspath . ltrim($relative, '/'); |
| 175 |
} |
| 176 |
|
| 177 |
if (strpos($url, $content_url) === 0) { |
| 178 |
$relative = str_replace($content_url, '', $url); |
| 179 |
return WP_CONTENT_DIR . '/' . ltrim($relative, '/'); |
| 180 |
} |
| 181 |
|
| 182 |
if (strpos($url, '/wp-content/') === 0 || strpos($url, '/wp-includes/') === 0) { |
| 183 |
return $abspath . ltrim($url, '/'); |
| 184 |
} |
| 185 |
|
| 186 |
return false; |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* Check if current request is the login page. |
| 191 |
*/ |
| 192 |
private function is_login_page(): bool { |
| 193 |
return in_array($GLOBALS['pagenow'] ?? '', ['wp-login.php', 'wp-register.php'], true); |
| 194 |
} |
| 195 |
} |
| 196 |
|