The demo writes a whole site root that exercises every feature: two languages with a fallback, a series with ordered chapters, a gallery, a figure, an include, extras, tags across sections, a slug with an alias, an undated page, a draft, a template override, static files and site.yaml. It generates its filler rather than copying stored files, because nothing in this repository is content (ADR-0011) — and that makes it a test of the engine rather than a fixture: anything khosra can do that the demo cannot express is a gap. Two things found by generating and then serving it, which is the whole point: `khosra check` reported the demo's own series as mixing ordered and unordered members. It was right — the chapter bodies *described* `order: 10` while the frontmatter never carried it. The checker caught its own author. And `/` was a **404**. ADR-0008 leaves the root engine-owned, which is right, but "engine-owned" was never given an answer, so a visitor to the site's own address got nothing. The root now lists every bundle, newest first, paginated like any other listing, and 404s only when nothing is published. A hand-written home page stays a separate decision, recorded as such. Verified end to end: 23 files written, 12 bundles, 12 derivatives, `check` clean, and every URL the demo promises answers — including the alias redirecting, the draft hidden, and the front page rendering through the site's *own* template override.
455 lines
18 KiB
Go
455 lines
18 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"
|
|
|
|
"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 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
|
|
// extras is the set for a bundle's supporting-file listing.
|
|
extras *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
|
|
// 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
|
|
// 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)
|
|
}
|
|
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
|
|
}
|
|
r := &Renderer{page: page, list: list, partials: partials, extras: extras, 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 {
|
|
h := head{
|
|
Title: title,
|
|
Lang: lang,
|
|
Canonical: r.absolute(canonical),
|
|
Style: r.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 }
|
|
|
|
// 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.extras = next.page, next.list, next.partials, next.extras
|
|
r.style, r.md = 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
|
|
}
|
|
|
|
// 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) {
|
|
if err := r.fresh(); err != nil {
|
|
return nil, err
|
|
}
|
|
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.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.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
|
|
}
|
|
// 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.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.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
|
|
}
|