diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4a407b4..d4993aa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,9 +12,9 @@ jobs: strategy: matrix: - php: [8.3, 8.2] - laravel: [11.*] - statamic: [^5.0] + php: [8.4, 8.3] + laravel: [12.*] + statamic: [^6.0] os: [ubuntu-latest] name: ${{ matrix.php }} - ${{ matrix.statamic }} - ${{ matrix.laravel }} diff --git a/README.md b/README.md index 220fc54..9d43271 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,4 @@ To invalidate all pages containing your tracked data call: Tracker::flush(); ``` +Alternatively you can use the Cache Tracker Utility in the CP to enter a list of URLs you want to clear. diff --git a/composer.json b/composer.json index ee2baf5..186faa7 100644 --- a/composer.json +++ b/composer.json @@ -11,17 +11,17 @@ } }, "require": { - "php": "^8.1", + "php": "^8.2", "pixelfear/composer-dist-plugin": "^0.1.5", - "statamic/cms": "^4.55 || ^5.0" + "statamic/cms": "^6.0" }, "require-dev": { "laravel/pint": "^1.13", "mockery/mockery": "^1.3.1", "nunomaduro/collision": "^8.0", - "orchestra/testbench": "^9.0", - "pestphp/pest": "^2.24", - "phpunit/phpunit": "^10.0" + "orchestra/testbench": "^10.0", + "pestphp/pest": "^4.0", + "phpunit/phpunit": "^12.0" }, "extra": { "statamic": { @@ -39,5 +39,6 @@ "pestphp/pest-plugin": true, "pixelfear/composer-dist-plugin": true } - } + }, + "minimum-stability": "alpha" } diff --git a/package.json b/package.json new file mode 100644 index 0000000..3bee835 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "dependencies": { + "@statamic/cms": "file:./vendor/statamic/cms/resources/dist-package", + "axios": "^1.12.2" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "production": "vite build" + }, + "devDependencies": { + "laravel-vite-plugin": "^1.2.0", + "vite": "^6.3.4" + } +} diff --git a/resources/js/components/CacheTrackerModal.vue b/resources/js/components/CacheTrackerModal.vue new file mode 100644 index 0000000..d0bbb71 --- /dev/null +++ b/resources/js/components/CacheTrackerModal.vue @@ -0,0 +1,92 @@ + + + diff --git a/resources/js/cp.js b/resources/js/cp.js new file mode 100644 index 0000000..fda0ac9 --- /dev/null +++ b/resources/js/cp.js @@ -0,0 +1,8 @@ +import CacheTrackerModal from './components/CacheTrackerModal.vue'; +import ClearUtility from "./pages/ClearUtility.vue"; + +Statamic.booting(() => { + Statamic.$components.register('cache-tracker-modal', CacheTrackerModal); + + Statamic.$components.register('Pages/CacheTracker/ClearUtility', ClearUtility); +}); diff --git a/resources/js/pages/ClearUtility.vue b/resources/js/pages/ClearUtility.vue new file mode 100644 index 0000000..9259060 --- /dev/null +++ b/resources/js/pages/ClearUtility.vue @@ -0,0 +1,38 @@ + + + diff --git a/routes/cp.php b/routes/cp.php new file mode 100644 index 0000000..c48fc1b --- /dev/null +++ b/routes/cp.php @@ -0,0 +1,9 @@ +prefix('cache-tracker')->group(function () { + Route::post('tags', [Controllers\GetTagsController::class, '__invoke'])->name('tags'); + Route::post('urls', [Controllers\GetUrlsController::class, '__invoke'])->name('url'); +}); diff --git a/src/Actions/ClearCache.php b/src/Actions/ClearCache.php new file mode 100644 index 0000000..42f13c6 --- /dev/null +++ b/src/Actions/ClearCache.php @@ -0,0 +1,51 @@ +filter(fn ($item) => $item->absoluteUrl()) + ->each(fn ($item) => Tracker::remove($item->absoluteUrl())); + + return __('Cache cleared'); + } + + public function icon(): string + { + return 'rewind'; + } + + public static function title() + { + return __('Clear cache'); + } + + public function confirmationText() + { + return __('Are you sure you want to clear the static cache for the url: :url ?', ['url' => $this->items->first()->absoluteUrl()]); + } + + public function visibleTo($item) + { + if (! auth()->user()->can('clear cache tracker tags')) { + return false; + } + + if (! ($item instanceof Entry || $item instanceof Term)) { + return false; + } + + return ! Blink::once( + 'cache-action::'.$item->collectionHandle.'::'.$item->locale(), + fn () => is_null($item->collection()->route($item->locale())) + ); + } +} diff --git a/src/Actions/ViewCacheTags.php b/src/Actions/ViewCacheTags.php new file mode 100644 index 0000000..fb1f418 --- /dev/null +++ b/src/Actions/ViewCacheTags.php @@ -0,0 +1,65 @@ +user()->can('view cache tracker tags')) { + return false; + } + + if (! $item instanceof Entry) { + return false; + } + + return ! Blink::once( + 'cache-action::'.$item->collectionHandle.'::'.$item->locale(), + fn () => is_null($item->collection()->route($item->locale())) + ); + } + + public function visibleToBulk($items) + { + return false; + } + + public function buttonText() + { + /** @translation */ + return __('Clear cache'); + } + + public function toArray() + { + return [ + ...parent::toArray(), + 'item_title' => $this->items->first()?->title, + 'item_url' => $this->items->first()?->absoluteUrl(), + ]; + } +} diff --git a/src/Http/Controllers/GetTagsController.php b/src/Http/Controllers/GetTagsController.php new file mode 100644 index 0000000..b81d3e4 --- /dev/null +++ b/src/Http/Controllers/GetTagsController.php @@ -0,0 +1,27 @@ +input('url')) { + return []; + } + + if (Str::endsWith($url, '/')) { + $url = Str::beforeLast($url, '/'); + } + + if ($data = Tracker::get($url)) { + return $data['tags']; + } + + return []; + } +} diff --git a/src/Http/Controllers/GetUrlsController.php b/src/Http/Controllers/GetUrlsController.php new file mode 100644 index 0000000..e12a92a --- /dev/null +++ b/src/Http/Controllers/GetUrlsController.php @@ -0,0 +1,35 @@ +input('url')) { + return []; + } + + if (! $item = Data::find($url)) { + return []; + } + + if ($item instanceof Entry) { + $item = $item->collectionHandle().':'.$item->id(); + } + + if ($item instanceof Term) { + $item = 'term:'.$item->id(); + } + + return collect(Tracker::all()) + ->filter(fn ($tracked) => in_array($item, $tracked['tags'])) + ->all(); + } +} diff --git a/src/Http/Controllers/UtilityController.php b/src/Http/Controllers/UtilityController.php new file mode 100644 index 0000000..e0b325f --- /dev/null +++ b/src/Http/Controllers/UtilityController.php @@ -0,0 +1,54 @@ +input('urls')) { + return [ + 'message' => __('No URLs provided'), + ]; + } + + if ($urls == '*') { + Tracker::flush(); + + return [ + 'message' => __('Cache flushed'), + ]; + } + + $urls = collect(explode(PHP_EOL, $urls)); + + $wildcards = $urls->filter(fn ($url) => Str::endsWith($url, '*')); + + // remove any non-wildcards first + $urls->reject(fn ($url) => Str::endsWith($url, '*')) + ->each(function ($url) { + if (Str::endsWith($url, '/')) { + $url = substr($url, 0, -1); + } + + Tracker::remove($url); + }); + + collect(Tracker::all()) + ->each(function ($data) use ($wildcards) { + $wildcards->each(function ($wildcard) use ($data) { + if (Str::startsWith($data['url'], Str::beforeLast($wildcard, '*'))) { + Tracker::remove($data['url']); + } + }); + }); + + return [ + 'message' => __('URLs cleared from cache'), + ]; + } +} diff --git a/src/Http/Middleware/CacheTracker.php b/src/Http/Middleware/CacheTracker.php index f0373d7..d60a6eb 100644 --- a/src/Http/Middleware/CacheTracker.php +++ b/src/Http/Middleware/CacheTracker.php @@ -49,6 +49,10 @@ public function handle($request, Closure $next) $url = $this->url(); + if (Str::endsWith($url, '/')) { + $url = substr($url, 0, -1); + } + if (Tracker::has($url)) { return $next($request); } @@ -123,13 +127,13 @@ private function setupAugmentationHooks(string $url) return $next($augmented); }); - app(Entry::class)::hook('augmented', function ($augmented, $next) use ($self, $url) { + app(Entry::class)::hook('augmented', function ($augmented, $next) use ($self) { $self->addContentTag($this->collection()->handle().':'.$this->id()); return $next($augmented); }); - Page::hook('augmented', function ($augmented, $next) use ($self, $url) { + Page::hook('augmented', function ($augmented, $next) use ($self) { if ($entry = $this->entry()) { $self->addContentTag($entry->collection()->handle().':'.$entry->id()); } @@ -137,7 +141,7 @@ private function setupAugmentationHooks(string $url) return $next($augmented); }); - LocalizedTerm::hook('augmented', function ($augmented, $next) use ($self, $url) { + LocalizedTerm::hook('augmented', function ($augmented, $next) use ($self) { $self->addContentTag('term:'.$this->id()); return $next($augmented); diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 69d106d..05d8545 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,10 +2,18 @@ namespace Thoughtco\StatamicCacheTracker; +use Statamic\Facades\Permission; +use Statamic\Facades\Utility; use Statamic\Providers\AddonServiceProvider; +use Thoughtco\StatamicCacheTracker\Http\Controllers\UtilityController; class ServiceProvider extends AddonServiceProvider { + protected $actions = [ + Actions\ClearCache::class, + Actions\ViewCacheTags::class, + ]; + protected $middlewareGroups = [ 'web' => [ Http\Middleware\CacheTracker::class, @@ -16,6 +24,16 @@ class ServiceProvider extends AddonServiceProvider Listeners\Subscriber::class, ]; + protected $routes = [ + 'cp' => __DIR__.'/../routes/cp.php', + ]; + + protected $vite = [ + 'input' => ['resources/js/cp.js'], + 'publicDirectory' => 'dist', + 'hotFile' => __DIR__.'/../dist/hot', + ]; + public function boot() { parent::boot(); @@ -25,5 +43,39 @@ public function boot() $this->publishes([ $config => config_path('statamic-cache-tracker.php'), ], 'statamic-cache-tracker-config'); + + $this->setupAddonPermissions() + ->setupAddonUtility(); + } + + private function setupAddonPermissions() + { + Permission::group('cache-tracker', 'Cache Tracker', function () { + Permission::register('view cache tracker tags') + ->label(__('View Tags')) + ->description(__('Enable the action on listing views to view tags')); + + Permission::register('clear cache tracker tags') + ->label(__('Clear Tags')) + ->description(__('Enable the action on listing views to clear tags')); + }); + + return $this; + } + + private function setupAddonUtility() + { + Utility::extend(function () { + Utility::register('static-cache-tracker') + ->title(__('Cache Tracker')) + ->description(__('Clear specific paths in your static cache.')) + ->inertia('CacheTracker/ClearUtility') + ->icon('taxonomies') + ->routes(function ($router) { + $router->post('/clear', UtilityController::class)->name('clear'); + }); + }); + + return $this; } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 8419f79..e5915a9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -20,7 +20,7 @@ protected function resolveApplicationConfiguration($app) { parent::resolveApplicationConfiguration($app); - $app['config']->set('app.key', 'base64:' . base64_encode(Encrypter::generateKey($app['config']['app.cipher']))); + $app['config']->set('app.key', 'base64:'.base64_encode(Encrypter::generateKey($app['config']['app.cipher']))); // Assume the pro edition within tests $app['config']->set('statamic.editions.pro', true); @@ -30,7 +30,7 @@ protected function resolveApplicationConfiguration($app) // views for front end routing tests $app['config']->set('view.paths', [ - __DIR__ . '/__fixtures__/resources/views', + __DIR__.'/__fixtures__/resources/views', ]); } diff --git a/tests/Unit/AdditionalTrackerTest.php b/tests/Unit/AdditionalTrackerTest.php index 6313da5..edac4ec 100644 --- a/tests/Unit/AdditionalTrackerTest.php +++ b/tests/Unit/AdditionalTrackerTest.php @@ -17,7 +17,7 @@ public function tracks_additional_closures() $this->get('/'); - $this->assertSame(['test::tag', 'pages:home'], collect(Tracker::all())->firstWhere('url', 'http://localhost/')['tags']); + $this->assertSame(['test::tag', 'pages:home'], collect(Tracker::all())->firstWhere('url', 'http://localhost')['tags']); } #[Test] @@ -27,7 +27,7 @@ public function tracks_additional_classes() $this->get('/'); - $this->assertSame(['additional::tag', 'pages:home'], collect(Tracker::all())->firstWhere('url', 'http://localhost/')['tags']); + $this->assertSame(['additional::tag', 'pages:home'], collect(Tracker::all())->firstWhere('url', 'http://localhost')['tags']); } } diff --git a/tests/Unit/EventListenerTest.php b/tests/Unit/EventListenerTest.php index 5d4fc29..e993c00 100644 --- a/tests/Unit/EventListenerTest.php +++ b/tests/Unit/EventListenerTest.php @@ -26,7 +26,7 @@ public function it_tracks_tags_from_events() $middleware = new CacheTracker(); $middleware->handle($request, $next); - $this->assertSame(['test::tag'], collect(Tracker::all())->firstWhere('url', 'http://localhost/')['tags']); + $this->assertSame(['test::tag'], collect(Tracker::all())->firstWhere('url', 'http://localhost')['tags']); } #[Test] @@ -43,7 +43,7 @@ public function dispatching_job_clears_tags() $middleware = new CacheTracker(); $middleware->handle($request, $next); - $this->assertSame(['test::tag'], collect(Tracker::all())->firstWhere('url', 'http://localhost/')['tags']); + $this->assertSame(['test::tag'], collect(Tracker::all())->firstWhere('url', 'http://localhost')['tags']); InvalidateTags::dispatch(['test::tag']); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..c547d8f --- /dev/null +++ b/vite.config.js @@ -0,0 +1,17 @@ +import laravel from 'laravel-vite-plugin' +import { defineConfig } from 'vite' +import statamic from '@statamic/cms/vite-plugin'; + +export default defineConfig({ + plugins: [ + statamic(), + laravel({ + input: [ + 'resources/js/cp.js' + ], + refresh: true, + publicDirectory: 'dist', + hotFile: 'dist/hot', + }), + ], +});