let a site root override the theme, and serve static/
A site's templates/ is parsed after the embedded set, so the last definition of a name wins and a theme redefines one block while inheriting the document (ADR-0019). Per kind exactly two files are overlaid — base.html and that kind's block — because overlaying every site template into every set lets a listing's "main" leak into bundle pages, which is the collision per-kind sets exist to prevent. Both directions are tested. templates/theme.css in the site root replaces the reference stylesheet outright; there is no merging to reason about. static/ is served verbatim under /static/, through the same os.Root-backed fs.FS, and directory paths answer 404 so it never indexes its own contents. Queue entries 6 and 7 are deferred again with triggers: the cascade's consumers are stage toggles, view selection and cache flags — none of which exist — and declared types are read by ordering, feeds and check, none of which have landed. Building either now is the speculation that justified withdrawing them. Evidence: a real site override renders <section class="mine"> inside the embedded document with canonical intact, the listing still uses the embedded block, and /static/site.css and /static/img/logo.svg are 200 while /static/ and /static/img/ are 404.
This commit is contained in:
+3
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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 |
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+52
-11
@@ -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.
|
||||
|
||||
@@ -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"}}<section class="mine">{{.Title}}</section>{{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, `<section class="mine">About</section>`) {
|
||||
t.Errorf("the site override should win:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<!doctype html>") || !strings.Contains(got, `<link rel="canonical"`) {
|
||||
t.Errorf("the document should still come from the embedded base:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "<article>") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -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
|
||||
|
||||
@@ -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("<svg/>")},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user