PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.5 All 32 releases
xspeed / includes / modules / Cache / CacheModule.php

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

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