| @@ -52,8 +52,39 @@ | ||
| 52 | 52 | */ |
| 53 | 53 | class Cache_Purger { |
| 54 | 54 | |
| 55 | 55 | /** |
| 56 | + * A cache this class can drop one URL from. | |
| 57 | + * | |
| 58 | + * @since 2.14.0 | |
| 59 | + * @var string | |
| 60 | + */ | |
| 61 | + public const PURGE_URL = 'url'; | |
| 62 | + | |
| 63 | + /** | |
| 64 | + * A cache whose narrowest purge is one post. | |
| 65 | + * | |
| 66 | + * Three of the integrations below expose a per-post hook and no per-URL | |
| 67 | + * one. They are purged from `purge_posts()`, which has the post ID, and | |
| 68 | + * cannot be reached by `purge_urls()` alone: an llms.txt document has no | |
| 69 | + * post behind it. That difference is what `$target` reports on, rather | |
| 70 | + * than a flat "purgeable" that would be true on one path and false on the | |
| 71 | + * other while saying the same thing to both. | |
| 72 | + * | |
| 73 | + * @since 2.14.0 | |
| 74 | + * @var string | |
| 75 | + */ | |
| 76 | + public const PURGE_POST = 'post'; | |
| 77 | + | |
| 78 | + /** | |
| 79 | + * A cache that cannot be purged from here at all. | |
| 80 | + * | |
| 81 | + * @since 2.14.0 | |
| 82 | + * @var string | |
| 83 | + */ | |
| 84 | + public const PURGE_NONE = 'none'; | |
| 85 | + | |
| 86 | + /** | |
| 56 | 87 | * Elementor's per-document rendered-element cache. |
| 57 | 88 | * |
| 58 | 89 | * Mirrors `Elementor\Core\Base\Document::CACHE_META_KEY`. The literal is |
| 59 | 90 | * used rather than the constant because Elementor may not be loaded, and |
| @@ -79,66 +110,94 @@ | ||
| 79 | 110 | */ |
| 80 | 111 | private static array $purged = []; |
| 81 | 112 | |
| 82 | 113 | /** |
| 83 | - * Caching plugins ThinkRank knows about, and whether it can purge them. | |
| 114 | + * Caching plugins ThinkRank knows about, and how narrowly it can purge | |
| 115 | + * each one. | |
| 84 | 116 | * |
| 85 | 117 | * Detection is a presence check on the plugin's own constant or class, not |
| 86 | 118 | * on its purge function: a plugin that loads after this one would report as |
| 87 | - * absent, and the plugins that matter most for the warning are the ones | |
| 88 | - * that expose no purge function at all. | |
| 119 | + * absent, and the plugin that matters most for the warning is the one that | |
| 120 | + * exposes no purge API at all. | |
| 89 | 121 | * |
| 122 | + * Every entry here was read from the plugin's own source at the version | |
| 123 | + * named, not from its documentation. Five of them were marked unpurgeable | |
| 124 | + * on the strength of the documentation alone and were wrong (#883). | |
| 125 | + * | |
| 90 | 126 | * @since 2.12.0 |
| 91 | - * @return array<string, array{detect: callable, purgeable: bool}> | |
| 127 | + * @return array<string, array{detect: callable, purge: string}> | |
| 92 | 128 | */ |
| 93 | 129 | private static function known_caches(): array { |
| 94 | 130 | return [ |
| 95 | 131 | 'WP Rocket' => [ |
| 96 | - 'detect' => static fn(): bool => defined('WP_ROCKET_VERSION'), | |
| 97 | - 'purgeable' => true, | |
| 132 | + 'detect' => static fn(): bool => defined('WP_ROCKET_VERSION'), | |
| 133 | + 'purge' => self::PURGE_URL, | |
| 98 | 134 | ], |
| 99 | 135 | 'W3 Total Cache' => [ |
| 100 | - 'detect' => static fn(): bool => defined('W3TC'), | |
| 101 | - 'purgeable' => true, | |
| 136 | + 'detect' => static fn(): bool => defined('W3TC'), | |
| 137 | + 'purge' => self::PURGE_URL, | |
| 102 | 138 | ], |
| 103 | 139 | 'WP Super Cache' => [ |
| 104 | - 'detect' => static fn(): bool => defined('WPCACHEHOME'), | |
| 105 | - 'purgeable' => true, | |
| 140 | + 'detect' => static fn(): bool => defined('WPCACHEHOME'), | |
| 141 | + 'purge' => self::PURGE_URL, | |
| 106 | 142 | ], |
| 107 | 143 | 'LiteSpeed Cache' => [ |
| 108 | - 'detect' => static fn(): bool => defined('LSCWP_V'), | |
| 109 | - 'purgeable' => true, | |
| 144 | + 'detect' => static fn(): bool => defined('LSCWP_V'), | |
| 145 | + 'purge' => self::PURGE_URL, | |
| 110 | 146 | ], |
| 111 | 147 | 'Nginx Helper' => [ |
| 112 | - 'detect' => static fn(): bool => class_exists('Nginx_Helper'), | |
| 113 | - 'purgeable' => true, | |
| 148 | + 'detect' => static fn(): bool => class_exists('Nginx_Helper'), | |
| 149 | + 'purge' => self::PURGE_URL, | |
| 114 | 150 | ], |
| 115 | - // Everything below is detected but not purgeable from here. Each | |
| 116 | - // one either exposes no per-URL API or needs credentials ThinkRank | |
| 117 | - // does not hold, so the honest answer is a warning to the caller. | |
| 118 | - 'WP Fastest Cache' => [ | |
| 119 | - 'detect' => static fn(): bool => defined('WPFC_MAIN_PATH') || class_exists('WpFastestCache'), | |
| 120 | - 'purgeable' => false, | |
| 151 | + // Cache Enabler 1.8.17 registers both of its clear hooks in | |
| 152 | + // Cache_Enabler::init(): `cache_enabler_clear_page_cache_by_url` | |
| 153 | + // runs clear_page_cache_by_url(). The hook is the published API and | |
| 154 | + // survives the 1.8.0 deprecation of clear_page_cache_by_post_id(), | |
| 155 | + // so this integrates against the hook rather than the class. | |
| 156 | + 'Cache Enabler' => [ | |
| 157 | + 'detect' => static fn(): bool => class_exists('Cache_Enabler'), | |
| 158 | + 'purge' => self::PURGE_URL, | |
| 121 | 159 | ], |
| 160 | + // SiteGround Optimizer 7.8.3 exposes | |
| 161 | + // Supercacher::purge_cache_request($url) as a public static. It has | |
| 162 | + // no published hook, so the method is called directly, guarded. | |
| 122 | 163 | 'SiteGround Optimizer' => [ |
| 123 | - 'detect' => static fn(): bool => class_exists('SiteGround_Optimizer\\Supercacher\\Supercacher'), | |
| 124 | - 'purgeable' => false, | |
| 164 | + 'detect' => static fn(): bool => class_exists('SiteGround_Optimizer\\Supercacher\\Supercacher'), | |
| 165 | + 'purge' => self::PURGE_URL, | |
| 125 | 166 | ], |
| 126 | - 'Cache Enabler' => [ | |
| 127 | - 'detect' => static fn(): bool => class_exists('Cache_Enabler'), | |
| 128 | - 'purgeable' => false, | |
| 167 | + // The three below purge a post, not a URL. See PURGE_POST. | |
| 168 | + // | |
| 169 | + // WP Fastest Cache 1.5.2 listens on `wpfc_clear_post_cache_by_id` | |
| 170 | + // with singleDeleteCache($comment_id, $post_id, $clear_parents), | |
| 171 | + // so the post ID is the second argument and the first is unused. | |
| 172 | + 'WP Fastest Cache' => [ | |
| 173 | + 'detect' => static fn(): bool => defined('WPFC_MAIN_PATH') || class_exists('WpFastestCache'), | |
| 174 | + 'purge' => self::PURGE_POST, | |
| 129 | 175 | ], |
| 176 | + // Hummingbird 3.21.2 listens on `wphb_clear_page_cache` with | |
| 177 | + // clear_cache_action($post_id), which purges that post when given | |
| 178 | + // an ID and the whole cache when given nothing. It is always given | |
| 179 | + // an ID here. | |
| 180 | + 'Hummingbird' => [ | |
| 181 | + 'detect' => static fn(): bool => defined('WPHB_VERSION'), | |
| 182 | + 'purge' => self::PURGE_POST, | |
| 183 | + ], | |
| 184 | + // Breeze 2.6.0 listens on `purge_post_cache` with | |
| 185 | + // purge_post_cache($post_id). The hook name carries no vendor | |
| 186 | + // prefix, which is Breeze's choice and not something this can fix; | |
| 187 | + // it is fired with a post ID, which is the only shape Breeze reads. | |
| 130 | 188 | 'Breeze' => [ |
| 131 | - 'detect' => static fn(): bool => defined('BREEZE_VERSION'), | |
| 132 | - 'purgeable' => false, | |
| 189 | + 'detect' => static fn(): bool => defined('BREEZE_VERSION'), | |
| 190 | + 'purge' => self::PURGE_POST, | |
| 133 | 191 | ], |
| 134 | - 'Hummingbird' => [ | |
| 135 | - 'detect' => static fn(): bool => defined('WPHB_VERSION'), | |
| 136 | - 'purgeable' => false, | |
| 137 | - ], | |
| 192 | + // Cloudflare stays unpurgeable, and is the only one that does. | |
| 193 | + // Its plugin purges on its own post events through | |
| 194 | + // Hooks::purgeCacheByRelevantURLs(), an internal method with no | |
| 195 | + // published hook in front of it, so there is nothing to call that | |
| 196 | + // is not reaching into its internals. | |
| 138 | 197 | 'Cloudflare' => [ |
| 139 | - 'detect' => static fn(): bool => class_exists('CF\\WordPress\\Hooks'), | |
| 140 | - 'purgeable' => false, | |
| 198 | + 'detect' => static fn(): bool => class_exists('CF\\WordPress\\Hooks'), | |
| 199 | + 'purge' => self::PURGE_NONE, | |
| 141 | 200 | ], |
| 142 | 201 | ]; |
| 143 | 202 | } |
| 144 | 203 | |
| @@ -176,8 +235,9 @@ | ||
| 176 | 235 | // still gets purged. The detector decides only what to warn about. |
| 177 | 236 | // These are third-party hook names ThinkRank fires, not ours to prefix. |
| 178 | 237 | foreach ($urls as $url) { |
| 179 | 238 | do_action('litespeed_purge_url', $url); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound |
| 239 | + do_action('cache_enabler_clear_page_cache_by_url', $url); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound | |
| 180 | 240 | |
| 181 | 241 | if (function_exists('w3tc_flush_url')) { |
| 182 | 242 | w3tc_flush_url($url); |
| 183 | 243 | } |
| @@ -183,8 +243,15 @@ | ||
| 183 | 243 | } |
| 184 | 244 | if (function_exists('wpsc_delete_url_cache')) { |
| 185 | 245 | wpsc_delete_url_cache($url); |
| 186 | 246 | } |
| 247 | + // SiteGround publishes no hook for this, so the static is called | |
| 248 | + // directly. It returns early on its own when the site is not on | |
| 249 | + // SiteGround and file caching is off, so calling it unconditionally | |
| 250 | + // costs nothing on a site that merely has the plugin installed. | |
| 251 | + if (is_callable(['SiteGround_Optimizer\\Supercacher\\Supercacher', 'purge_cache_request'])) { | |
| 252 | + \SiteGround_Optimizer\Supercacher\Supercacher::purge_cache_request($url); | |
| 253 | + } | |
| 187 | 254 | } |
| 188 | 255 | |
| 189 | 256 | // Nginx Helper only exposes a purge-everything hook, and WP Rocket |
| 190 | 257 | // takes the whole list at once, so both run once per call. |
| @@ -222,8 +289,18 @@ | ||
| 222 | 289 | delete_post_meta($post_id, self::ELEMENTOR_CACHE_META_KEY); |
| 223 | 290 | |
| 224 | 291 | clean_post_cache($post_id); |
| 225 | 292 | |
| 293 | + // Caches whose narrowest purge is a post, not a URL. Fired here | |
| 294 | + // rather than in purge_urls() because this is the only path that | |
| 295 | + // has a post ID; see PURGE_POST. Each is a no-op when the plugin | |
| 296 | + // that listens for it is absent. | |
| 297 | + // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party hook names ThinkRank fires, not ours to prefix. | |
| 298 | + do_action('wpfc_clear_post_cache_by_id', false, $post_id); | |
| 299 | + do_action('wphb_clear_page_cache', $post_id); | |
| 300 | + do_action('purge_post_cache', $post_id); | |
| 301 | + // phpcs:enable | |
| 302 | + | |
| 226 | 303 | $permalink = get_permalink($post_id); |
| 227 | 304 | if (is_string($permalink) && '' !== $permalink) { |
| 228 | 305 | $urls[] = $permalink; |
| 229 | 306 | } |
| @@ -239,15 +316,35 @@ | ||
| 239 | 316 | /** |
| 240 | 317 | * Caching layers that are installed but cannot be purged from here. |
| 241 | 318 | * |
| 242 | 319 | * @since 2.12.0 |
| 320 | + * @since 2.14.0 Takes the kind of purge being reported on. | |
| 321 | + * | |
| 322 | + * @param string $target What the caller purged: self::PURGE_POST when it | |
| 323 | + * had a post ID (the default, and what every caller | |
| 324 | + * in the plugin passes today), or self::PURGE_URL for | |
| 325 | + * a URL with no post behind it, such as llms.txt. A | |
| 326 | + * post-granularity cache is unreachable in the second | |
| 327 | + * case and is listed there and not in the first. | |
| 243 | 328 | * @return string[] Plugin names, in the order they are declared. |
| 244 | 329 | */ |
| 245 | - public static function unpurgeable_caches(): array { | |
| 330 | + public static function unpurgeable_caches(string $target = self::PURGE_POST): array { | |
| 246 | 331 | $names = []; |
| 247 | 332 | |
| 248 | 333 | foreach (self::known_caches() as $name => $cache) { |
| 249 | - if (!$cache['purgeable'] && ($cache['detect'])()) { | |
| 334 | + $purge = $cache['purge']; | |
| 335 | + | |
| 336 | + if (self::PURGE_URL === $purge) { | |
| 337 | + continue; | |
| 338 | + } | |
| 339 | + | |
| 340 | + // A per-post cache is purged on the post path and missed on the | |
| 341 | + // URL path, so only the URL path warns about it. | |
| 342 | + if (self::PURGE_POST === $purge && self::PURGE_POST === $target) { | |
| 343 | + continue; | |
| 344 | + } | |
| 345 | + | |
| 346 | + if (($cache['detect'])()) { | |
| 250 | 347 | $names[] = $name; |
| 251 | 348 | } |
| 252 | 349 | } |
| 253 | 350 | |
| @@ -262,19 +359,26 @@ | ||
| 262 | 359 | * |
| 263 | 360 | * @since 2.12.0 |
| 264 | 361 | * @return string[] Empty when everything detected was purged. |
| 265 | 362 | */ |
| 266 | - public static function warnings(): array { | |
| 267 | - $unpurgeable = self::unpurgeable_caches(); | |
| 363 | + public static function warnings(string $target = self::PURGE_POST): array { | |
| 364 | + $unpurgeable = self::unpurgeable_caches($target); | |
| 268 | 365 | |
| 269 | 366 | if ([] === $unpurgeable) { |
| 270 | 367 | return []; |
| 271 | 368 | } |
| 272 | 369 | |
| 370 | + // Two plugins read "A, B is active and offers" in the single-string | |
| 371 | + // version this replaces. | |
| 273 | 372 | return [ |
| 274 | 373 | sprintf( |
| 275 | 374 | /* translators: %s: comma-separated list of caching plugin names. */ |
| 276 | - __('%s is active and offers no way to clear a single page from here. Clear its cache to see the change on the front end.', 'thinkrank'), | |
| 375 | + _n( | |
| 376 | + '%s is active and offers no way to clear a single page from here. Clear its cache to see the change on the front end.', | |
| 377 | + '%s are active and offer no way to clear a single page from here. Clear their caches to see the change on the front end.', | |
| 378 | + count($unpurgeable), | |
| 379 | + 'thinkrank' | |
| 380 | + ), | |
| 277 | 381 | implode(', ', $unpurgeable) |
| 278 | 382 | ), |
| 279 | 383 | ]; |
| 280 | 384 | } |