PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 1.2.1 All 27 releases
xspeed / includes / modules / Minify / MinifyModule.php

MinifyModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.0, at includes/modules/Minify/MinifyModule.php

350 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Minify module.
4 *
5 * Owns the minify_html / minify_css / minify_js settings. Engine work
6 * still happens in XSpeed\Minifier (filters style_loader_src and
7 * script_loader_src), but the Module is now the storage and editing
8 * authority. Settings live in xspeed_module_minify; the legacy
9 * xspeed_options blob is drained on first boot and on POST.
10 *
11 * Tier: Free. See SETTINGS.md for the contract this module satisfies.
12 *
13 * @package XSpeed
14 */
15
16 declare(strict_types=1);
17
18 namespace XSpeed\Modules\Minify;
19
20 defined( 'ABSPATH' ) || exit;
21
22 use XSpeed\Minifier as LegacyMinifier;
23 use XSpeed\Module;
24 use XSpeed\Settings_Manager;
25
26 final class MinifyModule extends Module {
27
28 public const SLUG = 'minify';
29 public const TIER = self::TIER_FREE;
30 public const VERSION = '1.2.0';
31
32 public function ui_metadata(): array {
33 return array(
34 'label' => __( 'CSS & JavaScript', 'xspeed' ),
35 'tab_label' => __( 'Minify', 'xspeed' ), // its own tab on the CSS & JavaScript page
36 'icon' => 'Wand2',
37 'description' => __( 'Strip whitespace and rewrite enqueued CSS / JS.', 'xspeed' ),
38 // Host page: Minify (this module) / Critical CSS (Pro) / Unused
39 // CSS (Pro) as tabs — the three CSS/JS optimizations live on one
40 // page instead of three sidebar rows (FBS-83633).
41 'custom_panel' => 'CssJsPanel',
42 );
43 }
44
45 public function settings_schema(): array {
46 return array(
47 'minify_html' => array(
48 'type' => 'bool',
49 'default' => false,
50 'label' => __( 'Minify HTML', 'xspeed' ),
51 // Names the logged-out caveat up front: minification runs on the
52 // request that WRITES a cache entry, and should_cache() refuses
53 // logged-in requests — so "view source while logged in" shows
54 // un-minified HTML and reads as the feature being broken. (#2)
55 'description' => __( 'Strip whitespace and comments from HTML output, including inline <style> and <script> blocks. Safe on most themes. Applies to cached (logged-out) responses — view the page in a private window to see the result.', 'xspeed' ),
56 ),
57 'minify_css' => array(
58 'type' => 'bool',
59 'default' => false,
60 'label' => __( 'Minify CSS', 'xspeed' ),
61 'description' => __( 'Compress and rewrite enqueued local stylesheets. External CSS is left untouched.', 'xspeed' ),
62 ),
63 'minify_js' => array(
64 'type' => 'bool',
65 'default' => false,
66 'label' => __( 'Minify JavaScript', 'xspeed' ),
67 'description' => __( 'Compress enqueued local scripts. Disable if you hit script-loading conflicts on the frontend.', 'xspeed' ),
68 ),
69 'defer_js' => array(
70 'type' => 'bool',
71 'default' => false,
72 'label' => __( 'Defer JavaScript', 'xspeed' ),
73 'description' => __( 'Add defer="defer" to enqueued script tags so they execute after HTML parsing. jQuery + its hard dependencies are skipped automatically.', 'xspeed' ),
74 ),
75 'delay_js' => array(
76 'type' => 'bool',
77 'default' => false,
78 'label' => __( 'Delay JavaScript Until Interaction', 'xspeed' ),
79 'description' => __( 'Postpone script loading until the visitor scrolls, moves the mouse, taps, or presses a key. Drastically improves first paint on script-heavy pages; can break above-the-fold scripted UI — test before leaving on.', 'xspeed' ),
80 ),
81 'async_css' => array(
82 'type' => 'bool',
83 'default' => false,
84 'label' => __( 'Load CSS Asynchronously', 'xspeed' ),
85 'description' => __( 'Rewrite stylesheet link tags to load non-blocking via the print-then-all pattern. Pairs well with critical-CSS workflows; can cause a flash of unstyled content if the theme has no critical CSS.', 'xspeed' ),
86 ),
87 'remove_query_strings' => array(
88 'type' => 'bool',
89 'default' => false,
90 'label' => __( 'Remove Asset Query Strings', 'xspeed' ),
91 'description' => __( 'Strip ?ver=X.Y from enqueued CSS / JS URLs. Some CDN caches and proxies cache better when query strings are absent. Files under wp-content/uploads keep their version — page builders and consent plugins rewrite generated CSS there in place, and ?ver is what tells browsers to refetch it. Plugin, theme and core assets are still stripped: with Browser Cache on, an update reaches returning visitors only when their browser cache expires.', 'xspeed' ),
92 ),
93 'defer_js_excluded' => array(
94 'type' => 'list',
95 'default' => array( 'jquery-core', 'jquery-migrate' ),
96 'item_type' => 'string',
97 'label' => __( 'Defer / Delay Exclusions', 'xspeed' ),
98 'description' => __( 'Script handles OR URL substrings that skip defer + delay. Defaults exclude jQuery (most themes depend on it being available synchronously). One per line.', 'xspeed' ),
99 // Only relevant once defer OR delay is on — the exclusion list
100 // governs both. Uses the `any` (OR) dependency form. (FBS-82227)
101 'dependsOn' => array(
102 'any' => array(
103 array( 'field' => 'defer_js' ),
104 array( 'field' => 'delay_js' ),
105 ),
106 ),
107 ),
108 'delay_js_targets' => array(
109 'type' => 'list',
110 'default' => array(),
111 'item_type' => 'string',
112 'label' => __( 'Delay Only These Scripts', 'xspeed' ),
113 'description' => __( 'Script handles OR URL substrings. When non-empty, matching scripts are delayed, plus the known third-party tags xSpeed recognises on its own (analytics, tag managers, chat widgets, review embeds, error trackers) — so a heavy vendor script is postponed even when it is not listed here. Leave empty to delay all scripts (minus the exclusions above). Handles are the more reliable selector — a URL substring has to match the script\'s original URL, and minification rewrites that to a hashed cache path. One per line.', 'xspeed' ),
114 'dependsOn' => array( 'field' => 'delay_js' ),
115 ),
116 'delay_js_timeout' => array(
117 'type' => 'int',
118 'default' => 8000,
119 'min' => 0,
120 'max' => 60000,
121 'label' => __( 'Delay Failsafe Timeout (ms)', 'xspeed' ),
122 'unit' => 'ms',
123 'description' => __( 'Load delayed scripts automatically after this many milliseconds when the visitor never interacts. Set to 0 for interaction-only, with no timer: a timer that fires inside a lab tool\'s measurement window loads the "delayed" scripts anyway and inflates the reported TTI. Keep a non-zero value if a delayed script must eventually run for visitors who never scroll, tap, or type.', 'xspeed' ),
124 'dependsOn' => array( 'field' => 'delay_js' ),
125 ),
126 'combine_css' => array(
127 'type' => 'bool',
128 'default' => false,
129 'label' => __( 'Combine CSS Files', 'xspeed' ),
130 'description' => __( 'Concatenate enqueued local stylesheets into a single file (with @import and url(…) paths resolved). External CSS is left alone. Pairs poorly with HTTP/2 push — only enable on HTTP/1.1 hosts.', 'xspeed' ),
131 ),
132 'combine_js' => array(
133 'type' => 'bool',
134 'default' => false,
135 'label' => __( 'Combine JavaScript Files', 'xspeed' ),
136 'description' => __( 'Concatenate enqueued local scripts into a single file. External scripts + scripts marked async / deferred are left alone. Disable if you hit dependency-order issues; the combiner respects WordPress enqueue order but inline scripts attached via wp_add_inline_script can shift behavior.', 'xspeed' ),
137 ),
138 );
139 }
140
141 /**
142 * 1.1.0: drain minify_html / minify_css / minify_js from the legacy
143 * `xspeed_options` blob into this module's per-module option, then
144 * delete the keys from the legacy blob so duplicate sources can't
145 * re-appear. Idempotent — re-running is a no-op once the keys are
146 * gone from xspeed_options.
147 */
148 public function migrations(): array {
149 return array(
150 '1.1.0' => static function ( array $opts ): array {
151 $legacy = get_option( 'xspeed_options', array() );
152 if ( ! is_array( $legacy ) ) {
153 return $opts;
154 }
155 $dirty = false;
156 foreach ( array( 'minify_html', 'minify_css', 'minify_js' ) as $key ) {
157 if ( array_key_exists( $key, $legacy ) ) {
158 $opts[ $key ] = (bool) $legacy[ $key ];
159 unset( $legacy[ $key ] );
160 $dirty = true;
161 }
162 }
163 if ( $dirty ) {
164 update_option( 'xspeed_options', $legacy );
165 }
166 return $opts;
167 },
168 );
169 }
170
171 /**
172 * Conflict declarations. Detected automatically by Conflict_Registry,
173 * but listing them here keeps the module self-documenting.
174 */
175 public function conflicts(): array {
176 return array(
177 array(
178 'plugin' => 'autoptimize/autoptimize.php',
179 'feature' => 'minify.html',
180 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_REFUSE,
181 'reason' => 'Autoptimize is active and handles minification.',
182 ),
183 array(
184 'plugin' => 'wp-rocket/wp-rocket.php',
185 'feature' => 'minify.html',
186 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_REFUSE,
187 'reason' => 'WP Rocket already handles minification.',
188 ),
189 );
190 }
191
192 public function cli_commands(): array {
193 return array(
194 array(
195 'name' => 'xspeed minify',
196 'callback' => array( $this, 'cli_handler' ),
197 'shortdesc' => 'Inspect or purge xSpeed minify cache.',
198 'ai_hint' => 'Which CSS/JS optimizations are active (minify, combine, defer, delay, async)? Use for questions about render-blocking resources, unminified assets in PageSpeed, or when JavaScript broke after enabling optimizations.',
199 'synopsis' => array(
200 array(
201 'type' => 'positional',
202 'name' => 'action',
203 'options' => array( 'status', 'purge' ),
204 'optional' => false,
205 ),
206 ),
207 ),
208 );
209 }
210
211 /**
212 * On boot:
213 * 1. Seed our per-module option from the legacy blob if neither
214 * our option nor the migration has run yet (covers the
215 * already-installed-before-this-module-shipped path).
216 * 2. Instantiate the v1 Minifier engine; it now reads from
217 * Settings_Manager::get('minify') via its updated read path.
218 */
219 public function boot(): void {
220 /*
221 * Deferred to `init`: both calls below read this module's settings,
222 * which builds settings_schema(), whose labels go through __().
223 * boot() runs on `plugins_loaded`, before `after_setup_theme` — the
224 * earliest point WordPress 6.7+ considers safe to translate — so doing
225 * it here fires _load_textdomain_just_in_time on every request and
226 * resolves those labels against an unloaded domain.
227 *
228 * Every filter LegacyMinifier registers fires after `init`, so running
229 * one hook later is equivalent.
230 */
231 add_action( 'init', array( $this, 'boot_on_init' ) );
232 }
233
234 /**
235 * The real boot body — see boot() for why it runs on `init`.
236 */
237 public function boot_on_init(): void {
238 $this->seed_from_legacy_if_needed();
239 new LegacyMinifier();
240 }
241
242 public function activate(): void {
243 // Plugin activation hits all modules. Same seed logic — safe to
244 // run more than once.
245 $this->seed_from_legacy_if_needed();
246 }
247
248 /**
249 * Say so when HTML minification is switched on but suppressed.
250 *
251 * `Minifier::skip_reason()` was consulted only by `wp xspeed minify status`
252 * — the dashboard read "on" while `minify_html()` returned its input
253 * untouched, so the feature looked broken rather than paused. A field
254 * report showed a live site with `minify_html: on` and 3,856 indented lines
255 * delivered, and nothing anywhere explaining the contradiction. (#2)
256 *
257 * @return array<int,array<string,mixed>>
258 */
259 public function ui_notices(): array {
260 $opts = $this->get_settings();
261 if ( empty( $opts['minify_html'] ) ) {
262 return array();
263 }
264
265 $reason = LegacyMinifier::skip_reason();
266 if ( '' === $reason ) {
267 return array();
268 }
269
270 // Two different causes, two different fixes — naming the wrong one
271 // sends the user hunting in the wrong file.
272 $body = 'wp_debug' === $reason
273 ? __( 'HTML minification is paused because WP_DEBUG is enabled in wp-config.php. Readable HTML is usually what you want while debugging, so xSpeed leaves the markup alone. Cached pages are served un-minified until WP_DEBUG is turned off.', 'xspeed' )
274 : __( 'HTML minification is paused because a plugin or theme is returning true from the xspeed_skip_minify filter. Cached pages are served un-minified until that filter stops suppressing it.', 'xspeed' );
275
276 return array(
277 array(
278 'tone' => 'info',
279 'title' => __( 'HTML minification is on but currently paused', 'xspeed' ),
280 'body' => $body,
281 ),
282 );
283 }
284
285 private function seed_from_legacy_if_needed(): void {
286 $existing = get_option( 'xspeed_module_minify', null );
287 if ( null !== $existing ) {
288 return;
289 }
290 $legacy = get_option( 'xspeed_options', array() );
291 if ( ! is_array( $legacy ) ) {
292 return;
293 }
294 $seed = array( '_version' => self::VERSION );
295 $dirty = false;
296 foreach ( array( 'minify_html', 'minify_css', 'minify_js' ) as $key ) {
297 if ( array_key_exists( $key, $legacy ) ) {
298 $seed[ $key ] = (bool) $legacy[ $key ];
299 unset( $legacy[ $key ] );
300 $dirty = true;
301 }
302 }
303 if ( $dirty ) {
304 update_option( 'xspeed_module_minify', $seed );
305 update_option( 'xspeed_options', $legacy );
306 }
307 }
308
309 public function cli_handler( array $args, array $assoc ): void {
310 $action = $args[0] ?? 'status';
311
312 if ( 'status' === $action ) {
313 $opts = Settings_Manager::get( self::SLUG );
314 // A bare "on" is a lie when the skip guard is active: the
315 // setting is stored, but Minifier::minify_html() returns its
316 // input untouched and the delivered HTML is unchanged. Say so
317 // on the same line, so the contradiction can never be read as
318 // "minify is broken".
319 $skip = \XSpeed\Minifier::skip_reason();
320 $html_state = $opts['minify_html'] ? 'on' : 'off';
321 if ( $opts['minify_html'] && '' !== $skip ) {
322 $html_state .= ( 'wp_debug' === $skip )
323 ? ' (NOT APPLIED — WP_DEBUG is enabled; set WP_DEBUG to false to minify HTML)'
324 : ' (NOT APPLIED — suppressed by the xspeed_skip_minify filter)';
325 }
326 \WP_CLI::log( sprintf( 'minify_html: %s', $html_state ) );
327 \WP_CLI::log( sprintf( 'minify_css : %s', $opts['minify_css'] ? 'on' : 'off' ) );
328 \WP_CLI::log( sprintf( 'minify_js : %s', $opts['minify_js'] ? 'on' : 'off' ) );
329 return;
330 }
331
332 if ( 'purge' === $action ) {
333 LegacyMinifier::purge_minified();
334 \WP_CLI::success( 'Minify cache purged.' );
335 return;
336 }
337
338 \WP_CLI::error( "Unknown action: $action" );
339 }
340
341 /**
342 * Minify has no master switch -- it is on when any of minify_html /
343 * minify_css / minify_js / defer_js / delay_js / async_css /
344 * remove_query_strings is set. (#363)
345 */
346 public function is_active(): ?bool {
347 return $this->any_bool_flag_on();
348 }
349 }
350