Files
khosra/internal/render/render.go
T
Claude Opus 5andbdeshi 1b898fdfc3 split the theme's fragments into a directory, keeping the file form
One file held every fragment, and it gained one per feature all session: figure,
gallery, icon, three admonitions, details, aside, contents. A theme author
overriding one had to copy the file or redefine into it, and a diff of the theme
became a diff of everything.

Both forms are supported, because a small theme is happier with one file and the
contract should not force a directory on it. Parse order is embedded file,
embedded directory, site file, site directory, and the last definition wins — so
the directory overrides the file within one source and a site overrides the
binary either way. Verified with a site root using both at once: its
shortcodes.html supplied `icon`, its shortcodes/note.html supplied `note`, the
embedded directory supplied the rest, and with the same name in both the
directory won.

The embedded theme ships the directory only, seven files, so nothing is defined
twice.

parseSet takes globs now and lost a branch doing it. Its old guard — a literal
embedded name must exist — had to go, since shortcodes.html is deliberately
absent; the replacement is stronger, failing at startup when a set matches
nothing anywhere, which also catches a renamed base.html.
2026-08-01 23:39:12 +06:00

457 lines
19 KiB
Go

// Package render turns a bundle into bytes: Markdown to HTML, then a template set. It knows content and
// nothing about HTTP.
//
// The embedded templates and stylesheet are the reference theme (ADR-0026) — a demonstration of
// docs/theme-contract.md, not a design. Fields a template may rely on are listed there.
package render
import (
"bytes"
"embed"
"fmt"
"html/template"
"io/fs"
"path"
"sync/atomic"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"khosra/internal/content"
)
//go:embed templates
var themeFS embed.FS
// Renderer holds the parsed theme and the Markdown converter. The theme is parsed once per rebuild and
// swapped whole, never per request (conventions.md, ADR-0055).
type Renderer struct {
// theme is swapped rather than mutated, so a rebuild can replace it while requests are reading it — the
// same reason the index is an atomic.Pointer (ADR-0022, ADR-0055).
theme atomic.Pointer[parsedTheme]
md goldmark.Markdown
// files is the site root, handed to features through Origin. Nil when there is none.
files fs.FS
// settings are the site's declarations, constant for the life of the process: editing site.yaml needs a
// restart, which is why the watcher does not fingerprint it (ADR-0055).
settings content.Settings
// sections reports the site's sections when asked. A callback, because sections change when content does and
// the renderer must not hold a stale copy (ADR-0049).
sections func() []string
// siteFS is kept only so Refresh can reparse what New parsed.
siteFS fs.FS
// compose may rewrite a body before it is parsed, for a bundle that asks its includes to be merged
// (ADR-0066). Set at wiring time like sections, and never called otherwise.
compose func(src []byte, origin Origin) []byte
}
// parsedTheme is one snapshot of the theme: the sets a request executes, and the stylesheet the shell inlines.
// Never mutated once stored — a reparse builds another and swaps it in (ADR-0055).
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
}
// originKey identifies the Origin in a parse. Unexported, so the typed accessor is the only way in.
var originKey = parser.NewContextKey()
// OriginFrom reports the bundle being rendered, and false outside a bundle render.
func OriginFrom(pc parser.Context) (Origin, bool) {
origin, ok := pc.Get(originKey).(Origin)
return origin, ok
}
// WithOrigin records the bundle on a parse context. A feature that starts a parse of its own — an included
// file — carries the same Origin into it, so a path there resolves against the same bundle (ADR-0038).
func WithOrigin(pc parser.Context, origin Origin) {
pc.Set(originKey, origin)
}
// New parses the theme and prepares the Markdown converter.
//
// 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.
//
// extend is the seam features plug into: it receives the renderer's Partial and returns the Markdown
// extensions to enable. A callback rather than a parameter of feature types, because internal/render must
// not import internal/ext — only cmd knows which features a build includes (conventions.md, ADR-0036). It
// may be nil.
func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error) {
theme, err := parseTheme(siteFS)
if err != nil {
return nil, err
}
r := &Renderer{files: siteFS, settings: settings, siteFS: siteFS}
r.theme.Store(theme)
// The typographer smooths quotes, dashes and ellipses in authored prose and leaves code spans alone,
// because it works on the parsed tree rather than the text. That is the only change the engine makes to
// an author's words (ADR-0034), and it is a parser option rather than a render transform, so it does
// not move the transforms counter.
//
// Raw HTML renders, because content from the site root is trusted (invariant 2, ADR-0060). This is the
// *one* renderer allowed to say so, which `verify.sh` enforces by counting the call: an untrusted source
// — a comment, a webmention — gets its own goldmark without this option, never this one.
extensions := []goldmark.Extender{extension.Typographer}
if extend != nil {
extensions = append(extensions, extend(r.Partial)...)
}
// Heading IDs are a parser option rather than an extension, and they are the engine's half of a table of
// contents: the anchor has to exist before a theme can link to it (ADR-0058).
r.md = goldmark.New(goldmark.WithExtensions(extensions...),
goldmark.WithParserOptions(parser.WithAutoHeadingID(), parser.WithHeadingAttribute()),
goldmark.WithRendererOptions(html.WithUnsafe()))
return r, nil
}
// 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
}
// head builds the document shell every kind of page shares.
//
// canonical arrives as a path and leaves absolute when the site declared a base: a canonical link and an
// hreflang are read by machines that resolve neither against the page (ADR-0039).
func (r *Renderer) head(title, lang, canonical string) head {
h := head{
Title: title,
Lang: lang,
Canonical: r.absolute(canonical),
Style: r.theme.Load().style,
Site: r.settings,
}
if r.sections != nil {
for _, name := range r.sections() {
h.Sections = append(h.Sections, Item{Title: name, Key: name, URL: content.URL(name, lang)})
}
}
return h
}
// absolute is the site's own URL for a path the engine emitted, or the path itself when no base is declared.
func (r *Renderer) absolute(path string) string {
return content.Absolute(r.settings.Base, path)
}
// Navigation tells the renderer where to find the site's sections.
//
// Set once at wiring time, like Reload: a page needs to offer navigation, and only the index knows which
// sections exist. A callback rather than a slice, because content changes and a copy would go stale.
func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
// Compose registers the rewrite a merging bundle's body goes through before it is parsed (ADR-0066).
//
// A seam rather than a call, for the same reason extend is one: only cmd knows which features exist, and
// splicing source files together is a feature's work, not the renderer's.
func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite }
// Refresh reparses the theme and swaps it in, so a running server picks up an edited template the same way it
// picks up edited content (ADR-0055). Called once per rebuild, off the request path.
//
// This is the *only* way the theme changes, and that is the point (ADR-0056): every page the site serves after
// a swap was rendered from the same snapshot, so a template edit can never leave one page updated and its
// neighbour stale. Reparsing inside a render method could not promise that — two requests in flight would
// disagree, and `Partial` runs *during* a page's Markdown conversion, so even one page could mix two themes.
//
// A failure leaves the working theme in place and returns the error: a template with a typo in it must not
// replace a good set with a broken one, because the site would then serve nothing at all.
func (r *Renderer) Refresh() error {
theme, err := parseTheme(r.siteFS)
if err != nil {
return err
}
r.theme.Store(theme)
return nil
}
// Partial renders one named fragment. A missing template is an error the caller degrades on, never a
// failed request (extensions.md rule 5).
func (r *Renderer) Partial(name string, data Fragment) ([]byte, error) {
partials := r.theme.Load().partials
if partials.Lookup(name) == nil {
return nil, fmt.Errorf("no template named %q", name)
}
var out bytes.Buffer
if err := partials.ExecuteTemplate(&out, name, data); err != nil {
return nil, fmt.Errorf("partial %s: %w", name, err)
}
return out.Bytes(), 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
}
// Extras renders a bundle's supporting files, with one entry selected or none.
//
// The engine enumerates, classifies and renders what it can; how a tree and a selected file look is the theme's
// (ADR-0046). A file it cannot render still arrives with a RawURL, because "cannot show it inline" is not
// "cannot offer it".
func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error) {
title := b.Title
if title == "" {
title = b.Key
}
x := Extras{
head: r.head(title, served, content.ExtrasURL(b.Route, served, "")),
Bundle: r.item(b, served),
Entries: entries,
}
if selected != nil {
x.Selected = selected
x.head.Canonical = r.absolute(content.ExtrasURL(b.Route, served, selected.Path))
}
return r.execute(r.theme.Load().extras, x, b.Key+"/"+content.ExtrasDir)
}
// RenderText converts a markdown or plain-text file for display inside an extras listing.
//
// Markdown goes through the same converter as a body, so an included note reads the way the author wrote it.
// Anything else is shown as preformatted text, escaped — a log file is not markup.
func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error) {
if kind == "markdown" {
var out bytes.Buffer
if err := r.md.Convert(data, &out); err != nil {
return "", fmt.Errorf("markdown: %w", err)
}
return template.HTML(out.String()), nil
}
var escaped bytes.Buffer
template.HTMLEscape(&escaped, data)
return template.HTML("<pre>" + escaped.String() + "</pre>"), nil
}
// Bundle renders one bundle into a complete page.
//
// served is the language actually chosen by the fallback chain, and variants every language the key
// exists in; both feed canonical and hreflang, which a theme must not construct itself. seq is the series
// the bundle sits in, or nil.
func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error) {
// The parse carries which bundle it is, so a feature can resolve a path in a call against the bundle's
// own directory (ADR-0031: through the rooted filesystem, never a joined path).
pc := parser.NewContext()
origin := Origin{Dir: path.Dir(b.Path), Files: r.files, Lang: served}
WithOrigin(pc, origin)
// `include: merge` asks for one document rather than a page of embedded ones, so the fragments are
// spliced in before the parse and their footnotes, abbreviations and headings become the page's (ADR-0066).
source := b.Body
if kind, _ := b.Extra["include"].(string); kind == "merge" && r.compose != nil {
source = r.compose(source, origin)
}
var body bytes.Buffer
if err := r.md.Convert(source, &body, parser.WithContext(pc)); err != nil {
return nil, fmt.Errorf("markdown %s: %w", b.Path, err)
}
title := b.Title
if title == "" {
title = b.Key
}
p := Page{
head: r.head(title, served, content.URL(b.Route, served)),
Key: b.Key,
HTML: template.HTML(body.String()),
Extra: b.Extra,
Sequence: r.sequence(seq, served),
}
for _, l := range variants {
path := content.URL(b.Route, l)
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: r.absolute(path), Path: path})
}
for _, tag := range b.Tags {
p.Tags = append(p.Tags, Item{Title: tag, Key: content.TagSlug(tag), URL: content.TagURL("", content.TagSlug(tag), served, 1)})
}
// One Stat rather than a walk: a page only needs to know whether there is anything to link to (ADR-0047).
if assets, hasAssets := b.Assets(); hasAssets && r.files != nil {
if _, err := fs.Stat(r.files, path.Join(assets, content.ExtrasDir)); err == nil {
p.ExtrasURL = content.ExtrasURL(b.Route, served, "")
}
}
return r.execute(r.theme.Load().page, p, b.Key)
}
// Listing renders one page of a Query result for a section.
func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error) {
// The root has no section to name itself after, so it borrows the site's title, or says what it is.
title := section
if title == "" {
if title = r.settings.Title; title == "" {
title = text(lang, "everything")
}
}
l, window := r.paginate(title, lang, content.PageURL(section, lang, page), all, page,
func(p int) string { return content.PageURL(section, lang, p) })
for _, b := range window {
l.Items = append(l.Items, r.item(b, lang))
}
return r.execute(r.theme.Load().list, l, section)
}
// Tag renders one page of a tag listing, grouped by section.
//
// section narrows the listing to one section and is empty for the global one.
func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error) {
title := "#" + slug
if section != "" {
title = section + " · #" + slug
}
l, window := r.paginate(title, lang, content.TagURL(section, slug, lang, page), all, page,
func(p int) string { return content.TagURL(section, slug, lang, p) })
// Both shapes, always: the flat list in query order, and the same entries partitioned by section. A
// template cannot group for itself, so the engine offers the partition — but it does not decide that a tag
// listing must look grouped, which is a readability judgement belonging to whoever writes the markup
// (ADR-0046, amending ADR-0032).
for _, b := range window {
item := r.item(b, lang)
l.Items = append(l.Items, item)
if n := len(l.Groups); n > 0 && l.Groups[n-1].Name == item.Section {
l.Groups[n-1].Items = append(l.Groups[n-1].Items, item)
continue
}
l.Groups = append(l.Groups, Group{Name: item.Section, Items: []Item{item}})
}
return r.execute(r.theme.Load().list, l, "tag "+slug)
}
// sequence builds the series view for a page: its members, and the neighbours around this page.
//
// Neighbours are pointers into Members, so a theme reads them with `with` and gets nothing at the ends
// rather than an empty entry that looks like a link.
func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence {
if seq == nil {
return nil
}
out := &Sequence{
Title: seq.Series.Title,
URL: content.URL(seq.Series.Route, lang),
Index: seq.Index,
Count: len(seq.Members),
}
for _, m := range seq.Members {
out.Members = append(out.Members, r.item(m, lang))
}
if len(out.Members) == 0 {
return out
}
out.First, out.Last = &out.Members[0], &out.Members[len(out.Members)-1]
if seq.Index > 1 {
out.Prev = &out.Members[seq.Index-2]
}
if seq.Index > 0 && seq.Index < len(out.Members) {
out.Next = &out.Members[seq.Index]
}
return out
}
// item is one listing entry.
func (r *Renderer) item(b content.Bundle, lang string) Item {
return Item{Title: b.Title, Key: b.Key, URL: content.URL(b.Route, lang), Date: b.Date, Section: b.Section()}
}
// paginate builds the shell of a listing page and returns the slice of entries it shows.
func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle) {
pages := (len(all) + content.PerPage - 1) / content.PerPage
if pages < 1 {
pages = 1
}
start := (page - 1) * content.PerPage
end := min(start+content.PerPage, len(all))
l := List{
head: r.head(title, lang, canonical),
Page: page,
Pages: pages,
}
if page > 1 {
l.PrevURL = url(page - 1)
}
if page < pages {
l.NextURL = url(page + 1)
}
return l, all[start:end]
}
// execute runs a template set and wraps a failure with what was being rendered.
func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error) {
var out bytes.Buffer
if err := set.ExecuteTemplate(&out, "base", data); err != nil {
return nil, fmt.Errorf("template %s: %w", what, err)
}
return out.Bytes(), nil
}