serve a bundle at its permalink
-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.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"khosra/internal/content"
|
||||
)
|
||||
|
||||
func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) {
|
||||
r, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := content.Parse("posts/hello.md", []byte("---\ntitle: Hello\n---\n\n# Heading\n\nSome *prose*.\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Bundle(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(out)
|
||||
for _, want := range []string{
|
||||
"<!doctype html>", `<html lang="en">`, "<title>Hello</title>",
|
||||
"<h1>Hello</h1>", "<em>prose</em>", "<style>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q\n---\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleWithoutTitleFallsBackToKey(t *testing.T) {
|
||||
r, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := content.Parse("status/note.md", []byte("just a note\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Bundle(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), "<title>status/note</title>") {
|
||||
t.Errorf("a titleless bundle must still produce a title element:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{define "base" -}}
|
||||
<!doctype html>
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</title>
|
||||
<style>{{.Style}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{{block "main" . -}}
|
||||
<article>
|
||||
{{if .Title}}<h1>{{.Title}}</h1>{{end}}
|
||||
{{.HTML}}
|
||||
</article>
|
||||
{{- end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Reference theme: legibility only, no design opinions (ADR-0026). */
|
||||
html { font-family: Georgia, serif; line-height: 1.6; color: #1a1a1a; background: #fdfdfb; }
|
||||
main { max-width: 34rem; margin: 3rem auto; padding: 0 1rem; }
|
||||
h1, h2, h3 { line-height: 1.25; font-weight: 600; }
|
||||
a { color: #1a4d7a; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
pre, code { font-family: ui-monospace, monospace; font-size: 0.9em; }
|
||||
pre { overflow-x: auto; padding: 0.75rem; background: #f3f2ee; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html { color: #e8e6e1; background: #16161a; }
|
||||
a { color: #8ab4dd; }
|
||||
pre { background: #22222a; }
|
||||
}
|
||||
Reference in New Issue
Block a user