A site's templates/ is parsed after the embedded set, so the last definition of a name wins and a theme redefines one block while inheriting the document (ADR-0019). Per kind exactly two files are overlaid — base.html and that kind's block — because overlaying every site template into every set lets a listing's "main" leak into bundle pages, which is the collision per-kind sets exist to prevent. Both directions are tested. templates/theme.css in the site root replaces the reference stylesheet outright; there is no merging to reason about. static/ is served verbatim under /static/, through the same os.Root-backed fs.FS, and directory paths answer 404 so it never indexes its own contents. Queue entries 6 and 7 are deferred again with triggers: the cascade's consumers are stage toggles, view selection and cache flags — none of which exist — and declared types are read by ordering, feeds and check, none of which have landed. Building either now is the speculation that justified withdrawing them. Evidence: a real site override renders <section class="mine"> inside the embedded document with canonical intact, the listing still uses the embedded block, and /static/site.css and /static/img/logo.svg are 200 while /static/ and /static/img/ are 404.
118 lines
3.9 KiB
Go
118 lines
3.9 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
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if _, err := w.Write(out); err != nil {
|
|
slog.Warn("write failed", "section", res.key, "err", err)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// 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
|
|
}
|
|
out, err := r.Bundle(b, served, site.Variants(res.key))
|
|
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)
|
|
}
|
|
}
|