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:
Claude Opus 5
2026-07-30 01:35:50 +06:00
committed by bdeshi
parent 2466ce79c7
commit 519a41cf2f
13 changed files with 436 additions and 10 deletions
+82
View File
@@ -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
}
+50
View File
@@ -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)
}
}
+21
View File
@@ -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}}
+13
View File
@@ -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; }
}