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:
+26
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user