diff --git a/app/CacheBusting.php b/app/CacheBusting.php
new file mode 100644
index 0000000..7fa15fd
--- /dev/null
+++ b/app/CacheBusting.php
@@ -0,0 +1,75 @@
+ 'onAfterGenerate'];
+ }
+
+ public function getFunctions(): array
+ {
+ return [new TwigFunction('asset', [$this, 'asset'])];
+ }
+
+ public function asset(string $path): string
+ {
+ return $this->versioned($path) ?? throw new \InvalidArgumentException('Unable to version ' . $path);
+ }
+
+ public function onAfterGenerate(SourceSetEvent $event): void
+ {
+ foreach ($event->allSources() as $source) {
+ $versioned = $this->versioned($source->relativePathname());
+ if ($versioned !== null) {
+ $source->setPermalink(new Permalink($versioned, '/' . $versioned));
+ }
+ }
+ }
+
+ /**
+ * `src/tailwind.min.css` => `src/tailwind.1234567.css`, `null` for anything else
+ *
+ * Any `.min` or `.v2` in the source name is dropped: the hash already says
+ * this is a generated file, so repeating it in the public URL adds nothing.
+ */
+ private function versioned(string $path): ?string
+ {
+ if (!preg_match('/\.(css|js)$/', $path)) {
+ return null;
+ }
+
+ $hash = @sha1_file($this->sourceDir . '/' . $path);
+ if ($hash === false) {
+ throw new \RuntimeException('Unable to hash ' . $path);
+ }
+
+ return preg_replace(
+ '/(?:\.(?:min|v\d+))*\.([^.]+)$/',
+ '.' . substr($hash, 0, 7) . '.$1',
+ $path
+ );
+ }
+}
diff --git a/app/config/sculpin_services.yml b/app/config/sculpin_services.yml
new file mode 100644
index 0000000..8a9c6ab
--- /dev/null
+++ b/app/config/sculpin_services.yml
@@ -0,0 +1,7 @@
+services:
+ cache_busting:
+ class: App\CacheBusting
+ arguments: ['%sculpin.source_dir%']
+ tags:
+ - { name: twig.extension }
+ - { name: kernel.event_subscriber }
diff --git a/composer.json b/composer.json
index eda823e..eabf81b 100644
--- a/composer.json
+++ b/composer.json
@@ -5,6 +5,11 @@
"require-dev": {
"sculpin/sculpin": "^3.2"
},
+ "autoload-dev": {
+ "psr-4": {
+ "App\\": "app/"
+ }
+ },
"replace": {
"sculpin/sculpin-theme-composer-plugin": "1.0.2"
},
diff --git a/tests/integration.bash b/tests/integration.bash
index a81fe94..396bea6 100755
--- a/tests/integration.bash
+++ b/tests/integration.bash
@@ -1,6 +1,6 @@
#!/bin/bash
-# run with base url argument like "http://clue.localhost" or "https://user:pass@clue.example"
+# run from the repository root with base url argument like "http://clue.localhost" or "https://user:pass@clue.example"
base=${1:-http://clue.localhost/}
base=${base%/}
@@ -21,6 +21,8 @@ curl -v $base/
match "HTTP/.* 200"
match -iE "Content-Type: text/html(;.*)?$"
match -iE "Cache-Control: max-age=86400$"
+match -F ""
+match -F ""
curl -v $base/index.html
match "HTTP/.* 302"
@@ -36,6 +38,7 @@ curl -v $base/blog
match "HTTP/.* 200"
match -iE "Content-Type: text/html(;.*)?$"
match -iE "Cache-Control: max-age=86400$"
+match -F ""
curl -v $base/blog.html
match "HTTP/.* 302"
@@ -106,9 +109,28 @@ match "HTTP/.* 200"
match -iE "Content-Type: application/atom\+xml$"
match -iE "Cache-Control: max-age=86400$"
-curl -v $base/src/tailwind.min.css
+curl -v $base/src/tailwind.$(sha1sum www/src/tailwind.min.css | cut -c-7).css
match "HTTP/.* 200"
match -iE "Content-Type: text/css$"
-match -iE "Cache-Control: max-age=86400$"
+match -iE "Cache-Control: max-age=31536000, immutable$"
+
+curl -v $base/src/landing-page.$(sha1sum www/src/landing-page.v2.css | cut -c-7).css
+match "HTTP/.* 200"
+match -iE "Content-Type: text/css$"
+match -iE "Cache-Control: max-age=31536000, immutable$"
+
+curl -v $base/src/app.$(sha1sum www/src/app.v2.js | cut -c-7).js
+match "HTTP/.* 200"
+match -iE "Content-Type: (text|application)/javascript$"
+match -iE "Cache-Control: max-age=31536000, immutable$"
+
+curl -v $base/src/tailwind.min.css
+match "HTTP/.* 404"
+
+curl -v $base/src/landing-page.v2.css
+match "HTTP/.* 404"
+
+curl -v $base/src/app.v2.js
+match "HTTP/.* 404"
echo "OK ($n)"
diff --git a/www/.htaccess b/www/.htaccess
index 0f764de..7c24c99 100644
--- a/www/.htaccess
+++ b/www/.htaccess
@@ -63,3 +63,8 @@ Redirect /psocksd-0.3.1.phar https://github.com/clue/psocksd/releases/download/v
# all files should be cached for 1 day, otherwise any intermediaries may apply their own caching rules
Header set Cache-Control "max-age=86400"
+
+# content-hashed files get a new URL whenever they change, so they never expire
+