swap the index and the theme as one snapshot
Two stores meant a request landing between them saw a new theme with the previous index: each half coherent, the pair a state that never existed on disk. Microseconds, which is why it waited — it closes now because the fix removes machinery instead of adding it. web.Snapshot holds both behind one atomic.Pointer that a rebuild stores once. A Renderer never changes after New, so Refresh and the atomic inside the renderer are gone; an immutable renderer is the simpler object, and the one place a change is applied is now the one place it is observed. web.Handler lost its renderer argument and twenty test construction sites moved with it. Live reload verified on the real binary through the new path: a template edit appeared and reverted, two rebuilds for two edits. Also answers the session's open question: no subagents for fan-out reads. A verdict arriving without the reading behind it cannot be audited, which is the thing this harness exists to make possible. Latent list: 6.
This commit is contained in:
@@ -47,7 +47,7 @@ func exampleSite(t *testing.T) http.Handler {
|
||||
}
|
||||
site := content.NewSite(bundles)
|
||||
r.Navigation(site.Sections)
|
||||
return web.Handler(web.Fixed(site), r, fsys, nil, settings)
|
||||
return web.Handler(web.Fixed(site, r), fsys, nil, settings)
|
||||
}
|
||||
|
||||
func get(t *testing.T, h http.Handler, path string) (int, string) {
|
||||
|
||||
+16
-20
@@ -17,7 +17,6 @@ import (
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/ext/shortcodes"
|
||||
"khosra/internal/ext/watch"
|
||||
"khosra/internal/render"
|
||||
"khosra/internal/web"
|
||||
)
|
||||
|
||||
@@ -65,10 +64,6 @@ func runServe() {
|
||||
if *base != "" {
|
||||
settings.Base = strings.TrimSuffix(*base, "/")
|
||||
}
|
||||
renderer, err := theme(fsys, settings)
|
||||
if err != nil {
|
||||
fatal("cannot prepare the theme", err)
|
||||
}
|
||||
// Never on by default and never a bare boolean flag: revealing unpublished work is a visibility change, and
|
||||
// it should be impossible to enable by fumbling an argument (ADR-0024).
|
||||
interval := pollInterval(*dev == "on", *poll)
|
||||
@@ -80,22 +75,21 @@ func runServe() {
|
||||
// renderer never holds a stale copy (ADR-0049).
|
||||
// One atomic pointer, swapped whole: a request reads the index that was current when it arrived, never one
|
||||
// being rebuilt underneath it (ADR-0022).
|
||||
var live atomic.Pointer[content.Site]
|
||||
rebuild := rebuilder(fsys, *cache, *dev == "on", &live)
|
||||
var live atomic.Pointer[web.Snapshot]
|
||||
rebuild := rebuilder(fsys, settings, *cache, *dev == "on", &live)
|
||||
count := rebuild()
|
||||
if count < 0 {
|
||||
fatal("cannot read content", nil)
|
||||
fatal("cannot assemble the site", nil)
|
||||
}
|
||||
renderer.Navigation(func() []string { return live.Load().Sections() })
|
||||
|
||||
derivedFS, err := content.OpenSite(*cache)
|
||||
if err != nil {
|
||||
slog.Error("generated files will not be served", "cache", *cache, "err", err)
|
||||
}
|
||||
watching(fsys, interval, renderer, rebuild)
|
||||
watching(fsys, interval, rebuild)
|
||||
|
||||
slog.Info("serving", "site", *site, "bundles", count, "addr", *addr)
|
||||
handler := web.Handler(live.Load, renderer, fsys, derivedFS, settings)
|
||||
handler := web.Handler(live.Load, fsys, derivedFS, settings)
|
||||
if err := http.ListenAndServe(*addr, handler); err != nil {
|
||||
fatal("server stopped", err)
|
||||
}
|
||||
@@ -119,25 +113,26 @@ func pollInterval(dev bool, chosen time.Duration) time.Duration {
|
||||
// `-poll 0` never starts it — what an immutable deployment wants. The theme is reparsed here and nowhere else,
|
||||
// so one swap serves the whole site (ADR-0056); startup does not come through here, because New parsed it
|
||||
// already and both call `parseTheme`.
|
||||
func watching(fsys fs.FS, every time.Duration, renderer *render.Renderer, rebuild func() int) {
|
||||
func watching(fsys fs.FS, every time.Duration, rebuild func() int) {
|
||||
if every <= 0 {
|
||||
slog.Info("not watching for changes: -poll 0")
|
||||
return
|
||||
}
|
||||
go watch.Watch(fsys, every, every/2, nil, func() {
|
||||
if err := renderer.Refresh(); err != nil {
|
||||
slog.Error("keeping the previous theme: cannot parse the new one", "err", err)
|
||||
}
|
||||
rebuild()
|
||||
})
|
||||
go watch.Watch(fsys, every, every/2, nil, func() { rebuild() })
|
||||
}
|
||||
|
||||
// rebuilder returns the function that reads the content, makes any missing derivatives, and swaps the index in.
|
||||
//
|
||||
// One function used at startup and again on every change, so the running site is always assembled the same way
|
||||
// as a fresh one — a reload path that differs from the startup path is a reload path that drifts.
|
||||
func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site]) func() int {
|
||||
func rebuilder(fsys fs.FS, settings content.Settings, cache string, reveal bool,
|
||||
live *atomic.Pointer[web.Snapshot]) func() int {
|
||||
return func() int {
|
||||
renderer, err := theme(fsys, settings)
|
||||
if err != nil {
|
||||
slog.Error("keeping the previous theme: cannot parse the new one", "err", err)
|
||||
return -1
|
||||
}
|
||||
bundles, err := content.Scan(fsys)
|
||||
if err != nil {
|
||||
slog.Error("keeping the previous content: cannot read the site root", "err", err)
|
||||
@@ -147,6 +142,7 @@ func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[conte
|
||||
if reveal {
|
||||
indexed.Reveal()
|
||||
}
|
||||
renderer.Navigation(indexed.Sections)
|
||||
// Derivatives before the swap, so a picture is never referenced before it exists (ADR-0042). A failure is
|
||||
// not fatal: pages still serve the author's originals, which is what the fallback is for.
|
||||
if made, err := shortcodes.Derive(fsys, cache); err != nil {
|
||||
@@ -154,7 +150,7 @@ func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[conte
|
||||
} else if made > 0 {
|
||||
slog.Info("made derivatives", "count", made)
|
||||
}
|
||||
live.Store(indexed)
|
||||
live.Store(&web.Snapshot{Site: indexed, Theme: renderer})
|
||||
return len(bundles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1283,3 +1283,20 @@ something you opt into. Expensive — an unclosed fence in a fragment affects th
|
||||
existing content that relied on per-fragment footnote lists must say `include: embed`.
|
||||
Revisit if: containment turns out to matter more often than composition, which would be evidence the
|
||||
defaults are the wrong way round again.
|
||||
|
||||
## ADR-0077 — The index and the theme are one snapshot
|
||||
Date: 2026-08-02 · Status: accepted (completes ADR-0055 and ADR-0056; retires `Renderer.Refresh`)
|
||||
Decision: `web.Snapshot` holds the site and the renderer that were current together, behind one
|
||||
`atomic.Pointer` that a rebuild stores once. A `Renderer` never changes after `New` — a rebuild builds a new
|
||||
one — so `Refresh` and the atomic inside the renderer are gone. `web.Handler` takes the accessor and no
|
||||
separate renderer.
|
||||
Why: two stores meant a request landing between them saw a new theme with the previous index — each half
|
||||
coherent, the pair a state that never existed on disk. The gap was microseconds, which is why it waited; it
|
||||
closes now because the fix also removes machinery rather than adding it. An immutable renderer is the simpler
|
||||
object: nothing to swap inside it, and the one place a change is applied is the one place a change is
|
||||
observed.
|
||||
Consequence: cheap — one store, one accessor, and two mechanisms fewer than before. A rebuild reparses the
|
||||
theme every time rather than only when templates changed, which is microseconds against a content scan.
|
||||
Expensive — `web.Handler`'s signature changed and twenty test construction sites moved with it, and a
|
||||
`Renderer` can no longer be handed around and updated, which no caller wanted anyway.
|
||||
Revisit if: reparsing the theme on every content change ever shows up in a profile.
|
||||
|
||||
+7
-10
@@ -21,7 +21,7 @@ table owns.
|
||||
| `internal/content/extras.go` | a bundle's supporting files: enumeration, classification, and their URLs (ADR-0047) |
|
||||
| `internal/content/settings.go` | `site.yaml`: the site's own declarations (`base`, `title`) and absolute-URL building (ADR-0039) |
|
||||
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything`, slug routes, publication visibility |
|
||||
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods. The parsed sets plus the stylesheet are one snapshot behind an `atomic.Pointer`; `Refresh` is the only thing that replaces it, so every page serves one theme (ADR-0055, ADR-0056). Heading ids are a parser option set here, declared or derived (ADR-0058, ADR-0066), and this is the one renderer that enables raw HTML (ADR-0060). `Compose` is the seam a merging bundle's splice arrives through |
|
||||
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods. A Renderer never changes after `New`: a rebuild builds a new one and it is swapped with the index as a single `web.Snapshot`, so no page is assembled from two of them (ADR-0055, ADR-0056, ADR-0077). Heading ids are a parser option set here, declared or derived (ADR-0058, ADR-0066), and this is the one renderer that enables raw HTML (ADR-0060). `Compose` is the seam a merging bundle's splice arrives through |
|
||||
| `internal/render/view.go` | the theme contract in Go, and now actually all of it: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Partial`, `Fragment` (with `Body`, `Headings` and `Lang` — ADR-0064, ADR-0065, ADR-0067), `Heading`, `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), including the words a shortcode fragment supplies when the author gives none (ADR-0067) |
|
||||
| `internal/render/templates/` | reference theme, complete (six icon names map to Unicode, no assets — ADR-0063): `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes/` — seven fragment files rather than one, and a site may use either form (ADR-0071) — with the `sizes` its own layout implies (ADR-0068), `theme.css` (ADR-0026, ADR-0049) |
|
||||
@@ -36,8 +36,8 @@ table owns.
|
||||
| `internal/web/asset.go` | files inside a bundle's own directory, looked up through the owning bundle so visibility can only ever inherit (ADR-0024) |
|
||||
| `internal/web/feed.go` | Atom for the site, a section or a tag, from dated bundles via one Query (ADR-0043) |
|
||||
| `internal/web/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) |
|
||||
| `internal/web/web.go` | handler: `serve` dispatches by kind, `serveBundle` answers the commonest one; listings, `/static/`, `/derived/`, degrade on failure |
|
||||
| `cmd/khosra/main.go` | flags (including `-poll`, zero to stop watching), wiring, startup, the derivative pass, and the atomic swaps a change goes through — theme in the watcher's callback, index in `rebuilder`. `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
|
||||
| `internal/web/web.go` | handler: `Snapshot` pairs the index with the theme that was current with it (ADR-0077); `serve` dispatches by kind, `serveBundle` answers the commonest one; listings, `/static/`, `/derived/`, degrade on failure |
|
||||
| `cmd/khosra/main.go` | flags (including `-poll`, zero to stop watching), wiring, startup, the derivative pass, and the one atomic swap a change goes through, theme and index together in `rebuilder` (ADR-0077). `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
|
||||
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature |
|
||||
| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing |
|
||||
| `*_test.go` | table-driven, one file per source file — `shortcodes` has one each for icons, containers and the contents list; 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 — that last one in `cmd/khosra`, beside the wiring it proves (ADR-0072) |
|
||||
@@ -123,18 +123,15 @@ with a stated reason. A list nothing drains is a graveyard of known defects.
|
||||
| A gallery's images carry no `alt` | `width`/`height` now come from the original (ADR-0042), so only alt text is missing, and a filename does not supply one. An empty `alt` is honest for a picture the page has already introduced | Captions per gallery entry — a sidecar or a frontmatter list — if the reference theme ever needs them |
|
||||
| Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | Measured at the same time as the pictures (ADR-0044): a whole page is ~63µs, so this is not what costs anything. Remembering it would be a cache with no measurement behind it | A page render exceeding a few milliseconds, which is also what would revive the parked cache model |
|
||||
| The root listing's `<title>` repeats itself — "A Khosra Demo · A Khosra Demo" | Spotted 2026-08-01 by looking at the served page, not by any test: `base.html` joins page title and site title unconditionally, and at the root those are the same string. Cosmetic, and the fix is one `if` in a template — theme layer, not engine | The next time `base.html` is edited for any reason — its old trigger named queue entry G4, which has been dropped |
|
||||
| The theme and the index are two separate `atomic.Pointer` stores, so a request landing between them sees a new theme with the previous index | Accepted 2026-08-01 with ADR-0056: both halves are internally coherent and the gap is microseconds, so no page is ever internally inconsistent — it is simply not a snapshot of the disk. Closing it means one pointer holding both, which changes `web.Handler`'s signature and 20 test construction sites | Anything that makes the gap observable — a request rate high enough to land in it, or a feature where content and theme must agree exactly (an export, where every page is generated in one pass) |
|
||||
| Under `include: embed`, a fragment's footnotes render where the include sits, so a long one puts an `<hr>` and a numbered list mid-article | Spotted 2026-08-01 by looking at the served page, not by any test. It is ADR-0038's documented consequence, and the ids are correctly namespaced (ADR-0058); only the placement reads badly. Merging is the default since ADR-0076, so this is now something an author opts into by asking for containment | Nothing: it is the documented cost of the model you chose |
|
||||
|
||||
## Open questions
|
||||
|
||||
**Subagents for fan-out reads — policy, not judgment.** Delegating read-heavy sweeps (`/audit`,
|
||||
`/refresh-docs`, `/invariants`, rename sweeps) keeps thousands of lines of file dumps out of the main
|
||||
window and returns only the verdict. Deferred 2026-08-01; the case for and against is written out in
|
||||
`ideas/token-conservation.md`, including where it is clearly right (locating things) and clearly wrong
|
||||
(deciding things). Nothing blocks on it.
|
||||
**Answered 2026-08-02: no subagents.** Read-heavy sweeps stay in the main window. The case for and against
|
||||
is in `ideas/token-conservation.md`; the decision is that a verdict arriving without the reading behind it
|
||||
cannot be audited, which is the thing this harness is built to make possible. Nothing blocks on it.
|
||||
|
||||
Nothing else blocks Arc 1 or the first deploy.
|
||||
Nothing blocks Arc 1 or the first deploy.
|
||||
|
||||
Every feature has been audited against the layer test (ADR-0046). One was wrong and was deleted (widow
|
||||
prevention, ADR-0045); one quietly decided presentation and now offers both shapes (tag listing grouping). The
|
||||
|
||||
+48
-48
@@ -6,18 +6,18 @@ Every top-level declaration in the engine, with its line. Read this before openi
|
||||
file: it answers "where does X live" and "what is in this package" without the bodies. What each
|
||||
file is *for* lives in `state.md`; why it is that way lives in `decisions.md`.
|
||||
|
||||
## cmd/khosra — 309 lines + 292 test
|
||||
## cmd/khosra — 305 lines + 292 test
|
||||
|
||||
check.go 45 · main.go 181 · new.go 42 · wire.go 41
|
||||
check.go 45 · main.go 177 · new.go 42 · wire.go 41
|
||||
|
||||
- check.go:16 func runCheck(args []string)
|
||||
- main.go:24 func main()
|
||||
- main.go:45 func runServe()
|
||||
- main.go:106 func pollInterval(dev bool, chosen time.Duration) time.Duration
|
||||
- main.go:122 func watching(fsys fs.FS, every time.Duration, renderer *render.Renderer, rebuild func() int)
|
||||
- main.go:139 func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site]) func() int
|
||||
- main.go:164 func defaultCache() string
|
||||
- main.go:174 func fatal(msg string, err error)
|
||||
- main.go:23 func main()
|
||||
- main.go:44 func runServe()
|
||||
- main.go:100 func pollInterval(dev bool, chosen time.Duration) time.Duration
|
||||
- main.go:116 func watching(fsys fs.FS, every time.Duration, rebuild func() int)
|
||||
- main.go:128 func rebuilder(fsys fs.FS, settings content.Settings, cache string, reveal bool,
|
||||
- main.go:160 func defaultCache() string
|
||||
- main.go:170 func fatal(msg string, err error)
|
||||
- new.go:12 func runNew(args []string)
|
||||
- wire.go:18 func theme(siteFS fs.FS, settings content.Settings) (*render.Renderer, error)
|
||||
- wire.go:31 func extenders(partial render.Partial) []goldmark.Extender
|
||||
@@ -285,9 +285,9 @@ doc.go 8 · watch.go 125
|
||||
- watch.go:90 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
|
||||
- watch.go:113 func dropping(name string) bool
|
||||
|
||||
## internal/render — 757 lines + 489 test
|
||||
## internal/render — 733 lines + 441 test
|
||||
|
||||
chrome.go 115 · render.go 457 · view.go 185
|
||||
chrome.go 115 · render.go 433 · view.go 185
|
||||
|
||||
- chrome.go:19 var chrome = map[string]map[string]string{
|
||||
- chrome.go:38 var months = map[string][]string{
|
||||
@@ -297,31 +297,30 @@ chrome.go 115 · render.go 457 · view.go 185
|
||||
- chrome.go:78 func numerals(lang string, n int) string
|
||||
- chrome.go:87 func day(lang string, t time.Time) string
|
||||
- chrome.go:103 func localiseDigits(lang, s string) string
|
||||
- render.go:26 var themeFS embed.FS
|
||||
- render.go:30 type Renderer struct
|
||||
- render.go:52 type parsedTheme struct
|
||||
- render.go:65 var originKey = parser.NewContextKey()
|
||||
- render.go:68 func OriginFrom(pc parser.Context) (Origin, bool)
|
||||
- render.go:75 func WithOrigin(pc parser.Context, origin Origin)
|
||||
- render.go:88 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
|
||||
- render.go:119 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
|
||||
- render.go:147 func (r *Renderer) head(title, lang, canonical string) head
|
||||
- render.go:164 func (r *Renderer) absolute(path string) string
|
||||
- render.go:172 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
|
||||
- render.go:178 func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite }
|
||||
- render.go:190 func (r *Renderer) Refresh() error
|
||||
- render.go:201 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
|
||||
- render.go:220 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
|
||||
- render.go:249 func readStyle(siteFS fs.FS) (template.CSS, error)
|
||||
- render.go:267 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
|
||||
- render.go:288 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
|
||||
- render.go:306 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
|
||||
- render.go:351 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||
- render.go:370 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||
- render.go:397 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
|
||||
- render.go:424 func (r *Renderer) item(b content.Bundle, lang string) Item
|
||||
- render.go:429 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
|
||||
- render.go:451 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
|
||||
- render.go:25 var themeFS embed.FS
|
||||
- render.go:29 type Renderer struct
|
||||
- render.go:48 type parsedTheme struct
|
||||
- render.go:61 var originKey = parser.NewContextKey()
|
||||
- render.go:64 func OriginFrom(pc parser.Context) (Origin, bool)
|
||||
- render.go:71 func WithOrigin(pc parser.Context, origin Origin)
|
||||
- render.go:84 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
|
||||
- render.go:114 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
|
||||
- render.go:142 func (r *Renderer) head(title, lang, canonical string) head
|
||||
- render.go:159 func (r *Renderer) absolute(path string) string
|
||||
- render.go:167 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
|
||||
- render.go:173 func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite }
|
||||
- render.go:177 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
|
||||
- render.go:196 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
|
||||
- render.go:225 func readStyle(siteFS fs.FS) (template.CSS, error)
|
||||
- render.go:243 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
|
||||
- render.go:264 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
|
||||
- render.go:282 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
|
||||
- render.go:327 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||
- render.go:346 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||
- render.go:373 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
|
||||
- render.go:400 func (r *Renderer) item(b content.Bundle, lang string) Item
|
||||
- render.go:405 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
|
||||
- render.go:427 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
|
||||
- view.go:17 type head struct
|
||||
- view.go:37 type Page struct
|
||||
- view.go:56 type Sequence struct
|
||||
@@ -337,9 +336,9 @@ chrome.go 115 · render.go 457 · view.go 185
|
||||
- view.go:160 type Picture struct
|
||||
- view.go:177 type Origin struct
|
||||
|
||||
## internal/web — 734 lines + 1423 test
|
||||
## internal/web — 747 lines + 1423 test
|
||||
|
||||
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217
|
||||
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 230
|
||||
|
||||
- asset.go:22 func serveAsset(w http.ResponseWriter, req *http.Request, site *content.Site, siteFS fs.FS, res resolution) bool
|
||||
- discover.go:14 const
|
||||
@@ -364,13 +363,14 @@ asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170
|
||||
- resolve.go:131 func cutFeed(key string) (rest string, ok bool)
|
||||
- resolve.go:142 func cutTag(key string) (tag, section string, ok bool)
|
||||
- resolve.go:154 func cutPage(key string) (rest string, page int, ok bool)
|
||||
- web.go:19 type Current func() *content.Site
|
||||
- web.go:22 func Fixed(site *content.Site) Current { return func() *content.Site { return site } }
|
||||
- web.go:27 func Handler(current Current, r *render.Renderer, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler
|
||||
- web.go:60 func serveStatic(sub fs.FS) http.Handler
|
||||
- web.go:76 func serveListing(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool
|
||||
- web.go:100 func serveTags(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool
|
||||
- web.go:125 func write(w http.ResponseWriter, out []byte, what string)
|
||||
- web.go:134 func writeAs(w http.ResponseWriter, contentType string, out []byte, what string)
|
||||
- web.go:142 func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, siteFS fs.FS, settings content.Settings)
|
||||
- web.go:173 func serveBundle(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
|
||||
- web.go:19 type Snapshot struct
|
||||
- web.go:28 type Current func() *Snapshot
|
||||
- web.go:31 func Fixed(site *content.Site, theme *render.Renderer) Current
|
||||
- web.go:39 func Handler(current Current, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler
|
||||
- web.go:73 func serveStatic(sub fs.FS) http.Handler
|
||||
- web.go:89 func serveListing(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool
|
||||
- web.go:113 func serveTags(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool
|
||||
- web.go:138 func write(w http.ResponseWriter, out []byte, what string)
|
||||
- web.go:147 func writeAs(w http.ResponseWriter, contentType string, out []byte, what string)
|
||||
- web.go:155 func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, siteFS fs.FS, settings content.Settings)
|
||||
- web.go:186 func serveBundle(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
|
||||
|
||||
+11
-35
@@ -12,7 +12,6 @@ import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
@@ -28,9 +27,9 @@ var themeFS embed.FS
|
||||
// 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]
|
||||
// theme never changes after New: a rebuild builds a whole new Renderer and the site and theme are
|
||||
// swapped together as one snapshot, so no page is ever assembled from two of them (ADR-0077).
|
||||
theme *parsedTheme
|
||||
md goldmark.Markdown
|
||||
// files is the site root, handed to features through Origin. Nil when there is none.
|
||||
files fs.FS
|
||||
@@ -40,15 +39,12 @@ 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
|
||||
// siteFS is kept only so Refresh can reparse what New parsed.
|
||||
siteFS fs.FS
|
||||
// compose may rewrite a body before it is parsed, for a bundle that asks its includes to be merged
|
||||
// (ADR-0066). Set at wiring time like sections, and never called otherwise.
|
||||
compose func(src []byte, origin Origin) []byte
|
||||
}
|
||||
|
||||
// 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).
|
||||
// parsedTheme is the sets a request executes, and the stylesheet the shell inlines.
|
||||
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).
|
||||
@@ -90,8 +86,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}
|
||||
r.theme.Store(theme)
|
||||
r := &Renderer{files: siteFS, settings: settings, theme: 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
|
||||
@@ -149,7 +144,7 @@ func (r *Renderer) head(title, lang, canonical string) head {
|
||||
Title: title,
|
||||
Lang: lang,
|
||||
Canonical: r.absolute(canonical),
|
||||
Style: r.theme.Load().style,
|
||||
Style: r.theme.style,
|
||||
Site: r.settings,
|
||||
}
|
||||
if r.sections != nil {
|
||||
@@ -177,29 +172,10 @@ func (r *Renderer) Navigation(sections func() []string) { r.sections = sections
|
||||
// splicing source files together is a feature's work, not the renderer's.
|
||||
func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite }
|
||||
|
||||
// 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 {
|
||||
theme, err := parseTheme(r.siteFS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.theme.Store(theme)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
partials := r.theme.Load().partials
|
||||
partials := r.theme.partials
|
||||
if partials.Lookup(name) == nil {
|
||||
return nil, fmt.Errorf("no template named %q", name)
|
||||
}
|
||||
@@ -278,7 +254,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.theme.Load().extras, x, b.Key+"/"+content.ExtrasDir)
|
||||
return r.execute(r.theme.extras, x, b.Key+"/"+content.ExtrasDir)
|
||||
}
|
||||
|
||||
// RenderText converts a markdown or plain-text file for display inside an extras listing.
|
||||
@@ -344,7 +320,7 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se
|
||||
p.ExtrasURL = content.ExtrasURL(b.Route, served, "")
|
||||
}
|
||||
}
|
||||
return r.execute(r.theme.Load().page, p, b.Key)
|
||||
return r.execute(r.theme.page, p, b.Key)
|
||||
}
|
||||
|
||||
// Listing renders one page of a Query result for a section.
|
||||
@@ -361,7 +337,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.theme.Load().list, l, section)
|
||||
return r.execute(r.theme.list, l, section)
|
||||
}
|
||||
|
||||
// Tag renders one page of a tag listing, grouped by section.
|
||||
@@ -387,7 +363,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.theme.Load().list, l, "tag "+slug)
|
||||
return r.execute(r.theme.list, l, "tag "+slug)
|
||||
}
|
||||
|
||||
// sequence builds the series view for a page: its members, and the neighbours around this page.
|
||||
|
||||
@@ -106,8 +106,8 @@ func TestAuthoredHTMLRenders(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
|
||||
// The site is never half-stale: a Renderer never changes after New, so every render method serves the theme
|
||||
// it was built with (ADR-0056, ADR-0077). 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 {
|
||||
@@ -153,59 +153,11 @@ func TestEveryRenderMethodServesOneThemeSnapshot(t *testing.T) {
|
||||
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 {
|
||||
assertAll("V1", "after an edit: a Renderer never changes once built")
|
||||
if r, err = New(siteFS, content.Settings{}, nil); 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) {
|
||||
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)
|
||||
}
|
||||
assertAll("V2", "after a rebuild")
|
||||
}
|
||||
|
||||
func TestAListingOverrideDoesNotLeakIntoBundlePages(t *testing.T) {
|
||||
|
||||
@@ -38,7 +38,7 @@ func assetHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestABundlesOwnFilesAreServed(t *testing.T) {
|
||||
|
||||
@@ -53,7 +53,7 @@ func benchHandler(b *testing.B, pictures int) http.Handler {
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func serveOnce(b *testing.B, h http.Handler, path string) {
|
||||
|
||||
@@ -29,7 +29,7 @@ func crawlerHandler(t *testing.T, settings content.Settings, extra fstest.MapFS)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
|
||||
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings)
|
||||
}
|
||||
|
||||
func TestSitemapListsEveryVariantAbsolutely(t *testing.T) {
|
||||
|
||||
@@ -32,7 +32,7 @@ func extrasHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestExtrasAreNotBundles(t *testing.T) {
|
||||
|
||||
@@ -30,7 +30,7 @@ func feedHandler(t *testing.T, settings content.Settings) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
|
||||
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings)
|
||||
}
|
||||
|
||||
func fetchFeed(t *testing.T, h http.Handler, path string) (*httptest.ResponseRecorder, atom) {
|
||||
|
||||
@@ -21,7 +21,7 @@ func slugHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestASlugRenamesTheAddressInEveryLanguage(t *testing.T) {
|
||||
@@ -82,7 +82,7 @@ func TestListingsAndSitemapsUseTheSluggedAddress(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
|
||||
h := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/", nil))
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestNothingInsideAnUnpublishedBundleIsServed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hidden := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
hidden := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
|
||||
for path, want := range map[string]int{
|
||||
"/art/draft/": http.StatusNotFound,
|
||||
"/art/draft/one.jpg": http.StatusNotFound,
|
||||
@@ -58,7 +58,7 @@ func TestNothingInsideAnUnpublishedBundleIsServed(t *testing.T) {
|
||||
// Revealing them is the only thing that changes the answer.
|
||||
site := content.NewSite(bundles)
|
||||
site.Reveal()
|
||||
shown := Handler(Fixed(site), r, fsys, nil, content.Settings{})
|
||||
shown := Handler(Fixed(site, r), fsys, nil, content.Settings{})
|
||||
for _, path := range []string{"/art/draft/", "/art/draft/one.jpg", "/art/future/", "/art/future/two.jpg"} {
|
||||
rec := httptest.NewRecorder()
|
||||
shown.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
@@ -82,7 +82,7 @@ func TestUnpublishedBundlesAreAbsentFromEverythingThatLists(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
|
||||
h := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings)
|
||||
for _, path := range []string{"/art/", "/feed.xml", "/sitemap.xml"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
|
||||
+21
-8
@@ -12,22 +12,35 @@ import (
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
// Current returns the site as it is right now.
|
||||
// Snapshot is the content and the theme that were current together.
|
||||
//
|
||||
// A function rather than a pointer, so a background poller can swap what it returns and a request still sees one
|
||||
// coherent index instead of one being rebuilt underneath it (ADR-0022).
|
||||
type Current func() *content.Site
|
||||
// One value rather than two, because a page assembled from a new theme and the previous index is a page that
|
||||
// never existed on disk. A rebuild makes both and swaps them in one store (ADR-0077).
|
||||
type Snapshot struct {
|
||||
Site *content.Site
|
||||
Theme *render.Renderer
|
||||
}
|
||||
|
||||
// Current returns the snapshot as it is right now.
|
||||
//
|
||||
// A function rather than a pointer, so a background poller can swap what it returns and a request still sees
|
||||
// one coherent pair instead of one being rebuilt underneath it (ADR-0022).
|
||||
type Current func() *Snapshot
|
||||
|
||||
// Fixed is a Current for a site that never changes, which is every caller that does not watch for changes.
|
||||
func Fixed(site *content.Site) Current { return func() *content.Site { return site } }
|
||||
func Fixed(site *content.Site, theme *render.Renderer) Current {
|
||||
snap := &Snapshot{Site: site, Theme: theme}
|
||||
return func() *Snapshot { return snap }
|
||||
}
|
||||
|
||||
// Handler serves a site.
|
||||
//
|
||||
// One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
|
||||
func Handler(current Current, r *render.Renderer, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler {
|
||||
func Handler(current Current, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
|
||||
serve(w, req, current(), r, siteFS, settings)
|
||||
now := current()
|
||||
serve(w, req, now.Site, now.Theme, siteFS, settings)
|
||||
})
|
||||
// Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases: no
|
||||
// bundle can own them, since a key always sits under a section.
|
||||
@@ -35,7 +48,7 @@ func Handler(current Current, r *render.Renderer, siteFS, derivedFS fs.FS, setti
|
||||
serveRobots(w, req, siteFS, settings.Base)
|
||||
})
|
||||
mux.HandleFunc("GET "+sitemapPath, func(w http.ResponseWriter, req *http.Request) {
|
||||
serveSitemap(w, req, current(), settings.Base)
|
||||
serveSitemap(w, req, current().Site, settings.Base)
|
||||
})
|
||||
if siteFS != nil {
|
||||
if sub, err := fs.Sub(siteFS, "static"); err == nil {
|
||||
|
||||
@@ -28,7 +28,7 @@ func testHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestServeBundleAtItsPermalink(t *testing.T) {
|
||||
@@ -72,7 +72,7 @@ func TestTheRootListsEverything(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bare := Handler(Fixed(content.NewSite(nil)), empty, nil, nil, content.Settings{})
|
||||
bare := Handler(Fixed(content.NewSite(nil), empty), nil, nil, content.Settings{})
|
||||
rec = httptest.NewRecorder()
|
||||
bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
@@ -106,7 +106,7 @@ func multilingualHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestPrefixedLanguageServesThatVariant(t *testing.T) {
|
||||
@@ -158,7 +158,7 @@ func aliasHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestAliasRedirectsToCanonical(t *testing.T) {
|
||||
@@ -205,7 +205,7 @@ func listingHandler(t *testing.T, n int) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestSectionIndexListsNewestFirst(t *testing.T) {
|
||||
@@ -273,7 +273,7 @@ func TestStaticFilesAreServedAndDirectoriesAreNot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
h := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
|
||||
for path, want := range map[string]int{
|
||||
"/static/style.css": http.StatusOK,
|
||||
"/static/img/logo.svg": http.StatusOK,
|
||||
@@ -314,7 +314,7 @@ func TestAStaticPathThatEscapesTheRootIs404(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler(Fixed(content.NewSite(nil)), r, fsys, nil, content.Settings{})
|
||||
h := Handler(Fixed(content.NewSite(nil), r), fsys, nil, content.Settings{})
|
||||
for path, want := range map[string]int{
|
||||
"/static/ok.css": http.StatusOK,
|
||||
"/static/escape.txt": http.StatusNotFound,
|
||||
@@ -347,7 +347,7 @@ func seriesHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, nil, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles), r), nil, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestSequenceNavigationLinksNeighbours(t *testing.T) {
|
||||
@@ -439,7 +439,7 @@ func tagHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, nil, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles), r), nil, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestGlobalTagListingSpansSectionsGroupedByOne(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user