A pass over the content at startup writes three widths per picture into a cache outside the site root, named by the source's content hash and the width (ADR-0042). Idempotent by construction: a rerun stats and skips, an edited picture takes a new name, and nothing stale can be served under an old one. Restarting the evidence site made 0 derivatives the second time, as it should. Ahead of the request rather than during it, because resampling is felt and there is no page cache yet to hide it. Outside the site root, because the engine reads that directory and must not leave generated files in somebody's content git — a lost cache costs one startup pass and no correctness. Markup now carries the original as src, the derivatives as srcset closed by the original at its own width, and width/height from the original — which retires most of the latent row about the output floor; only a gallery's alt is still empty, and a filename cannot supply that. Two things the work itself decided: `Fragment.Items` became `Fragment.Pictures`, ADR-0037's own revisit trigger. Items had one consumer, so widening it beat adding a second list beside it. "A browser can show it" and "we can resample it" are different questions, and conflating them nearly deleted content: an SVG has no decoder here, so a single predicate would have dropped SVGs from galleries silently. Undecodable and unsupported pictures are now rendered as they are, without a size or a srcset.
402 lines
13 KiB
Go
402 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)
|
|
}
|
|
|
|
// 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) + "/"
|
|
}
|