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:
@@ -0,0 +1,92 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExtrasDir is the directory inside a bundle holding supporting files — drafts, notes, logs, scans.
|
||||
//
|
||||
// A fixed name for now. The parked settings cascade would let a comic call it `process/` and a story `notes/`,
|
||||
// which is a section-level setting and nothing reads one yet (`ideas/deferred-decisions.md`).
|
||||
const ExtrasDir = "extras"
|
||||
|
||||
// Entry is one file or directory inside a bundle's extras.
|
||||
type Entry struct {
|
||||
// Name is the entry's own name; Path is its path within the extras directory, which is what a URL carries.
|
||||
Name, Path string
|
||||
// Kind classifies the entry for a theme: markdown, text, image, pdf, audio, video, other, or dir.
|
||||
Kind string
|
||||
// Size is the file's length in bytes, zero for a directory.
|
||||
Size int64
|
||||
// IsDir is true for a directory, which a theme renders as something to open rather than to show.
|
||||
IsDir bool
|
||||
}
|
||||
|
||||
// Extras lists everything inside a bundle's extras directory, depth first, sorted by path.
|
||||
//
|
||||
// Sorted so the sparse numeric-prefix convention orders a set without putting numbers in URLs (ADR-0016), and
|
||||
// because a directory read has no order worth relying on. A bundle with no extras returns nothing, which is not
|
||||
// an error — most bundles have none.
|
||||
func Extras(fsys fs.FS, b Bundle) []Entry {
|
||||
assets, hasAssets := b.Assets()
|
||||
if !hasAssets || fsys == nil {
|
||||
return nil
|
||||
}
|
||||
root := path.Join(assets, ExtrasDir)
|
||||
var found []Entry
|
||||
err := fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || p == root {
|
||||
// A missing extras directory is the common case, not a problem to report.
|
||||
return nil
|
||||
}
|
||||
rel := strings.TrimPrefix(p, root+"/")
|
||||
entry := Entry{Name: d.Name(), Path: rel, IsDir: d.IsDir(), Kind: "dir"}
|
||||
if !d.IsDir() {
|
||||
entry.Kind = KindOf(d.Name())
|
||||
if info, statErr := d.Info(); statErr == nil {
|
||||
entry.Size = info.Size()
|
||||
}
|
||||
}
|
||||
found = append(found, entry)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(found, func(i, j int) bool { return found[i].Path < found[j].Path })
|
||||
return found
|
||||
}
|
||||
|
||||
// KindOf classifies a filename for a theme, by extension.
|
||||
//
|
||||
// By extension and not by content: reading every file to sniff it would turn listing a directory into reading
|
||||
// it, and the answer only decides how a theme presents the entry.
|
||||
func KindOf(name string) string {
|
||||
switch strings.ToLower(path.Ext(name)) {
|
||||
case ".md", ".markdown":
|
||||
return "markdown"
|
||||
case ".txt", ".log", ".csv", ".json", ".yaml", ".yml", ".toml":
|
||||
return "text"
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif":
|
||||
return "image"
|
||||
case ".pdf":
|
||||
return "pdf"
|
||||
case ".mp3", ".ogg", ".wav", ".flac", ".m4a":
|
||||
return "audio"
|
||||
case ".mp4", ".webm", ".mov":
|
||||
return "video"
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
// ExtrasURL is the address of a bundle's extras listing, or of one entry within it.
|
||||
func ExtrasURL(route, lang, entry string) string {
|
||||
base := URL(route, lang) + ExtrasDir + "/"
|
||||
if entry == "" {
|
||||
return base
|
||||
}
|
||||
return base + entry
|
||||
}
|
||||
Reference in New Issue
Block a user