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) } // DerivedPrefix is where generated files are served from. Reserved like any other engine-owned path, and // deliberately not under content: nothing an author writes is addressed there (ADR-0042). const DerivedPrefix = "/derived/" // DerivedURL is the address of one generated file. func DerivedURL(name string) string { return DerivedPrefix + name } // 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) + "/" }