Found by serving the include evidence: `tools.md` beside a bundle's index was
itself scanned as a bundle, so a file meant only to be included took a URL of its
own, appeared in its section's listing, and turned the including bundle into a
one-member series. The real binary showed the phantom series nav; no test would
have, because every fixture happened to name its partials differently.
The rule mirrors the one directories already have, `_index` excepted since that
names its directory. `{{< include file="_tools.md" >}}` is now the shape to write.
369 lines
11 KiB
Go
369 lines
11 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
|
|
// 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")
|
|
return b, nil
|
|
}
|
|
|
|
// 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) + "/"
|
|
}
|