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 + + Header set Cache-Control "max-age=31536000, immutable" + diff --git a/www/_layouts/default.html b/www/_layouts/default.html index d39eb65..c6835fd 100644 --- a/www/_layouts/default.html +++ b/www/_layouts/default.html @@ -5,11 +5,11 @@ {{ page.title }} - - + + {# always include javascript except for index and blog posts #} {% if page.url != "/." and not (page.url matches "#/.*/#") %} - + {% endif %}