Files
khosra/internal/web/web.go
T
bdeshi 919d6fcd41 resolve sequences from the directory tree
A bundle nested under another bundle is a member of that series (ADR-0033), so
`Site.Sequence` walks up to the nearest bundle ancestor and back down to its
members: ordered by `order` where set, then by name. Members resolve through the
language fallback, so a chapter with no Bengali variant still holds its place in
Bengali reading order instead of breaking prev/next.

One `.Sequence` field carries both shapes a theme needs. A landing page renders
`.Members` as an archive; a chapter renders `.Prev`/`.Next`, which are pointers
into `.Members` so `{{with}}` yields nothing at the ends. `Index == 0` is what
tells the two apart.

`Query` was deliberately not extended. A series ascends where `Run` descends, and
an order knob on `Query` is the config knob rule 6 bans; instead `Site.keys()`
came out so both iterate the index one way, deleting `Run`'s own dedupe map.

`draft` is not honoured: no bundle carries the field and nothing else excludes
drafts, so entry 19 adds it in both places at once. Recorded in content-model.md
rather than left implied.

state.md also corrects six inventory rows that had drifted before this change —
three LOC figures, the test total, `go.mod`, and two lines that were flatly wrong
("Dependencies: none", "goldmark is not yet imported"). The coupling gate proves
state.md changed with the code; it cannot prove the numbers are right.
2026-07-30 03:03:20 +06:00

157 lines
5.3 KiB
Go

// Package web maps requests to bundles and writes bytes. It knows content and render, and exposes
// neither to them.
package web
import (
"io/fs"
"log/slog"
"net/http"
"strings"
"khosra/internal/content"
"khosra/internal/render"
)
// Handler serves a site.
//
// One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
func Handler(site *content.Site, r *render.Renderer, siteFS fs.FS) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
serve(w, req, site, r)
})
if siteFS != nil {
mux.Handle("GET /static/", http.StripPrefix("/static/", noListing(staticFS(siteFS))))
}
return mux
}
// staticFS serves the site root's static/ directory verbatim. It keeps the os.Root guarantee, because
// the fs.FS it is given is the one rooted there (ADR-0031).
func staticFS(siteFS fs.FS) http.Handler {
sub, err := fs.Sub(siteFS, "static")
if err != nil {
return http.NotFoundHandler()
}
return http.FileServerFS(sub)
}
// noListing refuses directory paths, so static/ never answers with an index of its own contents.
func noListing(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "" || strings.HasSuffix(req.URL.Path, "/") {
http.NotFound(w, req)
return
}
h.ServeHTTP(w, req)
})
}
// serveListing answers a section index, reporting whether it handled the request.
//
// A section is not a bundle, so this runs only after the bundle lookup misses. A page number past the
// end is a 404 rather than an empty page, because an empty page is a URL that means nothing.
func serveListing(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool {
if res.key == "" || strings.Contains(res.key, "/") {
return false
}
items := site.Run(content.Query{Section: res.key, Lang: res.lang})
if len(items) == 0 {
return false
}
if res.page > 1 && (res.page-1)*content.PerPage >= len(items) {
return false
}
out, err := r.Listing(res.key, res.lang, items, res.page)
if err != nil {
slog.Error("listing failed", "section", res.key, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return true
}
write(w, out, res.key)
return true
}
// serveTags answers a tag listing, grouped by section so a busy term stays readable (ADR-0018).
func serveTags(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool {
items := site.Run(content.Query{Section: res.key, Tag: res.tag, Lang: res.lang})
if len(items) == 0 {
return false
}
if res.page > 1 && (res.page-1)*content.PerPage >= len(items) {
return false
}
// Redirect only once the listing is known to exist, the same rule bundles follow: a canonical URL for
// nothing would confirm what is not there.
if res.redirect != "" {
http.Redirect(w, req, res.redirect, http.StatusMovedPermanently)
return true
}
out, err := r.Tag(res.key, res.tag, res.lang, items, res.page)
if err != nil {
slog.Error("tag listing failed", "tag", res.tag, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return true
}
write(w, out, res.tag)
return true
}
// write sends a rendered page, logging a failed write rather than pretending it succeeded.
func write(w http.ResponseWriter, out []byte, what string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(out); err != nil {
slog.Warn("write failed", "what", what, "err", err)
}
}
// serve resolves one request and writes its bundle.
func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer) {
res, ok := resolve(req.URL.Path, site)
if !ok {
http.NotFound(w, req)
return
}
if res.tag != "" {
if !serveTags(w, req, site, r, res) {
http.NotFound(w, req)
}
return
}
// A redirect target only exists for a path that resolves, so check the bundle before sending one:
// otherwise a nonexistent page answers 301 and confirms nothing.
b, served, found := site.Lookup(res.key, res.lang)
if res.redirect != "" && (found || res.key == "") {
http.Redirect(w, req, res.redirect, http.StatusMovedPermanently)
return
}
if !found {
// An alias is a promise that an old URL keeps working, so it answers a permanent redirect to the
// canonical one — and only for an alias that exists, so nothing can be probed by 301.
if canonical, isAlias := site.Alias(res.key); isAlias {
http.Redirect(w, req, content.URL(canonical, res.lang), http.StatusMovedPermanently)
return
}
if serveListing(w, req, site, r, res) {
return
}
http.NotFound(w, req)
return
}
// A bundle nested under another, or holding others, is part of a series; anything else renders with no
// sequence at all (ADR-0033).
var seq *content.Sequence
if series, inSeries := site.Sequence(b.Key, served); inSeries {
seq = &series
}
out, err := r.Bundle(b, served, site.Variants(res.key), seq)
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
}
write(w, out, b.Key)
}