serve one theme snapshot to the whole site, and honour -poll

Per-render reparsing could not keep the site coherent, and did not. Only two of
the four render methods called fresh() — Bundle and Tag never did — so under
-dev on a listing served an edited template while a bundle served the old one.
Verified on the pre-G1 binary: /posts/ answered V2 while /posts/hello/ answered
V1, permanently, not for a window.

Adding the two missing calls would have left four places that must each
remember, and Partial runs during a page's Markdown conversion, so one page could
still mix two themes. So the per-render path is deleted instead: Renderer.reload,
Reload() and fresh() are gone, Refresh is the only thing that replaces a theme,
and -dev on gets its promptness from polling every 250ms. Coherence is now
structural rather than a discipline four methods share.

-poll arrives as ADR-0022 specified it and never delivered: it sets the interval,
and 0 stops watching for an immutable deployment. Watch takes the interval and
settle window as arguments, so the Interval and Settle package variables are
gone and no test mutates package state to control timing.

The theme is reparsed in the watcher's callback rather than inside rebuilder, so
startup parses it exactly once, in New — there is one call site and it is not on
the startup path. The two swaps it leaves are not one transaction; state.md's
latent list carries that gap and its trigger.

Measured on the real binary: bundle, section listing and tag listing all moved
V1 -> V9 together within 1s of editing two templates; -poll 0 served and then
ignored an edit; -dev on -poll 3s kept 3s. core 2780/2800, ext 1027/2000.
This commit is contained in:
Claude Opus 5
2026-08-01 10:54:30 +06:00
committed by bdeshi
parent 633debf743
commit b5ec3bfc9d
10 changed files with 230 additions and 131 deletions
+52 -19
View File
@@ -12,6 +12,7 @@ import (
"path/filepath"
"strings"
"sync/atomic"
"time"
"khosra/internal/content"
"khosra/internal/ext/shortcodes"
@@ -46,7 +47,8 @@ func runServe() {
addr := flag.String("addr", "localhost:8080", "address to listen on")
base := flag.String("base", "", "canonical site origin, overriding site.yaml (e.g. https://khosra.example)")
cache := flag.String("cache", defaultCache(), "directory for generated files; never inside the site root")
dev := flag.String("dev", "", "set to 'on' to reveal drafts and future-dated bundles and reload templates")
dev := flag.String("dev", "", "set to 'on' to reveal drafts and future-dated bundles")
poll := flag.Duration("poll", 2*time.Second, "how often to look for changes; 0 disables watching (ADR-0022)")
flag.Parse()
if *site == "" {
@@ -67,11 +69,11 @@ func runServe() {
if err != nil {
fatal("cannot prepare the theme", err)
}
// 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).
interval := pollInterval(*dev == "on", *poll)
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).
renderer.Reload()
slog.Warn("dev mode: drafts and future-dated bundles are visible, and templates reload")
slog.Warn("dev mode: drafts and future-dated bundles are visible", "poll", interval)
}
// Navigation reads the live index, so a section that appears with a rebuild appears in the nav too, and the
@@ -79,7 +81,7 @@ func runServe() {
// 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, renderer)
rebuild := rebuilder(fsys, *cache, *dev == "on", &live)
count := rebuild()
if count < 0 {
fatal("cannot read content", nil)
@@ -90,9 +92,7 @@ func runServe() {
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() })
watching(fsys, interval, renderer, rebuild)
slog.Info("serving", "site", *site, "bundles", count, "addr", *addr)
handler := web.Handler(live.Load, renderer, fsys, derivedFS, settings)
@@ -101,20 +101,40 @@ func runServe() {
}
}
// rebuilder returns the function that reparses the theme, reads the content, makes any missing derivatives, and
// swaps both in.
// pollInterval is how often to look for a change, and where `-dev on` gets its promptness (ADR-0056):
// authoring wants an edit applied quickly, but never faster than an interval the operator chose deliberately.
func pollInterval(dev bool, chosen time.Duration) time.Duration {
if dev && !given("poll") {
return 250 * time.Millisecond
}
return chosen
}
// watching starts the poller and applies each settled change: theme swapped, then index.
//
// 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. The theme is
// part of what a change can change, so it is reparsed here rather than per request (ADR-0055).
func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site],
renderer *render.Renderer) func() int {
return func() int {
// The theme first, and independently: a broken template keeps the working one and must not cost the
// site a content update it could have served.
// Noticing is the engine's job; fetching is not (ADR-0022). Nothing stops the loop but the process ending, and
// `-poll 0` never starts it — what an immutable deployment wants. The theme is reparsed here and nowhere else,
// so one swap serves the whole site (ADR-0056); startup does not come through here, because New parsed it
// already and both call `parseTheme`.
func watching(fsys fs.FS, every time.Duration, renderer *render.Renderer, rebuild func() int) {
if every <= 0 {
slog.Info("not watching for changes: -poll 0")
return
}
go watch.Watch(fsys, every, every/2, nil, func() {
if err := renderer.Refresh(); err != nil {
slog.Error("keeping the previous theme: cannot parse the new one", "err", err)
}
rebuild()
})
}
// 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)
@@ -136,6 +156,19 @@ func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[conte
}
}
// given reports whether a flag was passed rather than left at its default.
//
// Needed once: `-dev on` polls faster, and "faster" must not silently override an interval the operator chose.
func given(name string) bool {
passed := false
flag.Visit(func(f *flag.Flag) {
if f.Name == name {
passed = true
}
})
return passed
}
// 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 {
+12 -7
View File
@@ -301,9 +301,10 @@ For a future-dated bundle the 404 expires at its publish time, so it becomes vis
becomes public — the clock is read per request, so nothing has to be restarted or invalidated for a scheduled
post to appear.
`-dev on` reveals drafts and future-dated bundles and reparses templates before each render, so editing a
template needs no restart. It is off by default and is not a bare boolean: revealing unpublished work should be
impossible to enable by fumbling an argument.
`-dev on` reveals drafts and future-dated bundles, and polls every 250ms instead of every two seconds so an
edit lands promptly — unless `-poll` was passed, in which case the operator's interval wins (ADR-0056). It is
off by default and is not a bare boolean: revealing unpublished work should be impossible to enable by
fumbling an argument.
## Typography and localisation
@@ -382,17 +383,21 @@ optimise is still the author's picture, and is never dropped from a gallery.
## Noticing changes
A running server polls the site root every couple of seconds and rebuilds its index when something settles —
A running server polls the site root on an interval — `-poll`, two seconds by default, `-poll 0` to stop
watching altogether for an immutable deployment (ADR-0022) — 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. The theme is swapped the same way in the same rebuild, so an edited template takes effect
half-rebuilt one. The theme is swapped the same way at the same moment, so an edited template takes effect
without a restart, and a template with a typo in it keeps the last working theme instead of taking the site
down (ADR-0055). `-dev on` still reparses per request, which costs a parse but shows an edit on the next
request rather than at the next poll.
down (ADR-0055).
**One snapshot, whole site.** A swap replaces the theme for every page at once — bundles, listings, tag
listings, extras alike — so the site is never partly updated: no render path reparses on its own, and there
is no state in which one page shows an edited template and its neighbour shows the old one (ADR-0056).
`content/` and `templates/` are watched. `site.yaml` is **not**: it applies at startup only, because the
settings are copied by value into the renderer, the handler, the feeds and the sitemap, and a rebuild that
+2 -2
View File
@@ -87,8 +87,8 @@ sentence, and a gate whose cheapest satisfaction is noise buys noise.
## Performance
Correct and small first; fast where measured. The render path is the only hot path.
- Write to `io.Writer`, never build pages by string concatenation.
- Parse templates once per rebuild, never per request — startup and a settled change share one path, and
the parsed set is swapped whole rather than mutated (ADR-0055). `-dev on` is the one exception it buys.
- Parse templates once per rebuild, never per request and never inside a render method — the parsed set is
swapped whole so every page serves one snapshot, with no exception for `-dev` (ADR-0055, ADR-0056).
- No `sync.Pool`, caching layer, or goroutines in the render path until a benchmark justifies it and
the number goes in the commit message.
- No reflection in the hot path. `Extra` lookups are map reads, not reflection.
+30 -1
View File
@@ -831,7 +831,8 @@ would collide with everyone else's.
## ADR-0055 — A rebuild swaps the theme too; `site.yaml` is restart-only
Date: 2026-08-01 · Status: accepted (supersedes ADR-0048's last clause — "only content takes effect
without a restart" — and delivers the template half of ADR-0022's stated consequence)
without a restart" — and delivers the template half of ADR-0022's stated consequence); its `-dev`
clause, that a per-request reparse is a trade worth keeping, is superseded by ADR-0056
Decision: a rebuild reparses the theme and swaps it in whole, next to the index swap, so an edited
template takes effect within a poll interval and without a restart. The parsed sets and the stylesheet
become one immutable `parsedTheme` behind an `atomic.Pointer`, replaced rather than mutated. A parse
@@ -853,3 +854,31 @@ share one path and that is worth more than the microseconds. Expensive — `site
with nothing in the logs to say so, and the renderer must reach through `theme.Load()` at every use.
Revisit if: anything else wants a live `site.yaml` — then thread a live read through all four readers at
once, or give settings the same atomic treatment the theme just got.
## ADR-0056 — One theme snapshot for the whole site; the poll interval is a parameter
Date: 2026-08-01 · Status: accepted (supersedes ADR-0055's clause that `-dev` owns a per-request reparse;
implements the `-poll` flag ADR-0022 specified and never got)
Decision: `Refresh` is the only thing that ever replaces the theme, so every page served after a swap was
rendered from the same snapshot. The per-render reparse is deleted — `Renderer.reload`, `Reload()` and
`fresh()` are gone — and `-dev on` gets its promptness from polling every 250ms instead, unless `-poll`
was passed, in which case the operator's interval wins. `Watch` takes the interval and the settle window
as arguments; the `Interval` and `Settle` package variables are deleted. `-poll` sets the interval and
`-poll 0` disables watching entirely, as ADR-0022 said it would. The theme is reparsed in the watcher's
callback rather than inside `rebuilder`, so startup parses it exactly once, in `New`.
Why: per-render reparsing could not keep the site coherent, and did not. Only two of the four render
methods called `fresh()``Bundle` and `Tag` never did — so under `-dev on` a listing served an edited
template while a bundle served the old one, verified on the pre-G1 binary: `/posts/` answered V2 while
`/posts/hello/` answered V1, permanently. Adding the missing calls would have made four places that must
each remember, and `Partial` runs *during* a page's Markdown conversion, so a single page could still mix
two themes. A whole-site swap makes coherence structural instead of a discipline, and it costs a poll
interval of latency in dev, which is the cheaper half of that trade. The interval had to become a
parameter for dev and `-poll` to want different ones — the second and third callers, so no anticipation.
Consequence: cheap — one less exported method, one less field, two fewer package variables, and a test
that pins every render path to one snapshot at once. `-poll 0` gives immutable deployments a way to stop
polling, and no test mutates package state to control timing any more. Expensive — an author's template
edit now appears on the next poll rather than the next request (~375ms in dev, not instant); and the
theme and index are still two `Store` calls, so a request landing between them sees a new theme with the
previous index. Both halves are internally coherent and the gap is microseconds, but it is not a snapshot
of the disk, and closing it means one pointer holding both, which is `web.Handler`'s signature.
Revisit if: that microsecond gap ever matters — then the index and the theme become one snapshot behind
one pointer, and `web.Handler` takes an accessor for it instead of two arguments.
+9 -9
View File
@@ -1,6 +1,6 @@
# State
**Verified against:** `1909a31` on 2026-08-01 — update this line every change.
**Verified against:** `cf3edd7` on 2026-08-01 — update this line every change.
If this file disagrees with the code, the code is right and this file is a bug.
## Inventory
@@ -19,13 +19,13 @@ table owns.
| `internal/content/extras.go` | a bundle's supporting files: enumeration, classification, and their URLs (ADR-0047) |
| `internal/content/settings.go` | `site.yaml`: the site's own declarations (`base`, `title`) and absolute-URL building (ADR-0039) |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything`, slug routes, publication visibility |
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods. The parsed sets plus the stylesheet are one snapshot behind an `atomic.Pointer`, swapped by `Refresh` on every rebuild (ADR-0055) |
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods. The parsed sets plus the stylesheet are one snapshot behind an `atomic.Pointer`; `Refresh` is the only thing that replaces it, so every page serves one theme (ADR-0055, ADR-0056) |
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment`, `Picture`, `Origin` |
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) |
| `internal/render/templates/` | reference theme, complete: `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026, ADR-0049) |
| `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) |
| `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite |
| `internal/ext/watch/` | polls `content/` and `templates/`, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048). `site.yaml` is deliberately not fingerprinted (ADR-0055) |
| `internal/ext/watch/` | polls `content/` and `templates/` on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). `site.yaml` is deliberately not fingerprinted (ADR-0055) |
| `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 |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`) |
| `internal/web/resolve.go` | URL → (key, lang, page, tag, feed, extras) or a canonical redirect |
@@ -34,7 +34,7 @@ table owns.
| `internal/web/feed.go` | Atom for the site, a section or a tag, from dated bundles via one Query (ADR-0043) |
| `internal/web/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) |
| `internal/web/web.go` | handler: `serve` dispatches by kind, `serveBundle` answers the commonest one; listings, `/static/`, `/derived/`, degrade on failure |
| `cmd/khosra/main.go` | flags, wiring, startup, the derivative pass, and the two atomic swaps a rebuild goes through — theme and index. `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
| `cmd/khosra/main.go` | flags (including `-poll`, zero to stop watching), wiring, startup, the derivative pass, and the atomic swaps a change goes through — theme in the watcher's callback, index in `rebuilder`. `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature |
| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing |
| `*_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, what a page can reach, the root listing, and the example site end to end |
@@ -48,10 +48,11 @@ Chrome text, dates and digits render in English or Bengali; authored text is unt
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 running server notices changes under `content/` and `templates/` by
polling and swaps both the index and the parsed theme atomically, so a content *or* template edit appears
without a restart (ADR-0022, ADR-0055); `site.yaml` applies at startup only. A draft or
polling`-poll`, zero to stop — and swaps both the index and the parsed theme atomically, so a content *or*
template edit appears without a restart and every page updates together (ADR-0022, ADR-0055, ADR-0056);
`site.yaml` applies at startup only. 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.
polls four times a second (ADR-0056).
`-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph
URLs go absolute (ADR-0039).
@@ -108,8 +109,7 @@ with a stated reason. A list nothing drains is a graveyard of known defects.
| A gallery's images carry no `alt` | `width`/`height` now come from the original (ADR-0042), so only alt text is missing, and a filename does not supply one. An empty `alt` is honest for a picture the page has already introduced | Captions per gallery entry — a sidecar or a frontmatter list — if the reference theme ever needs them |
| Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | Measured at the same time as the pictures (ADR-0044): a whole page is ~63µs, so this is not what costs anything. Remembering it would be a cache with no measurement behind it | A page render exceeding a few milliseconds, which is also what would revive the parked cache model |
| The root listing's `<title>` repeats itself — "A Khosra Demo · A Khosra Demo" | Spotted 2026-08-01 by looking at the served page, not by any test: `base.html` joins page title and site title unconditionally, and at the root those are the same string. Cosmetic, and the fix is one `if` in a template — theme layer, not engine | The first time the reference theme is worked on (Phase G4 touches it), or sooner if a feed or OpenGraph title inherits the same doubling |
| `Renderer.Tag` is the one render method that never calls `fresh()`, so under `-dev on` a tag listing shows an edited template only at the next poll, not on the next request | Spotted 2026-08-01 while making the theme swappable (ADR-0055). Harmless in a serving build, where the rebuild swaps the theme for every method alike, and bounded by the poll interval even in `-dev`. The fix is three lines, but nothing tests `Reload()` today, so it would be three untested lines | The first test of `-dev`'s per-request reparse, which is what should have caught this |
| ADR-0022 says the poll interval is set by one flag, `-poll`, "zero to disable for immutable deployments". There is no such flag: `watch.Interval` is a package variable only tests assign | Spotted 2026-08-01 reading ADR-0022 against `watch.go` for G1. The decision was recorded before the feature was built and the flag was never part of what shipped (ADR-0048 does not mention it) | An immutable deployment that wants polling off, which is the only case the flag was for — then it is a flag plus a superseding ADR, not a rediscovery |
| The theme and the index are two separate `atomic.Pointer` stores, so a request landing between them sees a new theme with the previous index | Accepted 2026-08-01 with ADR-0056: both halves are internally coherent and the gap is microseconds, so no page is ever internally inconsistent — it is simply not a snapshot of the disk. Closing it means one pointer holding both, which changes `web.Handler`'s signature and 20 test construction sites | Anything that makes the gap observable — a request rate high enough to land in it, or a feature where content and theme must agree exactly (an export, where every page is generated in one pass) |
| The picture memo is never evicted — one entry per picture on the site, for the life of the process | Correct for one author's site, and the alternative is an eviction policy nothing needs. It is keyed on size and modification time, so it cannot go stale, only grow | A site root large enough that memory matters, or a long-running process where pictures churn |
## Open questions
+45 -45
View File
@@ -6,16 +6,19 @@ Every top-level declaration in the engine, with its line. Read this before openi
file: it answers "where does X live" and "what is in this package" without the bodies. What each
file is *for* lives in `state.md`; why it is that way lives in `decisions.md`.
## cmd/khosra — 264 lines
## cmd/khosra — 297 lines
check.go 45 · main.go 158 · new.go 42 · wire.go 19
check.go 45 · main.go 191 · new.go 42 · wire.go 19
- check.go:16 func runCheck(args []string)
- main.go:23 func main()
- main.go:44 func runServe()
- main.go:110 func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site],
- main.go:141 func defaultCache() string
- main.go:151 func fatal(msg string, err error)
- main.go:24 func main()
- main.go:45 func runServe()
- main.go:106 func pollInterval(dev bool, chosen time.Duration) time.Duration
- main.go:119 func watching(fsys fs.FS, every time.Duration, renderer *render.Renderer, rebuild func() int)
- main.go:136 func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site]) func() int
- main.go:162 func given(name string) bool
- main.go:174 func defaultCache() string
- main.go:184 func fatal(msg string, err error)
- new.go:12 func runNew(args []string)
- wire.go:15 func extenders(partial render.Partial) []goldmark.Extender
@@ -162,20 +165,19 @@ doc.go 7 · images.go 250 · shortcodes.go 312
- shortcodes.go:270 func parse(line string) (name string, args map[string]string, ok bool)
- shortcodes.go:297 func argument(s string) (key, value, rest string, ok bool)
## internal/ext/watch — 136 lines + 114 test
## internal/ext/watch — 133 lines + 114 test
doc.go 8 · watch.go 128
doc.go 8 · watch.go 125
- watch.go:23 var
- watch.go:29 type Changed func()
- watch.go:35 func Watch(fsys fs.FS, stop <-chan struct{}, onChange Changed)
- watch.go:79 func Fingerprint(fsys fs.FS) string
- watch.go:93 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
- watch.go:116 func dropping(name string) bool
- watch.go:16 type Changed func()
- watch.go:32 func Watch(fsys fs.FS, every, settle time.Duration, stop <-chan struct{}, onChange Changed)
- watch.go:76 func Fingerprint(fsys fs.FS) string
- watch.go:90 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
- watch.go:113 func dropping(name string) bool
## internal/render — 726 lines + 407 test
## internal/render — 707 lines + 461 test
chrome.go 110 · render.go 486 · view.go 130
chrome.go 110 · render.go 467 · view.go 130
- chrome.go:19 var chrome = map[string]map[string]string{
- chrome.go:33 var months = map[string][]string{
@@ -187,34 +189,32 @@ chrome.go 110 · render.go 486 · view.go 130
- chrome.go:98 func localiseDigits(lang, s string) string
- render.go:25 var themeFS embed.FS
- render.go:29 type Renderer struct
- render.go:52 type parsedTheme struct
- render.go:66 type Partial func(name string, data Fragment) ([]byte, error)
- render.go:69 type Fragment struct
- render.go:79 type Picture struct
- render.go:96 type Origin struct
- render.go:105 var originKey = parser.NewContextKey()
- render.go:108 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:115 func WithOrigin(pc parser.Context, origin Origin)
- render.go:128 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:154 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:182 func (r *Renderer) head(title, lang, canonical string) head
- render.go:199 func (r *Renderer) absolute(path string) string
- render.go:207 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:211 func (r *Renderer) Reload() { r.reload = true }
- render.go:218 func (r *Renderer) Refresh() error
- render.go:230 func (r *Renderer) fresh() error
- render.go:239 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:258 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:280 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:298 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:322 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:340 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:377 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:399 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:426 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:453 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:458 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:480 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- render.go:48 type parsedTheme struct
- render.go:62 type Partial func(name string, data Fragment) ([]byte, error)
- render.go:65 type Fragment struct
- render.go:75 type Picture struct
- render.go:92 type Origin struct
- render.go:101 var originKey = parser.NewContextKey()
- render.go:104 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:111 func WithOrigin(pc parser.Context, origin Origin)
- render.go:124 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:150 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:178 func (r *Renderer) head(title, lang, canonical string) head
- render.go:195 func (r *Renderer) absolute(path string) string
- render.go:203 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:215 func (r *Renderer) Refresh() error
- render.go:226 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:245 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:267 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:285 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:306 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:324 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:361 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:380 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:407 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:434 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:439 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:461 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- view.go:16 type head struct
- view.go:36 type Page struct
- view.go:55 type Sequence struct
+13 -16
View File
@@ -12,32 +12,29 @@ import (
"time"
)
// Interval is how often the site root is looked at, and Settle is how long it must hold still afterwards.
// Changed is called once per settled change, with the reason for the log.
type Changed func()
// Watch looks at fsys every `every` until stop is closed, calling onChange once a change has held still for
// `settle`.
//
// 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.
//
// Both durations are parameters rather than package variables, because three callers want different ones: an
// operator setting `-poll`, `-dev on` wanting a faster one, and a test that must not wait out two-second polls
// (ADR-0056).
//
// 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) {
func Watch(fsys fs.FS, every, settle time.Duration, stop <-chan struct{}, onChange Changed) {
previous := Fingerprint(fsys)
pending := ""
ticker := time.NewTicker(Interval)
ticker := time.NewTicker(every)
defer ticker.Stop()
settling := time.NewTimer(Settle)
settling := time.NewTimer(settle)
settling.Stop()
for {
select {
@@ -53,7 +50,7 @@ func Watch(fsys fs.FS, stop <-chan struct{}, onChange Changed) {
default:
// Something changed, or changed again — restart the settle window.
pending = current
settling.Reset(Settle)
settling.Reset(settle)
}
case <-settling.C:
if pending == "" || pending == previous {
+6 -6
View File
@@ -80,20 +80,20 @@ func TestWatchFiresOnceAfterAChangeSettles(t *testing.T) {
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 }()
// Durations are arguments, so this no longer mutates package state a parallel test could read (ADR-0056).
const every, settle = 20 * time.Millisecond, 40 * time.Millisecond
fsys := os.DirFS(dir)
stop := make(chan struct{})
defer close(stop)
changes := make(chan struct{}, 8)
go Watch(fsys, stop, func() { changes <- struct{}{} })
go Watch(fsys, every, settle, 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):
case <-time.After(every + settle):
}
// Several writes in quick succession are one change, not three: that is what the settle window is for.
@@ -102,13 +102,13 @@ func TestWatchFiresOnceAfterAChangeSettles(t *testing.T) {
write("one.md", "fourth")
select {
case <-changes:
case <-time.After(4 * (Interval + Settle)):
case <-time.After(4 * (every + 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):
case <-time.After(2 * every):
}
}
+7 -26
View File
@@ -39,12 +39,8 @@ type Renderer struct {
// sections reports the site's sections when asked. A callback, because sections change when content does and
// the renderer must not hold a stale copy (ADR-0049).
sections func() []string
// reload reparses the theme before each render, for `-dev`: an edit should appear on the next request rather
// than at the next poll. Off in a serving build, where the rebuild does the swapping.
reload bool
// siteFS and extend are kept only so a reparse can rebuild what New built.
// siteFS is kept only so Refresh can reparse what New parsed.
siteFS fs.FS
extend func(Partial) []goldmark.Extender
}
// parsedTheme is one snapshot of the theme: the sets a request executes, and the stylesheet the shell inlines.
@@ -130,7 +126,7 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar
if err != nil {
return nil, err
}
r := &Renderer{files: siteFS, settings: settings, siteFS: siteFS, extend: extend}
r := &Renderer{files: siteFS, settings: settings, siteFS: siteFS}
r.theme.Store(theme)
// The typographer smooths quotes, dashes and ellipses in authored prose and leaves code spans alone,
// because it works on the parsed tree rather than the text. That is the only change the engine makes to
@@ -206,13 +202,14 @@ func (r *Renderer) absolute(path string) string {
// sections exist. A callback rather than a slice, because content changes and a copy would go stale.
func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
// Reload makes every render reparse the theme first. For `-dev` only: it trades waiting for the next poll
// for the cost of a parse per request.
func (r *Renderer) Reload() { r.reload = true }
// Refresh reparses the theme and swaps it in, so a running server picks up an edited template the same way it
// picks up edited content (ADR-0055). Called once per rebuild, off the request path.
//
// This is the *only* way the theme changes, and that is the point (ADR-0056): every page the site serves after
// a swap was rendered from the same snapshot, so a template edit can never leave one page updated and its
// neighbour stale. Reparsing inside a render method could not promise that — two requests in flight would
// disagree, and `Partial` runs *during* a page's Markdown conversion, so even one page could mix two themes.
//
// A failure leaves the working theme in place and returns the error: a template with a typo in it must not
// replace a good set with a broken one, because the site would then serve nothing at all.
func (r *Renderer) Refresh() error {
@@ -224,16 +221,6 @@ func (r *Renderer) Refresh() error {
return nil
}
// fresh reparses before a render when `-dev` asked for it, and is what makes an edit visible on the next
// request rather than at the next poll. The Markdown converter is not rebuilt: its extenders close over
// Partial, which reads whatever theme is current, so template text never reaches goldmark's configuration.
func (r *Renderer) fresh() error {
if !r.reload {
return nil
}
return r.Refresh()
}
// Partial renders one named fragment. A missing template is an error the caller degrades on, never a
// failed request (extensions.md rule 5).
func (r *Renderer) Partial(name string, data Fragment) ([]byte, error) {
@@ -296,9 +283,6 @@ func readStyle(siteFS fs.FS) (template.CSS, error) {
// (ADR-0046). A file it cannot render still arrives with a RawURL, because "cannot show it inline" is not
// "cannot offer it".
func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error) {
if err := r.fresh(); err != nil {
return nil, err
}
title := b.Title
if title == "" {
title = b.Key
@@ -375,9 +359,6 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se
// Listing renders one page of a Query result for a section.
func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error) {
if err := r.fresh(); err != nil {
return nil, err
}
// The root has no section to name itself after, so it borrows the site's title, or says what it is.
title := section
if title == "" {
+54
View File
@@ -78,6 +78,60 @@ func TestSiteOverridesOneBlockAndInheritsTheRest(t *testing.T) {
}
}
// The site is never half-stale: every render method reads the theme snapshot that was current when it started,
// and only Refresh replaces it (ADR-0056). Before that, two of the four reparsed on their own, so a listing
// could serve a new template while a bundle served the old one — and a tag listing reparsed never.
func TestEveryRenderMethodServesOneThemeSnapshot(t *testing.T) {
mark := func(v string) []byte {
return []byte(`{{define "main"}}<section>` + v + `</section>{{end}}`)
}
siteFS := fstest.MapFS{
"templates/page.html": {Data: mark("V1")},
"templates/list.html": {Data: mark("V1")},
"templates/extras.html": {Data: mark("V1")},
}
r, err := New(siteFS, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, err := content.Parse("posts/essay.md", []byte("---\ntitle: Essay\ntags: [monsoon]\n---\nbody\n"))
if err != nil {
t.Fatal(err)
}
b.Route = b.Key
// One call per render method the theme reaches. Any of them reparsing on its own is what makes a site
// half-stale, so they are checked together or not at all.
paths := map[string]func() ([]byte, error){
"Bundle": func() ([]byte, error) { return r.Bundle(b, "en", []string{"en"}, nil) },
"Listing": func() ([]byte, error) { return r.Listing("posts", "en", []content.Bundle{b}, 1) },
"Tag": func() ([]byte, error) { return r.Tag("", "monsoon", "en", []content.Bundle{b}, 1) },
"Extras": func() ([]byte, error) { return r.Extras(b, "en", nil, nil) },
}
assertAll := func(want, when string) {
t.Helper()
for name, render := range paths {
out, err := render()
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if !strings.Contains(string(out), "<section>"+want+"</section>") {
t.Errorf("%s served the wrong theme %s — want %s:\n%s", name, when, want, out)
}
}
}
assertAll("V1", "before any edit")
for _, name := range []string{"templates/page.html", "templates/list.html", "templates/extras.html"} {
siteFS[name] = &fstest.MapFile{Data: mark("V2")}
}
assertAll("V1", "after an edit but before a Refresh")
if err := r.Refresh(); err != nil {
t.Fatal(err)
}
assertAll("V2", "after a Refresh")
}
// Refresh is what a rebuild calls, so an edited template takes effect without a restart (ADR-0055). Before it
// existed, the watcher noticed a template edit and the rebuild it fired changed nothing.
func TestRefreshSwapsAnEditedTemplateInAndKeepsTheWorkingOneOnAnError(t *testing.T) {