From 128c63c39f6abe29628e1ef419d99342cfb2d534 Mon Sep 17 00:00:00 2001
From: Ryan Mitchell
Date: Wed, 2 Sep 2026 10:12:58 +0100
Subject: [PATCH] Allow static cache CS to be externalized
---
config/static_caching.php | 17 ++++
.../views/static-caching/csrf-js.blade.php | 39 ++++++++
.../views/static-caching/nocache-js.blade.php | 51 ++++++++++
.../views/static-caching/script.blade.php | 13 +++
routes/web.php | 6 ++
src/Providers/AppServiceProvider.php | 4 +
src/StaticCaching/Cachers/FileCacher.php | 98 ++-----------------
.../NoCache/ScriptController.php | 37 +++++++
.../Replacers/CsrfTokenReplacer.php | 16 ++-
.../Replacers/NoCacheReplacer.php | 15 ++-
src/Testing/Concerns/FakesViews.php | 6 ++
.../ExternalScriptDeliveryTest.php | 76 ++++++++++++++
.../FullMeasureStaticCachingTest.php | 16 +++
tests/StaticCaching/NocacheRouteTest.php | 11 +++
14 files changed, 308 insertions(+), 97 deletions(-)
create mode 100644 resources/views/static-caching/csrf-js.blade.php
create mode 100644 resources/views/static-caching/nocache-js.blade.php
create mode 100644 resources/views/static-caching/script.blade.php
create mode 100644 src/StaticCaching/NoCache/ScriptController.php
create mode 100644 tests/StaticCaching/ExternalScriptDeliveryTest.php
diff --git a/config/static_caching.php b/config/static_caching.php
index 46766739505..2af89331ea4 100644
--- a/config/static_caching.php
+++ b/config/static_caching.php
@@ -142,6 +142,23 @@
\Statamic\StaticCaching\Replacers\NoCacheReplacer::class,
],
+ /*
+ |--------------------------------------------------------------------------
+ | Script Delivery
+ |--------------------------------------------------------------------------
+ |
+ | Full measure static caching injects small @else@endif
diff --git a/routes/web.php b/routes/web.php
index cb63cae04bd..8a947c7c485 100755
--- a/routes/web.php
+++ b/routes/web.php
@@ -34,6 +34,7 @@
use Statamic\StaticCaching\NoCache\CsrfTokenController;
use Statamic\StaticCaching\NoCache\NoCacheController;
use Statamic\StaticCaching\NoCache\NoCacheLocalize;
+use Statamic\StaticCaching\NoCache\ScriptController;
Route::name('statamic.')->group(function () {
Route::group(['prefix' => config('statamic.routes.action')], function () {
@@ -109,6 +110,11 @@
Route::post('csrf', CsrfTokenController::class)
->withoutMiddleware(['App\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\PreventRequestForgery']);
+ if (config('statamic.static_caching.script_delivery') === 'external') {
+ Route::get('nocache.js', [ScriptController::class, 'nocache'])->name('nocache.js');
+ Route::get('csrf.js', [ScriptController::class, 'csrf'])->name('csrf.js');
+ }
+
Statamic::additionalActionRoutes();
});
diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php
index 05bb28b501f..f9be0a7e58e 100644
--- a/src/Providers/AppServiceProvider.php
+++ b/src/Providers/AppServiceProvider.php
@@ -105,6 +105,10 @@ public function boot()
"{$this->root}/resources/views/extend/scaffolding" => resource_path('views/vendor/statamic/scaffolding'),
], 'statamic-scaffolding');
+ $this->publishes([
+ "{$this->root}/resources/views/static-caching" => resource_path('views/vendor/statamic/static-caching'),
+ ], 'statamic-static-caching');
+
$this->app['redirect']->macro('cpRoute', function ($route, $parameters = []) {
/** @var \Illuminate\Routing\Redirector $this */
return $this->to(cp_route($route, $parameters));
diff --git a/src/StaticCaching/Cachers/FileCacher.php b/src/StaticCaching/Cachers/FileCacher.php
index 2b339e40120..66d56809b18 100644
--- a/src/StaticCaching/Cachers/FileCacher.php
+++ b/src/StaticCaching/Cachers/FileCacher.php
@@ -248,102 +248,16 @@ public function setNocacheJs(string $js)
public function getCsrfTokenJs(): string
{
- $csrfPlaceholder = CsrfTokenReplacer::REPLACEMENT;
-
- $default = << response.json())
- .then((data) => {
- 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;
- }
-
- for (const input of document.querySelectorAll('script[data-csrf="$csrfPlaceholder"]')) {
- input.setAttribute('data-csrf', data.csrf);
- }
-
- if (window.hasOwnProperty('livewire_token')) {
- window.livewire_token = data.csrf
- }
-
- if (window.livewireScriptConfig) {
- // Replaces token if Livewire is already available. Usually on fast networks.
- window.livewireScriptConfig.csrf = data.csrf;
- } else {
- // Delays replacing the token until Livewire is initialized. Usually on slow networks.
- document.addEventListener('livewire:init', () => window.livewireScriptConfig.csrf = data.csrf);
- }
-
- document.dispatchEvent(new CustomEvent('statamic:csrf.replaced', { detail: data }));
- });
-})();
-EOT;
-
- return $this->csrfTokenJs ?? $default;
+ return $this->csrfTokenJs ?? trim(view('statamic::static-caching.csrf-js', [
+ 'csrfPlaceholder' => CsrfTokenReplacer::REPLACEMENT,
+ ])->render());
}
public function getNocacheJs(): string
{
- $nocacheUrl = URL::makeRelative(route('statamic.nocache'));
-
- $default = << response.json())
- .then((data) => {
- map = createMap();
-
- const regions = data.regions;
- for (var key in regions) {
- if (map[key]) replaceElement(map[key], regions[key]);
- }
-
- document.dispatchEvent(new CustomEvent('statamic:nocache.replaced', { detail: data }));
- });
-})();
-EOT;
-
- return $this->nocacheJs ?? $default;
+ return $this->nocacheJs ?? trim(view('statamic::static-caching.nocache-js', [
+ 'nocacheUrl' => URL::makeRelative(route('statamic.nocache')),
+ ])->render());
}
public function shouldOutputJs(): bool
diff --git a/src/StaticCaching/NoCache/ScriptController.php b/src/StaticCaching/NoCache/ScriptController.php
new file mode 100644
index 00000000000..e3ec0a111b7
--- /dev/null
+++ b/src/StaticCaching/NoCache/ScriptController.php
@@ -0,0 +1,37 @@
+response($this->cacher()->getNocacheJs());
+ }
+
+ public function csrf(): Response
+ {
+ return $this->response($this->cacher()->getCsrfTokenJs());
+ }
+
+ private function cacher(): FileCacher
+ {
+ $cacher = app(Cacher::class);
+
+ abort_unless($cacher instanceof FileCacher, 404);
+
+ return $cacher;
+ }
+
+ private function response(string $js): Response
+ {
+ return response($js)
+ ->header('Content-Type', 'application/javascript')
+ ->header('Cache-Control', 'public, max-age=3600')
+ ->setEtag(md5($js));
+ }
+}
diff --git a/src/StaticCaching/Replacers/CsrfTokenReplacer.php b/src/StaticCaching/Replacers/CsrfTokenReplacer.php
index 485b14564f1..054676ed6d3 100644
--- a/src/StaticCaching/Replacers/CsrfTokenReplacer.php
+++ b/src/StaticCaching/Replacers/CsrfTokenReplacer.php
@@ -4,6 +4,7 @@
use Illuminate\Http\Response;
use Statamic\Facades\StaticCache;
+use Statamic\Facades\URL;
use Statamic\StaticCaching\Cacher;
use Statamic\StaticCaching\Cachers\FileCacher;
use Statamic\StaticCaching\Replacer;
@@ -81,10 +82,19 @@ private function modifyFullMeasureResponse(Response $response)
Str::position($contents, ''),
])->filter()->min();
- $js = "";
-
- $contents = Str::substrReplace($contents, $js, $insertBefore, 0);
+ $contents = Str::substrReplace($contents, $this->scriptTag($cacher), $insertBefore, 0);
$response->setContent($contents);
}
+
+ private function scriptTag(FileCacher $cacher): string
+ {
+ $external = config('statamic.static_caching.script_delivery') === 'external';
+
+ return trim(view('statamic::static-caching.script', [
+ 'inline' => ! $external,
+ 'src' => $external ? URL::makeRelative(route('statamic.csrf.js')) : null,
+ 'contents' => $external ? null : $cacher->getCsrfTokenJs(),
+ ])->render());
+ }
}
diff --git a/src/StaticCaching/Replacers/NoCacheReplacer.php b/src/StaticCaching/Replacers/NoCacheReplacer.php
index f7ca32fd97f..bae6743cc35 100644
--- a/src/StaticCaching/Replacers/NoCacheReplacer.php
+++ b/src/StaticCaching/Replacers/NoCacheReplacer.php
@@ -4,6 +4,7 @@
use Illuminate\Http\Response;
use Statamic\Facades\StaticCache;
+use Statamic\Facades\URL;
use Statamic\StaticCaching\Cacher;
use Statamic\StaticCaching\Cachers\FileCacher;
use Statamic\StaticCaching\NoCache\Session;
@@ -94,12 +95,22 @@ private function modifyFullMeasureResponse(Response $response)
$contents = $response->getContent();
if ($cacher->shouldOutputJs()) {
- $js = $cacher->getNocacheJs();
- $contents = str_replace('
{{ template_content }}', '', $contents);
+ $contents = str_replace('', $this->scriptTag($cacher).'', $contents);
}
$contents = str_replace('NOCACHE_PLACEHOLDER', $cacher->getNocachePlaceholder(), $contents);
$response->setContent($contents);
}
+
+ private function scriptTag(FileCacher $cacher): string
+ {
+ $external = config('statamic.static_caching.script_delivery') === 'external';
+
+ return trim(view('statamic::static-caching.script', [
+ 'inline' => ! $external,
+ 'src' => $external ? URL::makeRelative(route('statamic.nocache.js')) : null,
+ 'contents' => $external ? null : $cacher->getNocacheJs(),
+ ])->render());
+ }
}
diff --git a/src/Testing/Concerns/FakesViews.php b/src/Testing/Concerns/FakesViews.php
index a37766a11dc..38cd72597d4 100644
--- a/src/Testing/Concerns/FakesViews.php
+++ b/src/Testing/Concerns/FakesViews.php
@@ -19,6 +19,12 @@ public function withFakeViews()
$this->fakeView = app(FakeViewEngine::class);
$this->fakeViewFinder = new FakeViewFinder($this->app['files'], config('view.paths'));
+ // Keep real namespace hints (e.g. `statamic::`) resolving so that faking
+ // the frontend views doesn't break package views rendered as a side effect.
+ foreach ($originalFactory->getFinder()->getHints() as $namespace => $paths) {
+ $this->fakeViewFinder->addNamespace($namespace, $paths);
+ }
+
$this->fakeViewFactory = new FakeViewFactory($this->app['view.engine.resolver'], $this->fakeViewFinder, $this->app['events']);
$this->fakeViewFactory->setFakeEngine($this->fakeView);
foreach (array_reverse($originalFactory->getExtensions()) as $ext => $engine) {
diff --git a/tests/StaticCaching/ExternalScriptDeliveryTest.php b/tests/StaticCaching/ExternalScriptDeliveryTest.php
new file mode 100644
index 00000000000..6884c2205ae
--- /dev/null
+++ b/tests/StaticCaching/ExternalScriptDeliveryTest.php
@@ -0,0 +1,76 @@
+set('statamic.static_caching.strategy', 'full');
+ $app['config']->set('statamic.static_caching.strategies.full.path', $this->dir = __DIR__.'/static');
+ $app['config']->set('statamic.static_caching.script_delivery', 'external');
+
+ File::delete($this->dir);
+ }
+
+ public function tearDown(): void
+ {
+ File::delete($this->dir);
+ parent::tearDown();
+ }
+
+ #[Test]
+ public function it_references_the_csrf_and_nocache_scripts_instead_of_inlining_them()
+ {
+ $this->withFakeViews();
+ $this->viewShouldReturnRaw('layout', '