Files
khosra/internal/content/content.go
T
bdeshi 5e574b038f a .md file starting with _ is a fragment, not a bundle
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.
2026-08-01 02:23:35 +06:00

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) + "/"
}