Parcourir la source

Add content hash to stylesheet URL for cache busting

The layout links /static/style.css?v=<hash>, where <hash> is the first
10 hex digits of the file's SHA-256, so a changed stylesheet gets a new
URL and browsers fetch it on the next page load. Requests carrying the
current hash are cached for a year (immutable); others keep the 1-hour
cache. Version 0.2.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude il y a 2 jours
Parent
commit
a5ee315799
5 fichiers modifiés avec 72 ajouts et 3 suppressions
  1. 13 1
      main.go
  2. 31 0
      main_test.go
  3. 26 0
      render.go
  4. 1 1
      templates/layout.html
  5. 1 1
      version.go

+ 13 - 1
main.go

@@ -18,6 +18,7 @@ type app struct {
 	store    *Store
 	config   *configFile
 	pages    map[string]*template.Template
+	assets   map[string]string // static file -> content hash
 	about    template.HTML
 	siteURL  string // absolute base URL, no trailing slash
 	siteName string
@@ -34,6 +35,10 @@ func newApp(contentDir, siteURL string) (*app, error) {
 	if err != nil {
 		return nil, err
 	}
+	assets, err := assetHashes()
+	if err != nil {
+		return nil, err
+	}
 	md := newMarkdown()
 	var about bytes.Buffer
 	if err := md.Convert(aboutMarkdown, &about); err != nil {
@@ -43,6 +48,7 @@ func newApp(contentDir, siteURL string) (*app, error) {
 		store:    NewStore(contentDir, md),
 		config:   newConfigFile(filepath.Join(contentDir, "site.json")),
 		pages:    pages,
+		assets:   assets,
 		about:    template.HTML(about.String()),
 		siteURL:  siteURL,
 		siteName: u.Host,
@@ -67,7 +73,13 @@ func (a *app) routes() http.Handler {
 			a.notFound(w, r)
 			return
 		}
-		w.Header().Set("Cache-Control", "public, max-age=3600")
+		// A URL carrying the file's current hash never changes content, so it
+		// can be cached for a year; anything else gets an hour.
+		cache := "public, max-age=3600"
+		if v := r.URL.Query().Get("v"); v != "" && v == a.assets[strings.TrimPrefix(r.URL.Path, "/static/")] {
+			cache = "public, max-age=31536000, immutable"
+		}
+		w.Header().Set("Cache-Control", cache)
 		files.ServeHTTP(w, r)
 	})
 	mux.HandleFunc("/", a.notFound)

+ 31 - 0
main_test.go

@@ -1,12 +1,15 @@
 package main
 
 import (
+	"crypto/sha256"
+	"encoding/hex"
 	"encoding/xml"
 	"io"
 	"net/http"
 	"net/http/httptest"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strings"
 	"testing"
 )
@@ -143,6 +146,34 @@ func TestStatic(t *testing.T) {
 	}
 }
 
+func TestStylesheetCacheBusting(t *testing.T) {
+	srv, _ := newTestServer(t, nil)
+	_, _, page := get(t, srv, "/")
+	m := regexp.MustCompile(`<link rel="stylesheet" href="(/static/style\.css\?v=([0-9a-f]{10}))">`).FindStringSubmatch(page)
+	if m == nil {
+		t.Fatalf("no hashed stylesheet link in:\n%s", page)
+	}
+	b, err := staticFS.ReadFile("static/style.css")
+	if err != nil {
+		t.Fatal(err)
+	}
+	sum := sha256.Sum256(b)
+	if want := hex.EncodeToString(sum[:])[:10]; m[2] != want {
+		t.Errorf("hash = %s, want %s", m[2], want)
+	}
+
+	for path, want := range map[string]string{
+		m[1]:                             "public, max-age=31536000, immutable",
+		"/static/style.css":              "public, max-age=3600",
+		"/static/style.css?v=0000000000": "public, max-age=3600",
+	} {
+		status, h, _ := get(t, srv, path)
+		if status != 200 || h.Get("Cache-Control") != want {
+			t.Errorf("%s: status = %d, Cache-Control = %q, want %q", path, status, h.Get("Cache-Control"), want)
+		}
+	}
+}
+
 func TestFeed(t *testing.T) {
 	srv, _ := newTestServer(t, sample)
 	status, h, body := get(t, srv, "/feed.xml")

+ 26 - 0
render.go

@@ -2,10 +2,14 @@ package main
 
 import (
 	"bytes"
+	"crypto/sha256"
 	"embed"
+	"encoding/hex"
 	"html/template"
+	"io/fs"
 	"log"
 	"net/http"
+	"strings"
 	"time"
 
 	"github.com/yuin/goldmark"
@@ -31,9 +35,30 @@ func newMarkdown() goldmark.Markdown {
 	)
 }
 
+// assetHashes returns a short content hash for each file in static/, keyed
+// by its path under static/. Pages link to static files with ?v=<hash> so a
+// changed file gets a new URL and browsers never use a stale cached copy.
+func assetHashes() (map[string]string, error) {
+	hashes := map[string]string{}
+	err := fs.WalkDir(staticFS, "static", func(path string, d fs.DirEntry, err error) error {
+		if err != nil || d.IsDir() {
+			return err
+		}
+		b, err := staticFS.ReadFile(path)
+		if err != nil {
+			return err
+		}
+		sum := sha256.Sum256(b)
+		hashes[strings.TrimPrefix(path, "static/")] = hex.EncodeToString(sum[:])[:10]
+		return nil
+	})
+	return hashes, err
+}
+
 // pageData is passed to every template.
 type pageData struct {
 	Site         string
+	CSS          string // stylesheet URL including its content hash
 	Config       siteConfig
 	Posts        []*Post
 	Post         *Post
@@ -73,6 +98,7 @@ func loadTemplates() (map[string]*template.Template, error) {
 // clean 500 instead of a half-written page.
 func (a *app) render(w http.ResponseWriter, status int, page string, data pageData) {
 	data.Site = a.siteName
+	data.CSS = "/static/style.css?v=" + a.assets["style.css"]
 	data.Config = a.config.Get()
 	var buf bytes.Buffer
 	if err := a.pages[page].ExecuteTemplate(&buf, "layout.html", data); err != nil {

+ 1 - 1
templates/layout.html

@@ -4,7 +4,7 @@
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>{{block "title" .}}{{.Site}}{{end}}</title>
-<link rel="stylesheet" href="/static/style.css">
+<link rel="stylesheet" href="{{.CSS}}">
 <link rel="alternate" type="application/rss+xml" title="{{.Site}}" href="/feed.xml">
 </head>
 <body>

+ 1 - 1
version.go

@@ -4,7 +4,7 @@ import "runtime/debug"
 
 // version is the release number shown on the About page. Bump it in the PR
 // that makes a user-visible change.
-const version = "0.2.0"
+const version = "0.2.1"
 
 // buildCommit returns the short git revision that `go build` stamped into the
 // binary, with "-dirty" if the tree had uncommitted changes, or "" if the