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 @@
+
+
+
+
+
+ Tags on this URL
+ URLs with this item
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+ {{ __('Enter URLs to clear, with each on a new line. You can use * as a wildcard at the end of your URL.') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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',
+ }),
+ ],
+});