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:
Claude Opus 5
2026-07-31 13:18:34 +06:00
committed by bdeshi
parent f3e54b1849
commit 669a94a26a
16 changed files with 685 additions and 136 deletions
+93
View File
@@ -0,0 +1,93 @@
package web
import (
"io/fs"
"log/slog"
"net/http"
"path"
"strings"
"khosra/internal/content"
"khosra/internal/render"
)
// serveExtras answers a bundle's supporting files: the tree, one entry selected, or an entry's raw bytes.
//
// The bundle is looked up first, so an unpublished bundle hides its extras exactly as it hides its assets and
// its body — one rule, one place (ADR-0024).
func serveExtras(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
siteFS fs.FS, res resolution) bool {
if siteFS == nil {
return false
}
key, live := site.KeyFor(res.key)
if !live {
return false
}
b, served, found := site.Lookup(key, res.lang)
if !found {
return false
}
assets, hasAssets := b.Assets()
if !hasAssets {
return false
}
entries := content.Extras(siteFS, b)
if len(entries) == 0 {
return false
}
if res.entry == "" {
return renderExtras(w, r, b, served, entries, nil)
}
entry, ok := find(entries, res.entry)
if !ok || entry.IsDir {
return false
}
name := path.Join(assets, content.ExtrasDir, entry.Path)
// ?raw is a representation of the same entry rather than a different one, which is why it is a parameter
// and not another path (ADR-0047).
if req.URL.Query().Has("raw") {
http.ServeFileFS(w, req, siteFS, name)
return true
}
selected := &render.Selected{Entry: entry, RawURL: content.ExtrasURL(b.Route, served, entry.Path) + "?raw"}
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)
} else if html, err := r.RenderText(entry.Kind, data); err != nil {
slog.Error("extras entry unrenderable", "path", name, "err", err)
} else {
selected.HTML = html
}
}
return renderExtras(w, r, b, served, entries, selected)
}
// renderExtras writes the listing, degrading like every other render failure.
func renderExtras(w http.ResponseWriter, r *render.Renderer, b content.Bundle, served string,
entries []content.Entry, selected *render.Selected) bool {
out, err := r.Extras(b, served, entries, selected)
if err != nil {
slog.Error("extras failed", "key", b.Key, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return true
}
write(w, out, b.Key+"/"+content.ExtrasDir)
return true
}
// find locates an entry by its path within the tree.
//
// Chosen from the enumeration rather than probed on disk: an entry a request names has to be one the listing
// showed, so a path that walks out of the tree is not found rather than refused.
func find(entries []content.Entry, want string) (content.Entry, bool) {
want = strings.TrimSuffix(want, "/")
for _, e := range entries {
if e.Path == want {
return e, true
}
}
return content.Entry{}, false
}
+155
View File
@@ -0,0 +1,155 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
"khosra/internal/render"
)
func extrasFS() fstest.MapFS {
return fstest.MapFS{
"content/writing/story/index.md": {Data: []byte("---\ntitle: A Story\ndate: 2026-01-01\n---\nThe story itself.\n")},
"content/writing/story/extras/notes.md": {Data: []byte("## Notes\n\nWith *emphasis*.\n")},
"content/writing/story/extras/log.txt": {Data: []byte("day one: <not markup>\n")},
"content/writing/story/extras/scan.jpg": {Data: []byte("jpeg bytes")},
"content/writing/story/extras/drafts/v1.md": {Data: []byte("first attempt\n")},
"content/writing/plain.md": {Data: []byte("---\ntitle: Plain\ndate: 2026-01-02\n---\nx\n")},
}
}
func extrasHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
t.Helper()
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
r, err := render.New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
}
func TestExtrasAreNotBundles(t *testing.T) {
// The scanner skips the directory entirely, so a .md in there has no URL, no identity and never appears in
// a listing (content-model.md).
fsys := extrasFS()
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
for _, b := range bundles {
if strings.Contains(b.Key, "extras") {
t.Errorf("%q was scanned as a bundle", b.Key)
}
}
if len(bundles) != 2 {
t.Errorf("scanned %d bundles, want 2", len(bundles))
}
h := extrasHandler(t, fsys)
for _, path := range []string{"/writing/story/extras/notes/", "/writing/story/extras/drafts/v1/"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "<h1>") {
t.Errorf("GET %s rendered a page for something that is not a bundle", path)
}
}
}
func TestTheExtrasListingShowsTheTreeClassified(t *testing.T) {
h := extrasHandler(t, extrasFS())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"notes.md", "log.txt", "scan.jpg", "drafts", "markdown", "text", "image"} {
if !strings.Contains(body, want) {
t.Errorf("listing missing %q:\n%s", want, body)
}
}
// Sorted by path, so the order is the same on every request and a numeric prefix orders a set.
if strings.Index(body, "drafts") > strings.Index(body, "log.txt") {
t.Errorf("entries should be sorted by path:\n%s", body)
}
// A bundle with no extras has no listing at all.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/plain/extras/", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("a bundle without extras = %d, want 404", rec.Code)
}
}
func TestSelectingAnEntryRendersWhatItCanAndOffersTheRest(t *testing.T) {
h := extrasHandler(t, extrasFS())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/notes.md", nil))
body := rec.Body.String()
if !strings.Contains(body, "<h2>Notes</h2>") || !strings.Contains(body, "<em>emphasis</em>") {
t.Errorf("markdown should be rendered:\n%s", body)
}
// The tree is still there: selecting is a link, and the page is a full re-render, so no JavaScript is needed.
if !strings.Contains(body, "log.txt") {
t.Errorf("the listing should still be shown beside the selection:\n%s", body)
}
// A text file is shown as text, escaped — a log is not markup.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/log.txt", nil))
body = rec.Body.String()
if !strings.Contains(body, "&lt;not markup&gt;") {
t.Errorf("a text entry must be escaped, not interpreted:\n%s", body)
}
// A picture cannot be rendered inline as text, so it arrives with a raw URL instead.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/scan.jpg", nil))
if body := rec.Body.String(); !strings.Contains(body, "scan.jpg?raw") {
t.Errorf("an image entry should offer its bytes:\n%s", body)
}
}
func TestRawReturnsTheBytesAndNothingElse(t *testing.T) {
h := extrasHandler(t, extrasFS())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/scan.jpg?raw", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if got := rec.Body.String(); got != "jpeg bytes" {
t.Errorf("body = %q, want the file itself", got)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
t.Errorf("content-type = %q", ct)
}
}
func TestExtrasCannotBeUsedToWanderOrToReadAnUnpublishedBundle(t *testing.T) {
fsys := extrasFS()
fsys["content/writing/secret/index.md"] = &fstest.MapFile{Data: []byte("---\ntitle: Secret\ndraft: true\n---\nx\n")}
fsys["content/writing/secret/extras/plan.md"] = &fstest.MapFile{Data: []byte("the plan\n")}
h := extrasHandler(t, fsys)
for _, path := range []string{
"/writing/story/extras/../../../etc/passwd",
"/writing/story/extras/nothing.md",
"/writing/story/extras/drafts", // a directory is not an entry to show
"/writing/secret/extras/", // draft: the whole bundle is hidden (ADR-0024)
"/writing/secret/extras/plan.md",
"/writing/secret/extras/plan.md?raw",
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code == http.StatusOK {
t.Errorf("GET %s = 200, want a miss:\n%s", path, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "the plan") {
t.Fatalf("GET %s leaked an unpublished bundle's extras", path)
}
}
}
+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) {
+15
View File
@@ -140,12 +140,27 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend
}
return
}
if res.extras {
if !serveExtras(w, req, site, r, siteFS, res) {
http.NotFound(w, req)
}
return
}
if res.tag != "" {
if !serveTags(w, req, site, r, res) {
http.NotFound(w, req)
}
return
}
serveBundle(w, req, site, r, siteFS, res)
}
// serveBundle answers a request that named a bundle, a section listing, or a file inside a bundle.
//
// Split from serve when that function crossed the length warning: serve decides *what kind* of thing was asked
// for, this one answers the commonest kind.
func serveBundle(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
siteFS fs.FS, res resolution) {
// A redirect target only exists for a path that resolves, so check the bundle before sending one:
// otherwise a nonexistent page answers 301 and confirms nothing.
// A request path is a route: a slug may have moved a bundle there, and moved another away (ADR-0035).