PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.3 1.3.2 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 All 29 releases
← All changes | includes/modules/Cache/CacheModule.php +820 -20 1.0.21.3.2 View file →
@@ -18,73 +18,237 @@
18 18 declare(strict_types=1);
19 19
20 20 namespace XSpeed\Modules\Cache;
21 21
22 +defined( 'ABSPATH' ) || exit;
23 +
22 24 use XSpeed\Module;
23 25 use XSpeed\Settings_Manager;
24 26
25 27 final class CacheModule extends Module {
26 28
29 + /**
30 + * Default Excluded Cookies.
31 + *
32 + * A constant for the same reason as DEFAULT_IGNORED_QUERY_PARAMS:
33 + * Cache::rewrite_block_lines() needs the list at boot, where building
34 + * the settings schema would translate its labels too early. The schema
35 + * and that fallback both read THIS, so they cannot drift.
36 + *
37 + * @var string[]
38 + */
39 + public const DEFAULT_EXCLUDED_COOKIES = array(
40 + 'comment_author',
41 + '~wordpress_[a-f0-9]+',
42 + 'wp-postpass',
43 + 'wordpress_no_cache',
44 + 'wordpress_logged_in',
45 + 'edd_items_in_cart',
46 + 'woocommerce_items_in_cart',
47 + 'fct_cart_hash',
48 + 'comment_',
49 + 'woocommerce_',
50 + 'wordpress',
51 + 'xf_',
52 + 'edd_',
53 + 'jetpack',
54 + 'yith_wcwl_session_',
55 + 'yith_wrvp_',
56 + 'wpsc_',
57 + 'ecwid',
58 + 'ec_',
59 + 'bookly',
60 + );
61 +
62 + /**
63 + * Default Ignored Query Parameters.
64 + *
65 + * A constant because Cache::sync_query_allowlist() needs this list at
66 + * boot, where building the settings schema would translate its labels
67 + * before WordPress allows it. Both the schema below and that boot-time
68 + * fallback read THIS, so the two cannot drift.
69 + *
70 + * @var string[]
71 + */
72 + public const DEFAULT_IGNORED_QUERY_PARAMS = array(
73 + '__s',
74 + '_ga',
75 + '_ke',
76 + '~[a-zA-Z0-9_-]+_sid',
77 + 'adgroupid',
78 + 'age-verified',
79 + 'ao_noptimize',
80 + 'campaignid',
81 + 'ck_subscriber_id',
82 + 'cn-reloaded',
83 + 'dclid',
84 + 'epik',
85 + 'fb_action_ids',
86 + 'fb_action_types',
87 + 'fb_source',
88 + 'fbclid',
89 + 'gclid',
90 + 'jobid',
91 + 'mc_cid',
92 + 'mc_eid',
93 + 'mkt_tok',
94 + 'msclkid',
95 + 'ref',
96 + // Twitter/X (`ref_src`, `ref_url`) and Facebook (`refid`)
97 + // decorations. Enumerated because param names match
98 + // whole-name: the bare `ref` above no longer absorbs them,
99 + // and a `ref*` glob would over-match `referrer` and
100 + // `refund_id`, which are page-selecting.
101 + 'ref_src',
102 + 'ref_url',
103 + 'refid',
104 + '~session_[a-zA-Z0-9_-]+_alive',
105 + 'sseid',
106 + 'sslid',
107 + 'usqp',
108 + '~utm_[a-zA-Z0-9_-]+',
109 + );
110 +
111 +
27 112 public const SLUG = 'cache';
28 113 public const TIER = self::TIER_FREE;
29 114 public const VERSION = '1.0.0';
30 115
116 + /**
117 + * Default cache lifetime in hours (7 days).
118 + *
119 + * Named so the readers that need a fallback share ONE value with the
120 + * schema below. Three of them carried their own hardcoded `?? 24`, which
121 + * silently became a stale copy the moment the default moved. They are
122 + * unreachable today — Settings_Manager::get() always merges defaults —
123 + * but an unreachable wrong number is still a trap for the next change.
124 + * (#284 B5)
125 + */
126 + public const DEFAULT_EXPIRY_HOURS = 24 * 7;
127 +
31 128 public function ui_metadata(): array {
32 129 return array(
33 - 'label' => 'Cache',
130 + 'label' => __( 'Page Cache', 'xspeed' ),
34 131 'icon' => 'Database',
35 - 'description' => 'Page caching for non-logged-in visitors.',
132 + 'description' => __( 'Page caching for non-logged-in visitors.', 'xspeed' ),
36 133 );
37 134 }
38 135
136 + /**
137 + * @inheritDoc
138 + *
139 + * Nothing exempt. Purging only ever touches xSpeed's own cache, so on an
140 + * occupied site the setting is inert either way — but a host installing xSpeed
141 + * on a user's behalf should leave nothing switched on that the user did not
142 + * ask for, and "inert today" is a weak reason to make an exception.
143 + */
144 + public function conflict_safe_exempt(): array {
145 + return array();
146 + }
147 +
39 148 public function settings_schema(): array {
40 149 return array(
41 150 'cache_expiry' => array(
42 151 'type' => 'int',
43 - 'default' => 24,
152 + // Matches the wizard's Balanced preset, which is what a fresh
153 + // install starts on — a shorter module default meant the two
154 + // disagreed about what "default" means. (#284)
155 + 'default' => self::DEFAULT_EXPIRY_HOURS,
44 156 'min' => 1,
45 157 'max' => 720,
46 - 'label' => 'Cache Expiry (hours)',
47 - 'description' => 'How long cached pages live before regenerating. 1 to 720 hours (30 days).',
158 + 'label' => __( 'Cache Expiry (hours)', 'xspeed' ),
159 + 'unit' => 'hours',
160 + 'description' => __( 'How long cached pages live before regenerating. 1 to 720 hours (30 days).', 'xspeed' ),
48 161 ),
49 162 'excluded_urls' => array(
50 163 'type' => 'list',
51 - 'default' => array(),
164 + // Comprehensive LiteSpeed / WP Rocket-parity default URL
165 + // exclusions (FBS-82181). Plain text = "contains", glob via
166 + // * ? [ ], or a `~` prefix for raw regex (e.g. ~wp-.*\.php).
167 + 'default' => array(
168 + '/wp-admin/',
169 + '/wp-json/',
170 + '/xmlrpc.php',
171 + '~wp-.*\.php',
172 + '/feed/',
173 + 'index.php',
174 + '~sitemap(_index)?\.xml',
175 + // Bare (no trailing slash) so "contains" matches both
176 + // /cart and /cart/items — WooCommerce serves both forms.
177 + '/cart',
178 + '/checkout',
179 + '/my-account',
180 + 'ao_noptirocket',
181 + 'ao_speedup_cachebuster',
182 + 'removed_item',
183 + '/wc-api',
184 + '/edd-api',
185 + '/wp-login',
186 + ),
52 187 'item_type' => 'string',
53 - 'label' => 'Excluded URLs',
54 - 'description' => 'One path per line. Plain text matches anywhere in the URL (e.g. /cart). Use glob syntax for anchored matches: /cart/* matches /cart/items but not /foo/cart/bar; *.pdf matches PDFs.',
188 + 'label' => __( 'Excluded URLs', 'xspeed' ),
189 + 'description' => __( 'One pattern per line. Plain text matches anywhere in the URL (e.g. /cart). Use glob for anchored matches (/cart/* matches /cart/items but not /foo/cart/bar; *.pdf matches PDFs). Prefix with ~ for a raw regex (e.g. ~wp-.*\.php).', 'xspeed' ),
55 190 ),
56 191 'excluded_cookies' => array(
57 192 'type' => 'list',
58 - 'default' => array(),
193 + // Cookies that signal a logged-in / transactional visitor
194 + // whose response must not be served from a shared cache.
195 + // `~` prefix = raw regex (e.g. ~wordpress_[a-f0-9]+). (FBS-82181)
196 + 'default' => self::DEFAULT_EXCLUDED_COOKIES,
59 197 'item_type' => 'string',
60 - 'label' => 'Excluded Cookies',
61 - 'description' => 'Skip cache for any visitor whose request carries a cookie whose NAME matches one of these patterns. Glob supported (comment_author_*, woocommerce_*). One per line.',
198 + 'label' => __( 'Excluded Cookies', 'xspeed' ),
199 + 'description' => __( 'Skip cache for any visitor whose request carries a cookie whose NAME matches one of these patterns. Plain text = "contains"; glob (woocommerce_*) and ~regex (~wordpress_[a-f0-9]+) supported. One per line.', 'xspeed' ),
62 200 ),
63 201 'bypass_user_agents' => array(
64 202 'type' => 'list',
65 203 'default' => array(),
66 204 'item_type' => 'string',
67 - 'label' => 'Bypass User Agents',
68 - 'description' => 'Substring match against the visitor User-Agent. Matched UAs bypass cache (useful for screenshot bots, internal previews, monitoring). One per line.',
205 + 'label' => __( 'Bypass User Agents', 'xspeed' ),
206 + 'description' => __( 'Substring match against the visitor User-Agent. Matched UAs bypass cache (useful for screenshot bots, internal previews, monitoring). Glob + ~regex supported. One per line.', 'xspeed' ),
69 207 ),
70 208 'ignored_query_params' => array(
71 209 'type' => 'list',
72 - 'default' => array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid' ),
210 + // Analytics / ad / session query keys stripped before the
211 + // cache key is computed, so /post?utm_source=x and /post
212 + // share one entry. `~` prefix = raw regex. (FBS-82181)
213 + // Matched whole-name, so every entry here means the param
214 + // it names and nothing that merely contains it.
215 + 'default' => self::DEFAULT_IGNORED_QUERY_PARAMS,
73 216 'item_type' => 'string',
74 - 'label' => 'Ignored Query Parameters',
75 - 'description' => 'Query keys removed from the URL before computing the cache key, so /post?utm_source=x and /post share a cache entry. Defaults cover the common analytics + ad params. One per line.',
217 + 'label' => __( 'Ignored Query Parameters', 'xspeed' ),
218 + 'description' => __( 'Query keys removed from the URL before computing the cache key, so /post?utm_source=x and /post share a cache entry. Defaults cover the common analytics + ad + session params. Each entry matches a whole param name — plain text is an exact name, and glob (utm_*) or ~regex are anchored too, so "ref" does not also match "preference". One per line.', 'xspeed' ),
76 219 ),
220 + 'purge_on_upgrade' => array(
221 + 'type' => 'bool',
222 + 'default' => true,
223 + 'label' => __( 'Purge After Updates', 'xspeed' ),
224 + 'description' => __( 'Clear the page cache when a plugin, theme or WordPress core is updated. Cached HTML is produced by the code being replaced, so leaving it in place serves pre-update markup — and links to minified assets that no longer exist — until the cache expires. Translation updates are ignored, since a language pack changes no markup a cached page depends on. Updates to xSpeed itself always purge, regardless of this setting.', 'xspeed' ),
225 + ),
77 226 'mobile_separate' => array(
78 227 'type' => 'bool',
79 228 'default' => false,
80 - 'label' => 'Separate Mobile Cache',
81 - 'description' => 'Keep mobile and desktop responses in separate cache buckets. Turn on for AMP, mobile-specific themes (WPtouch / Jetpack mobile theme), or any setup that serves different HTML by device.',
229 + 'label' => __( 'Separate Mobile Cache', 'xspeed' ),
230 + 'description' => __( 'Keep mobile and desktop responses in separate cache buckets. Turn on for AMP, mobile-specific themes (WPtouch / Jetpack mobile theme), or any setup that serves different HTML by device.', 'xspeed' ),
82 231 ),
83 232 );
84 233 }
85 234
86 235 /**
236 + * `mobile_separate_review` lives outside the schema: migration sets it
237 + * (bool) when a source plugin had "separate mobile cache" on, so the
238 + * dashboard can prompt the user to re-enable it deliberately instead of
239 + * silently importing it (which would kill the device-blind static fast
240 + * path). Without preserving it here, the first schema-driven cache save
241 + * would rebuild the option from the schema alone and drop the flag before
242 + * the user ever saw the prompt. (FBS-83145)
243 + *
244 + * @return string[]
245 + */
246 + public function preserved_keys(): array {
247 + return array( 'mobile_separate_review' );
248 + }
249 +
250 + /**
87 251 * Seed per-module option from the legacy xspeed_options blob if we
88 252 * haven't done so yet. Idempotent — once xspeed_module_cache exists
89 253 * or the legacy keys are gone, this is a no-op. Runs on both boot
90 254 * and activate so installs on every code path are covered.
@@ -90,14 +254,79 @@
90 254 * and activate so installs on every code path are covered.
91 255 */
92 256 public function boot(): void {
93 257 $this->seed_from_legacy_if_needed();
258 +
259 + // Keep every mobile_separate-dependent artifact (the drop-in's
260 + // `.mobile-separate` flag, the device-blind server rewrite, and the
261 + // device-keyed caches) in lockstep with the setting — on boot, and
262 + // whenever the cache settings are saved. The drop-in can't read WP
263 + // options, so it reads the sidecar marker Cache maintains here.
264 + \XSpeed\Cache::reconcile_mobile_separate();
265 + // Both write paths matter. On a fresh install `xspeed_module_cache`
266 + // does not exist yet, so core's update_option() delegates to
267 + // add_option() and fires `add_option_…` INSTEAD of
268 + // `update_option_…`. Hooking only the latter meant the very first
269 + // save of Cache Expiry never re-baked the drop-in: the panel and the
270 + // DB read the new value while the drop-in kept enforcing the old
271 + // one, and re-saving the same value could not recover it because
272 + // update_option() short-circuits on an unchanged value (#251).
273 + $xspeed_resync_cache_artifacts = static function () {
274 + \XSpeed\Cache::reconcile_mobile_separate();
275 + // Re-bake the cookie / user-agent exclusion rules into the
276 + // drop-in. It runs before WordPress loads and so carries a
277 + // COPY of those rules, substituted at install time — and
278 + // auto_heal() deliberately only reinstalls when the file is
279 + // missing, foreign, or an older version, none of which a
280 + // settings change makes true. Without this, adding an
281 + // excluded cookie left the drop-in serving the shared
282 + // anonymous page to exactly the visitors it excluded, until
283 + // the next plugin upgrade happened to reinstall it.
284 + //
285 + // ONLY when page caching is actually on, for the same reason
286 + // refresh_rewrite_if_installed() below refuses to write a
287 + // block that isn't there: re-baking is maintenance of an
288 + // artifact the user opted into, never a way to acquire one.
289 + // Re-baking unconditionally reached past our own module — a
290 + // site that had declined our page cache got the drop-in
291 + // installed anyway on the next Cache Expiry save, and the
292 + // following toggle(false) then removed it. auto_heal() has
293 + // always gated on this flag; this path simply never did.
294 + // (#251)
295 + //
296 + // Through toggle() rather than install_dropin() so the re-bake
297 + // gets the same ownership check, lock and rollback as every
298 + // other page-cache write. A drop-in that turned out not to be
299 + // ours between the save and now is refused here too.
300 + $xspeed_options = get_option( 'xspeed_options', array() );
301 + if ( ! empty( $xspeed_options['cache_enabled'] ) ) {
302 + \XSpeed\Cache::toggle( true );
303 + }
304 + // Same staleness applies to the .htaccess block, which is
305 + // written to disk from the same generator. Refresh it only
306 + // when a block is already installed — writing one here would
307 + // enable the static path on a site that never opted in.
308 + \XSpeed\Cache::refresh_rewrite_if_installed();
309 + };
310 + add_action( 'update_option_xspeed_module_cache', $xspeed_resync_cache_artifacts );
311 + add_action( 'add_option_xspeed_module_cache', $xspeed_resync_cache_artifacts );
312 +
313 + // Time-driven collection of expired entries and superseded minified
314 + // assets. Scheduled here as well as in activate() because a site that
315 + // upgrades into this version never runs the activation hook again.
316 + add_action( \XSpeed\Cache_GC::CRON_HOOK, array( \XSpeed\Cache_GC::class, 'run' ) );
317 + \XSpeed\Cache_GC::ensure_scheduled();
94 318 }
95 319
96 320 public function activate(): void {
97 321 $this->seed_from_legacy_if_needed();
322 + \XSpeed\Cache_GC::ensure_scheduled();
98 323 }
99 324
325 + public function deactivate(): void {
326 + \XSpeed\Cache_GC::unschedule();
327 + }
328 +
100 329 private function seed_from_legacy_if_needed(): void {
101 330 if ( null !== get_option( 'xspeed_module_cache', null ) ) {
102 331 return;
103 332 }
@@ -125,24 +354,486 @@
125 354
126 355 public function cli_commands(): array {
127 356 return array(
128 357 array(
358 + 'name' => 'xspeed optimize',
359 + 'callback' => array( $this, 'cli_optimize' ),
360 + 'shortdesc' => 'Measure, apply the recommended settings one at a time, verify the page still works after each, and report what changed. Use --dry-run to see the plan without touching anything.',
361 + 'synopsis' => array(
362 + array(
363 + 'type' => 'assoc',
364 + 'name' => 'aggressiveness',
365 + 'description' => 'safe (removals + server-side only), standard (default), or aggressive (includes settings known to break some themes).',
366 + 'optional' => true,
367 + 'options' => array( 'safe', 'standard', 'aggressive' ),
368 + ),
369 + array(
370 + 'type' => 'flag',
371 + 'name' => 'dry-run',
372 + 'description' => 'Show the plan and stop. Changes nothing.',
373 + 'optional' => true,
374 + ),
375 + array(
376 + 'type' => 'assoc',
377 + 'name' => 'budget',
378 + 'description' => 'Seconds to spend before stopping between steps. Default 120.',
379 + 'optional' => true,
380 + ),
381 + array(
382 + 'type' => 'assoc',
383 + 'name' => 'measure-score',
384 + 'description' => 'auto (default) measures when the stored score is stale and after changes land; never reuses the stored score; always measures even for a dry run.',
385 + 'optional' => true,
386 + 'options' => array( 'auto', 'never', 'always' ),
387 + ),
388 + ),
389 + ),
390 + array(
391 + 'name' => 'xspeed purge',
392 + 'callback' => array( $this, 'cli_purge' ),
393 + 'shortdesc' => 'Clear every cache xSpeed manages — page and static files, REST responses, minified assets, the object cache and the configured edge — and report per store what was cleared, what was skipped and why. Use --type to clear just one.',
394 + 'ai_hint' => 'Clear the cache after a change is live on the server but visitors still see the old version. Purges everything by default; --type=page for the local HTML only, --type=cloudflare for the edge only. Exits non-zero if a store that IS configured refused to purge, so its output can be trusted rather than assumed.',
395 + 'synopsis' => array(
396 + array(
397 + 'type' => 'assoc',
398 + 'name' => 'type',
399 + 'description' => 'What to clear: all (default), page, object, cloudflare, cdn — or a group name (edge). Comma-separate to clear several.',
400 + 'optional' => true,
401 + ),
402 + array(
403 + 'type' => 'assoc',
404 + 'name' => 'cause',
405 + 'description' => 'Label recorded in the purge log, so `wp xspeed cache purge-log` can tell this run apart from a click. Default "CLI".',
406 + 'optional' => true,
407 + ),
408 + array(
409 + 'type' => 'assoc',
410 + 'name' => 'format',
411 + 'description' => 'table (default, one line per store) or json (the full report, for scripts).',
412 + 'optional' => true,
413 + 'options' => array( 'table', 'json' ),
414 + ),
415 + ),
416 + ),
417 + array(
129 418 'name' => 'xspeed cache',
130 419 'callback' => array( $this, 'cli_handler' ),
131 - 'shortdesc' => 'Inspect Cache module settings (purge / toggle use the dedicated commands).',
420 + 'shortdesc' => 'Inspect the Cache module: `status` (settings), `inventory` (which pages are cached, and how old), `size` (where the disk usage goes), `purge-log` (what cleared the cache, when and why), `purge-url <url>` to clear one page, `recheck-rewrite` to re-run the static-rewrite probe, or `nginx-config` to print the unified nginx server-block for pasting into a vhost. To clear the whole site use `wp xspeed purge`.',
132 421 'synopsis' => array(
133 422 array(
134 423 'type' => 'positional',
135 424 'name' => 'action',
136 - 'options' => array( 'status' ),
425 + 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite', 'nginx-config' ),
137 426 'optional' => true,
138 427 ),
428 + array(
429 + 'type' => 'positional',
430 + 'name' => 'url',
431 + 'optional' => true,
432 + ),
433 + array(
434 + 'type' => 'assoc',
435 + 'name' => 'limit',
436 + 'description' => 'Rows to print for inventory / purge-log. Default 20.',
437 + 'optional' => true,
438 + ),
439 + array(
440 + 'type' => 'assoc',
441 + 'name' => 'cause',
442 + 'description' => 'Label recorded in the purge log for purge-url. Default "CLI".',
443 + 'optional' => true,
444 + ),
445 + array(
446 + 'type' => 'assoc',
447 + 'name' => 'server',
448 + 'description' => 'Server type to assume for nginx-config, skipping detection. Detection needs SERVER_SOFTWARE, which the command line does not have; an undetectable host is assumed to be nginx anyway, so this is for stating it outright — or for the case detection is positively wrong, such as nginx in front of Apache.',
449 + 'options' => array( 'nginx', 'apache', 'litespeed' ),
450 + 'optional' => true,
451 + ),
139 452 ),
140 453 ),
141 454 );
142 455 }
143 456
457 + /**
458 + * `wp xspeed optimize` — run the autopilot.
459 + *
460 + * Prints what it DID, not what it hoped to do: applied steps, reverted
461 + * steps with the reason they were undone, and the problems it could not
462 + * touch. A run that changes nothing prints that plainly rather than a
463 + * success banner.
464 + *
465 + * @param array<int,string> $args Positional args (unused).
466 + * @param array<string,string> $assoc Flags.
467 + */
468 + public function cli_optimize( array $args, array $assoc ): void {
469 + $result = \XSpeed\Optimize_Runner::run(
470 + array(
471 + 'aggressiveness' => (string) ( $assoc['aggressiveness'] ?? 'standard' ),
472 + 'dry_run' => isset( $assoc['dry-run'] ),
473 + 'budget_seconds' => isset( $assoc['budget'] ) ? (int) $assoc['budget'] : 120,
474 + 'measure_score' => (string) ( $assoc['measure-score'] ?? 'auto' ),
475 + )
476 + );
477 +
478 + if ( is_wp_error( $result ) ) {
479 + \WP_CLI::error( $result->get_error_message() );
480 + return;
481 + }
482 +
483 + if ( ! empty( $result['dry_run'] ) ) {
484 + // The summary carries the score AND its age. Printing the plan
485 + // without it left the one number a reader wants off the only
486 + // command they run before deciding to apply anything.
487 + if ( isset( $result['message'] ) ) {
488 + \WP_CLI::log( (string) $result['message'] );
489 + }
490 + \WP_CLI::log( 'Plan (' . count( $result['plan'] ) . ' steps, nothing applied):' );
491 + foreach ( $result['plan'] as $step ) {
492 + \WP_CLI::log( ' - ' . $step['change'] . ' [' . $step['tier'] . ']' );
493 + }
494 + foreach ( $result['skipped'] as $row ) {
495 + \WP_CLI::log( ' skipped: ' . $row['id'] . ' — ' . $row['why'] );
496 + }
497 + return;
498 + }
499 +
500 + if ( isset( $result['message'] ) ) {
501 + \WP_CLI::success( (string) $result['message'] );
502 + }
503 +
504 + foreach ( $result['applied'] as $row ) {
505 + \WP_CLI::log( ' ✓ ' . $row['change'] );
506 + }
507 + foreach ( $result['reverted'] as $row ) {
508 + \WP_CLI::warning( 'Undone: ' . $row['id'] . ' — ' . $row['why'] );
509 + }
510 + foreach ( $result['unfixable'] as $row ) {
511 + \WP_CLI::log( ' ! ' . $row['issue'] . ( '' !== $row['fix'] ? ' — ' . $row['fix'] : '' ) );
512 + }
513 +
514 + if ( ! empty( $result['applied'] ) ) {
515 + // "applied and verified" was more than the checks earn. They read
516 + // HTML in PHP and cannot run JavaScript, so this line was telling
517 + // someone the site was fine when the only honest claim is that
518 + // nothing in the markup looked broken.
519 + \WP_CLI::success( count( $result['applied'] ) . ' change(s) applied; HTML checks passed.' );
520 +
521 + if ( ! empty( $result['verify_urls'] ) ) {
522 + \WP_CLI::log( '' );
523 + \WP_CLI::log( 'Now open these and check they render, with no console errors:' );
524 + foreach ( $result['verify_urls'] as $u ) {
525 + \WP_CLI::log( ' ' . $u );
526 + }
527 + }
528 + }
529 + }
530 +
531 + /**
532 + * `wp xspeed purge` — clear every cache xSpeed owns, in one call.
533 + *
534 + * Reports per store rather than printing a success banner, because the
535 + * banner was the bug: a site whose Cloudflare token had lost its purge
536 + * permission saw "cache cleared" and kept serving stale HTML from the
537 + * edge. What is skipped is as much of the answer as what is cleared, so
538 + * every skip prints its reason.
539 + *
540 + * Exit code follows the same distinction. A store that is not configured
541 + * has nothing to clear and does not fail the run — otherwise every CI
542 + * pipeline on a site without Redis goes red for a purge that did exactly
543 + * what it should. A store that IS configured and refused is a failure.
544 + *
545 + * There is deliberately no `--url`: WP-CLI reserves that flag for
546 + * multisite site selection and consumes it before a handler ever sees it.
547 + * Clearing one page is `wp xspeed cache purge-url <url>`.
548 + *
549 + * @param array<int,string> $args Positional args (unused).
550 + * @param array<string,string> $assoc Flags.
551 + */
552 + public function cli_purge( array $args, array $assoc ): void {
553 + unset( $args );
554 +
555 + $requested = array_values(
556 + array_filter(
557 + array_map( 'trim', explode( ',', (string) ( $assoc['type'] ?? 'all' ) ) )
558 + )
559 + );
560 + if ( ! $requested ) {
561 + $requested = array( 'all' );
562 + }
563 +
564 + $accepted = \XSpeed\Purge_Runner::accepted_types();
565 + $unknown = array_diff( $requested, $accepted );
566 + if ( $unknown ) {
567 + // Refuse before purging anything: a typo in --type must not
568 + // quietly clear a DIFFERENT store than the one named.
569 + \WP_CLI::error(
570 + sprintf(
571 + 'Unknown purge type: %s. Expected one of: %s',
572 + implode( ', ', $unknown ),
573 + implode( ', ', $accepted )
574 + )
575 + );
576 + return;
577 + }
578 +
579 + $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
580 + $report = \XSpeed\Purge_Runner::run( $requested, $cause );
581 +
582 + if ( 'json' === ( $assoc['format'] ?? 'table' ) ) {
583 + // The report goes to STDOUT alone so `... --format=json | jq` works;
584 + // the failure message goes to STDERR via ::error, which is also
585 + // what produces the non-zero exit.
586 + \WP_CLI::line( (string) wp_json_encode( $report ) );
587 + if ( ! $report['ok'] ) {
588 + \WP_CLI::error( 'One or more cache stores failed to purge; see the report above.' );
589 + }
590 + return;
591 + }
592 +
593 + $cleared = 0;
594 + $skipped = 0;
595 + $failed = 0;
596 + foreach ( $report['types'] as $row ) {
597 + switch ( $row['status'] ) {
598 + case \XSpeed\Purge_Runner::CLEARED:
599 + ++$cleared;
600 + \WP_CLI::log( sprintf( ' cleared %s%s', $row['label'], self::purge_amount( $row ) ) );
601 + break;
602 + case \XSpeed\Purge_Runner::FAILED:
603 + ++$failed;
604 + \WP_CLI::log( sprintf( ' FAILED %s — %s', $row['label'], $row['reason'] ) );
605 + break;
606 + default:
607 + ++$skipped;
608 + \WP_CLI::log( sprintf( ' skipped %s — %s', $row['label'], $row['reason'] ) );
609 + }
610 + }
611 +
612 + if ( $failed ) {
613 + \WP_CLI::error(
614 + sprintf(
615 + '%d of %d cache store(s) failed to purge; %d cleared, %d skipped.',
616 + $failed,
617 + count( $report['types'] ),
618 + $cleared,
619 + $skipped
620 + )
621 + );
622 + return;
623 + }
624 +
625 + if ( ! $cleared ) {
626 + // Not a success banner: nothing was purged, and saying so is the
627 + // honest answer for a --type nobody has configured.
628 + \WP_CLI::log( sprintf( 'Nothing to purge — %d store(s) skipped.', $skipped ) );
629 + return;
630 + }
631 +
632 + \WP_CLI::success( sprintf( 'Purged %d cache store(s); %d skipped.', $cleared, $skipped ) );
633 + }
634 +
635 + /**
636 + * The " — 42 entries (1.3 MB)" tail on a cleared line.
637 + *
638 + * Entries and bytes are both optional: a Redis FLUSHALL reports neither,
639 + * and printing "0 entries" for it would read as an empty cache rather
640 + * than an uncountable one.
641 + *
642 + * @param array{entries:int|null,bytes:int|null} $row Report row.
643 + */
644 + private static function purge_amount( array $row ): string {
645 + $parts = array();
646 + if ( null !== $row['entries'] ) {
647 + $parts[] = sprintf( '%d entr%s', $row['entries'], 1 === (int) $row['entries'] ? 'y' : 'ies' );
648 + }
649 + if ( null !== $row['bytes'] && $row['bytes'] > 0 ) {
650 + $parts[] = size_format( $row['bytes'], 1 );
651 + }
652 +
653 + return $parts ? ' — ' . implode( ', ', $parts ) : '';
654 + }
655 +
144 656 public function cli_handler( array $args, array $assoc ): void {
657 + $action = isset( $args[0] ) ? (string) $args[0] : 'status';
658 + $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20;
659 +
660 + /*
661 + * Print the unified nginx server-block so an installer, provisioning
662 + * script, or another plugin can fetch it non-interactively and write
663 + * it into a vhost. Previously this was only reachable via
664 + * `wp eval 'echo \XSpeed\Cache::full_nginx_server_block();'`, which
665 + * is not a supported surface (and is unavailable over MCP, where
666 + * run_command dispatches these same callbacks).
667 + *
668 + * Output discipline matters here: the config goes to STDOUT with
669 + * nothing else, so `wp xspeed cache nginx-config > site.conf` yields a
670 + * pasteable file. Every diagnostic goes to STDERR via WP_CLI::warning
671 + * / ::error, and a non-nginx host or an empty block exits non-zero so
672 + * a script can branch on it rather than writing an empty file.
673 + *
674 + * --server exists because detection cannot work here. WP-CLI runs
675 + * without SERVER_SOFTWARE, so Server::type() falls back to the value
676 + * a previous web request cached — and on a site provisioned entirely
677 + * over WP-CLI there is no such value, leaving `unknown` on a genuine
678 + * nginx host. Rather than guess (a loopback request is the one thing
679 + * least likely to work mid-provisioning), let the caller state it:
680 + * the script writing to /etc/nginx/ already knows the answer.
681 + * Without the flag nothing changes, so a script sweeping a mixed
682 + * fleet still gets its non-zero exit on Apache.
683 + *
684 + * It pins Server::type() rather than being passed down, because the
685 + * decision is re-made at every level: full_nginx_server_block(),
686 + * Cache::nginx_snippet(), and each module's own nginx_directives()
687 + * all ask independently. Threading an argument through would leave
688 + * the deeper gates still detecting, and the command would emit a
689 + * config missing its cache rewrite — worse than refusing outright.
690 + */
691 + if ( 'nginx-config' === $action ) {
692 + /*
693 + * Scoped to this one generation pass, not the request. Under
694 + * real WP-CLI the process ends here either way, but the same
695 + * callback runs over MCP, where several commands share one PHP
696 + * request — a pin left in place made the NEXT command report
697 + * this host as nginx too.
698 + */
699 + $pin = null;
700 + $assume = null;
701 +
702 + if ( isset( $assoc['server'] ) ) {
703 + $assume = strtolower( trim( (string) $assoc['server'] ) );
704 + } elseif ( \XSpeed\Server::UNKNOWN === \XSpeed\Server::type() ) {
705 + /*
706 + * Nothing to detect from, and the action names the server:
707 + * `nginx-config` is the request, so absence of evidence
708 + * defers to it. Positive evidence to the contrary still
709 + * wins — an Apache or LiteSpeed host is told it needs no
710 + * nginx block at all, which is the answer that helps.
711 + */
712 + $assume = \XSpeed\Server::NGINX;
713 +
714 + /*
715 + * Only where warnings have somewhere else to go. Real WP-CLI
716 + * sends them to STDERR, leaving the config clean on STDOUT.
717 + * The MCP shim has ONE buffer for both, so warning there
718 + * would prepend "Warning: …" to the config itself and hand
719 + * the caller a file nginx refuses. The constant is the
720 + * discriminator: real WP-CLI defines it, the shim defines
721 + * only the class.
722 + */
723 + if ( defined( 'WP_CLI' ) && \WP_CLI ) {
724 + \WP_CLI::warning(
725 + 'Could not detect the web server — no recognisable SERVER_SOFTWARE, and no web request has cached one yet. Assuming nginx, which is what this command generates. Pass --server= to state it explicitly, or load any page once to settle detection.'
726 + );
727 + }
728 + }
729 +
730 + if ( null !== $assume ) {
731 + $pinned = $assume;
732 + $pin = static function () use ( $pinned ) {
733 + return $pinned;
734 + };
735 + add_filter( 'xspeed_server_type', $pin );
736 + }
737 +
738 + $block = \XSpeed\Cache::full_nginx_server_block();
739 + $server = \XSpeed\Server::type();
740 +
741 + if ( null !== $pin ) {
742 + remove_filter( 'xspeed_server_type', $pin );
743 + }
744 +
745 + if ( ! is_string( $block ) || '' === trim( $block ) ) {
746 + /*
747 + * $server cannot be UNKNOWN here: an undetectable host was
748 + * already assumed to be nginx above, so anything left is a
749 + * server we positively identified — and telling an Apache or
750 + * LiteSpeed operator that .htaccess already covers them is
751 + * more useful than handing them a block to paste nowhere.
752 + */
753 + if ( \XSpeed\Server::NGINX !== $server ) {
754 + \WP_CLI::error(
755 + sprintf(
756 + 'No nginx server-block to print — this site is running on %s. On Apache and LiteSpeed xSpeed writes its rules to .htaccess automatically.',
757 + $server
758 + )
759 + );
760 + return;
761 + }
762 + \WP_CLI::error( 'No nginx directives to print — page caching and every module that contributes directives are currently disabled.' );
763 + return;
764 + }
765 +
766 + // STDOUT only: no WP_CLI::log() prefixing, so redirection gives a
767 + // clean file. WP_CLI::line() writes the raw string.
768 + \WP_CLI::line( rtrim( $block, "\n" ) );
769 + return;
770 + }
771 +
772 + /*
773 + * Force a fresh static-rewrite probe. The result is cached for five
774 + * minutes and nothing invalidated it, so after fixing an nginx config
775 + * there was no way to re-check — the "configure your server" banner
776 + * just stayed up. (FBS-84012)
777 + */
778 + if ( 'recheck-rewrite' === $action ) {
779 + // Qualify the raw probe against known config refusals before
780 + // reporting. The probe fetches its OWN file from the static tree,
781 + // which succeeds even when no real page is served that way — so
782 + // an unqualified `active` reported "the web server is serving
783 + // cache hits directly" on sites whose every page returned
784 + // HIT (php). See Cache::qualify_rewrite_probe().
785 + $probe = \XSpeed\Cache::qualify_rewrite_probe( \XSpeed\Cache::recheck_static_rewrite() );
786 + $blocked = '' !== (string) $probe['block_reason'];
787 +
788 + if ( $probe['active'] ) {
789 + \WP_CLI::success( 'Static rewrite is active — the web server is serving cache hits directly.' );
790 + return;
791 + }
792 + if ( $blocked ) {
793 + \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) $probe['reason'] ) );
794 + return;
795 + }
796 + if ( $probe['inconclusive'] ) {
797 + \WP_CLI::warning( sprintf( 'Could not verify the static rewrite: %s', (string) $probe['reason'] ) );
798 + \WP_CLI::log( 'This is a probe failure, not proof that your server config is wrong.' );
799 + return;
800 + }
801 + \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) ( $probe['reason'] ?: 'unknown' ) ) );
802 + return;
803 + }
804 +
805 + if ( 'purge-url' === $action ) {
806 + $url = isset( $args[1] ) ? trim( (string) $args[1] ) : '';
807 + if ( '' === $url ) {
808 + \WP_CLI::error( 'Usage: wp xspeed cache purge-url <url-or-path>' );
809 + return;
810 + }
811 + $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
812 + $removed = \XSpeed\Cache::purge_url( $url, $cause );
813 + if ( $removed > 0 ) {
814 + \WP_CLI::success( sprintf( 'Purged %d cache file(s) for %s', $removed, $url ) );
815 + } else {
816 + \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) );
817 + }
818 + return;
819 + }
820 +
821 + if ( 'inventory' === $action ) {
822 + $this->cli_inventory( $limit );
823 + return;
824 + }
825 +
826 + if ( 'size' === $action ) {
827 + $this->cli_size();
828 + return;
829 + }
830 +
831 + if ( 'purge-log' === $action ) {
832 + $this->cli_purge_log( $limit );
833 + return;
834 + }
835 +
145 836 $opts = Settings_Manager::get( self::SLUG );
146 837 \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' );
147 838 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
148 839 foreach ( $opts['excluded_urls'] as $u ) {
@@ -147,6 +838,115 @@
147 838 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
148 839 foreach ( $opts['excluded_urls'] as $u ) {
149 840 \WP_CLI::log( ' - ' . $u );
150 841 }
842 + }
843 +
844 + /** `wp xspeed cache inventory [--limit=N]` — which pages are cached, and how old. */
845 + private function cli_inventory( int $limit ): void {
846 + $data = \XSpeed\Cache_Inventory::entries( $limit );
847 +
848 + if ( empty( $data['entries'] ) ) {
849 + \WP_CLI::log( 'Cache is empty — no cached pages on disk.' );
850 + return;
851 + }
852 +
853 + \WP_CLI::log( sprintf( '%d cached page(s); showing %d.', $data['total'], count( $data['entries'] ) ) );
854 + if ( ! empty( $data['capped'] ) ) {
855 + \WP_CLI::warning( sprintf( 'Scan stopped at %d files — the list is a recent sample, not the whole cache.', \XSpeed\Cache_Inventory::SCAN_CAP ) );
856 + }
857 + foreach ( $data['entries'] as $entry ) {
858 + \WP_CLI::log(
859 + sprintf(
860 + ' %-58s %8s %s [%s]',
861 + null === $entry['url'] ? '(url unknown: ' . $entry['key'] . ')' : $entry['url'],
862 + size_format( (int) $entry['bytes'] ),
863 + $this->relative_age( (int) $entry['age'] ),
864 + implode( '+', (array) $entry['stored_in'] )
865 + )
866 + );
867 + }
868 + }
869 +
870 + /** `wp xspeed cache size` — where the cache's disk usage goes. */
871 + private function cli_size(): void {
872 + $data = \XSpeed\Cache_Inventory::size_breakdown();
873 +
874 + \WP_CLI::log( sprintf( 'Total %s across %d file(s).', size_format( (int) $data['total_bytes'] ), (int) $data['total_files'] ) );
875 + foreach ( $data['buckets'] as $bucket ) {
876 + if ( 0 === (int) $bucket['files'] ) {
877 + continue;
878 + }
879 + \WP_CLI::log( sprintf( ' %-32s %10s %d file(s)', $bucket['label'], size_format( (int) $bucket['bytes'] ), (int) $bucket['files'] ) );
880 + }
881 + if ( (int) $data['compressed_bytes'] > 0 ) {
882 + \WP_CLI::log( sprintf( 'Precompressed on disk: %s (pages without a precompressed copy are compressed by the web server at request time).', size_format( (int) $data['compressed_bytes'] ) ) );
883 + }
884 + }
885 +
886 + /** `wp xspeed cache purge-log [--limit=N]` — what cleared the cache, when, and why. */
887 + private function cli_purge_log( int $limit ): void {
888 + $data = \XSpeed\Cache_Inventory::purge_log( $limit );
889 +
890 + if ( empty( $data['events'] ) ) {
891 + \WP_CLI::log( 'No purge events recorded yet.' );
892 + return;
893 + }
894 + foreach ( $data['events'] as $event ) {
895 + \WP_CLI::log( sprintf( ' %s %s', $this->relative_age( max( 0, time() - (int) $event['ts'] ) ), $event['message'] ) );
896 + }
897 + }
898 +
899 + /** Compact "4h ago" for CLI columns. */
900 + private function relative_age( int $seconds ): string {
901 + if ( $seconds < 60 ) {
902 + return $seconds . 's ago';
903 + }
904 + if ( $seconds < 3600 ) {
905 + return (int) floor( $seconds / 60 ) . 'm ago';
906 + }
907 + if ( $seconds < 86400 ) {
908 + return (int) floor( $seconds / 3600 ) . 'h ago';
909 + }
910 + return (int) floor( $seconds / 86400 ) . 'd ago';
911 + }
912 +
913 + /**
914 + * Static-rewrite directives for the unified nginx server-block
915 + * snippet. Returns null when cache is disabled — there's no rewrite
916 + * to install in that state. Delegates to \XSpeed\Cache::nginx_snippet()
917 + * which already produces nginx-detection-gated output.
918 + */
919 + public function nginx_directives(): ?string {
920 + $opts = get_option( 'xspeed_options', array() );
921 + if ( empty( $opts['cache_enabled'] ) ) {
922 + return null;
923 + }
924 + return \XSpeed\Cache::nginx_snippet();
925 + }
926 +
927 + /**
928 + * Page caching's master switch is `cache_enabled` in the GLOBAL
929 + * `xspeed_options`, not a per-module `enabled` key -- Cache::toggle owns
930 + * it because flipping it rewrites .htaccess and wp-config.php. The base
931 + * implementation looks only at this module's own settings bag, so it
932 + * found nothing and reported null: the plugin's headline feature was
933 + * missing from its own "N on" count. (#363)
934 + */
935 + public function is_active(): ?bool {
936 + $opts = get_option( 'xspeed_options', array() );
937 + return ! empty( $opts['cache_enabled'] );
938 + }
939 +
940 + /**
941 + * No reason shown: page caching has a single master switch, so the pill
942 + * already says everything an (i) would. The switch lives on the Overview
943 + * rather than on this page, but that is a "where is the control" question
944 + * the panel itself should answer, not a reason to explain the verdict.
945 + *
946 + * The (i) is reserved for modules whose on/off is genuinely non-obvious
947 + * -- counted from several flags, or from state outside the settings.
948 + */
949 + public function active_reason(): ?string {
950 + return null;
151 951 }
152 952 }