diff --git a/cmd/khosra/main.go b/cmd/khosra/main.go index a4bf4f7..7c9ddd3 100644 --- a/cmd/khosra/main.go +++ b/cmd/khosra/main.go @@ -30,13 +30,13 @@ func main() { if err != nil { fatal("cannot read content", err) } - renderer, err := render.New() + renderer, err := render.New(fsys) if err != nil { - fatal("cannot prepare the reference theme", err) + fatal("cannot prepare the theme", err) } slog.Info("serving", "site", *site, "bundles", len(bundles), "addr", *addr) - if err := http.ListenAndServe(*addr, web.Handler(content.NewSite(bundles), renderer)); err != nil { + if err := http.ListenAndServe(*addr, web.Handler(content.NewSite(bundles), renderer, fsys)); err != nil { fatal("server stopped", err) } } diff --git a/docs/state.md b/docs/state.md index 867c590..838f4a6 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,6 +1,6 @@ # State -**Verified against:** `2b6cdc7` on 2026-07-30 — update this line every change. +**Verified against:** `85a4906` on 2026-07-30 — update this line every change. If this file disagrees with the code, the code is right and this file is a bug. ## Inventory @@ -9,10 +9,10 @@ If this file disagrees with the code, the code is right and this file is a bug. |---|---|---| | `go.mod` | module `khosra`; `x/text`, `yaml.v3` direct | 8 | | `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop, key index, language fallback, alias index, URL building | 358 | -| `internal/render/render.go` | goldmark + the embedded reference theme; `Page` is what templates receive | 92 | -| `internal/render/templates/` | reference theme: `base.html`, `theme.css` (ADR-0026) | — | +| `internal/render/render.go` | goldmark, per-kind template sets with site override, `Page`/`List`/`head` | 214 | +| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `theme.css` (ADR-0026) | — | | `internal/web/resolve.go` | URL → (key, lang) or a canonical redirect: language prefix, `/en/…` fork guard, trailing slash | 56 | -| `internal/web/web.go` | handler: resolve, look up with fallback, render, degrade on failure | 62 | +| `internal/web/web.go` | handler: resolve, look up with fallback, listings, `/static/`, degrade on failure | 62 | | `cmd/khosra/main.go` | flags, wiring, startup — the only place things are assembled | 50 | | `*_test.go` | table-driven; symlink escape, permalink, redirect, 404 | 245 | diff --git a/docs/theme-contract.md b/docs/theme-contract.md index e3d42cf..ba39cb6 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -95,6 +95,19 @@ It demonstrates the contract; it is not the contract. Changing it does not chang — which is why `verify.sh` also fails when it changes without this document changing, since in practice the two drift together. +## Overriding it + +A site root's `templates/` is parsed **after** the embedded set, and the last definition of a name wins, so +a theme redefines one named block and inherits the document (ADR-0019). Per kind, exactly two files are +overlaid — `base.html` and that kind's block file (`page.html` or `list.html`). Overlaying every site +template into every set would let a listing's `main` leak into bundle pages, which is the collision +per-kind sets exist to prevent. + +`templates/theme.css` in the site root replaces the reference stylesheet entirely; there is no merging. + +`static/` in the site root is served verbatim under `/static/`. Directory paths answer 404 rather than +listing their contents. + ## Splitting a request When a feature spans engine and theme, this repo delivers: diff --git a/internal/render/render.go b/internal/render/render.go index 8400ba8..99e219a 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -10,6 +10,7 @@ import ( "embed" "fmt" "html/template" + "io/fs" "time" "github.com/yuin/goldmark" @@ -83,24 +84,64 @@ type Renderer struct { style template.CSS } -// New parses the reference theme and prepares the Markdown converter. +// New parses the theme and prepares the Markdown converter. // -// A malformed embedded template is a programming error caught at startup, not at request time, so this -// returns an error and the caller is expected to treat it as fatal. -func New() (*Renderer, error) { - page, err := template.ParseFS(themeFS, "templates/base.html", "templates/page.html") +// siteFS may be nil, in which case only the embedded reference theme is used. A malformed template is a +// startup failure rather than a request-time one, so this returns an error the caller treats as fatal. +func New(siteFS fs.FS) (*Renderer, error) { + page, err := parseSet(siteFS, "templates/page.html") if err != nil { - return nil, fmt.Errorf("parse bundle templates: %w", err) + return nil, fmt.Errorf("bundle templates: %w", err) } - list, err := template.ParseFS(themeFS, "templates/base.html", "templates/list.html") + list, err := parseSet(siteFS, "templates/list.html") if err != nil { - return nil, fmt.Errorf("parse listing templates: %w", err) + return nil, fmt.Errorf("listing templates: %w", err) } - css, err := themeFS.ReadFile("templates/theme.css") + css, err := readStyle(siteFS) if err != nil { - return nil, fmt.Errorf("read reference stylesheet: %w", err) + return nil, err } - return &Renderer{page: page, list: list, md: goldmark.New(), style: template.CSS(css)}, nil + return &Renderer{page: page, list: list, md: goldmark.New(), style: css}, nil +} + +// parseSet builds one kind of page: the embedded base and block, then the site's versions of exactly +// those two files parsed after them. +// +// Parse order is the whole mechanism — the last definition of a name wins — so a site redefines one +// named block and inherits the rest (ADR-0019). Only the same two names are overlaid: overlaying every +// site template into every set would let a listing's "main" leak into bundle pages, which is the +// collision per-kind sets exist to prevent. +func parseSet(siteFS fs.FS, kind string) (*template.Template, error) { + set, err := template.ParseFS(themeFS, "templates/base.html", kind) + if err != nil { + return nil, fmt.Errorf("parse embedded: %w", err) + } + if siteFS == nil { + return set, nil + } + for _, name := range []string{"templates/base.html", kind} { + if _, err := fs.Stat(siteFS, name); err != nil { + continue + } + if set, err = set.ParseFS(siteFS, name); err != nil { + return nil, fmt.Errorf("parse site override %s: %w", name, err) + } + } + return set, nil +} + +// readStyle prefers the site's stylesheet and falls back to the reference one. +func readStyle(siteFS fs.FS) (template.CSS, error) { + if siteFS != nil { + if data, err := fs.ReadFile(siteFS, "templates/theme.css"); err == nil { + return template.CSS(data), nil + } + } + data, err := themeFS.ReadFile("templates/theme.css") + if err != nil { + return "", fmt.Errorf("read reference stylesheet: %w", err) + } + return template.CSS(data), nil } // Bundle renders one bundle into a complete page. diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 14d9ec8..ef9829a 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -3,12 +3,13 @@ package render import ( "strings" "testing" + "testing/fstest" "khosra/internal/content" ) func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) { - r, err := New() + r, err := New(nil) if err != nil { t.Fatal(err) } @@ -32,7 +33,7 @@ func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) { } func TestBundleWithoutTitleFallsBackToKey(t *testing.T) { - r, err := New() + r, err := New(nil) if err != nil { t.Fatal(err) } @@ -48,3 +49,68 @@ func TestBundleWithoutTitleFallsBackToKey(t *testing.T) { t.Errorf("a titleless bundle must still produce a title element:\n%s", out) } } + +func TestSiteOverridesOneBlockAndInheritsTheRest(t *testing.T) { + siteFS := fstest.MapFS{ + "templates/page.html": {Data: []byte(`{{define "main"}}
{{.Title}}
{{end}}`)}, + } + r, err := New(siteFS) + 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) + } + out, err := r.Bundle(b, "en", []string{"en"}) + if err != nil { + t.Fatal(err) + } + got := string(out) + if !strings.Contains(got, `
About
`) { + t.Errorf("the site override should win:\n%s", got) + } + if !strings.Contains(got, "") || !strings.Contains(got, `") { + t.Error("the embedded main should have been replaced, not appended") + } +} + +func TestAListingOverrideDoesNotLeakIntoBundlePages(t *testing.T) { + siteFS := fstest.MapFS{ + "templates/list.html": {Data: []byte(`{{define "main"}}LISTING ONLY{{end}}`)}, + } + r, err := New(siteFS) + 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) + } + out, err := r.Bundle(b, "en", nil) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), "LISTING ONLY") { + t.Error("a listing override must not reach bundle pages — that is why sets are per kind") + } +} + +func TestSiteStylesheetReplacesTheReferenceOne(t *testing.T) { + siteFS := fstest.MapFS{"templates/theme.css": {Data: []byte("body{color:rebeccapurple}")}} + r, err := New(siteFS) + if err != nil { + t.Fatal(err) + } + b, _ := content.Parse("pages/x.md", []byte("hi\n")) + out, err := r.Bundle(b, "en", nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "rebeccapurple") { + t.Error("the site stylesheet should replace the reference one") + } +} diff --git a/internal/web/web.go b/internal/web/web.go index 04d7edd..37c2092 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -3,6 +3,7 @@ package web import ( + "io/fs" "log/slog" "net/http" "strings" @@ -14,14 +15,38 @@ import ( // Handler serves a site. // // One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve. -func Handler(site *content.Site, r *render.Renderer) http.Handler { +func Handler(site *content.Site, r *render.Renderer, siteFS fs.FS) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) { serve(w, req, site, r) }) + if siteFS != nil { + mux.Handle("GET /static/", http.StripPrefix("/static/", noListing(staticFS(siteFS)))) + } return mux } +// staticFS serves the site root's static/ directory verbatim. It keeps the os.Root guarantee, because +// the fs.FS it is given is the one rooted there (ADR-0031). +func staticFS(siteFS fs.FS) http.Handler { + sub, err := fs.Sub(siteFS, "static") + if err != nil { + return http.NotFoundHandler() + } + return http.FileServerFS(sub) +} + +// noListing refuses directory paths, so static/ never answers with an index of its own contents. +func noListing(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "" || strings.HasSuffix(req.URL.Path, "/") { + http.NotFound(w, req) + return + } + h.ServeHTTP(w, req) + }) +} + // serveListing answers a section index, reporting whether it handled the request. // // A section is not a bundle, so this runs only after the bundle lookup misses. A page number past the diff --git a/internal/web/web_test.go b/internal/web/web_test.go index be9e114..b1fbc3c 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -22,11 +22,11 @@ func testHandler(t *testing.T) http.Handler { if err != nil { t.Fatal(err) } - r, err := render.New() + r, err := render.New(nil) if err != nil { t.Fatal(err) } - return Handler(content.NewSite(bundles), r) + return Handler(content.NewSite(bundles), r, fsys) } func TestServeBundleAtItsPermalink(t *testing.T) { @@ -82,11 +82,11 @@ func multilingualHandler(t *testing.T) http.Handler { if err != nil { t.Fatal(err) } - r, err := render.New() + r, err := render.New(nil) if err != nil { t.Fatal(err) } - return Handler(content.NewSite(bundles), r) + return Handler(content.NewSite(bundles), r, fsys) } func TestPrefixedLanguageServesThatVariant(t *testing.T) { @@ -173,11 +173,11 @@ func aliasHandler(t *testing.T) http.Handler { if err != nil { t.Fatal(err) } - r, err := render.New() + r, err := render.New(nil) if err != nil { t.Fatal(err) } - return Handler(content.NewSite(bundles), r) + return Handler(content.NewSite(bundles), r, fsys) } func TestAliasRedirectsToCanonical(t *testing.T) { @@ -220,11 +220,11 @@ func listingHandler(t *testing.T, n int) http.Handler { if err != nil { t.Fatal(err) } - r, err := render.New() + r, err := render.New(nil) if err != nil { t.Fatal(err) } - return Handler(content.NewSite(bundles), r) + return Handler(content.NewSite(bundles), r, fsys) } func TestSectionIndexListsNewestFirst(t *testing.T) { @@ -289,3 +289,33 @@ func TestPagePastTheEndIs404(t *testing.T) { t.Errorf("got %d, want 404: an empty page is a URL that means nothing", rec.Code) } } + +func TestStaticFilesAreServedAndDirectoriesAreNot(t *testing.T) { + fsys := fstest.MapFS{ + "content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nx\n")}, + "static/style.css": {Data: []byte("body{}")}, + "static/img/logo.svg": {Data: []byte("")}, + } + bundles, err := content.Scan(fsys) + if err != nil { + t.Fatal(err) + } + r, err := render.New(fsys) + if err != nil { + t.Fatal(err) + } + h := Handler(content.NewSite(bundles), r, fsys) + for path, want := range map[string]int{ + "/static/style.css": http.StatusOK, + "/static/img/logo.svg": http.StatusOK, + "/static/": http.StatusNotFound, + "/static/img/": http.StatusNotFound, + "/static/nope.css": http.StatusNotFound, + } { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != want { + t.Errorf("GET %s = %d, want %d", path, rec.Code, want) + } + } +}