Files
khosra/internal/render/render.go
T
bdeshi a2ccb45b0d hide drafts and future-dated bundles; reveal them with -dev on
A draft is now not served at all, and neither is a bundle whose date has not
arrived. The filter sits in `Site.Lookup` and `Site.Run`, which is every path to a
bundle — so the files inside an unpublished bundle inherit its status for free,
which is what ADR-0024 asks for and what the asset route was written to allow. A
test asserts the 404 for the bundle *and* its picture, and that nothing leaks into
a listing, a feed or a sitemap.

The clock is read per request rather than at startup, so a scheduled post appears
exactly when its date arrives with nothing to restart and nothing to invalidate.
That first clock read created internal/content/clock.go, which is the only place
`verify.sh` allows `time.Now` — a render that depends on the time is worth being
able to find.

`-dev on` reveals both and reparses the theme before each render. Deliberately not
a bare boolean flag: turning unpublished work into public work should not be one
fumbled argument away. A reload that fails to parse leaves the working template set
in place, so a typo shows an error rather than replacing a good set with a broken one.
2026-08-01 02:23:36 +06:00

452 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
// 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"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"khosra/internal/content"
)
//go:embed templates
var themeFS embed.FS
// head is what every kind of page shares: the document shell the base template needs. Absence is the
// zero value — a template reads what exists and never fails on a missing field (invariant 1).
type head struct {
// Title may be empty for a bundle; a listing always has one.
Title string
// Lang is the locale being served.
Lang string
// Canonical is the permalink of what was actually served, which differs from the URL requested when
// the fallback chain supplied another language (ADR-0009).
Canonical string
// Alternates lists every language this key exists in, for hreflang.
Alternates []Alternate
// Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
Style template.CSS
// Site is what the site declared about itself in site.yaml (ADR-0039). Zero when it declared nothing.
Site content.Settings
}
// Page is one bundle rendered.
type Page struct {
head
// Key is the bundle's identity, useful for building links.
Key string
// HTML is the rendered body.
HTML template.HTML
// Extra carries every frontmatter key the parser does not name (ADR-0002).
Extra map[string]any
// Sequence is the series this page sits in, nil when it sits in none.
Sequence *Sequence
}
// Sequence is a series as a page sees it: its members in reading order, and where this page is in them
// (ADR-0033).
type Sequence struct {
// Title is the series' title, empty when the landing page omits one; URL is its permalink.
Title, URL string
// Members are every entry in reading order — ascending, unlike a dated listing.
Members []Item
// Index is this page's 1-based position, zero when this page is the series landing itself. Count is
// how many members there are.
Index, Count int
// Prev and Next are the neighbours in reading order, nil at the ends and on the landing page. Prev is
// the *earlier* entry, the opposite sense of a listing's PrevURL.
Prev, Next *Item
// First and Last are the ends of the series, set whenever it has members.
First, Last *Item
}
// List is a collection page: the result of a Query, one page of it.
type List struct {
head
// Items are the entries on this page, in the Query's order.
Items []Item
// Page is 1-based; Pages is the total, at least 1 even when empty.
Page, Pages int
// PrevURL and NextURL are empty at the ends. Newer is "prev" because the order is newest first.
PrevURL, NextURL string
// Groups is set instead of Items when entries are grouped — a tag listing groups by section, so one
// busy term stays readable (ADR-0018).
Groups []Group
}
// Group is a named run of entries within a listing.
type Group struct {
Name string
Items []Item
}
// Item is one entry in a listing.
type Item struct {
Title string
Key string
URL string
Date time.Time
}
// Alternate is one language a bundle exists in.
type Alternate struct {
Lang string
URL string
}
// Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never
// per request (conventions.md).
type Renderer 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
md goldmark.Markdown
style template.CSS
// 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.
settings content.Settings
// reload reparses the theme before each render, for `-dev`: editing a template should not need a restart.
// Off in a serving build, where parsing once is the point (conventions.md).
reload bool
// siteFS and extend are kept only so reload can rebuild what New built.
siteFS fs.FS
extend func(Partial) []goldmark.Extender
}
// 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) {
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)
}
css, err := readStyle(siteFS)
if err != nil {
return nil, err
}
r := &Renderer{page: page, list: list, partials: partials, style: css, files: siteFS, settings: settings,
siteFS: siteFS, extend: extend}
// 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 stays disabled — goldmark's default — so the only HTML a page carries comes from a template
// (ADR-0036, invariant 2). Nothing here may enable html.WithUnsafe.
extensions := []goldmark.Extender{extension.Typographer}
if extend != nil {
extensions = append(extensions, extend(r.Partial)...)
}
r.md = goldmark.New(goldmark.WithExtensions(extensions...))
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 {
return head{
Title: title,
Lang: lang,
Canonical: r.absolute(canonical),
Style: r.style,
Site: r.settings,
}
}
// 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)
}
// Reload makes every render reparse the theme first. For `-dev` only: it trades the parse-once rule for the
// ability to edit a template and refresh.
func (r *Renderer) Reload() { r.reload = true }
// fresh reparses the theme when reloading, and reports a failure without disturbing the working renderer — a
// template with a typo in it should show an error page, not replace a good set with a broken one.
func (r *Renderer) fresh() error {
if !r.reload {
return nil
}
next, err := New(r.siteFS, r.settings, r.extend)
if err != nil {
return err
}
r.page, r.list, r.partials, r.style, r.md = next.page, next.list, next.partials, next.style, next.md
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) {
if r.partials.Lookup(name) == nil {
return nil, fmt.Errorf("no template named %q", name)
}
var out bytes.Buffer
if err := r.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
}
// 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 {
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: r.absolute(content.URL(b.Route, l))})
}
return r.execute(r.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) {
if err := r.fresh(); err != nil {
return nil, err
}
l, window := r.paginate(section, 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.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) })
for _, b := range window {
sec := b.Section()
if n := len(l.Groups); n > 0 && l.Groups[n-1].Name == sec {
l.Groups[n-1].Items = append(l.Groups[n-1].Items, r.item(b, lang))
continue
}
l.Groups = append(l.Groups, Group{Name: sec, Items: []Item{r.item(b, lang)}})
}
return r.execute(r.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}
}
// 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
}