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:
2026-08-01 02:23:34 +06:00
parent 60a7e10aee
commit 61af326474
7 changed files with 204 additions and 29 deletions
+52 -11
View File
@@ -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.
+68 -2
View File
@@ -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")
}
}