Files
khosra/internal/render/render.go
T
Claude Opus 5andbdeshi 1f168d973b render the HTML an author writes, and narrow the gate to one call site
Dropping raw HTML was silently destructive. H<sub>2</sub>O rendered as "H2O",
10<sup>6</sup> as "106", <kbd>Ctrl</kbd> as "Ctrl", and khosra check reported
nothing — an author lost meaning with no signal anywhere. Measured on the real
binary before and after.

Invariant 2 already says content from the site root is trusted, so the old gate
was defending the half of the boundary that was never in question while the
untrusted half has no code to defend yet. Chemistry, units, exponents and
keystrokes are what a hard-science site needs and what no Markdown dialect
expresses, so html.WithUnsafe() goes on in internal/render/render.go.

The gate does not disappear; it narrows. verify.sh used to fail on WithUnsafe
appearing anywhere and now fails unless it appears in exactly that one file —
watched doing both, accepting one call site and naming both files when a second
appears. A second pipeline trusting its input is the failure ADR-0003 exists to
prevent, and when comments arrive they get their own goldmark without it. The
gate is the reminder that the split has to be built rather than assumed.

The security test that asserted "raw HTML must still be dropped" now asserts the
property that actually holds and matters more: a shortcode argument stays data
whatever the page around it is allowed to do. ::figure{alt=<b>bold</b>} still
arrives as &lt;b&gt; while the <span> beside it renders.

core 2793/2800, ext 1077/2000, 34 gates green, 0 warnings.
2026-08-01 21:08:06 +06:00

474 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
}
// 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
}
// Partial renders a named fragment. A feature under internal/ext is handed one of these at wiring time,
// because markup belongs to the theme and a feature must not write any (ADR-0036).
type Partial func(name string, data Fragment) ([]byte, error)
// Fragment is what a fragment template receives (ADR-0037, widened by ADR-0042).
type Fragment struct {
// Args are the call's key="value" pairs, exactly as written. Escaping is the template's.
Args map[string]string
// Pictures are what the engine gathered rather than the author wrote: one for a figure, many for a
// gallery, none when the call names nothing a picture. Kept apart from Args so a supplied value can never
// be mistaken for an authored one.
Pictures []Picture
}
// Picture is one image a fragment can render (ADR-0042).
type Picture struct {
// Src is the author's own file, relative to the bundle. A browser that ignores Srcset still gets the
// picture that was put there.
Src string
// Srcset offers the derivatives, closed by the original at its own width; empty when the picture is
// already small enough that no derivative was worth making.
Srcset string
// Width and Height are the original's intrinsic size, so a page can reserve the box before the bytes
// arrive. Zero when the file could not be read.
Width, Height int
}
// Origin tells a feature which bundle is being rendered, so a path in a call can resolve relative to it.
//
// Features read it from the parser context with OriginFrom. It carries the site's fs.FS rather than a
// directory name alone, because every read goes through the rooted filesystem and never a joined path
// (ADR-0031).
type Origin struct {
// Dir is the bundle's directory, relative to the site root: "content/comics/the-long-monsoon".
Dir string
// Files is the site root. Nil when the renderer was built without one, in which case a feature that
// needs files degrades rather than guessing.
Files fs.FS
}
// 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()),
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")
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 }
// 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).
set, err := template.New("theme").Funcs(funcs).ParseFS(themeFS, names...)
if err != nil {
return nil, fmt.Errorf("parse embedded: %w", err)
}
if siteFS == nil {
return set, nil
}
for _, name := range names {
if _, err := fs.Stat(siteFS, name); err != nil {
continue
}
if set, err = set.ParseFS(siteFS, name); err != nil {
return nil, fmt.Errorf("parse site override %s: %w", name, err)
}
}
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()
WithOrigin(pc, Origin{Dir: path.Dir(b.Path), Files: r.files})
var body bytes.Buffer
if err := r.md.Convert(b.Body, &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
}