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
xspeed / includes / modules / Cache / CacheModule.php

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

1,158 lines 45.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cache module.
4 *
5 * Owns the cache_expiry and excluded_urls settings. cache_enabled is
6 * deliberately NOT in this schema — flipping it triggers the
7 * advanced-cache.php drop-in install + WP_CACHE constant edit in
8 * wp-config.php, which is a sensitive single-purpose code path and lives
9 * in Cache::toggle() with its own dedicated /xspeed/v1/cache/toggle REST
10 * route. The dashboard's Cache page renders the special hero UI for it
11 * above this module's schema-driven settings panel.
12 *
13 * Tier: Free.
14 *
15 * @package XSpeed
16 */
17
18 declare(strict_types=1);
19
20 namespace XSpeed\Modules\Cache;
21
22 defined( 'ABSPATH' ) || exit;
23
24 use XSpeed\Module;
25 use XSpeed\Settings_Manager;
26
27 final class CacheModule extends Module {
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
151 public const SLUG = 'cache';
152 public const TIER = self::TIER_FREE;
153 public const VERSION = '1.0.0';
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
167 public function ui_metadata(): array {
168 return array(
169 'label' => __( 'Page Cache', 'xspeed' ),
170 'icon' => 'Database',
171 'description' => __( 'Saves each page as a file and serves it to logged-out visitors.', 'xspeed' ),
172 'group' => 'cache',
173 );
174 }
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
188 public function settings_schema(): array {
189 $schema = array(
190 'cache_expiry' => array(
191 'type' => 'int',
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,
196 'min' => 1,
197 'max' => 720,
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' ),
201 ),
202 'excluded_urls' => array(
203 'type' => 'list',
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 ),
229 'item_type' => 'string',
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' ),
232 ),
233 'excluded_cookies' => array(
234 'type' => 'list',
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,
239 'item_type' => 'string',
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' ),
242 ),
243 'bypass_user_agents' => array(
244 'type' => 'list',
245 'default' => array(),
246 'item_type' => 'string',
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' ),
249 ),
250 'ignored_query_params' => array(
251 'type' => 'list',
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(),
258 'item_type' => 'string',
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' ),
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 ),
269 'mobile_separate' => array(
270 'type' => 'bool',
271 'default' => false,
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' ),
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 ),
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;
343 }
344
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 /**
370 * Seed per-module option from the legacy xspeed_options blob if we
371 * haven't done so yet. Idempotent — once xspeed_module_cache exists
372 * or the legacy keys are gone, this is a no-op. Runs on both boot
373 * and activate so installs on every code path are covered.
374 */
375 public function boot(): void {
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();
437 }
438
439 public function activate(): void {
440 $this->seed_from_legacy_if_needed();
441 \XSpeed\Cache_GC::ensure_scheduled();
442 }
443
444 public function deactivate(): void {
445 \XSpeed\Cache_GC::unschedule();
446 }
447
448 private function seed_from_legacy_if_needed(): void {
449 if ( null !== get_option( 'xspeed_module_cache', null ) ) {
450 return;
451 }
452 $legacy = get_option( 'xspeed_options', array() );
453 if ( ! is_array( $legacy ) ) {
454 return;
455 }
456 $seed = array( '_version' => self::VERSION );
457 $dirty = false;
458 if ( array_key_exists( 'cache_expiry', $legacy ) ) {
459 $seed['cache_expiry'] = max( 1, min( 720, (int) $legacy['cache_expiry'] ) );
460 unset( $legacy['cache_expiry'] );
461 $dirty = true;
462 }
463 if ( array_key_exists( 'excluded_urls', $legacy ) ) {
464 $seed['excluded_urls'] = is_array( $legacy['excluded_urls'] ) ? array_values( array_filter( $legacy['excluded_urls'], 'is_string' ) ) : array();
465 unset( $legacy['excluded_urls'] );
466 $dirty = true;
467 }
468 if ( $dirty ) {
469 update_option( 'xspeed_module_cache', $seed );
470 update_option( 'xspeed_options', $legacy );
471 }
472 }
473
474 public function cli_commands(): array {
475 return array(
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(
537 'name' => 'xspeed cache',
538 'callback' => array( $this, 'cli_handler' ),
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`.',
540 'synopsis' => array(
541 array(
542 'type' => 'positional',
543 'name' => 'action',
544 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite', 'nginx-config', 'edge' ),
545 'optional' => true,
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 ),
571 ),
572 ),
573 );
574 }
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
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
960 $opts = Settings_Manager::get( self::SLUG );
961 \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' );
962 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
963 foreach ( $opts['excluded_urls'] as $u ) {
964 \WP_CLI::log( ' - ' . $u );
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;
1156 }
1157 }
1158