PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 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 All 33 releases
← All changes | includes/modules/Cache/CacheModule.php +1026 -21 1.0.2 → 1.3.7 View file →
@@ -18,73 +18,356 @@
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 + /**
112 + * Click and campaign IDs added to the defaults in 1.3.6.
113 + *
114 + * Each is unique per click and never changes the page, yet each one
115 + * bypassed the page cache and, with xSpeed Pro, was a new CSS entry to
116 + * build: Google Merchant's `srsltid` and Ads' `gad_*`/`gbraid`/`wbraid`,
117 + * GA4's cross-domain `_gl`, TikTok, X, Instagram, Yandex, HubSpot email
118 + * and LinkedIn IDs. Kept as their own list so the upgrade can add exactly
119 + * these to a saved list without re-adding anything a site removed.
120 + *
121 + * @var string[]
122 + */
123 + public const TRACKING_PARAMS_1_3_6 = array(
124 + '_gl',
125 + '_hsenc',
126 + '_hsmi',
127 + 'gad_campaignid',
128 + 'gad_source',
129 + 'gbraid',
130 + 'igshid',
131 + 'li_fat_id',
132 + 'srsltid',
133 + 'ttclid',
134 + 'twclid',
135 + 'wbraid',
136 + 'yclid',
137 + );
138 +
139 +
140 + /**
141 + * The shipped default list: the base list plus later additions, sorted.
142 + *
143 + * @return string[]
144 + */
145 + public static function default_ignored_query_params(): array {
146 + $all = array_values( array_unique( array_merge( self::DEFAULT_IGNORED_QUERY_PARAMS, self::TRACKING_PARAMS_1_3_6 ) ) );
147 + sort( $all );
148 + return $all;
149 + }
150 +
27 151 public const SLUG = 'cache';
28 152 public const TIER = self::TIER_FREE;
29 153 public const VERSION = '1.0.0';
30 154
155 + /**
156 + * Default cache lifetime in hours (7 days).
157 + *
158 + * Named so the readers that need a fallback share ONE value with the
159 + * schema below. Three of them carried their own hardcoded `?? 24`, which
160 + * silently became a stale copy the moment the default moved. They are
161 + * unreachable today — Settings_Manager::get() always merges defaults —
162 + * but an unreachable wrong number is still a trap for the next change.
163 + * (#284 B5)
164 + */
165 + public const DEFAULT_EXPIRY_HOURS = 24 * 7;
166 +
31 167 public function ui_metadata(): array {
32 168 return array(
33 - 'label' => 'Cache',
169 + 'label' => __( 'Page Cache', 'xspeed' ),
34 170 'icon' => 'Database',
35 - 'description' => 'Page caching for non-logged-in visitors.',
171 + 'description' => __( 'Saves each page as a file and serves it to logged-out visitors.', 'xspeed' ),
172 + 'group' => 'cache',
36 173 );
37 174 }
38 175
176 + /**
177 + * @inheritDoc
178 + *
179 + * Nothing exempt. Purging only ever touches xSpeed's own cache, so on an
180 + * occupied site the setting is inert either way — but a host installing xSpeed
181 + * on a user's behalf should leave nothing switched on that the user did not
182 + * ask for, and "inert today" is a weak reason to make an exception.
183 + */
184 + public function conflict_safe_exempt(): array {
185 + return array();
186 + }
187 +
39 188 public function settings_schema(): array {
40 - return array(
189 + $schema = array(
41 190 'cache_expiry' => array(
42 191 'type' => 'int',
43 - 'default' => 24,
192 + // Matches the wizard's Balanced preset, which is what a fresh
193 + // install starts on — a shorter module default meant the two
194 + // disagreed about what "default" means. (#284)
195 + 'default' => self::DEFAULT_EXPIRY_HOURS,
44 196 'min' => 1,
45 197 'max' => 720,
46 - 'label' => 'Cache Expiry (hours)',
47 - 'description' => 'How long cached pages live before regenerating. 1 to 720 hours (30 days).',
198 + 'label' => __( 'Cache expiry (hours)', 'xspeed' ),
199 + 'unit' => 'hours',
200 + 'description' => __( 'How long a cached page is kept before xSpeed builds it again. 1 to 720 hours (30 days).', 'xspeed' ),
48 201 ),
49 202 'excluded_urls' => array(
50 203 'type' => 'list',
51 - 'default' => array(),
204 + // Comprehensive LiteSpeed / WP Rocket-parity default URL
205 + // exclusions (FBS-82181). Plain text = "contains", glob via
206 + // * ? [ ], or a `~` prefix for raw regex (e.g. ~wp-.*\.php).
207 + 'default' => array(
208 + '/wp-admin/',
209 + '/wp-json/',
210 + '/xmlrpc.php',
211 + '~wp-.*\.php',
212 + '/feed/',
213 + 'index.php',
214 + // `sitemaps?` — SEOPress generates sitemaps.xml (plural).
215 + '~sitemaps?(_index)?\.xml',
216 + '/robots.txt',
217 + // Bare (no trailing slash) so "contains" matches both
218 + // /cart and /cart/items — WooCommerce serves both forms.
219 + '/cart',
220 + '/checkout',
221 + '/my-account',
222 + 'ao_noptirocket',
223 + 'ao_speedup_cachebuster',
224 + 'removed_item',
225 + '/wc-api',
226 + '/edd-api',
227 + '/wp-login',
228 + ),
52 229 '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.',
230 + 'label' => __( 'Excluded URLs', 'xspeed' ),
231 + 'description' => __( 'Pages whose URL matches a line here are never cached. Plain text matches anywhere (/cart). A pattern with * matches from the start of the path: /cart/* matches /cart/items but not /shop/cart/items. ~ starts a regex.', 'xspeed' ),
55 232 ),
56 233 'excluded_cookies' => array(
57 234 'type' => 'list',
58 - 'default' => array(),
235 + // Cookies that signal a logged-in / transactional visitor
236 + // whose response must not be served from a shared cache.
237 + // `~` prefix = raw regex (e.g. ~wordpress_[a-f0-9]+). (FBS-82181)
238 + 'default' => self::DEFAULT_EXCLUDED_COOKIES,
59 239 '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.',
240 + 'label' => __( 'Excluded cookies', 'xspeed' ),
241 + 'description' => __( 'Visitors with a cookie whose name matches a line here always get a fresh page. One per line; * and ~regex work.', 'xspeed' ),
62 242 ),
63 243 'bypass_user_agents' => array(
64 244 'type' => 'list',
65 245 'default' => array(),
66 246 '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.',
247 + 'label' => __( 'Excluded browsers and bots', 'xspeed' ),
248 + 'description' => __( 'Visitors whose user agent contains a line here always get a fresh page. Useful for screenshot bots and uptime monitors.', 'xspeed' ),
69 249 ),
70 250 'ignored_query_params' => array(
71 251 'type' => 'list',
72 - 'default' => array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid' ),
252 + // Analytics / ad / session query keys stripped before the
253 + // cache key is computed, so /post?utm_source=x and /post
254 + // share one entry. `~` prefix = raw regex. (FBS-82181)
255 + // Matched whole-name, so every entry here means the param
256 + // it names and nothing that merely contains it.
257 + 'default' => self::default_ignored_query_params(),
73 258 '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.',
259 + 'label' => __( 'Ignored query parameters', 'xspeed' ),
260 + 'advanced' => true,
261 + 'description' => __( 'URL parameters to ignore, so /post?utm_source=x gets the same cached page as /post. Each line matches a whole parameter name.', 'xspeed' ),
76 262 ),
263 + 'purge_on_upgrade' => array(
264 + 'type' => 'bool',
265 + 'default' => true,
266 + 'label' => __( 'Clear cache after updates', 'xspeed' ),
267 + 'description' => __( 'Clear the page cache when a plugin, theme or WordPress is updated, so visitors never get old pages with broken asset links.', 'xspeed' ),
268 + ),
77 269 'mobile_separate' => array(
78 270 'type' => 'bool',
79 271 '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.',
272 + 'label' => __( 'Separate mobile cache', 'xspeed' ),
273 + 'description' => __( 'Keep a separate cached copy for phones. Turn on only if your theme or plugins show different pages on mobile.', 'xspeed' ),
82 274 ),
275 + 'edge_provider' => array(
276 + 'type' => 'enum',
277 + 'default' => 'auto',
278 + 'options' => array( 'auto', 'off', 'cloudflare', 'fastly', 'varnish', 'nginx', 'akamai', 'cloudfront', 'google', 'keycdn', 'bunny', 'sucuri', 'incapsula', 'generic', 'custom' ),
279 + 'option_labels' => array(
280 + 'auto' => 'Detect automatically',
281 + 'off' => 'Off — send nothing',
282 + 'cloudflare' => 'Cloudflare',
283 + 'varnish' => 'Varnish',
284 + 'nginx' => 'nginx proxy cache',
285 + 'cloudfront' => 'Amazon CloudFront',
286 + 'google' => 'Google Cloud CDN',
287 + 'keycdn' => 'KeyCDN',
288 + 'bunny' => 'Bunny',
289 + // These four cannot be presented as supported on the same
290 + // footing as the ones above. Vendor documentation either
291 + // does not establish that they honour what we send, or
292 + // establishes that they ignore origin cache headers until
293 + // the property is configured to respect them — Akamai
294 + // caches for a theoretically infinite time by default, and
295 + // Sucuri's default caching level ignores the headers
296 + // outright. Naming them without the caveat would promise a
297 + // protection the CDN is not currently giving.
298 + 'fastly' => 'Fastly (needs CDN configuration)',
299 + 'akamai' => 'Akamai (needs CDN configuration)',
300 + 'sucuri' => 'Sucuri (needs CDN configuration)',
301 + 'incapsula' => 'Imperva / Incapsula (needs CDN configuration)',
302 + 'generic' => 'Something else',
303 + 'custom' => 'Custom headers',
304 + ),
305 + 'label' => __( 'CDN or proxy in front', 'xspeed' ),
306 + 'description' => __( 'Tells your CDN or proxy not to store pages xSpeed does not cache. Leave on Detect automatically unless you know your provider.', 'xspeed' ),
307 + 'advanced' => true,
308 + 'info_title' => __( 'Cache in front of this site', 'xspeed' ),
309 + 'info' => __( 'Naming your provider narrows the headers to the one it reads. Detect automatically works it out per request and otherwise sends a set every cache ignores unless it understands it, so it is safe not to know. Run "wp xspeed cache edge" to see what was detected and what gets sent.', 'xspeed' )
310 + ),
311 + 'edge_custom_headers' => array(
312 + 'type' => 'list',
313 + 'default' => array(),
314 + 'item_type' => 'string',
315 + 'label' => __( 'Custom CDN headers', 'xspeed' ),
316 + 'advanced' => true,
317 + 'dependsOn' => array( 'field' => 'edge_provider', 'value' => 'custom' ),
318 + 'description' => __( 'One header per line, as Name: value, for example "Surrogate-Control: no-store". Lines starting with # are ignored.', 'xspeed' ),
319 + 'info_title' => __( 'Custom edge headers', 'xspeed' ),
320 + 'info' => __( 'These replace the headers xSpeed would have picked for your CDN. Two baselines are still added underneath: a Cache-Control, and "X-Accel-Expires: 0" for a page cache running in nginx on your own server. Name either one yourself and yours is used instead. Values containing $, % or a backslash are dropped — the same pairs go into nginx and Apache directives, where those cannot be escaped safely. Content-Length, Content-Encoding, Content-Type, Transfer-Encoding, Set-Cookie and Location are refused.', 'xspeed' )
321 + ),
83 322 );
323 +
324 + // LiteSpeed-only opt-in (#509): meaningless on any other server, so
325 + // the field only exists in the schema where it can act — elsewhere the
326 + // stored value survives via preserved_keys(). Inserted right after
327 + // mobile_separate, its sibling static-fast-path trade-off.
328 + if ( \XSpeed\Server::LITESPEED === \XSpeed\Server::type() ) {
329 + $litespeed = array(
330 + 'litespeed_static_rewrite' => array(
331 + 'type' => 'bool',
332 + 'default' => false,
333 + 'label' => __( 'LiteSpeed static fast path', 'xspeed' ),
334 + 'advanced' => true,
335 + 'description' => __( 'LiteSpeed serves cached pages without running PHP, which is faster on slow hosts. These visits are not counted in the dashboard hit ratio.', 'xspeed' ),
336 + ),
337 + );
338 + $pos = (int) array_search( 'mobile_separate', array_keys( $schema ), true ) + 1;
339 + $schema = array_slice( $schema, 0, $pos, true ) + $litespeed + array_slice( $schema, $pos, null, true );
340 + }
341 +
342 + return $schema;
84 343 }
85 344
86 345 /**
346 + * `mobile_separate_review` lives outside the schema: migration sets it
347 + * (bool) when a source plugin had "separate mobile cache" on, so the
348 + * dashboard can prompt the user to re-enable it deliberately instead of
349 + * silently importing it (which would kill the device-blind static fast
350 + * path). Without preserving it here, the first schema-driven cache save
351 + * would rebuild the option from the schema alone and drop the flag before
352 + * the user ever saw the prompt. (FBS-83145)
353 + *
354 + * @return string[]
355 + */
356 + public function preserved_keys(): array {
357 + $keys = array( 'mobile_separate_review' );
358 + // On non-LiteSpeed servers the litespeed_static_rewrite field is not
359 + // in the schema (see settings_schema()), so a schema-driven save
360 + // would silently drop a value chosen while the site ran LiteSpeed.
361 + // Preserve it so moving LiteSpeed → other → LiteSpeed keeps the
362 + // user's choice. On LiteSpeed itself the schema owns the key.
363 + if ( \XSpeed\Server::LITESPEED !== \XSpeed\Server::type() ) {
364 + $keys[] = 'litespeed_static_rewrite';
365 + }
366 + return $keys;
367 + }
368 +
369 + /**
87 370 * Seed per-module option from the legacy xspeed_options blob if we
88 371 * haven't done so yet. Idempotent — once xspeed_module_cache exists
89 372 * or the legacy keys are gone, this is a no-op. Runs on both boot
90 373 * and activate so installs on every code path are covered.
@@ -90,14 +373,79 @@
90 373 * and activate so installs on every code path are covered.
91 374 */
92 375 public function boot(): void {
93 376 $this->seed_from_legacy_if_needed();
377 +
378 + // Keep every mobile_separate-dependent artifact (the drop-in's
379 + // `.mobile-separate` flag, the device-blind server rewrite, and the
380 + // device-keyed caches) in lockstep with the setting — on boot, and
381 + // whenever the cache settings are saved. The drop-in can't read WP
382 + // options, so it reads the sidecar marker Cache maintains here.
383 + \XSpeed\Cache::reconcile_mobile_separate();
384 + // Both write paths matter. On a fresh install `xspeed_module_cache`
385 + // does not exist yet, so core's update_option() delegates to
386 + // add_option() and fires `add_option_…` INSTEAD of
387 + // `update_option_…`. Hooking only the latter meant the very first
388 + // save of Cache Expiry never re-baked the drop-in: the panel and the
389 + // DB read the new value while the drop-in kept enforcing the old
390 + // one, and re-saving the same value could not recover it because
391 + // update_option() short-circuits on an unchanged value (#251).
392 + $xspeed_resync_cache_artifacts = static function () {
393 + \XSpeed\Cache::reconcile_mobile_separate();
394 + // Re-bake the cookie / user-agent exclusion rules into the
395 + // drop-in. It runs before WordPress loads and so carries a
396 + // COPY of those rules, substituted at install time — and
397 + // auto_heal() deliberately only reinstalls when the file is
398 + // missing, foreign, or an older version, none of which a
399 + // settings change makes true. Without this, adding an
400 + // excluded cookie left the drop-in serving the shared
401 + // anonymous page to exactly the visitors it excluded, until
402 + // the next plugin upgrade happened to reinstall it.
403 + //
404 + // ONLY when page caching is actually on, for the same reason
405 + // refresh_rewrite_if_installed() below refuses to write a
406 + // block that isn't there: re-baking is maintenance of an
407 + // artifact the user opted into, never a way to acquire one.
408 + // Re-baking unconditionally reached past our own module — a
409 + // site that had declined our page cache got the drop-in
410 + // installed anyway on the next Cache Expiry save, and the
411 + // following toggle(false) then removed it. auto_heal() has
412 + // always gated on this flag; this path simply never did.
413 + // (#251)
414 + //
415 + // Through toggle() rather than install_dropin() so the re-bake
416 + // gets the same ownership check, lock and rollback as every
417 + // other page-cache write. A drop-in that turned out not to be
418 + // ours between the save and now is refused here too.
419 + $xspeed_options = get_option( 'xspeed_options', array() );
420 + if ( ! empty( $xspeed_options['cache_enabled'] ) ) {
421 + \XSpeed\Cache::toggle( true );
422 + }
423 + // Same staleness applies to the .htaccess block, which is
424 + // written to disk from the same generator. Refresh it only
425 + // when a block is already installed — writing one here would
426 + // enable the static path on a site that never opted in.
427 + \XSpeed\Cache::refresh_rewrite_if_installed();
428 + };
429 + add_action( 'update_option_xspeed_module_cache', $xspeed_resync_cache_artifacts );
430 + add_action( 'add_option_xspeed_module_cache', $xspeed_resync_cache_artifacts );
431 +
432 + // Time-driven collection of expired entries and superseded minified
433 + // assets. Scheduled here as well as in activate() because a site that
434 + // upgrades into this version never runs the activation hook again.
435 + add_action( \XSpeed\Cache_GC::CRON_HOOK, array( \XSpeed\Cache_GC::class, 'run' ) );
436 + \XSpeed\Cache_GC::ensure_scheduled();
94 437 }
95 438
96 439 public function activate(): void {
97 440 $this->seed_from_legacy_if_needed();
441 + \XSpeed\Cache_GC::ensure_scheduled();
98 442 }
99 443
444 + public function deactivate(): void {
445 + \XSpeed\Cache_GC::unschedule();
446 + }
447 +
100 448 private function seed_from_legacy_if_needed(): void {
101 449 if ( null !== get_option( 'xspeed_module_cache', null ) ) {
102 450 return;
103 451 }
@@ -125,24 +473,491 @@
125 473
126 474 public function cli_commands(): array {
127 475 return array(
128 476 array(
477 + 'name' => 'xspeed optimize',
478 + 'callback' => array( $this, 'cli_optimize' ),
479 + '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.',
480 + 'synopsis' => array(
481 + array(
482 + 'type' => 'assoc',
483 + 'name' => 'aggressiveness',
484 + 'description' => 'safe (removals + server-side only), standard (default), or aggressive (includes settings known to break some themes).',
485 + 'optional' => true,
486 + 'options' => array( 'safe', 'standard', 'aggressive' ),
487 + ),
488 + array(
489 + 'type' => 'flag',
490 + 'name' => 'dry-run',
491 + 'description' => 'Show the plan and stop. Changes nothing.',
492 + 'optional' => true,
493 + ),
494 + array(
495 + 'type' => 'assoc',
496 + 'name' => 'budget',
497 + 'description' => 'Seconds to spend before stopping between steps. Default 120.',
498 + 'optional' => true,
499 + ),
500 + array(
501 + 'type' => 'assoc',
502 + 'name' => 'measure-score',
503 + '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.',
504 + 'optional' => true,
505 + 'options' => array( 'auto', 'never', 'always' ),
506 + ),
507 + ),
508 + ),
509 + array(
510 + 'name' => 'xspeed purge',
511 + 'callback' => array( $this, 'cli_purge' ),
512 + '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.',
513 + '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.',
514 + 'synopsis' => array(
515 + array(
516 + 'type' => 'assoc',
517 + 'name' => 'type',
518 + 'description' => 'What to clear: all (default), page, object, cloudflare, cdn — or a group name (edge). Comma-separate to clear several.',
519 + 'optional' => true,
520 + ),
521 + array(
522 + 'type' => 'assoc',
523 + 'name' => 'cause',
524 + 'description' => 'Label recorded in the purge log, so `wp xspeed cache purge-log` can tell this run apart from a click. Default "CLI".',
525 + 'optional' => true,
526 + ),
527 + array(
528 + 'type' => 'assoc',
529 + 'name' => 'format',
530 + 'description' => 'table (default, one line per store) or json (the full report, for scripts).',
531 + 'optional' => true,
532 + 'options' => array( 'table', 'json' ),
533 + ),
534 + ),
535 + ),
536 + array(
129 537 'name' => 'xspeed cache',
130 538 'callback' => array( $this, 'cli_handler' ),
131 - 'shortdesc' => 'Inspect Cache module settings (purge / toggle use the dedicated commands).',
539 + '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, or `edge` to show which cache is in front of the site and what xSpeed tells it. To clear the whole site use `wp xspeed purge`.',
132 540 'synopsis' => array(
133 541 array(
134 542 'type' => 'positional',
135 543 'name' => 'action',
136 - 'options' => array( 'status' ),
544 + 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite', 'nginx-config', 'edge' ),
137 545 'optional' => true,
138 546 ),
547 + array(
548 + 'type' => 'positional',
549 + 'name' => 'url',
550 + 'optional' => true,
551 + ),
552 + array(
553 + 'type' => 'assoc',
554 + 'name' => 'limit',
555 + 'description' => 'Rows to print for inventory / purge-log. Default 20.',
556 + 'optional' => true,
557 + ),
558 + array(
559 + 'type' => 'assoc',
560 + 'name' => 'cause',
561 + 'description' => 'Label recorded in the purge log for purge-url. Default "CLI".',
562 + 'optional' => true,
563 + ),
564 + array(
565 + 'type' => 'assoc',
566 + 'name' => 'server',
567 + '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.',
568 + 'options' => array( 'nginx', 'apache', 'litespeed' ),
569 + 'optional' => true,
570 + ),
139 571 ),
140 572 ),
141 573 );
142 574 }
143 575
576 + /**
577 + * `wp xspeed optimize` — run the autopilot.
578 + *
579 + * Prints what it DID, not what it hoped to do: applied steps, reverted
580 + * steps with the reason they were undone, and the problems it could not
581 + * touch. A run that changes nothing prints that plainly rather than a
582 + * success banner.
583 + *
584 + * @param array<int,string> $args Positional args (unused).
585 + * @param array<string,string> $assoc Flags.
586 + */
587 + public function cli_optimize( array $args, array $assoc ): void {
588 + $result = \XSpeed\Optimize_Runner::run(
589 + array(
590 + 'aggressiveness' => (string) ( $assoc['aggressiveness'] ?? 'standard' ),
591 + 'dry_run' => isset( $assoc['dry-run'] ),
592 + 'budget_seconds' => isset( $assoc['budget'] ) ? (int) $assoc['budget'] : 120,
593 + 'measure_score' => (string) ( $assoc['measure-score'] ?? 'auto' ),
594 + )
595 + );
596 +
597 + if ( is_wp_error( $result ) ) {
598 + \WP_CLI::error( $result->get_error_message() );
599 + return;
600 + }
601 +
602 + if ( ! empty( $result['dry_run'] ) ) {
603 + // The summary carries the score AND its age. Printing the plan
604 + // without it left the one number a reader wants off the only
605 + // command they run before deciding to apply anything.
606 + if ( isset( $result['message'] ) ) {
607 + \WP_CLI::log( (string) $result['message'] );
608 + }
609 + \WP_CLI::log( 'Plan (' . count( $result['plan'] ) . ' steps, nothing applied):' );
610 + foreach ( $result['plan'] as $step ) {
611 + \WP_CLI::log( ' - ' . $step['change'] . ' [' . $step['tier'] . ']' );
612 + }
613 + foreach ( $result['skipped'] as $row ) {
614 + \WP_CLI::log( ' skipped: ' . $row['id'] . ' — ' . $row['why'] );
615 + }
616 + return;
617 + }
618 +
619 + if ( isset( $result['message'] ) ) {
620 + \WP_CLI::success( (string) $result['message'] );
621 + }
622 +
623 + foreach ( $result['applied'] as $row ) {
624 + \WP_CLI::log( ' ✓ ' . $row['change'] );
625 + }
626 + foreach ( $result['reverted'] as $row ) {
627 + \WP_CLI::warning( 'Undone: ' . $row['id'] . ' — ' . $row['why'] );
628 + }
629 + foreach ( $result['unfixable'] as $row ) {
630 + \WP_CLI::log( ' ! ' . $row['issue'] . ( '' !== $row['fix'] ? ' — ' . $row['fix'] : '' ) );
631 + }
632 +
633 + if ( ! empty( $result['applied'] ) ) {
634 + // "applied and verified" was more than the checks earn. They read
635 + // HTML in PHP and cannot run JavaScript, so this line was telling
636 + // someone the site was fine when the only honest claim is that
637 + // nothing in the markup looked broken.
638 + \WP_CLI::success( count( $result['applied'] ) . ' change(s) applied; HTML checks passed.' );
639 +
640 + if ( ! empty( $result['verify_urls'] ) ) {
641 + \WP_CLI::log( '' );
642 + \WP_CLI::log( 'Now open these and check they render, with no console errors:' );
643 + foreach ( $result['verify_urls'] as $u ) {
644 + \WP_CLI::log( ' ' . $u );
645 + }
646 + }
647 + }
648 + }
649 +
650 + /**
651 + * `wp xspeed purge` — clear every cache xSpeed owns, in one call.
652 + *
653 + * Reports per store rather than printing a success banner, because the
654 + * banner was the bug: a site whose Cloudflare token had lost its purge
655 + * permission saw "cache cleared" and kept serving stale HTML from the
656 + * edge. What is skipped is as much of the answer as what is cleared, so
657 + * every skip prints its reason.
658 + *
659 + * Exit code follows the same distinction. A store that is not configured
660 + * has nothing to clear and does not fail the run — otherwise every CI
661 + * pipeline on a site without Redis goes red for a purge that did exactly
662 + * what it should. A store that IS configured and refused is a failure.
663 + *
664 + * There is deliberately no `--url`: WP-CLI reserves that flag for
665 + * multisite site selection and consumes it before a handler ever sees it.
666 + * Clearing one page is `wp xspeed cache purge-url <url>`.
667 + *
668 + * @param array<int,string> $args Positional args (unused).
669 + * @param array<string,string> $assoc Flags.
670 + */
671 + public function cli_purge( array $args, array $assoc ): void {
672 + unset( $args );
673 +
674 + $requested = array_values(
675 + array_filter(
676 + array_map( 'trim', explode( ',', (string) ( $assoc['type'] ?? 'all' ) ) )
677 + )
678 + );
679 + if ( ! $requested ) {
680 + $requested = array( 'all' );
681 + }
682 +
683 + $accepted = \XSpeed\Purge_Runner::accepted_types();
684 + $unknown = array_diff( $requested, $accepted );
685 + if ( $unknown ) {
686 + // Refuse before purging anything: a typo in --type must not
687 + // quietly clear a DIFFERENT store than the one named.
688 + \WP_CLI::error(
689 + sprintf(
690 + 'Unknown purge type: %s. Expected one of: %s',
691 + implode( ', ', $unknown ),
692 + implode( ', ', $accepted )
693 + )
694 + );
695 + return;
696 + }
697 +
698 + $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
699 + $report = \XSpeed\Purge_Runner::run( $requested, $cause );
700 +
701 + if ( 'json' === ( $assoc['format'] ?? 'table' ) ) {
702 + // The report goes to STDOUT alone so `... --format=json | jq` works;
703 + // the failure message goes to STDERR via ::error, which is also
704 + // what produces the non-zero exit.
705 + \WP_CLI::line( (string) wp_json_encode( $report ) );
706 + if ( ! $report['ok'] ) {
707 + \WP_CLI::error( 'One or more cache stores failed to purge; see the report above.' );
708 + }
709 + return;
710 + }
711 +
712 + $cleared = 0;
713 + $skipped = 0;
714 + $failed = 0;
715 + foreach ( $report['types'] as $row ) {
716 + switch ( $row['status'] ) {
717 + case \XSpeed\Purge_Runner::CLEARED:
718 + ++$cleared;
719 + \WP_CLI::log( sprintf( ' cleared %s%s', $row['label'], self::purge_amount( $row ) ) );
720 + break;
721 + case \XSpeed\Purge_Runner::FAILED:
722 + ++$failed;
723 + \WP_CLI::log( sprintf( ' FAILED %s — %s', $row['label'], $row['reason'] ) );
724 + break;
725 + default:
726 + ++$skipped;
727 + \WP_CLI::log( sprintf( ' skipped %s — %s', $row['label'], $row['reason'] ) );
728 + }
729 + }
730 +
731 + if ( $failed ) {
732 + \WP_CLI::error(
733 + sprintf(
734 + '%d of %d cache store(s) failed to purge; %d cleared, %d skipped.',
735 + $failed,
736 + count( $report['types'] ),
737 + $cleared,
738 + $skipped
739 + )
740 + );
741 + return;
742 + }
743 +
744 + if ( ! $cleared ) {
745 + // Not a success banner: nothing was purged, and saying so is the
746 + // honest answer for a --type nobody has configured.
747 + \WP_CLI::log( sprintf( 'Nothing to purge — %d store(s) skipped.', $skipped ) );
748 + return;
749 + }
750 +
751 + \WP_CLI::success( sprintf( 'Purged %d cache store(s); %d skipped.', $cleared, $skipped ) );
752 + }
753 +
754 + /**
755 + * The " — 42 entries (1.3 MB)" tail on a cleared line.
756 + *
757 + * Entries and bytes are both optional: a Redis FLUSHALL reports neither,
758 + * and printing "0 entries" for it would read as an empty cache rather
759 + * than an uncountable one.
760 + *
761 + * @param array{entries:int|null,bytes:int|null} $row Report row.
762 + */
763 + private static function purge_amount( array $row ): string {
764 + $parts = array();
765 + if ( null !== $row['entries'] ) {
766 + $parts[] = sprintf( '%d entr%s', $row['entries'], 1 === (int) $row['entries'] ? 'y' : 'ies' );
767 + }
768 + if ( null !== $row['bytes'] && $row['bytes'] > 0 ) {
769 + $parts[] = size_format( $row['bytes'], 1 );
770 + }
771 +
772 + return $parts ? ' — ' . implode( ', ', $parts ) : '';
773 + }
774 +
144 775 public function cli_handler( array $args, array $assoc ): void {
776 + $action = isset( $args[0] ) ? (string) $args[0] : 'status';
777 + $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20;
778 +
779 + /*
780 + * Print the unified nginx server-block so an installer, provisioning
781 + * script, or another plugin can fetch it non-interactively and write
782 + * it into a vhost. Previously this was only reachable via
783 + * `wp eval 'echo \XSpeed\Cache::full_nginx_server_block();'`, which
784 + * is not a supported surface (and is unavailable over MCP, where
785 + * run_command dispatches these same callbacks).
786 + *
787 + * Output discipline matters here: the config goes to STDOUT with
788 + * nothing else, so `wp xspeed cache nginx-config > site.conf` yields a
789 + * pasteable file. Every diagnostic goes to STDERR via WP_CLI::warning
790 + * / ::error, and a non-nginx host or an empty block exits non-zero so
791 + * a script can branch on it rather than writing an empty file.
792 + *
793 + * --server exists because detection cannot work here. WP-CLI runs
794 + * without SERVER_SOFTWARE, so Server::type() falls back to the value
795 + * a previous web request cached — and on a site provisioned entirely
796 + * over WP-CLI there is no such value, leaving `unknown` on a genuine
797 + * nginx host. Rather than guess (a loopback request is the one thing
798 + * least likely to work mid-provisioning), let the caller state it:
799 + * the script writing to /etc/nginx/ already knows the answer.
800 + * Without the flag nothing changes, so a script sweeping a mixed
801 + * fleet still gets its non-zero exit on Apache.
802 + *
803 + * It pins Server::type() rather than being passed down, because the
804 + * decision is re-made at every level: full_nginx_server_block(),
805 + * Cache::nginx_snippet(), and each module's own nginx_directives()
806 + * all ask independently. Threading an argument through would leave
807 + * the deeper gates still detecting, and the command would emit a
808 + * config missing its cache rewrite — worse than refusing outright.
809 + */
810 + if ( 'nginx-config' === $action ) {
811 + /*
812 + * Scoped to this one generation pass, not the request. Under
813 + * real WP-CLI the process ends here either way, but the same
814 + * callback runs over MCP, where several commands share one PHP
815 + * request — a pin left in place made the NEXT command report
816 + * this host as nginx too.
817 + */
818 + $pin = null;
819 + $assume = null;
820 +
821 + if ( isset( $assoc['server'] ) ) {
822 + $assume = strtolower( trim( (string) $assoc['server'] ) );
823 + } elseif ( \XSpeed\Server::UNKNOWN === \XSpeed\Server::type() ) {
824 + /*
825 + * Nothing to detect from, and the action names the server:
826 + * `nginx-config` is the request, so absence of evidence
827 + * defers to it. Positive evidence to the contrary still
828 + * wins — an Apache or LiteSpeed host is told it needs no
829 + * nginx block at all, which is the answer that helps.
830 + */
831 + $assume = \XSpeed\Server::NGINX;
832 +
833 + /*
834 + * Only where warnings have somewhere else to go. Real WP-CLI
835 + * sends them to STDERR, leaving the config clean on STDOUT.
836 + * The MCP shim has ONE buffer for both, so warning there
837 + * would prepend "Warning: …" to the config itself and hand
838 + * the caller a file nginx refuses. The constant is the
839 + * discriminator: real WP-CLI defines it, the shim defines
840 + * only the class.
841 + */
842 + if ( defined( 'WP_CLI' ) && \WP_CLI ) {
843 + \WP_CLI::warning(
844 + '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.'
845 + );
846 + }
847 + }
848 +
849 + if ( null !== $assume ) {
850 + $pinned = $assume;
851 + $pin = static function () use ( $pinned ) {
852 + return $pinned;
853 + };
854 + add_filter( 'xspeed_server_type', $pin );
855 + }
856 +
857 + $block = \XSpeed\Cache::full_nginx_server_block();
858 + $server = \XSpeed\Server::type();
859 +
860 + if ( null !== $pin ) {
861 + remove_filter( 'xspeed_server_type', $pin );
862 + }
863 +
864 + if ( ! is_string( $block ) || '' === trim( $block ) ) {
865 + /*
866 + * $server cannot be UNKNOWN here: an undetectable host was
867 + * already assumed to be nginx above, so anything left is a
868 + * server we positively identified — and telling an Apache or
869 + * LiteSpeed operator that .htaccess already covers them is
870 + * more useful than handing them a block to paste nowhere.
871 + */
872 + if ( \XSpeed\Server::NGINX !== $server ) {
873 + \WP_CLI::error(
874 + sprintf(
875 + 'No nginx server-block to print — this site is running on %s. On Apache and LiteSpeed xSpeed writes its rules to .htaccess automatically.',
876 + $server
877 + )
878 + );
879 + return;
880 + }
881 + \WP_CLI::error( 'No nginx directives to print — page caching and every module that contributes directives are currently disabled.' );
882 + return;
883 + }
884 +
885 + // STDOUT only: no WP_CLI::log() prefixing, so redirection gives a
886 + // clean file. WP_CLI::line() writes the raw string.
887 + \WP_CLI::line( rtrim( $block, "\n" ) );
888 + return;
889 + }
890 +
891 + /*
892 + * Force a fresh static-rewrite probe. The result is cached for five
893 + * minutes and nothing invalidated it, so after fixing an nginx config
894 + * there was no way to re-check — the "configure your server" banner
895 + * just stayed up. (FBS-84012)
896 + */
897 + if ( 'recheck-rewrite' === $action ) {
898 + // Qualify the raw probe against known config refusals before
899 + // reporting. The probe fetches its OWN file from the static tree,
900 + // which succeeds even when no real page is served that way — so
901 + // an unqualified `active` reported "the web server is serving
902 + // cache hits directly" on sites whose every page returned
903 + // HIT (php). See Cache::qualify_rewrite_probe().
904 + $probe = \XSpeed\Cache::qualify_rewrite_probe( \XSpeed\Cache::recheck_static_rewrite() );
905 + $blocked = '' !== (string) $probe['block_reason'];
906 +
907 + if ( $probe['active'] ) {
908 + \WP_CLI::success( 'Static rewrite is active — the web server is serving cache hits directly.' );
909 + return;
910 + }
911 + if ( $blocked ) {
912 + \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) $probe['reason'] ) );
913 + return;
914 + }
915 + if ( $probe['inconclusive'] ) {
916 + \WP_CLI::warning( sprintf( 'Could not verify the static rewrite: %s', (string) $probe['reason'] ) );
917 + \WP_CLI::log( 'This is a probe failure, not proof that your server config is wrong.' );
918 + return;
919 + }
920 + \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) ( $probe['reason'] ?: 'unknown' ) ) );
921 + return;
922 + }
923 +
924 + if ( 'purge-url' === $action ) {
925 + $url = isset( $args[1] ) ? trim( (string) $args[1] ) : '';
926 + if ( '' === $url ) {
927 + \WP_CLI::error( 'Usage: wp xspeed cache purge-url <url-or-path>' );
928 + return;
929 + }
930 + $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
931 + $removed = \XSpeed\Cache::purge_url( $url, $cause );
932 + if ( $removed > 0 ) {
933 + \WP_CLI::success( sprintf( 'Purged %d cache file(s) for %s', $removed, $url ) );
934 + } else {
935 + \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) );
936 + }
937 + return;
938 + }
939 +
940 + if ( 'inventory' === $action ) {
941 + $this->cli_inventory( $limit );
942 + return;
943 + }
944 +
945 + if ( 'size' === $action ) {
946 + $this->cli_size();
947 + return;
948 + }
949 +
950 + if ( 'purge-log' === $action ) {
951 + $this->cli_purge_log( $limit );
952 + return;
953 + }
954 +
955 + if ( 'edge' === $action ) {
956 + $this->cli_edge();
957 + return;
958 + }
959 +
145 960 $opts = Settings_Manager::get( self::SLUG );
146 961 \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' );
147 962 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
148 963 foreach ( $opts['excluded_urls'] as $u ) {
@@ -147,6 +962,196 @@
147 962 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
148 963 foreach ( $opts['excluded_urls'] as $u ) {
149 964 \WP_CLI::log( ' - ' . $u );
150 965 }
966 + $edge = \XSpeed\Edge_Provider::detect();
967 + \WP_CLI::log( 'edge ' . ( '' !== $edge['provider'] ? $edge['provider'] : $edge['confidence'] ) . ' (' . $edge['source'] . ')' );
968 + }
969 +
970 + /**
971 + * `wp xspeed cache edge` — what we think is in front, and what we say to it.
972 + *
973 + * Worth printing even when nothing is held back. "You are behind
974 + * Cloudflare, and a Cache Rule set to ignore origin headers overrides
975 + * anything xSpeed sends" is the answer to a support question that
976 + * otherwise costs someone a week, and it is true whether or not a hold
977 + * ever fires.
978 + */
979 + private function cli_edge(): void {
980 + $answer = \XSpeed\Edge_Provider::detect();
981 +
982 + \WP_CLI::log( 'provider ' . ( '' !== $answer['provider'] ? $answer['provider'] : '(none named)' ) );
983 + \WP_CLI::log( 'confidence ' . $answer['confidence'] );
984 + \WP_CLI::log( 'source ' . $answer['source'] );
985 +
986 + // A pin outranks detection by design, so nothing re-checks it on the
987 + // site's behalf. Saying the two disagree is the whole mechanism by
988 + // which a site that changed CDN ever finds out.
989 + $sniffed = \XSpeed\Edge_Provider::sniffed();
990 + if ( in_array( $answer['source'], array( 'setting', 'constant', 'filter' ), true )
991 + && '' !== $sniffed['provider']
992 + && $sniffed['provider'] !== $answer['provider'] ) {
993 + \WP_CLI::warning(
994 + sprintf(
995 + 'This request looks like %s, but the provider is pinned to %s. If the site moved, change it — the pinned answer is also baked into the drop-in and the server rules.',
996 + $sniffed['provider'],
997 + '' !== $answer['provider'] ? $answer['provider'] : 'off'
998 + )
999 + );
1000 + }
1001 +
1002 + if ( \XSpeed\Edge_Provider::is_off( $answer ) ) {
1003 + \WP_CLI::log( '' );
1004 + \WP_CLI::log( 'Nothing is sent: this is switched off.' );
1005 + return;
1006 + }
1007 +
1008 + // Resolved through edge_headers_for() rather than straight off the
1009 + // provider, so this prints what the serve path would ACTUALLY send —
1010 + // including `X-XSpeed-Edge-Hold`, and including the evidence gate.
1011 + // Listing the provider's raw set ignored that gate and told operators
1012 + // a first render would be held on a site where it would not be.
1013 + //
1014 + // `bake`, not `request`. Two reasons, and the second one matters:
1015 + // this command answers for the site rather than for one response, and
1016 + // `request` fires `xspeed_edge_optimization_pending`, whose Pro
1017 + // listener resolves the CSS plan — which by its own description is
1018 + // what queues a build. A read-only command must not burn a build
1019 + // slot, quarantine an entry or purge a page just by being run, and
1020 + // under WP-CLI it would do all three against the home page.
1021 + $bypass = \XSpeed\Cache::edge_headers_for( 'BYPASS', 'bake', 'logged-in' );
1022 + $miss = \XSpeed\Cache::edge_headers_for( 'MISS', 'bake' );
1023 +
1024 + \WP_CLI::log( '' );
1025 + \WP_CLI::log( 'On a page xSpeed refuses to cache (a cart, a logged-in view):' );
1026 + foreach ( $bypass as $name => $value ) {
1027 + \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) );
1028 + }
1029 +
1030 + \WP_CLI::log( '' );
1031 + if ( array() === $miss ) {
1032 + \WP_CLI::log( 'On a first render: nothing. A MISS is a performance hedge, so it is held only where a cache in front was detected — and none was. Name the provider in Cache In Front Of This Site to cover first renders too.' );
1033 + } else {
1034 + \WP_CLI::log( 'On a first render:' );
1035 + foreach ( $miss as $name => $value ) {
1036 + \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) );
1037 + }
1038 + }
1039 +
1040 + \WP_CLI::log( '' );
1041 + \WP_CLI::log( 'X-XSpeed-Edge-Hold names why a response was held: bypass, bypass-shape, miss, mobile-split or pending. No header means nothing was held.' );
1042 +
1043 + if ( 'cloudflare' === $answer['provider'] ) {
1044 + \WP_CLI::log( '' );
1045 + \WP_CLI::log( 'A Cloudflare Cache Rule whose Edge TTL is "Ignore cache-control header and use this TTL" overrides all of the above. Use "Respect origin TTL" on that rule if pages are still being stored.' );
1046 + }
1047 + }
1048 +
1049 + /** `wp xspeed cache inventory [--limit=N]` — which pages are cached, and how old. */
1050 + private function cli_inventory( int $limit ): void {
1051 + $data = \XSpeed\Cache_Inventory::entries( $limit );
1052 +
1053 + if ( empty( $data['entries'] ) ) {
1054 + \WP_CLI::log( 'Cache is empty — no cached pages on disk.' );
1055 + return;
1056 + }
1057 +
1058 + \WP_CLI::log( sprintf( '%d cached page(s); showing %d.', $data['total'], count( $data['entries'] ) ) );
1059 + if ( ! empty( $data['capped'] ) ) {
1060 + \WP_CLI::warning( sprintf( 'Scan stopped at %d files — the list is a recent sample, not the whole cache.', \XSpeed\Cache_Inventory::SCAN_CAP ) );
1061 + }
1062 + foreach ( $data['entries'] as $entry ) {
1063 + \WP_CLI::log(
1064 + sprintf(
1065 + ' %-58s %8s %s [%s]',
1066 + null === $entry['url'] ? '(url unknown: ' . $entry['key'] . ')' : $entry['url'],
1067 + size_format( (int) $entry['bytes'] ),
1068 + $this->relative_age( (int) $entry['age'] ),
1069 + implode( '+', (array) $entry['stored_in'] )
1070 + )
1071 + );
1072 + }
1073 + }
1074 +
1075 + /** `wp xspeed cache size` — where the cache's disk usage goes. */
1076 + private function cli_size(): void {
1077 + $data = \XSpeed\Cache_Inventory::size_breakdown();
1078 +
1079 + \WP_CLI::log( sprintf( 'Total %s across %d file(s).', size_format( (int) $data['total_bytes'] ), (int) $data['total_files'] ) );
1080 + foreach ( $data['buckets'] as $bucket ) {
1081 + if ( 0 === (int) $bucket['files'] ) {
1082 + continue;
1083 + }
1084 + \WP_CLI::log( sprintf( ' %-32s %10s %d file(s)', $bucket['label'], size_format( (int) $bucket['bytes'] ), (int) $bucket['files'] ) );
1085 + }
1086 + if ( (int) $data['compressed_bytes'] > 0 ) {
1087 + \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'] ) ) );
1088 + }
1089 + }
1090 +
1091 + /** `wp xspeed cache purge-log [--limit=N]` — what cleared the cache, when, and why. */
1092 + private function cli_purge_log( int $limit ): void {
1093 + $data = \XSpeed\Cache_Inventory::purge_log( $limit );
1094 +
1095 + if ( empty( $data['events'] ) ) {
1096 + \WP_CLI::log( 'No purge events recorded yet.' );
1097 + return;
1098 + }
1099 + foreach ( $data['events'] as $event ) {
1100 + \WP_CLI::log( sprintf( ' %s %s', $this->relative_age( max( 0, time() - (int) $event['ts'] ) ), $event['message'] ) );
1101 + }
1102 + }
1103 +
1104 + /** Compact "4h ago" for CLI columns. */
1105 + private function relative_age( int $seconds ): string {
1106 + if ( $seconds < 60 ) {
1107 + return $seconds . 's ago';
1108 + }
1109 + if ( $seconds < 3600 ) {
1110 + return (int) floor( $seconds / 60 ) . 'm ago';
1111 + }
1112 + if ( $seconds < 86400 ) {
1113 + return (int) floor( $seconds / 3600 ) . 'h ago';
1114 + }
1115 + return (int) floor( $seconds / 86400 ) . 'd ago';
1116 + }
1117 +
1118 + /**
1119 + * Static-rewrite directives for the unified nginx server-block
1120 + * snippet. Returns null when cache is disabled — there's no rewrite
1121 + * to install in that state. Delegates to \XSpeed\Cache::nginx_snippet()
1122 + * which already produces nginx-detection-gated output.
1123 + */
1124 + public function nginx_directives(): ?string {
1125 + $opts = get_option( 'xspeed_options', array() );
1126 + if ( empty( $opts['cache_enabled'] ) ) {
1127 + return null;
1128 + }
1129 + return \XSpeed\Cache::nginx_snippet();
1130 + }
1131 +
1132 + /**
1133 + * Page caching's master switch is `cache_enabled` in the GLOBAL
1134 + * `xspeed_options`, not a per-module `enabled` key -- Cache::toggle owns
1135 + * it because flipping it rewrites .htaccess and wp-config.php. The base
1136 + * implementation looks only at this module's own settings bag, so it
1137 + * found nothing and reported null: the plugin's headline feature was
1138 + * missing from its own "N on" count. (#363)
1139 + */
1140 + public function is_active(): ?bool {
1141 + $opts = get_option( 'xspeed_options', array() );
1142 + return ! empty( $opts['cache_enabled'] );
1143 + }
1144 +
1145 + /**
1146 + * No reason shown: page caching has a single master switch, so the pill
1147 + * already says everything an (i) would. The switch lives on the Overview
1148 + * rather than on this page, but that is a "where is the control" question
1149 + * the panel itself should answer, not a reason to explain the verdict.
1150 + *
1151 + * The (i) is reserved for modules whose on/off is genuinely non-obvious
1152 + * -- counted from several flags, or from state outside the settings.
1153 + */
1154 + public function active_reason(): ?string {
1155 + return null;
151 1156 }
152 1157 }