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:
+12
-4
@@ -79,7 +79,7 @@ func runServe() {
|
|||||||
// One atomic pointer, swapped whole: a request reads the index that was current when it arrived, never one
|
// One atomic pointer, swapped whole: a request reads the index that was current when it arrived, never one
|
||||||
// being rebuilt underneath it (ADR-0022).
|
// being rebuilt underneath it (ADR-0022).
|
||||||
var live atomic.Pointer[content.Site]
|
var live atomic.Pointer[content.Site]
|
||||||
rebuild := rebuilder(fsys, *cache, *dev == "on", &live)
|
rebuild := rebuilder(fsys, *cache, *dev == "on", &live, renderer)
|
||||||
count := rebuild()
|
count := rebuild()
|
||||||
if count < 0 {
|
if count < 0 {
|
||||||
fatal("cannot read content", nil)
|
fatal("cannot read content", nil)
|
||||||
@@ -101,12 +101,20 @@ func runServe() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebuilder returns the function that reads the content, makes any missing derivatives, and swaps the index in.
|
// rebuilder returns the function that reparses the theme, reads the content, makes any missing derivatives, and
|
||||||
|
// swaps both in.
|
||||||
//
|
//
|
||||||
// One function used at startup and again on every change, so the running site is always assembled the same way
|
// 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.
|
// as a fresh one — a reload path that differs from the startup path is a reload path that drifts. The theme is
|
||||||
func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site]) func() int {
|
// 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 {
|
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.
|
||||||
|
if err := renderer.Refresh(); err != nil {
|
||||||
|
slog.Error("keeping the previous theme: cannot parse the new one", "err", err)
|
||||||
|
}
|
||||||
bundles, err := content.Scan(fsys)
|
bundles, err := content.Scan(fsys)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("keeping the previous content: cannot read the site root", "err", err)
|
slog.Error("keeping the previous content: cannot read the site root", "err", err)
|
||||||
|
|||||||
@@ -389,8 +389,14 @@ temporaries, and the number vim writes to test a directory. Saving a file is oft
|
|||||||
change has to hold still for a moment before it counts.
|
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
|
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
|
half-rebuilt one. The theme is swapped the same way in the same rebuild, so an edited template takes effect
|
||||||
without a restart: templates are parsed once unless `-dev on` says otherwise.
|
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.
|
||||||
|
|
||||||
|
`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
|
||||||
|
updated some of them would be worse than one that updates none (ADR-0055). Editing it needs a restart.
|
||||||
|
|
||||||
The engine notices changes. It never fetches them — pulling a git repository is the operator's business, not
|
The engine notices changes. It never fetches them — pulling a git repository is the operator's business, not
|
||||||
the engine's.
|
the engine's.
|
||||||
|
|||||||
+2
-1
@@ -87,7 +87,8 @@ sentence, and a gate whose cheapest satisfaction is noise buys noise.
|
|||||||
## Performance
|
## Performance
|
||||||
Correct and small first; fast where measured. The render path is the only hot path.
|
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.
|
- Write to `io.Writer`, never build pages by string concatenation.
|
||||||
- Parse templates once at startup; never per request.
|
- 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.
|
||||||
- No `sync.Pool`, caching layer, or goroutines in the render path until a benchmark justifies it and
|
- No `sync.Pool`, caching layer, or goroutines in the render path until a benchmark justifies it and
|
||||||
the number goes in the commit message.
|
the number goes in the commit message.
|
||||||
- No reflection in the hot path. `Extra` lookups are map reads, not reflection.
|
- No reflection in the hot path. `Extra` lookups are map reads, not reflection.
|
||||||
|
|||||||
+27
-1
@@ -696,7 +696,8 @@ Revisit if: extras need per-file metadata — a caption, an order, a date. Then
|
|||||||
this decision was wrong.
|
this decision was wrong.
|
||||||
|
|
||||||
## ADR-0048 — Change detection lives in `internal/ext`, and a rebuild is an atomic swap
|
## 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)
|
Date: 2026-07-31 · Status: accepted (implements the polling half of ADR-0022); its last clause — what a
|
||||||
|
rebuild applies — is superseded by ADR-0055
|
||||||
Decision: polling lives in `internal/ext/watch`, a feature `cmd` runs in a goroutine, and a settled change calls
|
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
|
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;
|
request reads the site that was current when it arrived. `content/`, `templates/` and `site.yaml` are watched;
|
||||||
@@ -827,3 +828,28 @@ listing. ADR-0030 keeps its original text because `decisions.md` is append-only,
|
|||||||
there sees the old name; this entry is the pointer that makes it resolvable.
|
there sees the old name; this entry is the pointer that makes it resolvable.
|
||||||
Revisit if: the skill is ever published or shared outside this repo, where an unprefixed `feature-loop`
|
Revisit if: the skill is ever published or shared outside this repo, where an unprefixed `feature-loop`
|
||||||
would collide with everyone else's.
|
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)
|
||||||
|
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
|
||||||
|
failure keeps the theme that was working and logs. `site.yaml` goes the other way: it is dropped from
|
||||||
|
`watch.Fingerprint` and applies only at startup.
|
||||||
|
Why: ADR-0022 already promised that "a template edit in the site root invalidates through the same path
|
||||||
|
as content"; the code never did it, so the watcher fired a rebuild that changed nothing — a defect
|
||||||
|
against a recorded decision rather than a missing feature. The theme is swapped rather than reparsed per
|
||||||
|
request because `-dev` already owns the per-request trade and a serving build should not pay it. Settings
|
||||||
|
are the opposite case: they are copied by value into the renderer, the handler, the feeds and the
|
||||||
|
sitemap, so a live read would have to thread through all four, and applying it to some of them is worse
|
||||||
|
than applying it to none — a title that changes on a page but not in its feed is a bug that looks like a
|
||||||
|
feature. Removing it from the fingerprint makes the honest behaviour visible instead of hiding it behind
|
||||||
|
a rebuild that no-ops.
|
||||||
|
Consequence: cheap — theme edits need no restart, the reparse is off the request path, and the atomic
|
||||||
|
swap retires the in-place field mutation `-dev` used, which was a data race with every in-flight render.
|
||||||
|
Startup parses the theme twice, once in `New` and once in the first rebuild, because startup and change
|
||||||
|
share one path and that is worth more than the microseconds. Expensive — `site.yaml` now needs a restart
|
||||||
|
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.
|
||||||
|
|||||||
+10
-7
@@ -1,6 +1,6 @@
|
|||||||
# State
|
# State
|
||||||
|
|
||||||
**Verified against:** `de1ce73` on 2026-07-30 — update this line every change.
|
**Verified against:** `1909a31` 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.
|
If this file disagrees with the code, the code is right and this file is a bug.
|
||||||
|
|
||||||
## Inventory
|
## 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/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/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/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 |
|
| `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/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment`, `Picture`, `Origin` |
|
| `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/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/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/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/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite |
|
||||||
| `internal/ext/watch/` | polls the site root, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048) |
|
| `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/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering |
|
| `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`) |
|
| `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 |
|
| `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/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/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 |
|
| `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 atomic swap a rebuild goes through. `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
|
| `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/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature |
|
| `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 |
|
| `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 |
|
| `*_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 |
|
||||||
@@ -47,8 +47,9 @@ 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
|
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
|
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`
|
`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 content changes by polling and swaps the index
|
scaffolds a draft bundle into one. A running server notices changes under `content/` and `templates/` by
|
||||||
atomically, so an edit appears without a restart (ADR-0022). A draft or
|
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
|
||||||
future-dated bundle is not served at all — nor is any file inside it (ADR-0024) — until `-dev on` reveals it and
|
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.
|
reloads templates per request.
|
||||||
`-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph
|
`-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph
|
||||||
@@ -77,7 +78,7 @@ this change*.
|
|||||||
| Routing cases | 11 | **2** — done | Resolver at `internal/web/resolve.go`: bundle, language prefix, pagination, tag, section-narrowed tag |
|
| Routing cases | 11 | **2** — done | Resolver at `internal/web/resolve.go`: bundle, language prefix, pagination, tag, section-narrowed tag |
|
||||||
| Collection pages | 4 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `Site.Run`. The fourth — a series archive — resolves through `Site.Sequence` instead: membership is structural and the sort ascends, so it shares the index but not the Query |
|
| Collection pages | 4 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `Site.Run`. The fourth — a series archive — resolves through `Site.Sequence` instead: membership is structural and the sort ascends, so it shares the index but not the Query |
|
||||||
| Views — **per-bundle selection only** | 0 | **2** | The View layer `architecture.md` describes: `view:` in frontmatter choosing a presentation, resolved through the cascade. Nothing selects a view yet. *Output formats* are counted separately and are not it: HTML, sitemap XML and Atom are three functions with nothing to share — an interface over them would have one member and no leverage |
|
| Views — **per-bundle selection only** | 0 | **2** | The View layer `architecture.md` describes: `view:` in frontmatter choosing a presentation, resolved through the cascade. Nothing selects a view yet. *Output formats* are counted separately and are not it: HTML, sitemap XML and Atom are three functions with nothing to share — an interface over them would have one member and no leverage |
|
||||||
| Effects | 1 | **2** | Effect runner + trigger wiring (change / schedule / demand). The first is the derivative pass (ADR-0042), called straight from `cmd` at startup — one call needs no runner, and startup is the only change signal until queue 21 |
|
| Effects | 1 | **2** | Effect runner + trigger wiring (change / schedule / demand). The first and only is the derivative pass (ADR-0042), called straight from `cmd` inside `rebuilder`, so it already answers both triggers it will ever need — startup and a settled change (ADR-0048) — and one call needs no runner. Swapping the index or the theme is **not** an Effect: both re-read the site root into memory, writing no artifact and calling nothing outbound (ADR-0055) |
|
||||||
| Extensions | 3 | **3** — due, and the answer is still no | Extension registry (`extensions.md`). It reached 3 once before and went back to 2 when the widows feature was deleted (ADR-0045) — a threshold reached by a feature that should not exist was never a threshold. It is 3 again with `scaffold`, and the note below the table says why a registry still buys nothing |
|
| Extensions | 3 | **3** — due, and the answer is still no | Extension registry (`extensions.md`). It reached 3 once before and went back to 2 when the widows feature was deleted (ADR-0045) — a threshold reached by a feature that should not exist was never a threshold. It is 3 again with `scaffold`, and the note below the table says why a registry still buys nothing |
|
||||||
| Interface implementations | — | **2** | The interface itself |
|
| Interface implementations | — | **2** | The interface itself |
|
||||||
| Non-stdlib dependencies | 4 direct | budget in `scripts/budgets.env` | — |
|
| Non-stdlib dependencies | 4 direct | budget in `scripts/budgets.env` | — |
|
||||||
@@ -107,6 +108,8 @@ 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 |
|
| 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 |
|
| 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 |
|
| 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 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 |
|
| 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
|
## Open questions
|
||||||
|
|||||||
+42
-39
@@ -6,16 +6,16 @@ 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: 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`.
|
file is *for* lives in `state.md`; why it is that way lives in `decisions.md`.
|
||||||
|
|
||||||
## cmd/khosra — 256 lines
|
## cmd/khosra — 264 lines
|
||||||
|
|
||||||
check.go 45 · main.go 150 · new.go 42 · wire.go 19
|
check.go 45 · main.go 158 · new.go 42 · wire.go 19
|
||||||
|
|
||||||
- check.go:16 func runCheck(args []string)
|
- check.go:16 func runCheck(args []string)
|
||||||
- main.go:23 func main()
|
- main.go:23 func main()
|
||||||
- main.go:44 func runServe()
|
- main.go:44 func runServe()
|
||||||
- main.go:108 func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site]) func() int
|
- main.go:110 func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site],
|
||||||
- main.go:133 func defaultCache() string
|
- main.go:141 func defaultCache() string
|
||||||
- main.go:143 func fatal(msg string, err error)
|
- main.go:151 func fatal(msg string, err error)
|
||||||
- new.go:12 func runNew(args []string)
|
- new.go:12 func runNew(args []string)
|
||||||
- wire.go:15 func extenders(partial render.Partial) []goldmark.Extender
|
- wire.go:15 func extenders(partial render.Partial) []goldmark.Extender
|
||||||
|
|
||||||
@@ -162,20 +162,20 @@ 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: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)
|
- shortcodes.go:297 func argument(s string) (key, value, rest string, ok bool)
|
||||||
|
|
||||||
## internal/ext/watch — 137 lines + 103 test
|
## internal/ext/watch — 136 lines + 114 test
|
||||||
|
|
||||||
doc.go 8 · watch.go 129
|
doc.go 8 · watch.go 128
|
||||||
|
|
||||||
- watch.go:23 var
|
- watch.go:23 var
|
||||||
- watch.go:29 type Changed func()
|
- watch.go:29 type Changed func()
|
||||||
- watch.go:35 func Watch(fsys fs.FS, stop <-chan struct{}, onChange Changed)
|
- watch.go:35 func Watch(fsys fs.FS, stop <-chan struct{}, onChange Changed)
|
||||||
- watch.go:75 func Fingerprint(fsys fs.FS) string
|
- watch.go:79 func Fingerprint(fsys fs.FS) string
|
||||||
- watch.go:94 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
|
- watch.go:93 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
|
||||||
- watch.go:117 func dropping(name string) bool
|
- watch.go:116 func dropping(name string) bool
|
||||||
|
|
||||||
## internal/render — 694 lines + 359 test
|
## internal/render — 726 lines + 407 test
|
||||||
|
|
||||||
chrome.go 110 · render.go 454 · view.go 130
|
chrome.go 110 · render.go 486 · view.go 130
|
||||||
|
|
||||||
- chrome.go:19 var chrome = map[string]map[string]string{
|
- chrome.go:19 var chrome = map[string]map[string]string{
|
||||||
- chrome.go:33 var months = map[string][]string{
|
- chrome.go:33 var months = map[string][]string{
|
||||||
@@ -185,33 +185,36 @@ chrome.go 110 · render.go 454 · view.go 130
|
|||||||
- chrome.go:73 func numerals(lang string, n int) string
|
- chrome.go:73 func numerals(lang string, n int) string
|
||||||
- chrome.go:82 func day(lang string, t time.Time) string
|
- chrome.go:82 func day(lang string, t time.Time) string
|
||||||
- chrome.go:98 func localiseDigits(lang, s string) string
|
- chrome.go:98 func localiseDigits(lang, s string) string
|
||||||
- render.go:24 var themeFS embed.FS
|
- render.go:25 var themeFS embed.FS
|
||||||
- render.go:28 type Renderer struct
|
- render.go:29 type Renderer struct
|
||||||
- render.go:56 type Partial func(name string, data Fragment) ([]byte, error)
|
- render.go:52 type parsedTheme struct
|
||||||
- render.go:59 type Fragment struct
|
- render.go:66 type Partial func(name string, data Fragment) ([]byte, error)
|
||||||
- render.go:69 type Picture struct
|
- render.go:69 type Fragment struct
|
||||||
- render.go:86 type Origin struct
|
- render.go:79 type Picture struct
|
||||||
- render.go:95 var originKey = parser.NewContextKey()
|
- render.go:96 type Origin struct
|
||||||
- render.go:98 func OriginFrom(pc parser.Context) (Origin, bool)
|
- render.go:105 var originKey = parser.NewContextKey()
|
||||||
- render.go:105 func WithOrigin(pc parser.Context, origin Origin)
|
- render.go:108 func OriginFrom(pc parser.Context) (Origin, bool)
|
||||||
- render.go:118 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
|
- render.go:115 func WithOrigin(pc parser.Context, origin Origin)
|
||||||
- render.go:160 func (r *Renderer) head(title, lang, canonical string) head
|
- render.go:128 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
|
||||||
- render.go:177 func (r *Renderer) absolute(path string) string
|
- render.go:154 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
|
||||||
- render.go:185 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
|
- render.go:182 func (r *Renderer) head(title, lang, canonical string) head
|
||||||
- render.go:189 func (r *Renderer) Reload() { r.reload = true }
|
- render.go:199 func (r *Renderer) absolute(path string) string
|
||||||
- render.go:193 func (r *Renderer) fresh() error
|
- render.go:207 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
|
||||||
- render.go:208 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
|
- render.go:211 func (r *Renderer) Reload() { r.reload = true }
|
||||||
- render.go:226 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
|
- render.go:218 func (r *Renderer) Refresh() error
|
||||||
- render.go:248 func readStyle(siteFS fs.FS) (template.CSS, error)
|
- render.go:230 func (r *Renderer) fresh() error
|
||||||
- render.go:266 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
|
- render.go:239 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
|
||||||
- render.go:290 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
|
- render.go:258 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
|
||||||
- render.go:308 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
|
- render.go:280 func readStyle(siteFS fs.FS) (template.CSS, error)
|
||||||
- render.go:345 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
|
- render.go:298 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
|
||||||
- render.go:367 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
|
- render.go:322 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
|
||||||
- render.go:394 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
|
- render.go:340 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
|
||||||
- render.go:421 func (r *Renderer) item(b content.Bundle, lang string) Item
|
- render.go:377 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||||
- render.go:426 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
|
- render.go:399 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||||
- render.go:448 func (r *Renderer) execute(set *template.Template, data any, what string) ([]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)
|
||||||
- view.go:16 type head struct
|
- view.go:16 type head struct
|
||||||
- view.go:36 type Page struct
|
- view.go:36 type Page struct
|
||||||
- view.go:55 type Sequence struct
|
- view.go:55 type Sequence struct
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ func Watch(fsys fs.FS, stop <-chan struct{}, onChange Changed) {
|
|||||||
// Names, sizes and modification times — not contents: reading every file to detect a change would cost more
|
// 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
|
// than the rebuild it triggers. Editor droppings are excluded, or saving a file in vim would look like three
|
||||||
// changes and a deletion.
|
// changes and a deletion.
|
||||||
|
//
|
||||||
|
// `content/` and `templates/` only. `site.yaml` is deliberately absent: a rebuild cannot apply it, since the
|
||||||
|
// settings are copied by value into the renderer, the handler, the feeds and the sitemap, and a fingerprint
|
||||||
|
// that fires a rebuild changing nothing is a lie told every two seconds (ADR-0055).
|
||||||
func Fingerprint(fsys fs.FS) string {
|
func Fingerprint(fsys fs.FS) string {
|
||||||
sum := sha256.New()
|
sum := sha256.New()
|
||||||
for _, root := range []string{"content", "templates"} {
|
for _, root := range []string{"content", "templates"} {
|
||||||
@@ -79,11 +83,6 @@ func Fingerprint(fsys fs.FS) string {
|
|||||||
return record(sum, p, d, err)
|
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))
|
return hex.EncodeToString(sum.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ func TestFingerprintChangesOnlyForContent(t *testing.T) {
|
|||||||
"edited a bundle": {"content/posts/one.md": {Data: []byte("xx"), ModTime: time.Unix(200, 0)}},
|
"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)}},
|
"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 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{}
|
next := fstest.MapFS{}
|
||||||
for k, v := range base {
|
for k, v := range base {
|
||||||
@@ -39,6 +38,18 @@ func TestFingerprintChangesOnlyForContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// site.yaml is deliberately unwatched: a rebuild cannot apply it, because the settings are copied by value
|
||||||
|
// into the renderer, the handler, the feeds and the sitemap. Noticing it would fire a rebuild that changes
|
||||||
|
// nothing (ADR-0055), so editing it needs a restart.
|
||||||
|
settings := fstest.MapFS{}
|
||||||
|
for k, v := range base {
|
||||||
|
settings[k] = v
|
||||||
|
}
|
||||||
|
settings["site.yaml"] = &fstest.MapFile{Data: []byte("base: y"), ModTime: time.Unix(200, 0)}
|
||||||
|
if Fingerprint(settings) != before {
|
||||||
|
t.Error("site.yaml changed the fingerprint, so a rebuild will fire and apply nothing")
|
||||||
|
}
|
||||||
|
|
||||||
// Editor droppings are not content: saving in vim writes several of these, and each would look like a change.
|
// Editor droppings are not content: saving in vim writes several of these, and each would look like a change.
|
||||||
noise := fstest.MapFS{}
|
noise := fstest.MapFS{}
|
||||||
for k, v := range base {
|
for k, v := range base {
|
||||||
|
|||||||
+81
-49
@@ -12,6 +12,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"path"
|
"path"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
@@ -23,9 +24,32 @@ import (
|
|||||||
//go:embed templates
|
//go:embed templates
|
||||||
var themeFS embed.FS
|
var themeFS embed.FS
|
||||||
|
|
||||||
// Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never
|
// Renderer holds the parsed theme and the Markdown converter. The theme is parsed once per rebuild and
|
||||||
// per request (conventions.md).
|
// swapped whole, never per request (conventions.md, ADR-0055).
|
||||||
type Renderer struct {
|
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
|
// 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).
|
// definitions of "main" fighting, which is why per-type sets are the shape (ADR-0019).
|
||||||
page *template.Template
|
page *template.Template
|
||||||
@@ -34,21 +58,7 @@ type Renderer struct {
|
|||||||
partials *template.Template
|
partials *template.Template
|
||||||
// extras is the set for a bundle's supporting-file listing.
|
// extras is the set for a bundle's supporting-file listing.
|
||||||
extras *template.Template
|
extras *template.Template
|
||||||
md goldmark.Markdown
|
|
||||||
style template.CSS
|
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,
|
// 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
|
// not import internal/ext — only cmd knows which features a build includes (conventions.md, ADR-0036). It
|
||||||
// may be nil.
|
// may be nil.
|
||||||
func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error) {
|
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")
|
page, err := parseSet(siteFS, "templates/base.html", "templates/page.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bundle templates: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
r := &Renderer{page: page, list: list, partials: partials, extras: extras, style: css, files: siteFS,
|
return &parsedTheme{page: page, list: list, partials: partials, extras: extras, style: css}, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// head builds the document shell every kind of page shares.
|
// 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,
|
Title: title,
|
||||||
Lang: lang,
|
Lang: lang,
|
||||||
Canonical: r.absolute(canonical),
|
Canonical: r.absolute(canonical),
|
||||||
Style: r.style,
|
Style: r.theme.Load().style,
|
||||||
Site: r.settings,
|
Site: r.settings,
|
||||||
}
|
}
|
||||||
if r.sections != nil {
|
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.
|
// 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 }
|
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
|
// Reload makes every render reparse the theme first. For `-dev` only: it trades waiting for the next poll
|
||||||
// ability to edit a template and refresh.
|
// for the cost of a parse per request.
|
||||||
func (r *Renderer) Reload() { r.reload = true }
|
func (r *Renderer) Reload() { r.reload = true }
|
||||||
|
|
||||||
// fresh reparses the theme when reloading, and reports a failure without disturbing the working renderer — a
|
// Refresh reparses the theme and swaps it in, so a running server picks up an edited template the same way it
|
||||||
// template with a typo in it should show an error page, not replace a good set with a broken one.
|
// 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 {
|
func (r *Renderer) fresh() error {
|
||||||
if !r.reload {
|
if !r.reload {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
next, err := New(r.siteFS, r.settings, r.extend)
|
return r.Refresh()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Partial renders one named fragment. A missing template is an error the caller degrades on, never a
|
// Partial renders one named fragment. A missing template is an error the caller degrades on, never a
|
||||||
// failed request (extensions.md rule 5).
|
// failed request (extensions.md rule 5).
|
||||||
func (r *Renderer) Partial(name string, data Fragment) ([]byte, error) {
|
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)
|
return nil, fmt.Errorf("no template named %q", name)
|
||||||
}
|
}
|
||||||
var out bytes.Buffer
|
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 nil, fmt.Errorf("partial %s: %w", name, err)
|
||||||
}
|
}
|
||||||
return out.Bytes(), nil
|
return out.Bytes(), nil
|
||||||
@@ -280,7 +312,7 @@ func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Ent
|
|||||||
x.Selected = selected
|
x.Selected = selected
|
||||||
x.head.Canonical = r.absolute(content.ExtrasURL(b.Route, served, selected.Path))
|
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.
|
// 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, "")
|
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.
|
// 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 {
|
for _, b := range window {
|
||||||
l.Items = append(l.Items, r.item(b, lang))
|
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.
|
// 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}})
|
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.
|
// 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) {
|
func TestAListingOverrideDoesNotLeakIntoBundlePages(t *testing.T) {
|
||||||
siteFS := fstest.MapFS{
|
siteFS := fstest.MapFS{
|
||||||
"templates/list.html": {Data: []byte(`{{define "main"}}LISTING ONLY{{end}}`)},
|
"templates/list.html": {Data: []byte(`{{define "main"}}LISTING ONLY{{end}}`)},
|
||||||
|
|||||||
Reference in New Issue
Block a user