Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions app/CacheBusting.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

namespace App;

use Sculpin\Core\Event\SourceSetEvent;
use Sculpin\Core\Permalink\Permalink;
use Sculpin\Core\Sculpin;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

/**
* Serves each stylesheet and script from a URL containing a hash of its contents.
*
* Any change to their contents results in a new URL that is not cached yet, so
* they can be cached forever (see `Cache-Control` in `www/.htaccess`).
*
* This applies to every stylesheet and script, so templates have to reference
* them through `asset()` to get the URL they are actually published under.
*/
class CacheBusting extends AbstractExtension implements EventSubscriberInterface
{
public function __construct(private readonly string $sourceDir)
{
}

public static function getSubscribedEvents(): array
{
return [Sculpin::EVENT_AFTER_GENERATE => '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
);
}
}
7 changes: 7 additions & 0 deletions app/config/sculpin_services.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
services:
cache_busting:
class: App\CacheBusting
arguments: ['%sculpin.source_dir%']
tags:
- { name: twig.extension }
- { name: kernel.event_subscriber }
5 changes: 5 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
28 changes: 25 additions & 3 deletions tests/integration.bash
Original file line number Diff line number Diff line change
@@ -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%/}

Expand All @@ -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 "<link href=\"src/landing-page.$(sha1sum www/src/landing-page.v2.css | cut -c-7).css\" rel=\"stylesheet\">"
match -F "<link href=\"src/tailwind.$(sha1sum www/src/tailwind.min.css | cut -c-7).css\" rel=\"stylesheet\">"

curl -v $base/index.html
match "HTTP/.* 302"
Expand All @@ -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 "<script async src=\"src/app.$(sha1sum www/src/app.v2.js | cut -c-7).js\"></script>"

curl -v $base/blog.html
match "HTTP/.* 302"
Expand Down Expand Up @@ -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)"
5 changes: 5 additions & 0 deletions www/.htaccess
Original file line number Diff line number Diff line change
Expand Up @@ -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
<FilesMatch "\.[0-9a-f]{7}\.(css|js)$">
Header set Cache-Control "max-age=31536000, immutable"
</FilesMatch>
6 changes: 3 additions & 3 deletions www/_layouts/default.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page.title }}</title>
<link href="{{base}}src/landing-page.v2.css" rel="stylesheet">
<link href="{{base}}src/tailwind.min.css" rel="stylesheet">
<link href="{{ base ~ asset('src/landing-page.v2.css') }}" rel="stylesheet">
<link href="{{ base ~ asset('src/tailwind.min.css') }}" rel="stylesheet">
{# always include javascript except for index and blog posts #}
{% if page.url != "/." and not (page.url matches "#/.*/#") %}
<script async src="{{base}}src/app.v2.js"></script>
<script async src="{{ base ~ asset('src/app.v2.js') }}"></script>
{% endif %}
<link rel="alternate" type="application/atom+xml" href="{{base}}posts.atom" title="Recent blog posts" />
<script defer data-domain="clue.engineering" src="https://plausible.io/js/script.outbound-links.js"></script>
Expand Down
Loading