Files
khosra/internal/web/web.go
T
bdeshiandClaude Opus 5 69a7eb4733 move robots and sitemap out of core, and raise the ceiling on purpose
Item 0 of the roadmap's order of work, and it blocked everything after it: core
sat at 2965 of 3000 while the review scheduled four core-bound items, the first
of which — logging — wanted the whole remainder.

/robots.txt and /sitemap.xml are exact paths somebody else's software asks for by
name. They own no core concept and pass every test the architecture applies to a
feature; they lived in internal/web only because a feature could not own a route
until ADR-0081. internal/ext/discover/ now holds them. Core 2965 → 2913.

The seam gained one parameter to make it possible: a func() *content.Site, since a
sitemap must list what is served now and the index is swapped whole on every
rebuild (ADR-0077). A captured pointer would have frozen the site at startup —
which is the kind of bug that only shows up after a rebuild, in production.

The ceiling rises to 3400 as well as the move, because the move alone could not buy
the room. feed.go and web/extras.go cannot follow discover out: a feed lives at
/{section}/feed.xml and extras under a bundle's own URL, so both are resolver cases
while the seam mounts exact paths only. Raising by the minimum that unblocks one
item produces a ceiling nobody believes, so 3400 fits the View cluster with
headroom. HARNESS.md asks that a raise be read as evidence something belongs in
ext before evidence the number was small; both readings were true, so both actions
were taken.

web no longer reserves those two paths, so a clash between features is wire.go's:
it merges route maps in declaration order, keeps the earlier claim, logs the loser.
Verified — a site shipping root/robots.txt starts, serves the engine's robots.txt,
and logs the passthrough claim, where an unguarded mux.Handle would have panicked.

Evidence: robots.txt and sitemap.xml are byte-identical before and after the move
against the demo site (67 and 2701 bytes, cmp clean), and the sitemap keeps its
application/xml type.

One real cost, recorded in both places rather than hidden. internal/web's
visibility test asserted that a listing, a feed *and* a sitemap all hide
unpublished bundles — one property, one test, because all three share a Query. The
sitemap half moved to the feature instead of a web test importing ext, which would
invert the one-way layering the architecture gate enforces. That property is now
asserted twice, once per package owning a surface.

Three gates caught real mistakes on the way: the staged-tree check found a partial
stage where git rm had staged a deletion while the caller edits were unstaged, the
coupling gates demanded state.md and HARNESS.md, and the nesting advisory rejected
a closure that put the merge loop one level too deep — fixed by making it a plain
function rather than tolerated.

Extensions 6 → 7. Routing cases unmoved: exact paths are mux entries, never
resolver cases, which is what that counter's exclusion column already said.

13 files. Core 2913/3400, ext 2495/3500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:26:40 +06:00

242 lines
9.5 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"
)
// Snapshot is the content and the theme that were current together.
//
// One value rather than two, because a page assembled from a new theme and the previous index is a page that
// never existed on disk. A rebuild makes both and swaps them in one store (ADR-0077).
type Snapshot struct {
Site *content.Site
Theme *render.Renderer
}
// Current returns the snapshot 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 pair instead of one being rebuilt underneath it (ADR-0022).
type Current func() *Snapshot
// 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, theme *render.Renderer) Current {
snap := &Snapshot{Site: site, Theme: theme}
return func() *Snapshot { return snap }
}
// Handler serves a site.
//
// One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
// Handler builds the mux. routes are exact URL paths a feature owns, keyed by path — the one part of the
// extension contract that is earned, because a feature finally wants a route (ADR-0081, the seam ADR-0042
// named). Core learns only that some paths belong to somebody else; which files answer them, and what they
// contain, is the feature's business — the same division `/derived/` already uses.
func Handler(current Current, siteFS, derivedFS fs.FS, settings content.Settings, routes map[string]http.Handler) http.Handler {
mux := http.NewServeMux()
// Core's own answers, which a feature may not take over. /robots.txt and /sitemap.xml are no longer here:
// they moved to a feature (ADR-0085), so a clash between two features is wire.go's to detect.
reserved := map[string]bool{"/": true}
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
now := current()
serve(w, req, now.Site, now.Theme, siteFS, settings)
})
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)))
}
// A feature's routes go on last. A path core already answers is skipped, not overridden: http.ServeMux
// panics on a duplicate pattern, so without this a feature claiming "/" would take the server down at
// startup rather than lose a race it was never told about. Clashes *between* features are settled in
// cmd/khosra/wire.go, which is the only place that knows features exist.
for pattern, handler := range routes {
if reserved[pattern] || strings.HasPrefix(pattern, "/static/") || strings.HasPrefix(pattern, content.DerivedPrefix) {
slog.Warn("a feature claims a path the engine already answers; it is not served",
"path", pattern)
continue
}
mux.Handle("GET "+pattern, handler)
}
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)
}