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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user