├── 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 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | {{ __('Enter URLs to clear, with each on a new line. You can use * as a wildcard at the end of your URL.') }}
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
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 |
55 |
56 |
57 | Tags on this URL
58 | URLs with this item
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
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 |
--------------------------------------------------------------------------------