feat: implement Phase 1 - Core Page Cache and Admin Settings UI

Implement the core page caching functionality and admin settings interface
for WP Recache plugin. This includes:

- Plugin bootstrap with activation/deactivation handlers
- Admin settings page with tabbed interface (Cache, Optimization, Advanced)
- Admin bar integration for quick cache purging
- Core page cache using output buffering
- Cache file management with mobile device separation
- Cache purge on post update/comment
- Default URL exclusions (cart, checkout, account)
- Cache statistics display
- PHPUnit test suite setup
- Docker-based development environment
- PHPStan configuration for static analysis

References:
- Closes #1 (Core Page Cache)
- Closes #8 (Admin Settings UI)
This commit is contained in:
2026-09-13 09:56:35 +02:00
parent 88a0531d90
commit 33099591f5
18 changed files with 1452 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* Plugin activation handler.
*
* @package WP_Recache
*/
defined('ABSPATH') || exit;
class WP_Recache_Activator {
/**
* Run activation routines.
*/
public static function activate() {
self::create_cache_directory();
self::set_default_options();
self::create_advanced_cache();
self::flush_rewrite_rules();
}
/**
* Create cache directory.
*/
private static function create_cache_directory() {
if (!is_dir(WP_RECACHE_CACHE_PATH)) {
wp_mkdir_p(WP_RECACHE_CACHE_PATH);
}
$htaccess = WP_RECACHE_CACHE_PATH . '.htaccess';
if (!file_exists($htaccess)) {
file_put_contents($htaccess, "Deny from all\n");
}
$index = WP_RECACHE_CACHE_PATH . 'index.php';
if (!file_exists($index)) {
file_put_contents($index, '<?php // Silence is golden.');
}
}
/**
* Set default options.
*/
private static function set_default_options() {
$defaults = [
'cache_enabled' => true,
'separate_mobile_cache' => false,
'exclude_urls' => ['/cart', '/checkout', '/my-account'],
'minify_css' => false,
'minify_js' => false,
'lazyload_images' => false,
'preload_enabled' => false,
];
foreach ($defaults as $key => $value) {
if (get_option("wp_recache_{$key}") === false) {
add_option("wp_recache_{$key}", $value);
}
}
}
/**
* Create advanced-cache.php drop-in.
*/
private static function create_advanced_cache() {
$advanced_cache = WP_CONTENT_DIR . '/advanced-cache.php';
if (file_exists($advanced_cache)) {
return;
}
$content = '<?php
/**
* WP Recache advanced cache.
*
* This file is automatically generated by WP Recache.
* Do not edit this file manually.
*/
defined(\'ABSPATH\') || exit;
if (file_exists(WP_CONTENT_DIR . \'/wp-recache-cache.php\')) {
require_once WP_CONTENT_DIR . \'/wp-recache-cache.php\';
}
';
file_put_contents($advanced_cache, $content);
}
/**
* Flush rewrite rules.
*/
private static function flush_rewrite_rules() {
flush_rewrite_rules();
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
/**
* Admin functionality.
*
* @package WP_Recache
*/
defined('ABSPATH') || exit;
class WP_Recache_Admin {
/**
* Initialize admin hooks.
*/
public function init() {
add_action('admin_menu', [$this, 'add_admin_menu']);
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_bar_menu', [$this, 'add_admin_bar_button'], 100);
add_action('wp_ajax_wp_recache_purge_all', [$this, 'ajax_purge_all']);
add_action('wp_ajax_wp_recache_purge_url', [$this, 'ajax_purge_url']);
add_action('admin_notices', [$this, 'admin_notices']);
wp_localize_script('jquery', 'wpRecache', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('wp_recache_nonce'),
]);
}
/**
* Add admin menu page.
*/
public function add_admin_menu() {
add_options_page(
'WP Recache',
'WP Recache',
'manage_options',
'wp-recache',
[$this, 'settings_page']
);
}
/**
* Register settings.
*/
public function register_settings() {
register_setting('wp_recache_settings', 'wp_recache_cache_enabled', [
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => true,
]);
register_setting('wp_recache_settings', 'wp_recache_separate_mobile_cache', [
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
]);
register_setting('wp_recache_settings', 'wp_recache_exclude_urls', [
'type' => 'array',
'sanitize_callback' => [$this, 'sanitize_exclude_urls'],
'default' => ['/cart', '/checkout', '/my-account'],
]);
register_setting('wp_recache_settings', 'wp_recache_minify_css', [
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
]);
register_setting('wp_recache_settings', 'wp_recache_minify_js', [
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
]);
register_setting('wp_recache_settings', 'wp_recache_lazyload_images', [
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
]);
register_setting('wp_recache_settings', 'wp_recache_preload_enabled', [
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
]);
}
/**
* Sanitize exclude URLs.
*
* @param mixed $input Input value.
* @return array Sanitized URLs.
*/
public function sanitize_exclude_urls($input) {
if (!is_array($input)) {
return [];
}
return array_map('sanitize_text_field', $input);
}
/**
* Render settings page.
*/
public function settings_page() {
if (!current_user_can('manage_options')) {
return;
}
$active_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'cache';
include WP_RECACHE_INC_PATH . 'Admin/views/settings.php';
}
/**
* Add admin bar purge button.
*
* @param WP_Admin_Bar $admin_bar Admin bar instance.
*/
public function add_admin_bar_button($admin_bar) {
if (!is_user_logged_in()) {
return;
}
$admin_bar->add_menu([
'id' => 'wp-recache',
'title' => 'WP Recache',
'href' => admin_url('options-general.php?page=wp-recache'),
]);
$admin_bar->add_menu([
'id' => 'wp-recache-purge-all',
'parent' => 'wp-recache',
'title' => 'Purge All Cache',
'href' => '#',
'meta' => [
'onclick' => 'wpRecachePurgeAll();',
],
]);
}
/**
* AJAX handler for purging all cache.
*/
public function ajax_purge_all() {
check_ajax_referer('wp_recache_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error('Unauthorized');
}
wp_recache_delete_all_cache();
wp_send_json_success('Cache purged successfully');
}
/**
* AJAX handler for purging URL cache.
*/
public function ajax_purge_url() {
check_ajax_referer('wp_recache_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error('Unauthorized');
}
$url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
if (empty($url)) {
wp_send_json_error('No URL provided');
}
wp_recache_delete_cache($url);
wp_send_json_success('URL cache purged successfully');
}
/**
* Display admin notices.
*/
public function admin_notices() {
$screen = get_current_screen();
if (!$screen || $screen->id !== 'settings_page_wp-recache') {
return;
}
if (isset($_GET['settings-updated'])) {
echo '<div class="notice notice-success"><p>Settings saved.</p></div>';
}
}
/**
* Get cache statistics.
*
* @return array Cache statistics.
*/
public static function get_cache_stats() {
$cache_path = WP_RECACHE_CACHE_PATH;
if (!is_dir($cache_path)) {
return [
'files' => 0,
'size' => 0,
'size_formatted' => '0 B',
];
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($cache_path, RecursiveDirectoryIterator::SKIP_DOTS)
);
$count = 0;
$size = 0;
foreach ($files as $file) {
if ($file->isFile()) {
$count++;
$size += $file->getSize();
}
}
return [
'files' => $count,
'size' => $size,
'size_formatted' => size_format($size),
];
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* Settings page template.
*
* @package WP_Recache
*/
defined('ABSPATH') || exit;
$stats = WP_Recache_Admin::get_cache_stats();
?>
<div class="wrap">
<h1>WP Recache Settings</h1>
<form method="post" action="options.php">
<?php settings_fields('wp_recache_settings'); ?>
<nav class="nav-tab-wrapper">
<a href="?page=wp-recache&tab=cache" class="nav-tab <?php echo $active_tab === 'cache' ? 'nav-tab-active' : ''; ?>">Cache</a>
<a href="?page=wp-recache&tab=optimize" class="nav-tab <?php echo $active_tab === 'optimize' ? 'nav-tab-active' : ''; ?>">Optimization</a>
<a href="?page=wp-recache&tab=advanced" class="nav-tab <?php echo $active_tab === 'advanced' ? 'nav-tab-active' : ''; ?>">Advanced</a>
</nav>
<div class="tab-content" style="margin-top: 20px;">
<?php if ($active_tab === 'cache') : ?>
<table class="form-table">
<tr>
<th scope="row">Enable Cache</th>
<td>
<label>
<input type="checkbox" name="wp_recache_cache_enabled" value="1" <?php checked(get_option('wp_recache_cache_enabled', true)); ?>>
Enable page caching
</label>
<p class="description">Serve cached HTML pages to anonymous visitors.</p>
</td>
</tr>
<tr>
<th scope="row">Separate Mobile Cache</th>
<td>
<label>
<input type="checkbox" name="wp_recache_separate_mobile_cache" value="1" <?php checked(get_option('wp_recache_separate_mobile_cache', false)); ?>>
Create separate cache for mobile devices
</label>
<p class="description">Useful for sites with different layouts for mobile.</p>
</td>
</tr>
<tr>
<th scope="row">Exclude URLs</th>
<td>
<textarea name="wp_recache_exclude_urls[]" rows="5" cols="50" class="large-text"><?php echo esc_textarea(implode("\n", get_option('wp_recache_exclude_urls', ['/cart', '/checkout', '/my-account']))); ?></textarea>
<p class="description">One URL per line. These pages will not be cached.</p>
</td>
</tr>
</table>
<h2>Cache Statistics</h2>
<table class="form-table">
<tr>
<th scope="row">Cached Files</th>
<td><?php echo esc_html($stats['files']); ?></td>
</tr>
<tr>
<th scope="row">Cache Size</th>
<td><?php echo esc_html($stats['size_formatted']); ?></td>
</tr>
</table>
<?php elseif ($active_tab === 'optimize') : ?>
<table class="form-table">
<tr>
<th scope="row">Minify CSS</th>
<td>
<label>
<input type="checkbox" name="wp_recache_minify_css" value="1" <?php checked(get_option('wp_recache_minify_css', false)); ?>>
Minify CSS files
</label>
<p class="description">Remove whitespace and comments from CSS files.</p>
</td>
</tr>
<tr>
<th scope="row">Minify JavaScript</th>
<td>
<label>
<input type="checkbox" name="wp_recache_minify_js" value="1" <?php checked(get_option('wp_recache_minify_js', false)); ?>>
Minify JavaScript files
</label>
<p class="description">Remove whitespace and comments from JS files.</p>
</td>
</tr>
<tr>
<th scope="row">Lazy Load Images</th>
<td>
<label>
<input type="checkbox" name="wp_recache_lazyload_images" value="1" <?php checked(get_option('wp_recache_lazyload_images', false)); ?>>
Enable lazy loading for images
</label>
<p class="description">Load images only when they enter the viewport.</p>
</td>
</tr>
</table>
<?php elseif ($active_tab === 'advanced') : ?>
<table class="form-table">
<tr>
<th scope="row">Preload Cache</th>
<td>
<label>
<input type="checkbox" name="wp_recache_preload_enabled" value="1" <?php checked(get_option('wp_recache_preload_enabled', false)); ?>>
Enable cache preloading
</label>
<p class="description">Automatically crawl sitemap to warm cache.</p>
</td>
</tr>
</table>
<h2>Tools</h2>
<table class="form-table">
<tr>
<th scope="row">Purge Cache</th>
<td>
<button type="button" class="button" onclick="wpRecachePurgeAll();">Purge All Cache</button>
<p class="description">Delete all cached files.</p>
</td>
</tr>
</table>
<?php endif; ?>
</div>
<?php submit_button('Save Settings'); ?>
</form>
</div>
<script>
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);
}
});
}
</script>
+135
View File
@@ -0,0 +1,135 @@
<?php
/**
* Core page cache functionality.
*
* @package WP_Recache
*/
defined('ABSPATH') || exit;
class WP_Recache_WPCache {
/**
* Initialize cache hooks.
*/
public function init() {
add_action('init', [$this, 'start_output_buffering']);
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_filter('heartbeat_received', [$this, 'purge_cache_onheartbeat']);
}
/**
* Start output buffering.
*/
public function start_output_buffering() {
if (wp_recache_should_exclude()) {
return;
}
if (wp_recache_is_cache_enabled()) {
$this->serve_cached_page();
}
}
/**
* Serve cached page if available.
*/
private function serve_cached_page() {
$url = $this->get_current_url();
$cache_path = wp_recache_get_cache_path_with_device($url);
if (file_exists($cache_path)) {
$modified_time = filemtime($cache_path);
$cache_duration = wp_recache_get_option('cache_duration', 3600);
if ((time() - $modified_time) < $cache_duration) {
header('X-WP-Recache: HIT');
header('X-WP-Recache-Time: ' . date('Y-m-d H:i:s', $modified_time));
readfile($cache_path);
exit;
}
}
header('X-WP-Recache: MISS');
ob_start([$this, 'cache_output']);
}
/**
* Cache the output.
*
* @param string $output Page output.
* @return string Original output.
*/
public function cache_output($output) {
if (empty($output)) {
return $output;
}
$url = $this->get_current_url();
$cache_path = wp_recache_get_cache_path_with_device($url);
$cache_dir = dirname($cache_path);
if (!is_dir($cache_dir)) {
wp_mkdir_p($cache_dir);
}
file_put_contents($cache_path, $output);
return $output;
}
/**
* End output buffering.
*/
public function end_output_buffering() {
if (ob_get_level()) {
ob_end_flush();
}
}
/**
* Purge cache for a post.
*
* @param int $post_id Post ID.
*/
public function purge_post_cache($post_id) {
$url = get_permalink($post_id);
if ($url) {
wp_recache_delete_cache($url);
}
wp_recache_delete_cache(home_url('/'));
}
/**
* Purge cache on heartbeat.
*
* @param array $response Heartbeat response.
* @return array Modified response.
*/
public function purge_cache_onheartbeat($response) {
if (isset($response['wp_recache_purge'])) {
wp_recache_delete_all_cache();
}
return $response;
}
/**
* Get current request URL.
*
* @return string Current URL.
*/
private function get_current_url() {
$protocol = is_ssl() ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$uri = $_SERVER['REQUEST_URI'];
return $protocol . '://' . $host . $uri;
}
}
+216
View File
@@ -0,0 +1,216 @@
<?php
/**
* Common helper functions.
*
* @package WP_Recache
*/
defined('ABSPATH') || exit;
/**
* Check if cache is enabled.
*/
function wp_recache_is_cache_enabled() {
return (bool) get_option('wp_recache_cache_enabled', true);
}
/**
* Check if a specific feature is enabled.
*
* @param string $feature Feature name.
*/
function wp_recache_is_feature_enabled($feature) {
return (bool) get_option("wp_recache_{$feature}_enabled", false);
}
/**
* Get plugin option with default.
*
* @param string $option Option name.
* @param mixed $default Default value.
*/
function wp_recache_get_option($option, $default = false) {
return get_option("wp_recache_{$option}", $default);
}
/**
* Set plugin option.
*
* @param string $option Option name.
* @param mixed $value Option value.
*/
function wp_recache_set_option($option, $value) {
return update_option("wp_recache_{$option}", $value);
}
/**
* Check if current user is logged in.
*/
function wp_recache_is_logged_in() {
return is_user_logged_in();
}
/**
* Check if current request should be excluded from cache.
*/
function wp_recache_should_exclude() {
if (wp_recache_is_logged_in()) {
return true;
}
if (wp_recache_is_robots()) {
return true;
}
if (is_preview()) {
return true;
}
if (wp_recache_is_post_action()) {
return true;
}
$exclude_urls = wp_recache_get_option('exclude_urls', []);
$request_uri = wp_parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
foreach ($exclude_urls as $url) {
if (strpos($request_uri, trim($url)) !== false) {
return true;
}
}
return apply_filters('wp_recache_exclude', false);
}
/**
* Check if request is for a robot/crawler.
*/
function wp_recache_is_robots() {
return isset($_SERVER['HTTP_USER_AGENT']) && strpos($_SERVER['HTTP_USER_AGENT'], 'bot') !== false;
}
/**
* Check if request is a POST action.
*/
function wp_recache_is_post_action() {
return $_SERVER['REQUEST_METHOD'] === '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.
*
* @param string $url URL to cache.
*/
function wp_recache_get_cache_key($url) {
$key = wp_parse_url($url, PHP_URL_PATH);
if (empty($key)) {
$key = '/';
}
return md5($key);
}
/**
* Delete cache file for URL.
*
* @param string $url URL to delete cache for.
*/
function wp_recache_delete_cache($url) {
$path = wp_recache_get_cache_path($url);
if (file_exists($path)) {
return unlink($path);
}
return false;
}
/**
* Delete all cache files.
*/
function wp_recache_delete_all_cache() {
$cache_path = WP_RECACHE_CACHE_PATH;
if (!is_dir($cache_path)) {
return false;
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($cache_path, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
if ($file->isDir()) {
rmdir($file->getRealPath());
} else {
unlink($file->getRealPath());
}
}
return true;
}
/**
* Check if URL is mobile.
*/
function wp_recache_is_mobile() {
if (!isset($_SERVER['HTTP_USER_AGENT'])) {
return false;
}
$mobile_agents = [
'android',
'iphone',
'ipad',
'ipod',
'blackberry',
'opera mini',
'opera mobi',
];
$user_agent = strtolower($_SERVER['HTTP_USER_AGENT']);
foreach ($mobile_agents as $agent) {
if (strpos($user_agent, $agent) !== false) {
return true;
}
}
return false;
}
/**
* Get cache file path for URL with mobile detection.
*
* @param string $url URL to cache.
*/
function wp_recache_get_cache_path_with_device($url) {
$path = wp_recache_get_cache_path($url);
if (wp_recache_get_option('separate_mobile_cache', false)) {
$device = wp_recache_is_mobile() ? 'mobile' : 'desktop';
$path = str_replace('.html', "-{$device}.html", $path);
}
return $path;
}
+37
View File
@@ -0,0 +1,37 @@
<?php
/**
* Plugin deactivation handler.
*
* @package WP_Recache
*/
defined('ABSPATH') || exit;
class WP_Recache_Deactivator {
/**
* Run deactivation routines.
*/
public static function deactivate() {
self::remove_advanced_cache();
self::flush_rewrite_rules();
}
/**
* Remove advanced-cache.php drop-in.
*/
private static function remove_advanced_cache() {
$advanced_cache = WP_CONTENT_DIR . '/advanced-cache.php';
if (file_exists($advanced_cache)) {
unlink($advanced_cache);
}
}
/**
* Flush rewrite rules.
*/
private static function flush_rewrite_rules() {
flush_rewrite_rules();
}
}