Browse Source

Merge branch 'feature/css-cache-busting' of bajdev/posts into main

bajdev 2 days ago
parent
commit
12b391503d

+ 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")

+ 35 - 0
pull-releases/001-initial/README.md

@@ -0,0 +1,35 @@
+# Add Markdown blog server
+
+Branch `feature/initial` · merged 2026-10-24 · version (none; later called 0.1.0)
+
+## What changed
+
+- **Blog server:** a Go app that serves each `content/*.md` file as a blog post.
+  - `/` lists all posts, newest first, with title, date and summary.
+  - `/posts/<slug>` shows one post, with links to the newer and older posts.
+  - `/feed.xml` is an RSS feed of the 20 newest posts.
+  - Any other URL gets a styled 404 page.
+- **Posts:** named `YYYY-MM-DD-slug.md`, with an optional front-matter block for `title`, `date`, `summary` and `draft`. If the title is missing, the first `# Heading` is used, then the slug. If the date is missing, it comes from the filename.
+- **Live content:** posts are re-read whenever a file in the content directory is added, changed or removed, so new posts appear without a restart.
+- **Fault tolerance:** a post with a bad filename or broken front matter is skipped and logged; the rest of the site keeps working.
+- **Markdown:** rendered with goldmark (CommonMark plus GitHub-style tables, strikethrough, task lists and autolinks). Raw HTML in posts is left out.
+- **Templates and CSS:** built into the binary. The layout is minimal, with automatic dark mode.
+- **Security:** URLs can only reach posts that have been indexed, so `../` paths get a 404. Responses carry a strict Content-Security-Policy (no scripts), `nosniff` and a referrer policy. The server binds to `127.0.0.1` with request timeouts.
+- **Configuration:** set by environment variables `PORT`, `CONTENT_DIR` and `SITE_URL`.
+- **First post:** `content/2026-10-24-hello-world.md`, a guide to writing posts covering file names, the header block, publishing, and what Markdown is supported.
+
+## Testing
+
+- `go vet` is clean and all tests pass with the race detector (`go test -race ./...`), with 81% coverage.
+- Tests cover front-matter parsing and fallbacks, CRLF files, drafts, ordering, newer/older links, 404s including path-traversal attempts, the RSS feed, static files, security headers, and picking up added and removed posts.
+- Built and ran locally against the real `content/`, then checked the index, the post and the feed.
+
+## Screenshots
+
+Taken afterwards from a build of the branch's final commit (`0bd42c4`).
+
+| Desktop | Mobile |
+|---|---|
+| ![Desktop](desktop.png) | ![Mobile](mobile.png) |
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)

BIN
pull-releases/001-initial/desktop.png


BIN
pull-releases/001-initial/mobile.png


+ 32 - 0
pull-releases/002-header-about-config/README.md

@@ -0,0 +1,32 @@
+# Add About page, configurable Home link, RSS footer, MIT license
+
+Branch `feature/header-about-config` · merged 2026-10-24 · version 0.2.0
+
+## What changed
+
+- **Header:** the host name is replaced by a **Home** link to the index, and the RSS link is replaced by **About**.
+- **Configurable Home text:** set `"homeText"` in `content/site.json`. With no file, no setting or a blank value, it shows "Home". The file is reloaded when it changes, so editing it needs a redeploy but not a rebuild or restart.
+- **About page (`/about`):** a box at the top with the version (0.2.0 plus the short git commit), the total post count and a link to the latest post. Below it is the guide to writing posts, taken from the hello-world post (`about.md`, built into the binary).
+- **Footer:** a new footer with the RSS link at the bottom right, on every page.
+- **License:** MIT in `LICENSE`, linked from the README. The README also documents `site.json`, `/about` and how versioning works.
+
+## Notes
+
+- `site.json` lives in `content/`, so `deploy-go posts --content` ships it with no script change. It is never treated as a post.
+- If `site.json` has invalid JSON or a misspelled setting name, the error is logged and the last good settings stay in use.
+
+## Testing
+
+- All tests pass with the race detector (`go test -race ./...`).
+- New tests cover each `site.json` case (missing, set, left out, blank, broken JSON, unknown setting, deleted), the header and footer on every page type, the About page with and without posts, and `site.json` not being listed as a post.
+- Built and ran locally, then checked the header, footer and About page in the HTML.
+
+## Screenshots
+
+Taken afterwards from a build of the branch's final commit (`ac317db`).
+
+| Desktop | Mobile |
+|---|---|
+| ![Desktop](desktop.png) | ![Mobile](mobile.png) |
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)

BIN
pull-releases/002-header-about-config/desktop.png


BIN
pull-releases/002-header-about-config/mobile.png


+ 31 - 0
pull-releases/003-css-cache-busting/README.md

@@ -0,0 +1,31 @@
+# Add content hash to stylesheet URL; start pull-releases records
+
+Branch `feature/css-cache-busting` · version 0.2.1
+
+## What changed
+
+- **Hashed stylesheet URL:** pages link to `/static/style.css?v=<hash>`, where `<hash>` is the first 10 hex digits of the stylesheet's SHA-256. A changed stylesheet gets a new URL, so browsers fetch it on the next page load with no hard refresh needed.
+- **Caching:** requests carrying the current hash are cached for a year (`immutable`). Plain or outdated URLs keep the 1-hour cache.
+- **Pull-release records:** new `pull-releases/` folder with one subfolder per PR, each holding a `README.md` (title and changes) and desktop and mobile screenshots. Records for the two earlier PRs are included, with screenshots taken from builds of their final commits.
+- **Version:** 0.2.1.
+
+## Notes
+
+- There is no visible change in this PR; the screenshots match 002.
+- `pull-releases/` is not part of the app: it isn't built into the binary or deployed.
+
+## Testing
+
+- All tests pass with the race detector (`go test -race ./...`).
+- New test: the link's hash matches the file's SHA-256, plus Cache-Control for the hashed, plain and wrong-hash URLs.
+- Built and ran locally; the hash matched `sha256sum static/style.css` and the cache headers were correct.
+
+## Screenshots
+
+From the branch's build (`a5ee315`) running locally.
+
+| Desktop | Mobile |
+|---|---|
+| ![Desktop](desktop.png) | ![Mobile](mobile.png) |
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)

BIN
pull-releases/003-css-cache-busting/desktop.png


BIN
pull-releases/003-css-cache-busting/mobile.png


+ 27 - 0
pull-releases/README.md

@@ -0,0 +1,27 @@
+# Pull releases
+
+One folder per pull request, so each change has a written record with
+screenshots. The record is added on the PR's own branch, so it is reviewed and
+merged with the change.
+
+```
+pull-releases/
+  NNN-branch-name/      NNN = next number in sequence, branch name without feature/
+    README.md           title (first line), what changed, notes, testing
+    desktop.png         home page at 1280×900
+    mobile.png          home page at 390×844 (2× pixel density)
+```
+
+To open the PR on Gogs, use the compare link for the branch, paste the first
+line of `README.md` as the title and the rest as the description.
+
+Screenshots are taken from the branch's build running locally, before it is
+deployed, so they show exactly what the PR will put live. When a PR mainly
+changes a page other than the home page, that page is captured as well (for
+example `about-desktop.png`).
+
+| PR | Change |
+|---|---|
+| [001](001-initial/) | Markdown blog server |
+| [002](002-header-about-config/) | About page, configurable Home link, RSS footer, MIT license |
+| [003](003-css-cache-busting/) | Content hash on the stylesheet URL; this folder |

+ 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