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>
106 lines
4.1 KiB
Go
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
|
|
}
|