Files
khosra/internal/render/theme.go
T
bdeshiandClaude Opus 5 30c26bd1ac resolve relative links against the disk, serve them as addresses
Item 2 of the order of work. An author writes `../day-01.en.md` — the path an
editor preview resolves — and the engine emits `/posts/day-01/`.

The larger effect is durability. Resolution goes through key → route, and a slug
moves the route while never moving the key (ADR-0035), so a relative link survives
a rename that a hand-written /posts/a-better-name/ does not. The demo proves it:
`../renamed-thing.en.md` renders as href="/posts/a-better-name/" — the author wrote
the filename and got the slugged address.

This is the engine altering authored markup, which ADR-0045 polices, so the test
that matters is what it declines to touch. Fourteen cases must survive exactly as
written: an absolute URL, a scheme-relative URL, mailto:, tel:, a root-relative
path, a bare fragment, a bare query, a name climbing out of content/, and every
relative path whose extension is not .md. That last line is what keeps cover.jpg
working — a bundle's assets already resolve because its URL mirrors its directory,
so rewriting them would break what works. Nine rewrite cases sit beside them.

Key derivation goes through content.KeyFromName, exported for this: the
language-suffix rule is the part that would drift between two copies, so it lives
in one place while the five lines of joining are duplicated in check.

khosra check now reports a relative .md link resolving to no bundle, as fatal —
verified by mistyping one and watching exit 1. Only the .md form: an extensionless
relative path may be an asset, and a checker that calls a working link broken gets
ignored wholesale.

Two debts this change paid rather than deferred.

render.go reached the file-length advisory, so theme parsing moved to theme.go —
414 and 105 lines, one topic each, since parsing runs per rebuild and rendering
runs per request. Not a _helpers.go shard.

And the demo's coverage test bound its renderer with a *copy* of the rebuilder's
wiring, so it missed this feature entirely while the real binary served it
correctly. Navigation had already drifted the same way. Both now call one bind(),
which is exactly what ADR-0072 was written about — and the test failing is the only
reason the copy was found.

Extensions 7 → 8. Core 3020 → 3049 of 3400: the seam is ~20 lines, the feature is
in ext where it belongs.

18 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:44:48 +06:00

106 lines
4.1 KiB
Go

// Building the theme: the embedded reference templates, the site's overlay of them, and the stylesheet.
//
// Split from render.go when that file reached the length advisory (conventions.md). Parsing a theme and
// rendering with one are two topics: this file runs once per rebuild, the other runs per request.
package render
import (
"embed"
"fmt"
"html/template"
"io/fs"
)
//go:embed templates
var themeFS embed.FS
// 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).
page *template.Template
list *template.Template
// partials are named fragments a feature renders through, so no feature decides markup (ADR-0036).
partials *template.Template
// extras is the set for a bundle's supporting-file listing.
extras *template.Template
style template.CSS
}
// 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")
if err != nil {
return nil, fmt.Errorf("bundle templates: %w", err)
}
list, err := parseSet(siteFS, "templates/base.html", "templates/list.html")
if err != nil {
return nil, fmt.Errorf("listing templates: %w", err)
}
partials, err := parseSet(siteFS, "templates/shortcodes.html", "templates/shortcodes/*.html")
if err != nil {
return nil, fmt.Errorf("partial templates: %w", err)
}
extras, err := parseSet(siteFS, "templates/base.html", "templates/extras.html")
if err != nil {
return nil, fmt.Errorf("extras templates: %w", err)
}
css, err := readStyle(siteFS)
if err != nil {
return nil, err
}
return &parsedTheme{page: page, list: list, partials: partials, extras: extras, style: css}, nil
}
// parseSet builds one set from the named embedded templates, then the site's versions of exactly those
// 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 files this set is built from 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, names ...string) (*template.Template, error) {
// Funcs are attached before anything is parsed, so the chrome helpers are available to a site
// override's blocks as well as the embedded ones (ADR-0034). A name may be a glob, which is how a
// directory of fragments is parsed after the single file it may replace (ADR-0071).
set, parsed := template.New("theme").Funcs(funcs), false
for _, from := range []fs.FS{themeFS, siteFS} {
if from == nil {
continue
}
for _, name := range names {
if matches, _ := fs.Glob(from, name); len(matches) == 0 {
continue
}
var err error
if set, err = set.ParseFS(from, name); err != nil {
return nil, fmt.Errorf("parse %s: %w", name, err)
}
parsed = true
}
}
// Nothing matched anywhere, which means a name the binary embeds has been renamed. A startup failure,
// because the alternative is an empty set and a template error on the first request.
if !parsed {
return nil, fmt.Errorf("no template matched %v", names)
}
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
}