// 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" "time" "github.com/yuin/goldmark" "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 } // 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 } // 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 md goldmark.Markdown style template.CSS } // 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. func New(siteFS fs.FS) (*Renderer, error) { page, err := parseSet(siteFS, "templates/page.html") if err != nil { return nil, fmt.Errorf("bundle templates: %w", err) } list, err := parseSet(siteFS, "templates/list.html") if err != nil { return nil, fmt.Errorf("listing templates: %w", err) } css, err := readStyle(siteFS) if err != nil { return nil, err } return &Renderer{page: page, list: list, md: goldmark.New(), style: css}, nil } // parseSet builds one kind of page: the embedded base and block, then the site's versions of exactly // those two 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 same two names 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, kind string) (*template.Template, error) { set, err := template.ParseFS(themeFS, "templates/base.html", kind) if err != nil { return nil, fmt.Errorf("parse embedded: %w", err) } if siteFS == nil { return set, nil } for _, name := range []string{"templates/base.html", kind} { 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. func (r *Renderer) Bundle(b content.Bundle, served string, variants []string) ([]byte, error) { var body bytes.Buffer if err := r.md.Convert(b.Body, &body); err != nil { return nil, fmt.Errorf("markdown %s: %w", b.Path, err) } title := b.Title if title == "" { title = b.Key } p := Page{ head: head{Title: title, Lang: served, Canonical: content.URL(b.Key, served), Style: r.style}, Key: b.Key, HTML: template.HTML(body.String()), Extra: b.Extra, } for _, l := range variants { p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: content.URL(b.Key, 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) { 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) } // 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.Key, 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: head{Title: title, Lang: lang, Canonical: canonical, Style: r.style}, 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 }