Files
khosra/internal/web/web.go
T
bdeshi f4ba695e87 generate sized derivatives ahead of the request
A pass over the content at startup writes three widths per picture into a cache
outside the site root, named by the source's content hash and the width (ADR-0042).
Idempotent by construction: a rerun stats and skips, an edited picture takes a new
name, and nothing stale can be served under an old one. Restarting the evidence
site made 0 derivatives the second time, as it should.

Ahead of the request rather than during it, because resampling is felt and there is
no page cache yet to hide it. Outside the site root, because the engine reads that
directory and must not leave generated files in somebody's content git — a lost
cache costs one startup pass and no correctness.

Markup now carries the original as src, the derivatives as srcset closed by the
original at its own width, and width/height from the original — which retires most
of the latent row about the output floor; only a gallery's alt is still empty, and
a filename cannot supply that.

Two things the work itself decided:

`Fragment.Items` became `Fragment.Pictures`, ADR-0037's own revisit trigger. Items
had one consumer, so widening it beat adding a second list beside it.

"A browser can show it" and "we can resample it" are different questions, and
conflating them nearly deleted content: an SVG has no decoder here, so a single
predicate would have dropped SVGs from galleries silently. Undecodable and
unsupported pictures are now rendered as they are, without a size or a srcset.
2026-07-31 03:57:52 +06:00

186 lines
6.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, derivedFS fs.FS, settings content.Settings) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
serve(w, req, site, r, siteFS)
})
// 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, site, 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 {
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) {
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) {
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.
// 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)
}