notice content changes and rebuild without a restart

Polling lives in internal/ext/watch, per the human's call to keep core under its
ceiling rather than raise it a second time — which is what ADR-0041 said a second
raise would mean. It is a poller, deletable without trace, and core stayed at
2671/2800.

A settled change calls the same `rebuilder` that startup calls, because a reload
path that differs from the startup path is a reload path that drifts. The index is
an atomic.Pointer swapped whole, so a request reads the site that was current when
it arrived instead of one being rebuilt underneath it — the alternative, mutating in
place, is a data race with every in-flight request.

Names, sizes and modification times, not contents: reading every file to detect a
change costs more than the rebuild it triggers. Editor droppings are excluded,
because saving in vim writes a swap file, a backup and the number 4913, and each
would otherwise look like a change. A change must hold still for a moment first,
since one save is often several operations.

Verified against the running binary: a page 404s, the file appears, and five seconds
later it serves — one "site root changed" in the log. Then three droppings written
at once produced no rebuild at all.

Two warnings fired and were fixed rather than silenced: `runServe` gave up the
rebuild closure to `rebuilder`, and the fingerprint walk gave up its body to
`record`, where three exclusions read as a list instead of as nesting.

The Dockerfile ships the binary alone. The site root arrives as a volume and is
never copied in — it is somebody's content repository with its own history
(ADR-0011), so the image is the same for every site.
This commit is contained in:
Claude Opus 5
2026-07-31 14:00:53 +06:00
committed by bdeshi
parent 669a94a26a
commit 9100ce4876
17 changed files with 381 additions and 38 deletions
+26
View File
@@ -0,0 +1,26 @@
# One binary, a mounted site root, nothing else (ADR-0010).
#
# The site root is deliberately *not* copied in: it is somebody's content repository with its own git history
# (ADR-0011), so it arrives as a volume and the image stays the engine alone. That also means this image is the
# same for every site.
FROM golang:1.26 AS build
WORKDIR /src
# Modules first, so a content-only change never invalidates the dependency layer.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Static, so the final image needs no libc and nothing to keep patched.
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /khosra ./cmd/khosra
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /khosra /khosra
# The site root is read-only to the engine: it only ever writes derivatives, and those go to the cache.
VOLUME ["/site", "/cache"]
ENV KHOSRA_SITE=/site
EXPOSE 8080
# Listens on every interface, because inside a container localhost is only the container.
ENTRYPOINT ["/khosra"]
CMD ["-addr", "0.0.0.0:8080", "-cache", "/cache"]
+44 -13
View File
@@ -5,14 +5,17 @@ package main
import (
"flag"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"khosra/internal/content"
"khosra/internal/ext/shortcodes"
"khosra/internal/ext/watch"
"khosra/internal/render"
"khosra/internal/web"
)
@@ -53,10 +56,6 @@ func runServe() {
if err != nil {
fatal("cannot open the site root", err)
}
bundles, err := content.Scan(fsys)
if err != nil {
fatal("cannot read content", err)
}
settings, err := content.LoadSettings(fsys)
if err != nil {
fatal("cannot read site settings", err)
@@ -68,32 +67,64 @@ func runServe() {
if err != nil {
fatal("cannot prepare the theme", err)
}
indexed := content.NewSite(bundles)
if *dev == "on" {
// Never on by default and never a bare boolean flag: revealing unpublished work is a visibility
// change, and it should be impossible to enable by fumbling an argument (ADR-0024).
indexed.Reveal()
renderer.Reload()
slog.Warn("dev mode: drafts and future-dated bundles are visible, and templates reload")
}
// Derivatives are made before the first request rather than during one (ADR-0042). A failure here is not
// fatal: pages still serve the author's originals, which is the whole point of the fallback.
made, err := shortcodes.Derive(fsys, *cache)
if err != nil {
slog.Error("some derivatives were not made", "cache", *cache, "err", err)
// One atomic pointer, swapped whole: a request reads the index that was current when it arrived, never one
// being rebuilt underneath it (ADR-0022).
var live atomic.Pointer[content.Site]
rebuild := rebuilder(fsys, *cache, *dev == "on", &live)
count := rebuild()
if count < 0 {
fatal("cannot read content", nil)
}
derivedFS, err := content.OpenSite(*cache)
if err != nil {
slog.Error("generated files will not be served", "cache", *cache, "err", err)
}
// Noticing is the engine's job; fetching is not (ADR-0022). Nothing stops this loop, because the process
// ending is what stops it.
go watch.Watch(fsys, nil, func() { rebuild() })
slog.Info("serving", "site", *site, "bundles", len(bundles), "derivatives", made, "addr", *addr)
if err := http.ListenAndServe(*addr, web.Handler(indexed, renderer, fsys, derivedFS, settings)); err != nil {
slog.Info("serving", "site", *site, "bundles", count, "addr", *addr)
handler := web.Handler(live.Load, renderer, fsys, derivedFS, settings)
if err := http.ListenAndServe(*addr, handler); err != nil {
fatal("server stopped", err)
}
}
// rebuilder returns the function that reads the content, makes any missing derivatives, and swaps the index in.
//
// One function used at startup and again on every change, so the running site is always assembled the same way
// as a fresh one — a reload path that differs from the startup path is a reload path that drifts.
func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site]) func() int {
return func() int {
bundles, err := content.Scan(fsys)
if err != nil {
slog.Error("keeping the previous content: cannot read the site root", "err", err)
return -1
}
indexed := content.NewSite(bundles)
if reveal {
indexed.Reveal()
}
// Derivatives before the swap, so a picture is never referenced before it exists (ADR-0042). A failure is
// not fatal: pages still serve the author's originals, which is what the fallback is for.
if made, err := shortcodes.Derive(fsys, cache); err != nil {
slog.Error("some derivatives were not made", "cache", cache, "err", err)
} else if made > 0 {
slog.Info("made derivatives", "count", made)
}
live.Store(indexed)
return len(bundles)
}
}
// defaultCache is where generated files go when nothing says otherwise: the user's cache directory, never
// the site root, because the engine reads that and must not litter somebody's content git (ADR-0042).
func defaultCache() string {
+15
View File
@@ -371,6 +371,21 @@ optimise is still the author's picture, and is never dropped from a gallery.
`/derived/` is engine-owned, like `/tags/` and `/robots.txt`: nothing an author writes is addressed there.
## Noticing changes
A running server polls the site root every couple of seconds and rebuilds its index when something settles —
names, sizes and modification times, not contents, because reading every file to detect a change costs more than
the rebuild it triggers (ADR-0022). Editor droppings are ignored: swap files, backups, `~` copies, atomic-write
temporaries, and the number vim writes to test a directory. Saving a file is often several operations, so a
change has to hold still for a moment before it counts.
The index is swapped whole, so a request sees the content that was current when it arrived rather than a
half-rebuilt one. `content/`, `templates/` and `site.yaml` are all watched, but only content takes effect
without a restart: templates are parsed once unless `-dev on` says otherwise.
The engine notices changes. It never fetches them — pulling a git repository is the operator's business, not
the engine's.
## Time-dependent presentation `[spec]`
Anything derivable from a page plus the current clock is computed by a Stage, never stored in content:
+18
View File
@@ -694,3 +694,21 @@ directory is walked per request, which the render benchmark says costs nothing a
thing a cache would want.
Revisit if: extras need per-file metadata — a caption, an order, a date. Then they are bundles after all, and
this decision was wrong.
## ADR-0048 — Change detection lives in `internal/ext`, and a rebuild is an atomic swap
Date: 2026-07-31 · Status: accepted (implements the polling half of ADR-0022)
Decision: polling lives in `internal/ext/watch`, a feature `cmd` runs in a goroutine, and a settled change calls
one `rebuilder` function — the same one startup uses. The index is an `atomic.Pointer` swapped whole, so a
request reads the site that was current when it arrived. `content/`, `templates/` and `site.yaml` are watched;
only content takes effect without a restart, since templates are parsed once unless `-dev on`.
Why: it went to `ext` rather than core because the core ceiling had ~170 lines left and ADR-0041 said a second
raise should be read as evidence something belongs in `ext` — this did, being a poller that is deletable without
trace. Startup and reload share one function because a reload path that differs from the startup path is a
reload path that drifts. And the swap is atomic because the alternative — mutating the index in place — is a
data race with every in-flight request.
Consequence: cheap — an edit appears within a couple of seconds with no restart and no dependency, and
`Fingerprint` is testable without any timing. Expensive — a poll costs a stat per file, so a very large site
would want notifications after all; and the watcher never stops, because the process ending is what stops it,
which means no test can assert its shutdown.
Revisit if: a site grows big enough that polling shows up in a profile, or an operator wants a rebuild on
demand — a signal handler or an endpoint, not a shorter interval.
+8 -4
View File
@@ -1,6 +1,6 @@
# State
**Verified against:** `73d539d` on 2026-07-30 — update this line every change.
**Verified against:** `b6484e2` on 2026-07-30 — update this line every change.
If this file disagrees with the code, the code is right and this file is a bug.
## Inventory
@@ -20,6 +20,7 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026) | — |
| `internal/ext/shortcodes/` | first feature: `{{< name key="value" >}}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044) | 564 |
| `internal/ext/scaffold/` | writes a new bundle into the site root through `os.Root`: a directory bundle, a draft, never an overwrite | 102 |
| `internal/ext/watch/` | polls the site root, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048) | 129 |
| `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering | 216 |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`) | 20 |
| `internal/web/resolve.go` | URL → (key, lang, page, tag, feed, extras) or a canonical redirect | 168 |
@@ -28,10 +29,10 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/web/feed.go` | Atom for the site, a section or a tag, from dated bundles via one Query (ADR-0043) | 125 |
| `internal/web/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) | 74 |
| `internal/web/web.go` | handler: `serve` dispatches by kind, `serveBundle` answers the commonest one; listings, `/static/`, `/derived/`, degrade on failure | 206 |
| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`, `-dev`), wiring, startup including the derivative pass. `main` dispatches subcommands, `runServe` assembles the server — still the only place anything is wired | 116 |
| `cmd/khosra/main.go` | flags, wiring, startup, the derivative pass, and the atomic swap a rebuild goes through. `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike | 147 |
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature | 45 |
| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing | 42 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras | 2887 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras, change detection | 2990 |
Serves a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
key (ADR-0035) — a paginated listing per section, tag listings global and
@@ -41,7 +42,8 @@ section and tag, a bundle's extras as a browsable tree, plus `/robots.txt` and `
Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
smoothing (ADR-0034); line breaking is left to CSS (ADR-0045). This repo holds engine source only — the site root is external and passed with
`khosra check` validates a site root and exits non-zero on anything that makes it wrong; `khosra new`
scaffolds a draft bundle into one. A draft or
scaffolds a draft bundle into one. A running server notices content changes by polling and swaps the index
atomically, so an edit appears without a restart (ADR-0022). A draft or
future-dated bundle is not served at all — nor is any file inside it (ADR-0024) — until `-dev on` reveals it and
reloads templates per request.
`-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph
@@ -51,6 +53,8 @@ Frontmatter the parser lifts today: `title`, `date`, `tags`, `aliases`, `order`,
`content-model.md`'s table — including `draft` and `type` — lands in `Extra` unread, so that table
is the accepted format, not a list of what runs.
A `Dockerfile` ships the binary alone: the site root is a mounted volume, never copied in (ADR-0010, ADR-0011).
Dependencies: four, all allowlisted — `goldmark`, `golang.org/x/text`, `golang.org/x/image`, `gopkg.in/yaml.v3`.
## Counters — the earn-it authority
+8
View File
@@ -0,0 +1,8 @@
// Package watch notices that a site root changed and says so.
//
// Contributes: a polling loop `cmd` runs in the background (no request-path behaviour).
// Cascade keys: none.
// Contract fields: none.
// Not doing: filesystem notifications — polling needs no dependency and no per-platform code, and a site this
// size is cheap to stat (ADR-0022). Fetching changes: the engine notices, it never pulls.
package watch
+129
View File
@@ -0,0 +1,129 @@
package watch
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"hash"
"io/fs"
"log/slog"
"path"
"strings"
"time"
)
// Interval is how often the site root is looked at, and Settle is how long it must hold still afterwards.
//
// Polled rather than watched: notifications need a dependency and per-platform code, while a stat of a personal
// site's content costs almost nothing (ADR-0022). The settle window exists because saving a file is rarely one
// operation — an editor may write, rename and chmod — and rebuilding halfway through that reads a half-written
// bundle.
// Variables rather than constants for the same reason clock.go holds a variable: a test that has to wait out
// two-second polls is a test nobody runs. Nothing but a test assigns them.
var (
Interval = 2 * time.Second
Settle = time.Second
)
// Changed is called once per settled change, with the reason for the log.
type Changed func()
// Watch polls fsys until stop is closed, calling onChange after each settled change.
//
// A goroutine's worth of work, which `conventions.md` allows outside the render path: nothing here runs while a
// request is being served, and the only shared state is whatever onChange swaps.
func Watch(fsys fs.FS, stop <-chan struct{}, onChange Changed) {
previous := Fingerprint(fsys)
pending := ""
ticker := time.NewTicker(Interval)
defer ticker.Stop()
settling := time.NewTimer(Settle)
settling.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
current := Fingerprint(fsys)
switch {
case current == previous:
// Nothing moved. If something was pending, its settle timer is still running.
case current == pending:
// Still the same as last tick: the write has stopped, so let the timer finish.
default:
// Something changed, or changed again — restart the settle window.
pending = current
settling.Reset(Settle)
}
case <-settling.C:
if pending == "" || pending == previous {
continue
}
previous = pending
pending = ""
slog.Info("site root changed, rebuilding")
onChange()
}
}
}
// Fingerprint is one string standing for the current state of the content.
//
// Names, sizes and modification times — not contents: reading every file to detect a change would cost more
// than the rebuild it triggers. Editor droppings are excluded, or saving a file in vim would look like three
// changes and a deletion.
func Fingerprint(fsys fs.FS) string {
sum := sha256.New()
for _, root := range []string{"content", "templates"} {
_ = fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error {
return record(sum, p, d, err)
})
}
// site.yaml is part of the site's state, and editing it should not need a restart.
if info, err := fs.Stat(fsys, "site.yaml"); err == nil {
sum.Write([]byte("site.yaml"))
_ = binary.Write(sum, binary.LittleEndian, info.ModTime().UnixNano())
}
return hex.EncodeToString(sum.Sum(nil))
}
// record folds one entry into the running sum, skipping anything that is not content.
//
// Its own function so the walk stays flat: an error, a directory and a dropping are three early returns, which
// read as a list of exclusions rather than as nesting.
func record(sum hash.Hash, p string, d fs.DirEntry, err error) error {
switch {
case err != nil:
return nil
case d.IsDir() && dropping(d.Name()):
return fs.SkipDir
case d.IsDir(), dropping(d.Name()):
return nil
}
info, statErr := d.Info()
if statErr != nil {
return nil
}
sum.Write([]byte(p))
_ = binary.Write(sum, binary.LittleEndian, info.Size())
_ = binary.Write(sum, binary.LittleEndian, info.ModTime().UnixNano())
return nil
}
// dropping reports whether a name is an editor's leftovers rather than content.
//
// Every one of these is something a real editor writes beside the file it is saving: swap files, backups,
// atomic-write temporaries, and the number vim touches to test whether a directory is writable.
func dropping(name string) bool {
switch {
case strings.HasPrefix(name, "."), strings.HasSuffix(name, "~"):
return true
case name == "4913", name == "__pycache__":
return true
}
switch strings.ToLower(path.Ext(name)) {
case ".swp", ".swo", ".swx", ".tmp", ".bak", ".orig":
return true
}
return false
}
+103
View File
@@ -0,0 +1,103 @@
package watch
import (
"os"
"path/filepath"
"testing"
"testing/fstest"
"time"
)
func TestFingerprintChangesOnlyForContent(t *testing.T) {
base := fstest.MapFS{
"content/posts/one.md": {Data: []byte("x"), ModTime: time.Unix(100, 0)},
"templates/page.html": {Data: []byte("y"), ModTime: time.Unix(100, 0)},
"site.yaml": {Data: []byte("base: x"), ModTime: time.Unix(100, 0)},
}
before := Fingerprint(base)
// The same tree twice is the same fingerprint, or every tick would look like a change.
if Fingerprint(base) != before {
t.Fatal("fingerprint is not stable for an unchanged tree")
}
for name, change := range map[string]fstest.MapFS{
"edited a bundle": {"content/posts/one.md": {Data: []byte("xx"), ModTime: time.Unix(200, 0)}},
"added a bundle": {"content/posts/two.md": {Data: []byte("z"), ModTime: time.Unix(100, 0)}},
"edited a template": {"templates/page.html": {Data: []byte("yy"), ModTime: time.Unix(200, 0)}},
"edited site.yaml": {"site.yaml": {Data: []byte("base: y"), ModTime: time.Unix(200, 0)}},
} {
next := fstest.MapFS{}
for k, v := range base {
next[k] = v
}
for k, v := range change {
next[k] = v
}
if Fingerprint(next) == before {
t.Errorf("%s went unnoticed", name)
}
}
// Editor droppings are not content: saving in vim writes several of these, and each would look like a change.
noise := fstest.MapFS{}
for k, v := range base {
noise[k] = v
}
for _, dropping := range []string{
"content/posts/.one.md.swp", "content/posts/one.md~", "content/posts/4913",
"content/posts/one.md.tmp", "content/.DS_Store",
} {
noise[dropping] = &fstest.MapFile{Data: []byte("noise"), ModTime: time.Unix(300, 0)}
}
if Fingerprint(noise) != before {
t.Error("editor droppings changed the fingerprint")
}
}
func TestWatchFiresOnceAfterAChangeSettles(t *testing.T) {
// A real directory, because the point is the timing of repeated writes, which a static MapFS cannot show.
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "content"), 0o755); err != nil {
t.Fatal(err)
}
write := func(name, body string) {
if err := os.WriteFile(filepath.Join(dir, "content", name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("one.md", "first")
// Poll faster than a person could type, so the test measures the settle behaviour rather than the clock.
Interval, Settle = 20*time.Millisecond, 40*time.Millisecond
defer func() { Interval, Settle = 2*time.Second, time.Second }()
fsys := os.DirFS(dir)
stop := make(chan struct{})
defer close(stop)
changes := make(chan struct{}, 8)
go Watch(fsys, stop, func() { changes <- struct{}{} })
// Nothing has changed, so nothing should fire.
select {
case <-changes:
t.Fatal("fired without a change")
case <-time.After(Interval + Settle):
}
// Several writes in quick succession are one change, not three: that is what the settle window is for.
write("one.md", "second")
write("two.md", "third")
write("one.md", "fourth")
select {
case <-changes:
case <-time.After(4 * (Interval + Settle)):
t.Fatal("a change never arrived")
}
// And it does not keep firing once things are still.
select {
case <-changes:
t.Error("fired twice for one settled change")
case <-time.After(2 * Interval):
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ func assetHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
}
func TestABundlesOwnFilesAreServed(t *testing.T) {
+1 -1
View File
@@ -53,7 +53,7 @@ func benchHandler(b *testing.B, pictures int) http.Handler {
if err != nil {
b.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
}
func serveOnce(b *testing.B, h http.Handler, path string) {
+1 -1
View File
@@ -29,7 +29,7 @@ func crawlerHandler(t *testing.T, settings content.Settings, extra fstest.MapFS)
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, settings)
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
}
func TestSitemapListsEveryVariantAbsolutely(t *testing.T) {
+1 -1
View File
@@ -32,7 +32,7 @@ func extrasHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
}
func TestExtrasAreNotBundles(t *testing.T) {
+1 -1
View File
@@ -30,7 +30,7 @@ func feedHandler(t *testing.T, settings content.Settings) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, settings)
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
}
func fetchFeed(t *testing.T, h http.Handler, path string) (*httptest.ResponseRecorder, atom) {
+2 -2
View File
@@ -21,7 +21,7 @@ func slugHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
}
func TestASlugRenamesTheAddressInEveryLanguage(t *testing.T) {
@@ -82,7 +82,7 @@ func TestListingsAndSitemapsUseTheSluggedAddress(t *testing.T) {
if err != nil {
t.Fatal(err)
}
h := Handler(content.NewSite(bundles), r, fsys, nil, settings)
h := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/", nil))
+3 -3
View File
@@ -36,7 +36,7 @@ func TestNothingInsideAnUnpublishedBundleIsServed(t *testing.T) {
if err != nil {
t.Fatal(err)
}
hidden := Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
hidden := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
for path, want := range map[string]int{
"/art/draft/": http.StatusNotFound,
"/art/draft/one.jpg": http.StatusNotFound,
@@ -58,7 +58,7 @@ func TestNothingInsideAnUnpublishedBundleIsServed(t *testing.T) {
// Revealing them is the only thing that changes the answer.
site := content.NewSite(bundles)
site.Reveal()
shown := Handler(site, r, fsys, nil, content.Settings{})
shown := Handler(Fixed(site), r, fsys, nil, content.Settings{})
for _, path := range []string{"/art/draft/", "/art/draft/one.jpg", "/art/future/", "/art/future/two.jpg"} {
rec := httptest.NewRecorder()
shown.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
@@ -82,7 +82,7 @@ func TestUnpublishedBundlesAreAbsentFromEverythingThatLists(t *testing.T) {
if err != nil {
t.Fatal(err)
}
h := Handler(content.NewSite(bundles), r, fsys, nil, settings)
h := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
for _, path := range []string{"/art/", "/feed.xml", "/sitemap.xml"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
+12 -3
View File
@@ -12,13 +12,22 @@ import (
"khosra/internal/render"
)
// Current returns the site as it is right now.
//
// A function rather than a pointer, so a background poller can swap what it returns and a request still sees one
// coherent index instead of one being rebuilt underneath it (ADR-0022).
type Current func() *content.Site
// Fixed is a Current for a site that never changes, which is every caller that does not watch for changes.
func Fixed(site *content.Site) Current { return func() *content.Site { return site } }
// Handler serves a site.
//
// One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
func Handler(site *content.Site, r *render.Renderer, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler {
func Handler(current Current, r *render.Renderer, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
serve(w, req, site, r, siteFS, settings)
serve(w, req, current(), r, siteFS, settings)
})
// Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases: no
// bundle can own them, since a key always sits under a section.
@@ -26,7 +35,7 @@ func Handler(site *content.Site, r *render.Renderer, siteFS, derivedFS fs.FS, se
serveRobots(w, req, siteFS, settings.Base)
})
mux.HandleFunc("GET "+sitemapPath, func(w http.ResponseWriter, req *http.Request) {
serveSitemap(w, req, site, settings.Base)
serveSitemap(w, req, current(), settings.Base)
})
if siteFS != nil {
if sub, err := fs.Sub(siteFS, "static"); err == nil {
+8 -8
View File
@@ -28,7 +28,7 @@ func testHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
}
func TestServeBundleAtItsPermalink(t *testing.T) {
@@ -76,7 +76,7 @@ func multilingualHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
}
func TestPrefixedLanguageServesThatVariant(t *testing.T) {
@@ -128,7 +128,7 @@ func aliasHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
}
func TestAliasRedirectsToCanonical(t *testing.T) {
@@ -175,7 +175,7 @@ func listingHandler(t *testing.T, n int) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
}
func TestSectionIndexListsNewestFirst(t *testing.T) {
@@ -243,7 +243,7 @@ func TestStaticFilesAreServedAndDirectoriesAreNot(t *testing.T) {
if err != nil {
t.Fatal(err)
}
h := Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
h := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
for path, want := range map[string]int{
"/static/style.css": http.StatusOK,
"/static/img/logo.svg": http.StatusOK,
@@ -284,7 +284,7 @@ func TestAStaticPathThatEscapesTheRootIs404(t *testing.T) {
if err != nil {
t.Fatal(err)
}
h := Handler(content.NewSite(nil), r, fsys, nil, content.Settings{})
h := Handler(Fixed(content.NewSite(nil)), r, fsys, nil, content.Settings{})
for path, want := range map[string]int{
"/static/ok.css": http.StatusOK,
"/static/escape.txt": http.StatusNotFound,
@@ -317,7 +317,7 @@ func seriesHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, nil, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles)), r, nil, nil, content.Settings{})
}
func TestSequenceNavigationLinksNeighbours(t *testing.T) {
@@ -407,7 +407,7 @@ func tagHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, nil, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles)), r, nil, nil, content.Settings{})
}
func TestGlobalTagListingSpansSectionsGroupedByOne(t *testing.T) {