PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 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 All 36 releases
← All changes | includes/modules/Cache/CacheModule.php +733 -95 1.2.4 → 1.4.2 View file →
@@ -25,8 +25,130 @@
25 25 use XSpeed\Settings_Manager;
26 26
27 27 final class CacheModule extends Module {
28 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 +
29 151 public const SLUG = 'cache';
30 152 public const TIER = self::TIER_FREE;
31 153 public const VERSION = '1.0.0';
32 154
@@ -43,11 +165,12 @@
43 165 public const DEFAULT_EXPIRY_HOURS = 24 * 7;
44 166
45 167 public function ui_metadata(): array {
46 168 return array(
47 - 'label' => 'Page Cache',
169 + 'label' => __( 'Page Cache', 'xspeed' ),
48 170 'icon' => 'Database',
49 - 'description' => 'Page caching for non-logged-in visitors.',
171 + 'description' => __( 'Saves each page as a file and serves it to logged-out visitors.', 'xspeed' ),
172 + 'group' => 'cache',
50 173 );
51 174 }
52 175
53 176 /**
@@ -62,9 +185,9 @@
62 185 return array();
63 186 }
64 187
65 188 public function settings_schema(): array {
66 - return array(
189 + $schema = array(
67 190 'cache_expiry' => array(
68 191 'type' => 'int',
69 192 // Matches the wizard's Balanced preset, which is what a fresh
70 193 // install starts on — a shorter module default meant the two
@@ -71,11 +194,11 @@
71 194 // disagreed about what "default" means. (#284)
72 195 'default' => self::DEFAULT_EXPIRY_HOURS,
73 196 'min' => 1,
74 197 'max' => 720,
75 - 'label' => 'Cache Expiry (hours)',
198 + 'label' => __( 'Cache expiry (hours)', 'xspeed' ),
76 199 'unit' => 'hours',
77 - 'description' => 'How long cached pages live before regenerating. 1 to 720 hours (30 days).',
200 + 'description' => __( 'How long a cached page is kept before xSpeed builds it again. 1 to 720 hours (30 days).', 'xspeed' ),
78 201 ),
79 202 'excluded_urls' => array(
80 203 'type' => 'list',
81 204 // Comprehensive LiteSpeed / WP Rocket-parity default URL
@@ -87,9 +210,11 @@
87 210 '/xmlrpc.php',
88 211 '~wp-.*\.php',
89 212 '/feed/',
90 213 'index.php',
91 - '~sitemap(_index)?\.xml',
214 + // `sitemaps?` — SEOPress generates sitemaps.xml (plural).
215 + '~sitemaps?(_index)?\.xml',
216 + '/robots.txt',
92 217 // Bare (no trailing slash) so "contains" matches both
93 218 // /cart and /cart/items — WooCommerce serves both forms.
94 219 '/cart',
95 220 '/checkout',
@@ -100,11 +225,12 @@
100 225 '/wc-api',
101 226 '/edd-api',
102 227 '/wp-login',
103 228 ),
104 - 'item_type' => 'string',
105 - 'label' => 'Excluded URLs',
106 - '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).',
229 + // `path`: kept as typed, percent-encoded slugs included.
230 + 'item_type' => 'path',
231 + 'label' => __( 'Excluded URLs', 'xspeed' ),
232 + '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' ),
107 233 ),
108 234 'excluded_cookies' => array(
109 235 'type' => 'list',
110 236 // Cookies that signal a logged-in / transactional visitor
@@ -109,40 +235,19 @@
109 235 'type' => 'list',
110 236 // Cookies that signal a logged-in / transactional visitor
111 237 // whose response must not be served from a shared cache.
112 238 // `~` prefix = raw regex (e.g. ~wordpress_[a-f0-9]+). (FBS-82181)
113 - 'default' => array(
114 - 'comment_author',
115 - '~wordpress_[a-f0-9]+',
116 - 'wp-postpass',
117 - 'wordpress_no_cache',
118 - 'wordpress_logged_in',
119 - 'edd_items_in_cart',
120 - 'woocommerce_items_in_cart',
121 - 'fct_cart_hash',
122 - 'comment_',
123 - 'woocommerce_',
124 - 'wordpress',
125 - 'xf_',
126 - 'edd_',
127 - 'jetpack',
128 - 'yith_wcwl_session_',
129 - 'yith_wrvp_',
130 - 'wpsc_',
131 - 'ecwid',
132 - 'ec_',
133 - 'bookly',
134 - ),
239 + 'default' => self::DEFAULT_EXCLUDED_COOKIES,
135 240 'item_type' => 'string',
136 - 'label' => 'Excluded Cookies',
137 - '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.',
241 + 'label' => __( 'Excluded cookies', 'xspeed' ),
242 + 'description' => __( 'Visitors with a cookie whose name matches a line here always get a fresh page. One per line; * and ~regex work.', 'xspeed' ),
138 243 ),
139 244 'bypass_user_agents' => array(
140 245 'type' => 'list',
141 246 'default' => array(),
142 247 'item_type' => 'string',
143 - 'label' => 'Bypass User Agents',
144 - '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.',
248 + 'label' => __( 'Excluded browsers and bots', 'xspeed' ),
249 + 'description' => __( 'Visitors whose user agent contains a line here always get a fresh page. Useful for screenshot bots and uptime monitors.', 'xspeed' ),
145 250 ),
146 251 'ignored_query_params' => array(
147 252 'type' => 'list',
148 253 // Analytics / ad / session query keys stripped before the
@@ -149,63 +254,102 @@
149 254 // cache key is computed, so /post?utm_source=x and /post
150 255 // share one entry. `~` prefix = raw regex. (FBS-82181)
151 256 // Matched whole-name, so every entry here means the param
152 257 // it names and nothing that merely contains it.
153 - 'default' => array(
154 - '__s',
155 - '_ga',
156 - '_ke',
157 - '~[a-zA-Z0-9_-]+_sid',
158 - 'adgroupid',
159 - 'age-verified',
160 - 'ao_noptimize',
161 - 'campaignid',
162 - 'ck_subscriber_id',
163 - 'cn-reloaded',
164 - 'dclid',
165 - 'epik',
166 - 'fb_action_ids',
167 - 'fb_action_types',
168 - 'fb_source',
169 - 'fbclid',
170 - 'gclid',
171 - 'jobid',
172 - 'mc_cid',
173 - 'mc_eid',
174 - 'mkt_tok',
175 - 'msclkid',
176 - 'ref',
177 - // Twitter/X (`ref_src`, `ref_url`) and Facebook (`refid`)
178 - // decorations. Enumerated because param names match
179 - // whole-name: the bare `ref` above no longer absorbs them,
180 - // and a `ref*` glob would over-match `referrer` and
181 - // `refund_id`, which are page-selecting.
182 - 'ref_src',
183 - 'ref_url',
184 - 'refid',
185 - '~session_[a-zA-Z0-9_-]+_alive',
186 - 'sseid',
187 - 'sslid',
188 - 'usqp',
189 - '~utm_[a-zA-Z0-9_-]+',
190 - ),
258 + 'default' => self::default_ignored_query_params(),
191 259 'item_type' => 'string',
192 - 'label' => 'Ignored Query Parameters',
193 - '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.',
260 + 'label' => __( 'Ignored query parameters', 'xspeed' ),
261 + 'advanced' => true,
262 + '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' ),
194 263 ),
195 264 'purge_on_upgrade' => array(
196 265 'type' => 'bool',
197 266 'default' => true,
198 - 'label' => 'Purge After Updates',
199 - '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.',
267 + 'label' => __( 'Clear cache after updates', 'xspeed' ),
268 + 'description' => __( 'Clear the page cache when a plugin, theme or WordPress is updated, so visitors never get old pages with broken asset links.', 'xspeed' ),
200 269 ),
270 + 'purge_affected_only' => array(
271 + 'type' => 'bool',
272 + 'default' => true,
273 + 'label' => __( 'Clear only the pages a change affects', 'xspeed' ),
274 + 'description' => __( 'When you publish, edit or delete a post, clear that post, the pages that list it and its feeds instead of the whole site. Pages with a post grid from a page builder, a block plugin or the theme are noted as they are built and cleared when the change can affect their list; turn this off if a grid still shows an old list.', 'xspeed' ),
275 + 'info_title' => __( 'Which pages are cleared', 'xspeed' ),
276 + 'info' => __( 'The post, its old address if it moved, the home page, the blog page, its categories, tags and author pages with every page of each, the date archives, feeds, the posts next to it, pages that show a Latest Posts or Query Loop block, directly or in a synced pattern, and pages noted running a post grid of their own (a page builder, block plugin, related-posts or theme list) that the change can affect. If your theme shows a list that can be on any page, and the change alters it, xSpeed clears the whole site instead: a Recent Posts, Archives or Calendar widget, a post list block, or a latest-posts grid from a page builder or plugin (usually a new post, or an edit to one of the newest few it shows), a page list or menu (a page published, withdrawn, renamed or moved), or a Categories or Tag Cloud list (a post moved to other categories or tags).', 'xspeed' ),
277 + ),
201 278 'mobile_separate' => array(
202 279 'type' => 'bool',
203 280 'default' => false,
204 - 'label' => 'Separate Mobile Cache',
205 - '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.',
281 + 'label' => __( 'Separate mobile cache', 'xspeed' ),
282 + 'description' => __( 'Keep a separate cached copy for phones. Turn on only if your theme or plugins show different pages on mobile.', 'xspeed' ),
206 283 ),
284 + 'edge_provider' => array(
285 + 'type' => 'enum',
286 + 'default' => 'auto',
287 + 'options' => array( 'auto', 'off', 'cloudflare', 'fastly', 'varnish', 'nginx', 'akamai', 'cloudfront', 'google', 'keycdn', 'bunny', 'sucuri', 'incapsula', 'generic', 'custom' ),
288 + 'option_labels' => array(
289 + 'auto' => 'Detect automatically',
290 + 'off' => 'Off (no edge lifetime or cache tags)',
291 + 'cloudflare' => 'Cloudflare',
292 + 'varnish' => 'Varnish',
293 + 'nginx' => 'nginx proxy cache',
294 + 'cloudfront' => 'Amazon CloudFront',
295 + 'google' => 'Google Cloud CDN',
296 + 'keycdn' => 'KeyCDN',
297 + 'bunny' => 'Bunny',
298 + // These four cannot be presented as supported on the same
299 + // footing as the ones above. Vendor documentation either
300 + // does not establish that they honour what we send, or
301 + // establishes that they ignore origin cache headers until
302 + // the property is configured to respect them — Akamai
303 + // caches for a theoretically infinite time by default, and
304 + // Sucuri's default caching level ignores the headers
305 + // outright. Naming them without the caveat would promise a
306 + // protection the CDN is not currently giving.
307 + 'fastly' => 'Fastly (needs CDN configuration)',
308 + 'akamai' => 'Akamai (needs CDN configuration)',
309 + 'sucuri' => 'Sucuri (needs CDN configuration)',
310 + 'incapsula' => 'Imperva / Incapsula (needs CDN configuration)',
311 + 'generic' => 'Something else',
312 + 'custom' => 'Custom headers',
313 + ),
314 + 'label' => __( 'CDN or proxy in front', 'xspeed' ),
315 + 'description' => __( 'Tells your CDN or proxy not to store pages xSpeed does not cache. Leave on Detect automatically unless you know your provider.', 'xspeed' ),
316 + 'advanced' => true,
317 + 'info_title' => __( 'Cache in front of this site', 'xspeed' ),
318 + '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. Off stops the edge lifetime and cache tags. When an add-on that manages the edge, such as a Cloudflare Enterprise add-on, is active, the "don\'t store" headers for logged-in, cart and search pages still go out. Run "wp xspeed cache edge" to see what was detected and what gets sent.', 'xspeed' )
319 + ),
320 + 'edge_custom_headers' => array(
321 + 'type' => 'list',
322 + 'default' => array(),
323 + 'item_type' => 'string',
324 + 'label' => __( 'Custom CDN headers', 'xspeed' ),
325 + 'advanced' => true,
326 + 'dependsOn' => array( 'field' => 'edge_provider', 'value' => 'custom' ),
327 + 'description' => __( 'One header per line, as Name: value, for example "Surrogate-Control: no-store". Lines starting with # are ignored.', 'xspeed' ),
328 + 'info_title' => __( 'Custom edge headers', 'xspeed' ),
329 + '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' )
330 + ),
207 331 );
332 +
333 + // LiteSpeed-only opt-in (#509): meaningless on any other server, so
334 + // the field only exists in the schema where it can act — elsewhere the
335 + // stored value survives via preserved_keys(). Inserted right after
336 + // mobile_separate, its sibling static-fast-path trade-off.
337 + if ( \XSpeed\Server::LITESPEED === \XSpeed\Server::type() ) {
338 + $litespeed = array(
339 + 'litespeed_static_rewrite' => array(
340 + 'type' => 'bool',
341 + 'default' => false,
342 + 'label' => __( 'LiteSpeed static fast path', 'xspeed' ),
343 + 'advanced' => true,
344 + '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' ),
345 + ),
346 + );
347 + $pos = (int) array_search( 'mobile_separate', array_keys( $schema ), true ) + 1;
348 + $schema = array_slice( $schema, 0, $pos, true ) + $litespeed + array_slice( $schema, $pos, null, true );
349 + }
350 +
351 + return $schema;
208 352 }
209 353
210 354 /**
211 355 * `mobile_separate_review` lives outside the schema: migration sets it
@@ -218,9 +362,18 @@
218 362 *
219 363 * @return string[]
220 364 */
221 365 public function preserved_keys(): array {
222 - return array( 'mobile_separate_review' );
366 + $keys = array( 'mobile_separate_review' );
367 + // On non-LiteSpeed servers the litespeed_static_rewrite field is not
368 + // in the schema (see settings_schema()), so a schema-driven save
369 + // would silently drop a value chosen while the site ran LiteSpeed.
370 + // Preserve it so moving LiteSpeed → other → LiteSpeed keeps the
371 + // user's choice. On LiteSpeed itself the schema owns the key.
372 + if ( \XSpeed\Server::LITESPEED !== \XSpeed\Server::type() ) {
373 + $keys[] = 'litespeed_static_rewrite';
374 + }
375 + return $keys;
223 376 }
224 377
225 378 /**
226 379 * Seed per-module option from the legacy xspeed_options blob if we
@@ -352,40 +505,88 @@
352 505 'name' => 'budget',
353 506 'description' => 'Seconds to spend before stopping between steps. Default 120.',
354 507 'optional' => true,
355 508 ),
509 + array(
510 + 'type' => 'assoc',
511 + 'name' => 'measure-score',
512 + '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.',
513 + 'optional' => true,
514 + 'options' => array( 'auto', 'never', 'always' ),
515 + ),
356 516 ),
357 517 ),
358 518 array(
519 + 'name' => 'xspeed purge',
520 + 'callback' => array( $this, 'cli_purge' ),
521 + '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.',
522 + '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.',
523 + 'synopsis' => array(
524 + array(
525 + 'type' => 'assoc',
526 + 'name' => 'type',
527 + 'description' => 'What to clear: all (default), page, object, cloudflare, xcloud (sites with xCloud\'s purge plugin), cdn — or a group name (edge). Comma-separate to clear several.',
528 + 'optional' => true,
529 + ),
530 + array(
531 + 'type' => 'assoc',
532 + 'name' => 'cause',
533 + 'description' => 'Label recorded in the purge log, so `wp xspeed cache purge-log` can tell this run apart from a click. Default "CLI".',
534 + 'optional' => true,
535 + ),
536 + array(
537 + 'type' => 'assoc',
538 + 'name' => 'format',
539 + 'description' => 'table (default, one line per store) or json (the full report, for scripts).',
540 + 'optional' => true,
541 + 'options' => array( 'table', 'json' ),
542 + ),
543 + ),
544 + ),
545 + array(
359 546 'name' => 'xspeed cache',
360 547 'callback' => array( $this, 'cli_handler' ),
361 - '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 (site-wide purge / toggle use the dedicated commands).',
548 + '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` to clear everything (same as `wp xspeed purge`), `purge <url>` or `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, or `listings` to show which pages were recorded running a post list of their own (cleared by a narrow purge when a save may change that list). To clear the whole site use `wp xspeed purge`.',
362 549 'synopsis' => array(
363 550 array(
364 551 'type' => 'positional',
365 552 'name' => 'action',
366 - 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite', 'nginx-config' ),
553 + 'options' => array( 'status', 'inventory', 'size', 'purge', 'purge-log', 'purge-url', 'recheck-rewrite', 'nginx-config', 'edge', 'listings' ),
367 554 'optional' => true,
368 555 ),
369 556 array(
370 - 'type' => 'positional',
371 - 'name' => 'url',
372 - 'optional' => true,
557 + 'type' => 'positional',
558 + 'name' => 'url',
559 + 'description' => 'For purge and purge-url: the page to clear, as a URL on this site or a path such as /about/. Without it, purge clears everything.',
560 + 'optional' => true,
373 561 ),
374 562 array(
375 563 'type' => 'assoc',
376 564 'name' => 'limit',
377 - 'description' => 'Rows to print for inventory / purge-log. Default 20.',
565 + 'description' => 'Rows to print for inventory / purge-log (default 20), or sample pages for listings (default 5).',
378 566 'optional' => true,
379 567 ),
380 568 array(
381 569 'type' => 'assoc',
382 570 'name' => 'cause',
383 - 'description' => 'Label recorded in the purge log for purge-url. Default "CLI".',
571 + 'description' => 'Label recorded in the purge log for purge and purge-url. Default "CLI".',
384 572 'optional' => true,
385 573 ),
386 574 array(
387 575 'type' => 'assoc',
576 + 'name' => 'type',
577 + 'description' => 'For purge without a URL: what to clear, as in `wp xspeed purge --type`. Default all.',
578 + 'optional' => true,
579 + ),
580 + array(
581 + 'type' => 'assoc',
582 + 'name' => 'format',
583 + 'description' => 'For purge without a URL: table (default) or json.',
584 + 'optional' => true,
585 + 'options' => array( 'table', 'json' ),
586 + ),
587 + array(
588 + 'type' => 'assoc',
388 589 'name' => 'server',
389 590 '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.',
390 591 'options' => array( 'nginx', 'apache', 'litespeed' ),
391 592 'optional' => true,
@@ -411,8 +612,9 @@
411 612 array(
412 613 'aggressiveness' => (string) ( $assoc['aggressiveness'] ?? 'standard' ),
413 614 'dry_run' => isset( $assoc['dry-run'] ),
414 615 'budget_seconds' => isset( $assoc['budget'] ) ? (int) $assoc['budget'] : 120,
616 + 'measure_score' => (string) ( $assoc['measure-score'] ?? 'auto' ),
415 617 )
416 618 );
417 619
418 620 if ( is_wp_error( $result ) ) {
@@ -420,8 +622,14 @@
420 622 return;
421 623 }
422 624
423 625 if ( ! empty( $result['dry_run'] ) ) {
626 + // The summary carries the score AND its age. Printing the plan
627 + // without it left the one number a reader wants off the only
628 + // command they run before deciding to apply anything.
629 + if ( isset( $result['message'] ) ) {
630 + \WP_CLI::log( (string) $result['message'] );
631 + }
424 632 \WP_CLI::log( 'Plan (' . count( $result['plan'] ) . ' steps, nothing applied):' );
425 633 foreach ( $result['plan'] as $step ) {
426 634 \WP_CLI::log( ' - ' . $step['change'] . ' [' . $step['tier'] . ']' );
427 635 }
@@ -445,16 +653,238 @@
445 653 \WP_CLI::log( ' ! ' . $row['issue'] . ( '' !== $row['fix'] ? ' — ' . $row['fix'] : '' ) );
446 654 }
447 655
448 656 if ( ! empty( $result['applied'] ) ) {
449 - \WP_CLI::success( count( $result['applied'] ) . ' change(s) applied and verified.' );
657 + // "applied and verified" was more than the checks earn. They read
658 + // HTML in PHP and cannot run JavaScript, so this line was telling
659 + // someone the site was fine when the only honest claim is that
660 + // nothing in the markup looked broken.
661 + \WP_CLI::success( count( $result['applied'] ) . ' change(s) applied; HTML checks passed.' );
662 +
663 + if ( ! empty( $result['verify_urls'] ) ) {
664 + \WP_CLI::log( '' );
665 + \WP_CLI::log( 'Now open these and check they render, with no console errors:' );
666 + foreach ( $result['verify_urls'] as $u ) {
667 + \WP_CLI::log( ' ' . $u );
668 + }
669 + }
450 670 }
451 671 }
452 672
673 + /**
674 + * `wp xspeed purge` — clear every cache xSpeed owns, in one call.
675 + *
676 + * Reports per store rather than printing a success banner, because the
677 + * banner was the bug: a site whose Cloudflare token had lost its purge
678 + * permission saw "cache cleared" and kept serving stale HTML from the
679 + * edge. What is skipped is as much of the answer as what is cleared, so
680 + * every skip prints its reason.
681 + *
682 + * Exit code follows the same distinction. A store that is not configured
683 + * has nothing to clear and does not fail the run — otherwise every CI
684 + * pipeline on a site without Redis goes red for a purge that did exactly
685 + * what it should. A store that IS configured and refused is a failure.
686 + *
687 + * There is deliberately no `--url`: WP-CLI reserves that flag for
688 + * multisite site selection and consumes it before a handler ever sees it.
689 + * Clearing one page is `wp xspeed cache purge-url <url>`.
690 + *
691 + * @param array<int,string> $args Positional args (unused).
692 + * @param array<string,string> $assoc Flags.
693 + */
694 + public function cli_purge( array $args, array $assoc ): void {
695 + unset( $args );
696 +
697 + $requested = array_values(
698 + array_filter(
699 + array_map( 'trim', explode( ',', (string) ( $assoc['type'] ?? 'all' ) ) )
700 + )
701 + );
702 + if ( ! $requested ) {
703 + $requested = array( 'all' );
704 + }
705 +
706 + $accepted = \XSpeed\Purge_Runner::accepted_types();
707 + $unknown = array_diff( $requested, $accepted );
708 + if ( $unknown ) {
709 + // Refuse before purging anything: a typo in --type must not
710 + // quietly clear a DIFFERENT store than the one named.
711 + \WP_CLI::error(
712 + sprintf(
713 + 'Unknown purge type: %s. Expected one of: %s',
714 + implode( ', ', $unknown ),
715 + implode( ', ', $accepted )
716 + )
717 + );
718 + return;
719 + }
720 +
721 + $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
722 + $report = \XSpeed\Purge_Runner::run( $requested, $cause );
723 +
724 + if ( 'json' === ( $assoc['format'] ?? 'table' ) ) {
725 + // The report goes to STDOUT alone so `... --format=json | jq` works;
726 + // the failure message goes to STDERR via ::error, which is also
727 + // what produces the non-zero exit.
728 + \WP_CLI::line( (string) wp_json_encode( $report ) );
729 + if ( ! $report['ok'] ) {
730 + \WP_CLI::error( 'One or more cache stores failed to purge; see the report above.' );
731 + }
732 + return;
733 + }
734 +
735 + $cleared = 0;
736 + $skipped = 0;
737 + $failed = 0;
738 + foreach ( $report['types'] as $row ) {
739 + switch ( $row['status'] ) {
740 + case \XSpeed\Purge_Runner::CLEARED:
741 + ++$cleared;
742 + \WP_CLI::log(
743 + sprintf(
744 + ' cleared %s%s%s',
745 + $row['label'],
746 + self::purge_amount( $row ),
747 + '' !== $row['reason'] ? '; ' . $row['reason'] : ''
748 + )
749 + );
750 + break;
751 + case \XSpeed\Purge_Runner::FAILED:
752 + ++$failed;
753 + \WP_CLI::log( sprintf( ' FAILED %s — %s', $row['label'], $row['reason'] ) );
754 + break;
755 + default:
756 + ++$skipped;
757 + \WP_CLI::log( sprintf( ' skipped %s — %s', $row['label'], $row['reason'] ) );
758 + }
759 + }
760 +
761 + if ( $failed ) {
762 + \WP_CLI::error(
763 + sprintf(
764 + '%d of %d cache store(s) failed to purge; %d cleared, %d skipped.',
765 + $failed,
766 + count( $report['types'] ),
767 + $cleared,
768 + $skipped
769 + )
770 + );
771 + return;
772 + }
773 +
774 + if ( ! $cleared ) {
775 + // Not a success banner: nothing was purged, and saying so is the
776 + // honest answer for a --type nobody has configured.
777 + \WP_CLI::log( sprintf( 'Nothing to purge — %d store(s) skipped.', $skipped ) );
778 + return;
779 + }
780 +
781 + \WP_CLI::success( sprintf( 'Purged %d cache store(s); %d skipped.', $cleared, $skipped ) );
782 + }
783 +
784 + /**
785 + * Clear one page: `wp xspeed cache purge-url <url>` and
786 + * `wp xspeed cache purge <url>`.
787 + *
788 + * A string purge_url() cannot act on (another scheme, a scheme with no
789 + * host, a bare word) is refused with an error. purge_url() returns 0 for
790 + * it, which read as "already cold" and exited 0.
791 + *
792 + * @param string $url Absolute URL or site-relative path.
793 + * @param array<string,string> $assoc Flags; only --cause is read.
794 + */
795 + private function cli_purge_url( string $url, array $assoc ): void {
796 + if ( ! self::is_purgeable_url( $url ) ) {
797 + \WP_CLI::error( sprintf( 'Not a page URL: %s. Give an http(s) URL on this site or a path starting with /, such as /about/.', $url ) );
798 + return;
799 + }
800 + $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
801 + $result = \XSpeed\Cache::purge_url_reported( $url, $cause );
802 + $removed = $result['removed'];
803 + $forwarded = implode( ', ', $result['forwarded'] );
804 + if ( $removed > 0 ) {
805 + \WP_CLI::success(
806 + '' === $forwarded
807 + ? sprintf( 'Purged %d cache file(s) for %s', $removed, $url )
808 + : sprintf( 'Purged %d cache file(s) for %s, and sent the purge to %s', $removed, $url, $forwarded )
809 + );
810 + } elseif ( '' !== $forwarded ) {
811 + // xSpeed's own cache held nothing, but a cache in front of
812 + // PHP took the purge: that copy is the one visitors get.
813 + \WP_CLI::success( sprintf( 'Sent the purge for %s to %s. xSpeed\'s own cache held no copy.', $url, $forwarded ) );
814 + } else {
815 + \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) );
816 + }
817 + }
818 +
819 + /**
820 + * Whether purge_url() can act on this string: an http(s) URL with a
821 + * host, a host without a scheme (`site.test/page/`), or a path starting
822 + * with `/`. The same rules purge_url() applies before it returns 0.
823 + *
824 + * @param string $url What the operator typed.
825 + */
826 + private static function is_purgeable_url( string $url ): bool {
827 + $parts = wp_parse_url( $url );
828 + if ( ! is_array( $parts ) ) {
829 + return false;
830 + }
831 + if ( isset( $parts['scheme'] ) ) {
832 + return in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) && ! empty( $parts['host'] );
833 + }
834 + if ( isset( $parts['host'] ) ) {
835 + return true;
836 + }
837 + $path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
838 + return '' !== $path && '/' === $path[0];
839 + }
840 +
841 + /**
842 + * The " — 42 entries (1.3 MB)" tail on a cleared line.
843 + *
844 + * Entries and bytes are both optional: a Redis FLUSHALL reports neither,
845 + * and printing "0 entries" for it would read as an empty cache rather
846 + * than an uncountable one.
847 + *
848 + * @param array{entries:int|null,bytes:int|null} $row Report row.
849 + */
850 + private static function purge_amount( array $row ): string {
851 + $parts = array();
852 + if ( null !== $row['entries'] ) {
853 + $parts[] = sprintf( '%d entr%s', $row['entries'], 1 === (int) $row['entries'] ? 'y' : 'ies' );
854 + }
855 + if ( null !== $row['bytes'] && $row['bytes'] > 0 ) {
856 + $parts[] = size_format( $row['bytes'], 1 );
857 + }
858 +
859 + return $parts ? ' — ' . implode( ', ', $parts ) : '';
860 + }
861 +
453 862 public function cli_handler( array $args, array $assoc ): void {
454 863 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
455 864 $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20;
456 865
866 + // `wp xspeed cache purge` is what people type. It runs the same
867 + // purge as `wp xspeed purge`; before it was listed, WP-CLI rejected
868 + // it with "Invalid value specified for positional arg". Given a URL
869 + // it clears that one page, as purge-url does: it used to drop the
870 + // URL and clear the whole site.
871 + if ( 'purge' === $action ) {
872 + $url = isset( $args[1] ) ? trim( (string) $args[1] ) : '';
873 + if ( '' === $url ) {
874 + $this->cli_purge( array(), $assoc );
875 + return;
876 + }
877 + foreach ( array( 'type', 'format' ) as $flag ) {
878 + if ( isset( $assoc[ $flag ] ) ) {
879 + \WP_CLI::error( sprintf( '--%s applies to a full purge, not to one URL. Run `wp xspeed cache purge %s` without it, or drop the URL to purge everything.', $flag, $url ) );
880 + return;
881 + }
882 + }
883 + $this->cli_purge_url( $url, $assoc );
884 + return;
885 + }
886 +
457 887 /*
458 888 * Print the unified nginx server-block so an installer, provisioning
459 889 * script, or another plugin can fetch it non-interactively and write
460 890 * it into a vhost. Previously this was only reachable via
@@ -581,8 +1011,15 @@
581 1011 // HIT (php). See Cache::qualify_rewrite_probe().
582 1012 $probe = \XSpeed\Cache::qualify_rewrite_probe( \XSpeed\Cache::recheck_static_rewrite() );
583 1013 $blocked = '' !== (string) $probe['block_reason'];
584 1014
1015 + // Whether the installed rules are the ones these settings
1016 + // generate. Reported before the verdict below because it is the
1017 + // question someone running this command has just acted on — they
1018 + // pasted a block and want to know if it took — and because
1019 + // "active" is true for a stale block too.
1020 + $this->cli_report_rules_state( (array) ( $probe['rules'] ?? array() ) );
1021 +
585 1022 if ( $probe['active'] ) {
586 1023 \WP_CLI::success( 'Static rewrite is active — the web server is serving cache hits directly.' );
587 1024 return;
588 1025 }
@@ -604,15 +1041,9 @@
604 1041 if ( '' === $url ) {
605 1042 \WP_CLI::error( 'Usage: wp xspeed cache purge-url <url-or-path>' );
606 1043 return;
607 1044 }
608 - $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
609 - $removed = \XSpeed\Cache::purge_url( $url, $cause );
610 - if ( $removed > 0 ) {
611 - \WP_CLI::success( sprintf( 'Purged %d cache file(s) for %s', $removed, $url ) );
612 - } else {
613 - \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) );
614 - }
1045 + $this->cli_purge_url( $url, $assoc );
615 1046 return;
616 1047 }
617 1048
618 1049 if ( 'inventory' === $action ) {
@@ -629,8 +1060,18 @@
629 1060 $this->cli_purge_log( $limit );
630 1061 return;
631 1062 }
632 1063
1064 + if ( 'edge' === $action ) {
1065 + $this->cli_edge();
1066 + return;
1067 + }
1068 +
1069 + if ( 'listings' === $action ) {
1070 + $this->cli_listings( isset( $assoc['limit'] ) ? $limit : 5 );
1071 + return;
1072 + }
1073 +
633 1074 $opts = Settings_Manager::get( self::SLUG );
634 1075 \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' );
635 1076 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
636 1077 foreach ( $opts['excluded_urls'] as $u ) {
@@ -635,8 +1076,205 @@
635 1076 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
636 1077 foreach ( $opts['excluded_urls'] as $u ) {
637 1078 \WP_CLI::log( ' - ' . $u );
638 1079 }
1080 + $edge = \XSpeed\Edge_Provider::detect();
1081 + \WP_CLI::log( 'edge ' . ( '' !== $edge['provider'] ? $edge['provider'] : $edge['confidence'] ) . ' (' . $edge['source'] . ')' );
1082 + }
1083 +
1084 + /**
1085 + * `wp xspeed cache edge` — what we think is in front, and what we say to it.
1086 + *
1087 + * Worth printing even when nothing is held back. "You are behind
1088 + * Cloudflare, and a Cache Rule set to ignore origin headers overrides
1089 + * anything xSpeed sends" is the answer to a support question that
1090 + * otherwise costs someone a week, and it is true whether or not a hold
1091 + * ever fires.
1092 + */
1093 + private function cli_edge(): void {
1094 + $answer = \XSpeed\Edge_Provider::detect();
1095 +
1096 + \WP_CLI::log( 'provider ' . ( '' !== $answer['provider'] ? $answer['provider'] : '(none named)' ) );
1097 + \WP_CLI::log( 'confidence ' . $answer['confidence'] );
1098 + \WP_CLI::log( 'source ' . $answer['source'] );
1099 +
1100 + // A pin outranks detection by design, so nothing re-checks it on the
1101 + // site's behalf. Saying the two disagree is the whole mechanism by
1102 + // which a site that changed CDN ever finds out.
1103 + $sniffed = \XSpeed\Edge_Provider::sniffed();
1104 + if ( in_array( $answer['source'], array( 'setting', 'constant', 'filter' ), true )
1105 + && '' !== $sniffed['provider']
1106 + && $sniffed['provider'] !== $answer['provider'] ) {
1107 + \WP_CLI::warning(
1108 + sprintf(
1109 + '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.',
1110 + $sniffed['provider'],
1111 + '' !== $answer['provider'] ? $answer['provider'] : 'off'
1112 + )
1113 + );
1114 + }
1115 +
1116 + if ( \XSpeed\Edge_Provider::is_off( $answer ) ) {
1117 + \WP_CLI::log( '' );
1118 + \WP_CLI::log( 'Nothing is sent: this is switched off.' );
1119 + return;
1120 + }
1121 +
1122 + // Resolved through edge_headers_for() rather than straight off the
1123 + // provider, so this prints what the serve path would ACTUALLY send —
1124 + // including `X-XSpeed-Edge-Hold`, and including the evidence gate.
1125 + // Listing the provider's raw set ignored that gate and told operators
1126 + // a first render would be held on a site where it would not be.
1127 + //
1128 + // `bake`, not `request`. Two reasons, and the second one matters:
1129 + // this command answers for the site rather than for one response, and
1130 + // `request` fires `xspeed_edge_optimization_pending`, whose Pro
1131 + // listener resolves the CSS plan — which by its own description is
1132 + // what queues a build. A read-only command must not burn a build
1133 + // slot, quarantine an entry or purge a page just by being run, and
1134 + // under WP-CLI it would do all three against the home page.
1135 + $bypass = \XSpeed\Cache::edge_headers_for( 'BYPASS', 'bake', 'logged-in' );
1136 + $miss = \XSpeed\Cache::edge_headers_for( 'MISS', 'bake' );
1137 +
1138 + \WP_CLI::log( '' );
1139 + \WP_CLI::log( 'On a page xSpeed refuses to cache (a cart, a logged-in view):' );
1140 + foreach ( $bypass as $name => $value ) {
1141 + \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) );
1142 + }
1143 +
1144 + \WP_CLI::log( '' );
1145 + if ( array() === $miss ) {
1146 + \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.' );
1147 + } else {
1148 + \WP_CLI::log( 'On a first render:' );
1149 + foreach ( $miss as $name => $value ) {
1150 + \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) );
1151 + }
1152 + }
1153 +
1154 + // The same bake the drop-in gets, so it asks no per-page question.
1155 + $variant = \XSpeed\Cache::query_variant_edge_headers();
1156 +
1157 + \WP_CLI::log( '' );
1158 + if ( array() === $variant ) {
1159 + \WP_CLI::log( 'A cached page requested with an ignored parameter (?utm_source=x) gets the same headers as without one. It is held only where a cache in front was detected, and with Separate Mobile Cache on every page is held already.' );
1160 + } else {
1161 + \WP_CLI::log( 'On a cached page requested with an ignored parameter (?utm_source=x), a cached search or a query-form feed:' );
1162 + foreach ( $variant as $name => $value ) {
1163 + \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) );
1164 + }
1165 + }
1166 +
1167 + \WP_CLI::log( '' );
1168 + \WP_CLI::log( 'X-XSpeed-Edge-Hold names why a response was held: bypass, bypass-shape, miss, mobile-split, pending or query-variant. No header means nothing was held.' );
1169 +
1170 + if ( 'cloudflare' === $answer['provider'] ) {
1171 + \WP_CLI::log( '' );
1172 + \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.' );
1173 + }
1174 + }
1175 +
1176 + /**
1177 + * `wp xspeed cache listings`: the pages recorded running a post list
1178 + * outside the main loop, which a narrow purge adds when a save may
1179 + * change that list.
1180 + *
1181 + * For measuring how wide that set gets on a real site: close to every
1182 + * page means a narrow purge saves little there.
1183 + *
1184 + * @param int $samples Pages to print.
1185 + */
1186 + private function cli_listings( int $samples ): void {
1187 + $narrow = ! empty( Settings_Manager::get( self::SLUG )['purge_affected_only'] );
1188 + $stats = \XSpeed\Listing_Pages::stats( $samples );
1189 + $limit = \XSpeed\Affected_Pages::LIMIT;
1190 +
1191 + \WP_CLI::log( 'narrow purge ' . ( $narrow ? 'on' : 'off' ) );
1192 + \WP_CLI::log( 'recording ' . ( \XSpeed\Listing_Pages::enabled() ? 'on' : 'off' ) );
1193 + \WP_CLI::log( 'directory ' . $stats['dir'] );
1194 +
1195 + \WP_CLI::log( '' );
1196 + \WP_CLI::log( 'Newest-N lists (' . count( $stats['specs'] ) . ' of ' . \XSpeed\Listing_Pages::SPEC_CAP . '): a change to one of the newest N clears the whole site.' );
1197 + foreach ( $stats['specs'] as $spec ) {
1198 + \WP_CLI::log( sprintf( ' newest %d of %s%s', $spec['n'], implode( ', ', $spec['types'] ), $spec['sticky'] ? ', sticky posts first' : '' ) );
1199 + }
1200 +
1201 + \WP_CLI::log( '' );
1202 + \WP_CLI::log( sprintf( 'Pages with other lists: %d recorded (%d files, cap %d); %d posts in the shown-on index.', $stats['pages'], $stats['files'], \XSpeed\Listing_Pages::CAP, $stats['posts'] ) );
1203 + foreach ( $stats['types'] as $type => $counts ) {
1204 + \WP_CLI::log(
1205 + sprintf(
1206 + ' %-14s %d pages, %d not precise%s',
1207 + $type,
1208 + $counts['all'],
1209 + $counts['loose'],
1210 + $counts['loose'] > $limit
1211 + ? ' (over ' . $limit . ' not precise: any change to a post of this type clears the whole site)'
1212 + : ( $counts['all'] > $limit ? ' (over ' . $limit . ': a change that is not a plain edit clears the whole site)' : '' )
1213 + )
1214 + );
1215 + }
1216 +
1217 + if ( null !== $stats['full'] ) {
1218 + \WP_CLI::warning(
1219 + sprintf(
1220 + 'The record is full, and pages listing %s went unrecorded: a change to a post of those types clears the whole site until a save finds the record under the cap.',
1221 + implode( ', ', array_map( 'strval', (array) ( $stats['full']['types'] ?? array( 'any' ) ) ) )
1222 + )
1223 + );
1224 + }
1225 + if ( array() === $stats['samples'] ) {
1226 + return;
1227 + }
1228 + \WP_CLI::log( '' );
1229 + foreach ( $stats['samples'] as $record ) {
1230 + \WP_CLI::log(
1231 + sprintf(
1232 + ' %s [%s] %s',
1233 + $record['url'],
1234 + implode( ', ', $record['types'] ),
1235 + $record['precise'] ? 'precise, ' . count( $record['ids'] ) . ' post(s) shown' : 'not precise'
1236 + )
1237 + );
1238 + }
1239 + }
1240 +
1241 + /**
1242 + * Say which version of the cache rules the server is running.
1243 + *
1244 + * The generated block stamps every static hit with a hash of itself, and
1245 + * the probe reads that back — the only way to tell what someone actually
1246 + * pasted into a config WordPress cannot open. `stale` names the settings
1247 + * that moved since, where we can tell, because "re-paste the block" with
1248 + * no reason attached is what makes people ignore it.
1249 + *
1250 + * @param array $rules Cache::rules_state() output.
1251 + */
1252 + private function cli_report_rules_state( array $rules ): void {
1253 + $state = (string) ( $rules['state'] ?? '' );
1254 +
1255 + if ( 'current' === $state ) {
1256 + \WP_CLI::log( 'Cache rules: current — the installed rules are the ones these settings generate.' );
1257 + return;
1258 + }
1259 + if ( 'stale' === $state ) {
1260 + $changed = array_filter( array_map( 'strval', (array) ( $rules['changed'] ?? array() ) ) );
1261 + \WP_CLI::log(
1262 + '' === implode( '', $changed )
1263 + ? 'Cache rules: stale — a different version of the rules is installed. Re-paste the block.'
1264 + : sprintf(
1265 + 'Cache rules: stale — a different version of the rules is installed (%s changed since). Re-paste the block.',
1266 + implode( ', ', $changed )
1267 + )
1268 + );
1269 + return;
1270 + }
1271 + if ( 'absent' === $state ) {
1272 + \WP_CLI::log( 'Cache rules: absent — no xSpeed rules are installed, or they predate the version marker.' );
1273 + return;
1274 + }
1275 +
1276 + \WP_CLI::log( 'Cache rules: unknown — the probe could not read a rules marker back from this server.' );
639 1277 }
640 1278
641 1279 /** `wp xspeed cache inventory [--limit=N]` — which pages are cached, and how old. */
642 1280 private function cli_inventory( int $limit ): void {