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>
415 lines
17 KiB
Go
415 lines
17 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"
|
|
"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"
|
|
)
|
|
|
|
// 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
|
|
// links resolves a bundle key to the URL it is served at, set per rebuild like sections because only the
|
|
// current index can answer it (ADR-0087).
|
|
links func(key, lang string) (string, bool)
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 }
|
|
|
|
// Links registers how a bundle key becomes the URL it is served at.
|
|
//
|
|
// Set per rebuild beside Navigation, and for the same reason: only the index that exists now knows which
|
|
// route a key answers at, and a renderer holding a stale copy would emit addresses that used to work.
|
|
func (r *Renderer) Links(resolve func(key, lang string) (string, bool)) { r.links = resolve }
|
|
|
|
// 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())
|
|
}
|
|
|
|
// 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, Resolve: r.links}
|
|
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
|
|
}
|