diff --git a/config/static_caching.php b/config/static_caching.php index 7c8c43dc1c..e23c7039c6 100644 --- a/config/static_caching.php +++ b/config/static_caching.php @@ -98,4 +98,19 @@ 'ignore_query_strings' => false, + /* + |-------------------------------------------------------------------------- + | Replacers + |-------------------------------------------------------------------------- + | + | Here you may define replacers that dynamically replace content within + | the response. Each replacer must implement the Replacer interface. + | + */ + + 'replacers' => [ + \Statamic\StaticCaching\Replacers\CsrfTokenReplacer::class, + \Statamic\StaticCaching\Replacers\NoCacheReplacer::class, + ], + ]; diff --git a/routes/web.php b/routes/web.php index 0839506aa7..5e33fcd136 100755 --- a/routes/web.php +++ b/routes/web.php @@ -48,6 +48,10 @@ Statamic::additionalActionRoutes(); }); + Route::prefix(config('statamic.routes.action')) + ->post('nocache', '\Statamic\StaticCaching\NoCache\Controller') + ->withoutMiddleware('App\Http\Middleware\VerifyCsrfToken'); + if (OAuth::enabled()) { Route::get(config('statamic.oauth.routes.login'), 'OAuthController@redirectToProvider')->name('oauth.login'); Route::get(config('statamic.oauth.routes.callback'), 'OAuthController@handleProviderCallback')->name('oauth.callback'); diff --git a/src/Console/Commands/StaticClear.php b/src/Console/Commands/StaticClear.php index ad6add9087..a6bc6bf7fb 100644 --- a/src/Console/Commands/StaticClear.php +++ b/src/Console/Commands/StaticClear.php @@ -4,7 +4,7 @@ use Illuminate\Console\Command; use Statamic\Console\RunsInPlease; -use Statamic\StaticCaching\Cacher as StaticCacher; +use Statamic\Facades\StaticCache; class StaticClear extends Command { @@ -31,7 +31,7 @@ class StaticClear extends Command */ public function handle() { - app(StaticCacher::class)->flush(); + StaticCache::flush(); $this->info('Your static page cache is now so very, very empty.'); } diff --git a/src/Facades/StaticCache.php b/src/Facades/StaticCache.php new file mode 100644 index 0000000000..000b3f53c5 --- /dev/null +++ b/src/Facades/StaticCache.php @@ -0,0 +1,17 @@ +nocacheJs = $js; + } + + public function getNocacheJs(): string + { + $csrfPlaceholder = CsrfTokenReplacer::REPLACEMENT; + + $default = << response.json()) +.then((data) => { + const regions = data.regions; + for (var key in regions) { + if (map[key]) map[key].outerHTML = regions[key]; + } + + for (const input of document.querySelectorAll('input[value=$csrfPlaceholder]')) { + input.value = data.csrf; + } + + for (const meta of document.querySelectorAll('meta[content=$csrfPlaceholder]')) { + meta.content = data.csrf; + } +}); +EOT; + + return $this->nocacheJs ?? $default; + } + + public function shouldOutputJs(): bool + { + return $this->shouldOutputJs; + } + + public function includeJs() + { + $this->shouldOutputJs = true; + } + + public function setNocachePlaceholder(string $content) + { + $this->nocachePlaceholder = $content; + } + + public function getNocachePlaceholder() + { + return $this->nocachePlaceholder ?? ''; + } } diff --git a/src/StaticCaching/Middleware/Cache.php b/src/StaticCaching/Middleware/Cache.php index 83c367c56e..b7b3f41c42 100644 --- a/src/StaticCaching/Middleware/Cache.php +++ b/src/StaticCaching/Middleware/Cache.php @@ -3,8 +3,11 @@ namespace Statamic\StaticCaching\Middleware; use Closure; +use Illuminate\Support\Collection; use Statamic\Statamic; use Statamic\StaticCaching\Cacher; +use Statamic\StaticCaching\NoCache\Session; +use Statamic\StaticCaching\Replacer; class Cache { @@ -13,9 +16,15 @@ class Cache */ private $cacher; - public function __construct(Cacher $cacher) + /** + * @var Session + */ + protected $nocache; + + public function __construct(Cacher $cacher, Session $nocache) { $this->cacher = $cacher; + $this->nocache = $nocache; } /** @@ -28,18 +37,38 @@ public function __construct(Cacher $cacher) public function handle($request, Closure $next) { if ($this->canBeCached($request) && $this->cacher->hasCachedPage($request)) { - return response($this->cacher->getCachedPage($request)); + $response = response($this->cacher->getCachedPage($request)); + + $this->getReplacers()->each(fn (Replacer $replacer) => $replacer->replaceInCachedResponse($response)); + + return $response; } $response = $next($request); if ($this->shouldBeCached($request, $response)) { - $this->cacher->cachePage($request, $response); + $this->makeReplacementsAndCacheResponse($request, $response); + + $this->nocache->write(); } return $response; } + private function makeReplacementsAndCacheResponse($request, $response) + { + $cachedResponse = clone $response; + + $this->getReplacers()->each(fn (Replacer $replacer) => $replacer->prepareResponseToCache($cachedResponse, $response)); + + $this->cacher->cachePage($request, $cachedResponse); + } + + private function getReplacers(): Collection + { + return collect(config('statamic.static_caching.replacers'))->map(fn ($class) => app($class)); + } + private function canBeCached($request) { if ($request->method() !== 'GET') { diff --git a/src/StaticCaching/NoCache/BladeDirective.php b/src/StaticCaching/NoCache/BladeDirective.php new file mode 100644 index 0000000000..507fff3e0b --- /dev/null +++ b/src/StaticCaching/NoCache/BladeDirective.php @@ -0,0 +1,23 @@ +nocache = $nocache; + } + + public function handle($expression, $context) + { + $view = $expression; + + return $this->nocache->pushView($view, $context)->placeholder(); + } +} diff --git a/src/StaticCaching/NoCache/Controller.php b/src/StaticCaching/NoCache/Controller.php new file mode 100644 index 0000000000..e62e436302 --- /dev/null +++ b/src/StaticCaching/NoCache/Controller.php @@ -0,0 +1,26 @@ +input('url'); // todo: maybe strip off query params? + + $session = $session->setUrl($url)->restore(); + + $replacer = new NoCacheReplacer($session); + + return [ + 'csrf' => csrf_token(), + 'regions' => $session + ->regions() + ->map->render() + ->map(fn ($contents) => $replacer->replace($contents)), + ]; + } +} diff --git a/src/StaticCaching/NoCache/Region.php b/src/StaticCaching/NoCache/Region.php new file mode 100644 index 0000000000..0dea46b71b --- /dev/null +++ b/src/StaticCaching/NoCache/Region.php @@ -0,0 +1,80 @@ +session = $session; + } + + public function placeholder(): string + { + return sprintf('NOCACHE_PLACEHOLDER', $this->key()); + } + + public function context(): array + { + return $this->context; + } + + protected function filterContext(array $context) + { + foreach (['__env', 'app', 'errors'] as $var) { + unset($context[$var]); + } + + return $this->arrayRecursiveDiff($context, $this->session->cascade()); + } + + public function fragmentData(): array + { + return array_merge($this->session->cascade(), $this->context()); + } + + private function arrayRecursiveDiff($a, $b) + { + $data = []; + + foreach ($a as $aKey => $aValue) { + if (! is_object($aKey) && is_array($b) && array_key_exists($aKey, $b)) { + if (is_array($aValue)) { + $aRecursiveDiff = $this->arrayRecursiveDiff($aValue, $b[$aKey]); + + if (! empty($aRecursiveDiff)) { + $data[$aKey] = $aRecursiveDiff; + } + } else { + if ($aValue != $b[$aKey]) { + $data[$aKey] = $aValue; + } + } + } else { + $data[$aKey] = $aValue; + } + } + + return $data; + } + + public function __serialize(): array + { + return Arr::except(get_object_vars($this), ['session']); + } + + public function __unserialize(array $data) + { + foreach ($data as $key => $value) { + $this->{$key} = $value; + } + + $this->session = app(Session::class); + } +} diff --git a/src/StaticCaching/NoCache/Session.php b/src/StaticCaching/NoCache/Session.php new file mode 100644 index 0000000000..b3808a69af --- /dev/null +++ b/src/StaticCaching/NoCache/Session.php @@ -0,0 +1,114 @@ + + */ + protected $regions; + + protected $url; + + public function __construct($url) + { + $this->url = $url; + $this->regions = new Collection; + } + + public function url() + { + return $this->url; + } + + public function setUrl(string $url) + { + $this->url = $url; + + return $this; + } + + /** + * @return Collection + */ + public function regions(): Collection + { + return $this->regions; + } + + public function region(string $key): Region + { + return $this->regions[$key]; + } + + public function pushRegion($contents, $context, $extension): StringRegion + { + $region = new StringRegion($this, trim($contents), $context, $extension); + + return $this->regions[$region->key()] = $region; + } + + public function pushView($view, $context): ViewRegion + { + $region = new ViewRegion($this, $view, $context); + + return $this->regions[$region->key()] = $region; + } + + public function cascade() + { + return $this->cascade; + } + + public function setCascade(array $cascade) + { + $this->cascade = $cascade; + + return $this; + } + + public function reset() + { + $this->regions = new Collection; + $this->cascade = []; + } + + public function write() + { + if ($this->regions->isEmpty()) { + return; + } + + Cache::forever('nocache::urls', collect(Cache::get('nocache::urls', []))->push($this->url)->unique()->all()); + + Cache::forever('nocache::session.'.md5($this->url), [ + 'regions' => $this->regions, + ]); + } + + public function restore() + { + $session = Cache::get('nocache::session.'.md5($this->url)); + + $this->regions = $this->regions->merge($session['regions'] ?? []); + $this->cascade = $this->restoreCascade(); + + return $this; + } + + private function restoreCascade() + { + return Cascade::instance() + ->withContent(Data::findByRequestUrl($this->url)) + ->hydrate() + ->toArray(); + } +} diff --git a/src/StaticCaching/NoCache/StringFragment.php b/src/StaticCaching/NoCache/StringFragment.php new file mode 100644 index 0000000000..6de7ca5780 --- /dev/null +++ b/src/StaticCaching/NoCache/StringFragment.php @@ -0,0 +1,51 @@ +region = $region; + $this->contents = $contents; + $this->extension = $extension; + $this->data = $data; + $this->directory = config('view.compiled').'/nocache'; + } + + public function render(): string + { + view()->addNamespace('nocache', $this->directory); + File::makeDirectory($this->directory); + + $this->createTemporaryView(); + + $this->data['__frontmatter'] = Arr::pull($this->data, 'view', []); + + return view('nocache::'.$this->region, $this->data)->render(); + } + + private function createTemporaryView() + { + $path = vsprintf('%s/%s.%s', [ + $this->directory, + $this->region, + $this->extension, + ]); + + if (File::exists($path)) { + return; + } + + File::put($path, $this->contents); + } +} diff --git a/src/StaticCaching/NoCache/StringRegion.php b/src/StaticCaching/NoCache/StringRegion.php new file mode 100644 index 0000000000..44fbb4d0ad --- /dev/null +++ b/src/StaticCaching/NoCache/StringRegion.php @@ -0,0 +1,33 @@ +session = $session; + $this->content = $content; + $this->context = $this->filterContext($context); + $this->extension = $extension; + $this->key = sha1($content.str_random()); + } + + public function key(): string + { + return $this->key; + } + + public function render(): string + { + return (new StringFragment( + $this->key(), + $this->content, + $this->extension, + $this->fragmentData() + ))->render(); + } +} diff --git a/src/StaticCaching/NoCache/Tags.php b/src/StaticCaching/NoCache/Tags.php new file mode 100644 index 0000000000..0f174626e9 --- /dev/null +++ b/src/StaticCaching/NoCache/Tags.php @@ -0,0 +1,27 @@ +nocache = $nocache; + } + + public function index() + { + return $this + ->nocache + ->pushRegion($this->content, $this->context->all(), 'antlers.html') + ->placeholder(); + } +} diff --git a/src/StaticCaching/NoCache/ViewRegion.php b/src/StaticCaching/NoCache/ViewRegion.php new file mode 100644 index 0000000000..1330234ccd --- /dev/null +++ b/src/StaticCaching/NoCache/ViewRegion.php @@ -0,0 +1,26 @@ +session = $session; + $this->view = $view; + $this->context = $this->filterContext($context); + $this->key = str_random(32); + } + + public function key(): string + { + return $this->key; + } + + public function render(): string + { + return view($this->view, $this->fragmentData())->render(); + } +} diff --git a/src/StaticCaching/Replacer.php b/src/StaticCaching/Replacer.php new file mode 100644 index 0000000000..ce32b5fa62 --- /dev/null +++ b/src/StaticCaching/Replacer.php @@ -0,0 +1,12 @@ +getContent()) { + return; + } + + if (! $token = csrf_token()) { + return; + } + + if (! str_contains($content, $token)) { + return; + } + + StaticCache::includeJs(); + + $response->setContent(str_replace( + $token, + self::REPLACEMENT, + $content + )); + } + + public function replaceInCachedResponse(Response $response) + { + if (! $response->getContent()) { + return; + } + + $response->setContent(str_replace( + self::REPLACEMENT, + csrf_token(), + $response->getContent() + )); + } +} diff --git a/src/StaticCaching/Replacers/NoCacheReplacer.php b/src/StaticCaching/Replacers/NoCacheReplacer.php new file mode 100644 index 0000000000..d685c965a4 --- /dev/null +++ b/src/StaticCaching/Replacers/NoCacheReplacer.php @@ -0,0 +1,89 @@ +NOCACHE_PLACEHOLDER<\/span>/'; + + private $session; + + public function __construct(Session $session) + { + $this->session = $session; + } + + public function prepareResponseToCache(Response $responseToBeCached, Response $initialResponse) + { + $this->replaceInResponse($initialResponse); + + $this->modifyFullMeasureResponse($responseToBeCached); + } + + public function replaceInCachedResponse(Response $response) + { + $this->replaceInResponse($response); + } + + private function replaceInResponse(Response $response) + { + if (! $content = $response->getContent()) { + return; + } + + if (preg_match(self::PATTERN, $content)) { + $this->session->restore(); + + StaticCache::includeJs(); + } + + $response->setContent($this->replace($content)); + } + + public function replace(string $content) + { + while (preg_match(self::PATTERN, $content)) { + $content = $this->performReplacement($content); + } + + return $content; + } + + private function performReplacement(string $content) + { + return preg_replace_callback(self::PATTERN, function ($matches) { + if (! $region = $matches[1] ?? null) { + return ''; + } + + return $this->session->region($region)->render(); + }, $content); + } + + private function modifyFullMeasureResponse(Response $response) + { + $cacher = app(Cacher::class); + + if (! $cacher instanceof FileCacher) { + return; + } + + $contents = $response->getContent(); + + if ($cacher->shouldOutputJs()) { + $js = $cacher->getNocacheJs(); + $contents = str_replace('', '', $contents); + } + + $contents = str_replace('NOCACHE_PLACEHOLDER', $cacher->getNocachePlaceholder(), $contents); + + $response->setContent($contents); + } +} diff --git a/src/StaticCaching/ServiceProvider.php b/src/StaticCaching/ServiceProvider.php index c0e8e078ef..730cf8b19f 100644 --- a/src/StaticCaching/ServiceProvider.php +++ b/src/StaticCaching/ServiceProvider.php @@ -2,8 +2,11 @@ namespace Statamic\StaticCaching; +use Facades\Statamic\View\Cascade; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider as LaravelServiceProvider; +use Statamic\StaticCaching\NoCache\Session; class ServiceProvider extends LaravelServiceProvider { @@ -30,6 +33,10 @@ public function register() ); }); + $this->app->singleton(Session::class, function ($app) { + return new Session($app['request']->getUri()); + }); + $this->app->bind(UrlExcluder::class, function ($app) { $class = config('statamic.static_caching.exclude.class') ?? DefaultUrlExcluder::class; @@ -55,5 +62,15 @@ public function boot() if (config('statamic.static_caching.strategy')) { Event::subscribe(Invalidate::class); } + + // When the cascade gets hydrated, insert it into the + // nocache session so it can filter out contextual data. + Cascade::hydrated(function ($cascade) { + $this->app[Session::class]->setCascade($cascade->toArray()); + }); + + Blade::directive('nocache', function ($exp) { + return 'handle('.$exp.', $__data); ?>'; + }); } } diff --git a/src/StaticCaching/StaticCacheManager.php b/src/StaticCaching/StaticCacheManager.php index 08d055b791..932adafb24 100644 --- a/src/StaticCaching/StaticCacheManager.php +++ b/src/StaticCaching/StaticCacheManager.php @@ -3,6 +3,7 @@ namespace Statamic\StaticCaching; use Illuminate\Cache\Repository; +use Illuminate\Support\Facades\Cache; use Statamic\Facades\Site; use Statamic\StaticCaching\Cachers\ApplicationCacher; use Statamic\StaticCaching\Cachers\FileCacher; @@ -49,4 +50,35 @@ protected function getConfig($name) 'locale' => Site::current()->handle(), ]); } + + public function flush() + { + $this->driver()->flush(); + + collect(Cache::get('nocache::urls', []))->each(function ($url) { + Cache::forget('nocache::session.'.md5($url)); + }); + + Cache::forget('nocache::urls'); + } + + public function nocacheJs(string $js) + { + $this->fileDriver()->setNocacheJs($js); + } + + public function nocachePlaceholder(string $placeholder) + { + $this->fileDriver()->setNocachePlaceholder($placeholder); + } + + public function includeJs() + { + $this->fileDriver()->includeJs(); + } + + private function fileDriver() + { + return ($driver = $this->driver()) instanceof FileCacher ? $driver : optional(); + } } diff --git a/tests/FakesViews.php b/tests/FakesViews.php index d4247c1c77..7eb3e6c410 100644 --- a/tests/FakesViews.php +++ b/tests/FakesViews.php @@ -4,15 +4,21 @@ use Illuminate\View\Factory; use Illuminate\View\View; -use InvalidArgumentException; trait FakesViews { public function withFakeViews() { + $originalFactory = $this->app['view']; + $this->fakeView = app(FakeViewEngine::class); $this->fakeViewFinder = new FakeViewFinder($this->app['files'], config('view.paths')); - $this->fakeViewFactory = new FakeViewFactory($this->app['view.engine.resolver'], $this->app['view.finder'], $this->app['events']); + + $this->fakeViewFactory = new FakeViewFactory($this->app['view.engine.resolver'], $this->fakeViewFinder, $this->app['events']); + foreach (array_reverse($originalFactory->getExtensions()) as $ext => $engine) { + $this->fakeViewFactory->addExtension($ext, $engine); + } + $this->app->instance('FakeViewEngine', $this->fakeView); $this->app->instance('view.finder', $this->fakeViewFinder); $this->app->instance('view', $this->fakeViewFactory); @@ -37,32 +43,32 @@ public function withStandardFakeErrorViews() public function viewShouldReturnRaw($view, $contents, $extension = 'antlers.html') { $this->fakeView->rawContents["$view.$extension"] = $contents; - $this->fakeViewFinder->views[$view] = $view; - $this->fakeViewFactory->extensions[$view] = $extension; + $this->fakeViewFinder->fakeViews[$view] = $view; + $this->fakeViewFactory->fileExtensions[$view] = $extension; } public function viewShouldReturnRendered($view, $contents, $extension = 'antlers.html') { $this->fakeView->renderedContents["$view.$extension"] = $contents; - $this->fakeViewFinder->views[$view] = $view; - $this->fakeViewFactory->extensions[$view] = $extension; + $this->fakeViewFinder->fakeViews[$view] = $view; + $this->fakeViewFactory->fileExtensions[$view] = $extension; } } class FakeViewFactory extends Factory { - public $extensions = []; + public $fileExtensions = []; public function make($view, $data = [], $mergeData = []) { $engine = app('FakeViewEngine'); - $ext = $this->extensions[$view] ?? 'antlers.html'; + $ext = $this->fileExtensions[$view] ?? 'antlers.html'; - if (! $engine->exists($view)) { - throw new InvalidArgumentException("View [{$view}] not found."); + if ($engine->exists($view)) { + return new View($this, $engine, $view, "{$view}.{$ext}", $data); } - return new View($this, $engine, $view, "{$view}.{$ext}", $data); + return parent::make($view, $data, $mergeData); } public function exists($view) @@ -102,12 +108,12 @@ public function exists($path) class FakeViewFinder extends \Illuminate\View\FileViewFinder { - public $views = []; + public $fakeViews = []; public function find($view) { - if (isset($this->views[$view])) { - return $this->views[$view]; + if (isset($this->fakeViews[$view])) { + return $this->fakeViews[$view]; } return parent::find($view); @@ -115,6 +121,6 @@ public function find($view) public function exists($path) { - return isset($this->views[$path]); + return isset($this->fakeViews[$path]); } } diff --git a/tests/StaticCaching/FullMeasureStaticCachingTest.php b/tests/StaticCaching/FullMeasureStaticCachingTest.php new file mode 100644 index 0000000000..c92578761c --- /dev/null +++ b/tests/StaticCaching/FullMeasureStaticCachingTest.php @@ -0,0 +1,158 @@ +set('statamic.static_caching.strategy', 'full'); + $app['config']->set('statamic.static_caching.strategies.full.path', $this->dir = __DIR__.'/static'); + + File::delete($this->dir); + } + + public function tearDown(): void + { + File::delete($this->dir); + parent::tearDown(); + } + + /** @test */ + public function it_can_keep_parts_dynamic_using_nocache_tags() + { + // Use a tag that outputs something dynamic. + // It will just increment by one every time it's used. + + app()->instance('example_count', 0); + + (new class extends \Statamic\Tags\Tags + { + public static $handle = 'example_count'; + + public function index() + { + $count = app('example_count'); + $count++; + app()->instance('example_count', $count); + + return $count; + } + })::register(); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', '{{ example_count }} {{ nocache }}{{ example_count }}{{ /nocache }}'); + + $this->createPage('about'); + + StaticCache::nocacheJs('js here'); + StaticCache::nocachePlaceholder('Loading...'); + + $this->assertFalse(file_exists($this->dir.'/about_.html')); + + $response = $this + ->get('/about') + ->assertOk(); + + $region = app(Session::class)->regions()->first(); + + // Initial response should be dynamic and not contain javascript. + $this->assertEquals('1 2', $response->getContent()); + + // The cached response should have the nocache placeholder, and the javascript. + $this->assertTrue(file_exists($this->dir.'/about_.html')); + $this->assertEquals(vsprintf('1 %s%s', [ + $region->key(), + 'Loading...', + '', + ]), file_get_contents($this->dir.'/about_.html')); + } + + /** @test */ + public function javascript_doesnt_get_output_if_there_are_no_nocache_tags() + { + // Use a tag that outputs something dynamic. + // It will just increment by one every time it's used. + + app()->instance('example_count', 0); + + (new class extends \Statamic\Tags\Tags + { + public static $handle = 'example_count'; + + public function index() + { + $count = app('example_count'); + $count++; + app()->instance('example_count', $count); + + return $count; + } + })::register(); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', '{{ example_count }}'); + + $this->createPage('about'); + + StaticCache::nocacheJs('js here'); + StaticCache::nocachePlaceholder('Loading...'); + + $this->assertFalse(file_exists($this->dir.'/about_.html')); + + $response = $this + ->get('/about') + ->assertOk(); + + // Initial response should be dynamic and not contain javascript. + $this->assertEquals('1', $response->getContent()); + + // The cached response should be the same, with no javascript. + $this->assertTrue(file_exists($this->dir.'/about_.html')); + $this->assertEquals('1', file_get_contents($this->dir.'/about_.html')); + } + + /** @test */ + public function it_should_add_the_javascript_if_there_is_a_csrf_token() + { + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', '{{ csrf_token }}'); + + $this->createPage('about'); + + StaticCache::nocacheJs('js here'); + + $this->assertFalse(file_exists($this->dir.'/about_.html')); + + $response = $this + ->get('/about') + ->assertOk(); + + // Initial response should be dynamic and not contain javascript. + $this->assertEquals(''.csrf_token().'', $response->getContent()); + + // The cached response should have the token placeholder, and the javascript. + $this->assertTrue(file_exists($this->dir.'/about_.html')); + $this->assertEquals(vsprintf('STATAMIC_CSRF_TOKEN%s', [ + '', + ]), file_get_contents($this->dir.'/about_.html')); + } +} diff --git a/tests/StaticCaching/HalfMeasureStaticCachingTest.php b/tests/StaticCaching/HalfMeasureStaticCachingTest.php new file mode 100644 index 0000000000..c52dee61b9 --- /dev/null +++ b/tests/StaticCaching/HalfMeasureStaticCachingTest.php @@ -0,0 +1,366 @@ +set('cache.default', 'file'); + + $app['config']->set('statamic.static_caching.strategy', 'half'); + + $app['config']->set('statamic.static_caching.replacers', array_merge($app['config']->get('statamic.static_caching.replacers'), [ + 'test' => TestReplacer::class, + ])); + } + + /** @test */ + public function it_statically_caches() + { + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', '

{{ title }}

{{ content }}'); + + $page = $this->createPage('about', [ + 'with' => [ + 'title' => 'The About Page', + 'content' => 'This is the about page.', + ], + ]); + + $this + ->get('/about') + ->assertOk() + ->assertSee('

The About Page

This is the about page.

', false); + + $page + ->set('content', 'Updated content') + ->saveQuietly(); // Save quietly to prevent the invalidator from clearing the statically cached page. + + $this + ->get('/about') + ->assertOk() + ->assertSee('

The About Page

This is the about page.

', false); + } + + /** @test */ + public function it_performs_replacements() + { + Carbon::setTestNow(Carbon::parse('2019-01-01')); + + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', '{{ now format="Y-m-d" }} REPLACEME'); + + $this->createPage('about'); + + $response = $this->get('/about')->assertOk(); + $this->assertSame('2019-01-01 INITIAL-2019-01-01', $response->getContent()); + + Carbon::setTestNow(Carbon::parse('2020-05-23')); + $response = $this->get('/about')->assertOk(); + $this->assertSame('2019-01-01 SUBSEQUENT-2020-05-23', $response->getContent()); + } + + /** @test */ + public function it_can_keep_parts_dynamic_using_nocache_tags() + { + // Use a tag that outputs something dynamic. + // It will just increment by one every time it's used. + + app()->instance('example_count', 0); + + (new class extends \Statamic\Tags\Tags + { + public static $handle = 'example_count'; + + public function index() + { + $count = app('example_count'); + $count++; + app()->instance('example_count', $count); + + return $count; + } + })::register(); + + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', '{{ example_count }} {{ nocache }}{{ example_count }}{{ /nocache }}'); + + $this->createPage('about'); + + $this + ->get('/about') + ->assertOk() + ->assertSee('1 2', false); + + $this + ->get('/about') + ->assertOk() + ->assertSee('1 3', false); + } + + /** @test */ + public function it_can_keep_parts_dynamic_using_nocache_tags_in_loops() + { + // Use a tag that outputs something dynamic but consistent. + // It will just increment by one every time it's used. + + app()->instance('example_count', 0); + + (new class extends \Statamic\Tags\Tags + { + public static $handle = 'example_count'; + + public function wildcard($method) + { + $count = app('example_count'); + $count++; + app()->instance('example_count', $count); + + return $this->context->get($method).$count; + } + })::register(); + + $this->withStandardFakeViews(); + + $template = <<<'EOT' + {{ array }} + {{ value }} + {{ example_count:value }} + {{ nocache }} + {{ value }} + {{ example_count:value }} + {{ /nocache }} + {{ /array }} + EOT; + + $this->viewShouldReturnRaw('default', $template); + + $this->createPage('about', ['with' => [ + 'array' => [ + ['value' => 'One'], + ['value' => 'Two'], + ['value' => 'Three'], + ], + ]]); + + $this + ->get('/about') + ->assertOk() + ->assertSeeInOrder([ + 'One', 'One1', 'One', 'One4', + 'Two', 'Two2', 'Two', 'Two5', + 'Three', 'Three3', 'Three', 'Three6', + ]); + + $this + ->get('/about') + ->assertOk() + ->assertSeeInOrder([ + 'One', 'One1', 'One', 'One7', + 'Two', 'Two2', 'Two', 'Two8', + 'Three', 'Three3', 'Three', 'Three9', + ]); + } + + /** @test */ + public function it_can_keep_the_cascade_parts_dynamic_using_nocache_tags() + { + // The "now" variable is generated in the cascade on every request. + + Carbon::setTestNow(Carbon::parse('2019-01-01')); + + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', '{{ now format="Y-m-d" }} {{ nocache }}{{ now format="Y-m-d" }}{{ /nocache }}'); + + $this->createPage('about'); + + $this + ->get('/about') + ->assertOk() + ->assertSee('2019-01-01 2019-01-01', false); + + Carbon::setTestNow(Carbon::parse('2020-05-23')); + + $this + ->get('/about') + ->assertOk() + ->assertSee('2019-01-01 2020-05-23', false); + } + + /** @test */ + public function it_can_keep_the_urls_page_parts_dynamic_using_nocache_tags() + { + // The "page" variable (i.e. the about entry) is inserted into the cascade on every request. + + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', '

{{ title }}

{{ text }} {{ nocache }}{{ text }}{{ /nocache }}'); + + $page = $this->createPage('about', [ + 'with' => [ + 'title' => 'The About Page', + 'text' => 'This is the about page.', + ], + ]); + + $this + ->get('/about') + ->assertOk() + ->assertSee('

The About Page

This is the about page. This is the about page.', false); + + $page + ->set('text', 'Updated text') + ->saveQuietly(); // Save quietly to prevent the invalidator from clearing the statically cached page. + + $this + ->get('/about') + ->assertOk() + ->assertSee('

The About Page

This is the about page. Updated text', false); + } + + /** @test */ + public function it_can_keep_parts_dynamic_using_nested_nocache_tags() + { + // Use a tag that outputs something dynamic. + // It will just increment by one every time it's used. + + app()->instance('example_count', 0); + + (new class extends \Statamic\Tags\Tags + { + public static $handle = 'example_count'; + + public function index() + { + $count = app('example_count'); + $count++; + app()->instance('example_count', $count); + + return $count; + } + })::register(); + + $template = <<<'EOT' +{{ example_count }} +{{ nocache }} + {{ example_count }} + {{ nocache }} + {{ example_count }} + {{ /nocache }} +{{ /nocache }} +EOT; + + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', $template); + + $this->createPage('about'); + + $this + ->get('/about') + ->assertOk() + ->assertSeeInOrder([1, 2, 3]); + + $this + ->get('/about') + ->assertOk() + ->assertSeeInOrder([1, 4, 5]); + } + + /** @test */ + public function it_can_keep_parts_dynamic_using_nocache_tags_with_view_front_matter() + { + $template = <<<'EOT' +--- +foo: bar +--- +{{ view:foo }} {{ nocache }}{{ view:foo }}{{ /nocache }} +EOT; + + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', $template); + + $this->createPage('about'); + + $this + ->get('/about') + ->assertOk() + ->assertSee('bar bar'); + + $this + ->get('/about') + ->assertOk() + ->assertSee('bar bar'); + } + + public function bladeViewPaths($app) + { + $app['config']->set('view.paths', [ + __DIR__.'/blade', + ...$app['config']->get('view.paths'), + ]); + } + + /** + * @test + * @define-env bladeViewPaths + */ + public function it_can_keep_parts_dynamic_using_blade() + { + // Use a tag that outputs something dynamic. + // It will just increment by one every time it's used. + + app()->instance('example_count', 0); + + app()->instance('example_count_tag', function () { + $count = app('example_count'); + $count++; + app()->instance('example_count', $count); + + return $count; + }); + + $this->createPage('about'); + + $this + ->get('/about') + ->assertOk() + ->assertSee('1 2', false); + + $this + ->get('/about') + ->assertOk() + ->assertSee('1 3', false); + } +} + +class TestReplacer implements Replacer +{ + public function prepareResponseToCache(Response $response, Response $initial) + { + $initial->setContent( + str_replace('REPLACEME', 'INITIAL-'.Carbon::now()->format('Y-m-d'), $initial->getContent()) + ); + } + + public function replaceInCachedResponse(Response $response) + { + $response->setContent( + str_replace('REPLACEME', 'SUBSEQUENT-'.Carbon::now()->format('Y-m-d'), $response->getContent()) + ); + } +} diff --git a/tests/StaticCaching/ManagerTest.php b/tests/StaticCaching/ManagerTest.php new file mode 100644 index 0000000000..db280f1997 --- /dev/null +++ b/tests/StaticCaching/ManagerTest.php @@ -0,0 +1,31 @@ + 'test', + 'statamic.static_caching.strategies.test.driver' => 'test', + ]); + + $mock = Mockery::mock(Cacher::class)->shouldReceive('flush')->once()->getMock(); + StaticCache::extend('test', fn () => $mock); + + Cache::shouldReceive('get')->with('nocache::urls', [])->once()->andReturn(['/one', '/two']); + Cache::shouldReceive('forget')->with('nocache::session.'.md5('/one'))->once(); + Cache::shouldReceive('forget')->with('nocache::session.'.md5('/two'))->once(); + Cache::shouldReceive('forget')->with('nocache::urls')->once(); + + StaticCache::flush(); + } +} diff --git a/tests/StaticCaching/NoCacheSessionTest.php b/tests/StaticCaching/NoCacheSessionTest.php new file mode 100644 index 0000000000..d92c7c20cd --- /dev/null +++ b/tests/StaticCaching/NoCacheSessionTest.php @@ -0,0 +1,226 @@ +setCascade([ + 'csrf_token' => 'abc', + 'now' => 'carbon', + 'title' => 'base title', + ]); + + $region = $session->pushRegion('', [ + 'csrf_token' => 'abc', + 'now' => 'carbon', + 'title' => 'different title', + 'foo' => 'bar', + 'baz' => 'qux', + ], ''); + + $this->assertEquals([ + 'title' => 'different title', + 'foo' => 'bar', + 'baz' => 'qux', + ], $region->context()); + } + + /** @test */ + public function it_gets_the_fragment_data() + { + // fragment data should be the context, + // with the cascade merged in. + + $session = new Session('/'); + + $region = $session->pushRegion('', [ + 'foo' => 'bar', + 'baz' => 'qux', + 'title' => 'local title', + ], ''); + + $session->setCascade([ + 'csrf_token' => 'abc', + 'now' => 'carbon', + 'title' => 'root title', + ]); + + $this->assertEquals([ + 'csrf_token' => 'abc', + 'now' => 'carbon', + 'foo' => 'bar', + 'baz' => 'qux', + 'title' => 'local title', + ], $region->fragmentData()); + } + + /** @test */ + public function it_writes() + { + // Testing that the cache key used is unique to the url. + // The contents aren't really important. + + Cache::shouldReceive('forever') + ->with('nocache::session.'.md5('/'), Mockery::any()) + ->once(); + + Cache::shouldReceive('forever') + ->with('nocache::session.'.md5('/foo'), Mockery::any()) + ->once(); + + // ...and that the urls are tracked in the cache. + + Cache::shouldReceive('get') + ->with('nocache::urls', []) + ->times(2) + ->andReturn([], ['/']); + + Cache::shouldReceive('forever') + ->with('nocache::urls', ['/']) + ->once(); + + Cache::shouldReceive('forever') + ->with('nocache::urls', ['/', '/foo']) + ->once(); + + tap(new Session('/'), function ($session) { + $session->pushRegion('test', [], '.html'); + })->write(); + + tap(new Session('/foo'), function ($session) { + $session->pushRegion('test', [], '.html'); + })->write(); + } + + /** @test */ + public function it_restores_from_cache() + { + Cache::forever('nocache::session.'.md5('http://localhost/test'), [ + 'regions' => [ + $regionOne = Mockery::mock(StringRegion::class), + $regionTwo = Mockery::mock(StringRegion::class), + ], + ]); + + $this->createPage('/test', [ + 'with' => ['title' => 'Test page'], + ]); + + $session = new Session('http://localhost/test'); + $this->assertEquals([], $session->regions()->all()); + $this->assertEquals([], $session->cascade()); + + $session->restore(); + + $this->assertEquals([$regionOne, $regionTwo], $session->regions()->all()); + $this->assertNotEquals([], $cascade = $session->cascade()); + $this->assertEquals('/test', $cascade['url']); + $this->assertEquals('Test page', $cascade['title']); + $this->assertEquals('http://localhost/cp', $cascade['cp_url']); + } + + /** @test */ + public function a_singleton_is_bound_in_the_container() + { + $this->get('/test?foo=bar'); + + $session = $this->app->make(Session::class); + + $this->assertInstanceOf(Session::class, $session); + $this->assertEquals('http://localhost/test?foo=bar', $session->url()); + } + + /** @test */ + public function it_writes_session_if_a_nocache_tag_is_used() + { + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', '{{ title }} {{ nocache }}{{ title }}{{ /nocache }}'); + $this->createPage('test', ['with' => ['title' => 'Test']]); + + $this->assertNull(Cache::get('nocache::session.'.md5('http://localhost/test'))); + + $this + ->get('/test') + ->assertOk() + ->assertSee('Test Test'); + + $this->assertNotNull(Cache::get('nocache::session.'.md5('http://localhost/test'))); + } + + /** @test */ + public function it_doesnt_write_session_if_a_nocache_tag_is_not_used() + { + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', '{{ title }}'); + $this->createPage('test', ['with' => ['title' => 'Test']]); + + $this->assertNull(Cache::get('nocache::session.'.md5('http://localhost/test'))); + + $this + ->get('/test') + ->assertOk() + ->assertSee('Test'); + + $this->assertNull(Cache::get('nocache::session.'.md5('http://localhost/test'))); + } + + /** @test */ + public function it_restores_session_if_theres_a_nocache_placeholder_in_the_response() + { + $this->withStandardFakeViews(); + $this->viewShouldReturnRendered('default', 'Hello NOCACHE_PLACEHOLDER'); + $this->createPage('test'); + + Cache::put('nocache::session.'.md5('http://localhost/test'), [ + 'regions' => [ + 'abc' => $region = Mockery::mock(StringRegion::class), + ], + ]); + + $region->shouldReceive('render')->andReturn('world'); + + $this + ->get('/test') + ->assertOk() + ->assertSee('Hello world'); + + $this->assertEquals(['abc' => $region], app(Session::class)->regions()->all()); + } + + /** @test */ + public function it_doesnt_restore_session_if_there_is_no_nocache_placeholder_in_the_response() + { + $this->withStandardFakeViews(); + $this->viewShouldReturnRendered('default', 'Hello'); + $this->createPage('test'); + + Cache::put('nocache::session.'.md5('http://localhost/test'), [ + 'regions' => ['abc' => ['type' => 'string', 'contents' => 'world', 'extension' => 'html', 'context' => ['foo' => 'bar']]], + ]); + + $this + ->get('/test') + ->assertOk() + ->assertSee('Hello'); + + $this->assertEquals([], app(Session::class)->regions()->all()); + } +} diff --git a/tests/StaticCaching/NocacheRouteTest.php b/tests/StaticCaching/NocacheRouteTest.php new file mode 100644 index 0000000000..86c7552106 --- /dev/null +++ b/tests/StaticCaching/NocacheRouteTest.php @@ -0,0 +1,71 @@ +instance('example_count', 0); + + (new class extends \Statamic\Tags\Tags + { + public static $handle = 'example_count'; + + public function index() + { + $count = app('example_count'); + $count++; + app()->instance('example_count', $count); + + return $count; + } + })::register(); + + $this->createPage('test', ['with' => ['title' => 'Test']]); + + $secondTemplate = <<<'EOT' +Second {{ example_count }} {{ name }} {{ title }} +{{ nocache }} + Nested {{ example_count }} {{ name }} {{ title }} + {{ nocache }} + Double nested {{ example_count }} {{ name }} {{ title }} + {{ /nocache }} +{{ /nocache }} +EOT; + + $session = new Session('http://localhost/test'); + $regionOne = $session->pushRegion('First {{ example_count }} {{ name }} {{ title }}', ['name' => 'Dustin'], 'antlers.html'); + $regionTwo = $session->pushRegion($secondTemplate, ['name' => 'Will'], 'antlers.html'); + $session->write(); + + $secondExpectation = <<<'EOT' +Second 2 Will Test +Nested 3 Will Test + Double nested 4 Will Test +EOT; + + $this + ->postJson('/!/nocache', ['url' => 'http://localhost/test']) + ->assertOk() + ->assertExactJson([ + 'csrf' => csrf_token(), + 'regions' => [ + $regionOne->key() => 'First 1 Dustin Test', + $regionTwo->key() => $secondExpectation, + ], + ]); + } +} diff --git a/tests/StaticCaching/NocacheTagsTest.php b/tests/StaticCaching/NocacheTagsTest.php new file mode 100644 index 0000000000..0272940d0f --- /dev/null +++ b/tests/StaticCaching/NocacheTagsTest.php @@ -0,0 +1,102 @@ +set('statamic.static_caching.strategy', null); + } + + /** @test */ + public function it_can_keep_nocache_tags_dynamic_inside_cache_tags() + { + $this->withStandardFakeViews(); + + $template = <<<'EOT' +{{ title }} +{{ cache }} + {{ title }} + {{ nocache }}{{ title }}{{ /nocache }} +{{ /cache }} +EOT; + + $this->viewShouldReturnRaw('default', $template); + + $page = $this->createPage('about', [ + 'with' => [ + 'title' => 'Existing', + ], + ]); + + $this + ->get('/about') + ->assertOk() + ->assertSeeInOrder(['Existing', 'Existing', 'Existing']); + + $page + ->set('title', 'Updated') + ->saveQuietly(); // Save quietly to prevent the invalidator from clearing the statically cached page. + + $this->app->make(Session::class)->reset(); + + $this + ->get('/about') + ->assertOk() + ->assertSeeInOrder(['Updated', 'Existing', 'Updated']); + } + + /** @test */ + public function it_can_keep_nested_nocache_tags_dynamic_inside_cache_tags() + { + $this->withStandardFakeViews(); + + $template = <<<'EOT' +{{ title }} +{{ nocache }} + {{ title }} + {{ cache }} + {{ title }} + {{ nocache }}{{ title }}{{ /nocache }} + {{ /cache }} +{{ /nocache }} +EOT; + + $this->viewShouldReturnRaw('default', $template); + + $page = $this->createPage('about', [ + 'with' => [ + 'title' => 'Existing', + ], + ]); + + $this + ->get('/about') + ->assertOk() + ->assertSeeInOrder(['Existing', 'Existing', 'Existing', 'Existing']); + + $page + ->set('title', 'Updated') + ->saveQuietly(); // Save quietly to prevent the invalidator from clearing the statically cached page. + + $this->app->make(Session::class)->reset(); + + $this + ->get('/about') + ->assertOk() + ->assertSeeInOrder(['Updated', 'Updated', 'Existing', 'Updated']); + } +} diff --git a/tests/StaticCaching/blade/default.blade.php b/tests/StaticCaching/blade/default.blade.php new file mode 100644 index 0000000000..475022619c --- /dev/null +++ b/tests/StaticCaching/blade/default.blade.php @@ -0,0 +1 @@ +{{ app('example_count_tag')() }} @nocache("dynamic") diff --git a/tests/StaticCaching/blade/dynamic.blade.php b/tests/StaticCaching/blade/dynamic.blade.php new file mode 100644 index 0000000000..380028c8b6 --- /dev/null +++ b/tests/StaticCaching/blade/dynamic.blade.php @@ -0,0 +1 @@ +{{ app('example_count_tag')() }}