Files
khosra/cmd/khosra/main.go
T
Claude Opus 5andbdeshi 9100ce4876 notice content changes and rebuild without a restart
Polling lives in internal/ext/watch, per the human's call to keep core under its
ceiling rather than raise it a second time — which is what ADR-0041 said a second
raise would mean. It is a poller, deletable without trace, and core stayed at
2671/2800.

A settled change calls the same `rebuilder` that startup calls, because a reload
path that differs from the startup path is a reload path that drifts. The index is
an atomic.Pointer swapped whole, so a request reads the site that was current when
it arrived instead of one being rebuilt underneath it — the alternative, mutating in
place, is a data race with every in-flight request.

Names, sizes and modification times, not contents: reading every file to detect a
change costs more than the rebuild it triggers. Editor droppings are excluded,
because saving in vim writes a swap file, a backup and the number 4913, and each
would otherwise look like a change. A change must hold still for a moment first,
since one save is often several operations.

Verified against the running binary: a page 404s, the file appears, and five seconds
later it serves — one "site root changed" in the log. Then three droppings written
at once produced no rebuild at all.

Two warnings fired and were fixed rather than silenced: `runServe` gave up the
rebuild closure to `rebuilder`, and the fingerprint walk gave up its body to
`record`, where three exclusions read as a list instead of as nesting.

The Dockerfile ships the binary alone. The site root arrives as a volume and is
never copied in — it is somebody's content repository with its own history
(ADR-0011), so the image is the same for every site.
2026-07-31 14:00:53 +06:00

148 lines
4.9 KiB
Go

// Command khosra serves a site root over HTTP.
//
// Everything is assembled here and nowhere else: no init(), no package-level state (conventions.md).
package main
import (
"flag"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"khosra/internal/content"
"khosra/internal/ext/shortcodes"
"khosra/internal/ext/watch"
"khosra/internal/render"
"khosra/internal/web"
)
func main() {
// Subcommands, matched before the flags are defined: `check` validates a site root, `new` scaffolds a
// bundle, and a bare `khosra` serves. A switch while there are two; a table when there is a third with
// flags worth sharing.
if len(os.Args) > 1 {
switch os.Args[1] {
case "check":
runCheck(os.Args[2:])
return
case "new":
runNew(os.Args[2:])
return
}
}
runServe()
}
// runServe is what a bare `khosra` does: assemble everything and listen.
//
// Its own function so main() stays a dispatch table. Everything is still wired in one place, which is the rule
// that matters (conventions.md).
func runServe() {
site := flag.String("site", os.Getenv("KHOSRA_SITE"), "path to the site root (or KHOSRA_SITE)")
addr := flag.String("addr", "localhost:8080", "address to listen on")
base := flag.String("base", "", "canonical site origin, overriding site.yaml (e.g. https://khosra.example)")
cache := flag.String("cache", defaultCache(), "directory for generated files; never inside the site root")
dev := flag.String("dev", "", "set to 'on' to reveal drafts and future-dated bundles and reload templates")
flag.Parse()
if *site == "" {
fatal("no site root: pass -site or set KHOSRA_SITE", nil)
}
fsys, err := content.OpenSite(*site)
if err != nil {
fatal("cannot open the site root", err)
}
settings, err := content.LoadSettings(fsys)
if err != nil {
fatal("cannot read site settings", err)
}
if *base != "" {
settings.Base = strings.TrimSuffix(*base, "/")
}
renderer, err := render.New(fsys, settings, extenders)
if err != nil {
fatal("cannot prepare the theme", err)
}
if *dev == "on" {
// Never on by default and never a bare boolean flag: revealing unpublished work is a visibility
// change, and it should be impossible to enable by fumbling an argument (ADR-0024).
renderer.Reload()
slog.Warn("dev mode: drafts and future-dated bundles are visible, and templates reload")
}
// One atomic pointer, swapped whole: a request reads the index that was current when it arrived, never one
// being rebuilt underneath it (ADR-0022).
var live atomic.Pointer[content.Site]
rebuild := rebuilder(fsys, *cache, *dev == "on", &live)
count := rebuild()
if count < 0 {
fatal("cannot read content", nil)
}
derivedFS, err := content.OpenSite(*cache)
if err != nil {
slog.Error("generated files will not be served", "cache", *cache, "err", err)
}
// Noticing is the engine's job; fetching is not (ADR-0022). Nothing stops this loop, because the process
// ending is what stops it.
go watch.Watch(fsys, nil, func() { rebuild() })
slog.Info("serving", "site", *site, "bundles", count, "addr", *addr)
handler := web.Handler(live.Load, renderer, fsys, derivedFS, settings)
if err := http.ListenAndServe(*addr, handler); err != nil {
fatal("server stopped", err)
}
}
// rebuilder returns the function that reads the content, makes any missing derivatives, and swaps the index in.
//
// One function used at startup and again on every change, so the running site is always assembled the same way
// as a fresh one — a reload path that differs from the startup path is a reload path that drifts.
func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site]) func() int {
return func() int {
bundles, err := content.Scan(fsys)
if err != nil {
slog.Error("keeping the previous content: cannot read the site root", "err", err)
return -1
}
indexed := content.NewSite(bundles)
if reveal {
indexed.Reveal()
}
// Derivatives before the swap, so a picture is never referenced before it exists (ADR-0042). A failure is
// not fatal: pages still serve the author's originals, which is what the fallback is for.
if made, err := shortcodes.Derive(fsys, cache); err != nil {
slog.Error("some derivatives were not made", "cache", cache, "err", err)
} else if made > 0 {
slog.Info("made derivatives", "count", made)
}
live.Store(indexed)
return len(bundles)
}
}
// defaultCache is where generated files go when nothing says otherwise: the user's cache directory, never
// the site root, because the engine reads that and must not litter somebody's content git (ADR-0042).
func defaultCache() string {
dir, err := os.UserCacheDir()
if err != nil {
return ""
}
return filepath.Join(dir, "khosra", "derived")
}
// fatal reports a startup failure and exits. Startup failure is fatal and loud; request-time failure
// degrades instead (conventions.md).
func fatal(msg string, err error) {
if err != nil {
slog.Error(msg, "err", err)
} else {
slog.Error(msg)
}
os.Exit(1)
}