Tables, footnotes, definition lists, strikethrough and automatic heading ids. Which dialect a site is written against is permanent, so ADR-0058 names the whole set at once — including the four refused, each for a reason rather than a taste: task lists publish nothing, linkify rewrites plain text into markup that ADR-0034 forbids the engine to touch, CJK is the wrong script family for a Bengali site, and the GFM bundle is a package deal for the first two. Footnotes collided with includes, as the queue predicted but worse. An include converts its file on its own bytes (ADR-0038), so goldmark numbered its notes from one again and the page carried two id="fn:1"s — the parent's reference jumped to the fragment's note. shortcodes.FootnotePrefix stamps the file name on the nested document and hands it to goldmark's id-prefix function, so the fragment gets _method-fn:1 and the page keeps fn:1. Two things nothing tested before. The extender list ships from cmd/khosra, which no package can import, so the dialect had never been rendered through the list the binary actually uses — cmd/khosra/wire_test.go now does exactly that, including that the typographer no longer eats a table's delimiter row. And the demo carries the dialect and the footnote namespacing as cases, which caught auto heading ids changing markup in three existing assertions. The reference theme gains five lines: a rule under each table row, an indent for definitions, smaller footnotes. core 2790/2800, ext 1058/2000, 34 gates green.
471 lines
19 KiB
Go
471 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"
|
|
|
|
"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 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)...)
|
|
}
|
|
// 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()))
|
|
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
|
|
}
|