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:
@@ -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 == "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user