Files
khosra/internal/render/render.go
T
bdeshiandClaude Opus 5 ec6e9838f0 rename docs/ to harness/, and reserve docs/ for the reader
docs/content-model.md opens with "Engine specification". It is also where the
rule lives that a leading underscore makes a file unaddressable — and the human
who owns this site did not know that rule, because nothing in this repository is
addressed to an author. Twelve documents named docs/ while being exclusively
about building the parser is a signpost pointing at the wrong room.

Naming the directory for its audience makes the gap visible instead of hiding it.
docs/ is now reserved and deliberately absent: an empty docs/ is an honest
statement that end-user documentation does not exist, where docs/ full of parser
specs was a claim that it did.

HARNESS.md stays at the root. Root holds the three entry points — README.md for a
human, CLAUDE.md for an agent, HARNESS.md for whoever maintains the machine — and
harness/README.md is the map of the directory, so moving the guide inside would
have collided with it for nothing.

Mechanical and wide: 100 path references across 24 files. Every verify.sh gate
that names a doc by path, the directory lists the dangling-path and ADR-number
gates scan, surface.sh's output target, the Makefile, CLAUDE.md's read order, the
skill, four commands, and two Go package comments. A first pass with a shell loop
silently edited only four files and the rest still said docs/; the fix was to
write the file list out and check the remaining count was zero rather than trust
the loop's exit status.

No rule, threshold, gate or obligation moved — this is a rename, and the gates
demonstrated it twice: they stayed green on the new paths, and the ADR-number gate
caught ADR-0082 before the entry existed.

Deferred, both on the human's call: the end-user documentation site itself, which
wants its own decision about where it lives and whether its claims are gated; and
moving examples/ under docs/, since demo-site is a live site root that verify.sh,
the coverage test and make demo all point at, and moving it would couple a rename
to a design nobody has made.

31 files, +146/-106.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:11:06 +06:00

500 lines
20 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
// harness/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"
"slices"
"strings"
"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 never changes after New: a rebuild builds a whole new Renderer and the site and theme are
// swapped together as one snapshot, so no page is ever assembled from two of them (ADR-0077).
theme *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
// 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 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
}
// 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)
}
// callsKey identifies the set of shortcode names called during one parse. Same shape as originKey, and
// unexported for the same reason.
var callsKey = parser.NewContextKey()
// RecordCall notes that a shortcode of this name was called while converting this page.
//
// A feature records the call; the renderer decides what it means. That split is what keeps the engine
// free of a table mapping shortcode names to the files they need — that mapping is the theme's, written
// as an `assets:<name>` fragment (theme-contract.md, ADR-0079), so a theme adds an asset without a
// rebuild exactly as it adds a shortcode.
//
// First-call order is preserved and repeats collapse, so a page calling one shortcode five times carries
// its asset once.
func RecordCall(pc parser.Context, name string) {
seen, _ := pc.Get(callsKey).(*[]string)
if seen == nil {
seen = &[]string{}
pc.Set(callsKey, seen)
}
if !slices.Contains(*seen, name) {
*seen = append(*seen, name)
}
}
// callsFrom returns the shortcode names recorded during a parse, in first-call order.
func callsFrom(pc parser.Context) []string {
if seen, ok := pc.Get(callsKey).(*[]string); ok {
return *seen
}
return nil
}
// 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, theme: 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.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 }
// 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.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
}
// assets renders the theme's `assets:<name>` fragment once for each asset this page uses.
//
// Sources are the shortcodes the conversion actually called and the bundle's own `use:` list, so a page
// carries the CSS and JS its own content needs and an ordinary page carries none (ADR-0079). A name the
// theme defines no fragment for contributes nothing and is not an error: most shortcodes need no asset,
// and asking the theme to declare that emptiness would be ceremony.
func (r *Renderer) assets(names []string) template.HTML {
var out strings.Builder
var done []string
for _, name := range names {
if slices.Contains(done, name) {
continue
}
done = append(done, name)
fragment, err := r.Partial("assets:"+name, Fragment{})
if err != nil {
continue
}
out.Write(fragment)
}
return template.HTML(out.String())
}
// 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.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)
// Fragments are spliced in before the parse, so their footnotes, abbreviations and headings are the
// page's — one document, which is what composing a page from files nearly always wants. `include: embed`
// asks for the other thing: each fragment its own document (ADR-0066, ADR-0076).
source := b.Body
if kind, _ := b.Extra["include"].(string); kind != "embed" && 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),
Assets: r.assets(append(callsFrom(pc), b.Use...)),
}
// A bundle's own files are already served under its URL (ADR-0024), so the engine builds the address
// and the theme never constructs one — the same rule canonical and hreflang follow.
for _, name := range b.Styles {
p.Styles = append(p.Styles, content.URL(b.Route, served)+name)
}
for _, name := range b.Scripts {
p.Scripts = append(p.Scripts, content.URL(b.Route, served)+name)
}
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.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.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.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
}