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
+53
View File
@@ -0,0 +1,53 @@
// Command khosra serves a site root over HTTP.
//
// Everything is assembled here and nowhere else: no init(), no package-level state (conventions.md).
package main
import (
"flag"
"log/slog"
"net/http"
"os"
"khosra/internal/content"
"khosra/internal/render"
"khosra/internal/web"
)
func main() {
site := flag.String("site", os.Getenv("KHOSRA_SITE"), "path to the site root (or KHOSRA_SITE)")
addr := flag.String("addr", "localhost:8080", "address to listen on")
flag.Parse()
if *site == "" {
fatal("no site root: pass -site or set KHOSRA_SITE", nil)
}
fsys, err := content.OpenSite(*site)
if err != nil {
fatal("cannot open the site root", err)
}
bundles, err := content.Scan(fsys)
if err != nil {
fatal("cannot read content", err)
}
renderer, err := render.New()
if err != nil {
fatal("cannot prepare the reference theme", err)
}
slog.Info("serving", "site", *site, "bundles", len(bundles), "addr", *addr)
if err := http.ListenAndServe(*addr, web.Handler(content.NewSite(bundles), renderer)); err != nil {
fatal("server stopped", err)
}
}
// fatal reports a startup failure and exits. Startup failure is fatal and loud; request-time failure
// degrades instead (conventions.md).
func fatal(msg string, err error) {
if err != nil {
slog.Error(msg, "err", err)
} else {
slog.Error(msg)
}
os.Exit(1)
}
+12 -8
View File
@@ -1,6 +1,6 @@
# State
**Verified against:** `d66c3cb` on 2026-07-30 — update this line every change.
**Verified against:** `HEAD` on 2026-07-30 — update this line every change.
If this file disagrees with the code, the code is right and this file is a bug.
## Inventory
@@ -8,11 +8,15 @@ If this file disagrees with the code, the code is right and this file is a bug.
| File | Purpose | LOC |
|---|---|---|
| `go.mod` | module `khosra`; `x/text`, `yaml.v3` direct | 8 |
| `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop | 217 |
| `internal/content/content_test.go` | table-driven; symlink-escape evidence for the path guard | 155 |
| `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop, key index | 245 |
| `internal/render/render.go` | goldmark + the embedded reference theme; `Page` is what templates receive | 92 |
| `internal/render/templates/` | reference theme: `base.html`, `theme.css` (ADR-0026) | — |
| `internal/web/web.go` | one routing case: path → bundle key, canonical trailing slash, 404, degrade on render failure | 62 |
| `cmd/khosra/main.go` | flags, wiring, startup — the only place things are assembled | 50 |
| `*_test.go` | table-driven; symlink escape, permalink, redirect, 404 | 245 |
No HTTP yet. This repo holds engine source only — the site root is external and passed with `-site`
(ADR-0011).
Serves a bundle at `/{section}/{slug}/`. This repo holds engine source only — the site root is external
and passed with `-site` (ADR-0011).
Dependencies: none.
@@ -24,13 +28,13 @@ this change*.
| Counter | Now | Extraction due at | What it buys |
|---|---|---|---|
| Render transforms | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`) |
| Routing cases | 0 | **2** | Resolver extraction |
| Routing cases | 1 | **2** | Resolver extraction |
| Collection pages | 0 | **1** | Query primitive |
| Views / output formats | 0 | **2** | View layer (contract per `theme-contract.md`) |
| Views / output formats | 1 | **2** | View layer (contract per `theme-contract.md`) |
| Effects | 0 | **2** | Effect runner + trigger wiring (change / schedule / demand) |
| Extensions | 0 | **3** | Extension registry + wire file (`extensions.md`) |
| Interface implementations | — | **2** | The interface itself |
| Non-stdlib dependencies | 2 direct, 7 modules | budget in `scripts/budgets.env` | — |
| Non-stdlib dependencies | 3 direct | budget in `scripts/budgets.env` | — |
Allowlisted, in use: `goldmark` is not yet imported. Allowlist: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015),
`gopkg.in/yaml.v3` (frontmatter, ADR-0020).
+19 -2
View File
@@ -3,8 +3,25 @@
What the engine promises a theme, and the only thing this repository is bound to (ADR-0023). A theme's
markup, layout and styling are not the engine's business; a theme's *inputs* are.
**STATUS: not built.** This is the shape the contract takes when the first template renders. Everything
here is engine obligation, not theme instruction — a theme may ignore any of it.
**STATUS: partly live.** The fields under *Live today* exist and are gated; everything else is the shape
the contract takes when the feature arrives. All of it is engine obligation, not theme instruction — a
theme may ignore any of it.
## Live today
A bundle page receives:
| Field | Contents |
|---|---|
| `.Title` | may be empty; a template falls back to `.Key` rather than failing |
| `.Lang` | the locale of this variant, always set |
| `.Key` | the bundle's identity, without language or extension |
| `.HTML` | the rendered body, already escaped |
| `.Extra` | every frontmatter key the parser does not name (ADR-0002) |
| `.Style` | the reference theme's stylesheet, inlined so a bare site root needs no asset route |
Two named templates: `base` is executed for every page; `main` is the block a theme redefines to change
the body while inheriting the document. Nothing else is promised yet.
## The stability rule
+2
View File
@@ -6,3 +6,5 @@ require (
golang.org/x/text v0.40.0
gopkg.in/yaml.v3 v3.0.1
)
require github.com/yuin/goldmark v1.8.5
+2
View File
@@ -1,3 +1,5 @@
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+27
View File
@@ -215,3 +215,30 @@ func dropCollisions(all []Bundle) []Bundle {
}
return kept
}
// Site is a set of bundles indexed for lookup by permalink key.
type Site struct {
byKeyLang map[string]Bundle
}
// NewSite indexes bundles for lookup. Later variants of a key and language cannot occur, because Scan
// drops ambiguity before this sees it.
func NewSite(bundles []Bundle) *Site {
s := &Site{byKeyLang: make(map[string]Bundle, len(bundles))}
for _, b := range bundles {
s.byKeyLang[b.Key+"\x00"+b.Lang] = b
}
return s
}
// Lookup returns the default-locale variant of a key.
//
// Other languages are reachable only once language routing exists; until then a key means its default
// variant (ADR-0021).
func (s *Site) Lookup(key string) (Bundle, bool) {
b, ok := s.byKeyLang[key+"\x00"+DefaultLang]
return b, ok
}
// Len reports how many bundles the site holds.
func (s *Site) Len() int { return len(s.byKeyLang) }
+22
View File
@@ -153,3 +153,25 @@ func TestOpenSiteRefusesSymlinkEscape(t *testing.T) {
t.Fatal("traversal with .. succeeded")
}
}
func TestSiteLookupFindsDefaultVariant(t *testing.T) {
fsys := fstest.MapFS{
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nhi\n")},
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\nহাই\n")},
}
bundles, err := Scan(fsys)
if err != nil {
t.Fatal(err)
}
site := NewSite(bundles)
if site.Len() != 2 {
t.Fatalf("indexed %d bundles, want 2", site.Len())
}
b, ok := site.Lookup("pages/about")
if !ok || b.Title != "About" {
t.Fatalf("Lookup gave %+v %v, want the English variant", b, ok)
}
if _, ok := site.Lookup("pages/missing"); ok {
t.Error("Lookup invented a bundle")
}
}
+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; }
}
+62
View File
@@ -0,0 +1,62 @@
// Package web maps requests to bundles and writes bytes. It knows content and render, and exposes
// neither to them.
package web
import (
"log/slog"
"net/http"
"strings"
"khosra/internal/content"
"khosra/internal/render"
)
// Handler serves a site.
//
// One routing case for now: a path is a bundle key. Extracting a resolver waits for the second case,
// which language routing brings — check the counter in docs/state.md rather than anticipating it.
func Handler(site *content.Site, r *render.Renderer) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
serve(w, req, site, r)
})
return mux
}
// serve resolves one request.
//
// The canonical form of every bundle URL ends in a slash (ADR-0008), so a slashless path that names a
// bundle redirects permanently rather than serving a second URL for the same content.
func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer) {
p := req.URL.Path
if p == "/" {
http.NotFound(w, req)
return
}
key := content.Normalise(strings.Trim(p, "/"))
if !strings.HasSuffix(p, "/") {
if _, ok := site.Lookup(key); ok {
http.Redirect(w, req, p+"/", http.StatusMovedPermanently)
return
}
http.NotFound(w, req)
return
}
b, ok := site.Lookup(key)
if !ok {
http.NotFound(w, req)
return
}
out, err := r.Bundle(b)
if err != nil {
// A render failure degrades: log it and say nothing more to the client than that it failed
// (conventions.md). It must never leak a template or filesystem detail.
slog.Error("render failed", "key", b.Key, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(out); err != nil {
slog.Warn("write failed", "key", b.Key, "err", err)
}
}
+71
View File
@@ -0,0 +1,71 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
"khosra/internal/render"
)
func testHandler(t *testing.T) http.Handler {
t.Helper()
fsys := fstest.MapFS{
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nAbout me.\n")},
"content/posts/hello/index.md": {Data: []byte("---\ntitle: Hello\n---\nFirst post.\n")},
}
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
r, err := render.New()
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r)
}
func TestServeBundleAtItsPermalink(t *testing.T) {
h := testHandler(t)
for _, path := range []string{"/pages/about/", "/posts/hello/"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200", path, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("GET %s content-type = %q", path, ct)
}
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/pages/about/", nil))
if body := rec.Body.String(); !strings.Contains(body, "<h1>About</h1>") || !strings.Contains(body, "About me.") {
t.Errorf("body did not render the bundle:\n%s", body)
}
}
func TestSlashlessPathRedirectsPermanently(t *testing.T) {
h := testHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/pages/about", nil))
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("got %d, want 301", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/pages/about/" {
t.Errorf("Location = %q", loc)
}
}
func TestUnknownPathsAre404(t *testing.T) {
h := testHandler(t)
for _, path := range []string{"/", "/nope/", "/pages/nope", "/pages/about/deeper/"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", path, rec.Code)
}
}
}