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.
148 lines
4.9 KiB
Go
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)
|
|
}
|