config its live settings object * 'wp-stack-cache-options' the option it stores those settings in * 'stack-cache-purge-cache' its admin bar node, replaced with one menu * '_stack_cache_admin' its settings screen, linked to * * From ABCode Cache Warmer (optional, absent by default): * 'abcw_warm_urls' its action, handed the URLs we purged * 'abcw_warm_all' its action, for a full rebuild * * Every name this plugin owns is prefixed absc_ or ABSC_: its functions, its * option, its cron hook, its nonces, its admin-post actions and its filters. * * Nothing above is called blind. Access to StackCache goes through * absc_available(), which checks the class and method exist, and the whole * plugin no-ops without it. The Cache Warmer actions are fired with do_action() * and simply go nowhere when the warmer is not installed. */ define('ABSC_PURGE_HOOK', 'absc_purge'); // ─── StackCache bridge ──────────────────────────────────────────────────────── /** * Whether StackCache is present and its purge method is callable. * * Everything in this plugin gates on this, so on any site without StackCache it * is a silent no-op, and a future StackCache release that renames or drops the * method turns the plugin off rather than fatalling the site. * * @return bool */ function absc_available() { return class_exists('WPStackCache') && is_callable(['WPStackCache', 'purge']); } /** * Ask StackCache to drop one URL from its cache. * * StackCache::purge() is public and takes any URL: it simply fires an HTTP GET * to the platform's purge endpoint. It has no timeout of its own, which is why * every call in this plugin runs from cron or an admin request, never from a * content save on the editor's own request. * * @param string $url URL to purge. * @return bool Whether a purge was dispatched. */ function absc_purge_url($url) { $url = trim((string) $url); if ('' === $url || !absc_available() || !absc_is_local_url($url)) { return false; } WPStackCache::purge($url); return true; } /** * StackCache's settings, if we can reach them. * * Two routes, because one is not dependable. StackCache runs from a platform * mu-plugin rather than a normal plugin file, so the instance it builds on load * is not reliably reachable as a global from here. It does persist the same * settings as JSON in its own option, which is stable and readable whenever the * site is, so that is the fallback. * * Both routes yield the same shape: an object with the underscored property * names StackCache uses internally. * * Returning null is safe. Every caller falls back to a conservative default * rather than guessing, so an unreadable config never causes a wrong purge. * * @return object|null */ function absc_stackcache_config() { if (isset($GLOBALS['wpsc']) && is_object($GLOBALS['wpsc']) && isset($GLOBALS['wpsc']->config) && is_object($GLOBALS['wpsc']->config)) { return $GLOBALS['wpsc']->config; } $stored = get_option('wp-stack-cache-options'); if (is_string($stored) && '' !== $stored) { $config = json_decode($stored); if (is_object($config)) { return $config; } } return null; } /** * Whether StackCache is set to purge the whole cache on every content change. * * In that mode there is no hierarchy gap to fill, because StackCache already drops * everything, so this plugin stands aside rather than issue purges nobody * needs. The warmer's own watchdog is the safety net that rebuilds the site. * * @return bool */ function absc_full_purge_mode() { $config = absc_stackcache_config(); return $config ? !empty($config->full_purge_on_auto) : false; } /** * Whether StackCache will purge the given post's own URL itself on an edit. * * Mirrors StackCache's own rule exactly: it purges standard types always, and * custom post types only when its "purge custom post types" option is on. When * it will NOT (an unsupported CPT with that option off), it leaves the post * itself stale, so we add the post's own URL to what we purge. When it will, we * leave the post URL alone so we never double-purge what StackCache handles. * * @param WP_Post $post Post. * @return bool */ function absc_stackcache_purges_post($post) { // The types StackCache's own code treats as always-supported. $supported = ['post', 'page', 'attachment', 'custom_css', 'customize_changeset']; if (in_array($post->post_type, $supported, true)) { return true; } $config = absc_stackcache_config(); // Config unreadable: assume StackCache does purge it, so we never double up. // A standard site has the config readable, so this only guards the edge. return $config ? !empty($config->purge_custom_post_types) : true; } /** * Whether a URL belongs to this site. We never purge anything off-host. * * @param string $url URL. * @return bool */ function absc_is_local_url($url) { $target = wp_parse_url($url); $home = wp_parse_url(home_url('/')); return !empty($target['host']) && !empty($home['host']) && strtolower($target['host']) === strtolower($home['host']); } // ─── The content hierarchy StackCache leaves behind ─────────────────────────── /** * The pages that list a post: homepage, its public taxonomy term archives, its * post-type archive, the blog page (static front page), and the main feed. * * These are exactly what StackCache does NOT purge on an edit: it purges only * the post's own permalink, so they are what goes stale. * * @param WP_Post $post Post. * @return array */ function absc_listing_urls($post) { $urls = [home_url('/')]; foreach (get_object_taxonomies($post->post_type, 'objects') as $taxonomy) { if (empty($taxonomy->public)) { continue; } $terms = get_the_terms($post, $taxonomy->name); if (!is_array($terms)) { continue; } foreach ($terms as $term) { $link = get_term_link($term); if (!is_wp_error($link)) { $urls[] = $link; } } } $archive = get_post_type_archive_link($post->post_type); if ($archive) { $urls[] = $archive; } // Parent pages, for hierarchical types. A child under /accommodation/leeds/ // usually appears in a listing on its parents, so an edit makes those stale // too. StackCache purges none of them. foreach (get_post_ancestors($post) as $ancestor_id) { $link = get_permalink($ancestor_id); if ($link) { $urls[] = $link; } } if ('post' === $post->post_type && 'page' === get_option('show_on_front')) { $posts_page = (int) get_option('page_for_posts'); if ($posts_page > 0) { $link = get_permalink($posts_page); if ($link) { $urls[] = $link; } } } $feed = get_feed_link(); if ($feed) { $urls[] = $feed; } $urls = (array) apply_filters('absc_listing_urls', $urls, $post); // Dedupe ignoring the trailing slash. For the "post" type, // get_post_type_archive_link() resolves to the site root without its slash, // so a plain array_unique() leaves the homepage in twice and it gets purged // twice on every edit. The first spelling wins, which is the canonical // home_url() form. $seen = []; $unique = []; foreach (array_filter($urls) as $url) { $key = strtolower(untrailingslashit($url)); if (isset($seen[$key])) { continue; } $seen[$key] = true; $unique[] = $url; } return array_values($unique); } // ─── Purge on edit ──────────────────────────────────────────────────────────── /** * When a post is published or updated, queue its listing pages for purge. * * Deferred to a short cron event rather than run inline: StackCache's purge is a * blocking network call, and a burst of edits (a bulk action) folds into one * pass instead of hammering the purge endpoint. */ add_action( 'transition_post_status', function ($new_status, $old_status, $post) { if ('publish' !== $new_status && 'publish' !== $old_status) { return; } if (!$post instanceof WP_Post || wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) { return; } $post_type = get_post_type_object($post->post_type); if (!$post_type || empty($post_type->public)) { return; } if (!absc_available()) { return; } // StackCache in full-purge mode already drops the whole cache on this // edit, so there is nothing to add. Stand aside rather than duplicate it. if (absc_full_purge_mode()) { return; } $hierarchy = absc_listing_urls($post); $post_url = get_permalink($post->ID); // Purge set: the hierarchy, plus the post's own URL only when StackCache // will not purge it itself (an unsupported CPT with its CPT purging off). // Otherwise StackCache has handled the post and we would double up. $purge = $hierarchy; if ($post_url && !absc_stackcache_purges_post($post)) { $purge[] = $post_url; } // Warm set: everything, the post included. The post was dropped by // StackCache (or by us, above), so it needs rebuilding regardless of // whether the warmer's own publish trigger is switched on. $warm = $hierarchy; if ($post_url) { array_unshift($warm, $post_url); } if (!empty($purge)) { $queue = get_option('absc_queue', []); $queue = is_array($queue) ? array_merge($queue, $purge) : $purge; update_option('absc_queue', array_values(array_unique($queue)), false); if (!wp_next_scheduled(ABSC_PURGE_HOOK)) { // Kept short and always ahead of the warmer's own minimum warm // delay (10s), so the purge lands before the rebuild, otherwise // the warm would re-read a still-cached page. It is still a // background event, never run on the editor's save. $delay = max(2, min(8, (int) apply_filters('absc_purge_delay', 5))); wp_schedule_single_event(time() + $delay, ABSC_PURGE_HOOK); } } // Hand the warm to the warmer straight away, so it shows in the queue // the instant you save rather than after the purge delay. The warmer // schedules the actual rebuild for its own settle delay (well after the // purge above), and dedupes against its own publish trigger. // abcw_ is ABCode Cache Warmer's prefix, not this plugin's. These are // its hooks, fired to hand it the URLs we cleared; prefixing them // absc_ would simply mean nothing listens. Nothing happens here when // the warmer is absent, which is the intended optional behaviour. if (!empty($warm)) { do_action('abcw_warm_urls', $warm); } }, 10, 3 ); /** * Purge the queued listing pages. * * The purge is the fix on its own: the pages go cold and the next visitor gets * fresh content. The warm that rebuilds them was handed to the warmer back when * the edit happened (for immediate queue visibility) and is scheduled to fire * after this purge, so it rebuilds fresh rather than re-reading a cached page. */ add_action(ABSC_PURGE_HOOK, function () { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Blocking purge calls can outlast the default limit on a large queue. @set_time_limit(120); $queue = get_option('absc_queue', []); delete_option('absc_queue'); if (!is_array($queue) || empty($queue) || !absc_available()) { return; } foreach ($queue as $url) { absc_purge_url($url); } }); // ─── Purge this page ────────────────────────────────────────────────────────── /** * The front-end URL currently being viewed. * * @return string */ function absc_current_url() { if (is_singular()) { $id = get_queried_object_id(); if ($id) { $link = get_permalink($id); if ($link) { return $link; } } } if (!empty($_SERVER['HTTP_HOST']) && !empty($_SERVER['REQUEST_URI'])) { $host = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])); $path = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])); return esc_url_raw((is_ssl() ? 'https://' : 'http://') . $host . $path); } return ''; } /** * One tidy "StackCache" admin-bar menu in place of the lone "Purge Cache" item. * * The parent opens StackCache's own settings; under it sit "Purge This Page" * (front-end views) and "Purge Everything". StackCache's standalone item is * removed so there is only one control. Runs at priority 100, after StackCache * adds its own node at 99, so the removal lands. */ add_action('admin_bar_menu', function ($bar) { if (!absc_available() || !current_user_can('manage_options')) { return; } // Honour StackCache's own "show purge link" preference. If the user hid the // purge control there, we do not bring it back under a different name. $config = absc_stackcache_config(); if ($config && isset($config->purge_cache_link_enabled) && !$config->purge_cache_link_enabled) { return; } $bar->remove_node('stack-cache-purge-cache'); $bar->add_node( [ 'id' => 'absc-stackcache', 'title' => __('StackCache', 'abcode-improved-caching-for-20i-stackcache'), 'href' => admin_url('admin.php?page=_stack_cache_admin'), ] ); if (!is_admin()) { $url = absc_current_url(); if ('' !== $url) { $bar->add_node( [ 'parent' => 'absc-stackcache', 'id' => 'absc-purge-page', 'title' => __('Purge This Page', 'abcode-improved-caching-for-20i-stackcache'), 'href' => wp_nonce_url( admin_url('admin-post.php?action=absc_purge_page&url=' . rawurlencode($url)), 'absc_purge_page' ), ] ); } } $bar->add_node( [ 'parent' => 'absc-stackcache', 'id' => 'absc-purge-all', 'title' => __('Purge Everything', 'abcode-improved-caching-for-20i-stackcache'), 'href' => wp_nonce_url(admin_url('admin-post.php?action=absc_purge_all'), 'absc_purge_all'), ] ); }, 100); /** * Handle the "Purge This Page" click. The CSRF check StackCache's own forms * are missing is done here properly: capability plus nonce. */ add_action('admin_post_absc_purge_page', function () { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to purge the cache.', 'abcode-improved-caching-for-20i-stackcache'), '', ['response' => 403]); } check_admin_referer('absc_purge_page'); $url = isset($_GET['url']) ? esc_url_raw(wp_unslash($_GET['url'])) : ''; if ('' !== $url && absc_is_local_url($url)) { absc_purge_url($url); // The warmer rebuilds it if present; either way the redirect below // reloads the page, so StackCache rebuilds it on that request too. // abcw_ is Cache Warmer's prefix: this is its hook, not ours. do_action('abcw_warm_urls', [$url]); } wp_safe_redirect($url ?: home_url('/')); exit; }); /** * Handle "Purge Everything": StackCache's full purge, done through our own * nonce-checked handler so it can hand off to the warmer and redirect cleanly. */ add_action('admin_post_absc_purge_all', function () { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to purge the cache.', 'abcode-improved-caching-for-20i-stackcache'), '', ['response' => 403]); } check_admin_referer('absc_purge_all'); if (absc_available()) { WPStackCache::purge('all'); // Ask the warmer, if present, to rebuild the whole site now rather than // leaving every page cold until a visitor or the watchdog arrives. // abcw_ is Cache Warmer's prefix: this is its hook, not ours. do_action('abcw_warm_all'); } $back = wp_get_referer(); wp_safe_redirect($back ? $back : home_url('/')); exit; }); // ─── Housekeeping ───────────────────────────────────────────────────────────── /** * Tell the user if the plugin is active but StackCache is not, so it is never a * silent do-nothing. */ add_action('admin_notices', function () { if (absc_available() || !current_user_can('activate_plugins')) { return; } echo '

'; echo 'ABCode Improved Caching for 20i StackCache '; esc_html_e('needs 20i StackCache to be active. It is doing nothing on this site until StackCache is present.', 'abcode-improved-caching-for-20i-stackcache'); echo '

'; }); register_deactivation_hook(__FILE__, function () { wp_unschedule_hook(ABSC_PURGE_HOOK); delete_option('absc_queue'); });