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
xspeed / includes / class-cache-plugin-catalog.php

class-cache-plugin-catalog.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.2, at includes/class-cache-plugin-catalog.php

890 lines 36.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cache_Plugin_Catalog — the one description of every caching /
4 * optimization plugin xSpeed knows about.
5 *
6 * Before this existed the same plugins were described twice: once in
7 * Conflict_Registry::matrix() (per-feature refuse/warn strategy) and once in
8 * Server::conflicts() (a flat label list for the health card). The two lists
9 * drifted — Swift Performance was in neither, and Server::conflicts() counted
10 * Autoptimize as a caching conflict even though it only minifies. Both are now
11 * projections of this catalog, so a plugin is added in exactly one place.
12 *
13 * Each entry carries:
14 * - label : human-readable name.
15 * - capabilities : what the plugin actually DOES (page-cache, minify,
16 * object-cache, …). Kept separate from `strategy` because
17 * "is this a page cache?" and "may xSpeed enable minify
18 * alongside it?" are different questions.
19 * - strategy : per-feature refuse | warn | allow, consumed by
20 * Conflict_Registry.
21 * - signals : read-only evidence Page_Cache_Detector looks for beyond
22 * the active-plugin list — constants, classes, options,
23 * files under wp-content, and the token its
24 * advanced-cache.php drop-in identifies itself by.
25 *
26 * Signals exist because "plugin file is in active_plugins" is not the same
27 * question as "is this plugin's page cache live right now". A deactivated
28 * plugin can leave a working drop-in behind; an active one can have page
29 * caching switched off. Nothing here loads or executes foreign plugin code.
30 *
31 * @package XSpeed
32 */
33
34 namespace XSpeed;
35
36 defined( 'ABSPATH' ) || exit;
37
38 final class Cache_Plugin_Catalog {
39
40 public const CAP_PAGE_CACHE = 'page-cache';
41 public const CAP_OBJECT_CACHE = 'object-cache';
42 public const CAP_MINIFY = 'minify';
43 public const CAP_LAZY_LOAD = 'lazy-load';
44 public const CAP_IMAGE_OPT = 'image-opt';
45 public const CAP_CDN = 'cdn';
46 public const CAP_PRELOAD = 'preload';
47
48 /**
49 * @var array|null Memoized (filtered) catalog for this request.
50 */
51 private static $cache = null;
52
53 /**
54 * The catalog. Keys are plugin main-file paths exactly as they appear in
55 * the `active_plugins` option — folder-only checks would false-positive on
56 * a plugin that is merely present on disk.
57 *
58 * @return array<string,array>
59 */
60 private static function entries(): array {
61 return array(
62 /*
63 * xSpeed itself. The catalog answers "does this site already have a
64 * page cache, and whose is it?" — and xSpeed is one. Leaving it out
65 * made a site running our own drop-in report an unattributable one,
66 * and left the plugin unnamed in a refusal that counted it.
67 *
68 * Drop-in token only, deliberately: no constants, options or paths.
69 * Those are residual-evidence signals, and xSpeed's are present on
70 * any site running it — including this suite — which would report a
71 * site as holding leftovers from the very plugin asking. An ACTIVE
72 * xSpeed is already caught by is_plugin_active() on the key.
73 */
74 'xspeed/xspeed.php' => array(
75 'label' => 'xSpeed Cache',
76 'capabilities' => array( self::CAP_PAGE_CACHE ),
77 'dropin' => array( 'XSPEED_DROPIN' ),
78 ),
79 'wp-rocket/wp-rocket.php' => array(
80 'label' => 'WP Rocket',
81 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN, self::CAP_LAZY_LOAD ),
82 'strategy' => array(
83 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
84 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
85 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
86 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
87 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
88 'cdn.rewrite' => Conflict_Registry::STRATEGY_REFUSE,
89 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
90 ),
91 'signals' => array(
92 'constants' => array( 'WP_ROCKET_VERSION' ),
93 'paths' => array( 'cache/wp-rocket' ),
94 'dropin_tokens' => array( 'WP Rocket', 'WP_ROCKET' ),
95 'dropin_identifier_tokens' => array( 'WP_ROCKET_ADVANCED_CACHE' ),
96 ),
97 ),
98 'litespeed-cache/litespeed-cache.php' => array(
99 'label' => 'LiteSpeed Cache',
100 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_OBJECT_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN, self::CAP_LAZY_LOAD ),
101 'strategy' => array(
102 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
103 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
104 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
105 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
106 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
107 'cdn.rewrite' => Conflict_Registry::STRATEGY_WARN,
108 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
109 ),
110 'signals' => array(
111 'constants' => array( 'LSCWP_V', 'LSCWP_DIR' ),
112 'options' => array( 'litespeed.conf.cache' ),
113 'paths' => array( 'litespeed', 'cache/litespeed' ),
114 'dropin_tokens' => array( 'LiteSpeed_Cache', 'LSCWP' ),
115 ),
116 // It caches at server level and writes no drop-in, so its
117 // own switch is the only proof of whether it serves pages.
118 // GridPane keeps it installed on OpenLiteSpeed and turns
119 // the page cache on or off there. (#653)
120 'page_cache_state' => array( self::class, 'litespeed_page_cache_state' ),
121 ),
122 'w3-total-cache/w3-total-cache.php' => array(
123 'label' => 'W3 Total Cache',
124 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_OBJECT_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN ),
125 'strategy' => array(
126 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
127 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
128 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
129 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
130 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
131 'cdn.rewrite' => Conflict_Registry::STRATEGY_WARN,
132 ),
133 'signals' => array(
134 'constants' => array( 'W3TC_DIR', 'W3TC_VERSION' ),
135 'paths' => array( 'w3tc-config/master.php', 'cache/page_enhanced' ),
136 'dropin_tokens' => array( 'W3TC', 'w3-total-cache' ),
137 ),
138 ),
139 'wp-super-cache/wp-cache.php' => array(
140 'label' => 'WP Super Cache',
141 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_PRELOAD ),
142 'strategy' => array(
143 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
144 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
145 ),
146 'signals' => array(
147 'constants' => array( 'WPCACHEHOME' ),
148 'paths' => array( 'wp-cache-config.php', 'cache/supercache' ),
149 'dropin_tokens' => array( 'WP SUPER CACHE', 'wp-cache-phase1' ),
150 ),
151 ),
152 'wp-fastest-cache/wpFastestCache.php' => array(
153 'label' => 'WP Fastest Cache',
154 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_LAZY_LOAD ),
155 'strategy' => array(
156 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
157 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
158 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
159 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
160 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
161 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
162 ),
163 'signals' => array(
164 'classes' => array( 'WpFastestCache' ),
165 'options' => array( 'WpFastestCache' ),
166 'paths' => array( 'cache/all', 'cache/wpfc-minified' ),
167 'dropin_tokens' => array( 'WpFastestCache', 'wpFastestCache' ),
168 ),
169 ),
170 /*
171 * Swift Performance ships as two distinct plugin files (Lite and
172 * the commercial build) that share one options row. It was missing
173 * from both of the old lists — an active Swift page cache looked
174 * like a clear field.
175 */
176 'swift-performance-lite/performance.php' => array(
177 'label' => 'Swift Performance Lite',
178 // Both builds ship the same drop-in banner, so a drop-in can
179 // prove the product and never the edition. `family` is what a
180 // drop-in attribution is allowed to say out loud.
181 'family' => 'Swift Performance',
182 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN, self::CAP_LAZY_LOAD ),
183 'strategy' => array(
184 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
185 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
186 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
187 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
188 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
189 'cdn.rewrite' => Conflict_Registry::STRATEGY_WARN,
190 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
191 ),
192 'signals' => array(
193 'constants' => array( 'SWIFT_PERFORMANCE_VER', 'SWIFT_PERFORMANCE_DIR' ),
194 'options' => array( 'swift_performance_options' ),
195 'paths' => array( 'cache/swift-performance' ),
196 'dropin_tokens' => array( 'Swift Performance', 'swift_performance' ),
197 ),
198 ),
199 'swift-performance/performance.php' => array(
200 'label' => 'Swift Performance',
201 // Both builds ship the same drop-in banner, so a drop-in can
202 // prove the product and never the edition. `family` is what a
203 // drop-in attribution is allowed to say out loud.
204 'family' => 'Swift Performance',
205 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN, self::CAP_LAZY_LOAD ),
206 'strategy' => array(
207 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
208 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
209 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
210 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
211 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
212 'cdn.rewrite' => Conflict_Registry::STRATEGY_WARN,
213 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
214 ),
215 'signals' => array(
216 'constants' => array( 'SWIFT_PERFORMANCE_VER', 'SWIFT_PERFORMANCE_DIR' ),
217 'options' => array( 'swift_performance_options' ),
218 'paths' => array( 'cache/swift-performance' ),
219 'dropin_tokens' => array( 'Swift Performance', 'swift_performance' ),
220 ),
221 ),
222 'cache-enabler/cache-enabler.php' => array(
223 'label' => 'Cache Enabler',
224 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_PRELOAD ),
225 'strategy' => array(
226 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
227 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
228 ),
229 'signals' => array(
230 'constants' => array( 'CACHE_ENABLER_DIR', 'CACHE_ENABLER_VERSION' ),
231 'classes' => array( 'Cache_Enabler_Engine' ),
232 'paths' => array( 'cache/cache-enabler' ),
233 'dropin_tokens' => array( 'Cache_Enabler', 'cache-enabler' ),
234 'dropin_identifier_tokens' => array( 'Cache_Enabler_Engine', 'CACHE_ENABLER_DIR' ),
235 ),
236 ),
237 'comet-cache/comet-cache.php' => array(
238 'label' => 'Comet Cache',
239 'capabilities' => array( self::CAP_PAGE_CACHE ),
240 'strategy' => array(
241 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
242 ),
243 'signals' => array(
244 'paths' => array( 'cache/comet-cache' ),
245 'dropin_tokens' => array( 'comet-cache', 'ZenCache', 'Quick Cache' ),
246 ),
247 ),
248 'hummingbird-performance/wp-hummingbird.php' => array(
249 'label' => 'Hummingbird',
250 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_LAZY_LOAD, self::CAP_PRELOAD, self::CAP_CDN ),
251 'strategy' => array(
252 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
253 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
254 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
255 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
256 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
257 'preload.crawler' => Conflict_Registry::STRATEGY_WARN,
258 'cdn.rewrite' => Conflict_Registry::STRATEGY_WARN,
259 ),
260 'signals' => array(
261 'constants' => array( 'WPHB_ADVANCED_CACHE', 'WPHB_VERSION' ),
262 'paths' => array( 'wphb-cache' ),
263 'dropin_tokens' => array( 'Hummingbird', 'WPHB' ),
264 ),
265 ),
266 'sg-cachepress/sg-cachepress.php' => array(
267 'label' => 'SG Optimizer',
268 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_LAZY_LOAD ),
269 'strategy' => array(
270 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
271 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
272 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
273 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
274 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
275 'images.lazyload' => Conflict_Registry::STRATEGY_WARN,
276 ),
277 'signals' => array(
278 'constants' => array( 'SiteGround_Optimizer\\VERSION' ),
279 'dropin_tokens' => array( 'SG CachePress', 'SiteGround' ),
280 ),
281 ),
282 'breeze/breeze.php' => array(
283 'label' => 'Breeze',
284 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN ),
285 'strategy' => array(
286 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
287 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
288 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
289 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
290 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
291 'cdn.rewrite' => Conflict_Registry::STRATEGY_WARN,
292 ),
293 'signals' => array(
294 'constants' => array( 'BREEZE_VERSION', 'BREEZE_CACHE_DIR' ),
295 'paths' => array( 'cache/breeze-minification' ),
296 'dropin_tokens' => array( 'Breeze', 'breeze-cache' ),
297 ),
298 ),
299 /*
300 * Minification only. It used to appear in Server::conflicts(),
301 * whose health row reads "deactivate before enabling xSpeed cache
302 * to avoid double-caching" — advice that was simply wrong for a
303 * plugin that never writes a page cache.
304 */
305 'autoptimize/autoptimize.php' => array(
306 'label' => 'Autoptimize',
307 'capabilities' => array( self::CAP_MINIFY ),
308 'strategy' => array(
309 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
310 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
311 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
312 ),
313 'signals' => array(
314 'constants' => array( 'AUTOPTIMIZE_PLUGIN_VERSION' ),
315 'paths' => array( 'cache/autoptimize' ),
316 ),
317 ),
318 'flying-press/flying-press.php' => array(
319 'label' => 'FlyingPress',
320 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN, self::CAP_LAZY_LOAD ),
321 'strategy' => array(
322 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
323 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
324 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
325 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
326 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
327 'cdn.rewrite' => Conflict_Registry::STRATEGY_REFUSE,
328 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
329 ),
330 'signals' => array(
331 'constants' => array( 'FLYING_PRESS_VERSION', 'FLYING_PRESS_CACHE_DIR' ),
332 'paths' => array( 'cache/flying-press' ),
333 'dropin_tokens' => array( 'FlyingPress', 'flying-press' ),
334 ),
335 ),
336 'nitropack/main.php' => array(
337 'label' => 'NitroPack',
338 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN, self::CAP_LAZY_LOAD ),
339 'strategy' => array(
340 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
341 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
342 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
343 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
344 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
345 'cdn.rewrite' => Conflict_Registry::STRATEGY_REFUSE,
346 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
347 ),
348 'signals' => array(
349 'constants' => array( 'NITROPACK_VERSION' ),
350 'dropin_tokens' => array( 'NitroPack', 'nitropack' ),
351 ),
352 ),
353 'wp-optimize/wp-optimize.php' => array(
354 'label' => 'WP-Optimize',
355 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY ),
356 'strategy' => array(
357 'cache.page' => Conflict_Registry::STRATEGY_WARN,
358 'minify.html' => Conflict_Registry::STRATEGY_WARN,
359 'minify.css' => Conflict_Registry::STRATEGY_WARN,
360 'minify.js' => Conflict_Registry::STRATEGY_WARN,
361 ),
362 'signals' => array(
363 'constants' => array( 'WPO_VERSION', 'WPO_CACHE_DIR' ),
364 'paths' => array( 'cache/wpo-cache' ),
365 'dropin_tokens' => array( 'WP-Optimize', 'WPO_CACHE' ),
366 ),
367 ),
368 /*
369 * Boost gained a real page cache (its own advanced-cache.php) after
370 * the original matrix was written, which is why the page-cache
371 * capability and the cache.page warning are here now.
372 */
373 'jetpack-boost/jetpack-boost.php' => array(
374 'label' => 'Jetpack Boost',
375 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_LAZY_LOAD ),
376 'strategy' => array(
377 'cache.page' => Conflict_Registry::STRATEGY_WARN,
378 'minify.css' => Conflict_Registry::STRATEGY_WARN,
379 'minify.js' => Conflict_Registry::STRATEGY_WARN,
380 'images.lazyload' => Conflict_Registry::STRATEGY_WARN,
381 ),
382 'signals' => array(
383 'paths' => array( 'boost-cache' ),
384 'dropin_tokens' => array( 'Jetpack Boost', 'jetpack-boost' ),
385 ),
386 ),
387 'wp-asset-clean-up/wpacu.php' => array(
388 'label' => 'Asset CleanUp',
389 'capabilities' => array( self::CAP_MINIFY ),
390 'strategy' => array(
391 'minify.css' => Conflict_Registry::STRATEGY_WARN,
392 'minify.js' => Conflict_Registry::STRATEGY_WARN,
393 ),
394 ),
395 'ewww-image-optimizer/ewww-image-optimizer.php' => array(
396 'label' => 'EWWW Image Optimizer',
397 'capabilities' => array( self::CAP_IMAGE_OPT ),
398 'strategy' => array(
399 'images.optimize' => Conflict_Registry::STRATEGY_REFUSE,
400 ),
401 ),
402 'shortpixel-image-optimiser/wp-shortpixel.php' => array(
403 'label' => 'ShortPixel',
404 'capabilities' => array( self::CAP_IMAGE_OPT ),
405 'strategy' => array(
406 'images.optimize' => Conflict_Registry::STRATEGY_REFUSE,
407 ),
408 ),
409 'wp-smushit/wp-smush.php' => array(
410 'label' => 'Smush',
411 'capabilities' => array( self::CAP_IMAGE_OPT, self::CAP_LAZY_LOAD ),
412 'strategy' => array(
413 'images.optimize' => Conflict_Registry::STRATEGY_REFUSE,
414 'images.lazyload' => Conflict_Registry::STRATEGY_WARN,
415 ),
416 ),
417 /*
418 * Object cache only. It owns object-cache.php, never
419 * advanced-cache.php, so it conflicts with nothing we do and must
420 * never block a page-cache install. Catalogued so the detector can
421 * NAME the object-cache drop-in instead of reporting an unknown
422 * file sitting in wp-content.
423 */
424 'redis-cache/redis-cache.php' => array(
425 'label' => 'Redis Object Cache',
426 'capabilities' => array( self::CAP_OBJECT_CACHE ),
427 'strategy' => array(),
428 'signals' => array(
429 'constants' => array( 'WP_REDIS_VERSION' ),
430 'object_dropin_tokens' => array( 'Redis Object Cache', 'WP_Redis' ),
431 ),
432 ),
433 'memcached/object-cache.php' => array(
434 'label' => 'Memcached Object Cache',
435 'capabilities' => array( self::CAP_OBJECT_CACHE ),
436 'strategy' => array(),
437 'signals' => array(
438 'object_dropin_tokens' => array( 'Memcached', 'memcache' ),
439 ),
440 ),
441 );
442 }
443
444 /**
445 * The catalog, filterable so a Pro module (or a site) can describe a
446 * plugin we don't ship knowledge of. Memoized per request.
447 *
448 * @return array<string,array>
449 */
450 public static function all(): array {
451 if ( null !== self::$cache ) {
452 return self::$cache;
453 }
454
455 $catalog = apply_filters( 'xspeed_cache_plugin_catalog', self::entries() );
456 if ( ! is_array( $catalog ) ) {
457 $catalog = self::entries();
458 }
459
460 // Normalize so consumers never have to null-check an optional key.
461 foreach ( $catalog as $file => $entry ) {
462 $catalog[ $file ] = array(
463 'label' => (string) ( $entry['label'] ?? $file ),
464 // The label to use when only a drop-in identifies the owner
465 // and the drop-in cannot tell two builds apart. Defaults to
466 // the precise label, so an entry without twins needs nothing.
467 'family' => (string) ( $entry['family'] ?? $entry['label'] ?? $file ),
468 'capabilities' => array_values( (array) ( $entry['capabilities'] ?? array() ) ),
469 'strategy' => (array) ( $entry['strategy'] ?? array() ),
470 'signals' => (array) ( $entry['signals'] ?? array() ),
471 // Reads the plugin's own page-cache setting: true on, false
472 // off, null when it cannot be read. See page_cache_state().
473 'page_cache_state' => is_callable( $entry['page_cache_state'] ?? null ) ? $entry['page_cache_state'] : null,
474 );
475 }
476
477 self::$cache = $catalog;
478 return $catalog;
479 }
480
481 /**
482 * One entry, or null when the plugin isn't catalogued.
483 */
484 public static function get( string $plugin_file ): ?array {
485 $all = self::all();
486 return $all[ $plugin_file ] ?? null;
487 }
488
489 /**
490 * Every catalogued plugin declaring a capability.
491 *
492 * @return array<string,array> Keyed by plugin file, same shape as all().
493 */
494 public static function with_capability( string $capability ): array {
495 return array_filter(
496 self::all(),
497 static function ( array $entry ) use ( $capability ) {
498 return in_array( $capability, $entry['capabilities'], true );
499 }
500 );
501 }
502
503 /**
504 * Does this plugin write a page cache? The question behind every
505 * "can xSpeed take the drop-in" decision — as opposed to "does it
506 * overlap with some xSpeed feature", which is what `strategy` answers.
507 */
508 public static function is_page_cache( string $plugin_file ): bool {
509 $entry = self::get( $plugin_file );
510 return null !== $entry && in_array( self::CAP_PAGE_CACHE, $entry['capabilities'], true );
511 }
512
513 /**
514 * Is this plugin's own page cache switched on?
515 *
516 * True when its settings say on, false when they say off, null when we
517 * cannot tell (no reader for this plugin, or the setting is not stored
518 * where we look). "Active" and "caching pages" are different facts: a
519 * plugin can stay active for its other features with its page cache off,
520 * and only false lets a caller treat it as not caching. Null keeps the
521 * old "it is active, so it may be caching" answer.
522 *
523 * Readers only read options. They never load the plugin's code.
524 */
525 public static function page_cache_state( string $plugin_file ): ?bool {
526 $entry = self::get( $plugin_file );
527 if ( null === $entry || ! is_callable( $entry['page_cache_state'] ) ) {
528 return null;
529 }
530 $state = call_user_func( $entry['page_cache_state'] );
531 return is_bool( $state ) ? $state : null;
532 }
533
534 /**
535 * LiteSpeed Cache's page-cache switch, read the way its own
536 * Conf::define_cache() decides it (checked against 7.9):
537 *
538 * - `cache` is 1 (on), 0 (off), or on multisite 2, "use the network
539 * setting", which lives in the network's own `litespeed.conf.cache`;
540 * - QUIC.cloud CDN (`cdn-quic`) turns the page cache on by itself;
541 * - Toolbox > "Disable All Features" (`debug-disable_all`) turns
542 * everything off, whatever `cache` says.
543 *
544 * Without a LiteSpeed web server or QUIC.cloud its page cache has
545 * nothing to run on, so "on" proves nothing there and the answer is
546 * null. "Off" is off on every server.
547 */
548 public static function litespeed_page_cache_state(): ?bool {
549 if ( ! function_exists( 'get_option' ) ) {
550 return null;
551 }
552
553 if ( self::option_is_on( get_option( 'litespeed.conf.debug-disable_all', null ) ) ) {
554 return false;
555 }
556 // "Disable All Features for 24 hours" stores when the pause ends, and
557 // LiteSpeed Cache stands down until then (its Debug2::is_tmp_disable()).
558 $paused_until = get_option( 'litespeed.conf.debug-disable_tmp', 0 );
559 if ( is_numeric( $paused_until ) && time() < (int) $paused_until ) {
560 return false;
561 }
562
563 $cache = get_option( 'litespeed.conf.cache', null );
564 if ( null === $cache || false === $cache || ! is_scalar( $cache ) ) {
565 // Not stored per setting: LiteSpeed Cache 3.x or older, or not
566 // set up yet. Unknown, not off.
567 return null;
568 }
569
570 $on = 1 === (int) $cache;
571 if ( 2 === (int) $cache ) {
572 if ( ! function_exists( 'is_multisite' ) || ! is_multisite() || ! function_exists( 'get_site_option' ) ) {
573 return null;
574 }
575 $network = get_site_option( 'litespeed.conf.cache', null );
576 if ( null === $network || ! is_scalar( $network ) ) {
577 return null;
578 }
579 $on = self::option_is_on( $network );
580 }
581
582 $quic = self::option_is_on( get_option( 'litespeed.conf.cdn-quic', null ) );
583 if ( $quic ) {
584 return true;
585 }
586 if ( ! $on ) {
587 return false;
588 }
589
590 return Server::LITESPEED === Server::type() ? true : null;
591 }
592
593 /** A LiteSpeed Cache switch value as stored: '1', 1, true are on. */
594 private static function option_is_on( $value ): bool {
595 return is_scalar( $value ) && 1 === (int) $value;
596 }
597
598 /**
599 * Match an advanced-cache.php body against the catalog's drop-in tokens.
600 * Returns the plugin file of the owner, or null when nothing matches —
601 * null means "unknown drop-in", NOT "safe to overwrite".
602 *
603 * Catalog tokens are candidates, not proof by themselves. A token only
604 * identifies an owner when it appears as an anchored comment banner or an
605 * exact PHP identifier. Text embedded in an arbitrary string/comment must
606 * not let one plugin claim another plugin's shared drop-in.
607 */
608 public static function identify_dropin( string $contents ): ?string {
609 return self::identify_by_tokens( $contents, 'dropin_tokens' );
610 }
611
612 /**
613 * Same, for object-cache.php. Separate key because a plugin can own one
614 * drop-in and not the other (Redis owns object-cache.php only).
615 */
616 public static function identify_object_dropin( string $contents ): ?string {
617 $hit = self::identify_by_tokens( $contents, 'object_dropin_tokens' );
618 if ( null !== $hit ) {
619 return $hit;
620 }
621 // Several page-cache plugins ship both drop-ins and identify them with
622 // the same token, so fall back to the page-cache token list.
623 return self::identify_by_tokens( $contents, 'dropin_tokens' );
624 }
625
626 private static function identify_by_tokens( string $contents, string $key ): ?string {
627 if ( '' === $contents ) {
628 return null;
629 }
630
631 $evidence = self::signature_evidence( $contents );
632 $owners = array();
633 foreach ( self::all() as $file => $entry ) {
634 foreach ( (array) ( $entry['signals'][ $key ] ?? array() ) as $token ) {
635 if ( self::has_anchored_signature( (string) $token, $evidence ) ) {
636 $owners[ $file ] = true;
637 break;
638 }
639 }
640 if ( isset( $owners[ $file ] ) ) {
641 continue;
642 }
643 foreach ( (array) ( $entry['signals'][ self::identifier_key( $key ) ] ?? array() ) as $token ) {
644 if ( self::declares_identifier( (string) $token, $evidence ) ) {
645 $owners[ $file ] = true;
646 break;
647 }
648 }
649 }
650
651 if ( 1 === count( $owners ) ) {
652 return (string) array_key_first( $owners );
653 }
654 if ( count( $owners ) > 1 ) {
655 return self::resolve_twins( array_keys( $owners ), $key );
656 }
657 return null;
658 }
659
660 /**
661 * More than one catalog entry claimed the drop-in.
662 *
663 * Two genuinely different plugins both anchoring a banner is not reliable
664 * attribution: the detector treats that drop-in as unknown and refuses to
665 * replace it. But two BUILDS of one product — Swift Performance Lite and
666 * the commercial build — declare the same tokens by design, and a drop-in
667 * they wrote is theirs, not unknown (PR #295 review). When every claimant
668 * declares the identical token list, the one on disk wins; failing that,
669 * the first in catalog order.
670 *
671 * @param string[] $owners Plugin files that matched.
672 * @return string|null
673 */
674 private static function resolve_twins( array $owners, string $key ): ?string {
675 $catalog = self::all();
676 $sets = array();
677 foreach ( $owners as $file ) {
678 $tokens = array_merge(
679 (array) ( $catalog[ $file ]['signals'][ $key ] ?? array() ),
680 (array) ( $catalog[ $file ]['signals'][ self::identifier_key( $key ) ] ?? array() )
681 );
682 sort( $tokens );
683 $sets[] = implode( "\0", $tokens );
684 }
685 if ( 1 !== count( array_unique( $sets ) ) ) {
686 return null;
687 }
688
689 $plugin_dir = defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : ( defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/plugins' : '' );
690 if ( '' !== $plugin_dir ) {
691 $installed = array_values(
692 array_filter(
693 $owners,
694 static function ( $file ) use ( $plugin_dir ) {
695 return is_file( $plugin_dir . '/' . $file );
696 }
697 )
698 );
699 if ( 1 === count( $installed ) ) {
700 return (string) $installed[0];
701 }
702 }
703
704 return (string) $owners[0];
705 }
706
707 /**
708 * Extract non-executing, structured signature locations from PHP source.
709 *
710 * @return array{comment_lines:string[],identifiers:string[]}
711 */
712 private static function signature_evidence( string $contents ): array {
713 $comments = array();
714 $identifiers = array();
715
716 $tokens = token_get_all( $contents );
717 foreach ( $tokens as $i => $token ) {
718 // The constant a `define( 'NAME', … )` declares is structured
719 // evidence too: WP Rocket's drop-in announces itself with
720 // define( 'WP_ROCKET_ADVANCED_CACHE', true ) and nothing else on
721 // some builds. The name is quoted, so the identifier scan below
722 // never sees it.
723 if ( self::is_global_define_call( $tokens, $i ) ) {
724 $open = self::next_code_index( $tokens, $i + 1 );
725 $name = null === $open || '(' !== $tokens[ $open ] ? null : self::next_code_index( $tokens, $open + 1 );
726 if ( null !== $name && is_array( $tokens[ $name ] ) && T_CONSTANT_ENCAPSED_STRING === $tokens[ $name ][0] ) {
727 $declared = trim( $tokens[ $name ][1], '"\'' );
728 if ( preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $declared ) ) {
729 $identifiers[] = strtolower( $declared );
730 }
731 }
732 }
733 if ( ! is_array( $token ) ) {
734 continue;
735 }
736 if ( T_STRING === $token[0] || ( defined( 'T_NAME_QUALIFIED' ) && T_NAME_QUALIFIED === $token[0] ) || ( defined( 'T_NAME_FULLY_QUALIFIED' ) && T_NAME_FULLY_QUALIFIED === $token[0] ) ) {
737 foreach ( explode( '\\', trim( $token[1], '\\' ) ) as $identifier ) {
738 if ( '' !== $identifier ) {
739 $identifiers[] = strtolower( $identifier );
740 }
741 }
742 }
743 }
744
745 // Comments from the HEADER only — the run of comments before the
746 // first line of code. A banner further down is a mention, not a
747 // declaration. Same rule as the portable detector's; the two must
748 // agree, because they answer the same question about the same file.
749 foreach ( $tokens as $token ) {
750 $id = is_array( $token ) ? $token[0] : null;
751 if ( T_OPEN_TAG === $id || T_WHITESPACE === $id ) {
752 continue;
753 }
754 // A BOM before `<?php` arrives as inline HTML, not code.
755 if ( T_INLINE_HTML === $id && '' === trim( $token[1], " \t\r\n\0\x0B\xEF\xBB\xBF" ) ) {
756 continue;
757 }
758 if ( ! in_array( $id, array( T_COMMENT, T_DOC_COMMENT ), true ) ) {
759 break;
760 }
761 foreach ( preg_split( '/\\R/', $token[1] ) ?: array() as $line ) {
762 $line = preg_replace( '/^\\s*(?:\/\\*+|\\*|\/\/|#)\\s*/', '', $line );
763 $line = preg_replace( '/\\s*\\*\\/\\s*$/', '', (string) $line );
764 if ( '' !== trim( (string) $line ) ) {
765 $comments[] = trim( (string) $line );
766 }
767 }
768 }
769
770 return array(
771 'comment_lines' => $comments,
772 'identifiers' => array_values( array_unique( $identifiers ) ),
773 );
774 }
775
776 /** `dropin_tokens` -> `dropin_identifier_tokens`. */
777 private static function identifier_key( string $key ): string {
778 return str_replace( '_tokens', '_identifier_tokens', $key );
779 }
780
781 /**
782 * Is the token at $i a call to the GLOBAL define()?
783 *
784 * The same rule as the portable detector's helper of this name, and it
785 * has to be: matching `T_STRING === 'define'` alone missed
786 * `\define( 'X', true )`, which PHP 8 tokenizes as one
787 * T_NAME_FULLY_QUALIFIED — so the two copies attributed the same
788 * drop-in differently, and only on PHP 8.
789 */
790 private static function is_global_define_call( array $tokens, int $i ): bool {
791 $token = $tokens[ $i ];
792 if ( ! is_array( $token ) ) {
793 return false;
794 }
795 if ( defined( 'T_NAME_FULLY_QUALIFIED' ) && T_NAME_FULLY_QUALIFIED === $token[0] ) {
796 return 0 === strcasecmp( $token[1], '\\define' );
797 }
798 if ( T_STRING !== $token[0] || 0 !== strcasecmp( $token[1], 'define' ) ) {
799 return false;
800 }
801 $previous = self::previous_code_index( $tokens, $i );
802 if ( null === $previous || ! is_array( $tokens[ $previous ] ) ) {
803 return true;
804 }
805 if ( in_array( $tokens[ $previous ][0], array( T_OBJECT_OPERATOR, T_DOUBLE_COLON ), true )
806 || ( defined( 'T_NULLSAFE_OBJECT_OPERATOR' ) && T_NULLSAFE_OBJECT_OPERATOR === $tokens[ $previous ][0] ) ) {
807 return false;
808 }
809 if ( T_NS_SEPARATOR === $tokens[ $previous ][0] ) {
810 // `Foo\define` on PHP 7 (T_STRING before the separator), or
811 // `namespace\define` (T_NAMESPACE before it).
812 $before = self::previous_code_index( $tokens, $previous );
813 return null === $before || ! is_array( $tokens[ $before ] ) || ! in_array( $tokens[ $before ][0], array( T_STRING, T_NAMESPACE ), true );
814 }
815 return true;
816 }
817
818 /** Index of the nearest code token before $before, skipping trivia. */
819 private static function previous_code_index( array $tokens, int $before ): ?int {
820 for ( $i = $before - 1; $i >= 0; $i-- ) {
821 if ( ! is_array( $tokens[ $i ] ) || ! in_array( $tokens[ $i ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
822 return $i;
823 }
824 }
825 return null;
826 }
827
828 /** Index of the nearest code token at or after $start, skipping trivia. */
829 private static function next_code_index( array $tokens, int $start ): ?int {
830 $count = count( $tokens );
831 for ( $i = $start; $i < $count; $i++ ) {
832 if ( ! is_array( $tokens[ $i ] ) || ! in_array( $tokens[ $i ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
833 return $i;
834 }
835 }
836 return null;
837 }
838
839 /**
840 * A banner in the file's HEADER naming the candidate, and nothing else.
841 *
842 * The banner must be the whole semantic line, optionally followed by a
843 * version — a sentence such as "compatible with WP Rocket" is not an
844 * ownership signature — or a `@package <candidate>` docblock tag, which
845 * is how W3 Total Cache's drop-in names itself and the only place it
846 * does.
847 */
848 private static function has_anchored_signature( string $candidate, array $evidence ): bool {
849 $candidate = trim( $candidate );
850 if ( '' === $candidate ) {
851 return false;
852 }
853
854 $token = preg_quote( $candidate, '/' ) . '(?:\\s+(?:v(?:ersion)?\\s*)?\\d[A-Za-z0-9._-]*)?';
855 $pattern = '/^(?:@package\\s+)?' . $token . '\\s*$/i';
856 foreach ( $evidence['comment_lines'] as $line ) {
857 if ( preg_match( $pattern, $line ) ) {
858 return true;
859 }
860 }
861
862 return false;
863 }
864
865 /**
866 * A constant or class the file's CODE uses, anywhere in it.
867 *
868 * Much weaker than a header banner: an identifier of the right name in
869 * any arrangement matches, a `defined( 'X' )` guard included. Opt-in per
870 * entry, via `<key>_identifier_tokens`, for the drop-ins that name
871 * themselves no other way — Cache Enabler's header is a prose sentence,
872 * and some WP Rocket builds declare only WP_ROCKET_ADVANCED_CACHE.
873 */
874 private static function declares_identifier( string $candidate, array $evidence ): bool {
875 $candidate = trim( $candidate );
876 if ( '' === $candidate || ! preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $candidate ) ) {
877 return false;
878 }
879 return in_array( strtolower( $candidate ), $evidence['identifiers'], true );
880 }
881
882 /**
883 * Drop the memoized catalog. Called when the filter's inputs can have
884 * changed (plugin activation, module registration).
885 */
886 public static function invalidate(): void {
887 self::$cache = null;
888 }
889 }
890