PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.1.6 1.1.7 1.1.8 1.2.0 1.2.1 All 27 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.3.0, at includes/class-cache-plugin-catalog.php

797 lines 32.5 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 ),
117 'w3-total-cache/w3-total-cache.php' => array(
118 'label' => 'W3 Total Cache',
119 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_OBJECT_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN ),
120 'strategy' => array(
121 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
122 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
123 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
124 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
125 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
126 'cdn.rewrite' => Conflict_Registry::STRATEGY_WARN,
127 ),
128 'signals' => array(
129 'constants' => array( 'W3TC_DIR', 'W3TC_VERSION' ),
130 'paths' => array( 'w3tc-config/master.php', 'cache/page_enhanced' ),
131 'dropin_tokens' => array( 'W3TC', 'w3-total-cache' ),
132 ),
133 ),
134 'wp-super-cache/wp-cache.php' => array(
135 'label' => 'WP Super Cache',
136 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_PRELOAD ),
137 'strategy' => array(
138 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
139 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
140 ),
141 'signals' => array(
142 'constants' => array( 'WPCACHEHOME' ),
143 'paths' => array( 'wp-cache-config.php', 'cache/supercache' ),
144 'dropin_tokens' => array( 'WP SUPER CACHE', 'wp-cache-phase1' ),
145 ),
146 ),
147 'wp-fastest-cache/wpFastestCache.php' => array(
148 'label' => 'WP Fastest Cache',
149 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_LAZY_LOAD ),
150 'strategy' => array(
151 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
152 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
153 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
154 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
155 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
156 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
157 ),
158 'signals' => array(
159 'classes' => array( 'WpFastestCache' ),
160 'options' => array( 'WpFastestCache' ),
161 'paths' => array( 'cache/all', 'cache/wpfc-minified' ),
162 'dropin_tokens' => array( 'WpFastestCache', 'wpFastestCache' ),
163 ),
164 ),
165 /*
166 * Swift Performance ships as two distinct plugin files (Lite and
167 * the commercial build) that share one options row. It was missing
168 * from both of the old lists — an active Swift page cache looked
169 * like a clear field.
170 */
171 'swift-performance-lite/performance.php' => array(
172 'label' => 'Swift Performance Lite',
173 // Both builds ship the same drop-in banner, so a drop-in can
174 // prove the product and never the edition. `family` is what a
175 // drop-in attribution is allowed to say out loud.
176 'family' => 'Swift Performance',
177 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN, self::CAP_LAZY_LOAD ),
178 'strategy' => array(
179 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
180 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
181 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
182 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
183 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
184 'cdn.rewrite' => Conflict_Registry::STRATEGY_WARN,
185 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
186 ),
187 'signals' => array(
188 'constants' => array( 'SWIFT_PERFORMANCE_VER', 'SWIFT_PERFORMANCE_DIR' ),
189 'options' => array( 'swift_performance_options' ),
190 'paths' => array( 'cache/swift-performance' ),
191 'dropin_tokens' => array( 'Swift Performance', 'swift_performance' ),
192 ),
193 ),
194 'swift-performance/performance.php' => array(
195 'label' => 'Swift Performance',
196 // Both builds ship the same drop-in banner, so a drop-in can
197 // prove the product and never the edition. `family` is what a
198 // drop-in attribution is allowed to say out loud.
199 'family' => 'Swift Performance',
200 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN, self::CAP_LAZY_LOAD ),
201 'strategy' => array(
202 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
203 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
204 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
205 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
206 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
207 'cdn.rewrite' => Conflict_Registry::STRATEGY_WARN,
208 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
209 ),
210 'signals' => array(
211 'constants' => array( 'SWIFT_PERFORMANCE_VER', 'SWIFT_PERFORMANCE_DIR' ),
212 'options' => array( 'swift_performance_options' ),
213 'paths' => array( 'cache/swift-performance' ),
214 'dropin_tokens' => array( 'Swift Performance', 'swift_performance' ),
215 ),
216 ),
217 'cache-enabler/cache-enabler.php' => array(
218 'label' => 'Cache Enabler',
219 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_PRELOAD ),
220 'strategy' => array(
221 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
222 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
223 ),
224 'signals' => array(
225 'constants' => array( 'CACHE_ENABLER_DIR', 'CACHE_ENABLER_VERSION' ),
226 'classes' => array( 'Cache_Enabler_Engine' ),
227 'paths' => array( 'cache/cache-enabler' ),
228 'dropin_tokens' => array( 'Cache_Enabler', 'cache-enabler' ),
229 'dropin_identifier_tokens' => array( 'Cache_Enabler_Engine', 'CACHE_ENABLER_DIR' ),
230 ),
231 ),
232 'comet-cache/comet-cache.php' => array(
233 'label' => 'Comet Cache',
234 'capabilities' => array( self::CAP_PAGE_CACHE ),
235 'strategy' => array(
236 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
237 ),
238 'signals' => array(
239 'paths' => array( 'cache/comet-cache' ),
240 'dropin_tokens' => array( 'comet-cache', 'ZenCache', 'Quick Cache' ),
241 ),
242 ),
243 'hummingbird-performance/wp-hummingbird.php' => array(
244 'label' => 'Hummingbird',
245 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_LAZY_LOAD, self::CAP_PRELOAD, self::CAP_CDN ),
246 'strategy' => array(
247 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
248 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
249 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
250 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
251 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
252 'preload.crawler' => Conflict_Registry::STRATEGY_WARN,
253 'cdn.rewrite' => Conflict_Registry::STRATEGY_WARN,
254 ),
255 'signals' => array(
256 'constants' => array( 'WPHB_ADVANCED_CACHE', 'WPHB_VERSION' ),
257 'paths' => array( 'wphb-cache' ),
258 'dropin_tokens' => array( 'Hummingbird', 'WPHB' ),
259 ),
260 ),
261 'sg-cachepress/sg-cachepress.php' => array(
262 'label' => 'SG Optimizer',
263 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_LAZY_LOAD ),
264 'strategy' => array(
265 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
266 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
267 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
268 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
269 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
270 'images.lazyload' => Conflict_Registry::STRATEGY_WARN,
271 ),
272 'signals' => array(
273 'constants' => array( 'SiteGround_Optimizer\\VERSION' ),
274 'dropin_tokens' => array( 'SG CachePress', 'SiteGround' ),
275 ),
276 ),
277 'breeze/breeze.php' => array(
278 'label' => 'Breeze',
279 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN ),
280 'strategy' => array(
281 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
282 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
283 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
284 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
285 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
286 'cdn.rewrite' => Conflict_Registry::STRATEGY_WARN,
287 ),
288 'signals' => array(
289 'constants' => array( 'BREEZE_VERSION', 'BREEZE_CACHE_DIR' ),
290 'paths' => array( 'cache/breeze-minification' ),
291 'dropin_tokens' => array( 'Breeze', 'breeze-cache' ),
292 ),
293 ),
294 /*
295 * Minification only. It used to appear in Server::conflicts(),
296 * whose health row reads "deactivate before enabling xSpeed cache
297 * to avoid double-caching" — advice that was simply wrong for a
298 * plugin that never writes a page cache.
299 */
300 'autoptimize/autoptimize.php' => array(
301 'label' => 'Autoptimize',
302 'capabilities' => array( self::CAP_MINIFY ),
303 'strategy' => array(
304 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
305 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
306 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
307 ),
308 'signals' => array(
309 'constants' => array( 'AUTOPTIMIZE_PLUGIN_VERSION' ),
310 'paths' => array( 'cache/autoptimize' ),
311 ),
312 ),
313 'flying-press/flying-press.php' => array(
314 'label' => 'FlyingPress',
315 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN, self::CAP_LAZY_LOAD ),
316 'strategy' => array(
317 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
318 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
319 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
320 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
321 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
322 'cdn.rewrite' => Conflict_Registry::STRATEGY_REFUSE,
323 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
324 ),
325 'signals' => array(
326 'constants' => array( 'FLYING_PRESS_VERSION', 'FLYING_PRESS_CACHE_DIR' ),
327 'paths' => array( 'cache/flying-press' ),
328 'dropin_tokens' => array( 'FlyingPress', 'flying-press' ),
329 ),
330 ),
331 'nitropack/main.php' => array(
332 'label' => 'NitroPack',
333 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_PRELOAD, self::CAP_CDN, self::CAP_LAZY_LOAD ),
334 'strategy' => array(
335 'cache.page' => Conflict_Registry::STRATEGY_REFUSE,
336 'minify.html' => Conflict_Registry::STRATEGY_REFUSE,
337 'minify.css' => Conflict_Registry::STRATEGY_REFUSE,
338 'minify.js' => Conflict_Registry::STRATEGY_REFUSE,
339 'preload.crawler' => Conflict_Registry::STRATEGY_REFUSE,
340 'cdn.rewrite' => Conflict_Registry::STRATEGY_REFUSE,
341 'images.lazyload' => Conflict_Registry::STRATEGY_REFUSE,
342 ),
343 'signals' => array(
344 'constants' => array( 'NITROPACK_VERSION' ),
345 'dropin_tokens' => array( 'NitroPack', 'nitropack' ),
346 ),
347 ),
348 'wp-optimize/wp-optimize.php' => array(
349 'label' => 'WP-Optimize',
350 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY ),
351 'strategy' => array(
352 'cache.page' => Conflict_Registry::STRATEGY_WARN,
353 'minify.html' => Conflict_Registry::STRATEGY_WARN,
354 'minify.css' => Conflict_Registry::STRATEGY_WARN,
355 'minify.js' => Conflict_Registry::STRATEGY_WARN,
356 ),
357 'signals' => array(
358 'constants' => array( 'WPO_VERSION', 'WPO_CACHE_DIR' ),
359 'paths' => array( 'cache/wpo-cache' ),
360 'dropin_tokens' => array( 'WP-Optimize', 'WPO_CACHE' ),
361 ),
362 ),
363 /*
364 * Boost gained a real page cache (its own advanced-cache.php) after
365 * the original matrix was written, which is why the page-cache
366 * capability and the cache.page warning are here now.
367 */
368 'jetpack-boost/jetpack-boost.php' => array(
369 'label' => 'Jetpack Boost',
370 'capabilities' => array( self::CAP_PAGE_CACHE, self::CAP_MINIFY, self::CAP_LAZY_LOAD ),
371 'strategy' => array(
372 'cache.page' => Conflict_Registry::STRATEGY_WARN,
373 'minify.css' => Conflict_Registry::STRATEGY_WARN,
374 'minify.js' => Conflict_Registry::STRATEGY_WARN,
375 'images.lazyload' => Conflict_Registry::STRATEGY_WARN,
376 ),
377 'signals' => array(
378 'paths' => array( 'boost-cache' ),
379 'dropin_tokens' => array( 'Jetpack Boost', 'jetpack-boost' ),
380 ),
381 ),
382 'wp-asset-clean-up/wpacu.php' => array(
383 'label' => 'Asset CleanUp',
384 'capabilities' => array( self::CAP_MINIFY ),
385 'strategy' => array(
386 'minify.css' => Conflict_Registry::STRATEGY_WARN,
387 'minify.js' => Conflict_Registry::STRATEGY_WARN,
388 ),
389 ),
390 'ewww-image-optimizer/ewww-image-optimizer.php' => array(
391 'label' => 'EWWW Image Optimizer',
392 'capabilities' => array( self::CAP_IMAGE_OPT ),
393 'strategy' => array(
394 'images.optimize' => Conflict_Registry::STRATEGY_REFUSE,
395 ),
396 ),
397 'shortpixel-image-optimiser/wp-shortpixel.php' => array(
398 'label' => 'ShortPixel',
399 'capabilities' => array( self::CAP_IMAGE_OPT ),
400 'strategy' => array(
401 'images.optimize' => Conflict_Registry::STRATEGY_REFUSE,
402 ),
403 ),
404 'wp-smushit/wp-smush.php' => array(
405 'label' => 'Smush',
406 'capabilities' => array( self::CAP_IMAGE_OPT, self::CAP_LAZY_LOAD ),
407 'strategy' => array(
408 'images.optimize' => Conflict_Registry::STRATEGY_REFUSE,
409 'images.lazyload' => Conflict_Registry::STRATEGY_WARN,
410 ),
411 ),
412 /*
413 * Object cache only. It owns object-cache.php, never
414 * advanced-cache.php, so it conflicts with nothing we do and must
415 * never block a page-cache install. Catalogued so the detector can
416 * NAME the object-cache drop-in instead of reporting an unknown
417 * file sitting in wp-content.
418 */
419 'redis-cache/redis-cache.php' => array(
420 'label' => 'Redis Object Cache',
421 'capabilities' => array( self::CAP_OBJECT_CACHE ),
422 'strategy' => array(),
423 'signals' => array(
424 'constants' => array( 'WP_REDIS_VERSION' ),
425 'object_dropin_tokens' => array( 'Redis Object Cache', 'WP_Redis' ),
426 ),
427 ),
428 'memcached/object-cache.php' => array(
429 'label' => 'Memcached Object Cache',
430 'capabilities' => array( self::CAP_OBJECT_CACHE ),
431 'strategy' => array(),
432 'signals' => array(
433 'object_dropin_tokens' => array( 'Memcached', 'memcache' ),
434 ),
435 ),
436 );
437 }
438
439 /**
440 * The catalog, filterable so a Pro module (or a site) can describe a
441 * plugin we don't ship knowledge of. Memoized per request.
442 *
443 * @return array<string,array>
444 */
445 public static function all(): array {
446 if ( null !== self::$cache ) {
447 return self::$cache;
448 }
449
450 $catalog = apply_filters( 'xspeed_cache_plugin_catalog', self::entries() );
451 if ( ! is_array( $catalog ) ) {
452 $catalog = self::entries();
453 }
454
455 // Normalize so consumers never have to null-check an optional key.
456 foreach ( $catalog as $file => $entry ) {
457 $catalog[ $file ] = array(
458 'label' => (string) ( $entry['label'] ?? $file ),
459 // The label to use when only a drop-in identifies the owner
460 // and the drop-in cannot tell two builds apart. Defaults to
461 // the precise label, so an entry without twins needs nothing.
462 'family' => (string) ( $entry['family'] ?? $entry['label'] ?? $file ),
463 'capabilities' => array_values( (array) ( $entry['capabilities'] ?? array() ) ),
464 'strategy' => (array) ( $entry['strategy'] ?? array() ),
465 'signals' => (array) ( $entry['signals'] ?? array() ),
466 );
467 }
468
469 self::$cache = $catalog;
470 return $catalog;
471 }
472
473 /**
474 * One entry, or null when the plugin isn't catalogued.
475 */
476 public static function get( string $plugin_file ): ?array {
477 $all = self::all();
478 return $all[ $plugin_file ] ?? null;
479 }
480
481 /**
482 * Every catalogued plugin declaring a capability.
483 *
484 * @return array<string,array> Keyed by plugin file, same shape as all().
485 */
486 public static function with_capability( string $capability ): array {
487 return array_filter(
488 self::all(),
489 static function ( array $entry ) use ( $capability ) {
490 return in_array( $capability, $entry['capabilities'], true );
491 }
492 );
493 }
494
495 /**
496 * Does this plugin write a page cache? The question behind every
497 * "can xSpeed take the drop-in" decision — as opposed to "does it
498 * overlap with some xSpeed feature", which is what `strategy` answers.
499 */
500 public static function is_page_cache( string $plugin_file ): bool {
501 $entry = self::get( $plugin_file );
502 return null !== $entry && in_array( self::CAP_PAGE_CACHE, $entry['capabilities'], true );
503 }
504
505 /**
506 * Match an advanced-cache.php body against the catalog's drop-in tokens.
507 * Returns the plugin file of the owner, or null when nothing matches —
508 * null means "unknown drop-in", NOT "safe to overwrite".
509 *
510 * Catalog tokens are candidates, not proof by themselves. A token only
511 * identifies an owner when it appears as an anchored comment banner or an
512 * exact PHP identifier. Text embedded in an arbitrary string/comment must
513 * not let one plugin claim another plugin's shared drop-in.
514 */
515 public static function identify_dropin( string $contents ): ?string {
516 return self::identify_by_tokens( $contents, 'dropin_tokens' );
517 }
518
519 /**
520 * Same, for object-cache.php. Separate key because a plugin can own one
521 * drop-in and not the other (Redis owns object-cache.php only).
522 */
523 public static function identify_object_dropin( string $contents ): ?string {
524 $hit = self::identify_by_tokens( $contents, 'object_dropin_tokens' );
525 if ( null !== $hit ) {
526 return $hit;
527 }
528 // Several page-cache plugins ship both drop-ins and identify them with
529 // the same token, so fall back to the page-cache token list.
530 return self::identify_by_tokens( $contents, 'dropin_tokens' );
531 }
532
533 private static function identify_by_tokens( string $contents, string $key ): ?string {
534 if ( '' === $contents ) {
535 return null;
536 }
537
538 $evidence = self::signature_evidence( $contents );
539 $owners = array();
540 foreach ( self::all() as $file => $entry ) {
541 foreach ( (array) ( $entry['signals'][ $key ] ?? array() ) as $token ) {
542 if ( self::has_anchored_signature( (string) $token, $evidence ) ) {
543 $owners[ $file ] = true;
544 break;
545 }
546 }
547 if ( isset( $owners[ $file ] ) ) {
548 continue;
549 }
550 foreach ( (array) ( $entry['signals'][ self::identifier_key( $key ) ] ?? array() ) as $token ) {
551 if ( self::declares_identifier( (string) $token, $evidence ) ) {
552 $owners[ $file ] = true;
553 break;
554 }
555 }
556 }
557
558 if ( 1 === count( $owners ) ) {
559 return (string) array_key_first( $owners );
560 }
561 if ( count( $owners ) > 1 ) {
562 return self::resolve_twins( array_keys( $owners ), $key );
563 }
564 return null;
565 }
566
567 /**
568 * More than one catalog entry claimed the drop-in.
569 *
570 * Two genuinely different plugins both anchoring a banner is not reliable
571 * attribution: the detector treats that drop-in as unknown and refuses to
572 * replace it. But two BUILDS of one product — Swift Performance Lite and
573 * the commercial build — declare the same tokens by design, and a drop-in
574 * they wrote is theirs, not unknown (PR #295 review). When every claimant
575 * declares the identical token list, the one on disk wins; failing that,
576 * the first in catalog order.
577 *
578 * @param string[] $owners Plugin files that matched.
579 * @return string|null
580 */
581 private static function resolve_twins( array $owners, string $key ): ?string {
582 $catalog = self::all();
583 $sets = array();
584 foreach ( $owners as $file ) {
585 $tokens = array_merge(
586 (array) ( $catalog[ $file ]['signals'][ $key ] ?? array() ),
587 (array) ( $catalog[ $file ]['signals'][ self::identifier_key( $key ) ] ?? array() )
588 );
589 sort( $tokens );
590 $sets[] = implode( "\0", $tokens );
591 }
592 if ( 1 !== count( array_unique( $sets ) ) ) {
593 return null;
594 }
595
596 $plugin_dir = defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : ( defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/plugins' : '' );
597 if ( '' !== $plugin_dir ) {
598 $installed = array_values(
599 array_filter(
600 $owners,
601 static function ( $file ) use ( $plugin_dir ) {
602 return is_file( $plugin_dir . '/' . $file );
603 }
604 )
605 );
606 if ( 1 === count( $installed ) ) {
607 return (string) $installed[0];
608 }
609 }
610
611 return (string) $owners[0];
612 }
613
614 /**
615 * Extract non-executing, structured signature locations from PHP source.
616 *
617 * @return array{comment_lines:string[],identifiers:string[]}
618 */
619 private static function signature_evidence( string $contents ): array {
620 $comments = array();
621 $identifiers = array();
622
623 $tokens = token_get_all( $contents );
624 foreach ( $tokens as $i => $token ) {
625 // The constant a `define( 'NAME', … )` declares is structured
626 // evidence too: WP Rocket's drop-in announces itself with
627 // define( 'WP_ROCKET_ADVANCED_CACHE', true ) and nothing else on
628 // some builds. The name is quoted, so the identifier scan below
629 // never sees it.
630 if ( self::is_global_define_call( $tokens, $i ) ) {
631 $open = self::next_code_index( $tokens, $i + 1 );
632 $name = null === $open || '(' !== $tokens[ $open ] ? null : self::next_code_index( $tokens, $open + 1 );
633 if ( null !== $name && is_array( $tokens[ $name ] ) && T_CONSTANT_ENCAPSED_STRING === $tokens[ $name ][0] ) {
634 $declared = trim( $tokens[ $name ][1], '"\'' );
635 if ( preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $declared ) ) {
636 $identifiers[] = strtolower( $declared );
637 }
638 }
639 }
640 if ( ! is_array( $token ) ) {
641 continue;
642 }
643 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] ) ) {
644 foreach ( explode( '\\', trim( $token[1], '\\' ) ) as $identifier ) {
645 if ( '' !== $identifier ) {
646 $identifiers[] = strtolower( $identifier );
647 }
648 }
649 }
650 }
651
652 // Comments from the HEADER only — the run of comments before the
653 // first line of code. A banner further down is a mention, not a
654 // declaration. Same rule as the portable detector's; the two must
655 // agree, because they answer the same question about the same file.
656 foreach ( $tokens as $token ) {
657 $id = is_array( $token ) ? $token[0] : null;
658 if ( T_OPEN_TAG === $id || T_WHITESPACE === $id ) {
659 continue;
660 }
661 // A BOM before `<?php` arrives as inline HTML, not code.
662 if ( T_INLINE_HTML === $id && '' === trim( $token[1], " \t\r\n\0\x0B\xEF\xBB\xBF" ) ) {
663 continue;
664 }
665 if ( ! in_array( $id, array( T_COMMENT, T_DOC_COMMENT ), true ) ) {
666 break;
667 }
668 foreach ( preg_split( '/\\R/', $token[1] ) ?: array() as $line ) {
669 $line = preg_replace( '/^\\s*(?:\/\\*+|\\*|\/\/|#)\\s*/', '', $line );
670 $line = preg_replace( '/\\s*\\*\\/\\s*$/', '', (string) $line );
671 if ( '' !== trim( (string) $line ) ) {
672 $comments[] = trim( (string) $line );
673 }
674 }
675 }
676
677 return array(
678 'comment_lines' => $comments,
679 'identifiers' => array_values( array_unique( $identifiers ) ),
680 );
681 }
682
683 /** `dropin_tokens` -> `dropin_identifier_tokens`. */
684 private static function identifier_key( string $key ): string {
685 return str_replace( '_tokens', '_identifier_tokens', $key );
686 }
687
688 /**
689 * Is the token at $i a call to the GLOBAL define()?
690 *
691 * The same rule as the portable detector's helper of this name, and it
692 * has to be: matching `T_STRING === 'define'` alone missed
693 * `\define( 'X', true )`, which PHP 8 tokenizes as one
694 * T_NAME_FULLY_QUALIFIED — so the two copies attributed the same
695 * drop-in differently, and only on PHP 8.
696 */
697 private static function is_global_define_call( array $tokens, int $i ): bool {
698 $token = $tokens[ $i ];
699 if ( ! is_array( $token ) ) {
700 return false;
701 }
702 if ( defined( 'T_NAME_FULLY_QUALIFIED' ) && T_NAME_FULLY_QUALIFIED === $token[0] ) {
703 return 0 === strcasecmp( $token[1], '\\define' );
704 }
705 if ( T_STRING !== $token[0] || 0 !== strcasecmp( $token[1], 'define' ) ) {
706 return false;
707 }
708 $previous = self::previous_code_index( $tokens, $i );
709 if ( null === $previous || ! is_array( $tokens[ $previous ] ) ) {
710 return true;
711 }
712 if ( in_array( $tokens[ $previous ][0], array( T_OBJECT_OPERATOR, T_DOUBLE_COLON ), true )
713 || ( defined( 'T_NULLSAFE_OBJECT_OPERATOR' ) && T_NULLSAFE_OBJECT_OPERATOR === $tokens[ $previous ][0] ) ) {
714 return false;
715 }
716 if ( T_NS_SEPARATOR === $tokens[ $previous ][0] ) {
717 // `Foo\define` on PHP 7 (T_STRING before the separator), or
718 // `namespace\define` (T_NAMESPACE before it).
719 $before = self::previous_code_index( $tokens, $previous );
720 return null === $before || ! is_array( $tokens[ $before ] ) || ! in_array( $tokens[ $before ][0], array( T_STRING, T_NAMESPACE ), true );
721 }
722 return true;
723 }
724
725 /** Index of the nearest code token before $before, skipping trivia. */
726 private static function previous_code_index( array $tokens, int $before ): ?int {
727 for ( $i = $before - 1; $i >= 0; $i-- ) {
728 if ( ! is_array( $tokens[ $i ] ) || ! in_array( $tokens[ $i ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
729 return $i;
730 }
731 }
732 return null;
733 }
734
735 /** Index of the nearest code token at or after $start, skipping trivia. */
736 private static function next_code_index( array $tokens, int $start ): ?int {
737 $count = count( $tokens );
738 for ( $i = $start; $i < $count; $i++ ) {
739 if ( ! is_array( $tokens[ $i ] ) || ! in_array( $tokens[ $i ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
740 return $i;
741 }
742 }
743 return null;
744 }
745
746 /**
747 * A banner in the file's HEADER naming the candidate, and nothing else.
748 *
749 * The banner must be the whole semantic line, optionally followed by a
750 * version — a sentence such as "compatible with WP Rocket" is not an
751 * ownership signature — or a `@package <candidate>` docblock tag, which
752 * is how W3 Total Cache's drop-in names itself and the only place it
753 * does.
754 */
755 private static function has_anchored_signature( string $candidate, array $evidence ): bool {
756 $candidate = trim( $candidate );
757 if ( '' === $candidate ) {
758 return false;
759 }
760
761 $token = preg_quote( $candidate, '/' ) . '(?:\\s+(?:v(?:ersion)?\\s*)?\\d[A-Za-z0-9._-]*)?';
762 $pattern = '/^(?:@package\\s+)?' . $token . '\\s*$/i';
763 foreach ( $evidence['comment_lines'] as $line ) {
764 if ( preg_match( $pattern, $line ) ) {
765 return true;
766 }
767 }
768
769 return false;
770 }
771
772 /**
773 * A constant or class the file's CODE uses, anywhere in it.
774 *
775 * Much weaker than a header banner: an identifier of the right name in
776 * any arrangement matches, a `defined( 'X' )` guard included. Opt-in per
777 * entry, via `<key>_identifier_tokens`, for the drop-ins that name
778 * themselves no other way — Cache Enabler's header is a prose sentence,
779 * and some WP Rocket builds declare only WP_ROCKET_ADVANCED_CACHE.
780 */
781 private static function declares_identifier( string $candidate, array $evidence ): bool {
782 $candidate = trim( $candidate );
783 if ( '' === $candidate || ! preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $candidate ) ) {
784 return false;
785 }
786 return in_array( strtolower( $candidate ), $evidence['identifiers'], true );
787 }
788
789 /**
790 * Drop the memoized catalog. Called when the filter's inputs can have
791 * changed (plugin activation, module registration).
792 */
793 public static function invalidate(): void {
794 self::$cache = null;
795 }
796 }
797