// 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("
" + escaped.String() + "
"), 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 }