-site (or KHOSRA_SITE) opens the site root through content.OpenSite, so every
read keeps the os.Root guarantee. A path is a bundle key: /{section}/{slug}/
serves, the slashless form redirects permanently to it (ADR-0008), anything
unknown is 404. Render failure logs and returns a bare 500 rather than leaking a
template or filesystem detail.
internal/render holds goldmark plus the embedded reference theme (ADR-0026):
base.html with a redefinable "main" block, and one stylesheet inlined through
.Style. Serving it at an asset route would have been a second routing case for no
gain, and static serving belongs to a later entry.
Evidence beyond the tests: the binary against a real site root returns 200 with
<h1>About</h1> and the rendered body, 301 from /pages/about to /pages/about/, and
404 for /nope/. A Bengali variant is scanned but not yet reachable — that is the
next entry.
theme-contract.md gains a "Live today" section listing the six fields and two
named templates a theme may now rely on; the rest stays marked as shape.
83 lines
2.6 KiB
Go
83 lines
2.6 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"
|
|
|
|
"github.com/yuin/goldmark"
|
|
|
|
"khosra/internal/content"
|
|
)
|
|
|
|
//go:embed templates
|
|
var themeFS embed.FS
|
|
|
|
// Page is what a template receives. Absence is the zero value: a template reads what exists and never
|
|
// fails on a missing field (invariant 1).
|
|
type Page struct {
|
|
// Title may be empty; whether that is legal depends on a type, which nothing decides yet.
|
|
Title string
|
|
// Lang is the locale this variant is written in.
|
|
Lang string
|
|
// Key is the bundle's identity, useful for building links.
|
|
Key string
|
|
// HTML is the rendered body, already escaped by the Markdown renderer.
|
|
HTML template.HTML
|
|
// Extra carries every frontmatter key the parser does not name (ADR-0002).
|
|
Extra map[string]any
|
|
// Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
|
|
Style template.CSS
|
|
}
|
|
|
|
// Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never
|
|
// per request (conventions.md).
|
|
type Renderer struct {
|
|
tmpl *template.Template
|
|
md goldmark.Markdown
|
|
style template.CSS
|
|
}
|
|
|
|
// New parses the reference theme and prepares the Markdown converter.
|
|
//
|
|
// A malformed embedded template is a programming error caught at startup, not at request time, so this
|
|
// returns an error and the caller is expected to treat it as fatal.
|
|
func New() (*Renderer, error) {
|
|
tmpl, err := template.ParseFS(themeFS, "templates/*.html")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse reference theme: %w", err)
|
|
}
|
|
css, err := themeFS.ReadFile("templates/theme.css")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read reference stylesheet: %w", err)
|
|
}
|
|
return &Renderer{tmpl: tmpl, md: goldmark.New(), style: template.CSS(css)}, nil
|
|
}
|
|
|
|
// Bundle renders one bundle into a complete page.
|
|
func (r *Renderer) Bundle(b content.Bundle) ([]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)
|
|
}
|
|
p := Page{
|
|
Title: b.Title,
|
|
Lang: b.Lang,
|
|
Key: b.Key,
|
|
HTML: template.HTML(body.String()),
|
|
Extra: b.Extra,
|
|
Style: r.style,
|
|
}
|
|
var out bytes.Buffer
|
|
if err := r.tmpl.ExecuteTemplate(&out, "base", p); err != nil {
|
|
return nil, fmt.Errorf("template %s: %w", b.Key, err)
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|