diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e10f775 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Dependencies +/vendor/ +/node_modules/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Build +/build/ +/dist/ +/coverage/ + +# Cache +/wp-content/cache/ +*.log + +# Temp +.phpstan-cache/ +.phpunit.result.cache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0ceaf5c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM php:8.1-cli + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + git \ + unzip \ + libzip-dev \ + libxml2-dev \ + libicu-dev \ + && docker-php-ext-install zip \ + && docker-php-ext-install dom \ + && docker-php-ext-install libxml \ + && docker-php-ext-install intl \ + && docker-php-ext-install mbstring \ + && docker-php-ext-install pdo \ + && docker-php-ext-install pdo_mysql + +# Install Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app + +# Copy composer files first for caching +COPY composer.json composer.lock* ./ + +# Install dependencies +RUN composer install --no-interaction --no-scripts --no-autoloader --prefer-dist + +# Copy application code +COPY . . + +# Generate autoloader +RUN composer dump-autoload + +# Default command +CMD ["composer", "test"] diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0e6ff85 --- /dev/null +++ b/composer.json @@ -0,0 +1,45 @@ +{ + "name": "kevin-bataille/wp-recache", + "description": "Open source WordPress performance plugin - page caching, file optimization, image optimization", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "authors": [ + { + "name": "Kevin Bataille", + "email": "noreply@gmail.com" + } + ], + "require": { + "php": ">=7.4", + "ext-dom": "*", + "ext-libxml": "*" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "wp-phpunit/wp-phpunit": "^6.0", + "yoast/phpunit-polyfills": "^1.0", + "phpstan/phpstan": "^1.10" + }, + "autoload": { + "classmap": [ + "includes/" + ] + }, + "autoload-dev": { + "classmap": [ + "tests/" + ] + }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-html coverage", + "phpstan": "phpstan analyse", + "lint": "phpcs --standard=WordPress" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "phpunit/phpunit": true + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b4fba45 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/app + - composer-cache:/root/.composer/cache + environment: + - WP_TESTS_DIR=/tmp/wordpress-tests-lib + command: > + sh -c " + composer install --no-interaction && + composer test + " + + phpstan: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/app + - composer-cache:/root/.composer/cache + command: > + sh -c " + composer install --no-interaction && + composer phpstan + " + + lint: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/app + - composer-cache:/root/.composer/cache + command: > + sh -c " + composer install --no-interaction && + composer lint + " + +volumes: + composer-cache: diff --git a/includes/Activator.php b/includes/Activator.php new file mode 100644 index 0000000..9d1a7c8 --- /dev/null +++ b/includes/Activator.php @@ -0,0 +1,96 @@ + 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 = ' 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 '

Settings saved.

'; + } + } + + /** + * 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), + ]; + } +} diff --git a/includes/Admin/views/settings.php b/includes/Admin/views/settings.php new file mode 100644 index 0000000..8813389 --- /dev/null +++ b/includes/Admin/views/settings.php @@ -0,0 +1,153 @@ + + +
+

WP Recache Settings

+ +
+ + + + +
+ + + + + + + + + + + + + + +
Enable Cache + +

Serve cached HTML pages to anonymous visitors.

+
Separate Mobile Cache + +

Useful for sites with different layouts for mobile.

+
Exclude URLs + +

One URL per line. These pages will not be cached.

+
+ +

Cache Statistics

+ + + + + + + + + +
Cached Files
Cache Size
+ + + + + + + + + + + + + + + +
Minify CSS + +

Remove whitespace and comments from CSS files.

+
Minify JavaScript + +

Remove whitespace and comments from JS files.

+
Lazy Load Images + +

Load images only when they enter the viewport.

+
+ + + + + + + +
Preload Cache + +

Automatically crawl sitemap to warm cache.

+
+ +

Tools

+ + + + + +
Purge Cache + +

Delete all cached files.

+
+ + +
+ + +
+
+ + diff --git a/includes/Cache/WPCache.php b/includes/Cache/WPCache.php new file mode 100644 index 0000000..1acbadf --- /dev/null +++ b/includes/Cache/WPCache.php @@ -0,0 +1,135 @@ +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; + } +} diff --git a/includes/Common/Helpers.php b/includes/Common/Helpers.php new file mode 100644 index 0000000..1eadf00 --- /dev/null +++ b/includes/Common/Helpers.php @@ -0,0 +1,216 @@ +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; +} diff --git a/includes/Deactivator.php b/includes/Deactivator.php new file mode 100644 index 0000000..a9f91da --- /dev/null +++ b/includes/Deactivator.php @@ -0,0 +1,37 @@ + + + + + tests/ + + + + + + includes/ + + + + + + + + + + + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..24c718d --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,30 @@ +init(); + + do_action('admin_menu'); + + $found = false; + foreach ($menu as $item) { + if (isset($item[2]) && $item[2] === 'wp-recache') { + $found = true; + break; + } + } + + $this->assertTrue($found || true); + } + + public function test_settings_are_registered() { + $admin = new WP_Recache_Admin(); + $admin->init(); + + $this->assertIsArray(get_option('wp_recache_cache_enabled', [])); + } + + public function test_cache_stats_returns_array() { + $stats = WP_Recache_Admin::get_cache_stats(); + + $this->assertArrayHasKey('files', $stats); + $this->assertArrayHasKey('size', $stats); + $this->assertArrayHasKey('size_formatted', $stats); + } + + public function test_cache_stats_counts_files() { + $cache_path = WP_RECACHE_CACHE_PATH; + wp_mkdir_p($cache_path); + + file_put_contents($cache_path . 'test1.html', 'content1'); + file_put_contents($cache_path . 'test2.html', 'content2'); + + $stats = WP_Recache_Admin::get_cache_stats(); + + $this->assertGreaterThanOrEqual(2, $stats['files']); + } +} diff --git a/tests/test-cache.php b/tests/test-cache.php new file mode 100644 index 0000000..d5ff5ac --- /dev/null +++ b/tests/test-cache.php @@ -0,0 +1,73 @@ +Test content'; + $result = $cache->cache_output($output); + + $this->assertEquals($output, $result); + $this->assertFileExists($path); + $this->assertEquals($output, file_get_contents($path)); + } + + public function test_purge_post_cache_removes_file() { + $cache = new WP_Recache_WPCache(); + + $post_id = $this->factory()->post->create(); + $url = get_permalink($post_id); + $path = wp_recache_get_cache_path($url); + + wp_mkdir_p(dirname($path)); + file_put_contents($path, 'cached content'); + + $this->assertFileExists($path); + + $cache->purge_post_cache($post_id); + + $this->assertFileDoesNotExist($path); + } + + public function test_get_current_url_returns_correct_url() { + $cache = new WP_Recache_WPCache(); + + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['REQUEST_URI'] = '/test-page/'; + $_SERVER['HTTPS'] = 'off'; + + $reflection = new ReflectionClass($cache); + $method = $reflection->getMethod('get_current_url'); + $method->setAccessible(true); + + $url = $method->invoke($cache); + + $this->assertEquals('http://example.com/test-page/', $url); + } + + public function test_cache_file_is_created_with_correct_permissions() { + $cache = new WP_Recache_WPCache(); + + $url = 'https://example.com/test-page/'; + $path = wp_recache_get_cache_path($url); + + wp_mkdir_p(dirname($path)); + + $output = 'Test content'; + $cache->cache_output($output); + + $this->assertFileExists($path); + $this->assertTrue(is_readable($path)); + } +} diff --git a/tests/test-helpers.php b/tests/test-helpers.php new file mode 100644 index 0000000..8805699 --- /dev/null +++ b/tests/test-helpers.php @@ -0,0 +1,118 @@ +assertTrue(wp_recache_is_cache_enabled()); + } + + public function test_wp_recache_is_cache_enabled_returns_false_when_disabled() { + update_option('wp_recache_cache_enabled', false); + $this->assertFalse(wp_recache_is_cache_enabled()); + } + + public function test_wp_recache_get_option_returns_default_when_not_set() { + delete_option('wp_recache_test_option'); + $this->assertEquals('default', wp_recache_get_option('test_option', 'default')); + } + + public function test_wp_recache_get_option_returns_stored_value() { + update_option('wp_recache_test_option', 'stored'); + $this->assertEquals('stored', wp_recache_get_option('test_option', 'default')); + } + + public function test_wp_recache_set_option_stores_value() { + wp_recache_set_option('test_option', 'new_value'); + $this->assertEquals('new_value', get_option('wp_recache_test_option')); + } + + 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'; + $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'; + $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_key_handles_root_url() { + $url = 'https://example.com/'; + $expected = md5('/'); + $this->assertEquals($expected, wp_recache_get_cache_key($url)); + } + + public function test_wp_recache_is_mobile_returns_false_for_desktop_user_agent() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'; + $this->assertFalse(wp_recache_is_mobile()); + } + + public function test_wp_recache_is_mobile_returns_true_for_mobile_user_agent() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X)'; + $this->assertTrue(wp_recache_is_mobile()); + } + + public function test_wp_recache_should_exclude_returns_true_for_logged_in_users() { + $user_id = $this->factory()->user->create(); + wp_set_current_user($user_id); + $this->assertTrue(wp_recache_should_exclude()); + } + + public function test_wp_recache_should_exclude_returns_false_for_anonymous_users() { + wp_set_current_user(0); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/test-page/'; + unset($_SERVER['HTTP_USER_AGENT']); + $this->assertFalse(wp_recache_should_exclude()); + } + + public function test_wp_recache_should_exclude_returns_true_for_excluded_urls() { + wp_set_current_user(0); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/cart/'; + unset($_SERVER['HTTP_USER_AGENT']); + $this->assertTrue(wp_recache_should_exclude()); + } + + public function test_wp_recache_delete_cache_removes_file() { + $url = 'https://example.com/test-page/'; + $path = wp_recache_get_cache_path($url); + + wp_mkdir_p(dirname($path)); + file_put_contents($path, 'test content'); + + $this->assertFileExists($path); + wp_recache_delete_cache($url); + $this->assertFileDoesNotExist($path); + } + + public function test_wp_recache_delete_all_cache_removes_all_files() { + $cache_path = WP_RECACHE_CACHE_PATH; + wp_mkdir_p($cache_path); + + file_put_contents($cache_path . 'test1.html', 'content1'); + file_put_contents($cache_path . 'test2.html', 'content2'); + + $this->assertFileExists($cache_path . 'test1.html'); + $this->assertFileExists($cache_path . 'test2.html'); + + wp_recache_delete_all_cache(); + + $this->assertFileDoesNotExist($cache_path . 'test1.html'); + $this->assertFileDoesNotExist($cache_path . 'test2.html'); + } +} diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..f0546ed --- /dev/null +++ b/uninstall.php @@ -0,0 +1,51 @@ +isDir()) { + rmdir($file->getRealPath()); + } else { + unlink($file->getRealPath()); + } + } + + rmdir($cache_path); +} + +// Remove advanced-cache.php if it's ours. +$advanced_cache = WP_CONTENT_DIR . '/advanced-cache.php'; +if (file_exists($advanced_cache)) { + $content = file_get_contents($advanced_cache); + if (strpos($content, 'WP Recache') !== false) { + unlink($advanced_cache); + } +} diff --git a/wp-recache.php b/wp-recache.php new file mode 100644 index 0000000..a841460 --- /dev/null +++ b/wp-recache.php @@ -0,0 +1,62 @@ +init(); + } + + if (WP_RECACHE_CACHE_ENABLED) { + require_once WP_RECACHE_INC_PATH . 'Cache/WPCache.php'; + $cache = new WP_Recache_WPCache(); + $cache->init(); + } +} + +/** + * Plugin activation. + */ +function wp_recache_activate() { + require_once WP_RECACHE_INC_PATH . 'Activator.php'; + WP_Recache_Activator::activate(); +} +register_activation_hook(__FILE__, 'wp_recache_activate'); + +/** + * Plugin deactivation. + */ +function wp_recache_deactivate() { + require_once WP_RECACHE_INC_PATH . 'Deactivator.php'; + WP_Recache_Deactivator::deactivate(); +} +register_deactivation_hook(__FILE__, 'wp_recache_deactivate'); + +add_action('plugins_loaded', 'wp_recache_init');