let a feature own a route, and serve the site's own files at exact paths

Addresses like /.well-known/security.txt are fixed by somebody else's spec.
None is a bundle, none belongs under /static/, and core had no way to serve one.

This is the trigger the extension registry has been held for, in those words:
ADR-0042 called core's generic derived-file route "the seam to revisit when a
second feature wants output of its own", and state.md's counter note said to
build the registry "when a feature wants a route". Raw passthrough is that
feature, so the seam is built rather than worked around.

Only Routes, not the seven-field Extension struct extensions.md describes. Five
of the other six fields have no implementor and building them would be the
speculation rule 6 forbids. It also kept the change inside the core budget,
which had 65 lines left: the seam is ~30 core lines and the feature's own code
lands in internal/ext/, where there is room. Core is 2965/3000.

A feature returns map[string]http.Handler; core mounts each as an exact pattern
and learns nothing about who owns it. A path core already answers is skipped
with a warning, not overridden — http.ServeMux panics on a duplicate pattern, so
a site shipping root/robots.txt would otherwise take the server down at startup.
Verified: server alive, engine keeps /robots.txt, warning logged, zero panics.

Templating is opt-in by filename. A .tmpl suffix is stripped from the URL and
the file is rendered with text/template — never html/template, which would turn
an ampersand in a contact address into & and a JSON quote into ". Opt-in
by name rather than by sniffing the type, because a key or a signature may
contain anything and a pass choosing for itself which files to rewrite would
eventually eat one. The data is the site's own declarations and nothing more,
which is the point: a security.txt naming its canonical URL should not repeat
what site.yaml already says.

Headers come from root/_headers.yaml, exact paths only. Globs are a second-use
feature and the concrete need is a handful of .well-known names. The manifest is
not served, by the leading-underscore rule that already means "not addressable"
everywhere else — no special case was added for it. A manifest that will not
parse is logged and ignored; the files still serve.

Found while counting: the Extensions row read 4 while five packages existed.
notation landed in ADR-0061/0062 and was never counted, though the prose beside
the number already named all five. Corrected to 6. That is the latent item about
counters having no mechanical check, demonstrating itself.

Not done, and logged as latent: khosra check cannot report a root/ file
shadowing an engine path, because verify.sh fails a feature that imports a
sibling and the reserved paths live in passthrough. The startup warning fires on
every boot, which is louder than a check finding.

Evidence against the demo with a fresh binary: /pubkey answers with its declared
text/plain despite having no extension; /.well-known/security.txt answers with
Canonical filled from site.yaml's base, plus the declared CORS header;
/humans.txt gets a derived type; /_headers.yaml is 404; / and a bundle page are
untouched. Eight unit tests cover layout, absence, interpolation, non-escaping,
declared and derived headers, a broken template, and a broken manifest.

24 files, +514/-46. Extensions 4 (miscounted) → 6. Routing cases unmoved: exact
paths are mux entries, not resolver cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 19:48:59 +06:00
co-authored by Claude Opus 5
parent 64e53f28c8
commit 9349c54d2e
24 changed files with 527 additions and 46 deletions
+6 -1
View File
@@ -47,7 +47,7 @@ func exampleSite(t *testing.T) http.Handler {
}
site := content.NewSite(bundles)
r.Navigation(site.Sections)
return web.Handler(web.Fixed(site, r), fsys, nil, settings)
return web.Handler(web.Fixed(site, r), fsys, nil, settings, routes(fsys, settings))
}
func get(t *testing.T, h http.Handler, path string) (int, string) {
@@ -140,6 +140,11 @@ var exampleFeatures = []featureCase{
expect: []string{`href="/pages/sandbox/sandbox.css"`, `src="/pages/sandbox/sandbox.js" defer`}},
{what: "the script exception reaches only the page that asked — every other page stays scriptless", path: "/pages/colophon/", code: 200,
absent: []string{"<script"}},
{what: "a file in root/ answers at an exact path, with its declared type", path: "/pubkey", code: 200,
expect: []string{"ssh-ed25519"}},
{what: "a .tmpl passthrough interpolates the site's own base and drops the suffix from its URL", path: "/.well-known/security.txt", code: 200,
expect: []string{"Canonical: http://localhost:8080/.well-known/security.txt"}, absent: []string{"{{"}},
{what: "the header manifest is not itself served", path: "/_headers.yaml", code: 404},
{what: "a task list renders disabled checkboxes and nothing interactive", path: "/pages/colophon/", code: 200,
expect: []string{`<input checked="" disabled="" type="checkbox"`, `<input disabled="" type="checkbox"`},
absent: []string{"<script"}},
+1 -1
View File
@@ -89,7 +89,7 @@ func runServe() {
watching(fsys, interval, rebuild)
slog.Info("serving", "site", *site, "bundles", count, "addr", *addr)
handler := web.Handler(live.Load, fsys, derivedFS, settings)
handler := web.Handler(live.Load, fsys, derivedFS, settings, routes(fsys, settings))
if err := http.ListenAndServe(*addr, handler); err != nil {
fatal("server stopped", err)
}
+12
View File
@@ -2,12 +2,14 @@ package main
import (
"io/fs"
"net/http"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"khosra/internal/content"
"khosra/internal/ext/notation"
"khosra/internal/ext/passthrough"
"khosra/internal/ext/shortcodes"
"khosra/internal/render"
)
@@ -42,3 +44,13 @@ func extenders(partial render.Partial) []goldmark.Extender {
shortcodes.New(partial),
}
}
// routes is the only list of features owning a URL path of their own.
//
// The sibling of extenders(), and the same rule: nothing below cmd may know which features exist, so core
// receives a map of paths and never the name of whatever filled it (ADR-0081). One entry today — the
// registry earned exactly the field that has an implementor, and the other six in `extensions.md` wait for
// theirs.
func routes(siteFS fs.FS, settings content.Settings) map[string]http.Handler {
return passthrough.Routes(siteFS, settings)
}
+41 -1
View File
@@ -22,7 +22,8 @@ overrides the defaults the binary embeds, so a bare root still renders. Shortcod
<site>/
site.yaml # what the site declares about itself, optional
content/ # bundles — the disk contract below
static/ # verbatim, served as-is
static/ # verbatim, served under /static/
root/ # verbatim, served at / — exact paths (ADR-0081), optional
templates/ # html/template overrides, optional
<site>/content/
@@ -214,6 +215,45 @@ What it writes is a draft: `title`, today's `date`, and `draft: true`. A tool th
publishes by accident. Nothing is ever overwritten, and a key containing `..` is refused — it names a place
under `content/`, not a path to walk.
## Files served at the URL root
`root/` holds files that must answer at an exact address somebody else specified — `/.well-known/security.txt`,
`/humans.txt`, a public key at `/pubkey` (ADR-0081). The path a file occupies is the path it answers at, so
`root/.well-known/security.txt` needs no configuration to reach its address. This is not `static/`: that
directory answers under `/static/`, and nothing in it can take a root-level path.
- **Engine-owned paths win.** `/`, `/robots.txt`, `/sitemap.xml`, `/static/` and `/derived/` are already
answered; a file in `root/` claiming one is skipped and logged at startup rather than overriding it. It is
a warning rather than a silent loss because the alternative is a server that will not start.
- **A leading underscore is not addressable**, here as everywhere: `root/_drafts/` is not walked and
`root/_headers.yaml` is not served.
- **`.tmpl` opts a file into templating** and is stripped from its URL, so `security.txt.tmpl` answers at
`security.txt`. Only the suffix decides — never the content type — so a key, an image or a signature is
never rewritten by accident.
- A template is filled with what the site declares about itself and nothing else: `{{.Base}}` and
`{{.Title}}`, so a file naming its own canonical URL does not repeat `site.yaml`. It is rendered as **text**,
not HTML, so an `&` or a `"` in plain text or JSON survives intact.
- A template that fails to render serves its own source and logs, because a promised address answering
nothing is worse than one answering unrendered (ADR-0029).
`root/_headers.yaml` declares response headers per exact served path. Exact paths only, no globs:
```yaml
/pubkey:
Content-Type: text/plain; charset=utf-8
/.well-known/webfinger:
Content-Type: application/jrd+json
Access-Control-Allow-Origin: "*"
```
Without a declaration the type is derived from the filename, which is enough for `.txt` and `.json` and
nothing at all for an extensionless `/pubkey` — the case the manifest exists for. A manifest that does not
parse is logged and ignored; the files still serve.
**The set of paths is fixed at startup**, because a pattern cannot be added to a running mux. File contents
are read per request, so editing a served file takes effect at once and only adding or removing one needs a
restart — the same bargain `site.yaml` makes (ADR-0055).
## Site settings
`site.yaml` at the site root declares the site (ADR-0039). Declared keys only — absent is fine, since a bare
+34
View File
@@ -1378,3 +1378,37 @@ Arc 3 comment path. Hover-preview *footnotes* are not the popups this refuses
author's own text, and `:::aside` already renders margin notes server-side with no script.
Revisit if: a reader-facing need arises that genuinely cannot be met server-side. "Would be nicer with
JavaScript" is not that, and never has been for any item on this list.
## ADR-0081 — A feature may own a route, and `root/` is the first one
Date: 2026-08-02 · Status: accepted
Decision: `cmd` collects `map[string]http.Handler` from the features it enables and hands it to
`web.Handler`, which mounts each as an exact mux pattern. Core learns that some paths belong to somebody
else and nothing about who. A path core already answers — `/`, `/robots.txt`, `/sitemap.xml`, `/static/`,
`/derived/` — is skipped with a warning rather than overridden, because `http.ServeMux` **panics** on a
duplicate pattern and a site shipping `root/robots.txt` must not take the server down.
The first user is `internal/ext/passthrough/`: files in `root/` are served at the path they occupy, so
`root/pubkey` answers `/pubkey` and `root/.well-known/security.txt` answers that. A `.tmpl` suffix opts a
file into templating and is stripped from its URL; `root/_headers.yaml` declares response headers per exact
path and is excluded from serving by the leading-underscore rule that already means "not addressable".
Why: this is the trigger the extension registry has been waiting for, named in ADR-0042 — "the seam to
revisit when a second feature wants output of its own" — and in `state.md`'s counter note, which said to
build it "when a feature wants a **route**". Addresses like `/.well-known/security.txt` are fixed by
somebody else's spec: none is a bundle, none belongs under `/static/`, and core had no way to serve one.
Only `Routes` was built, not the seven-field `Extension` struct `extensions.md` describes. Five of the six
other fields have no implementor, and building them would be the speculation rule 6 forbids. This also kept
the change inside the core budget, which had 65 lines left — the feature's own code lands in `internal/ext/`.
Templating is `text/template`, never `html/template`: these files are plain text and JSON, where escaping
an ampersand or a quote corrupts the file rather than protecting anyone. The site root is trusted
(ADR-0003, ADR-0060), so there is nothing to escape against. Opt-in is by filename rather than by sniffing
the content type, because a key or a signature may contain anything and a pass that decided for itself
which files to rewrite would eventually eat one — the failure this engine keeps producing.
The data a template sees is the site's own declarations and nothing more, which is the point: a
`security.txt` naming its canonical URL should not repeat what `site.yaml` already says.
Consequence: the Extensions counter moves to 5 and the standing "a registry buys nothing" note is retired —
it was right until a feature wanted a route, and said so in those words. Paths are fixed at startup, since
a pattern cannot be added to a running mux; contents are read per request, so editing a served file takes
effect immediately and only adding or removing one needs a restart, exactly as `site.yaml` does (ADR-0055).
A broken template serves its own source rather than 404ing, because a promised address answering nothing is
worse than one answering unrendered (ADR-0029).
Revisit if: a feature needs a path *prefix* rather than exact paths, or two features claim the same path —
neither is expressible today, and both would want the resolver rather than the mux.
+12 -1
View File
@@ -51,7 +51,18 @@ type Extension struct {
}
```
`cmd/khosra/wire.go` holds the only list of enabled extensions — it exists now, holding one line. Enabling
**One field of that struct is real: `Routes` (ADR-0081).** It is not the struct — a feature returns
`map[string]http.Handler` from `cmd/khosra/wire.go`'s `routes()`, keyed by exact URL path, and `web.Handler`
mounts each one. Core learns that some paths belong to somebody else and nothing about who owns them. A path
core already answers is skipped with a warning rather than overridden, because `http.ServeMux` panics on a
duplicate pattern. `internal/ext/passthrough/` is the first and only user.
The other six fields wait for their own triggers. Building them now would give five of six no implementor,
and the earn-it rule exists to prevent exactly that. The shape above stays the target, not a promise about
next week.
`cmd/khosra/wire.go` holds the only list of enabled extensions — two lists now, `extenders()` for the ones
goldmark composes and `routes()` for the ones owning a path. Enabling
or disabling one is a one-line diff and a rebuild. Removing one leaves no trace elsewhere — that property is
the test of whether the contract is right, and it is testable today: empty the list and the engine still
builds and serves, minus that feature.
+27 -8
View File
@@ -29,19 +29,24 @@ table owns.
| `internal/ext/notation/` | the inline marks CommonMark lacks: `~sub~`, `^sup^`, `==mark==`, and `~~strike~~`, which it owns so a single tilde can mean subscript (ADR-0061). `abbr.go` adds `*[TERM]:` definitions and the pass that expands them (ADR-0062) |
| `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite |
| `internal/ext/watch/` | polls `content/` and `templates/` on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). `site.yaml` is deliberately not fingerprinted (ADR-0055) |
| `internal/ext/passthrough/` | fifth feature, and the first to own a **route** (ADR-0081): files in `root/` served at the exact path they occupy, `.tmpl` rendered as text with the site's own settings, headers declared per path in `root/_headers.yaml`, underscore-prefixed names not addressable |
| `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering, and calls left in the retired shortcode form (ADR-0059) |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`), the `theme` function that builds the renderer this build ships (ADR-0072), and the Markdown dialect with it — tables, footnotes, definition lists, strikethrough, task lists (ADR-0058, ADR-0078) |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`)`extenders()` for the ones goldmark composes and `routes()` for the ones owning a URL path (ADR-0081), the `theme` function that builds the renderer this build ships (ADR-0072), and the Markdown dialect with it — tables, footnotes, definition lists, strikethrough, task lists (ADR-0058, ADR-0078) |
| `internal/web/resolve.go` | URL → (key, lang, page, tag, feed, extras) or a canonical redirect |
| `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/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) |
| `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 |
| `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) |
| `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/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) |
`root/` in the site root is served at the URL root: `root/pubkey` answers `/pubkey`, a `.tmpl` suffix renders
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.
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
@@ -96,19 +101,32 @@ a row that leaves it empty (ADR-0070).
| Collection pages | 4 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `Site.Run` | A series archive. Membership is structural and the sort ascends, so it resolves through `Site.Sequence` — sharing the index but not the Query |
| Views — **per-bundle selection only** | 0 | **2** | The View layer `architecture.md` describes: `view:` in frontmatter choosing a presentation, resolved through the cascade. Nothing selects a view yet | Output formats. HTML, sitemap XML and Atom are three functions with nothing to share — an interface over them would have one member and no leverage |
| Effects | 1 | **2** | Effect runner + trigger wiring (change / schedule / demand). The only one is the derivative pass (ADR-0042), called from `cmd` inside `rebuilder`, so it already answers both triggers it will ever need — startup and a settled change (ADR-0048) | An in-memory swap. Replacing the index or the theme re-reads the site root into memory, writing no artifact and calling nothing outbound (ADR-0055) |
| Extensions | 4 | **3** — passed, and the answer is still no | Extension registry (`extensions.md`). It reached 3 once before and went back to 2 when the widows feature was deleted (ADR-0045) — a threshold reached by a feature that should not exist was never a threshold. The note below says why a registry still buys nothing | An upstream extension enabled in the list. `Table`, `Footnote` and `DefinitionList` are goldmark's, so they are dialect rather than features of this engine (ADR-0058) — only a package under `internal/ext/` counts |
| Extensions | 6 | **3** — passed, and the registry is now partly built | Extension registry (`extensions.md`). It reached 3 once before and went back to 2 when the widows feature was deleted (ADR-0045) — a threshold reached by a feature that should not exist was never a threshold. The note below says which field was built and why the rest were not | An upstream extension enabled in the list. `Table`, `Footnote` and `DefinitionList` are goldmark's, so they are dialect rather than features of this engine (ADR-0058) — only a package under `internal/ext/` counts |
| Interface implementations | — | **2** | The interface itself | An interface this repo did not declare. Satisfying `fs.FS`, `http.Handler` or `goldmark.Extender` is using somebody else's abstraction, which is the opposite of inventing one |
| Non-stdlib dependencies | 4 direct | budget in `scripts/budgets.env` | — | The standard library, and a dependency's own test-only modules — `go list -m all` shows those, and the gate counts `require` entries instead (`scripts/budgets.env`) |
**The Extensions counter is past its threshold, and a registry would still buy nothing.** The four features
attach in three unrelated ways: `shortcodes` and `notation` are goldmark extenders listed in `extenders()`,
`check` and `scaffold` are functions `cmd` calls for a subcommand, and `watch` is a goroutine. A registry would
**The registry's trigger fired, and exactly one field of it was built (ADR-0081).** The note below stood for
five features and was right until `passthrough` wanted a **route** — the condition its own last paragraph
named. What exists now is `Routes`: a feature returns `map[string]http.Handler` from `wire.go`, core mounts
it, and nothing else from `extensions.md`'s struct was built, because the other six fields have no
implementor. The reasoning that kept the rest unbuilt is unchanged and still applies:
**Counted by hand this change and found wrong.** The row read 4 while five packages existed — `notation`
landed (ADR-0061, ADR-0062) and the count was never incremented, though the prose below already named all
five. Six now, with `passthrough`. This is the latent item about the counters having no mechanical check,
demonstrating itself; the count is authoritative only because someone just ran `ls internal/ext/`.
**A registry over the *other* attachment points would still buy nothing.** The six features
attach in four unrelated ways: `shortcodes` and `notation` are goldmark extenders listed in `extenders()`,
`check` and `scaffold` are functions `cmd` calls for a subcommand, `watch` is a goroutine, and
`passthrough` hands back a map of URL paths. A registry would
have to abstract over "extends Markdown", "validates content", "writes a file" and "polls a directory", which
share nothing but the word *feature* — one member and no leverage. Adding `notation` made this clearer rather
than more urgent: two goldmark extenders compose in goldmark's own extender list, which is already the registry
for that shape, and they need no order relative to each other because one is inline and the other block.
Build the registry when a feature wants a **route** (the seam ADR-0042 named) or when two features genuinely
need to agree on an order that no existing mechanism expresses.
A feature wanting a **route** was the recorded trigger and it has now fired, which is why `Routes` exists and
nothing else does. Build the next field when two features genuinely need to agree on an order that no
existing mechanism expresses, or when one of the remaining six gets a second implementor.
Allowlist, all four imported: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015),
`gopkg.in/yaml.v3` (frontmatter, ADR-0020), `golang.org/x/image` (resampling and WebP, ADR-0040).
@@ -126,6 +144,7 @@ with a stated reason. A list nothing drains is a graveyard of known defects.
| A gallery's images carry no `alt` | `width`/`height` now come from the original (ADR-0042), so only alt text is missing, and a filename does not supply one. An empty `alt` is honest for a picture the page has already introduced | Captions per gallery entry — a sidecar or a frontmatter list — if the reference theme ever needs them |
| Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | Measured at the same time as the pictures (ADR-0044): a whole page is ~63µs, so this is not what costs anything. Remembering it would be a cache with no measurement behind it | A page render exceeding a few milliseconds, which is also what would revive the parked cache model |
| The root listing's `<title>` repeats itself — "A Khosra Demo · A Khosra Demo" | Spotted 2026-08-01 by looking at the served page, not by any test: `base.html` joins page title and site title unconditionally, and at the root those are the same string. Cosmetic, and the fix is one `if` in a template — theme layer, not engine | The next time `base.html` is edited for any reason — its old trigger named queue entry G4, which has been dropped |
| `khosra check` cannot report a `root/` file shadowing an engine path | The knowledge lives in `internal/ext/passthrough/`, and `verify.sh` fails a feature that imports a sibling — so `check` would need the reserved paths moved into a shared package for a cosmetic gain. The startup warning fires on every boot and cannot be missed, which is louder than a `check` finding anyway (ADR-0081) | A second feature owning routes, at which point reserved paths stop belonging to one feature and want a home of their own |
| A draft member's absence from a sequence has no test | Proven by hand against the real binary on 2026-08-02 — a three-chapter series with a draft middle lists two members, prev/next closes over the gap, and the draft 404s. It holds by construction: `members()` resolves through `Lookup`, which is the one place ADR-0024 hides unpublished bundles, so there is no second code path to drift | The first change to `members()`, or to how `Lookup` decides visibility |
| Under `include: embed`, a fragment's footnotes render where the include sits, so a long one puts an `<hr>` and a numbered list mid-article | Spotted 2026-08-01 by looking at the served page, not by any test. It is ADR-0038's documented consequence, and the ids are correctly namespaced (ADR-0058); only the placement reads badly. Merging is the default since ADR-0076, so this is now something an author opts into by asking for containment | Nothing: it is the documented cost of the model you chose |
+28 -14
View File
@@ -6,9 +6,9 @@ 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 — 308 lines + 303 test
## cmd/khosra — 320 lines + 308 test
check.go 45 · main.go 177 · new.go 42 · wire.go 44
check.go 45 · main.go 177 · new.go 42 · wire.go 56
- check.go:16 func runCheck(args []string)
- main.go:23 func main()
@@ -19,8 +19,9 @@ check.go 45 · main.go 177 · new.go 42 · wire.go 44
- main.go:160 func defaultCache() string
- main.go:170 func fatal(msg string, err error)
- new.go:12 func runNew(args []string)
- wire.go:18 func theme(siteFS fs.FS, settings content.Settings) (*render.Renderer, error)
- wire.go:31 func extenders(partial render.Partial) []goldmark.Extender
- wire.go:20 func theme(siteFS fs.FS, settings content.Settings) (*render.Renderer, error)
- wire.go:33 func extenders(partial render.Partial) []goldmark.Extender
- wire.go:54 func routes(siteFS fs.FS, settings content.Settings) map[string]http.Handler
## internal/content — 1074 lines + 598 test
@@ -167,6 +168,19 @@ abbr.go 246 · doc.go 8 · notation.go 157
- notation.go:144 func (r nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- notation.go:149 func render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
## internal/ext/passthrough — 178 lines + 136 test
doc.go 11 · passthrough.go 167
- passthrough.go:23 const Dir = "root"
- passthrough.go:28 const headersFile = "_headers.yaml"
- passthrough.go:35 const tmplSuffix = ".tmpl"
- passthrough.go:46 func Routes(siteFS fs.FS, settings content.Settings) map[string]http.Handler
- passthrough.go:92 func readHeaders(siteFS fs.FS) map[string]map[string]string
- passthrough.go:109 type file struct
- passthrough.go:117 func (f *file) ServeHTTP(w http.ResponseWriter, r *http.Request)
- passthrough.go:157 func (f *file) render(data []byte) ([]byte, error)
## internal/ext/scaffold — 102 lines + 89 test
doc.go 8 · scaffold.go 94
@@ -341,9 +355,9 @@ chrome.go 115 · render.go 499 · view.go 192
- view.go:167 type Picture struct
- view.go:184 type Origin struct
## internal/web — 747 lines + 1423 test
## internal/web — 765 lines + 1423 test
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 230
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 248
- asset.go:22 func serveAsset(w http.ResponseWriter, req *http.Request, site *content.Site, siteFS fs.FS, res resolution) bool
- discover.go:14 const
@@ -371,11 +385,11 @@ asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170
- web.go:19 type Snapshot struct
- web.go:28 type Current func() *Snapshot
- web.go:31 func Fixed(site *content.Site, theme *render.Renderer) Current
- web.go:39 func Handler(current Current, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler
- web.go:73 func serveStatic(sub fs.FS) http.Handler
- web.go:89 func serveListing(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool
- web.go:113 func serveTags(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool
- web.go:138 func write(w http.ResponseWriter, out []byte, what string)
- web.go:147 func writeAs(w http.ResponseWriter, contentType string, out []byte, what string)
- web.go:155 func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, siteFS fs.FS, settings content.Settings)
- web.go:186 func serveBundle(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
- web.go:43 func Handler(current Current, siteFS, derivedFS fs.FS, settings content.Settings, routes map[string]http.Handler) http.Handler
- web.go:91 func serveStatic(sub fs.FS) http.Handler
- web.go:107 func serveListing(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool
- web.go:131 func serveTags(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool
- web.go:156 func write(w http.ResponseWriter, out []byte, what string)
- web.go:165 func writeAs(w http.ResponseWriter, contentType string, out []byte, what string)
- web.go:173 func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, siteFS fs.FS, settings content.Settings)
- web.go:204 func serveBundle(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
@@ -0,0 +1,4 @@
Contact: mailto:security@khosra.example
Canonical: {{.Base}}/.well-known/security.txt
Preferred-Languages: en, bn
Expires: 2027-01-01T00:00:00.000Z
+7
View File
@@ -0,0 +1,7 @@
# Response headers per exact served path (ADR-0081). Exact paths only — no globs until something needs one.
# This file is not served: a leading underscore means "not addressable" everywhere in this engine.
/pubkey:
Content-Type: text/plain; charset=utf-8
/.well-known/security.txt:
Content-Type: text/plain; charset=utf-8
Access-Control-Allow-Origin: "*"
+2
View File
@@ -0,0 +1,2 @@
/* SITE */ Built with khosra, a flat-file publishing engine.
/* TOOLS */ Go, goldmark, chroma. No JavaScript on this page.
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI0000000000000000000000000000000000000000 demo@khosra
+11
View File
@@ -0,0 +1,11 @@
// Package passthrough serves a site's own files at exact URL paths.
//
// Some addresses are specified by somebody else: `/.well-known/security.txt`, `/humans.txt`, a public key
// at `/pubkey`. None of them is a bundle, none belongs under `/static/`, and their paths are fixed by a
// spec rather than by this engine. This is the first feature to own a **route** — the seam ADR-0042 named
// and the trigger the extension registry waited for (ADR-0081).
//
// Files live in `root/` in the site root and are served at the path they occupy. A `.tmpl` suffix opts a
// file into templating and is stripped from its URL; `root/_headers.yaml` declares response headers per
// exact path and is not itself served.
package passthrough
+167
View File
@@ -0,0 +1,167 @@
package passthrough
import (
"bytes"
"errors"
"io/fs"
"log/slog"
"net/http"
"path"
"strings"
"text/template"
"time"
"gopkg.in/yaml.v3"
"khosra/internal/content"
)
// Dir is the directory in the site root whose files are served at the URL root.
//
// Named for what it does rather than for what it holds: everything in it answers at `/`, which is the one
// thing distinguishing it from `static/` (ADR-0081).
const Dir = "root"
// headersFile declares response headers per exact served path. The leading underscore is the engine's
// existing "not addressable" mark (content-model.md), so the manifest excludes itself by a rule that
// already exists rather than by a special case here.
const headersFile = "_headers.yaml"
// tmplSuffix opts a file into templating and is stripped from its URL.
//
// Opt-in by name, never by sniffing the content type. A key, an image or a signature may contain anything
// at all, and a pass that decided for itself which files to rewrite would eventually eat one — the failure
// this engine keeps producing when new syntax meets ordinary bytes.
const tmplSuffix = ".tmpl"
// Routes enumerates the files under Dir and returns one handler per exact URL path.
//
// The set of paths is fixed here, at startup: a mux pattern cannot be added to a running server, and the
// alternative — one catch-all at `/` deciding per request — would put this feature in front of every page
// on the site. File *contents* are read per request, so editing a served file takes effect immediately and
// only adding or removing one needs a restart. `site.yaml` already sets that precedent (ADR-0055).
//
// settings is copied by value for the same reason the rest of the engine copies it: a template rendering
// `{{.Base}}` must not see a half-updated site.
func Routes(siteFS fs.FS, settings content.Settings) map[string]http.Handler {
if siteFS == nil {
return nil
}
headers := readHeaders(siteFS)
routes := map[string]http.Handler{}
err := fs.WalkDir(siteFS, Dir, func(p string, d fs.DirEntry, err error) error {
switch {
case err != nil:
return err
case d.IsDir():
// `_` means not addressable everywhere else in this engine, and a directory of drafts beside a
// public key is a reasonable thing to keep. `.well-known` is a dot, not an underscore, so it
// walks normally.
if p != Dir && strings.HasPrefix(d.Name(), "_") {
return fs.SkipDir
}
return nil
case strings.HasPrefix(d.Name(), "_"):
return nil
}
url := "/" + strings.TrimPrefix(strings.TrimSuffix(p, tmplSuffix), Dir+"/")
routes[url] = &file{
siteFS: siteFS,
name: p,
headers: headers[url],
settings: settings,
template: strings.HasSuffix(p, tmplSuffix),
}
return nil
})
if err != nil {
// 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)
}
return nil
}
return routes
}
// readHeaders parses the manifest: exact served path to header name and value.
//
// Exact paths only. A glob would be a second mechanism for choosing which files a rule covers, and the
// concrete need is a handful of `.well-known` names — so globs wait for a case that wants them.
func readHeaders(siteFS fs.FS) map[string]map[string]string {
data, err := fs.ReadFile(siteFS, path.Join(Dir, headersFile))
if err != nil {
return nil
}
declared := 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",
"file", path.Join(Dir, headersFile), "err", err)
return nil
}
return declared
}
// file answers one exact path.
type file struct {
siteFS fs.FS
name string
headers map[string]string
settings content.Settings
template bool
}
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)
http.NotFound(w, r)
return
}
if f.template {
if rendered, err := f.render(data); err == nil {
data = rendered
} 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",
"file", f.name, "err", err)
}
}
for name, value := range f.headers {
w.Header().Set(name, value)
}
if w.Header().Get("Content-Type") == "" {
// Nothing declared, so let net/http sniff. An extensionless file such as /pubkey is exactly why the
// manifest exists.
// A zero time means no Last-Modified header; ServeContent still derives the type from the name and
// answers Range requests, which matters for the binary files that live here.
http.ServeContent(w, r, path.Base(strings.TrimSuffix(f.name, tmplSuffix)), time.Time{}, bytes.NewReader(data))
return
}
w.Write(data)
}
// render fills a text template with what the site declares about itself.
//
// text/template, never html/template: this serves plain text and JSON, and HTML escaping would turn an
// ampersand in a contact address into `&amp;` and a JSON quote into `&#34;`. The site root is trusted
// (ADR-0003, ADR-0060), so there is nothing here to escape against.
//
// The data is the site's own declarations and nothing more — `{{.Base}}` and `{{.Title}}` — which is the
// whole reason this exists: a security.txt naming its own canonical URL should not repeat what site.yaml
// already says.
func (f *file) render(data []byte) ([]byte, error) {
t, err := template.New(path.Base(f.name)).Parse(string(data))
if err != nil {
return nil, err
}
var out bytes.Buffer
if err := t.Execute(&out, f.settings); err != nil {
return nil, err
}
return out.Bytes(), nil
}
@@ -0,0 +1,136 @@
package passthrough
import (
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
)
func site() fstest.MapFS {
return fstest.MapFS{
"root/pubkey": {Data: []byte("ssh-ed25519 AAAA…\n")},
"root/humans.txt": {Data: []byte("me\n")},
"root/.well-known/security.txt.tmpl": {Data: []byte("Canonical: {{.Base}}/.well-known/security.txt\nContact: mailto:me@example\n")},
"root/_headers.yaml": {Data: []byte(
"/pubkey:\n Content-Type: text/plain; charset=utf-8\n" +
"/.well-known/security.txt:\n Content-Type: text/plain; charset=utf-8\n Access-Control-Allow-Origin: \"*\"\n")},
"root/_drafts/notes.txt": {Data: []byte("not for anyone\n")},
}
}
func settings() content.Settings { return content.Settings{Base: "https://khosra.example"} }
// The path a file occupies under root/ is the path it answers at — with `.tmpl` removed, and with the
// underscore rule that already means "not addressable" everywhere else applying here too (ADR-0081).
func TestTheDirectoryLayoutIsTheURLSpace(t *testing.T) {
got := Routes(site(), settings())
var paths []string
for p := range got {
paths = append(paths, p)
}
sort.Strings(paths)
want := []string{"/.well-known/security.txt", "/humans.txt", "/pubkey"}
if strings.Join(paths, " ") != strings.Join(want, " ") {
t.Errorf("routes = %v, want %v", paths, want)
}
}
// A missing root/ is the ordinary case for a site that wants none of this: no routes, no error, no log.
func TestASiteWithoutTheDirectoryGetsNoRoutes(t *testing.T) {
if got := Routes(fstest.MapFS{"content/x.md": {Data: []byte("---\ntitle: T\n---\n")}}, settings()); len(got) != 0 {
t.Errorf("routes = %v, want none", got)
}
if got := Routes(nil, settings()); got != nil {
t.Errorf("a nil site FS should yield nil, got %v", got)
}
}
func serve(t *testing.T, routes map[string]http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
h, ok := routes[path]
if !ok {
t.Fatalf("no route for %s", path)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
return rec
}
// A .tmpl file is rendered with what the site declares about itself, which is the whole point: a
// security.txt naming its own canonical URL should not repeat what site.yaml already says.
func TestATemplateReadsTheSiteSettings(t *testing.T) {
rec := serve(t, Routes(site(), settings()), "/.well-known/security.txt")
body := rec.Body.String()
if !strings.Contains(body, "Canonical: https://khosra.example/.well-known/security.txt") {
t.Errorf("the base was not interpolated: %q", body)
}
if strings.Contains(body, "{{") {
t.Errorf("the template was served unrendered: %q", body)
}
}
// text/template, not html/template: this serves plain text and JSON, where escaping an ampersand or a
// quote would corrupt the file rather than protect anybody. The site root is trusted (ADR-0060).
func TestTemplatingDoesNotHTMLEscape(t *testing.T) {
fsys := site()
fsys["root/note.txt.tmpl"] = &fstest.MapFile{Data: []byte(`{{.Title}} & "quoted" <tag>`)}
rec := serve(t, Routes(fsys, content.Settings{Title: "A & B"}), "/note.txt")
if got := rec.Body.String(); got != `A & B & "quoted" <tag>` {
t.Errorf("text was escaped as if it were HTML: %q", got)
}
}
// Declared headers are set; an undeclared file still gets a type derived from its name.
func TestDeclaredHeadersAreSetAndOthersDerived(t *testing.T) {
routes := Routes(site(), settings())
sec := serve(t, routes, "/.well-known/security.txt")
if got := sec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("CORS header = %q, want *", got)
}
if got := sec.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Errorf("declared type = %q", got)
}
// humans.txt declares nothing, so net/http derives text/plain from the extension.
if got := serve(t, routes, "/humans.txt").Header().Get("Content-Type"); !strings.HasPrefix(got, "text/plain") {
t.Errorf("derived type = %q, want text/plain…", got)
}
}
// A file with no extension is exactly why the manifest exists: nothing can derive a type from "pubkey".
func TestAnExtensionlessFileTakesItsDeclaredType(t *testing.T) {
if got := serve(t, Routes(site(), settings()), "/pubkey").Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Errorf("Content-Type = %q, want the declared text/plain", got)
}
}
// A broken template serves its own source rather than 404ing: the address is promised to somebody else's
// spec, and an empty answer is worse than an unrendered one. Logged loudly (ADR-0029).
func TestABrokenTemplateStillAnswers(t *testing.T) {
fsys := site()
fsys["root/bad.txt.tmpl"] = &fstest.MapFile{Data: []byte("{{.Nope")}
rec := serve(t, Routes(fsys, settings()), "/bad.txt")
if rec.Code != http.StatusOK {
t.Errorf("code = %d, want 200 — a promised address must answer", rec.Code)
}
if !strings.Contains(rec.Body.String(), "{{.Nope") {
t.Errorf("the source should have been served: %q", rec.Body.String())
}
}
// A manifest that does not parse must not take the site down: files still serve, without declared headers.
func TestABrokenManifestDoesNotStopTheFilesServing(t *testing.T) {
fsys := site()
fsys["root/_headers.yaml"] = &fstest.MapFile{Data: []byte("this: [is not: a map of maps")}
routes := Routes(fsys, settings())
if len(routes) != 3 {
t.Fatalf("routes = %v, want the three files regardless of the manifest", routes)
}
if got := serve(t, routes, "/pubkey").Code; got != http.StatusOK {
t.Errorf("code = %d, want 200", got)
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ func assetHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
}
func TestABundlesOwnFilesAreServed(t *testing.T) {
+1 -1
View File
@@ -53,7 +53,7 @@ func benchHandler(b *testing.B, pictures int) http.Handler {
if err != nil {
b.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
}
func serveOnce(b *testing.B, h http.Handler, path string) {
+1 -1
View File
@@ -29,7 +29,7 @@ func crawlerHandler(t *testing.T, settings content.Settings, extra fstest.MapFS)
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings)
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings, nil)
}
func TestSitemapListsEveryVariantAbsolutely(t *testing.T) {
+1 -1
View File
@@ -32,7 +32,7 @@ func extrasHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
}
func TestExtrasAreNotBundles(t *testing.T) {
+1 -1
View File
@@ -30,7 +30,7 @@ func feedHandler(t *testing.T, settings content.Settings) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings)
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings, nil)
}
func fetchFeed(t *testing.T, h http.Handler, path string) (*httptest.ResponseRecorder, atom) {
+2 -2
View File
@@ -21,7 +21,7 @@ func slugHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
}
func TestASlugRenamesTheAddressInEveryLanguage(t *testing.T) {
@@ -82,7 +82,7 @@ func TestListingsAndSitemapsUseTheSluggedAddress(t *testing.T) {
if err != nil {
t.Fatal(err)
}
h := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings)
h := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/", nil))
+3 -3
View File
@@ -36,7 +36,7 @@ func TestNothingInsideAnUnpublishedBundleIsServed(t *testing.T) {
if err != nil {
t.Fatal(err)
}
hidden := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
hidden := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
for path, want := range map[string]int{
"/art/draft/": http.StatusNotFound,
"/art/draft/one.jpg": http.StatusNotFound,
@@ -58,7 +58,7 @@ func TestNothingInsideAnUnpublishedBundleIsServed(t *testing.T) {
// Revealing them is the only thing that changes the answer.
site := content.NewSite(bundles)
site.Reveal()
shown := Handler(Fixed(site, r), fsys, nil, content.Settings{})
shown := Handler(Fixed(site, r), fsys, nil, content.Settings{}, nil)
for _, path := range []string{"/art/draft/", "/art/draft/one.jpg", "/art/future/", "/art/future/two.jpg"} {
rec := httptest.NewRecorder()
shown.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
@@ -82,7 +82,7 @@ func TestUnpublishedBundlesAreAbsentFromEverythingThatLists(t *testing.T) {
if err != nil {
t.Fatal(err)
}
h := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings)
h := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings, nil)
for _, path := range []string{"/art/", "/feed.xml", "/sitemap.xml"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
+19 -1
View File
@@ -36,8 +36,15 @@ func Fixed(site *content.Site, theme *render.Renderer) Current {
// Handler serves a site.
//
// One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
func Handler(current Current, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler {
// 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()
// Registered first so a later duplicate is caught rather than panicking, and so core's own answers are
// the ones that cannot be taken over.
reserved := map[string]bool{"/": true, robotsPath: true, sitemapPath: true}
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
now := current()
serve(w, req, now.Site, now.Theme, siteFS, settings)
@@ -61,6 +68,17 @@ func Handler(current Current, siteFS, derivedFS fs.FS, settings content.Settings
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 site shipping root/robots.txt would take the server
// down at startup rather than lose a race it was never told about. `khosra check` reports the shadow.
for pattern, handler := range routes {
if reserved[pattern] || strings.HasPrefix(pattern, "/static/") || strings.HasPrefix(pattern, content.DerivedPrefix) {
slog.Warn("a passthrough path is already answered by the engine and is not served",
"path", pattern)
continue
}
mux.Handle("GET "+pattern, handler)
}
return mux
}
+9 -9
View File
@@ -28,7 +28,7 @@ func testHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
}
func TestServeBundleAtItsPermalink(t *testing.T) {
@@ -72,7 +72,7 @@ func TestTheRootListsEverything(t *testing.T) {
if err != nil {
t.Fatal(err)
}
bare := Handler(Fixed(content.NewSite(nil), empty), nil, nil, content.Settings{})
bare := Handler(Fixed(content.NewSite(nil), empty), nil, nil, content.Settings{}, nil)
rec = httptest.NewRecorder()
bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusNotFound {
@@ -106,7 +106,7 @@ func multilingualHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
}
func TestPrefixedLanguageServesThatVariant(t *testing.T) {
@@ -158,7 +158,7 @@ func aliasHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
}
func TestAliasRedirectsToCanonical(t *testing.T) {
@@ -205,7 +205,7 @@ func listingHandler(t *testing.T, n int) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
}
func TestSectionIndexListsNewestFirst(t *testing.T) {
@@ -273,7 +273,7 @@ func TestStaticFilesAreServedAndDirectoriesAreNot(t *testing.T) {
if err != nil {
t.Fatal(err)
}
h := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{})
h := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
for path, want := range map[string]int{
"/static/style.css": http.StatusOK,
"/static/img/logo.svg": http.StatusOK,
@@ -314,7 +314,7 @@ func TestAStaticPathThatEscapesTheRootIs404(t *testing.T) {
if err != nil {
t.Fatal(err)
}
h := Handler(Fixed(content.NewSite(nil), r), fsys, nil, content.Settings{})
h := Handler(Fixed(content.NewSite(nil), r), fsys, nil, content.Settings{}, nil)
for path, want := range map[string]int{
"/static/ok.css": http.StatusOK,
"/static/escape.txt": http.StatusNotFound,
@@ -347,7 +347,7 @@ func seriesHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), nil, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles), r), nil, nil, content.Settings{}, nil)
}
func TestSequenceNavigationLinksNeighbours(t *testing.T) {
@@ -439,7 +439,7 @@ func tagHandler(t *testing.T) http.Handler {
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), nil, nil, content.Settings{})
return Handler(Fixed(content.NewSite(bundles), r), nil, nil, content.Settings{}, nil)
}
func TestGlobalTagListingSpansSectionsGroupedByOne(t *testing.T) {