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.
94 lines
2.8 KiB
Go
94 lines
2.8 KiB
Go
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
|
|
}
|