Files
khosra/internal/render/render.go
T
Claude Opus 5andbdeshi 2041c43b70 include another file from the bundle, one level deep
{{< include file="notes.md" >}} renders a file from the bundle as Markdown in
place. The included file is converted by the same goldmark instance that is
rendering the page — handed to the transformer in Extend — so its configuration
can never drift from the page's.

Three properties, each tested:

A name containing ".." is refused. os.Root would stop a path leaving the site
root, but path.Join collapses ".." long before the filesystem sees it, so without
this an include could read a template or a stray dotfile from the site root and
publish it. Verified the test fails without the guard: it took three levels of
".." from content/pages/d to reach the root, and the first version of the test
used two, so it passed either way and proved nothing.

An include inside an included file renders nothing and logs. The nested parse is
marked, so one level is all there is and a file including itself is a log line
rather than a stack overflow (ADR-0038, ADR-0029).

A gallery inside an included file still resolves, because the nested parse carries
the same Origin.

The node gained `content` for output a feature produced itself, plus `isContent`
so a failed include renders nothing instead of falling through to a fragment
lookup and complaining about a template that was never meant to exist.
2026-07-31 02:00:39 +06:00

387 lines
14 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
}
// 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
}
// 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).
type Fragment struct {
// Args are the call's key="value" pairs, exactly as written. Escaping is the template's.
Args map[string]string
// Items is a list the feature gathered rather than the author wrote — the filenames a gallery found.
// Kept apart from Args so a supplied value can never be mistaken for an authored one.
Items []string
}
// 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, 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}
// 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
}
// 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: 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,
Sequence: r.sequence(seq, served),
}
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)
}
// 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.Key, 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.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
}