PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 All 55 releases
thinkrank / includes / seo / class-cache-purger.php

class-cache-purger.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.0, at includes/seo/class-cache-purger.php

399 lines 15.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Cache purging for content ThinkRank rewrites underneath a cache.
5 *
6 * ThinkRank writes values that other software has already rendered into HTML
7 * and stored. Alt text is the clearest case: `update-image-alt-text` writes
8 * `_wp_attachment_image_alt` and returns success, while the page a visitor
9 * loads still carries the old words because Elementor kept the rendered widget
10 * in post meta and a page cache kept the whole document. The write is correct
11 * and invisible, which is the worst possible combination for an agent that
12 * reports back to a user (#763).
13 *
14 * Two layers are dealt with here:
15 *
16 * - **Builder output caches.** Elementor stores rendered elements per document
17 * in `_elementor_element_cache` and reuses them for up to 24 hours. Element
18 * caching is on unless the site turns it off: `Document::print_elements()`
19 * treats any value of `elementor_element_cache_ttl` except the literal
20 * `disable` as enabled, and the option does not exist on a default install.
21 * Elementor's own public API for this is `files_manager->clear_cache()`,
22 * which also deletes every generated CSS file on the site. Deleting the one
23 * meta key on the affected documents is the narrow equivalent, and it is
24 * what Elementor's protected `Document::delete_cache()` does.
25 * - **Page and CDN caches.** The same set `LLMs_Txt_Manager` has purged since
26 * 2.1.0, moved here so more than one feature can reach it.
27 *
28 * What cannot be purged is reported rather than ignored. A site behind Varnish,
29 * a CDN, or a caching plugin with no per-URL API will keep serving the old
30 * markup no matter what this class does, and the caller needs to be told that
31 * so it can tell the user to clear it by hand instead of insisting the change
32 * has gone live.
33 *
34 * @package ThinkRank
35 * @subpackage SEO
36 * @since 2.12.0
37 */
38
39 declare(strict_types=1);
40
41 namespace ThinkRank\SEO;
42
43 // Prevent direct access
44 if (!defined('ABSPATH')) {
45 exit;
46 }
47
48 /**
49 * Drops cached copies of pages ThinkRank has just changed.
50 *
51 * @since 2.12.0
52 */
53 class Cache_Purger {
54
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 /**
87 * Elementor's per-document rendered-element cache.
88 *
89 * Mirrors `Elementor\Core\Base\Document::CACHE_META_KEY`. The literal is
90 * used rather than the constant because Elementor may not be loaded, and
91 * because reaching into another plugin's class for a meta key is a harder
92 * dependency than repeating the string with this note attached.
93 *
94 * @since 2.12.0
95 * @var string
96 */
97 public const ELEMENTOR_CACHE_META_KEY = '_elementor_element_cache';
98
99 /**
100 * Post IDs already purged during this request.
101 *
102 * A library-wide alt fill touches hundreds of attachments that between them
103 * appear on a handful of pages, so without this the same document is
104 * cleared once per image. Per-request only: nothing here needs to survive
105 * into the next one, and a stale memory of "already purged" across requests
106 * would be a correctness bug rather than an optimisation.
107 *
108 * @since 2.12.0
109 * @var array<int, bool>
110 */
111 private static array $purged = [];
112
113 /**
114 * Caching plugins ThinkRank knows about, and how narrowly it can purge
115 * each one.
116 *
117 * Detection is a presence check on the plugin's own constant or class, not
118 * on its purge function: a plugin that loads after this one would report as
119 * absent, and the plugin that matters most for the warning is the one that
120 * exposes no purge API at all.
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 *
126 * @since 2.12.0
127 * @return array<string, array{detect: callable, purge: string}>
128 */
129 private static function known_caches(): array {
130 return [
131 'WP Rocket' => [
132 'detect' => static fn(): bool => defined('WP_ROCKET_VERSION'),
133 'purge' => self::PURGE_URL,
134 ],
135 'W3 Total Cache' => [
136 'detect' => static fn(): bool => defined('W3TC'),
137 'purge' => self::PURGE_URL,
138 ],
139 'WP Super Cache' => [
140 'detect' => static fn(): bool => defined('WPCACHEHOME'),
141 'purge' => self::PURGE_URL,
142 ],
143 'LiteSpeed Cache' => [
144 'detect' => static fn(): bool => defined('LSCWP_V'),
145 'purge' => self::PURGE_URL,
146 ],
147 'Nginx Helper' => [
148 'detect' => static fn(): bool => class_exists('Nginx_Helper'),
149 'purge' => self::PURGE_URL,
150 ],
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,
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.
163 'SiteGround Optimizer' => [
164 'detect' => static fn(): bool => class_exists('SiteGround_Optimizer\\Supercacher\\Supercacher'),
165 'purge' => self::PURGE_URL,
166 ],
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,
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.
188 'Breeze' => [
189 'detect' => static fn(): bool => defined('BREEZE_VERSION'),
190 'purge' => self::PURGE_POST,
191 ],
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.
197 'Cloudflare' => [
198 'detect' => static fn(): bool => class_exists('CF\\WordPress\\Hooks'),
199 'purge' => self::PURGE_NONE,
200 ],
201 ];
202 }
203
204 /**
205 * Ask every cache layer to drop its copy of the given URLs.
206 *
207 * Every integration is guarded, so a site running none of them simply gets
208 * the action hook, which other integrations can listen on.
209 *
210 * @since 2.12.0
211 * @param string[] $urls Absolute URLs to purge.
212 * @return void
213 */
214 public static function purge_urls(array $urls): void {
215 $urls = array_values(array_unique(array_filter(array_map('strval', $urls))));
216
217 if ([] === $urls) {
218 return;
219 }
220
221 /**
222 * Fires when ThinkRank changes what a URL serves.
223 *
224 * Cache layers ThinkRank does not know about can listen here and drop
225 * their copy.
226 *
227 * @since 2.12.0
228 *
229 * @param string[] $urls Absolute URLs whose content has changed.
230 */
231 do_action('thinkrank_purge_urls', $urls);
232
233 // Each integration is guarded by its own API rather than by the
234 // detector above, so a plugin whose constant this file guesses wrong
235 // still gets purged. The detector decides only what to warn about.
236 // These are third-party hook names ThinkRank fires, not ours to prefix.
237 foreach ($urls as $url) {
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
240
241 if (function_exists('w3tc_flush_url')) {
242 w3tc_flush_url($url);
243 }
244 if (function_exists('wpsc_delete_url_cache')) {
245 wpsc_delete_url_cache($url);
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 }
254 }
255
256 // Nginx Helper only exposes a purge-everything hook, and WP Rocket
257 // takes the whole list at once, so both run once per call.
258 do_action('rt_nginx_helper_purge_all'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
259
260 if (function_exists('rocket_clean_files')) {
261 rocket_clean_files($urls);
262 }
263 }
264
265 /**
266 * Drop cached renderings of specific posts, then their public URLs.
267 *
268 * @since 2.12.0
269 * @param int[] $post_ids Posts whose rendered output is now out of date.
270 * @return int[] The post IDs actually purged in this request.
271 */
272 public static function purge_posts(array $post_ids): array {
273 $purged = [];
274 $urls = [];
275
276 foreach ($post_ids as $post_id) {
277 $post_id = (int) $post_id;
278
279 if ($post_id <= 0 || isset(self::$purged[$post_id])) {
280 continue;
281 }
282
283 self::$purged[$post_id] = true;
284 $purged[] = $post_id;
285
286 // Elementor rebuilds the document on the next front-end request
287 // once the meta is gone. Deleting it is safe on a post Elementor
288 // never touched: the key simply is not there.
289 delete_post_meta($post_id, self::ELEMENTOR_CACHE_META_KEY);
290
291 clean_post_cache($post_id);
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
303 $permalink = get_permalink($post_id);
304 if (is_string($permalink) && '' !== $permalink) {
305 $urls[] = $permalink;
306 }
307 }
308
309 if ([] !== $urls) {
310 self::purge_urls($urls);
311 }
312
313 return $purged;
314 }
315
316 /**
317 * Caching layers that are installed but cannot be purged from here.
318 *
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.
328 * @return string[] Plugin names, in the order they are declared.
329 */
330 public static function unpurgeable_caches(string $target = self::PURGE_POST): array {
331 $names = [];
332
333 foreach (self::known_caches() as $name => $cache) {
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'])()) {
347 $names[] = $name;
348 }
349 }
350
351 return $names;
352 }
353
354 /**
355 * Sentences describing what a caller still has to clear by hand.
356 *
357 * Returned to the MCP abilities so an agent can pass the caveat on instead
358 * of reporting an unqualified success the visitor cannot see yet.
359 *
360 * @since 2.12.0
361 * @return string[] Empty when everything detected was purged.
362 */
363 public static function warnings(string $target = self::PURGE_POST): array {
364 $unpurgeable = self::unpurgeable_caches($target);
365
366 if ([] === $unpurgeable) {
367 return [];
368 }
369
370 // Two plugins read "A, B is active and offers" in the single-string
371 // version this replaces.
372 return [
373 sprintf(
374 /* translators: %s: comma-separated list of caching plugin names. */
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 ),
381 implode(', ', $unpurgeable)
382 ),
383 ];
384 }
385
386 /**
387 * Forget which posts were purged this request.
388 *
389 * Only the test suite needs this: a single web request never purges the
390 * same post twice on purpose.
391 *
392 * @since 2.12.0
393 * @return void
394 */
395 public static function reset(): void {
396 self::$purged = [];
397 }
398 }
399