├── tests ├── __fixtures__ │ ├── dev-null │ │ └── .gitkeep │ └── resources │ │ └── views │ │ ├── default.antlers.html │ │ └── layout.antlers.html ├── Unit │ ├── AdditionalTrackerTest.php │ ├── EventListenerTest.php │ └── TrackerTest.php └── TestCase.php ├── config └── statamic-cache-tracker.php ├── .gitignore ├── src ├── Events │ ├── TrackContentTags.php │ └── ContentTracked.php ├── Facades │ └── Tracker.php ├── Jobs │ └── InvalidateTags.php ├── Http │ ├── Controllers │ │ ├── GetTagsController.php │ │ ├── GetUrlsController.php │ │ └── UtilityController.php │ └── Middleware │ │ └── CacheTracker.php ├── Actions │ ├── ClearCache.php │ └── ViewCacheTags.php ├── ServiceProvider.php ├── Tracker │ └── Manager.php └── Listeners │ └── Subscriber.php ├── .editorconfig ├── resources └── js │ ├── cp.js │ ├── pages │ └── ClearUtility.vue │ └── components │ └── CacheTrackerModal.vue ├── routes └── cp.php ├── package.json ├── vite.config.js ├── README.md ├── phpunit.xml ├── .github └── workflows │ ├── tests.yml │ └── release.yaml └── composer.json /tests/__fixtures__/dev-null/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__fixtures__/resources/views/default.antlers.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

{{ title }}

4 | -------------------------------------------------------------------------------- /tests/__fixtures__/resources/views/layout.antlers.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ template_content }} 4 | -------------------------------------------------------------------------------- /config/statamic-cache-tracker.php: -------------------------------------------------------------------------------- 1 | env('STATAMIC_CACHE_TRACKER_ENABLED', true), 5 | ]; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.phpunit.cache 2 | /dist 3 | /node_modules 4 | /vendor 5 | composer.lock 6 | mix-manifest.json 7 | package-lock.json 8 | yarn.lock 9 | .idea/* 10 | -------------------------------------------------------------------------------- /src/Events/TrackContentTags.php: -------------------------------------------------------------------------------- 1 | { 6 | Statamic.$components.register('cache-tracker-modal', CacheTrackerModal); 7 | 8 | inertia.register('CacheTracker/ClearUtility', ClearUtility); 9 | }); 10 | -------------------------------------------------------------------------------- /routes/cp.php: -------------------------------------------------------------------------------- 1 | prefix('cache-tracker')->group(function () { 7 | Route::post('tags', [Controllers\GetTagsController::class, '__invoke'])->name('tags'); 8 | Route::post('urls', [Controllers\GetUrlsController::class, '__invoke'])->name('url'); 9 | }); 10 | -------------------------------------------------------------------------------- /src/Facades/Tracker.php: -------------------------------------------------------------------------------- 1 | tags); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Statamic Cache Tracker 2 | 3 | > Statamic Cache Tracker keeps a record of what items (entries, assets, terms etc) are used in the output of each page, and clears the cache (full or half) for those pages when an item is saved. 4 | 5 | 6 | ## How to Install 7 | 8 | Run the following command from your project root: 9 | 10 | ``` bash 11 | composer require thoughtco/statamic-cache-tracker 12 | ``` 13 | 14 | You can also optionally publish the config: 15 | 16 | ```bash 17 | php artisan vendor:publish --tag=statamic-cache-tracker-config 18 | ``` 19 | 20 | ## Documentation 21 | 22 | Documentation for this addon is available at [https://www.docs.tc/cache-tracker](https://www.docs.tc/cache-tracker). 23 | -------------------------------------------------------------------------------- /src/Http/Controllers/GetTagsController.php: -------------------------------------------------------------------------------- 1 | input('url')) { 14 | return []; 15 | } 16 | 17 | if (Str::endsWith($url, '/')) { 18 | $url = Str::beforeLast($url, '/'); 19 | } 20 | 21 | if ($data = Tracker::get($url)) { 22 | return $data['tags']; 23 | } 24 | 25 | return []; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Http/Controllers/GetUrlsController.php: -------------------------------------------------------------------------------- 1 | input('url')) { 16 | return []; 17 | } 18 | 19 | if (! $item = Data::find($url)) { 20 | return []; 21 | } 22 | 23 | if ($item instanceof Entry) { 24 | $item = $item->collectionHandle().':'.$item->id(); 25 | } 26 | 27 | if ($item instanceof Term) { 28 | $item = 'term:'.$item->id(); 29 | } 30 | 31 | return collect(Tracker::all()) 32 | ->filter(fn ($tracked) => in_array($item, $tracked['tags'])) 33 | ->all(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test Suite 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | php_tests: 9 | if: "!contains(github.event.head_commit.message, 'changelog')" 10 | 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | matrix: 15 | php: [8.4, 8.3] 16 | laravel: [12.*] 17 | statamic: [^6.0] 18 | os: [ubuntu-latest] 19 | 20 | name: ${{ matrix.php }} - ${{ matrix.statamic }} - ${{ matrix.laravel }} 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v1 25 | 26 | - name: Setup PHP 27 | uses: shivammathur/setup-php@v2 28 | with: 29 | php-version: ${{ matrix.php }} 30 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 31 | 32 | - name: Install dependencies 33 | run: | 34 | composer require "laravel/framework:${{ matrix.laravel }}" "statamic/cms:${{ matrix.statamic }}" --no-interaction --no-update 35 | composer install --no-interaction 36 | 37 | - name: Run Pest 38 | run: vendor/bin/pest 39 | -------------------------------------------------------------------------------- /tests/Unit/AdditionalTrackerTest.php: -------------------------------------------------------------------------------- 1 | addContentTag('test::tag'); 16 | }); 17 | 18 | $this->get('/'); 19 | 20 | $this->assertSame(['test::tag', 'pages:home'], collect(Tracker::all())->firstWhere('url', 'http://localhost')['tags']); 21 | } 22 | 23 | #[Test] 24 | public function tracks_additional_classes() 25 | { 26 | Tracker::addAdditionalTracker(AdditionalTrackerClass::class); 27 | 28 | $this->get('/'); 29 | 30 | $this->assertSame(['additional::tag', 'pages:home'], collect(Tracker::all())->firstWhere('url', 'http://localhost')['tags']); 31 | } 32 | } 33 | 34 | class AdditionalTrackerClass 35 | { 36 | public function __invoke($tracker, $next) 37 | { 38 | $tracker->addContentTag('additional::tag'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /resources/js/pages/ClearUtility.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 39 | -------------------------------------------------------------------------------- /src/Actions/ClearCache.php: -------------------------------------------------------------------------------- 1 | filter(fn ($item) => $item->absoluteUrl()) 16 | ->each(fn ($item) => Tracker::remove($item->absoluteUrl())); 17 | 18 | return __('Cache cleared'); 19 | } 20 | 21 | public function icon(): string 22 | { 23 | return 'rewind'; 24 | } 25 | 26 | public static function title() 27 | { 28 | return __('Clear cache'); 29 | } 30 | 31 | public function confirmationText() 32 | { 33 | return __('Are you sure you want to clear the static cache for the url: :url ?', ['url' => $this->items->first()->absoluteUrl()]); 34 | } 35 | 36 | public function visibleTo($item) 37 | { 38 | if (! auth()->user()->can('clear cache tracker tags')) { 39 | return false; 40 | } 41 | 42 | if (! ($item instanceof Entry || $item instanceof Term)) { 43 | return false; 44 | } 45 | 46 | return ! Blink::once( 47 | 'cache-action::'.$item->collectionHandle.'::'.$item->locale(), 48 | fn () => is_null($item->collection()->route($item->locale())) 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Bundle Release Assets 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - released 8 | - prereleased 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: 8.4 21 | tools: composer:v2 22 | 23 | - name: Use Node.js 23.3.0 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 23.3.0 27 | 28 | - name: Install composer dependencies 29 | run: composer install --no-interaction --prefer-dist --optimize-autoloader 30 | 31 | - name: Install node dependencies 32 | run: npm i 33 | 34 | - name: Build release assets 35 | run: npm run build 36 | 37 | - name: Create zip 38 | run: tar -czvf dist.tar.gz dist 39 | 40 | - name: Get release 41 | id: get_release 42 | uses: cardinalby/git-get-release-action@v1 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Upload dist zip to release 47 | uses: actions/upload-release-asset@v1.0.1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | upload_url: ${{ steps.get_release.outputs.upload_url }} 52 | asset_path: ./dist.tar.gz 53 | asset_name: dist.tar.gz 54 | asset_content_type: application/tar+gz 55 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | set('app.key', 'base64:'.base64_encode(Encrypter::generateKey($app['config']['app.cipher']))); 24 | 25 | // Assume the pro edition within tests 26 | $app['config']->set('statamic.editions.pro', true); 27 | 28 | // enable caching 29 | $app['config']->set('statamic.static_caching.strategy', 'half'); 30 | 31 | // views for front end routing tests 32 | $app['config']->set('view.paths', [ 33 | __DIR__.'/__fixtures__/resources/views', 34 | ]); 35 | } 36 | 37 | protected function setUp(): void 38 | { 39 | parent::setUp(); 40 | 41 | Facades\Collection::make('pages') 42 | ->title('Pages') 43 | ->routes('{parent_uri}/{slug}') 44 | ->save(); 45 | 46 | Facades\Entry::make() 47 | ->id('home') 48 | ->collection('pages') 49 | ->save(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thoughtco/statamic-cache-tracker", 3 | "autoload": { 4 | "psr-4": { 5 | "Thoughtco\\StatamicCacheTracker\\": "src" 6 | } 7 | }, 8 | "autoload-dev": { 9 | "psr-4": { 10 | "Thoughtco\\StatamicCacheTracker\\Tests\\": "tests" 11 | } 12 | }, 13 | "require": { 14 | "php": "^8.3", 15 | "pixelfear/composer-dist-plugin": "^0.1.6", 16 | "statamic/cms": "^6.0" 17 | }, 18 | "require-dev": { 19 | "laravel/pint": "^1.13", 20 | "mockery/mockery": "^1.3.1", 21 | "nunomaduro/collision": "^8.0", 22 | "orchestra/testbench": "^10.0", 23 | "pestphp/pest": "^4.0", 24 | "phpunit/phpunit": "^12.0" 25 | }, 26 | "extra": { 27 | "download-dist": [ 28 | { 29 | "url": "https://github.com/thoughtco/statamic-cache-tracker/releases/download/{$version}/dist.tar.gz", 30 | "path": "dist" 31 | } 32 | ], 33 | "statamic": { 34 | "name": "Statamic Cache Tracker", 35 | "description": "Keep a record of what items are used on each page and invalidate those pages when they are saved." 36 | }, 37 | "laravel": { 38 | "providers": [ 39 | "Thoughtco\\StatamicCacheTracker\\ServiceProvider" 40 | ] 41 | } 42 | }, 43 | "config": { 44 | "allow-plugins": { 45 | "pestphp/pest-plugin": true, 46 | "pixelfear/composer-dist-plugin": true 47 | } 48 | }, 49 | "minimum-stability": "alpha" 50 | } 51 | -------------------------------------------------------------------------------- /src/Actions/ViewCacheTags.php: -------------------------------------------------------------------------------- 1 | user()->can('view cache tracker tags')) { 33 | return false; 34 | } 35 | 36 | if (! $item instanceof Entry) { 37 | return false; 38 | } 39 | 40 | return ! Blink::once( 41 | 'cache-action::'.$item->collectionHandle.'::'.$item->locale(), 42 | fn () => is_null($item->collection()->route($item->locale())) 43 | ); 44 | } 45 | 46 | public function visibleToBulk($items) 47 | { 48 | return false; 49 | } 50 | 51 | public function buttonText() 52 | { 53 | /** @translation */ 54 | return __('Clear cache'); 55 | } 56 | 57 | public function toArray() 58 | { 59 | return [ 60 | ...parent::toArray(), 61 | 'item_title' => $this->items->first()?->title, 62 | 'item_url' => $this->items->first()?->absoluteUrl(), 63 | ]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Unit/EventListenerTest.php: -------------------------------------------------------------------------------- 1 | handle($request, $next); 28 | 29 | $this->assertSame(['test::tag'], collect(Tracker::all())->firstWhere('url', 'http://localhost')['tags']); 30 | } 31 | 32 | #[Test] 33 | public function dispatching_job_clears_tags() 34 | { 35 | $request = Request::create('/'); 36 | 37 | $next = function () { 38 | TrackContentTags::dispatch(['test::tag']); 39 | 40 | return response(''); 41 | }; 42 | 43 | $middleware = new CacheTracker(); 44 | $middleware->handle($request, $next); 45 | 46 | $this->assertSame(['test::tag'], collect(Tracker::all())->firstWhere('url', 'http://localhost')['tags']); 47 | 48 | InvalidateTags::dispatch(['test::tag']); 49 | 50 | $this->assertSame([], Tracker::all()); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Http/Controllers/UtilityController.php: -------------------------------------------------------------------------------- 1 | input('urls')) { 14 | return [ 15 | 'message' => __('No URLs provided'), 16 | ]; 17 | } 18 | 19 | if ($urls == '*') { 20 | Tracker::flush(); 21 | 22 | return [ 23 | 'message' => __('Cache flushed'), 24 | ]; 25 | } 26 | 27 | $urls = collect(explode(PHP_EOL, $urls)); 28 | 29 | $wildcards = $urls->filter(fn ($url) => Str::endsWith($url, '*')); 30 | 31 | // remove any non-wildcards first 32 | $urls->reject(fn ($url) => Str::endsWith($url, '*')) 33 | ->each(function ($url) { 34 | if (Str::endsWith($url, '/')) { 35 | $url = substr($url, 0, -1); 36 | } 37 | 38 | Tracker::remove($url); 39 | }); 40 | 41 | collect(Tracker::all()) 42 | ->each(function ($data) use ($wildcards) { 43 | $wildcards->each(function ($wildcard) use ($data) { 44 | if (Str::startsWith($data['url'], Str::beforeLast($wildcard, '*'))) { 45 | Tracker::remove($data['url']); 46 | } 47 | }); 48 | }); 49 | 50 | return [ 51 | 'message' => __('URLs cleared from cache'), 52 | ]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 19 | Http\Middleware\CacheTracker::class, 20 | ], 21 | ]; 22 | 23 | protected $subscribe = [ 24 | Listeners\Subscriber::class, 25 | ]; 26 | 27 | protected $routes = [ 28 | 'cp' => __DIR__.'/../routes/cp.php', 29 | ]; 30 | 31 | protected $vite = [ 32 | 'input' => ['resources/js/cp.js'], 33 | 'publicDirectory' => 'dist', 34 | 'hotFile' => __DIR__.'/../dist/hot', 35 | ]; 36 | 37 | public function boot() 38 | { 39 | parent::boot(); 40 | 41 | $this->mergeConfigFrom($config = __DIR__.'/../config/statamic-cache-tracker.php', 'statamic-cache-tracker'); 42 | 43 | $this->publishes([ 44 | $config => config_path('statamic-cache-tracker.php'), 45 | ], 'statamic-cache-tracker-config'); 46 | 47 | $this->setupAddonPermissions() 48 | ->setupAddonUtility(); 49 | } 50 | 51 | private function setupAddonPermissions() 52 | { 53 | Permission::group('cache-tracker', 'Cache Tracker', function () { 54 | Permission::register('view cache tracker tags') 55 | ->label(__('View Tags')) 56 | ->description(__('Enable the action on listing views to view tags')); 57 | 58 | Permission::register('clear cache tracker tags') 59 | ->label(__('Clear Tags')) 60 | ->description(__('Enable the action on listing views to clear tags')); 61 | }); 62 | 63 | return $this; 64 | } 65 | 66 | private function setupAddonUtility() 67 | { 68 | Utility::extend(function () { 69 | Utility::register('static-cache-tracker') 70 | ->title(__('Cache Tracker')) 71 | ->description(__('Clear specific paths in your static cache.')) 72 | ->inertia('CacheTracker/ClearUtility') 73 | ->icon('taxonomies') 74 | ->routes(function ($router) { 75 | $router->post('/clear', UtilityController::class)->name('clear'); 76 | }); 77 | }); 78 | 79 | return $this; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/Unit/TrackerTest.php: -------------------------------------------------------------------------------- 1 | addContentTag('test::tag'); 21 | }); 22 | 23 | $this->get('/'); 24 | 25 | $this->assertSame(['test::tag', 'pages:home'], collect(Tracker::all())->first()['tags']); 26 | 27 | Event::assertDispatched(ContentTracked::class, 1); 28 | } 29 | 30 | #[Test] 31 | public function it_doesnt_track_already_cached_pages() 32 | { 33 | Event::fake(); 34 | 35 | Tracker::addAdditionalTracker(function ($tracker, $next) { 36 | $tracker->addContentTag('test::tag'); 37 | }); 38 | 39 | $this->get('/'); 40 | 41 | $this->assertSame(['test::tag', 'pages:home'], collect(Tracker::all())->first()['tags']); 42 | 43 | $this->get('/'); 44 | 45 | $this->assertSame(['test::tag', 'pages:home'], collect(Tracker::all())->first()['tags']); 46 | 47 | Event::assertDispatched(ContentTracked::class, 1); 48 | } 49 | 50 | #[Test] 51 | public function it_doesnt_track_pages_already_in_the_manifest() 52 | { 53 | Event::fake(); 54 | 55 | Tracker::add('/', ['some:thing']); 56 | 57 | Tracker::addAdditionalTracker(function ($tracker, $next) { 58 | $tracker->addContentTag('test::tag'); 59 | }); 60 | 61 | $this->get('/'); 62 | 63 | $this->assertSame(['some:thing'], collect(Tracker::all())->first()['tags']); 64 | } 65 | 66 | #[Test] 67 | public function it_doesnt_track_404_pages() 68 | { 69 | $this->get('/i-dont-exist'); 70 | 71 | $this->assertCount(0, Tracker::all()); 72 | } 73 | 74 | #[Test] 75 | public function it_flushes() 76 | { 77 | Event::fake(); 78 | 79 | Tracker::addAdditionalTracker(function ($tracker, $next) { 80 | $tracker->addContentTag('test::tag'); 81 | }); 82 | 83 | $this->get('/'); 84 | 85 | $this->assertSame(['test::tag', 'pages:home'], collect(Tracker::all())->first()['tags']); 86 | 87 | $this->assertCount(1, Tracker::all()); 88 | 89 | Tracker::flush(); 90 | 91 | $this->assertCount(0, Tracker::all()); 92 | Event::assertDispatched(UrlInvalidated::class); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /resources/js/components/CacheTrackerModal.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 93 | -------------------------------------------------------------------------------- /src/Tracker/Manager.php: -------------------------------------------------------------------------------- 1 | all(); 21 | $storeData[md5($url)] = [ 22 | 'url' => $url, 23 | 'tags' => collect($tags)->unique()->values()->all(), 24 | ]; 25 | 26 | $this->cacheStore()->forever($this->cacheKey, $storeData); 27 | 28 | ContentTracked::dispatch($url, $tags); 29 | 30 | return $this; 31 | } 32 | 33 | public function addAdditionalTracker(Closure|string $class) 34 | { 35 | if (is_string($class)) { 36 | $class = new $class; 37 | } 38 | 39 | $this->pipelines[] = $class; 40 | 41 | return $this; 42 | } 43 | 44 | public function all() 45 | { 46 | return $this->cacheStore()->get($this->cacheKey) ?? []; 47 | } 48 | 49 | public function cacheStore() 50 | { 51 | try { 52 | $store = Cache::store('static_cache'); 53 | } catch (InvalidArgumentException $e) { 54 | $store = Cache::store(); 55 | } 56 | 57 | return $store; 58 | } 59 | 60 | public function get(string $url) 61 | { 62 | return $this->all()[md5($url)] ?? null; 63 | } 64 | 65 | public function getAdditionalTrackers() 66 | { 67 | return $this->pipelines; 68 | } 69 | 70 | public function has(string $url) 71 | { 72 | return Arr::exists($this->all(), md5($url)); 73 | } 74 | 75 | public function invalidate(array $tags = []) 76 | { 77 | $storeData = $this->all(); 78 | 79 | $urls = []; 80 | foreach ($storeData as $key => $data) { 81 | $storeTags = $data['tags']; 82 | $url = $data['url']; 83 | 84 | if (count(array_intersect($tags, $storeTags)) > 0) { 85 | $urls[] = $url; 86 | 87 | unset($storeData[$key]); 88 | } 89 | } 90 | 91 | if (! empty($urls)) { 92 | $this->cacheStore()->forever($this->cacheKey, $storeData); 93 | 94 | $this->invalidateUrls($urls); 95 | } 96 | 97 | return $this; 98 | } 99 | 100 | private function invalidateUrls($urls) 101 | { 102 | $cacher = app(Cacher::class); 103 | $cacher->invalidateUrls($urls); 104 | } 105 | 106 | public function flush() 107 | { 108 | $urls = collect($this->all())->pluck('url'); 109 | 110 | $this->invalidateUrls($urls); 111 | 112 | $this->cacheStore()->forever($this->cacheKey, []); 113 | } 114 | 115 | public function remove(string $url) 116 | { 117 | $this->invalidateUrls([$url]); 118 | 119 | $this->cacheStore()->forget(md5($url)); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Listeners/Subscriber.php: -------------------------------------------------------------------------------- 1 | 'invalidateAsset', 13 | Events\AssetSaved::class => 'invalidateAsset', 14 | 15 | Events\EntryDeleted::class => 'invalidateAndDeleteEntry', 16 | Events\EntrySaved::class => 'invalidateEntry', 17 | 18 | Events\FormDeleted::class => 'invalidateForm', 19 | Events\FormSaved::class => 'invalidateForm', 20 | 21 | Events\GlobalSetDeleted::class => 'invalidateGlobal', 22 | Events\GlobalVariablesSaved::class => 'invalidateGlobal', 23 | 24 | Events\NavDeleted::class => 'invalidateNav', 25 | Events\NavTreeSaved::class => 'invalidateNav', 26 | Events\CollectionTreeSaved::class => 'invalidateNav', 27 | 28 | Events\TermDeleted::class => 'invalidateAndDeleteTerm', 29 | Events\TermSaved::class => 'invalidateTerm', 30 | ]; 31 | 32 | public function subscribe($dispatcher): void 33 | { 34 | foreach ($this->events as $event => $method) { 35 | if (class_exists($event)) { 36 | $dispatcher->listen($event, [self::class, $method]); 37 | } 38 | } 39 | } 40 | 41 | public function invalidateAsset($event) 42 | { 43 | $tags = [ 44 | 'asset:'.$event->asset->id(), 45 | ]; 46 | 47 | $this->invalidateContent($tags); 48 | } 49 | 50 | public function invalidateEntry($event) 51 | { 52 | $entry = $event->entry; 53 | 54 | $collectionHandle = strtolower($entry->collection()->handle()); 55 | 56 | $tags = [ 57 | $collectionHandle.':'.$entry->id(), 58 | ]; 59 | 60 | $this->invalidateContent($tags); 61 | } 62 | 63 | public function invalidateAndDeleteEntry($event) 64 | { 65 | $this->invalidateEntry($event); 66 | 67 | if ($url = $event->entry->absoluteUrl()) { 68 | Tracker::remove($url); 69 | } 70 | } 71 | 72 | public function invalidateForm($event) 73 | { 74 | $tags = [ 75 | 'form:'.$event->form->handle(), 76 | ]; 77 | 78 | $this->invalidateContent($tags); 79 | } 80 | 81 | public function invalidateGlobal($event) 82 | { 83 | $tags = [ 84 | 'global:'.($event->globals ?? $event->variables->globalSet())->handle(), 85 | ]; 86 | 87 | $this->invalidateContent($tags); 88 | } 89 | 90 | public function invalidateNav($event) 91 | { 92 | $tags = [ 93 | 'nav:'.($event->nav ?? $event->tree)->handle(), 94 | ]; 95 | 96 | $this->invalidateContent($tags); 97 | } 98 | 99 | public function invalidateTerm($event) 100 | { 101 | $tags = [ 102 | 'term:'.$event->term->id(), 103 | ]; 104 | 105 | $this->invalidateContent($tags); 106 | } 107 | 108 | public function invalidateAndDeleteTerm($event) 109 | { 110 | $this->invalidateTerm($event); 111 | 112 | if ($url = $event->term->absoluteUrl()) { 113 | Tracker::remove($url); 114 | } 115 | } 116 | 117 | private function invalidateContent($tags) 118 | { 119 | InvalidateTags::dispatch($tags); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Http/Middleware/CacheTracker.php: -------------------------------------------------------------------------------- 1 | content[] = $tag; 33 | } 34 | 35 | return $this; 36 | } 37 | 38 | public function handle($request, Closure $next) 39 | { 40 | if (! $this->isEnabled($request)) { 41 | return $next($request); 42 | } 43 | 44 | $cacher = app(Cacher::class); 45 | 46 | if ($cacher && $cacher->hasCachedPage($request)) { 47 | return $next($request); 48 | } 49 | 50 | $url = $this->url(); 51 | 52 | if (Str::endsWith($url, '/')) { 53 | $url = substr($url, 0, -1); 54 | } 55 | 56 | if (Tracker::has($url)) { 57 | return $next($request); 58 | } 59 | 60 | $this 61 | ->setupTagHooks() 62 | ->setupAugmentationHooks($url) 63 | ->setupAdditionalTracking(); 64 | 65 | Event::listen(function (TrackContentTags $event) { 66 | $this->content = array_merge($this->content, $event->tags ?? []); 67 | }); 68 | 69 | $response = $next($request); 70 | 71 | if (method_exists($response, 'status') && $response->status() !== 200) { 72 | return $response; 73 | } 74 | 75 | try { 76 | if ($response->wasStaticallyCached()) { 77 | return $response; 78 | } 79 | } catch (\Throwable $e) { 80 | 81 | } 82 | 83 | if ($this->content) { 84 | Tracker::add($url, array_unique($this->content)); 85 | } 86 | 87 | return $response; 88 | } 89 | 90 | private function isEnabled($request) 91 | { 92 | if (! config('statamic.static_caching.strategy')) { 93 | return false; 94 | } 95 | 96 | if (! config('statamic-cache-tracker.enabled', true)) { 97 | return false; 98 | } 99 | 100 | // Only GET requests. This disables the cache during live preview. 101 | return $request->method() === 'GET' && ! Str::startsWith($request->path(), [config('statamic.routes.action', '!').'/', config('statamic.assets.image_manipulation.route')]); 102 | } 103 | 104 | private function setupAdditionalTracking() 105 | { 106 | $pipelines = Tracker::getAdditionalTrackers(); 107 | 108 | if (empty($pipelines)) { 109 | return $this; 110 | } 111 | 112 | (new Pipeline) 113 | ->send($this) 114 | ->through($pipelines) 115 | ->thenReturn(); 116 | 117 | return $this; 118 | } 119 | 120 | private function setupAugmentationHooks(string $url) 121 | { 122 | $self = $this; 123 | 124 | app(Asset::class)::hook('augmented', function ($augmented, $next) use ($self) { 125 | $self->addContentTag('asset:'.$this->id()); 126 | 127 | return $next($augmented); 128 | }); 129 | 130 | app(Entry::class)::hook('augmented', function ($augmented, $next) use ($self) { 131 | $self->addContentTag($this->collection()->handle().':'.$this->id()); 132 | 133 | return $next($augmented); 134 | }); 135 | 136 | Page::hook('augmented', function ($augmented, $next) use ($self) { 137 | if ($entry = $this->entry()) { 138 | $self->addContentTag($entry->collection()->handle().':'.$entry->id()); 139 | } 140 | 141 | return $next($augmented); 142 | }); 143 | 144 | LocalizedTerm::hook('augmented', function ($augmented, $next) use ($self) { 145 | $self->addContentTag('term:'.$this->id()); 146 | 147 | return $next($augmented); 148 | }); 149 | 150 | app(Variables::class)::hook('augmented', function ($augmented, $next) use ($self) { 151 | $self->addContentTag('global:'.$this->globalSet()->handle()); 152 | 153 | return $next($augmented); 154 | }); 155 | 156 | return $this; 157 | } 158 | 159 | private function setupTagHooks() 160 | { 161 | $self = $this; 162 | 163 | Forms\Tags::hook('init', function ($value, $next) use ($self) { 164 | if (in_array($this->method, ['errors', 'success', 'submission'])) { 165 | return $next($value); 166 | } 167 | 168 | if ($form = $this->params->get('in')) { 169 | $form = is_string($form) ? $form : $form->handle(); 170 | $self->addContentTag('form:'.$form); 171 | 172 | return $next($value); 173 | } 174 | 175 | $self->addContentTag($this->tag); 176 | 177 | return $next($value); 178 | }); 179 | 180 | Tags\Nav::hook('init', function ($value, $next) use ($self) { 181 | $handle = $this->params->get('handle') ? 'nav:'.$this->params->get('handle') : $this->tag; 182 | $self->addContentTag($handle); 183 | 184 | return $next($value); 185 | }); 186 | 187 | Tags\Partial::hook('init', function ($value, $next) use ($self) { 188 | $handle = $this->params->get('src') ?? str_replace('partial:', '', $this->tag); 189 | $self->addContentTag('partial:'.$handle); 190 | 191 | return $next($value); 192 | }); 193 | 194 | return $this; 195 | } 196 | 197 | private function url() 198 | { 199 | return URL::makeAbsolute(class_exists(Livewire::class) ? Livewire::originalUrl() : URL::getCurrent()); 200 | } 201 | } 202 | --------------------------------------------------------------------------------