The engine now owns the words it puts on a page that the author did not write (ADR-0034). `internal/render/chrome.go` holds the phrase table, Gregorian month names and decimal digits per language, keyed phrase-then-language so both forms sit side by side and a half-translated row is visible while reading. Templates reach it through `t`, `num` and `day`, registered before parsing so a site override's blocks may call them too. The reference theme stops hardcoding English: "Newer", "Page 2 of 2" and every date now come from the table, while `datetime` attributes stay ASCII because a parser reads them. Authored text gets goldmark's typographer and nothing else — quotes, dashes and ellipses smoothed, code spans untouched because it works on the parsed tree. It is a parser option rather than a function over a page, so the transforms counter does not move; state.md now says why, so the next reader does not miscount. Deliberately absent: relative dates, which need a validity window that only the cache entry will have, and body widow prevention, which cannot be done safely by a pass over rendered HTML.
306 lines
11 KiB
Go
306 lines
11 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"
|
|
"time"
|
|
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/extension"
|
|
|
|
"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
|
|
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
|
|
}
|
|
// 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.
|
|
md := goldmark.New(goldmark.WithExtensions(extension.Typographer))
|
|
return &Renderer{page: page, list: list, md: md, 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) {
|
|
// 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, "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. 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) {
|
|
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,
|
|
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
|
|
}
|