Every figure and gallery shipped so far emitted links a browser could not fetch: a relative src resolves under the page's URL, and nothing answered there. Found by fetching the pages' own links rather than by reading their markup — the evidence runs had been checking that the right src appeared, never that it worked. A directory bundle's files are now served under its URL. The bundle is looked up first and the file is read only from the directory that bundle owns, never from a path assembled out of the request: ADR-0024 requires that no route serve bundle bytes by path alone, since every byte inside a bundle inherits its publish status. When drafts arrive at queue 19 the filter belongs beside that lookup and nowhere else, which is why the ordering is written down in the comment. A single-file bundle owns nothing: its neighbours belong to the section, and its slash-terminated URL has nothing beneath it. An author with assets writes a directory bundle, now stated in content-model.md. A .md inside a bundle directory is never an asset — it is a bundle with its own URL or a fragment that was never addressable, and serving either raw would publish source. http.ServeFileFS handles content type, conditional requests and ranges, none of which is worth reimplementing here.
395 lines
13 KiB
Go
395 lines
13 KiB
Go
package content
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/text/unicode/norm"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// DefaultLang is the locale served at the root of the URL space; every other language is served under a
|
|
// prefix (ADR-0009). A filename with no language suffix means this locale (ADR-0021).
|
|
const DefaultLang = "en"
|
|
|
|
// Bundle is one addressable piece of content in one language.
|
|
//
|
|
// Only Title is lifted out of frontmatter; every other key lands in Extra, so a template or a later
|
|
// feature can read a field the parser has never heard of (ADR-0002). Absence is always the zero value,
|
|
// never an error.
|
|
type Bundle struct {
|
|
// Key identifies the bundle across languages: its path under content/, without language suffix or
|
|
// extension, NFC-normalised. This is the identity in ADR-0004 and never contains a language.
|
|
Key string
|
|
// Lang is always set; a file with no suffix reports DefaultLang.
|
|
Lang string
|
|
// Path is the file this bundle was read from, relative to the site root.
|
|
Path string
|
|
// Title is empty when frontmatter omits it. Whether that is legal depends on the type, which
|
|
// nothing decides yet, so the parser accepts it.
|
|
Title string
|
|
// Date is publication time, zero when frontmatter omits it. Undated bundles sort after dated ones.
|
|
Date time.Time
|
|
// Tags are free-form terms, case and script preserved as the author wrote them. The URL form is
|
|
// TagSlug of each (ADR-0018).
|
|
Tags []string
|
|
// Aliases are paths that must keep resolving to this bundle, each redirecting to its canonical URL
|
|
// (ADR-0008). Additive only: an alias is a promise never withdrawn.
|
|
Aliases []string
|
|
// Slug is a hand-chosen final path segment, empty unless frontmatter declares one. It renames the
|
|
// bundle's address in every language (ADR-0035) and never its Key, which stays the identity.
|
|
Slug string
|
|
// Route is the path this bundle is served at: its Key, unless a slug renamed the last segment. Set by
|
|
// NewSite, which is the only place that can see whether every variant agrees.
|
|
Route string
|
|
// Order is this bundle's position in the series it is nested under, zero when frontmatter omits it.
|
|
// The convention is sparse (10, 20, 30), so zero is not a position: an unordered member sorts by name
|
|
// after every ordered one (ADR-0033).
|
|
Order int
|
|
// Body is everything after the frontmatter, unrendered.
|
|
Body []byte
|
|
// Extra holds every frontmatter key other than title, exactly as YAML parsed it.
|
|
Extra map[string]any
|
|
}
|
|
|
|
// OpenSite opens a site root for reading.
|
|
//
|
|
// The returned fs.FS is backed by [os.Root], which refuses any name that would resolve outside dir,
|
|
// including through a symlink — unlike os.DirFS, which does not (ADR-0031). The root is held for the
|
|
// life of the process.
|
|
func OpenSite(dir string) (fs.FS, error) {
|
|
root, err := os.OpenRoot(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open site root %s: %w", dir, err)
|
|
}
|
|
return root.FS(), nil
|
|
}
|
|
|
|
// Scan reads every bundle under content/ in fsys.
|
|
//
|
|
// A bundle that cannot be parsed, or that collides with another on the same key and language, is logged
|
|
// at error level and left out; neither is fatal, because one mistyped colon must not take down a site
|
|
// (ADR-0029). An error is returned only when the walk itself fails.
|
|
func Scan(fsys fs.FS) ([]Bundle, error) {
|
|
var found []Bundle
|
|
err := fs.WalkDir(fsys, "content", func(p string, d fs.DirEntry, err error) error {
|
|
switch {
|
|
case err != nil:
|
|
return err
|
|
case d.IsDir():
|
|
if skipDir(path.Base(p)) {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
case !strings.HasSuffix(p, ".md"):
|
|
return nil
|
|
case isPartial(path.Base(p)):
|
|
return nil
|
|
}
|
|
data, err := fs.ReadFile(fsys, p)
|
|
if err != nil {
|
|
slog.Error("skipping unreadable bundle", "path", p, "err", err)
|
|
return nil
|
|
}
|
|
b, err := Parse(strings.TrimPrefix(p, "content/"), data)
|
|
if err != nil {
|
|
slog.Error("skipping unparseable bundle", "path", p, "err", err)
|
|
return nil
|
|
}
|
|
b.Path = p
|
|
found = append(found, b)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan content: %w", err)
|
|
}
|
|
return dropCollisions(found), nil
|
|
}
|
|
|
|
// Parse reads one bundle from the bytes of a file, named relative to content/.
|
|
func Parse(name string, data []byte) (Bundle, error) {
|
|
key, lang, ok := splitName(name)
|
|
if !ok {
|
|
return Bundle{}, fmt.Errorf("not a bundle filename: %s", name)
|
|
}
|
|
front, body := splitFrontmatter(data)
|
|
b := Bundle{Key: key, Lang: lang, Body: body, Extra: map[string]any{}}
|
|
if len(front) > 0 {
|
|
if err := yaml.Unmarshal(front, &b.Extra); err != nil {
|
|
return Bundle{}, fmt.Errorf("frontmatter: %w", err)
|
|
}
|
|
}
|
|
if t, isStr := b.Extra["title"].(string); isStr {
|
|
b.Title = t
|
|
}
|
|
delete(b.Extra, "title")
|
|
b.Aliases = stringList(b.Extra["aliases"])
|
|
delete(b.Extra, "aliases")
|
|
b.Date = asTime(b.Extra["date"])
|
|
b.Tags = terms(b.Extra["tags"])
|
|
delete(b.Extra, "tags")
|
|
b.Order = asInt(b.Extra["order"])
|
|
delete(b.Extra, "order")
|
|
if slug, isStr := b.Extra["slug"].(string); isStr {
|
|
// One segment, normalised like every other identifier (ADR-0015). Slashes would let a slug move the
|
|
// bundle to another section, which is a move, not a rename.
|
|
b.Slug = Normalise(strings.Trim(strings.TrimSpace(slug), "/"))
|
|
}
|
|
delete(b.Extra, "slug")
|
|
b.Route = b.Key
|
|
return b, nil
|
|
}
|
|
|
|
// Assets is the directory holding a bundle's own local files, and false for a bundle that has none.
|
|
//
|
|
// Only a directory bundle has one. A single-file bundle's neighbours belong to its section rather than to it,
|
|
// and its URL ends in a slash that no file beside it sits under — so an author with assets writes a directory
|
|
// bundle (content-model.md).
|
|
func (b Bundle) Assets() (string, bool) {
|
|
base := path.Base(b.Path)
|
|
if strings.HasPrefix(base, "index.") || strings.HasPrefix(base, "_index.") {
|
|
return path.Dir(b.Path), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// stringList reads a YAML scalar or sequence of strings as keys: normalised, without surrounding
|
|
// slashes. Anything that is not a string is ignored rather than failing the bundle.
|
|
func stringList(v any) []string {
|
|
var out []string
|
|
add := func(x any) {
|
|
if str, ok := x.(string); ok {
|
|
if k := Normalise(strings.Trim(str, "/")); k != "" {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
}
|
|
switch t := v.(type) {
|
|
case string:
|
|
add(t)
|
|
case []any:
|
|
for _, x := range t {
|
|
add(x)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// asTime reads a frontmatter date. yaml.v3 hands back a time.Time for an unquoted timestamp and a string
|
|
// for a quoted one, so both spellings work and anything else is simply absent.
|
|
func asTime(v any) time.Time {
|
|
switch t := v.(type) {
|
|
case time.Time:
|
|
return t
|
|
case string:
|
|
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
|
|
if parsed, err := time.Parse(layout, t); err == nil {
|
|
return parsed
|
|
}
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// asInt reads a frontmatter integer. yaml.v3 hands back an int for an unquoted number and a string for a
|
|
// quoted one, so both spellings work and anything else is simply absent.
|
|
func asInt(v any) int {
|
|
switch t := v.(type) {
|
|
case int:
|
|
return t
|
|
case string:
|
|
if n, err := strconv.Atoi(t); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// terms reads a scalar or sequence of tag names, preserving case and script.
|
|
func terms(v any) []string {
|
|
var out []string
|
|
add := func(x any) {
|
|
if str, ok := x.(string); ok {
|
|
if t := strings.TrimSpace(Normalise(str)); t != "" {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
}
|
|
switch t := v.(type) {
|
|
case string:
|
|
add(t)
|
|
case []any:
|
|
for _, x := range t {
|
|
add(x)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TagSlug is the URL form of a tag: normalised, lowercased, spaces joined by hyphens.
|
|
//
|
|
// Lowercasing is a no-op for scripts without case, so Bengali terms pass through unchanged. A hand-chosen
|
|
// slug per term waits for the type declaration that owns term overrides (ADR-0015).
|
|
func TagSlug(tag string) string {
|
|
return strings.Join(strings.Fields(strings.ToLower(Normalise(tag))), "-")
|
|
}
|
|
|
|
// Normalise puts s into NFC.
|
|
//
|
|
// Every identifier goes through this: Bengali conjuncts have several byte encodings for text that looks
|
|
// identical, and macOS hands back NFD, so without it two indistinguishable files take different keys
|
|
// (ADR-0015).
|
|
func Normalise(s string) string { return norm.NFC.String(s) }
|
|
|
|
// splitName derives a bundle key and language from a path under content/.
|
|
//
|
|
// index.md and _index.md name the directory they sit in; anything else names itself. A trailing
|
|
// two- or three-letter lowercase segment is a language suffix.
|
|
func splitName(name string) (key, lang string, ok bool) {
|
|
if !strings.HasSuffix(name, ".md") || name == "" {
|
|
return "", "", false
|
|
}
|
|
dir, base := path.Split(strings.TrimSuffix(name, ".md"))
|
|
dir = strings.TrimSuffix(dir, "/")
|
|
if base == "" {
|
|
return "", "", false
|
|
}
|
|
lang = DefaultLang
|
|
if i := strings.LastIndex(base, "."); i > 0 && isLangTag(base[i+1:]) {
|
|
lang, base = base[i+1:], base[:i]
|
|
}
|
|
if base == "index" || base == "_index" {
|
|
key = dir
|
|
} else {
|
|
key = path.Join(dir, base)
|
|
}
|
|
return Normalise(key), lang, true
|
|
}
|
|
|
|
// isLangTag reports whether s looks like a language suffix: two or three lowercase letters.
|
|
func isLangTag(s string) bool {
|
|
if len(s) < 2 || len(s) > 3 {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if r < 'a' || r > 'z' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isPartial reports whether a filename is a fragment rather than a bundle of its own.
|
|
//
|
|
// An underscore prefix, the same mark a directory already uses, with `_index` excepted because that names
|
|
// the directory it sits in. Without this a file meant only to be included would also be a bundle: it would
|
|
// take a URL, appear in its section's listing, and turn its bundle into a one-member series
|
|
// (content-model.md).
|
|
func isPartial(base string) bool {
|
|
name := strings.TrimSuffix(base, ".md")
|
|
if i := strings.LastIndex(name, "."); i > 0 && isLangTag(name[i+1:]) {
|
|
name = name[:i]
|
|
}
|
|
return strings.HasPrefix(name, "_") && name != "_index"
|
|
}
|
|
|
|
// skipDir reports whether a directory is not content: hidden, or underscore-prefixed.
|
|
func skipDir(base string) bool {
|
|
return strings.HasPrefix(base, ".") && base != "." || strings.HasPrefix(base, "_")
|
|
}
|
|
|
|
// splitFrontmatter separates a leading --- delimited YAML block from the body. A file without one is
|
|
// all body.
|
|
func splitFrontmatter(data []byte) (front, body []byte) {
|
|
const fence = "---"
|
|
rest, hasFence := trimLeadingFence(data, fence)
|
|
if !hasFence {
|
|
return nil, data
|
|
}
|
|
if i := bytes.Index(rest, []byte("\n"+fence)); i >= 0 {
|
|
front = rest[:i+1]
|
|
body = rest[i+1+len(fence):]
|
|
return front, bytes.TrimLeft(body, "\r\n")
|
|
}
|
|
return nil, data
|
|
}
|
|
|
|
// trimLeadingFence removes an opening fence line, reporting whether one was there.
|
|
func trimLeadingFence(data []byte, fence string) ([]byte, bool) {
|
|
s := bytes.TrimLeft(data, "\ufeff")
|
|
if !bytes.HasPrefix(s, []byte(fence)) {
|
|
return data, false
|
|
}
|
|
s = s[len(fence):]
|
|
s = bytes.TrimLeft(s, "\r")
|
|
if !bytes.HasPrefix(s, []byte("\n")) {
|
|
return data, false
|
|
}
|
|
return s[1:], true
|
|
}
|
|
|
|
// dropCollisions removes every bundle sharing a key and language with another.
|
|
//
|
|
// Two spellings of the same variant — about.md and about.en.md, or about.md and about/index.md — are
|
|
// ambiguous rather than harmless, so none of them is served (ADR-0021).
|
|
func dropCollisions(all []Bundle) []Bundle {
|
|
seen := map[string]int{}
|
|
for _, b := range all {
|
|
seen[b.Key+"\x00"+b.Lang]++
|
|
}
|
|
kept := make([]Bundle, 0, len(all))
|
|
for _, b := range all {
|
|
if seen[b.Key+"\x00"+b.Lang] > 1 {
|
|
slog.Error("skipping ambiguous bundle: two files claim one key and language",
|
|
"key", b.Key, "lang", b.Lang, "path", b.Path)
|
|
continue
|
|
}
|
|
kept = append(kept, b)
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// PerPage is how many entries a listing shows.
|
|
//
|
|
// Changing it renumbers page URLs, which ADR-0028 calls a URL event; it becomes a setting when the
|
|
// cascade exists rather than being one knob early.
|
|
const PerPage = 10
|
|
|
|
// URL is the permalink of a variant: /{section}/{slug}/, with a language prefix for anything but the
|
|
// default locale (ADR-0008, ADR-0009). Templates never build a path by hand.
|
|
func URL(key, lang string) string {
|
|
if lang == "" || lang == DefaultLang {
|
|
return "/" + key + "/"
|
|
}
|
|
return "/" + lang + "/" + key + "/"
|
|
}
|
|
|
|
// TagURL is the permalink of a tag listing, optionally narrowed to a section (ADR-0018).
|
|
func TagURL(section, slug, lang string, page int) string {
|
|
key := TagsSegment + "/" + slug
|
|
if section != "" {
|
|
key = section + "/" + key
|
|
}
|
|
return PageURL(key, lang, page)
|
|
}
|
|
|
|
// TagsSegment is reserved at the top level and inside every section, so no bundle may be slugged with it.
|
|
const TagsSegment = "tags"
|
|
|
|
// PageURL is the permalink of a listing page. Page one is the bare listing URL, never /page/1/
|
|
// (ADR-0028).
|
|
func PageURL(key, lang string, page int) string {
|
|
base := URL(key, lang)
|
|
if page <= 1 {
|
|
return base
|
|
}
|
|
return base + "page/" + strconv.Itoa(page) + "/"
|
|
}
|