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:
+81
-49
@@ -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.
|
||||
|
||||
@@ -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}}`)},
|
||||
|
||||
Reference in New Issue
Block a user