log every request, and make error mean something again
Item 1 of the order of work, and two gaps rather than one polish item. There was no access log. Nothing recorded that a request happened, and the Dockerfile ships the binary alone with the site root mounted (ADR-0010), so a bare deployment produced none at all — which mattered because offline log analysis is this project's answer to analytics: no counter on the read path, no third-party script. web.Logged now writes one Info line per request with method, path, status, bytes and duration. And slog was never configured. conventions.md makes importing `log` instead of log/slog a hard failure while nothing ever set a level, a handler or a format. Two flags now do, rejected at startup if unusable, because a logger quietly less verbose than asked for hides exactly the lines somebody changed the flag to see. JSON is the half that matters: it is what makes a log parseable. The re-levelling was the larger half. Counts were 38 error, 7 warn, 4 info, 0 debug; they are now 7, 40, 6, 0. Almost every one of those errors was ADR-0029's "logged, not fatal" category — a misspelled directive, an asset path climbing out of its bundle, an unreadable picture — where the engine coped and the reader still got a good page. Error used as "somebody should see this" means an operator cannot tell a broken build from a typo. The seven that remain are the five requests that answer 500 and the two inside fatal. A successful rebuild now says so. It swapped silently before, so an operator could see a failed rebuild and never a successful one, which leaves the failures with nothing to be read against. No wrapper package: log/slog is the module, and a layer over it would be an abstraction with one caller. Duration comes from content.Now, since the clock is confined to one file and verify.sh enforces it by filename. The recorder does not forward Flusher or ReaderFrom — nothing here streams, so the cost is one io.Copy fast path on static files, and implementing interfaces no caller needs is the speculation rule 6 forbids. Two things this change owed and paid. content.go's comment still said content problems were "logged at error level", which the re-levelling made false. And the new flags pushed runServe past the function-length advisory, so the site-opening block became opened() — a warning that fires on correct code gets acted on, not tolerated, and that is the whole reason the advisory exists. Evidence, demo site, JSON: rebuilt bundles=31, then serving, then one request line each for a 200, a 404 and robots.txt with real byte counts and durations. At -log-level warn, request lines disappear. An invalid level exits with the reason. 12 files. Core 2913 → 2959 of 3400. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+66
-19
@@ -5,6 +5,7 @@ package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -48,22 +49,18 @@ func runServe() {
|
||||
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")
|
||||
poll := flag.Duration("poll", 2*time.Second, "how often to look for changes; 0 disables watching (ADR-0022)")
|
||||
level := flag.String("log-level", "info", "log verbosity: debug, info, warn or error")
|
||||
format := flag.String("log-format", "text", "log format: text or json")
|
||||
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, "/")
|
||||
// Before anything else can log, so a rejected level cannot hide the message saying it was rejected. Only
|
||||
// `serve` takes these: `check` and `new` are short-lived and print their own findings, so configuring a
|
||||
// logger for them would be ceremony.
|
||||
if err := logging(*level, *format); err != nil {
|
||||
fatal("cannot configure logging", err)
|
||||
}
|
||||
|
||||
fsys, settings := opened(*site, *base)
|
||||
// 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).
|
||||
interval := pollInterval(*dev == "on", *poll)
|
||||
@@ -84,18 +81,42 @@ func runServe() {
|
||||
|
||||
derivedFS, err := content.OpenSite(*cache)
|
||||
if err != nil {
|
||||
slog.Error("generated files will not be served", "cache", *cache, "err", err)
|
||||
slog.Warn("generated files will not be served", "cache", *cache, "err", err)
|
||||
}
|
||||
watching(fsys, interval, rebuild)
|
||||
|
||||
slog.Info("serving", "site", *site, "bundles", count, "addr", *addr)
|
||||
handler := web.Handler(live.Load, fsys, derivedFS, settings,
|
||||
routes(fsys, settings, func() *content.Site { return live.Load().Site }))
|
||||
handler := web.Logged(web.Handler(live.Load, fsys, derivedFS, settings,
|
||||
routes(fsys, settings, func() *content.Site { return live.Load().Site })))
|
||||
if err := http.ListenAndServe(*addr, handler); err != nil {
|
||||
fatal("server stopped", err)
|
||||
}
|
||||
}
|
||||
|
||||
// opened resolves the site root and the settings that describe it.
|
||||
//
|
||||
// Extracted when the log flags pushed runServe past the function-length advisory: a warning that fires on
|
||||
// correct code gets acted on rather than tolerated (`conventions.md`). Every failure here is fatal, because a
|
||||
// server with no content is a misconfiguration rather than a degraded server.
|
||||
func opened(dir, base string) (fs.FS, content.Settings) {
|
||||
if dir == "" {
|
||||
fatal("no site root: pass -site or set KHOSRA_SITE", nil)
|
||||
}
|
||||
fsys, err := content.OpenSite(dir)
|
||||
if err != nil {
|
||||
fatal("cannot open the site root", err)
|
||||
}
|
||||
settings, err := content.LoadSettings(fsys)
|
||||
if err != nil {
|
||||
fatal("cannot read site settings", err)
|
||||
}
|
||||
// -base overrides site.yaml, so a staging host needs no edit to content (ADR-0039).
|
||||
if base != "" {
|
||||
settings.Base = strings.TrimSuffix(base, "/")
|
||||
}
|
||||
return fsys, settings
|
||||
}
|
||||
|
||||
// pollInterval is how often to look for a change, and where `-dev on` gets its promptness (ADR-0056):
|
||||
// authoring wants an edit applied quickly, but never faster than an interval the operator chose deliberately.
|
||||
func pollInterval(dev bool, chosen time.Duration) time.Duration {
|
||||
@@ -131,12 +152,12 @@ func rebuilder(fsys fs.FS, settings content.Settings, cache string, reveal bool,
|
||||
return func() int {
|
||||
renderer, err := theme(fsys, settings)
|
||||
if err != nil {
|
||||
slog.Error("keeping the previous theme: cannot parse the new one", "err", err)
|
||||
slog.Warn("keeping the previous theme: cannot parse the new one", "err", err)
|
||||
return -1
|
||||
}
|
||||
bundles, err := content.Scan(fsys)
|
||||
if err != nil {
|
||||
slog.Error("keeping the previous content: cannot read the site root", "err", err)
|
||||
slog.Warn("keeping the previous content: cannot read the site root", "err", err)
|
||||
return -1
|
||||
}
|
||||
indexed := content.NewSite(bundles)
|
||||
@@ -147,11 +168,15 @@ func rebuilder(fsys fs.FS, settings content.Settings, cache string, reveal bool,
|
||||
// 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)
|
||||
slog.Warn("some derivatives were not made", "cache", cache, "err", err)
|
||||
} else if made > 0 {
|
||||
slog.Info("made derivatives", "count", made)
|
||||
}
|
||||
live.Store(&web.Snapshot{Site: indexed, Theme: renderer})
|
||||
// The engine used to swap silently, so an operator watching the log could see a *failed* rebuild and
|
||||
// never a successful one — which makes the failures unreadable, since there is nothing to compare them
|
||||
// against.
|
||||
slog.Info("rebuilt", "bundles", len(bundles))
|
||||
return len(bundles)
|
||||
}
|
||||
}
|
||||
@@ -176,3 +201,25 @@ func fatal(msg string, err error) {
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// logging installs the default logger for the process.
|
||||
//
|
||||
// Two knobs and no wrapper package: `log/slog` is the module, and a layer over it would be an abstraction
|
||||
// with one caller (conventions.md, ADR-0083's reasoning applied to the standard library). JSON exists because
|
||||
// it is what makes a log parseable, which is the whole point of having one.
|
||||
func logging(level, format string) error {
|
||||
var l slog.Level
|
||||
if err := l.UnmarshalText([]byte(level)); err != nil {
|
||||
return fmt.Errorf("log level %q: want debug, info, warn or error", level)
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: l}
|
||||
switch format {
|
||||
case "text":
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, opts)))
|
||||
case "json":
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, opts)))
|
||||
default:
|
||||
return fmt.Errorf("log format %q: want text or json", format)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The two log flags are the whole logging configuration, so a bad value must be rejected at startup rather
|
||||
// than silently falling back — a logger quietly less verbose than asked for hides exactly the lines somebody
|
||||
// changed the flag to see.
|
||||
func TestLoggingRejectsWhatItCannotHonour(t *testing.T) {
|
||||
prev := slog.Default()
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
level, format string
|
||||
wantErr bool
|
||||
}{
|
||||
{"the defaults", "info", "text", false},
|
||||
{"debug in json", "debug", "json", false},
|
||||
{"levels are case-insensitive", "WARN", "text", false},
|
||||
{"error level", "error", "json", false},
|
||||
{"a level nobody defined", "shouty", "text", true},
|
||||
{"a format nobody defined", "info", "yaml", true},
|
||||
} {
|
||||
err := logging(c.level, c.format)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Errorf("%s: logging(%q, %q) error = %v, want error: %v", c.what, c.level, c.format, err, c.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1531,3 +1531,32 @@ gate enforces. The property is now asserted twice, once per package that owns a
|
||||
loss, recorded in both tests.
|
||||
Revisit if: the resolver gains feature participation, at which point `feed.go` and `web/extras.go` can leave
|
||||
too and the ceiling should be reconsidered downward rather than left as headroom.
|
||||
|
||||
## ADR-0086 — One log line per request, and error stops meaning "look at this"
|
||||
Date: 2026-08-03 · Status: accepted
|
||||
Decision: `cmd` configures exactly one logger behind `-log-level` (debug/info/warn/error) and `-log-format`
|
||||
(text/json), rejecting an unusable value at startup rather than falling back. `web.Logged` wraps the finished
|
||||
handler and writes one Info line per request — method, path, status, bytes, duration — which is the access
|
||||
log this engine did not have. Levels are fixed in `conventions.md`: **error** is something the engine could
|
||||
not do (a request or build step failed, startup aborting), **warn** is something it worked around while still
|
||||
serving, **info** is lifecycle and requests, **debug** is off by default.
|
||||
Why: `conventions.md` made importing `log` instead of `log/slog` a hard failure while never configuring
|
||||
slog — no level, no JSON, default handler. And nothing recorded requests at all, so a deployment of the
|
||||
binary alone (ADR-0010) produced no access log, which matters because offline log analysis is this project's
|
||||
answer to analytics: no counter on the read path, no third-party script.
|
||||
The re-levelling is the larger half. Counts were 38 error, 7 warn, 4 info, 0 debug; they are now 7, 40, 5, 0.
|
||||
Almost every one of those errors was ADR-0029's "logged, not fatal" category — a misspelled directive, an
|
||||
asset path climbing out of its bundle, an unreadable picture — where the engine coped and the reader still got
|
||||
a good page. Error used as "I want somebody to see this" means an operator cannot tell a broken build from a
|
||||
typo, which is the same failure as a warning nobody can act on. The seven that remain are the five requests
|
||||
that answer 500 and the two inside `fatal`.
|
||||
Consequence: the middleware is applied by `cmd` rather than inside `Handler`, so tests and the demo's coverage
|
||||
test stay quiet and logging is the operator's choice. Duration comes from `content.Now` because the clock is
|
||||
confined to one file and `verify.sh` enforces it by filename. The recorder deliberately does not forward
|
||||
`Flusher` or `ReaderFrom`: nothing here streams, so the only cost is an `io.Copy` fast path on static files,
|
||||
and implementing interfaces no caller needs is the speculation rule 6 forbids. A successful rebuild now logs
|
||||
too — it swapped silently before, which made the failures unreadable for want of anything to compare them to.
|
||||
No wrapper package: `log/slog` is the module, and a layer over it would be an abstraction with one caller.
|
||||
Only `serve` takes the flags; `check` and `new` are short-lived and print their own findings.
|
||||
Revisit if: request logging shows up in a profile, or an operator needs per-route levels — neither of which a
|
||||
two-flag configuration can express, and both of which would be evidence for a real logging design.
|
||||
|
||||
+7
-1
@@ -41,8 +41,9 @@ table owns.
|
||||
| `internal/web/extras.go` | the extras route: listing, one entry selected, or `?raw` bytes, all behind the bundle lookup |
|
||||
| `internal/web/asset.go` | files inside a bundle's own directory, looked up through the owning bundle so visibility can only ever inherit (ADR-0024) |
|
||||
| `internal/web/feed.go` | Atom for the site, a section or a tag, from dated bundles via one Query (ADR-0043) |
|
||||
| `internal/web/logging.go` | the access log: one Info line per request with method, path, status, bytes and duration, wrapped around the finished handler by `cmd` so tests stay quiet. Duration comes from `content.Now`, since the clock lives in one file (ADR-0086) |
|
||||
| `internal/web/web.go` | handler: `Snapshot` pairs the index with the theme that was current with it (ADR-0077); `serve` dispatches by kind, `serveBundle` answers the commonest one; listings, `/static/`, `/derived/`, degrade on failure. Mounts the exact paths features own, skipping any the engine already answers — a duplicate pattern would panic (ADR-0081). Since ADR-0085 it reserves only `/`: `/robots.txt` and `/sitemap.xml` are a feature's, so a clash *between* features is `wire.go`'s to settle |
|
||||
| `cmd/khosra/main.go` | flags (including `-poll`, zero to stop watching), wiring, startup, the derivative pass, and the one atomic swap a change goes through, theme and index together in `rebuilder` (ADR-0077). `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
|
||||
| `cmd/khosra/main.go` | flags (including `-poll`, zero to stop watching, and `-log-level`/`-log-format` which configure the one logger before anything can use it — ADR-0086), wiring, startup, the derivative pass, and the one atomic swap a change goes through, theme and index together in `rebuilder` (ADR-0077). `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
|
||||
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature |
|
||||
| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing |
|
||||
| `*_test.go` | table-driven, one file per source file — `shortcodes` has one each for icons, containers and the contents list; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras, change detection, what a page can reach, the root listing, and the example site end to end — that last one in `cmd/khosra`, beside the wiring it proves (ADR-0072) |
|
||||
@@ -51,6 +52,11 @@ table owns.
|
||||
the file as text with the site's own settings and is dropped from the address, and `root/_headers.yaml`
|
||||
declares headers per exact path (ADR-0081). Paths the engine already answers are skipped and logged.
|
||||
|
||||
Every request produces one log line, and levels mean one thing each: **error** is something the engine could
|
||||
not do, **warn** is something it worked around while still serving — all of ADR-0029's category — **info** is
|
||||
lifecycle and requests, **debug** is off by default (ADR-0086, `conventions.md`). `-log-level` and
|
||||
`-log-format` (text or json) configure it.
|
||||
|
||||
A page carries only the CSS and JS its own shortcode calls or its `use:` list asked for, rendered once each from the theme's `assets:<name>` fragments, plus its own `styles`/`scripts` files — bundle-relative, anything climbing out dropped (ADR-0079, ADR-0080). The reference theme emits the stylesheets and **no `<script>` at all**, which `verify.sh` enforces; `examples/demo-site` redefines the `head` block to add the tag, so the one JavaScript exception is demonstrated by a site rather than built into the binary.
|
||||
|
||||
Serves a listing of everything at `/` (ADR-0050), a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
|
||||
|
||||
+45
-39
@@ -6,27 +6,29 @@ Every top-level declaration in the engine, with its line. Read this before openi
|
||||
file: it answers "where does X live" and "what is in this package" without the bodies. What each
|
||||
file is *for* lives in `state.md`; why it is that way lives in `decisions.md`.
|
||||
|
||||
## cmd/khosra — 346 lines + 309 test
|
||||
## cmd/khosra — 393 lines + 341 test
|
||||
|
||||
check.go 45 · main.go 178 · new.go 42 · wire.go 81
|
||||
check.go 45 · main.go 225 · new.go 42 · wire.go 81
|
||||
|
||||
- check.go:16 func runCheck(args []string)
|
||||
- main.go:23 func main()
|
||||
- main.go:44 func runServe()
|
||||
- main.go:101 func pollInterval(dev bool, chosen time.Duration) time.Duration
|
||||
- main.go:117 func watching(fsys fs.FS, every time.Duration, rebuild func() int)
|
||||
- main.go:129 func rebuilder(fsys fs.FS, settings content.Settings, cache string, reveal bool,
|
||||
- main.go:161 func defaultCache() string
|
||||
- main.go:171 func fatal(msg string, err error)
|
||||
- main.go:24 func main()
|
||||
- main.go:45 func runServe()
|
||||
- main.go:101 func opened(dir, base string) (fs.FS, content.Settings)
|
||||
- main.go:122 func pollInterval(dev bool, chosen time.Duration) time.Duration
|
||||
- main.go:138 func watching(fsys fs.FS, every time.Duration, rebuild func() int)
|
||||
- main.go:150 func rebuilder(fsys fs.FS, settings content.Settings, cache string, reveal bool,
|
||||
- main.go:186 func defaultCache() string
|
||||
- main.go:196 func fatal(msg string, err error)
|
||||
- main.go:210 func logging(level, format string) error
|
||||
- new.go:12 func runNew(args []string)
|
||||
- wire.go:22 func theme(siteFS fs.FS, settings content.Settings) (*render.Renderer, error)
|
||||
- wire.go:35 func extenders(partial render.Partial) []goldmark.Extender
|
||||
- wire.go:61 func routes(siteFS fs.FS, settings content.Settings, site func() *content.Site) map[string]http.Handler
|
||||
- wire.go:72 func claim(out map[string]http.Handler, feature string, from map[string]http.Handler)
|
||||
|
||||
## internal/content — 1074 lines + 598 test
|
||||
## internal/content — 1076 lines + 598 test
|
||||
|
||||
clock.go 12 · content.go 481 · doc.go 5 · extras.go 92 · settings.go 59 · site.go 425
|
||||
clock.go 12 · content.go 483 · doc.go 5 · extras.go 92 · settings.go 59 · site.go 425
|
||||
|
||||
- clock.go:9 var now = time.Now
|
||||
- clock.go:12 func Now() time.Time { return now() }
|
||||
@@ -34,32 +36,32 @@ clock.go 12 · content.go 481 · doc.go 5 · extras.go 92 · settings.go 59 · s
|
||||
- content.go:27 type Bundle struct
|
||||
- content.go:77 func OpenSite(dir string) (fs.FS, error)
|
||||
- content.go:89 type Problem struct
|
||||
- content.go:101 func Scan(fsys fs.FS) ([]Bundle, error)
|
||||
- content.go:110 func ScanReport(fsys fs.FS) ([]Bundle, []Problem, error)
|
||||
- content.go:149 func Parse(name string, data []byte) (Bundle, error)
|
||||
- content.go:196 func (b Bundle) Published(at time.Time) bool
|
||||
- content.go:205 func (b Bundle) Assets() (string, bool)
|
||||
- content.go:215 func stringList(v any) []string
|
||||
- content.go:237 func asTime(v any) time.Time
|
||||
- content.go:253 func asInt(v any) int
|
||||
- content.go:271 func bundleFiles(v any, where string) []string
|
||||
- content.go:283 func terms(v any) []string
|
||||
- content.go:307 func TagSlug(tag string) string
|
||||
- content.go:316 func Normalise(s string) string { return norm.NFC.String(s) }
|
||||
- content.go:322 func splitName(name string) (key, lang string, ok bool)
|
||||
- content.go:344 func isLangTag(s string) bool
|
||||
- content.go:362 func isPartial(base string) bool
|
||||
- content.go:374 func skipDir(base string) bool
|
||||
- content.go:382 func splitFrontmatter(data []byte) (front, body []byte)
|
||||
- content.go:397 func trimLeadingFence(data []byte, fence string) ([]byte, bool)
|
||||
- content.go:414 func dropCollisions(all []Bundle) ([]Bundle, []Problem)
|
||||
- content.go:436 const PerPage = 10
|
||||
- content.go:442 func URL(key, lang string) string
|
||||
- content.go:455 func TagURL(section, slug, lang string, page int) string
|
||||
- content.go:465 const DerivedPrefix = "/derived/"
|
||||
- content.go:468 func DerivedURL(name string) string { return DerivedPrefix + name }
|
||||
- content.go:471 const TagsSegment = "tags"
|
||||
- content.go:475 func PageURL(key, lang string, page int) string
|
||||
- content.go:103 func Scan(fsys fs.FS) ([]Bundle, error)
|
||||
- content.go:112 func ScanReport(fsys fs.FS) ([]Bundle, []Problem, error)
|
||||
- content.go:151 func Parse(name string, data []byte) (Bundle, error)
|
||||
- content.go:198 func (b Bundle) Published(at time.Time) bool
|
||||
- content.go:207 func (b Bundle) Assets() (string, bool)
|
||||
- content.go:217 func stringList(v any) []string
|
||||
- content.go:239 func asTime(v any) time.Time
|
||||
- content.go:255 func asInt(v any) int
|
||||
- content.go:273 func bundleFiles(v any, where string) []string
|
||||
- content.go:285 func terms(v any) []string
|
||||
- content.go:309 func TagSlug(tag string) string
|
||||
- content.go:318 func Normalise(s string) string { return norm.NFC.String(s) }
|
||||
- content.go:324 func splitName(name string) (key, lang string, ok bool)
|
||||
- content.go:346 func isLangTag(s string) bool
|
||||
- content.go:364 func isPartial(base string) bool
|
||||
- content.go:376 func skipDir(base string) bool
|
||||
- content.go:384 func splitFrontmatter(data []byte) (front, body []byte)
|
||||
- content.go:399 func trimLeadingFence(data []byte, fence string) ([]byte, bool)
|
||||
- content.go:416 func dropCollisions(all []Bundle) ([]Bundle, []Problem)
|
||||
- content.go:438 const PerPage = 10
|
||||
- content.go:444 func URL(key, lang string) string
|
||||
- content.go:457 func TagURL(section, slug, lang string, page int) string
|
||||
- content.go:467 const DerivedPrefix = "/derived/"
|
||||
- content.go:470 func DerivedURL(name string) string { return DerivedPrefix + name }
|
||||
- content.go:473 const TagsSegment = "tags"
|
||||
- content.go:477 func PageURL(key, lang string, page int) string
|
||||
- extras.go:14 const ExtrasDir = "extras"
|
||||
- extras.go:17 type Entry struct
|
||||
- extras.go:33 func Extras(fsys fs.FS, b Bundle) []Entry
|
||||
@@ -367,9 +369,9 @@ chrome.go 115 · render.go 499 · view.go 192
|
||||
- view.go:167 type Picture struct
|
||||
- view.go:184 type Origin struct
|
||||
|
||||
## internal/web — 687 lines + 1324 test
|
||||
## internal/web — 745 lines + 1396 test
|
||||
|
||||
asset.go 58 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 241
|
||||
asset.go 58 · extras.go 93 · feed.go 125 · logging.go 58 · resolve.go 170 · web.go 241
|
||||
|
||||
- asset.go:22 func serveAsset(w http.ResponseWriter, req *http.Request, site *content.Site, siteFS fs.FS, res resolution) bool
|
||||
- extras.go:18 func serveExtras(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
|
||||
@@ -383,6 +385,10 @@ asset.go 58 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 241
|
||||
- feed.go:52 func serveFeed(w http.ResponseWriter, req *http.Request, site *content.Site, res resolution, settings content.Settings) bool
|
||||
- feed.go:102 func dated(all []content.Bundle) []content.Bundle
|
||||
- feed.go:113 func feedTitle(settings content.Settings, res resolution) string
|
||||
- logging.go:20 func Logged(h http.Handler) http.Handler
|
||||
- logging.go:43 type recorder struct
|
||||
- logging.go:49 func (r *recorder) WriteHeader(status int)
|
||||
- logging.go:54 func (r *recorder) Write(b []byte) (int, error)
|
||||
- resolve.go:12 type resolution struct
|
||||
- resolve.go:40 func resolve(path string, site *content.Site) (resolution, bool)
|
||||
- resolve.go:102 func cutLang(key string, site *content.Site) (lang, rest, redirect string)
|
||||
|
||||
@@ -96,12 +96,14 @@ type Problem struct {
|
||||
// Scan reads every bundle under content/ in fsys, logging anything it worked around.
|
||||
//
|
||||
// A bundle that cannot be parsed, or that collides with another on the same key and language, is logged
|
||||
// at error level and left out; neither is fatal, because one mistyped colon must not take down a site
|
||||
// (ADR-0029). An error is returned only when the walk itself fails.
|
||||
// at **warn** and left out; neither is fatal, because one mistyped colon must not take down a site
|
||||
// (ADR-0029). Warn rather than error is the whole point of the distinction: the engine coped and the site
|
||||
// still serves, so error stays reserved for a request or a build step that actually failed
|
||||
// (`conventions.md`). An error is returned only when the walk itself fails.
|
||||
func Scan(fsys fs.FS) ([]Bundle, error) {
|
||||
found, problems, err := ScanReport(fsys)
|
||||
for _, p := range problems {
|
||||
slog.Error("content problem", "path", p.Path, "detail", p.Detail)
|
||||
slog.Warn("content problem", "path", p.Path, "detail", p.Detail)
|
||||
}
|
||||
return found, err
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *Site) Problems() []Problem { return s.problems }
|
||||
// note records a problem and logs it, so the server says the same thing it always did.
|
||||
func (s *Site) note(path, detail string) {
|
||||
s.problems = append(s.problems, Problem{path, detail})
|
||||
slog.Error("content problem", "path", path, "detail", detail)
|
||||
slog.Warn("content problem", "path", path, "detail", detail)
|
||||
}
|
||||
|
||||
// indexRoutes resolves each key's served path from the slugs its variants declare.
|
||||
|
||||
@@ -78,7 +78,7 @@ func Routes(siteFS fs.FS, settings content.Settings) map[string]http.Handler {
|
||||
// A missing directory is the ordinary case for a site that wants none of this, so it is not worth a
|
||||
// line in the log; anything else is.
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
slog.Error("cannot read the passthrough directory", "dir", Dir, "err", err)
|
||||
slog.Warn("cannot read the passthrough directory", "dir", Dir, "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func readHeaders(siteFS fs.FS) map[string]map[string]string {
|
||||
if err := yaml.Unmarshal(data, &declared); err != nil {
|
||||
// One bad manifest must not take the site down, so the files still serve with derived types
|
||||
// (ADR-0029).
|
||||
slog.Error("cannot parse the passthrough header manifest, so no declared headers apply",
|
||||
slog.Warn("cannot parse the passthrough header manifest, so no declared headers apply",
|
||||
"file", path.Join(Dir, headersFile), "err", err)
|
||||
return nil
|
||||
}
|
||||
@@ -117,7 +117,7 @@ type file struct {
|
||||
func (f *file) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := fs.ReadFile(f.siteFS, f.name)
|
||||
if err != nil {
|
||||
slog.Error("a passthrough file vanished between startup and this request", "file", f.name, "err", err)
|
||||
slog.Warn("a passthrough file vanished between startup and this request", "file", f.name, "err", err)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func (f *file) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
// The address is promised, and a spec-mandated file answering 404 because of a typo is worse
|
||||
// than one answering with its own source. Logged loudly, served anyway (ADR-0029).
|
||||
slog.Error("a passthrough template did not render, so its source is served instead",
|
||||
slog.Warn("a passthrough template did not render, so its source is served instead",
|
||||
"file", f.name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,12 +118,12 @@ func readFence(f *ast.FencedCodeBlock, source []byte, origin render.Origin) *cod
|
||||
// template or a dotfile (ADR-0038).
|
||||
func fromFile(origin render.Origin, name, span string) ([]byte, int, bool) {
|
||||
if origin.Files == nil || strings.Contains(name, "..") {
|
||||
slog.Error("code block cannot read that file", "file", name)
|
||||
slog.Warn("code block cannot read that file", "file", name)
|
||||
return nil, 0, false
|
||||
}
|
||||
data, err := fs.ReadFile(origin.Files, path.Join(origin.Dir, name))
|
||||
if err != nil {
|
||||
slog.Error("code block cannot read that file", "file", name, "err", err)
|
||||
slog.Warn("code block cannot read that file", "file", name, "err", err)
|
||||
return nil, 0, false
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
@@ -180,7 +180,7 @@ func (f fragments) renderCode(w util.BufWriter, source []byte, n ast.Node, enter
|
||||
}
|
||||
tokens, err := chroma.Coalesce(lexer).Tokenise(nil, string(block.source))
|
||||
if err != nil {
|
||||
slog.Error("cannot highlight", "lang", block.lang, "err", err)
|
||||
slog.Warn("cannot highlight", "lang", block.lang, "err", err)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
options := []html.Option{html.WithClasses(true)}
|
||||
@@ -192,7 +192,7 @@ func (f fragments) renderCode(w util.BufWriter, source []byte, n ast.Node, enter
|
||||
}
|
||||
var highlighted bytes.Buffer
|
||||
if err := html.New(options...).Format(&highlighted, styles.Fallback, tokens); err != nil {
|
||||
slog.Error("cannot format", "lang", block.lang, "err", err)
|
||||
slog.Warn("cannot format", "lang", block.lang, "err", err)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
args := map[string]string{"lang": block.lang}
|
||||
@@ -201,7 +201,7 @@ func (f fragments) renderCode(w util.BufWriter, source []byte, n ast.Node, enter
|
||||
}
|
||||
out, err := f.partial(codeFragment, render.Fragment{Args: args, Body: template.HTML(highlighted.String())})
|
||||
if err != nil {
|
||||
slog.Error("skipping code fragment", "err", err)
|
||||
slog.Warn("skipping code fragment", "err", err)
|
||||
out = highlighted.Bytes()
|
||||
}
|
||||
if _, err := w.Write(out); err != nil {
|
||||
|
||||
@@ -95,7 +95,7 @@ func (b bodies) Transform(doc *ast.Document, reader text.Reader, pc parser.Conte
|
||||
var out bytes.Buffer
|
||||
for child := call.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if err := b.md.Renderer().Render(&out, reader.Source(), child); err != nil {
|
||||
slog.Error("rendering a container body", "name", call.name, "err", err)
|
||||
slog.Warn("rendering a container body", "name", call.name, "err", err)
|
||||
}
|
||||
}
|
||||
call.body = out.Bytes()
|
||||
@@ -112,7 +112,7 @@ func (f fragments) renderContainer(w util.BufWriter, source []byte, n ast.Node,
|
||||
call := n.(*container)
|
||||
out, err := f.partial(call.name, render.Fragment{Args: call.args, Lang: call.lang, Body: template.HTML(call.body)})
|
||||
if err != nil {
|
||||
slog.Error("skipping container", "name", call.name, "err", err)
|
||||
slog.Warn("skipping container", "name", call.name, "err", err)
|
||||
out = nil
|
||||
}
|
||||
if len(bytes.TrimSpace(out)) == 0 {
|
||||
@@ -151,7 +151,7 @@ func Merge(src []byte, origin render.Origin) []byte {
|
||||
}
|
||||
body, err := included(origin, args["file"])
|
||||
if err != nil {
|
||||
slog.Error("skipping include", "file", args["file"], "err", err)
|
||||
slog.Warn("skipping include", "file", args["file"], "err", err)
|
||||
continue
|
||||
}
|
||||
out.Write(withoutIncludes(body))
|
||||
@@ -168,7 +168,7 @@ func withoutIncludes(body []byte) []byte {
|
||||
line, remainder, found := bytes.Cut(rest, []byte("\n"))
|
||||
rest = remainder
|
||||
if name, _, ok := parse(string(line), opener); ok && name == "include" {
|
||||
slog.Error("ignoring an include inside an included file", "line", string(line))
|
||||
slog.Warn("ignoring an include inside an included file", "line", string(line))
|
||||
continue
|
||||
}
|
||||
out.Write(line)
|
||||
|
||||
@@ -71,7 +71,7 @@ func (f fragments) renderIcon(w util.BufWriter, source []byte, n ast.Node, enter
|
||||
call := n.(*iconNode)
|
||||
out, err := f.partial(iconFragment, render.Fragment{Args: map[string]string{"name": call.name}, Lang: call.lang})
|
||||
if err != nil {
|
||||
slog.Error("skipping icon", "name", call.name, "err", err)
|
||||
slog.Warn("skipping icon", "name", call.name, "err", err)
|
||||
out = nil
|
||||
}
|
||||
if len(bytes.TrimSpace(out)) == 0 {
|
||||
|
||||
@@ -53,13 +53,13 @@ func Derive(siteFS fs.FS, cacheDir string) (int, error) {
|
||||
}
|
||||
data, err := fs.ReadFile(siteFS, p)
|
||||
if err != nil {
|
||||
slog.Error("skipping unreadable picture", "path", p, "err", err)
|
||||
slog.Warn("skipping unreadable picture", "path", p, "err", err)
|
||||
return nil
|
||||
}
|
||||
n, err := derive(data, p, cacheDir)
|
||||
if err != nil {
|
||||
// One unreadable picture must not stop a site from starting (ADR-0029).
|
||||
slog.Error("skipping picture", "path", p, "err", err)
|
||||
slog.Warn("skipping picture", "path", p, "err", err)
|
||||
return nil
|
||||
}
|
||||
made += n
|
||||
@@ -166,7 +166,7 @@ func picture(origin render.Origin, file string) (render.Picture, bool) {
|
||||
name := path.Join(origin.Dir, file)
|
||||
info, err := fs.Stat(origin.Files, name)
|
||||
if err != nil {
|
||||
slog.Error("picture unreadable", "path", name, "err", err)
|
||||
slog.Warn("picture unreadable", "path", name, "err", err)
|
||||
return p, true
|
||||
}
|
||||
key := fmt.Sprintf("%s\x00%d\x00%d", name, info.Size(), info.ModTime().UnixNano())
|
||||
@@ -175,7 +175,7 @@ func picture(origin render.Origin, file string) (render.Picture, bool) {
|
||||
}
|
||||
data, err := fs.ReadFile(origin.Files, name)
|
||||
if err != nil {
|
||||
slog.Error("picture unreadable", "path", name, "err", err)
|
||||
slog.Warn("picture unreadable", "path", name, "err", err)
|
||||
return p, true
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
|
||||
@@ -112,12 +112,12 @@ func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Co
|
||||
}
|
||||
for _, call := range pending(doc) {
|
||||
if insideInclude {
|
||||
slog.Error("ignoring an include inside an included file", "file", call.args["file"])
|
||||
slog.Warn("ignoring an include inside an included file", "file", call.args["file"])
|
||||
continue
|
||||
}
|
||||
content, err := in.convert(call.args["file"], pc)
|
||||
if err != nil {
|
||||
slog.Error("skipping include", "file", call.args["file"], "err", err)
|
||||
slog.Warn("skipping include", "file", call.args["file"], "err", err)
|
||||
continue
|
||||
}
|
||||
call.content = content
|
||||
@@ -174,7 +174,7 @@ func pending(doc *ast.Document) []*node {
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("walking for includes", "err", err)
|
||||
slog.Warn("walking for includes", "err", err)
|
||||
}
|
||||
return found
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func gallery(pc parser.Context) []render.Picture {
|
||||
}
|
||||
entries, err := fs.ReadDir(origin.Files, origin.Dir)
|
||||
if err != nil {
|
||||
slog.Error("gallery cannot read its bundle directory", "dir", origin.Dir, "err", err)
|
||||
slog.Warn("gallery cannot read its bundle directory", "dir", origin.Dir, "err", err)
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
@@ -315,7 +315,7 @@ func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering
|
||||
out, err := f.partial(call.name, render.Fragment{
|
||||
Args: call.args, Pictures: call.pictures, Headings: call.headings, Lang: call.lang})
|
||||
if err != nil {
|
||||
slog.Error("skipping shortcode", "name", call.name, "err", err)
|
||||
slog.Warn("skipping shortcode", "name", call.name, "err", err)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if _, err := w.Write(out); err != nil {
|
||||
|
||||
@@ -55,9 +55,9 @@ func serveExtras(w http.ResponseWriter, req *http.Request, site *content.Site, r
|
||||
if entry.Kind == "markdown" || entry.Kind == "text" {
|
||||
data, err := fs.ReadFile(siteFS, name)
|
||||
if err != nil {
|
||||
slog.Error("extras entry unreadable", "path", name, "err", err)
|
||||
slog.Warn("extras entry unreadable", "path", name, "err", err)
|
||||
} else if html, err := r.RenderText(entry.Kind, data); err != nil {
|
||||
slog.Error("extras entry unrenderable", "path", name, "err", err)
|
||||
slog.Warn("extras entry unrenderable", "path", name, "err", err)
|
||||
} else {
|
||||
selected.HTML = html
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"khosra/internal/content"
|
||||
)
|
||||
|
||||
// Logged wraps a handler so every request produces one line.
|
||||
//
|
||||
// This is the access log the engine did not have. Nothing else recorded that a request happened, and the
|
||||
// Dockerfile ships the binary alone with the site root mounted (ADR-0010) — so a bare deployment produced no
|
||||
// access log at all, which made offline traffic analysis impossible rather than merely inconvenient. That
|
||||
// matters because log analysis is this project's answer to analytics: no counter on the read path, no
|
||||
// third-party script.
|
||||
//
|
||||
// Applied by `cmd` around the finished handler rather than inside Handler, so tests and the demo's coverage
|
||||
// test stay quiet and so whether to log is the operator's decision rather than the engine's.
|
||||
func Logged(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
started := content.Now()
|
||||
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
|
||||
h.ServeHTTP(rec, req)
|
||||
slog.Info("request",
|
||||
"method", req.Method,
|
||||
"path", req.URL.Path,
|
||||
"status", rec.status,
|
||||
"bytes", rec.bytes,
|
||||
"took", content.Now().Sub(started))
|
||||
})
|
||||
}
|
||||
|
||||
// recorder remembers what the handler answered, because a ResponseWriter tells nobody.
|
||||
//
|
||||
// status starts at 200: a handler that writes without calling WriteHeader has sent one, and that is the
|
||||
// common path here. Duration comes from content.Now, not time.Now — the clock lives in exactly one file and
|
||||
// `verify.sh` enforces it by filename.
|
||||
//
|
||||
// It deliberately does not forward Flusher or ReaderFrom. Nothing in this engine streams or hijacks, so the
|
||||
// only cost is losing an io.Copy fast path on static files; implementing optional interfaces that no caller
|
||||
// needs would be the speculative kind of code rule 6 forbids.
|
||||
type recorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (r *recorder) WriteHeader(status int) {
|
||||
r.status = status
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (r *recorder) Write(b []byte) (int, error) {
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.bytes += n
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// capture installs a logger writing to a buffer and restores the previous one afterwards.
|
||||
func capture(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
return &buf
|
||||
}
|
||||
|
||||
// One line per request, carrying what an access log is for: what was asked, what was answered, how big and
|
||||
// how long. Nothing recorded requests before this, so a bare deployment had no access log at all.
|
||||
func TestLoggedRecordsWhatWasAnswered(t *testing.T) {
|
||||
buf := capture(t)
|
||||
h := Logged(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
if _, err := w.Write([]byte("hello")); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/a/path", nil))
|
||||
|
||||
line := buf.String()
|
||||
for _, want := range []string{"msg=request", "method=GET", "path=/a/path", "status=418", "bytes=5", "took="} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Errorf("the access log is missing %q:\n%s", want, line)
|
||||
}
|
||||
}
|
||||
// The wrapper must not alter what the client receives.
|
||||
if rec.Code != http.StatusTeapot || rec.Body.String() != "hello" {
|
||||
t.Errorf("the wrapper changed the response: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A handler that writes without calling WriteHeader has sent a 200, which is the common path here — so the
|
||||
// recorder must assume it rather than reporting zero.
|
||||
func TestLoggedAssumesTwoHundredWhenTheHandlerNeverSaysOtherwise(t *testing.T) {
|
||||
buf := capture(t)
|
||||
h := Logged(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if _, err := w.Write([]byte("x")); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if got := buf.String(); !strings.Contains(got, "status=200") {
|
||||
t.Errorf("status should default to 200:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A 404 is a normal answer, not a failure, so it is logged at info like every other request. The level
|
||||
// convention reserves error for something the engine could not do (conventions.md).
|
||||
func TestLoggedTreatsANotFoundAsAnOrdinaryRequest(t *testing.T) {
|
||||
buf := capture(t)
|
||||
h := Logged(http.HandlerFunc(http.NotFound))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/missing", nil))
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "status=404") || !strings.Contains(got, "level=INFO") {
|
||||
t.Errorf("a 404 should be one INFO line:\n%s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user