publish a bundle's extras as a browsable tree

Re-adopts the parked extras entry as ADR-0047: `extras/` inside a bundle is skipped
by the scanner entirely, so a `.md` in there is an asset with no identity and no URL
of its own. The engine enumerates the tree, sorts it by path, classifies by
extension, renders markdown and text, and offers everything else as bytes. One route
with two behaviours — `…/extras/{path}` selects an entry, `?raw` returns the file.

Almost everything it needed already existed, which is the sign the model was right:
the scanner had a directory exclusion, `Assets()` knew which bundles own a
directory, and `Lookup` already decided visibility — so a draft hides its extras
with no new check. A test proves that, including `?raw`.

Two deviations from the parked shape, both because the shape was written before the
code. The directory name is fixed rather than a cascade key, since nothing reads a
section-level setting yet. And an entry is resolved against the *enumeration* rather
than the filesystem: not being in the listing is a stronger answer than os.Root
refusing a path, and cheaper.

Selecting is a link and a full page. No JavaScript is involved, and a
sidebar-and-pane layout is the theme's business — which is the layer rule applied
before writing the feature rather than after.

Three size warnings fired as a result and were fixed by splitting at seams, not by
sharding: render.go gave up its type declarations to view.go, which is the theme
contract in Go and nothing else; serve() split into a dispatcher and serveBundle;
resolve() gave up its language-prefix step to cutLang.
This commit is contained in:
2026-08-01 02:23:37 +06:00
parent e79aba2b11
commit 37a3fa6ce6
16 changed files with 685 additions and 136 deletions
+45 -12
View File
@@ -22,6 +22,10 @@ type resolution struct {
redirect string
// feed means the request named a feed of whatever scope key and tag describe (ADR-0043).
feed bool
// extras means the request named a bundle's supporting files; entry is the one it picked, or "" for the
// listing (ADR-0047).
extras bool
entry string
}
// resolve maps a request path to a bundle key and language.
@@ -42,18 +46,9 @@ func resolve(path string, site *content.Site) (resolution, bool) {
return resolution{}, false
}
lang := content.DefaultLang
key := content.Normalise(trimmed)
if head, rest, found := strings.Cut(key, "/"); found && head != "" {
switch {
case head == content.DefaultLang:
// /en/… is a second spelling of the root form; send the client to the real one.
return resolution{redirect: content.URL(rest, content.DefaultLang)}, true
case site.HasLang(head):
lang, key = head, rest
}
} else if key == content.DefaultLang {
return resolution{redirect: "/"}, true
lang, key, redirect := cutLang(content.Normalise(trimmed), site)
if redirect != "" {
return resolution{redirect: redirect}, true
}
// A trailing feed.xml names a feed of the scope before it. It is a file rather than a page, so none of
@@ -65,6 +60,12 @@ func resolve(path string, site *content.Site) (resolution, bool) {
return resolution{key: rest, lang: lang, feed: true}, true
}
// An `extras` segment inside a key names a bundle's supporting files, and everything after it is one
// entry's path — which may contain slashes, so it is taken whole (ADR-0047).
if bundle, entry, isExtras := cutExtras(key); isExtras {
return resolution{key: bundle, entry: entry, lang: lang, extras: true}, true
}
page := 1
// A trailing /page/N/ is pagination, not part of the key (ADR-0028). Page one is the bare listing
// URL, so /page/1/ is a second spelling and redirects.
@@ -91,6 +92,38 @@ func resolve(path string, site *content.Site) (resolution, bool) {
return resolution{key: key, lang: lang, page: page}, true
}
// cutLang splits a leading language prefix off a key.
//
// A prefix wins over a section of the same name, so a site with Bengali content cannot also have a section
// called `bn` (content-model.md). `/en/…` is never live: it is a second spelling of the root form, and the
// second return value is the redirect that collapses it (ADR-0009).
func cutLang(key string, site *content.Site) (lang, rest, redirect string) {
if head, after, found := strings.Cut(key, "/"); found && head != "" {
switch {
case head == content.DefaultLang:
return "", "", content.URL(after, content.DefaultLang)
case site.HasLang(head):
return head, after, ""
}
} else if key == content.DefaultLang {
return "", "", "/"
}
return content.DefaultLang, key, ""
}
// cutExtras splits a key at its `extras` segment, into the bundle before it and the entry path after.
func cutExtras(key string) (bundle, entry string, ok bool) {
const marker = "/" + content.ExtrasDir
switch {
case strings.HasSuffix(key, marker):
return strings.TrimSuffix(key, marker), "", true
case strings.Contains(key, marker+"/"):
before, after, _ := strings.Cut(key, marker+"/")
return before, after, true
}
return "", "", false
}
// cutFeed strips a trailing feed.xml, reporting whether one was there. What remains is the scope: empty for
// the whole site, a section, or a tag path.
func cutFeed(key string) (rest string, ok bool) {