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 }