-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.
63 lines
1.8 KiB
Go
63 lines
1.8 KiB
Go
// 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)
|
|
}
|
|
}
|