diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..99fafa4
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,65 @@
+# Testing WP Recache (manual)
+
+Environment: Docker (`docker-compose up -d`), or any WP ≥ 5.8 + PHP ≥ 7.4 install with the plugin symlinked into `wp-content/plugins/`.
+
+## Activation
+
+1. Activate the plugin.
+2. Check `wp-config.php` contains `define('WP_CACHE', true); // Added by WP Recache.`
+3. Check these files exist:
+ - `wp-content/advanced-cache.php`
+ - `wp-content/wp-recache-cache.php`
+ - `wp-content/cache/wp-recache/.htaccess` + `index.php`
+
+## Core cache (issue #1 stories 1, 11, 12)
+
+4. Anonymous visit (incognito) to a page twice:
+ - 1st: response header `X-WP-Recache: MISS`, file `md5(host + path) . '.html'` appears in `wp-content/cache/wp-recache/`.
+ - 2nd: `X-WP-Recache: HIT` (via WP) or `X-WP-Recache-Dropin: 1` (served before WP loaded).
+5. Same URL with `?utm_source=test`: always MISS, no new cache file (query strings bypass).
+6. Define `DONOTCACHEPAGE` on a page (e.g. in a must-use plugin): page never cached.
+7. Logged-in visit: no `X-WP-Recache` headers, nothing cached (logged-in cookie also bypasses the drop-in).
+
+## Exclusions (stories 4–6)
+
+8. Settings → Cache → Exclude URLs: add `/about`, save, visit `/about` twice → never cached.
+9. Default exclusions: `/cart`, `/checkout`, `/my-account` never cached.
+10. Feeds (`/feed/`) and 404 pages: no `X-WP-Recache` MISS header, no cache file.
+
+## Purge (stories 2, 3)
+
+11. Edit a published post → its cache file and the homepage file are deleted.
+12. Add a comment to a post → that post's cache file is deleted (not the comment ID's).
+13. Admin bar → WP Recache → Purge All Cache (works on every admin page AND on the front-end admin bar, not just the settings page) → cache dir emptied.
+14. Settings → Advanced → Purge All Cache button → same result.
+15. Heartbeat purge: `wp.heartbeat.send('wp_recache_purge', 1)` in browser console on an admin page → next heartbeat tick purges all (check response `wp_recache_purged`).
+
+## Mobile (story 7)
+
+16. Enable "Separate Mobile Cache", visit once with a mobile UA (`curl -A "iPhone"`), once desktop → two files: `*-mobile.html`, `*-desktop.html`.
+17. With mobile cache ON, the drop-in does not serve (falls through to WP path) — expected.
+
+## Stats (story 9)
+
+18. Settings → Cache → Cache Statistics shows file count and size matching `wp-content/cache/wp-recache/`.
+
+## Multisite (story 10)
+
+19. On a network: same path on two sites produces two different files (host+path in key). Purge on one site must not touch the other.
+
+## Deactivation / uninstall
+
+20. Deactivate: `advanced-cache.php`, `wp-recache-cache.php` removed, `WP_CACHE` marker line removed from `wp-config.php`.
+21. If another plugin's `advanced-cache.php` exists (no "WP Recache" marker), deactivation must NOT delete it.
+22. Uninstall: options deleted, `wp-content/cache/wp-recache/` removed.
+
+## Automated
+
+- `composer test` (PHPUnit, needs WP test suite via docker-compose).
+- `composer phpstan`, `composer lint`.
+
+## Known ceilings
+
+- Drop-in ignores `cache_duration` TTL (no WP available to read the option); freshness relies on purge-on-update. Files are never served stale for edited content, only for TTL-based expiry.
+- Preload toggle (story 8) has no crawler implementation yet.
+- Purge on publish clears only the post + homepage; archives/taxonomy pages expire via TTL.
diff --git a/assets/admin.js b/assets/admin.js
new file mode 100644
index 0000000..3c52817
--- /dev/null
+++ b/assets/admin.js
@@ -0,0 +1,23 @@
+/**
+ * WP Recache admin-bar / settings actions.
+ *
+ * @package WP_Recache
+ */
+
+function wpRecachePurgeAll() {
+ if (!confirm('Are you sure you want to purge all cache?')) {
+ return;
+ }
+
+ jQuery.post(wpRecache.ajaxUrl, {
+ action: 'wp_recache_purge_all',
+ nonce: wpRecache.nonce
+ }, function (response) {
+ if (response.success) {
+ alert('Cache purged successfully');
+ location.reload();
+ } else {
+ alert('Error: ' + response.data);
+ }
+ });
+}
diff --git a/includes/Activator.php b/includes/Activator.php
index 9d1a7c8..72b0bad 100644
--- a/includes/Activator.php
+++ b/includes/Activator.php
@@ -15,7 +15,9 @@ class WP_Recache_Activator {
public static function activate() {
self::create_cache_directory();
self::set_default_options();
+ self::create_cache_server();
self::create_advanced_cache();
+ self::set_wp_cache_constant(true);
self::flush_rewrite_rules();
}
@@ -29,7 +31,7 @@ class WP_Recache_Activator {
$htaccess = WP_RECACHE_CACHE_PATH . '.htaccess';
if (!file_exists($htaccess)) {
- file_put_contents($htaccess, "Deny from all\n");
+ file_put_contents($htaccess, "\nRequire all denied\n\n\nDeny from all\n\n");
}
$index = WP_RECACHE_CACHE_PATH . 'index.php';
@@ -59,6 +61,50 @@ class WP_Recache_Activator {
}
}
+ /**
+ * Write the standalone cache server required by the advanced-cache.php
+ * drop-in. Must run without WordPress loaded.
+ */
+ private static function create_cache_server() {
+ $content = ' $value) {
+ if (strpos($name, \'wordpress_logged_in\') === 0) {
+ return;
+ }
+}
+
+$uri = isset($_SERVER[\'REQUEST_URI\']) ? (string) parse_url($_SERVER[\'REQUEST_URI\'], PHP_URL_PATH) : \'/\';
+$host = $_SERVER[\'HTTP_HOST\'] ?? \'\';
+$file = WP_CONTENT_DIR . \'/cache/wp-recache/\' . md5($host . $uri) . \'.html\';
+
+// With separate mobile cache enabled, files carry a -desktop/-mobile suffix,
+// so the plain file never exists and serving falls through to WordPress.
+if (file_exists($file)) {
+ header(\'X-WP-Recache: HIT\');
+ header(\'X-WP-Recache-Dropin: 1\');
+ readfile($file);
+ exit;
+}
+';
+
+ file_put_contents(WP_CONTENT_DIR . '/wp-recache-cache.php', $content);
+ }
+
/**
* Create advanced-cache.php drop-in.
*/
@@ -87,6 +133,40 @@ if (file_exists(WP_CONTENT_DIR . \'/wp-recache-cache.php\')) {
file_put_contents($advanced_cache, $content);
}
+ /**
+ * Add or remove the WP_CACHE constant in wp-config.php.
+ *
+ * Without it, WordPress never loads advanced-cache.php.
+ *
+ * @param bool $enable True to add the constant, false to remove it.
+ */
+ public static function set_wp_cache_constant($enable) {
+ $marker = "define('WP_CACHE', true); // Added by WP Recache.\n";
+ $config_path = ABSPATH . 'wp-config.php';
+
+ if (!file_exists($config_path)) {
+ $config_path = dirname(ABSPATH) . '/wp-config.php';
+ }
+
+ if (!file_exists($config_path) || !is_writable($config_path)) {
+ return;
+ }
+
+ $content = file_get_contents($config_path);
+
+ if ($enable) {
+ if (strpos($content, $marker) !== false || preg_match("/define\s*\(\s*['\"]WP_CACHE['\"]\s*,\s*true\s*\)/", $content)) {
+ return;
+ }
+
+ $content = preg_replace('/^<\?php/', " admin_url('admin-ajax.php'),
- 'nonce' => wp_create_nonce('wp_recache_nonce'),
- ]);
+ add_action('admin_enqueue_scripts', 'wp_recache_enqueue_admin_bar_script');
+ // New exclusions must not leave stale files that the drop-in would still serve.
+ add_action('update_option_wp_recache_exclude_urls', 'wp_recache_delete_all_cache');
}
/**
@@ -93,11 +91,13 @@ class WP_Recache_Admin {
* @return array Sanitized URLs.
*/
public function sanitize_exclude_urls($input) {
- if (!is_array($input)) {
- return [];
+ if (is_array($input)) {
+ $input = implode("\n", $input);
}
- return array_map('sanitize_text_field', $input);
+ $lines = array_filter(array_map('trim', explode("\n", (string) $input)));
+
+ return array_map('sanitize_text_field', $lines);
}
/**
@@ -110,6 +110,10 @@ class WP_Recache_Admin {
$active_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'cache';
+ if (!in_array($active_tab, ['cache', 'optimize', 'advanced'], true)) {
+ $active_tab = 'cache';
+ }
+
include WP_RECACHE_INC_PATH . 'Admin/views/settings.php';
}
diff --git a/includes/Admin/views/settings.php b/includes/Admin/views/settings.php
index 8813389..21e2b35 100644
--- a/includes/Admin/views/settings.php
+++ b/includes/Admin/views/settings.php
@@ -48,7 +48,7 @@ $stats = WP_Recache_Admin::get_cache_stats();
| Exclude URLs |
-
+
One URL per line. These pages will not be cached.
|
@@ -131,23 +131,3 @@ $stats = WP_Recache_Admin::get_cache_stats();
-
-
diff --git a/includes/Cache/WPCache.php b/includes/Cache/WPCache.php
index 1acbadf..b14b4ca 100644
--- a/includes/Cache/WPCache.php
+++ b/includes/Cache/WPCache.php
@@ -9,17 +9,26 @@ defined('ABSPATH') || exit;
class WP_Recache_WPCache {
+ /**
+ * Whether this request started our output buffer.
+ *
+ * @var bool
+ */
+ private $buffer_started = false;
+
/**
* Initialize cache hooks.
*/
public function init() {
- add_action('init', [$this, 'start_output_buffering']);
+ // template_redirect: query conditionals (is_feed/is_404/is_preview) are
+ // available here, and REST/AJAX requests never reach it.
+ add_action('template_redirect', [$this, 'start_output_buffering'], 0);
add_action('shutdown', [$this, 'end_output_buffering']);
add_action('save_post', [$this, 'purge_post_cache']);
- add_action('wp_insert_comment', [$this, 'purge_post_cache']);
+ add_action('wp_insert_comment', [$this, 'purge_comment_cache']);
- add_filter('heartbeat_received', [$this, 'purge_cache_onheartbeat']);
+ add_filter('heartbeat_received', [$this, 'purge_cache_onheartbeat'], 10, 2);
}
/**
@@ -54,8 +63,14 @@ class WP_Recache_WPCache {
}
}
+ // Bots get served warm cache but never warm it themselves.
+ if (wp_recache_is_robots()) {
+ return;
+ }
+
header('X-WP-Recache: MISS');
ob_start([$this, 'cache_output']);
+ $this->buffer_started = true;
}
/**
@@ -69,6 +84,20 @@ class WP_Recache_WPCache {
return $output;
}
+ // Respect opt-outs from WordPress and third-party plugins.
+ if (defined('DONOTCACHEPAGE') && DONOTCACHEPAGE) {
+ return $output;
+ }
+
+ $status = http_response_code();
+ if ($status !== false && $status !== 200) {
+ return $output;
+ }
+
+ if (wp_recache_is_robots()) {
+ return $output;
+ }
+
$url = $this->get_current_url();
$cache_path = wp_recache_get_cache_path_with_device($url);
$cache_dir = dirname($cache_path);
@@ -86,7 +115,7 @@ class WP_Recache_WPCache {
* End output buffering.
*/
public function end_output_buffering() {
- if (ob_get_level()) {
+ if ($this->buffer_started && ob_get_level()) {
ob_end_flush();
}
}
@@ -106,15 +135,30 @@ class WP_Recache_WPCache {
wp_recache_delete_cache(home_url('/'));
}
+ /**
+ * Purge cache for the post a comment belongs to.
+ *
+ * @param int $comment_id Comment ID.
+ */
+ public function purge_comment_cache($comment_id) {
+ $comment = get_comment($comment_id);
+
+ if ($comment) {
+ $this->purge_post_cache($comment->comment_post_ID);
+ }
+ }
+
/**
* Purge cache on heartbeat.
*
* @param array $response Heartbeat response.
+ * @param array $data Data sent with the heartbeat request.
* @return array Modified response.
*/
- public function purge_cache_onheartbeat($response) {
- if (isset($response['wp_recache_purge'])) {
+ public function purge_cache_onheartbeat($response, $data) {
+ if (isset($data['wp_recache_purge'])) {
wp_recache_delete_all_cache();
+ $response['wp_recache_purged'] = true;
}
return $response;
@@ -127,8 +171,8 @@ class WP_Recache_WPCache {
*/
private function get_current_url() {
$protocol = is_ssl() ? 'https' : 'http';
- $host = $_SERVER['HTTP_HOST'];
- $uri = $_SERVER['REQUEST_URI'];
+ $host = $_SERVER['HTTP_HOST'] ?? '';
+ $uri = $_SERVER['REQUEST_URI'] ?? '/';
return $protocol . '://' . $host . $uri;
}
diff --git a/includes/Common/Helpers.php b/includes/Common/Helpers.php
index 1eadf00..0696fa4 100644
--- a/includes/Common/Helpers.php
+++ b/includes/Common/Helpers.php
@@ -58,11 +58,12 @@ function wp_recache_should_exclude() {
return true;
}
- if (wp_recache_is_robots()) {
+ // Dynamic query-string requests are never cached (story 11).
+ if (!empty($_SERVER['QUERY_STRING'])) {
return true;
}
- if (is_preview()) {
+ if (is_preview() || is_feed() || is_404()) {
return true;
}
@@ -93,40 +94,22 @@ function wp_recache_is_robots() {
* Check if request is a POST action.
*/
function wp_recache_is_post_action() {
- return $_SERVER['REQUEST_METHOD'] === 'POST';
+ return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
}
/**
* Get cache file path for URL.
*
- * @param string $url URL to cache.
- */
-function wp_recache_get_cache_path($url) {
- $path = wp_parse_url($url, PHP_URL_PATH);
- $path = trim($path, '/');
-
- if (empty($path)) {
- $path = 'index';
- }
-
- $path = sanitize_file_name($path);
-
- return WP_RECACHE_CACHE_PATH . $path . '.html';
-}
-
-/**
- * Get cache key for URL.
+ * Keyed by md5(host + path) so nested paths, hosts and multisite
+ * sub-directories never collide. Query strings are bypassed upstream.
*
* @param string $url URL to cache.
*/
-function wp_recache_get_cache_key($url) {
- $key = wp_parse_url($url, PHP_URL_PATH);
+function wp_recache_get_cache_path($url) {
+ $host = (string) wp_parse_url($url, PHP_URL_HOST);
+ $path = (string) wp_parse_url($url, PHP_URL_PATH);
- if (empty($key)) {
- $key = '/';
- }
-
- return md5($key);
+ return WP_RECACHE_CACHE_PATH . md5($host . $path) . '.html';
}
/**
@@ -214,3 +197,18 @@ function wp_recache_get_cache_path_with_device($url) {
return $path;
}
+
+/**
+ * Enqueue the admin-bar purge script (admin and front-end admin bar).
+ */
+function wp_recache_enqueue_admin_bar_script() {
+ if (!is_admin_bar_showing() || !current_user_can('manage_options')) {
+ return;
+ }
+
+ wp_enqueue_script('wp-recache-admin', WP_RECACHE_URL . 'assets/admin.js', ['jquery'], WP_RECACHE_VERSION, true);
+ wp_localize_script('wp-recache-admin', 'wpRecache', [
+ 'ajaxUrl' => admin_url('admin-ajax.php'),
+ 'nonce' => wp_create_nonce('wp_recache_nonce'),
+ ]);
+}
diff --git a/includes/Deactivator.php b/includes/Deactivator.php
index a9f91da..af42de1 100644
--- a/includes/Deactivator.php
+++ b/includes/Deactivator.php
@@ -14,20 +14,38 @@ class WP_Recache_Deactivator {
*/
public static function deactivate() {
self::remove_advanced_cache();
+ self::remove_cache_server();
+ WP_Recache_Activator::set_wp_cache_constant(false);
self::flush_rewrite_rules();
}
/**
- * Remove advanced-cache.php drop-in.
+ * Remove advanced-cache.php drop-in, but only if it is ours.
*/
private static function remove_advanced_cache() {
$advanced_cache = WP_CONTENT_DIR . '/advanced-cache.php';
- if (file_exists($advanced_cache)) {
+ if (!file_exists($advanced_cache)) {
+ return;
+ }
+
+ $content = file_get_contents($advanced_cache);
+ if (strpos($content, 'WP Recache') !== false) {
unlink($advanced_cache);
}
}
+ /**
+ * Remove the standalone cache server.
+ */
+ private static function remove_cache_server() {
+ $server = WP_CONTENT_DIR . '/wp-recache-cache.php';
+
+ if (file_exists($server)) {
+ unlink($server);
+ }
+ }
+
/**
* Flush rewrite rules.
*/
diff --git a/tests/test-cache.php b/tests/test-cache.php
index d5ff5ac..8dee9d3 100644
--- a/tests/test-cache.php
+++ b/tests/test-cache.php
@@ -10,6 +10,11 @@ class WP_Recache_WPCache_Test extends WP_UnitTestCase {
public function test_cache_creates_file_on_first_request() {
$cache = new WP_Recache_WPCache();
+ $_SERVER['HTTP_HOST'] = 'example.com';
+ $_SERVER['REQUEST_URI'] = '/test-page/';
+ $_SERVER['HTTPS'] = 'off';
+ unset($_SERVER['HTTP_USER_AGENT']);
+
$url = 'https://example.com/test-page/';
$path = wp_recache_get_cache_path($url);
@@ -59,6 +64,11 @@ class WP_Recache_WPCache_Test extends WP_UnitTestCase {
public function test_cache_file_is_created_with_correct_permissions() {
$cache = new WP_Recache_WPCache();
+ $_SERVER['HTTP_HOST'] = 'example.com';
+ $_SERVER['REQUEST_URI'] = '/test-page/';
+ $_SERVER['HTTPS'] = 'off';
+ unset($_SERVER['HTTP_USER_AGENT']);
+
$url = 'https://example.com/test-page/';
$path = wp_recache_get_cache_path($url);
diff --git a/tests/test-helpers.php b/tests/test-helpers.php
index 8805699..d3cea89 100644
--- a/tests/test-helpers.php
+++ b/tests/test-helpers.php
@@ -34,26 +34,26 @@ class WP_Recache_Helpers_Test extends WP_UnitTestCase {
public function test_wp_recache_get_cache_path_returns_correct_path() {
$url = 'https://example.com/test-page/';
- $expected = WP_RECACHE_CACHE_PATH . 'test-page.html';
+ $expected = WP_RECACHE_CACHE_PATH . md5('example.com/test-page/') . '.html';
$this->assertEquals($expected, wp_recache_get_cache_path($url));
}
public function test_wp_recache_get_cache_path_handles_root_url() {
$url = 'https://example.com/';
- $expected = WP_RECACHE_CACHE_PATH . 'index.html';
+ $expected = WP_RECACHE_CACHE_PATH . md5('example.com/') . '.html';
$this->assertEquals($expected, wp_recache_get_cache_path($url));
}
- public function test_wp_recache_get_cache_key_returns_md5_hash() {
- $url = 'https://example.com/test-page/';
- $expected = md5('/test-page/');
- $this->assertEquals($expected, wp_recache_get_cache_key($url));
+ public function test_wp_recache_get_cache_path_no_collision_between_nested_urls() {
+ $a = wp_recache_get_cache_path('https://example.com/a/b/');
+ $b = wp_recache_get_cache_path('https://example.com/ab/');
+ $this->assertNotEquals($a, $b);
}
- public function test_wp_recache_get_cache_key_handles_root_url() {
- $url = 'https://example.com/';
- $expected = md5('/');
- $this->assertEquals($expected, wp_recache_get_cache_key($url));
+ public function test_wp_recache_get_cache_path_no_collision_between_hosts() {
+ $a = wp_recache_get_cache_path('https://site1.example.com/');
+ $b = wp_recache_get_cache_path('https://site2.example.com/');
+ $this->assertNotEquals($a, $b);
}
public function test_wp_recache_is_mobile_returns_false_for_desktop_user_agent() {
diff --git a/uninstall.php b/uninstall.php
index f0546ed..dbd2148 100644
--- a/uninstall.php
+++ b/uninstall.php
@@ -49,3 +49,9 @@ if (file_exists($advanced_cache)) {
unlink($advanced_cache);
}
}
+
+// Remove the standalone cache server.
+$cache_server = WP_CONTENT_DIR . '/wp-recache-cache.php';
+if (file_exists($cache_server)) {
+ unlink($cache_server);
+}
diff --git a/wp-recache.php b/wp-recache.php
index a841460..61d8300 100644
--- a/wp-recache.php
+++ b/wp-recache.php
@@ -34,11 +34,13 @@ function wp_recache_init() {
$admin->init();
}
- if (WP_RECACHE_CACHE_ENABLED) {
+ if (wp_recache_is_cache_enabled()) {
require_once WP_RECACHE_INC_PATH . 'Cache/WPCache.php';
$cache = new WP_Recache_WPCache();
$cache->init();
}
+
+ add_action('wp_enqueue_scripts', 'wp_recache_enqueue_admin_bar_script');
}
/**
@@ -54,6 +56,7 @@ register_activation_hook(__FILE__, 'wp_recache_activate');
* Plugin deactivation.
*/
function wp_recache_deactivate() {
+ require_once WP_RECACHE_INC_PATH . 'Activator.php';
require_once WP_RECACHE_INC_PATH . 'Deactivator.php';
WP_Recache_Deactivator::deactivate();
}