The demo writes a whole site root that exercises every feature: two languages with a fallback, a series with ordered chapters, a gallery, a figure, an include, extras, tags across sections, a slug with an alias, an undated page, a draft, a template override, static files and site.yaml. It generates its filler rather than copying stored files, because nothing in this repository is content (ADR-0011) — and that makes it a test of the engine rather than a fixture: anything khosra can do that the demo cannot express is a gap. Two things found by generating and then serving it, which is the whole point: `khosra check` reported the demo's own series as mixing ordered and unordered members. It was right — the chapter bodies *described* `order: 10` while the frontmatter never carried it. The checker caught its own author. And `/` was a **404**. ADR-0008 leaves the root engine-owned, which is right, but "engine-owned" was never given an answer, so a visitor to the site's own address got nothing. The root now lists every bundle, newest first, paginated like any other listing, and 404s only when nothing is published. A hand-written home page stays a separate decision, recorded as such. Verified end to end: 23 files written, 12 bundles, 12 derivatives, `check` clean, and every URL the demo promises answers — including the alias redirecting, the draft hidden, and the front page rendering through the site's *own* template override.
218 lines
8.2 KiB
Go
218 lines
8.2 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"
|
|
)
|
|
|
|
// Current returns the site as it is right now.
|
|
//
|
|
// A function rather than a pointer, so a background poller can swap what it returns and a request still sees one
|
|
// coherent index instead of one being rebuilt underneath it (ADR-0022).
|
|
type Current func() *content.Site
|
|
|
|
// Fixed is a Current for a site that never changes, which is every caller that does not watch for changes.
|
|
func Fixed(site *content.Site) Current { return func() *content.Site { return site } }
|
|
|
|
// Handler serves a site.
|
|
//
|
|
// One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
|
|
func Handler(current Current, r *render.Renderer, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
|
|
serve(w, req, current(), r, siteFS, settings)
|
|
})
|
|
// Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases: no
|
|
// bundle can own them, since a key always sits under a section.
|
|
mux.HandleFunc("GET "+robotsPath, func(w http.ResponseWriter, req *http.Request) {
|
|
serveRobots(w, req, siteFS, settings.Base)
|
|
})
|
|
mux.HandleFunc("GET "+sitemapPath, func(w http.ResponseWriter, req *http.Request) {
|
|
serveSitemap(w, req, current(), settings.Base)
|
|
})
|
|
if siteFS != nil {
|
|
if sub, err := fs.Sub(siteFS, "static"); err == nil {
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", serveStatic(sub)))
|
|
}
|
|
}
|
|
// Generated files, served as opaque names. Core knows only that a directory of them exists: which files
|
|
// are there, and what they are derived from, is the feature's business (ADR-0042).
|
|
if derivedFS != nil {
|
|
mux.Handle("GET "+content.DerivedPrefix,
|
|
http.StripPrefix(content.DerivedPrefix, serveStatic(derivedFS)))
|
|
}
|
|
return mux
|
|
}
|
|
|
|
// serveStatic 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).
|
|
//
|
|
// Anything it cannot serve answers 404: a directory, a missing file, or a name the root refuses because it
|
|
// resolves outside. A listing would expose the tree, and an error page for a refused symlink would confirm
|
|
// the path is there — the same reason a hidden bundle answers 404 rather than 403 (ADR-0024).
|
|
func serveStatic(sub fs.FS) http.Handler {
|
|
files := http.FileServerFS(sub)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
info, err := fs.Stat(sub, strings.TrimPrefix(req.URL.Path, "/"))
|
|
if err != nil || info.IsDir() {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
files.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 {
|
|
// An empty key is the site root, which lists everything. Anything with a slash in it is a bundle path that
|
|
// missed, not a section.
|
|
if 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) {
|
|
writeAs(w, "text/html; charset=utf-8", out, what)
|
|
}
|
|
|
|
// writeAs sends bytes with the type they actually are.
|
|
//
|
|
// Separate from write because headers are only sent with the first byte, so a handler that set its own type
|
|
// before calling write would have had it silently replaced by HTML — which is how a sitemap ends up served
|
|
// as a web page.
|
|
func writeAs(w http.ResponseWriter, contentType string, out []byte, what string) {
|
|
w.Header().Set("Content-Type", contentType)
|
|
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, siteFS fs.FS, settings content.Settings) {
|
|
res, ok := resolve(req.URL.Path, site)
|
|
if !ok {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
if res.feed {
|
|
if !serveFeed(w, req, site, res, settings) {
|
|
http.NotFound(w, req)
|
|
}
|
|
return
|
|
}
|
|
if res.extras {
|
|
if !serveExtras(w, req, site, r, siteFS, res) {
|
|
http.NotFound(w, req)
|
|
}
|
|
return
|
|
}
|
|
if res.tag != "" {
|
|
if !serveTags(w, req, site, r, res) {
|
|
http.NotFound(w, req)
|
|
}
|
|
return
|
|
}
|
|
serveBundle(w, req, site, r, siteFS, res)
|
|
}
|
|
|
|
// serveBundle answers a request that named a bundle, a section listing, or a file inside a bundle.
|
|
//
|
|
// Split from serve when that function crossed the length warning: serve decides *what kind* of thing was asked
|
|
// for, this one answers the commonest kind.
|
|
func serveBundle(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
|
|
siteFS fs.FS, res resolution) {
|
|
// 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.
|
|
// A request path is a route: a slug may have moved a bundle there, and moved another away (ADR-0035).
|
|
key, live := site.KeyFor(res.key)
|
|
b, served, found := site.Lookup(key, res.lang)
|
|
found = found && live
|
|
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(site.RouteOf(canonical), res.lang), http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
// A file inside a bundle's directory: how a relative src in a body resolves (ADR-0024).
|
|
if serveAsset(w, req, site, siteFS, res) {
|
|
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(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)
|
|
}
|