apply a template edit without a restart

The watcher fingerprinted templates/ but a rebuild only re-scanned content, so
editing a template fired a rebuild that changed nothing. ADR-0022 already
promised the opposite — "a template edit in the site root invalidates through
the same path as content" — which makes this a defect against a recorded
decision rather than a missing feature. ADR-0055 records the fix and supersedes
ADR-0048's narrower clause.

The parsed sets and the stylesheet become one parsedTheme behind an
atomic.Pointer, swapped by Refresh once per rebuild instead of per request. A
parse failure keeps the theme that was working, so a typo cannot take the site
down. The swap also retires the in-place field mutation -dev was doing, which
was a data race with every in-flight render.

site.yaml goes the other way and leaves the fingerprint: the settings are copied
by value into the renderer, the handler, the feeds and the sitemap, so applying
a change to some of them is worse than applying it to none. It is restart-only.

Corrects the Effects counter row while proving it did not move: it still said
startup was the only change signal "until queue 21", but queue 21 shipped as
ADR-0048 and put the derivative pass inside rebuilder, so that has been wrong
since. The row now also answers the question ADR-0055 invites — an in-memory
swap is not an Effect, because it writes no artifact and calls nothing outbound.

Measured on the real binary: a template edit went live in ~2s; a typo logged
"keeping the previous theme" and kept answering 200 with the last good markup; a
site.yaml edit now fires no rebuild at all. core 2766/2800, ext 1030/2000,
34 gates green, 0 warnings.
This commit is contained in:
Claude Opus 5
2026-08-01 10:54:30 +06:00
committed by bdeshi
parent 36194a16d8
commit 633debf743
10 changed files with 246 additions and 109 deletions
+81 -49
View File
@@ -12,6 +12,7 @@ import (
"html/template"
"io/fs"
"path"
"sync/atomic"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
@@ -23,9 +24,32 @@ import (
//go:embed templates
var themeFS embed.FS
// Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never
// per request (conventions.md).
// Renderer holds the parsed theme and the Markdown converter. The theme is parsed once per rebuild and
// swapped whole, never per request (conventions.md, ADR-0055).
type Renderer struct {
// theme is swapped rather than mutated, so a rebuild can replace it while requests are reading it — the
// same reason the index is an atomic.Pointer (ADR-0022, ADR-0055).
theme atomic.Pointer[parsedTheme]
md goldmark.Markdown
// files is the site root, handed to features through Origin. Nil when there is none.
files fs.FS
// settings are the site's declarations, constant for the life of the process: editing site.yaml needs a
// restart, which is why the watcher does not fingerprint it (ADR-0055).
settings content.Settings
// 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 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.
// Never mutated once stored — a reparse builds another and swaps it in (ADR-0055).
type parsedTheme struct {
// Two sets, not one: base plus the block that kind of page defines. A single set would have two
// definitions of "main" fighting, which is why per-type sets are the shape (ADR-0019).
page *template.Template
@@ -34,21 +58,7 @@ type Renderer struct {
partials *template.Template
// extras is the set for a bundle's supporting-file listing.
extras *template.Template
md goldmark.Markdown
style template.CSS
// files is the site root, handed to features through Origin. Nil when there is none.
files fs.FS
// settings are the site's declarations, constant for the life of the process.
settings content.Settings
// 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`: editing a template should not need a restart.
// Off in a serving build, where parsing once is the point (conventions.md).
reload bool
// siteFS and extend are kept only so reload can rebuild what New built.
siteFS fs.FS
extend func(Partial) []goldmark.Extender
}
// Partial renders a named fragment. A feature under internal/ext is handed one of these at wiring time,
@@ -116,6 +126,32 @@ func WithOrigin(pc parser.Context, origin Origin) {
// not import internal/ext — only cmd knows which features a build includes (conventions.md, ADR-0036). It
// may be nil.
func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error) {
theme, err := parseTheme(siteFS)
if err != nil {
return nil, err
}
r := &Renderer{files: siteFS, settings: settings, siteFS: siteFS, extend: extend}
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
// an author's words (ADR-0034), and it is a parser option rather than a render transform, so it does
// not move the transforms counter.
//
// Raw HTML stays disabled — goldmark's default — so the only HTML a page carries comes from a template
// (ADR-0036, invariant 2). Nothing here may enable html.WithUnsafe.
extensions := []goldmark.Extender{extension.Typographer}
if extend != nil {
extensions = append(extensions, extend(r.Partial)...)
}
r.md = goldmark.New(goldmark.WithExtensions(extensions...))
return r, nil
}
// parseTheme parses every set the theme is made of, plus its stylesheet.
//
// Its own function because a running server parses the theme again on every rebuild (ADR-0055): startup and
// reparse must be the same code, or the theme a running site serves drifts from the one a fresh boot would.
func parseTheme(siteFS fs.FS) (*parsedTheme, error) {
page, err := parseSet(siteFS, "templates/base.html", "templates/page.html")
if err != nil {
return nil, fmt.Errorf("bundle templates: %w", err)
@@ -136,21 +172,7 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar
if err != nil {
return nil, err
}
r := &Renderer{page: page, list: list, partials: partials, extras: extras, style: css, files: siteFS,
settings: settings, siteFS: siteFS, extend: extend}
// 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
// an author's words (ADR-0034), and it is a parser option rather than a render transform, so it does
// not move the transforms counter.
//
// Raw HTML stays disabled — goldmark's default — so the only HTML a page carries comes from a template
// (ADR-0036, invariant 2). Nothing here may enable html.WithUnsafe.
extensions := []goldmark.Extender{extension.Typographer}
if extend != nil {
extensions = append(extensions, extend(r.Partial)...)
}
r.md = goldmark.New(goldmark.WithExtensions(extensions...))
return r, nil
return &parsedTheme{page: page, list: list, partials: partials, extras: extras, style: css}, nil
}
// head builds the document shell every kind of page shares.
@@ -162,7 +184,7 @@ func (r *Renderer) head(title, lang, canonical string) head {
Title: title,
Lang: lang,
Canonical: r.absolute(canonical),
Style: r.style,
Style: r.theme.Load().style,
Site: r.settings,
}
if r.sections != nil {
@@ -184,33 +206,43 @@ 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 the parse-once rule for the
// ability to edit a template and refresh.
// 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 }
// fresh reparses the theme when reloading, and reports a failure without disturbing the working renderer — a
// template with a typo in it should show an error page, not replace a good set with a broken one.
// 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.
//
// 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 {
theme, err := parseTheme(r.siteFS)
if err != nil {
return err
}
r.theme.Store(theme)
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
}
next, err := New(r.siteFS, r.settings, r.extend)
if err != nil {
return err
}
r.page, r.list, r.partials, r.extras = next.page, next.list, next.partials, next.extras
r.style, r.md = next.style, next.md
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) {
if r.partials.Lookup(name) == nil {
partials := r.theme.Load().partials
if partials.Lookup(name) == nil {
return nil, fmt.Errorf("no template named %q", name)
}
var out bytes.Buffer
if err := r.partials.ExecuteTemplate(&out, name, data); err != nil {
if err := partials.ExecuteTemplate(&out, name, data); err != nil {
return nil, fmt.Errorf("partial %s: %w", name, err)
}
return out.Bytes(), nil
@@ -280,7 +312,7 @@ func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Ent
x.Selected = selected
x.head.Canonical = r.absolute(content.ExtrasURL(b.Route, served, selected.Path))
}
return r.execute(r.extras, x, b.Key+"/"+content.ExtrasDir)
return r.execute(r.theme.Load().extras, x, b.Key+"/"+content.ExtrasDir)
}
// RenderText converts a markdown or plain-text file for display inside an extras listing.
@@ -338,7 +370,7 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se
p.ExtrasURL = content.ExtrasURL(b.Route, served, "")
}
}
return r.execute(r.page, p, b.Key)
return r.execute(r.theme.Load().page, p, b.Key)
}
// Listing renders one page of a Query result for a section.
@@ -358,7 +390,7 @@ func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int)
for _, b := range window {
l.Items = append(l.Items, r.item(b, lang))
}
return r.execute(r.list, l, section)
return r.execute(r.theme.Load().list, l, section)
}
// Tag renders one page of a tag listing, grouped by section.
@@ -384,7 +416,7 @@ func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page in
}
l.Groups = append(l.Groups, Group{Name: item.Section, Items: []Item{item}})
}
return r.execute(r.list, l, "tag "+slug)
return r.execute(r.theme.Load().list, l, "tag "+slug)
}
// sequence builds the series view for a page: its members, and the neighbours around this page.
+48
View File
@@ -78,6 +78,54 @@ func TestSiteOverridesOneBlockAndInheritsTheRest(t *testing.T) {
}
}
// 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) {
siteFS := fstest.MapFS{
"templates/page.html": {Data: []byte(`{{define "main"}}<section>first</section>{{end}}`)},
}
r, err := New(siteFS, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, err := content.Parse("pages/about.md", []byte("---\ntitle: About\n---\nbody\n"))
if err != nil {
t.Fatal(err)
}
rendered := func() string {
t.Helper()
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
return string(out)
}
if got := rendered(); !strings.Contains(got, "<section>first</section>") {
t.Fatalf("the site override should be in use before any edit:\n%s", got)
}
siteFS["templates/page.html"] = &fstest.MapFile{Data: []byte(`{{define "main"}}<section>second</section>{{end}}`)}
if got := rendered(); !strings.Contains(got, "<section>first</section>") {
t.Error("an edit on disk must not reach a render on its own: parsing stays off the request path")
}
if err := r.Refresh(); err != nil {
t.Fatal(err)
}
got := rendered()
if !strings.Contains(got, "<section>second</section>") || strings.Contains(got, "<section>first</section>") {
t.Errorf("Refresh should have swapped the edited template in:\n%s", got)
}
// A typo must not cost the site its working theme, because the alternative is serving nothing at all.
siteFS["templates/page.html"] = &fstest.MapFile{Data: []byte(`{{define "main"}}{{end`)}
if err := r.Refresh(); err == nil {
t.Fatal("a malformed template must be reported, not stored")
}
if got := rendered(); !strings.Contains(got, "<section>second</section>") {
t.Errorf("the last good theme should still be serving:\n%s", got)
}
}
func TestAListingOverrideDoesNotLeakIntoBundlePages(t *testing.T) {
siteFS := fstest.MapFS{
"templates/list.html": {Data: []byte(`{{define "main"}}LISTING ONLY{{end}}`)},