~bigbes/sr-ht-spec

305bb09f7218d363c63a7d21d75e49c8df33a0cf — Eugene Blikh 27 days ago 64488fc
feat: doc — warren's vault+render absorbed onto the git-object read path

warren scanned a directory (filepath.WalkDir + os.ReadFile). There is no
checkout here, so Scan walks a git tree through gitx instead and feeds the
FromPages seam: the approved head, a pinned sha and a proposal branch are one
code path with a different revision, and nothing downstream of Archive knows
where its pages came from.

Frontmatter is core's, not warren's. Front embeds core.Frontmatter and adds
only what core deliberately does not model — parent, aliases, planned, and the
ordered key list used for display and search text. Two parsers that disagree
about a document header is a bug that surfaces in the ID registry months later.

Documents key on their frontmatter id, falling back to their path: paths move
and ids do not, and a duplicated id resolves to neither document rather than
letting one win silently, matching what the merge already does with the
approved branch. A header core rejects degrades to "no frontmatter" instead of
failing, because --push-option=skip-validation means such a document can exist
and refusing to render it would turn a typo into an outage.
A doc/archive.go => doc/archive.go +426 -0
@@ 0,0 1,426 @@
package doc

import (
	"net/url"
	"path"
	"regexp"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

var schemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`)

// Archive is one space at one revision: the ordered document set plus the
// lookup structures the resolver, the read plane and the indexer need.
//
// It holds no bodies and touches nothing outside itself. That is the property
// the design leans on — the archive is built from a git tree by Scan, but
// everything below this line would work just as well against a page set that
// arrived some other way.
type Archive struct {
	// Space is the space these documents belong to. It is what site hrefs are
	// built from and what an indexed document is filtered by at query time. A
	// zero SpaceRef yields root-relative hrefs, which is what a caller building
	// an archive outside a space context gets.
	Space core.SpaceRef
	// Rev is the revision the documents were read at, as the caller named it.
	// Pass a resolved commit sha when the archive must stay pinned; a branch
	// name here means "whatever that branch pointed at when Scan ran".
	Rev string

	Pages []*Page

	byID   map[string]*Page
	byPath map[string]*Page // repo-relative path, with extension
	byStem map[string]*Page // filename stem -> the document that won the stem
	// stemsIn maps "<section>/<stem>" to a document, so a bare wikilink written
	// in one section prefers a document in the same section — Obsidian's
	// proximity rule, which is what colliding stems across sections mean.
	stemsIn map[string]*Page
	// assets maps an attachment's base name and its full path to that path, so
	// `![[image.png]]` resolves the way it is written.
	assets map[string]string
	// aliases maps a normalised `aliases:` entry to the canonical document ID.
	aliases map[string]string
}

// newArchive returns an Archive with empty lookup maps.
func newArchive(sp core.SpaceRef, rev string) *Archive {
	return &Archive{
		Space:   sp,
		Rev:     rev,
		byID:    make(map[string]*Page),
		byPath:  make(map[string]*Page),
		byStem:  make(map[string]*Page),
		stemsIn: make(map[string]*Page),
		assets:  make(map[string]string),
		aliases: make(map[string]string),
	}
}

// FromPages rebuilds an Archive's lookup structures from a page set that was
// produced earlier, without reading anything.
//
// This is the seam the git-tree walk feeds: Scan produces pages and calls into
// here, and nothing downstream of Archive knows the difference. aliases and the
// attachment index are supplied separately because they are not derivable from
// a Page; pass nil for either when they are not needed.
func FromPages(sp core.SpaceRef, rev string, pages []*Page, aliases, assets map[string]string) *Archive {
	a := newArchive(sp, rev)
	a.Pages = pages
	for _, p := range pages {
		a.register(p)
	}
	for k, v := range aliases {
		a.aliases[k] = v
	}
	for k, v := range assets {
		a.assets[k] = v
	}
	return a
}

// register wires one document into the lookup maps.
//
// An ID already claimed is never overwritten: two documents that both resolve
// to one name is exactly the case the design refuses to guess about, and the
// loser stays reachable by path rather than being silently merged into the
// winner.
func (a *Archive) register(p *Page) {
	if _, taken := a.byID[p.ID]; !taken {
		a.byID[p.ID] = p
	}
	if p.Path != "" {
		a.byPath[p.Path] = p
	}
	stem := Stem(p.Path)
	if stem == "" {
		return
	}
	if cur, ok := a.byStem[stem]; !ok || lessByRank(p.Path, cur.Path) {
		a.byStem[stem] = p
	}
	if key := p.Section + "/" + stem; a.stemsIn[key] == nil {
		a.stemsIn[key] = p
	}
}

// lessByRank orders two paths competing for the same bare stem: shorter path
// first, then lexicographic — total and deterministic.
//
// warren ranked by a fixed list of the vault's top-level directories
// ("wiki" beat "sources"). A space here has no such vocabulary — its
// directories are whatever the policy's auto_merge globs name — so ranking by
// them would be ranking by names that do not exist.
func lessByRank(a, b string) bool {
	if len(a) != len(b) {
		return len(a) < len(b)
	}
	return a < b
}

// Stem returns the filename stem of a path ("specs/storage.md" -> "storage").
// It returns "" for an empty path.
func Stem(p string) string {
	if p == "" {
		return ""
	}
	base := path.Base(p)
	return strings.TrimSuffix(base, path.Ext(base))
}

// Page returns a document by ID.
func (a *Archive) Page(id string) (*Page, bool) { p, ok := a.byID[id]; return p, ok }

// ByPath returns a document by its path in the tree, extension included. This
// is what the read plane's `GET /~user/space/<path>` resolves through.
func (a *Archive) ByPath(p string) (*Page, bool) { pg, ok := a.byPath[p]; return pg, ok }

// All returns all documents in path order.
func (a *Archive) All() []*Page { return a.Pages }

// Aliases returns the alias -> canonical document ID map.
func (a *Archive) Aliases() map[string]string { return a.aliases }

// Assets returns the attachment lookup (base name and path -> path).
func (a *Archive) Assets() map[string]string { return a.assets }

// Canonical resolves an alias to its document. It reports ok=false when the
// name is not a known alias or the alias points at a document that is gone.
func (a *Archive) Canonical(alias string) (*Page, bool) {
	id, ok := a.aliases[normalizeName(alias)]
	if !ok {
		return nil, false
	}
	p, ok := a.byID[id]
	return p, ok
}

// Children returns the documents whose immediate parent is id, in path order.
func (a *Archive) Children(id string) []*Page {
	var out []*Page
	for _, p := range a.Pages {
		if p.ParentID == id {
			out = append(out, p)
		}
	}
	return out
}

// Roots returns top-level documents (those without a parent).
func (a *Archive) Roots() []*Page {
	var out []*Page
	for _, p := range a.Pages {
		if p.ParentID == "" {
			out = append(out, p)
		}
	}
	return out
}

// Backlinks returns documents that link to id. Catalog and log documents are
// skipped: they link to nearly everything, so counting them would make every
// document look referenced and orphan detection would never return a result.
func (a *Archive) Backlinks(id string) []*Page {
	var out []*Page
	for _, p := range a.Pages {
		if SuppressesEdges(p) {
			continue
		}
		for _, l := range p.Links {
			if l == id {
				out = append(out, p)
				break
			}
		}
	}
	return out
}

// SuppressesEdges reports whether a document's outbound links are excluded from
// backlink counts. A catalog links to everything in its section and a log
// summarises everything that happened; left in, no document in the space can
// ever have zero inbound links.
func SuppressesEdges(p *Page) bool {
	return p.Kind == KindCatalog || p.Kind == KindLog
}

// base is the site path prefix every href in this archive hangs off:
// "/~owner/space", or "" when the archive has no space.
func (a *Archive) base() string {
	if a.Space.Owner == "" || a.Space.Name == "" {
		return ""
	}
	return "/" + a.Space.String()
}

// DocHref is the site path a document renders at: its tree path without the
// ".md" extension, under the space prefix.
//
// The extension is dropped because the read plane negotiates content by it —
// "path.md" is the raw source — and a wikilink means "show me this document",
// not "show me its bytes".
func (a *Archive) DocHref(p *Page) string {
	return a.base() + "/" + escapePath(strings.TrimSuffix(p.Path, core.DocExt))
}

// AssetHref is the site path an attachment is served at. Every segment is
// escaped so spaces, Cyrillic and literal percent signs survive.
func (a *Archive) AssetHref(p string) string {
	return a.base() + "/" + escapePath(p)
}

func escapePath(p string) string {
	parts := strings.Split(p, "/")
	for i, seg := range parts {
		parts[i] = url.PathEscape(seg)
	}
	return strings.Join(parts, "/")
}

// Resolve implements Resolver. dest is a link destination as written in a
// document living in fromDir (space-relative, "" for the space root): either a
// wikilink target ("SPEC-0007", "specs/storage", "note#heading") or an ordinary
// markdown destination (a URL, or a path relative to fromDir).
func (a *Archive) Resolve(fromDir, dest string) Target {
	dest = strings.TrimSpace(dest)
	if dest == "" || strings.HasPrefix(dest, "#") {
		return Target{Href: dest}
	}
	if schemeRe.MatchString(dest) || strings.HasPrefix(dest, "//") {
		return Target{Href: dest, IsExternal: true}
	}

	base, frag := splitFragment(dest)
	if base == "" {
		return Target{Href: dest}
	}

	if p := a.lookupPage(fromDir, base); p != nil {
		return Target{
			Href:   a.DocHref(p) + fragmentSuffix(frag),
			PageID: p.ID,
			Path:   p.Path,
			Kind:   string(p.Kind),
		}
	}
	if rel, ok := a.lookupAsset(fromDir, base); ok {
		return Target{Href: a.AssetHref(rel), Path: rel}
	}
	// Nothing resolved. The destination is handed back exactly as it was
	// written rather than pointed at an invented URL: the renderer marks it
	// visibly broken, and a caller that wants to repair it needs to see what
	// the author actually typed.
	return Target{Href: dest, Missing: true}
}

// lookupPage resolves a link target to a document, narrowest scope first: an
// explicit path wins outright, then a document id, then a name beside the
// linking document, then within its section, then space-wide, then aliases.
//
// The id step is what makes [[SPEC-0007]] work and is matched exactly —
// case-insensitive matching would let a lowercase or homograph id resolve to a
// document it is not, which is the confusion core.ParseDocID exists to prevent.
func (a *Archive) lookupPage(fromDir, base string) *Page {
	bare := strings.TrimSuffix(base, core.DocExt)
	if bare == "" {
		return nil
	}

	if strings.Contains(bare, "/") {
		// Space-relative ("specs/storage") or relative to the linking document
		// ("../specs/storage", as an ordinary markdown link would write it). A
		// path-qualified target that matches nothing is a miss, not an
		// invitation to fall back to a bare stem in some other directory.
		for _, cand := range []string{bare, cleanJoin(fromDir, bare)} {
			if cand == "" {
				continue
			}
			if p, ok := a.byPath[cand+core.DocExt]; ok {
				return p
			}
			if p, ok := a.byID[cand]; ok {
				return p
			}
		}
		return nil
	}

	if p, ok := a.byID[bare]; ok && p.DocID == bare {
		return p
	}

	// A bare name carrying a non-markdown extension ("diagram.png") names an
	// attachment. Stem matching would strip the extension and could hand back an
	// unrelated document that happens to be called "diagram".
	if ext := path.Ext(bare); ext != "" && !strings.EqualFold(ext, core.DocExt) {
		return nil
	}

	if cand := cleanJoin(fromDir, bare); cand != "" {
		if p, ok := a.byPath[cand+core.DocExt]; ok {
			return p
		}
	}
	if p, ok := a.stemsIn[topSection(fromDir)+"/"+bare]; ok {
		return p
	}
	if p, ok := a.byStem[bare]; ok {
		return p
	}
	if p, ok := a.byID[bare]; ok {
		return p
	}
	if p, ok := a.Canonical(bare); ok {
		return p
	}
	return nil
}

// lookupAsset resolves a link target to a blob in the space that is not a
// document — an image, a PDF. Embeds name attachments by base name alone
// (`![[diagram.png]]`), so the base name is tried after the paths.
func (a *Archive) lookupAsset(fromDir, base string) (string, bool) {
	for _, cand := range []string{cleanJoin(fromDir, base), base} {
		if cand == "" {
			continue
		}
		if rel, ok := a.assets[cand]; ok {
			return rel, true
		}
	}
	if rel, ok := a.assets[path.Base(base)]; ok {
		return rel, true
	}
	return "", false
}

// splitFragment separates a heading or block reference from a link target.
// Document paths never contain '#', so the first one is always the separator.
func splitFragment(dest string) (base, frag string) {
	if i := strings.IndexByte(dest, '#'); i >= 0 {
		return dest[:i], dest[i+1:]
	}
	return dest, ""
}

// fragmentSuffix renders a heading reference as a URL fragment matching
// goldmark's auto-generated heading anchors. Block references ("^block-id")
// have no anchor in the rendered HTML, so they are dropped.
func fragmentSuffix(frag string) string {
	if frag == "" || strings.HasPrefix(frag, "^") {
		return ""
	}
	return "#" + slugify(frag)
}

// slugify lowercases a heading and replaces every run of non-alphanumerics with
// a single hyphen, matching goldmark's WithAutoHeadingID output for ASCII
// headings.
func slugify(s string) string {
	var b strings.Builder
	lastDash := true
	for _, r := range strings.ToLower(s) {
		switch {
		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
			b.WriteRune(r)
			lastDash = false
		case r > 127: // keep non-ASCII letters; goldmark passes them through
			b.WriteRune(r)
			lastDash = false
		default:
			if !lastDash {
				b.WriteByte('-')
				lastDash = true
			}
		}
	}
	return strings.Trim(b.String(), "-")
}

// normalizeName folds an alias or link target for case-insensitive matching.
func normalizeName(s string) string { return strings.ToLower(strings.TrimSpace(s)) }

// cleanJoin joins a relative destination onto the linking document's directory,
// returning "" when the result escapes the space root.
func cleanJoin(fromDir, dest string) string {
	joined := path.Join(fromDir, dest)
	joined = strings.TrimPrefix(joined, "./")
	if joined == "." || joined == ".." || strings.HasPrefix(joined, "../") {
		return ""
	}
	return joined
}

// topSection returns the top-level directory of a space-relative path; a
// document at the space root has the empty section.
func topSection(rel string) string {
	if i := strings.IndexByte(rel, '/'); i >= 0 {
		return rel[:i]
	}
	if strings.HasSuffix(rel, core.DocExt) {
		return ""
	}
	return rel
}

A doc/archive_test.go => doc/archive_test.go +195 -0
@@ 0,0 1,195 @@
package doc

import (
	"strings"
	"testing"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

func TestTitleFallbackChain(t *testing.T) {
	arc := archiveOf(t, map[string]string{
		"notes/from-frontmatter.md": "---\ntitle: From Frontmatter\n---\n\n# Ignored H1\n",
		"notes/from-h1.md":          "# From H1\n\nbody\n",
		"notes/from-filename.md":    "just prose, no heading\n",
		"notes/fenced-h1.md":        "```\n# Not A Heading\n```\n\ntext\n",
	})
	want := map[string]string{
		"notes/from-frontmatter.md": "From Frontmatter",
		"notes/from-h1.md":          "From H1",
		"notes/from-filename.md":    "from-filename",
		"notes/fenced-h1.md":        "fenced-h1",
	}
	for path, title := range want {
		if got := mustPage(t, arc, path).Title; got != title {
			t.Errorf("%s title = %q, want %q", path, got, title)
		}
	}
}

func TestPageKindClassification(t *testing.T) {
	arc := archiveOf(t, map[string]string{
		// No marker at all — the file-name fallback.
		"notes/index.md": "# Notes Index\n",
		"notes/log.md":   "# Notes Log\n",
		// The explicit frontmatter markers.
		"specs/catalogue.md": "---\ntitle: Catalogue\ntype: catalog\n---\n",
		"specs/journal.md":   "---\ntitle: Journal\ntype: log\n---\n",
		"specs/ordinary.md":  "---\ntitle: Ordinary\ntype: concept\n---\n",
	})
	want := map[string]PageKind{
		"notes/index.md":     KindCatalog,
		"notes/log.md":       KindLog,
		"specs/catalogue.md": KindCatalog,
		"specs/journal.md":   KindLog,
		"specs/ordinary.md":  KindMarkdown,
	}
	for path, kind := range want {
		p := mustPage(t, arc, path)
		if p.Kind != kind {
			t.Errorf("%s kind = %q, want %q", path, p.Kind, kind)
		}
		if SuppressesEdges(p) != (kind != KindMarkdown) {
			t.Errorf("%s: SuppressesEdges = %v for kind %q", path, SuppressesEdges(p), kind)
		}
	}
}

func TestParentChainAndCycleGuard(t *testing.T) {
	arc := archiveOf(t, map[string]string{
		"notes/root.md": "---\ntitle: Root\n---\n",
		"notes/mid.md":  "---\ntitle: Mid\nparent: \"[[root]]\"\n---\n",
		"notes/leaf.md": "---\ntitle: Leaf\nparent: \"[[mid]]\"\n---\n",
		// A deliberate two-document cycle, plus one that parents itself.
		"notes/cyc-a.md": "---\ntitle: A\nparent: \"[[cyc-b]]\"\n---\n",
		"notes/cyc-b.md": "---\ntitle: B\nparent: \"[[cyc-a]]\"\n---\n",
		"notes/self.md":  "---\ntitle: Self\nparent: \"[[self]]\"\n---\n",
		// A parent that does not exist.
		"notes/orphan.md": "---\ntitle: Orphan\nparent: \"[[nowhere]]\"\n---\n",
	})

	leaf := mustPage(t, arc, "notes/leaf.md")
	if got := strings.Join(leaf.Crumbs, " > "); got != "notes/root > notes/mid" {
		t.Errorf("leaf crumbs = %q", got)
	}
	if leaf.ParentID != "notes/mid" {
		t.Errorf("leaf parent = %q", leaf.ParentID)
	}
	if kids := arc.Children("notes/mid"); len(kids) != 1 || kids[0] != leaf {
		t.Errorf("Children(notes/mid) = %v", kids)
	}

	for _, path := range []string{"notes/cyc-a.md", "notes/cyc-b.md", "notes/self.md", "notes/orphan.md"} {
		p := mustPage(t, arc, path)
		if p.ParentID != "" || len(p.Crumbs) != 0 {
			t.Errorf("%s: cyclic/broken parent must detach, got parent=%q crumbs=%v",
				path, p.ParentID, p.Crumbs)
		}
	}
	if len(arc.Roots()) != len(arc.Pages)-2 { // mid and leaf have parents
		t.Errorf("Roots() = %d of %d documents", len(arc.Roots()), len(arc.Pages))
	}
}

func TestResolveWikilinks(t *testing.T) {
	arc := archiveOf(t, map[string]string{
		"index.md":            "---\ntitle: Home\n---\n",
		"specs/index.md":      "# Specs Index\n",
		"specs/lsm-tree.md":   "---\ntitle: LSM Tree\naliases:\n  - log-structured-merge\n---\n",
		"reports/lsm-tree.md": "Clipped report.\n",
		"specs/0007-store.md": spec("SPEC-0007", "Storage model", "body"),
	})

	cases := []struct {
		name    string
		fromDir string
		dest    string
		want    string
		missing bool
	}{
		{"bare stem prefers the shorter path", "specs", "lsm-tree", "/~bigbes/rfcs/specs/lsm-tree", false},
		{"path-qualified hits the other section", "specs", "reports/lsm-tree", "/~bigbes/rfcs/reports/lsm-tree", false},
		{"proximity: a report links its own section first", "reports", "lsm-tree", "/~bigbes/rfcs/reports/lsm-tree", false},
		{"a bare name prefers the linking document's own folder", "specs", "index", "/~bigbes/rfcs/specs/index", false},
		{"path-qualified index", "reports", "specs/index", "/~bigbes/rfcs/specs/index", false},
		{"a bare stem from the root falls to the stem contest", "", "lsm-tree", "/~bigbes/rfcs/specs/lsm-tree", false},
		{"document id resolves from anywhere", "reports", "SPEC-0007", "/~bigbes/rfcs/specs/0007-store", false},
		{"alias resolves to the canonical document", "specs", "log-structured-merge", "/~bigbes/rfcs/specs/lsm-tree", false},
		{"heading reference becomes an anchor", "specs", "lsm-tree#Core Components", "/~bigbes/rfcs/specs/lsm-tree#core-components", false},
		{"block reference has no anchor", "specs", "lsm-tree#^abc123", "/~bigbes/rfcs/specs/lsm-tree", false},
		{"trailing .md is stripped", "specs", "lsm-tree.md", "/~bigbes/rfcs/specs/lsm-tree", false},
		{"a lowercase id is not the id", "specs", "spec-0007", "", true},
		{"unknown target is missing", "specs", "no-such-document", "", true},
		{"path-qualified miss does not fall back to the stem", "specs", "notes/lsm-tree", "", true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := arc.Resolve(c.fromDir, c.dest)
			if c.missing {
				if !got.Missing {
					t.Fatalf("Resolve(%q) = %+v, want Missing", c.dest, got)
				}
				if got.Href != c.dest {
					t.Errorf("a missing target must be handed back as written, got %q", got.Href)
				}
				return
			}
			if got.Missing {
				t.Fatalf("Resolve(%q) unexpectedly missing", c.dest)
			}
			if got.Href != c.want {
				t.Errorf("Resolve(%q).Href = %q, want %q", c.dest, got.Href, c.want)
			}
		})
	}
}

func TestResolveExternalAndAnchors(t *testing.T) {
	arc := archiveOf(t, map[string]string{"notes/a.md": "# A\n"})
	cases := []struct {
		dest     string
		external bool
		href     string
	}{
		{"https://example.com/x", true, "https://example.com/x"},
		{"mailto:a@b.c", true, "mailto:a@b.c"},
		{"//cdn.example.com/x", true, "//cdn.example.com/x"},
		{"#same-page", false, "#same-page"},
	}
	for _, c := range cases {
		got := arc.Resolve("notes", c.dest)
		if got.IsExternal != c.external || got.Href != c.href {
			t.Errorf("Resolve(%q) = %+v, want href=%q external=%v", c.dest, got, c.href, c.external)
		}
	}
}

// Every href is scoped to the space, because a document only means anything
// inside one, and every segment is escaped because paths carry spaces and
// Cyrillic.
func TestHrefsAreSpaceScopedAndEscaped(t *testing.T) {
	arc := archiveOf(t, map[string]string{"notes/тех долг.md": "# Debt\n"})
	p := mustPage(t, arc, "notes/тех долг.md")
	if got := arc.DocHref(p); got != "/~bigbes/rfcs/notes/%D1%82%D0%B5%D1%85%20%D0%B4%D0%BE%D0%BB%D0%B3" {
		t.Errorf("DocHref = %q", got)
	}

	if got := FromPages(core.SpaceRef{}, "main", []*Page{p}, nil, nil).DocHref(p); !strings.HasPrefix(got, "/notes/") {
		t.Errorf("an archive with no space must yield root-relative hrefs, got %q", got)
	}
}

func TestBacklinksIgnoreCatalogsAndLogs(t *testing.T) {
	arc := archiveOf(t, map[string]string{
		"notes/a.md":     "# A\n",
		"notes/b.md":     "# B\n",
		"notes/index.md": "# Index\n",
	})
	mustPage(t, arc, "notes/b.md").Links = []string{"notes/a"}
	mustPage(t, arc, "notes/index.md").Links = []string{"notes/a"}

	back := arc.Backlinks("notes/a")
	if len(back) != 1 || back[0].Path != "notes/b.md" {
		t.Fatalf("Backlinks = %v, want only notes/b.md", back)
	}
}

A doc/doc.go => doc/doc.go +112 -0
@@ 0,0 1,112 @@
// Package doc is spec.sr.ht's read model for a space's documents: it turns the
// markdown blobs of one git revision into an addressable Archive, resolves the
// [[wikilinks]] between them, and renders them to HTML.
//
// It is warren's vault/ + render/ packages absorbed, with exactly one
// structural change and one consolidation.
//
// # No filesystem
//
// warren scanned a directory: filepath.WalkDir plus os.ReadFile. There is no
// checkout here, so Scan walks a git tree instead — every read resolves a
// revision and reads blobs, which is what makes the approved head, a pinned
// ?rev=<sha> and a proposal branch the same code path with a different rev.
// The seam warren already had, FromPages, is untouched in spirit: it builds an
// Archive out of a page set with no I/O at all, and everything downstream of
// Archive — resolution, backlinks, hierarchy, rendering — is unaware of where
// the pages came from.
//
// # One frontmatter parser
//
// warren's vault/frontmatter.go had its own YAML header parser. core/ already
// owns that contract (core.SplitFrontmatter, core.ParseFrontmatter,
// core.Frontmatter, core.Schema, core.DocID) and it is the one the write plane
// and the push hook validate against. Front therefore embeds core.Frontmatter
// and adds only what core deliberately does not model — `parent:`, `aliases:`,
// `planned:`, and the ordered key list used for display and search text. Two
// parsers that disagree about a document header is a bug that surfaces months
// later, in the registry, not at the door.
//
// # Tolerant on the read path, strict at the door
//
// core's parser is strict, because a malformed header must be rejected when it
// is proposed or pushed. This package is not the door: `--push-option=
// skip-validation` exists, so a document with a broken header can be on the
// approved branch, and refusing to render it would turn a cosmetic typo into an
// outage. A header this package cannot parse degrades to "no frontmatter" — the
// document still renders, still indexes, and still has a title from its first
// H1 or its file name.
package doc

import "sourcecraft.dev/bigbes/sr-ht-spec/core"

// PageKind distinguishes ordinary documents from the two structurally unusual
// kinds — hand-maintained catalogs and append-only logs — whose links are
// suppressed in backlink counts.
type PageKind string

const (
	KindMarkdown PageKind = "markdown"
	// KindCatalog is a hand-maintained listing page that links to nearly every
	// document in its section. Marked by frontmatter `type: catalog`, or by the
	// file name `index.md` as a fallback.
	KindCatalog PageKind = "catalog"
	// KindLog is an append-only activity log carrying dated entries. Marked by
	// frontmatter `type: log`, or by the file name `log.md` as a fallback.
	KindLog PageKind = "log"
)

// Page is one document of a space at one revision, as the read plane addresses
// it. It carries no body: bodies live in git and are read by blob sha, which is
// what keeps an Archive cheap to build and impossible to serve stale.
type Page struct {
	// ID is the archive's addressing key: the document's frontmatter id when it
	// has a well-formed one that no other document in the space claims, and
	// otherwise its path without the ".md" extension.
	//
	// Falling back to the path rather than to warren's bare filename stem is
	// deliberate. warren keyed on the stem because Obsidian resolves wikilinks
	// that way; here `id` is the load-bearing field and paths are unique by
	// construction, so the fallback is unique too and cannot be taken away from
	// a document by an unrelated file appearing elsewhere.
	ID string `json:"id"`
	// DocID is the frontmatter `id:` when it parses as a core.DocID, otherwise
	// empty. A document with a malformed or duplicated id keeps its path and
	// stays readable — it is excluded from id resolution, not from the archive.
	DocID string `json:"doc_id,omitempty"`

	Kind  PageKind `json:"kind"`
	Title string   `json:"title"`
	// Path is the document's path in the git tree, forward-slashed, with its
	// ".md" extension.
	Path string `json:"path"`
	// Blob is the hex sha of the document's blob at this revision: the render
	// cache key. Content-addressed, so an entry keyed by it can never go stale.
	Blob string `json:"blob,omitempty"`

	Status  core.Status `json:"status,omitempty"`
	Summary string      `json:"summary,omitempty"`
	Tags    []string    `json:"tags,omitempty"`

	// ParentID is the resolved ID of the document named by `parent:`, or empty.
	ParentID string `json:"parent_id,omitempty"`
	// Section is the top-level directory the document lives under ("specs",
	// "notes", "reports"), used to group results and to resolve a bare wikilink
	// in favour of the linking document's own section.
	Section string `json:"section"`
	// Crumbs is the ordered list of ancestor IDs from root to this document's
	// parent (not including the document itself).
	Crumbs []string `json:"crumbs,omitempty"`
	// Links holds the IDs of documents this one links to, deduplicated, in
	// order of first appearance. Filled in by a render pass, not by Scan.
	Links []string `json:"links,omitempty"`
	// WordCount is an approximate word count of the rendered text. Filled in by
	// a render pass, not by Scan.
	WordCount int `json:"word_count,omitempty"`
}

// DocProperty is one frontmatter key flattened to text, in document order.
type DocProperty struct {
	Name  string `json:"name"`
	Value string `json:"value"`
}

A doc/fixture_test.go => doc/fixture_test.go +102 -0
@@ 0,0 1,102 @@
package doc

import (
	"context"
	"fmt"
	"sort"
	"testing"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)

// Fixtures are real git objects, built in-process through gitx. Nothing here
// shells out to git and nothing reads a checkout — which is the point of the
// port: if these tests could be satisfied by a directory of files, they would
// not be testing the thing that changed.

var fxSpace = core.SpaceRef{Owner: "bigbes", Name: "rfcs"}

func fxSig(n int) gitx.Signature {
	return gitx.Signature{
		Name:  "bigbes",
		Email: "bigbes@gmail.com",
		When:  time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC).Add(time.Duration(n) * time.Minute),
	}
}

// space creates an empty bare space under a temp repos root.
func space(t *testing.T) *gitx.Repo {
	t.Helper()
	repo, err := gitx.Create(context.Background(), t.TempDir(), fxSpace, gitx.CreateOptions{Owner: fxSig(0)})
	if err != nil {
		t.Fatalf("gitx.Create: %v", err)
	}
	return repo
}

// commit writes files onto a fresh proposal branch cut from base and returns
// the branch name. A proposal branch is used rather than the approved branch
// because the write path refuses the latter by design, and because reading a
// proposal branch is one of the three revisions the read plane must serve
// through this exact code path.
func commit(t *testing.T, repo *gitx.Repo, n int, base string, files map[string]string) string {
	t.Helper()
	ctx := context.Background()

	branch, err := gitx.ProposalBranch(int64(n))
	if err != nil {
		t.Fatalf("ProposalBranch(%d): %v", n, err)
	}
	if _, err := repo.CreateProposalBranch(ctx, branch, base); err != nil {
		t.Fatalf("CreateProposalBranch(%q, %q): %v", branch, base, err)
	}

	paths := make([]string, 0, len(files))
	for p := range files {
		paths = append(paths, p)
	}
	sort.Strings(paths)

	writes := make([]gitx.Write, 0, len(files))
	for _, p := range paths {
		writes = append(writes, gitx.Write{Path: p, Content: []byte(files[p])})
	}
	meta := gitx.CommitMeta{
		Message:   fmt.Sprintf("fixture %d", n),
		Author:    fxSig(n),
		Committer: fxSig(n),
	}
	if _, err := repo.CommitProposal(ctx, branch, writes, meta); err != nil {
		t.Fatalf("CommitProposal(%q): %v", branch, err)
	}
	return branch
}

// archiveOf commits files into a fresh space and scans the result — the whole
// path from git objects to Archive.
func archiveOf(t *testing.T, files map[string]string) *Archive {
	t.Helper()
	repo := space(t)
	rev := commit(t, repo, 1, repo.ApprovedBranch(), files)
	arc, err := Scan(context.Background(), repo, fxSpace, rev)
	if err != nil {
		t.Fatalf("Scan(%q): %v", rev, err)
	}
	return arc
}

// mustPage looks a document up by path, failing the test when it is absent.
func mustPage(t *testing.T, a *Archive, path string) *Page {
	t.Helper()
	p, ok := a.ByPath(path)
	if !ok {
		var have []string
		for _, p := range a.Pages {
			have = append(have, p.Path)
		}
		t.Fatalf("document %q missing; archive holds %v", path, have)
	}
	return p
}

A doc/front.go => doc/front.go +177 -0
@@ 0,0 1,177 @@
package doc

import (
	"bytes"
	"strings"

	"gopkg.in/yaml.v3"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

// Front is a document's YAML frontmatter as the read plane sees it: everything
// core models, plus the three keys core deliberately does not and the ordered
// key list used for display and for search text.
//
// The modelled fields all come from core.Frontmatter. Nothing here re-derives
// `id`, `title`, `status`, `tags`, `type`, `summary`, `supersedes` or `owners`
// from the YAML a second time — the write plane and the push hook validate
// against core's reading of those keys, and a second interpretation of them
// here would be a disagreement waiting to be discovered in the ID registry.
type Front struct {
	core.Frontmatter

	// Parent is the raw `parent:` value, still in wikilink form
	// ("[[storage-model]]"). Resolved to an ID by the archive's hierarchy pass.
	Parent string
	// Aliases are alternative names that resolve to this document. They are what
	// keeps a link working across a rename in a corpus whose links are written
	// by hand.
	Aliases []string
	// Planned lists wikilink targets a document deliberately leaves unresolved
	// because the target is meant to be written later.
	Planned []string
	// Props is every top-level frontmatter key in document order, flattened to
	// text. It is a projection for display and search, not an interpretation:
	// nothing reads meaning out of it.
	Props []DocProperty
}

// ParseFront splits a document into its frontmatter and its markdown body.
//
// It is deliberately more forgiving than core.ParseDocument, and the two
// failure modes are kept apart:
//
//   - No frontmatter block, or one that is never closed, yields a zero Front and
//     the source unchanged as the body. A document that opens with a thematic
//     break is not a document with a broken header.
//   - A block that is present and closed but that core rejects — malformed YAML,
//     a duplicate key, a non-mapping — yields a zero Front and the body after
//     the closing fence. The header is dropped, not rendered as prose.
//
// Neither case is an error here. This is the read path, and a document that a
// `--push-option=skip-validation` push put on the approved branch with a broken
// header is still a document worth serving and searching; rejecting it belongs
// at the door, where core is used directly.
func ParseFront(src []byte) (Front, []byte) {
	front, body, err := core.SplitFrontmatter(src)
	if err != nil {
		return Front{}, src
	}
	body = bytes.TrimLeft(body, "\r\n")

	fm, err := core.ParseFrontmatter(front)
	if err != nil {
		return Front{}, body
	}
	return Front{Frontmatter: fm}.withExtras(front), body
}

// withExtras fills the keys core does not model, walking the YAML block a
// second time for document order. Only blocks core already accepted reach this,
// so the walk can assume a well-formed mapping and simply skip what it is not
// looking at.
func (f Front) withExtras(block []byte) Front {
	var node yaml.Node
	if err := yaml.Unmarshal(block, &node); err != nil {
		return f
	}
	if len(node.Content) == 0 || node.Content[0].Kind != yaml.MappingNode {
		return f
	}
	m := node.Content[0]

	for i := 0; i+1 < len(m.Content); i += 2 {
		key := m.Content[i].Value
		val := m.Content[i+1]
		list := nodeList(val)

		switch strings.ToLower(key) {
		case "parent":
			f.Parent = val.Value
		case "aliases", "alias":
			f.Aliases = list
		case "planned":
			f.Planned = list
		}
		if text := strings.Join(list, ", "); text != "" {
			f.Props = append(f.Props, DocProperty{Name: key, Value: text})
		}
	}
	return f
}

// nodeList flattens a YAML value to a list of strings: a scalar becomes one
// entry, a sequence one entry per element, and a nested mapping is skipped.
func nodeList(n *yaml.Node) []string {
	switch n.Kind {
	case yaml.ScalarNode:
		if v := strings.TrimSpace(n.Value); v != "" {
			return []string{v}
		}
	case yaml.SequenceNode:
		out := make([]string, 0, len(n.Content))
		for _, c := range n.Content {
			out = append(out, nodeList(c)...)
		}
		return out
	}
	return nil
}

// Prop returns the first of the named keys that carries a value, or "" when
// none do. Key matching is case-insensitive, matching how documents are
// actually written.
func (f Front) Prop(keys ...string) string {
	for _, want := range keys {
		for _, p := range f.Props {
			if strings.EqualFold(p.Name, want) {
				return p.Value
			}
		}
	}
	return ""
}

// SearchText renders the frontmatter as "key: value" lines for the keyword
// index, so a search for a tag, an owner or a summary phrase finds the document
// even though those render as chips rather than prose.
func (f Front) SearchText() string {
	if len(f.Props) == 0 {
		return ""
	}
	var b strings.Builder
	for _, p := range f.Props {
		b.WriteString(p.Name)
		b.WriteString(": ")
		b.WriteString(stripWikiBrackets(p.Value))
		b.WriteByte('\n')
	}
	b.WriteByte('\n')
	return b.String()
}

// stripWikiBrackets removes [[ ]] wrappers from a frontmatter value so the
// keyword index sees "storage-model" rather than "[[storage-model]]".
func stripWikiBrackets(s string) string {
	s = strings.ReplaceAll(s, "[[", "")
	return strings.ReplaceAll(s, "]]", "")
}

// LinkTarget reduces a frontmatter wikilink value ("\"[[storage|Storage]]\"") to
// its bare target ("storage"). A value that is not a wikilink is returned
// trimmed, since `parent: storage` is written that way too.
func LinkTarget(v string) string {
	v = strings.TrimSpace(v)
	v = strings.Trim(v, `"'`)
	v = strings.TrimSpace(v)
	if inner, ok := strings.CutPrefix(v, "[["); ok {
		if inner, ok = strings.CutSuffix(inner, "]]"); ok {
			v = inner
		}
	}
	if i := strings.IndexByte(v, '|'); i >= 0 {
		v = v[:i]
	}
	return strings.TrimSpace(v)
}

A doc/front_test.go => doc/front_test.go +164 -0
@@ 0,0 1,164 @@
package doc

import (
	"strings"
	"testing"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

func TestParseFront(t *testing.T) {
	cases := []struct {
		name       string
		src        string
		wantTitle  string
		wantType   string
		wantStatus core.Status
		wantID     string
		wantTags   []string
		wantProps  int
		wantBody   string
	}{
		{
			name: "full document",
			src: "---\nid: SPEC-0007\ntitle: Proposal storage model\nstatus: draft\n" +
				"type: concept\nsummary: \"Write-optimized storage\"\n" +
				"tags:\n  - databases\n  - storage\n---\n\nA Log-Structured Merge tree.\n",
			wantTitle: "Proposal storage model", wantType: "concept",
			wantStatus: core.StatusDraft, wantID: "SPEC-0007",
			wantTags: []string{"databases", "storage"}, wantProps: 6,
			wantBody: "A Log-Structured Merge tree.\n",
		},
		{
			// Pushed with --push-option=skip-validation, or written by hand.
			name:     "no frontmatter at all",
			src:      "Loading... [13 kB]\n\nProse begins here.\n",
			wantBody: "Loading... [13 kB]\n\nProse begins here.\n",
		},
		{
			// A `**Source:**` bold-label header is body text, not frontmatter,
			// and must be left exactly alone.
			name:     "bold-label header is not frontmatter",
			src:      "**Source:** https://example.com/a\n\nArticle text.\n",
			wantBody: "**Source:** https://example.com/a\n\nArticle text.\n",
		},
		{
			name:     "unterminated block stays body",
			src:      "---\ntitle: broken\n\nno closing fence\n",
			wantBody: "---\ntitle: broken\n\nno closing fence\n",
		},
		{
			name:     "malformed yaml degrades to no properties",
			src:      "---\ntitle: [unclosed\n---\n\nBody.\n",
			wantBody: "Body.\n",
		},
		{
			// core rejects a duplicated key rather than taking the last one:
			// two `id:` lines is exactly the typo that corrupts the registry.
			// On the read path that degrades to an untitled document, not to a
			// document with one of the two ids silently chosen.
			name:     "duplicate key degrades to no properties",
			src:      "---\nid: SPEC-0007\nid: SPEC-0008\ntitle: Two ids\n---\n\nBody.\n",
			wantBody: "Body.\n",
		},
		{
			// A thematic break must not be mistaken for an opening fence.
			name:     "leading thematic break is not frontmatter",
			src:      "---\n\nJust a rule.\n",
			wantBody: "---\n\nJust a rule.\n",
		},
		{
			name:      "explicit yaml end marker closes the block",
			src:       "---\ntitle: Ends early\n...\nBody.\n",
			wantTitle: "Ends early", wantProps: 1,
			wantBody: "Body.\n",
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			front, body := ParseFront([]byte(c.src))
			if front.Title != c.wantTitle {
				t.Errorf("Title = %q, want %q", front.Title, c.wantTitle)
			}
			if front.Type != c.wantType {
				t.Errorf("Type = %q, want %q", front.Type, c.wantType)
			}
			if front.Status != c.wantStatus {
				t.Errorf("Status = %q, want %q", front.Status, c.wantStatus)
			}
			if front.ID != c.wantID {
				t.Errorf("ID = %q, want %q", front.ID, c.wantID)
			}
			if strings.Join(front.Tags, ",") != strings.Join(c.wantTags, ",") {
				t.Errorf("Tags = %v, want %v", front.Tags, c.wantTags)
			}
			if len(front.Props) != c.wantProps {
				t.Errorf("Props = %+v, want %d entries", front.Props, c.wantProps)
			}
			if string(body) != c.wantBody {
				t.Errorf("body = %q, want %q", body, c.wantBody)
			}
		})
	}
}

// The modelled fields must come from core and nowhere else, so that what the
// read plane believes a header says is what the push hook validated.
func TestParseFrontUsesCoreForModelledFields(t *testing.T) {
	src := "---\nid: SPEC-0007\ntitle: T\nstatus: review\nsupersedes: SPEC-0003\n" +
		"owners: [~bigbes]\nparent: \"[[storage]]\"\naliases: [storage-model]\n" +
		"planned: [SPEC-0009]\n---\n\nbody\n"
	front, _ := ParseFront([]byte(src))

	if err := core.DefaultSchema().ValidateFrontmatter(front.Frontmatter); err != nil {
		t.Fatalf("core rejects the frontmatter this package parsed: %v", err)
	}
	if front.Supersedes != "SPEC-0003" || len(front.Owners) != 1 {
		t.Errorf("core fields lost: %+v", front.Frontmatter)
	}
	if !front.Has("parent") {
		t.Errorf("Present must cover every key, including ones core does not model")
	}
	if front.Parent != "[[storage]]" {
		t.Errorf("Parent = %q", front.Parent)
	}
	if strings.Join(front.Aliases, ",") != "storage-model" {
		t.Errorf("Aliases = %v", front.Aliases)
	}
	if strings.Join(front.Planned, ",") != "SPEC-0009" {
		t.Errorf("Planned = %v", front.Planned)
	}
}

func TestFrontSearchTextAndProp(t *testing.T) {
	front, _ := ParseFront([]byte("---\nid: SPEC-0007\ntitle: T\nparent: \"[[storage]]\"\n---\n\nbody\n"))

	if got := front.Prop("Parent"); got != "[[storage]]" {
		t.Errorf("Prop(Parent) = %q", got)
	}
	st := front.SearchText()
	if strings.Contains(st, "[[") {
		t.Errorf("search text kept wikilink brackets: %q", st)
	}
	if !strings.Contains(st, "id: SPEC-0007") {
		t.Errorf("search text missing the id line: %q", st)
	}
}

func TestLinkTarget(t *testing.T) {
	cases := map[string]string{
		`"[[storage]]"`:       "storage",
		`[[storage]]`:         "storage",
		`[[storage|Storage]]`: "storage",
		`  [[specs/index]]  `: "specs/index",
		`storage`:             "storage",
		`"[[a-b]]"`:           "a-b",
		``:                    "",
	}
	for in, want := range cases {
		if got := LinkTarget(in); got != want {
			t.Errorf("LinkTarget(%q) = %q, want %q", in, got, want)
		}
	}
}

A doc/log.go => doc/log.go +95 -0
@@ 0,0 1,95 @@
package doc

import (
	"fmt"
	"regexp"
	"strings"
)

// LogSection is the Section every log entry carries in the index. It is not a
// real directory: the entries all come out of one document. Default search
// excludes it, so a query returns the document describing a thing rather than
// the log entry paraphrasing it, and an explicit filter reaches it.
const LogSection = "log"

// entryHeadRe matches an activity log's entry header:
// "## [YYYY-MM-DD] action | Title".
var entryHeadRe = regexp.MustCompile(`^##\s+\[(\d{4}-\d{2}-\d{2})\]\s+([a-z]+)\s*\|\s*(.*)$`)

// LogEntry is one dated entry of an activity log (a document marked
// `type: log`), indexed as its own document.
//
// Indexing such a log as a single document is the wrong shape twice over: it is
// a large body of text summarising other documents, so it produces chunks that
// are near duplicates of what they describe and compete with them for the same
// queries, and a hit anywhere in the file resolves to the whole file. Split by
// entry, each piece is the size of the thing it describes and answers the
// question a log is uniquely good for — when something landed, what changed in
// a month, what came out of one session.
type LogEntry struct {
	ID     string // "log#<date>-<n>", unique even when a date repeats
	Date   string // YYYY-MM-DD
	Action string // ingest | query | lint | update
	Title  string
	Body   string // entry text, header line excluded
	Anchor string // heading anchor within /log
}

// SplitLog parses an activity log's body into entries. Text before the first
// entry header (the file's own title) is dropped. A file that matches no header
// at all yields no entries, and the caller keeps treating it as one ordinary
// document.
func SplitLog(body []byte) []LogEntry {
	lines := strings.Split(string(body), "\n")

	var (
		entries []LogEntry
		cur     *LogEntry
		buf     []string
		perDate = map[string]int{}
	)
	flush := func() {
		if cur == nil {
			return
		}
		cur.Body = strings.TrimSpace(strings.Join(buf, "\n"))
		entries = append(entries, *cur)
		cur, buf = nil, nil
	}

	inFence := false
	for _, line := range lines {
		trimmed := strings.TrimSpace(line)
		if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
			inFence = !inFence
		}
		if !inFence {
			if m := entryHeadRe.FindStringSubmatch(line); m != nil {
				flush()
				date, action, title := m[1], m[2], strings.TrimSpace(m[3])
				perDate[date]++
				cur = &LogEntry{
					ID:     fmt.Sprintf("log#%s-%d", date, perDate[date]),
					Date:   date,
					Action: action,
					Title:  title,
					Anchor: fmt.Sprintf("e-%s-%d", date, perDate[date]),
				}
				continue
			}
		}
		if cur != nil {
			buf = append(buf, line)
		}
	}
	flush()
	return entries
}

// SearchText is what the keyword index stores for an entry: the header fields
// followed by the body, with wikilink brackets left in place — the renderer is
// not involved here, and the bracketed names are still the words a reader would
// search for.
func (e LogEntry) SearchText() string {
	return e.Date + " " + e.Action + " " + e.Title + "\n" + e.Body
}

A doc/log_test.go => doc/log_test.go +99 -0
@@ 0,0 1,99 @@
package doc

import (
	"strings"
	"testing"
)

func TestSplitLog(t *testing.T) {
	body := strings.Join([]string{
		"# Wiki Log",
		"",
		"## [2026-05-31] ingest | specification.website — modern web spec checklist",
		"Single-page catalog. Pages created: [[specification-website-checklist]].",
		"",
		"## [2026-05-22] ingest | AgentMail — hosted inboxes",
		"Service-side ingest, no source page.",
		"",
		"## [2026-05-22] lint | consistency pass",
		"Second entry on the same date.",
		"",
		"## [2026-04-01] update | rewrote the storage cluster",
		"```",
		"## [2020-01-01] not-an-entry | inside a fence",
		"```",
		"Trailing prose after the fence.",
		"",
	}, "\n")

	entries := SplitLog([]byte(body))
	if len(entries) != 4 {
		for _, e := range entries {
			t.Logf("  %s %s | %s", e.ID, e.Action, e.Title)
		}
		t.Fatalf("got %d entries, want 4", len(entries))
	}

	t.Run("header fields are parsed", func(t *testing.T) {
		e := entries[0]
		if e.Date != "2026-05-31" || e.Action != "ingest" {
			t.Errorf("date/action = %q/%q", e.Date, e.Action)
		}
		if e.Title != "specification.website — modern web spec checklist" {
			t.Errorf("title = %q", e.Title)
		}
		if !strings.Contains(e.Body, "Pages created") {
			t.Errorf("body lost: %q", e.Body)
		}
		if strings.Contains(e.Body, "## [") {
			t.Errorf("body kept its own header line: %q", e.Body)
		}
	})

	t.Run("the file title before the first entry is dropped", func(t *testing.T) {
		for _, e := range entries {
			if strings.Contains(e.Body, "# Wiki Log") {
				t.Fatalf("preamble leaked into %s", e.ID)
			}
		}
	})

	t.Run("same-date entries get distinct ids", func(t *testing.T) {
		if entries[1].ID != "log#2026-05-22-1" || entries[2].ID != "log#2026-05-22-2" {
			t.Errorf("ids = %q, %q", entries[1].ID, entries[2].ID)
		}
		if entries[1].Anchor == entries[2].Anchor {
			t.Errorf("same-date entries share an anchor: %q", entries[1].Anchor)
		}
	})

	t.Run("a header inside a fence is not an entry", func(t *testing.T) {
		last := entries[3]
		if last.Date != "2026-04-01" {
			t.Fatalf("last entry date = %q", last.Date)
		}
		if !strings.Contains(last.Body, "not-an-entry") {
			t.Errorf("fenced text should stay in the body: %q", last.Body)
		}
		if !strings.Contains(last.Body, "Trailing prose") {
			t.Errorf("content after the fence was dropped: %q", last.Body)
		}
	})

	t.Run("search text carries the header fields", func(t *testing.T) {
		st := entries[0].SearchText()
		for _, want := range []string{"2026-05-31", "ingest", "specification.website"} {
			if !strings.Contains(st, want) {
				t.Errorf("search text missing %q: %q", want, st)
			}
		}
	})
}

func TestSplitLogWithNoEntries(t *testing.T) {
	// A page classified as a log that turns out not to follow the format keeps
	// working as an ordinary page rather than vanishing from the index.
	if got := SplitLog([]byte("# Notes\n\nJust prose, no dated headers.\n")); len(got) != 0 {
		t.Fatalf("got %d entries, want none", len(got))
	}
}

A doc/render.go => doc/render.go +249 -0
@@ 0,0 1,249 @@
package doc

import (
	"bytes"
	"strings"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer"
	"github.com/yuin/goldmark/renderer/html"
	"github.com/yuin/goldmark/text"
	"github.com/yuin/goldmark/util"
)

// Link rewriting happens on the goldmark AST rather than via regex so that
// destinations are split from titles correctly and — for the [[wikilink]]
// syntax — so that links inside code spans and fenced code blocks are never
// mistaken for real links. A spec describing this service's own link syntax is
// exactly the document a regex would corrupt.

// Target is the outcome of resolving a raw link destination found in a
// document.
type Target struct {
	Href   string // final href: a site path, or the destination unchanged when missing
	PageID string // non-empty when the link points at another document in the archive
	Path   string // the target's path in the tree, when it resolved to one
	Kind   string // the target document's PageKind when PageID is set
	// IsExternal marks http(s)/mailto and friends.
	IsExternal bool
	// Missing marks an internal reference that resolved to no document and no
	// attachment. The renderer emits it with a distinct CSS class rather than
	// dropping it, so the read plane doubles as a link checker.
	Missing bool
}

// Resolver maps a raw link destination (as written in a document located in
// directory fromDir, relative to the space root) to a Target.
type Resolver interface {
	Resolve(fromDir, dest string) Target
}

// Heading is one entry in a document's table of contents.
type Heading struct {
	Level int    `json:"level"`
	Text  string `json:"text"`
	ID    string `json:"id"`
}

// Result bundles everything produced from rendering one document.
type Result struct {
	HTML      string
	Headings  []Heading
	LinkedIDs []string // outbound document IDs, deduped, first-appearance order
	PlainText string
	WordCount int
	// Wikilinks counts every [[…]] and ![[…]] in the document, resolved or not.
	Wikilinks int
	// MissingWikilinks holds the targets of wikilinks that resolved to no
	// document, alias or attachment — in first-appearance order, deduped.
	// Counted separately from ordinary markdown links because the two mean
	// different things: a broken wikilink is a defect in the space's own link
	// graph, while a broken relative link is usually a pasted external path.
	MissingWikilinks []string
}

// Renderer is a reusable, concurrency-safe markdown renderer.
type Renderer struct {
	md goldmark.Markdown
}

// NewRenderer builds a Renderer configured for GitHub-flavoured markdown with
// automatic heading anchors and the [[wikilink]] syntax.
func NewRenderer() *Renderer {
	md := goldmark.New(
		goldmark.WithExtensions(
			extension.GFM, // tables, strikethrough, autolinks, task lists
			extension.DefinitionList,
			extension.Footnote,
			wikilinkExtension{},
		),
		goldmark.WithParserOptions(
			parser.WithAutoHeadingID(),
		),
		goldmark.WithRendererOptions(
			html.WithUnsafe(), // documents here are first-party and reviewed
			renderer.WithNodeRenderers(
				util.Prioritized(tableRenderer{}, tableRendererPriority),
			),
		),
	)
	return &Renderer{md: md}
}

// Render parses source, rewrites links relative to fromDir via res, and returns
// the HTML plus the extracted structure. source must already have its YAML
// frontmatter removed (see ParseFront); a leading "---" block would otherwise
// render as a thematic break followed by stray text.
func (r *Renderer) Render(source []byte, fromDir string, res Resolver) Result {
	reader := text.NewReader(source)
	doc := r.md.Parser().Parse(reader)

	var headings []Heading
	linked := make([]string, 0, 8)
	seen := make(map[string]struct{}, 8)
	wikilinks := 0
	var missing []string
	missingSeen := make(map[string]struct{})

	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		switch node := n.(type) {
		case *wikilink:
			wikilinks++
			node.target = res.Resolve(fromDir, node.Dest)
			if id := node.target.PageID; id != "" {
				if _, ok := seen[id]; !ok {
					seen[id] = struct{}{}
					linked = append(linked, id)
				}
			}
			if node.target.Missing {
				if _, ok := missingSeen[node.Dest]; !ok {
					missingSeen[node.Dest] = struct{}{}
					missing = append(missing, node.Dest)
				}
			}
		case *ast.Link:
			t := res.Resolve(fromDir, string(node.Destination))
			node.Destination = []byte(t.Href)
			if t.PageID != "" {
				if _, ok := seen[t.PageID]; !ok {
					seen[t.PageID] = struct{}{}
					linked = append(linked, t.PageID)
				}
			}
		case *ast.Image:
			t := res.Resolve(fromDir, string(node.Destination))
			node.Destination = []byte(t.Href)
		case *ast.Heading:
			id, _ := node.AttributeString("id")
			hid, _ := id.([]byte)
			headings = append(headings, Heading{
				Level: node.Level,
				Text:  string(nodeText(node, source)),
				ID:    string(hid),
			})
		}
		return ast.WalkContinue, nil
	})

	var buf bytes.Buffer
	_ = r.md.Renderer().Render(&buf, source, doc)

	plain := plainText(doc, source)
	return Result{
		HTML:             buf.String(),
		Headings:         headings,
		LinkedIDs:        linked,
		PlainText:        plain,
		WordCount:        len(strings.Fields(plain)),
		Wikilinks:        wikilinks,
		MissingWikilinks: missing,
	}
}

// RenderInline renders a one-line fragment — a frontmatter property value — and
// returns just its inline HTML, without the wrapping paragraph.
//
// Frontmatter carries real links: `parent: "[[storage-model]]"`, and often the
// only pointer a document has to an attachment. Emitted as escaped text those
// read as literal double brackets and the attachment is unreachable. Running
// them through the same renderer as the body resolves wikilinks, marks the
// unresolved ones, and autolinks bare URLs.
//
// A value spanning more than one block is returned as rendered, paragraphs and
// all; mangling one would be worse than an extra <p>.
func (r *Renderer) RenderInline(source []byte, fromDir string, res Resolver) string {
	h := strings.TrimSuffix(r.Render(source, fromDir, res).HTML, "\n")
	inner, ok := strings.CutPrefix(h, "<p>")
	if !ok {
		return h
	}
	inner, ok = strings.CutSuffix(inner, "</p>")
	if !ok || strings.Contains(inner, "<p>") {
		return h
	}
	return inner
}

// nodeText returns the concatenated text of a node's descendants.
func nodeText(n ast.Node, source []byte) []byte {
	var b bytes.Buffer
	_ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		if t, ok := c.(*ast.Text); ok {
			b.Write(t.Segment.Value(source))
		}
		return ast.WalkContinue, nil
	})
	return b.Bytes()
}

// plainText projects the document to searchable text: inline text with a
// newline after each block-level node, and code-block contents included
// verbatim.
func plainText(doc ast.Node, source []byte) string {
	var b strings.Builder
	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		switch node := n.(type) {
		case *wikilink:
			// A wikilink holds no Text child, so without this its label — often
			// the only mention of a related concept in the document — would be
			// absent from the keyword index.
			if entering {
				b.WriteString(node.DisplayText())
				b.WriteByte(' ')
			}
		case *ast.Text:
			if entering {
				b.Write(node.Segment.Value(source))
				if node.SoftLineBreak() || node.HardLineBreak() {
					b.WriteByte('\n')
				}
			}
		case *ast.FencedCodeBlock, *ast.CodeBlock:
			if entering {
				lines := n.Lines()
				for i := 0; i < lines.Len(); i++ {
					seg := lines.At(i)
					b.Write(seg.Value(source))
				}
			}
		default:
			// After leaving a block-level node, emit a separator so words from
			// adjacent blocks don't run together in the search index.
			if !entering && node != nil && n.Type() == ast.TypeBlock {
				b.WriteByte('\n')
			}
		}
		return ast.WalkContinue, nil
	})
	return b.String()
}

A doc/render_test.go => doc/render_test.go +185 -0
@@ 0,0 1,185 @@
package doc

import (
	"strings"
	"testing"
)

// fakeResolver resolves every target except those starting with "missing" and
// those ending in an attachment extension, so the tests can exercise all four
// render paths without a real archive. The hrefs it hands back are deliberately
// arbitrary: what is under test is the renderer, not the resolution rules.
type fakeResolver struct{}

func (fakeResolver) Resolve(fromDir, dest string) Target {
	switch {
	case strings.HasPrefix(dest, "http"):
		return Target{Href: dest, IsExternal: true}
	case strings.HasPrefix(dest, "missing"):
		return Target{Href: "/p/" + dest, Missing: true}
	case strings.HasSuffix(dest, ".png"), strings.HasSuffix(dest, ".pdf"), strings.HasSuffix(dest, ".base"):
		return Target{Href: "/assets/" + dest}
	}
	id, frag, _ := strings.Cut(dest, "#")
	href := "/p/" + id
	if frag != "" {
		href += "#" + strings.ToLower(frag)
	}
	return Target{Href: href, PageID: id, Kind: "markdown"}
}

func renderMD(t *testing.T, src string) Result {
	t.Helper()
	return NewRenderer().Render([]byte(src), "specs", fakeResolver{})
}

func TestWikilink(t *testing.T) {
	cases := []struct {
		name       string
		src        string
		wantHTML   []string
		wantAbsent []string
		wantLinked []string
	}{
		{
			name:       "plain",
			src:        "see [[lsm-tree]] for details",
			wantHTML:   []string{`<a class="wikilink" href="/p/lsm-tree">lsm-tree</a>`},
			wantLinked: []string{"lsm-tree"},
		},
		{
			name:       "aliased",
			src:        "see [[lsm-tree|LSM trees]]",
			wantHTML:   []string{`href="/p/lsm-tree">LSM trees</a>`},
			wantLinked: []string{"lsm-tree"},
		},
		{
			name:       "path qualified shows the last segment",
			src:        "[[reports/watchlist]]",
			wantHTML:   []string{`href="/p/reports/watchlist">watchlist</a>`},
			wantLinked: []string{"reports/watchlist"},
		},
		{
			name:       "heading reference",
			src:        "[[ai-too-expensive#Zillow|AI Chernobyl]]",
			wantHTML:   []string{`href="/p/ai-too-expensive#zillow">AI Chernobyl</a>`},
			wantLinked: []string{"ai-too-expensive"},
		},
		{
			name:     "image embed renders inline",
			src:      "![[diagram.png]]",
			wantHTML: []string{`<img class="wikilink-embed" src="/assets/diagram.png"`},
		},
		{
			name:       "non-image embed degrades to a labelled link",
			src:        "![[wiki.base]]",
			wantHTML:   []string{`class="wikilink wikilink-file" href="/assets/wiki.base">wiki.base</a>`},
			wantAbsent: []string{"<img"},
		},
		{
			name:       "page embed links rather than transcluding",
			src:        "![[lsm-tree]]",
			wantHTML:   []string{`wikilink-embed-ref" href="/p/lsm-tree">lsm-tree</a>`},
			wantLinked: []string{"lsm-tree"},
		},
		{
			name:       "unresolved link is visibly broken, never dropped",
			src:        "[[missing-page]]",
			wantHTML:   []string{`<span class="wikilink-missing"`, `>missing-page</span>`},
			wantAbsent: []string{"<a "},
		},
		{
			name: "inside a code span it is literal text",
			src:  "write `[[page]]` to link",
			wantHTML: []string{
				"<code>[[page]]</code>",
			},
			wantAbsent: []string{`class="wikilink"`},
		},
		{
			name: "inside a fenced block it is literal text",
			src:  "```\n[[page]] and ![[transclusions]]\n```\n",
			wantHTML: []string{
				"[[page]] and ![[transclusions]]",
			},
			wantAbsent: []string{`class="wikilink"`, "<img"},
		},
		{
			name:       "empty target is not a link",
			src:        "[[]] and [[|x]]",
			wantAbsent: []string{`class="wikilink"`},
		},
		{
			name:       "unclosed brackets are left alone",
			src:        "an [[unclosed link",
			wantAbsent: []string{`class="wikilink"`},
		},
		{
			// Obsidian escapes the alias pipe inside table cells.
			name:       "escaped alias pipe in a table cell",
			src:        "| a | b |\n| --- | --- |\n| [[lsm-tree\\|LSM]] | x |\n",
			wantHTML:   []string{`href="/p/lsm-tree">LSM</a>`},
			wantLinked: []string{"lsm-tree"},
		},
		{
			name:       "label is HTML-escaped",
			src:        "[[lsm-tree|<script>alert(1)</script>]]",
			wantAbsent: []string{"<script>"},
			wantHTML:   []string{"&lt;script&gt;"},
			wantLinked: []string{"lsm-tree"},
		},
		{
			name:       "an ordinary markdown link still works",
			src:        "[label](lsm-tree)",
			wantHTML:   []string{`href="/p/lsm-tree"`},
			wantLinked: []string{"lsm-tree"},
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			res := renderMD(t, c.src)
			for _, want := range c.wantHTML {
				if !strings.Contains(res.HTML, want) {
					t.Errorf("HTML missing %q:\n%s", want, res.HTML)
				}
			}
			for _, absent := range c.wantAbsent {
				if strings.Contains(res.HTML, absent) {
					t.Errorf("HTML must not contain %q:\n%s", absent, res.HTML)
				}
			}
			if len(res.LinkedIDs) != len(c.wantLinked) {
				t.Fatalf("LinkedIDs = %v, want %v", res.LinkedIDs, c.wantLinked)
			}
			for i, want := range c.wantLinked {
				if res.LinkedIDs[i] != want {
					t.Errorf("LinkedIDs[%d] = %q, want %q", i, res.LinkedIDs[i], want)
				}
			}
		})
	}
}

func TestWikilinkDedupesAndKeepsFirstAppearanceOrder(t *testing.T) {
	res := renderMD(t, "[[b]] then [[a]] then [[b]] again")
	if got := strings.Join(res.LinkedIDs, ","); got != "b,a" {
		t.Fatalf("LinkedIDs = %q, want %q", got, "b,a")
	}
}

func TestWikilinkInsideEmphasisAndLists(t *testing.T) {
	res := renderMD(t, "- **[[lsm-tree]]** — a note\n- see *[[b-tree|B-trees]]*\n")
	for _, want := range []string{`<strong><a class="wikilink" href="/p/lsm-tree"`, `href="/p/b-tree">B-trees</a>`} {
		if !strings.Contains(res.HTML, want) {
			t.Errorf("HTML missing %q:\n%s", want, res.HTML)
		}
	}
}

func TestPlainTextExcludesWikilinkMarkup(t *testing.T) {
	res := renderMD(t, "see [[lsm-tree|LSM trees]] now")
	if strings.Contains(res.PlainText, "[[") {
		t.Errorf("plain text leaked wikilink syntax: %q", res.PlainText)
	}
}

A doc/scan.go => doc/scan.go +230 -0
@@ 0,0 1,230 @@
package doc

import (
	"bytes"
	"context"
	"path"
	"sort"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)

// DocumentSource is the read side of a space: everything this package needs
// from git. *gitx.Repo satisfies it.
//
// It is an interface rather than a *gitx.Repo so that the archive can be built
// over any document set — a test fixture, a cached tree — without pretending to
// be a repository. It is deliberately one method: an Archive is a whole
// revision, so there is nothing to read lazily.
type DocumentSource interface {
	WalkDocuments(ctx context.Context, rev string, fn func(gitx.Document) error) error
}

// Scan builds the Archive of a space at a revision. It is what warren's
// Scan(root string) became: the same result, walking a git tree instead of a
// directory.
//
// rev is anything gitx resolves — the approved branch, a proposal branch, or a
// pinned commit sha — which is what makes the read plane, the review UI and a
// `?rev=` request one code path. Pass a resolved sha when the archive must stay
// pinned to what the caller already saw.
//
// Attachments are not enumerated: gitx's walk yields documents only, so an
// archive built this way resolves `![[diagram.png]]` to a visibly missing link
// rather than to an attachment it cannot see. Use FromPages when a caller has
// an attachment index to supply.
func Scan(ctx context.Context, src DocumentSource, sp core.SpaceRef, rev string) (*Archive, error) {
	var docs []gitx.Document
	if err := src.WalkDocuments(ctx, rev, func(d gitx.Document) error {
		docs = append(docs, d)
		return nil
	}); err != nil {
		return nil, err
	}
	return FromDocuments(sp, rev, docs), nil
}

// FromDocuments builds an Archive out of already-read documents. It performs no
// I/O, so it is also the seam a caller with its own source of blobs uses.
func FromDocuments(sp core.SpaceRef, rev string, docs []gitx.Document) *Archive {
	sorted := make([]gitx.Document, len(docs))
	copy(sorted, docs)
	sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path })

	a := newArchive(sp, rev)
	fronts := make([]Front, len(sorted))

	// First pass: parse every header, so the id contest below is decided over
	// the whole revision rather than in scan order.
	docIDs := make(map[string]int, len(sorted))
	for i, d := range sorted {
		front, body := ParseFront(d.Data)
		fronts[i] = front

		p := &Page{
			Kind:     pageKind(front, d.Path),
			Title:    pageTitle(front, body, d.Path),
			Path:     d.Path,
			Blob:     d.Blob.String(),
			Status:   front.Status,
			Summary:  front.Summary,
			Tags:     front.Tags,
			Section:  topSection(d.Path),
			ParentID: LinkTarget(front.Parent), // resolved to an ID by linkHierarchy
		}
		if err := core.ValidateDocID(front.ID); err == nil {
			p.DocID = front.ID
			docIDs[front.ID]++
		}
		a.Pages = append(a.Pages, p)
	}

	// A document id claimed by two documents is not resolved to either of them.
	// The design says exactly this about the approved branch: a duplicate id is
	// tolerated so one typo cannot block a space, the paths stay occupied, and
	// the id is refused only where something actually needs to resolve it.
	for _, p := range a.Pages {
		if p.DocID != "" && docIDs[p.DocID] == 1 {
			p.ID = p.DocID
			continue
		}
		p.ID = strings.TrimSuffix(p.Path, core.DocExt)
	}
	for _, p := range a.Pages {
		a.register(p)
	}

	for i, p := range a.Pages {
		for _, alias := range fronts[i].Aliases {
			key := normalizeName(alias)
			if key == "" {
				continue
			}
			if _, taken := a.byID[key]; taken {
				continue // a real document owns this name; never shadow it
			}
			a.aliases[key] = p.ID
		}
	}

	a.linkHierarchy()
	return a
}

// pageTitle applies the title fallback chain: frontmatter `title:`, then the
// first `# H1`, then the file name. A document whose header failed to parse has
// no title of its own and must still get one — that is the whole point of the
// chain here, rather than reporting an untitled document.
func pageTitle(front Front, body []byte, p string) string {
	if t := strings.TrimSpace(front.Title); t != "" {
		return t
	}
	if h1 := firstH1(body); h1 != "" {
		return h1
	}
	return Stem(p)
}

// firstH1 returns the text of the first ATX level-1 heading in the body, or ""
// if there is none. Headings inside a fenced block are not headings.
func firstH1(body []byte) string {
	inFence := false
	for _, line := range bytes.Split(body, []byte("\n")) {
		t := bytes.TrimSpace(line)
		if bytes.HasPrefix(t, []byte("```")) || bytes.HasPrefix(t, []byte("~~~")) {
			inFence = !inFence
			continue
		}
		if inFence {
			continue
		}
		if rest, ok := bytes.CutPrefix(t, []byte("# ")); ok {
			return strings.TrimSpace(string(bytes.TrimRight(rest, " #")))
		}
	}
	return ""
}

// pageKind classifies the two structurally unusual kinds. The explicit
// frontmatter marker wins; the file-name rule is the fallback, kept from warren
// because the same corpus conventions produce the same index.md and log.md.
func pageKind(front Front, p string) PageKind {
	switch strings.ToLower(strings.TrimSpace(front.Type)) {
	case "catalog":
		return KindCatalog
	case "log":
		return KindLog
	}
	switch Stem(p) {
	case "index":
		return KindCatalog
	case "log":
		return KindLog
	}
	return KindMarkdown
}

// maxCrumbDepth bounds a parent chain. `parent:` is a wikilink and nothing
// stops it forming a cycle, so the walk is both cycle-guarded and depth-capped.
const maxCrumbDepth = 32

// linkHierarchy resolves each document's raw `parent:` target to an ID and
// walks the chain upward to build its crumbs. A cycle, a self-parent, or a
// parent that resolves to nothing leaves the document at the top level rather
// than failing the scan: a broken `parent:` is a defect in one document, not a
// reason to serve none.
func (a *Archive) linkHierarchy() {
	for _, p := range a.Pages {
		if p.ParentID == "" {
			continue
		}
		parent := a.lookupPage(path.Dir(p.Path), p.ParentID)
		if parent == nil || parent.ID == p.ID {
			p.ParentID = ""
			continue
		}
		p.ParentID = parent.ID
	}
	// Crumbs are computed for every document before any is detached: a cycle
	// must be seen as a cycle by each document in it, and detaching one mid-loop
	// would make the next document's walk terminate at the break and look sound.
	chains := make([][]string, len(a.Pages))
	for i, p := range a.Pages {
		chains[i] = a.crumbs(p)
	}
	for i, p := range a.Pages {
		p.Crumbs = chains[i]
		if len(chains[i]) == 0 {
			p.ParentID = "" // cyclic or dangling chain; detach rather than loop
		}
	}
}

// crumbs walks a document's ancestors root-most first, stopping on a repeat or
// at maxCrumbDepth. It returns nil when the chain is cyclic.
func (a *Archive) crumbs(p *Page) []string {
	if p.ParentID == "" {
		return nil
	}
	seen := map[string]bool{p.ID: true}
	var chain []string
	for id := p.ParentID; id != ""; {
		if seen[id] || len(chain) >= maxCrumbDepth {
			return nil
		}
		seen[id] = true
		chain = append(chain, id)
		parent, ok := a.byID[id]
		if !ok {
			break
		}
		id = parent.ParentID
	}
	// Reverse: crumbs run root -> immediate parent.
	for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 {
		chain[i], chain[j] = chain[j], chain[i]
	}
	return chain
}

A doc/scan_test.go => doc/scan_test.go +224 -0
@@ 0,0 1,224 @@
package doc

import (
	"context"
	"strings"
	"testing"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)

func spec(id, title, body string) string {
	return "---\nid: " + id + "\ntitle: " + title + "\nstatus: draft\n---\n\n" + body + "\n"
}

// The archive keys on the frontmatter id when there is one. Paths move; ids do
// not, so this is what makes [[SPEC-0007]] survive a rename.
func TestScanKeysOnDocumentID(t *testing.T) {
	arc := archiveOf(t, map[string]string{
		"specs/0007-storage.md": spec("SPEC-0007", "Storage model", "See [[SPEC-0003]]."),
		"specs/0003-old.md":     spec("SPEC-0003", "Old model", "superseded"),
	})

	p := mustPage(t, arc, "specs/0007-storage.md")
	if p.ID != "SPEC-0007" || p.DocID != "SPEC-0007" {
		t.Fatalf("ID/DocID = %q/%q, want SPEC-0007", p.ID, p.DocID)
	}
	if p.Title != "Storage model" || p.Status != core.StatusDraft {
		t.Errorf("title/status = %q/%q", p.Title, p.Status)
	}
	if p.Blob == "" {
		t.Errorf("Blob (the render cache key) was not carried over from git")
	}
	if got, ok := arc.Page("SPEC-0007"); !ok || got != p {
		t.Errorf("Page(SPEC-0007) did not return the document")
	}

	got := arc.Resolve("specs", "SPEC-0003")
	if got.Missing || got.Href != "/~bigbes/rfcs/specs/0003-old" {
		t.Errorf("Resolve(SPEC-0003) = %+v", got)
	}
}

// A document with no usable id is still a document: it keys on its path, stays
// readable, and keeps its path occupied. Refusing to serve it would turn one
// cosmetic typo into an outage for the whole space.
func TestScanToleratesDocumentsWithoutAnID(t *testing.T) {
	arc := archiveOf(t, map[string]string{
		"notes/free-form.md": "# Free form\n\nNo frontmatter at all.\n",
		"notes/broken.md":    "---\ntitle: [unclosed\n---\n\n# Recovered Title\n",
		"notes/lower.md":     "---\nid: spec-0007\ntitle: Lowercase id\nstatus: draft\n---\n\nbody\n",
	})

	for path, want := range map[string]string{
		"notes/free-form.md": "Free form",
		"notes/broken.md":    "Recovered Title",
		"notes/lower.md":     "Lowercase id",
	} {
		p := mustPage(t, arc, path)
		if p.Title != want {
			t.Errorf("%s title = %q, want %q", path, p.Title, want)
		}
		if p.DocID != "" {
			t.Errorf("%s claimed DocID %q; a malformed id must not enter id resolution", path, p.DocID)
		}
		if p.ID != strings.TrimSuffix(path, core.DocExt) {
			t.Errorf("%s ID = %q, want the path without %q", path, p.ID, core.DocExt)
		}
	}
}

// The design tolerates a duplicated id on the approved branch and refuses to
// resolve it, rather than letting one of the two documents win silently.
func TestScanRefusesToResolveADuplicatedID(t *testing.T) {
	arc := archiveOf(t, map[string]string{
		"specs/a.md": spec("SPEC-0007", "First claimant", "a"),
		"specs/b.md": spec("SPEC-0007", "Second claimant", "b"),
	})

	for _, path := range []string{"specs/a.md", "specs/b.md"} {
		p := mustPage(t, arc, path)
		if p.DocID != "SPEC-0007" {
			t.Errorf("%s: DocID = %q, want the id as authored", path, p.DocID)
		}
		if p.ID != strings.TrimSuffix(path, core.DocExt) {
			t.Errorf("%s: ID = %q, want the path", path, p.ID)
		}
	}
	if _, ok := arc.Page("SPEC-0007"); ok {
		t.Errorf("a duplicated id must resolve to neither document")
	}
	if got := arc.Resolve("specs", "SPEC-0007"); !got.Missing {
		t.Errorf("Resolve(SPEC-0007) = %+v, want Missing", got)
	}
}

// The approved head, a pinned sha and a proposal branch are the same code path
// with a different revision. That is the whole reason the checkout was dropped.
func TestScanReadsAnyRevisionThroughOnePath(t *testing.T) {
	ctx := context.Background()
	repo := space(t)

	first := commit(t, repo, 1, repo.ApprovedBranch(), map[string]string{
		"specs/0007-storage.md": spec("SPEC-0007", "Storage model", "v1"),
	})
	pinned, err := repo.ResolveRev(ctx, first)
	if err != nil {
		t.Fatalf("ResolveRev(%q): %v", first, err)
	}
	second := commit(t, repo, 2, first, map[string]string{
		"specs/0007-storage.md": spec("SPEC-0007", "Storage model, revised", "v2"),
		"notes/aside.md":        "# Aside\n",
	})

	at := func(rev string) *Archive {
		t.Helper()
		arc, err := Scan(ctx, repo, fxSpace, rev)
		if err != nil {
			t.Fatalf("Scan(%q): %v", rev, err)
		}
		return arc
	}

	head := at(pinned.String())
	if len(head.Pages) != 1 {
		t.Fatalf("pinned rev has %d documents, want 1", len(head.Pages))
	}
	if got := mustPage(t, head, "specs/0007-storage.md").Title; got != "Storage model" {
		t.Errorf("pinned rev title = %q, want the revision as it was", got)
	}

	draft := at(second)
	if len(draft.Pages) != 2 {
		t.Fatalf("proposal branch has %d documents, want 2", len(draft.Pages))
	}
	if got := mustPage(t, draft, "specs/0007-storage.md").Title; got != "Storage model, revised" {
		t.Errorf("proposal branch title = %q", got)
	}
	if draft.Rev != second {
		t.Errorf("Rev = %q, want the revision the caller named", draft.Rev)
	}
}

func TestScanReportsGitErrors(t *testing.T) {
	repo := space(t)
	if _, err := Scan(context.Background(), repo, fxSpace, "no-such-branch"); err == nil {
		t.Fatal("Scan of an unknown revision must fail rather than return an empty archive")
	}
}

// Scan sorts by path, so an archive is identical whatever order the tree walk
// yields — which is what makes the index reproducible.
func TestFromDocumentsIsPathOrdered(t *testing.T) {
	docs := []gitx.Document{
		{Path: "specs/z.md", Data: []byte(spec("SPEC-0002", "Z", "z"))},
		{Path: "notes/a.md", Data: []byte(spec("SPEC-0001", "A", "a"))},
	}
	arc := FromDocuments(fxSpace, "main", docs)
	if len(arc.Pages) != 2 || arc.Pages[0].Path != "notes/a.md" {
		t.Fatalf("pages = %v", arc.Pages)
	}
}

// gitx enumerates documents only, so a git-fed archive has no attachment index
// and says so by marking the link missing instead of inventing an href. An
// attachment index supplied through FromPages resolves as it always did.
func TestAttachmentsComeFromTheCallerNotFromTheTreeWalk(t *testing.T) {
	arc := archiveOf(t, map[string]string{"notes/a.md": "# A\n"})
	if len(arc.Assets()) != 0 {
		t.Fatalf("Scan invented an attachment index: %v", arc.Assets())
	}
	if got := arc.Resolve("notes", "diagram.png"); !got.Missing {
		t.Errorf("Resolve(diagram.png) = %+v, want Missing", got)
	}

	withAssets := FromPages(fxSpace, "main", arc.Pages, nil, map[string]string{
		"diagram.png":        "assets/diagram.png",
		"assets/diagram.png": "assets/diagram.png",
	})
	got := withAssets.Resolve("notes", "diagram.png")
	if got.Missing || got.Href != "/~bigbes/rfcs/assets/diagram.png" {
		t.Errorf("Resolve(diagram.png) = %+v", got)
	}
}

// The two halves this package merged — the archive and the renderer — meet
// here: a body rendered with the archive as its Resolver must link to the
// space's own hrefs, feed the link graph, and mark what did not resolve.
func TestRenderThroughTheArchive(t *testing.T) {
	arc := archiveOf(t, map[string]string{
		"specs/0007-storage.md": spec("SPEC-0007", "Storage model",
			"Supersedes [[SPEC-0003]] and [[SPEC-0099]].\n\n## Trade-offs\n\nSee [[0003-old|the old one]]."),
		"specs/0003-old.md": spec("SPEC-0003", "Old model", "superseded"),
	})

	p := mustPage(t, arc, "specs/0007-storage.md")
	_, body := ParseFront([]byte(spec("SPEC-0007", "Storage model",
		"Supersedes [[SPEC-0003]] and [[SPEC-0099]].\n\n## Trade-offs\n\nSee [[0003-old|the old one]].")))

	res := NewRenderer().Render(body, "specs", arc)
	p.Links = res.LinkedIDs
	p.WordCount = res.WordCount

	if !strings.Contains(res.HTML, `href="/~bigbes/rfcs/specs/0003-old"`) {
		t.Errorf("resolved wikilink did not become a space href:\n%s", res.HTML)
	}
	if !strings.Contains(res.HTML, `class="wikilink-missing"`) {
		t.Errorf("unresolved wikilink was not marked broken:\n%s", res.HTML)
	}
	if got := strings.Join(res.MissingWikilinks, ","); got != "SPEC-0099" {
		t.Errorf("MissingWikilinks = %v", res.MissingWikilinks)
	}
	if got := strings.Join(res.LinkedIDs, ","); got != "SPEC-0003" {
		t.Errorf("LinkedIDs = %v, want the id once, deduped across both spellings", res.LinkedIDs)
	}
	if len(res.Headings) != 1 || res.Headings[0].ID != "trade-offs" {
		t.Errorf("headings = %+v", res.Headings)
	}

	back := arc.Backlinks("SPEC-0003")
	if len(back) != 1 || back[0].ID != "SPEC-0007" {
		t.Fatalf("Backlinks(SPEC-0003) = %v", back)
	}
}

A doc/table.go => doc/table.go +50 -0
@@ 0,0 1,50 @@
package doc

import (
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/extension"
	east "github.com/yuin/goldmark/extension/ast"
	"github.com/yuin/goldmark/renderer"
	"github.com/yuin/goldmark/renderer/html"
	"github.com/yuin/goldmark/util"
)

// tableRenderer wraps every GFM table in a horizontally scrollable box.
//
// A `<table>` will not shrink below its min-content width, and specs have
// tables whose cells are bare URLs — an unbreakable 70-character token each.
// Inside the page grid's `minmax(0,1fr)` column such a table simply overflows
// its cell and paints on top of the table-of-contents rail. The wrapper is a
// block box that does honour the column width, so the overflow becomes a scroll
// bar on the table instead of a collision with the rail.
//
// Two nested divs rather than one: the review UI hangs per-block affordances in
// the left gutter of every top-level child of .prose, and the scrolling box —
// their containing block — would clip an absolutely positioned handle. The
// outer div takes the block role, the inner one does the scrolling.
//
// Only the table element itself is overridden; rows, cells, and the alignment
// handling stay with goldmark's own renderer.
type tableRenderer struct{}

// tableRendererPriority beats the GFM table renderer's 500. goldmark registers
// node renderers from the lowest priority number last, so the last write to a
// node kind — and thus the winner — is the smaller number.
const tableRendererPriority = 100

func (tableRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
	reg.Register(east.KindTable, renderTable)
}

func renderTable(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		_, _ = w.WriteString(`<div class="table-block"><div class="table-scroll"><table`)
		if n.Attributes() != nil {
			html.RenderAttributes(w, n, extension.TableAttributeFilter)
		}
		_, _ = w.WriteString(">\n")
	} else {
		_, _ = w.WriteString("</table></div></div>\n")
	}
	return ast.WalkContinue, nil
}

A doc/table_test.go => doc/table_test.go +53 -0
@@ 0,0 1,53 @@
package doc

import (
	"strings"
	"testing"
)

// A GFM table must come out inside .table-scroll. Without the wrapper a table of
// bare URLs cannot shrink to its grid column and paints over the document's
// table-of-contents rail.
func TestTableIsWrappedForScrolling(t *testing.T) {
	src := "| Date | URL |\n|---|---|\n| 2026-07-21 | https://example.com/a |\n"
	html := renderMD(t, src).HTML

	if !strings.Contains(html, `<div class="table-block"><div class="table-scroll"><table>`) {
		t.Errorf("table not wrapped:\n%s", html)
	}
	if !strings.Contains(html, "</table></div></div>") {
		t.Errorf("wrapper not closed:\n%s", html)
	}
	// The rows still go through goldmark's own renderer.
	if !strings.Contains(html, "<thead>") || !strings.Contains(html, "<td>") {
		t.Errorf("table body lost:\n%s", html)
	}
}

func TestRenderInline(t *testing.T) {
	r := NewRenderer()
	cases := []struct {
		name string
		in   string
		want string
	}{
		{"wikilink", "[[eatonphil-bookclub]]",
			`<a class="wikilink" href="/p/eatonphil-bookclub">eatonphil-bookclub</a>`},
		{"pdf attachment", "[[paper.pdf]]",
			`<a class="wikilink" href="/assets/paper.pdf">paper.pdf</a>`},
		{"several values", "[[alpha]], [[beta]]",
			`<a class="wikilink" href="/p/alpha">alpha</a>, <a class="wikilink" href="/p/beta">beta</a>`},
		{"bare url is autolinked", "https://example.com/x",
			`<a href="https://example.com/x">https://example.com/x</a>`},
		{"unresolved link stays visible", "[[missing-thing]]",
			`<span class="wikilink-missing" title="unresolved link: missing-thing">missing-thing</span>`},
		{"plain text", "want-to-read", "want-to-read"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := r.RenderInline([]byte(c.in), "specs", fakeResolver{}); got != c.want {
				t.Errorf("RenderInline(%q)\n got: %s\nwant: %s", c.in, got, c.want)
			}
		})
	}
}

A doc/wikilink.go => doc/wikilink.go +220 -0
@@ 0,0 1,220 @@
package doc

import (
	"bytes"
	"strings"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer"
	"github.com/yuin/goldmark/text"
	"github.com/yuin/goldmark/util"
)

// wikilink is an inline node for the [[target]], [[target|label]] and
// ![[target]] syntax the corpus is written in.
//
// It is a goldmark inline parser rather than a pre-pass regex for one reason
// that matters here: a spec store documents its own link syntax, so documents
// contain literal `[[doc]]` inside code spans and ![[transclusions]] inside
// fenced code blocks. goldmark hands inline parsers only the text that is not
// code, so those are left alone for free — a regex over the raw source would
// rewrite them and break the very documents explaining the syntax.
type wikilink struct {
	ast.BaseInline
	Dest  string // link target as written, before resolution
	Label string // display text; empty means "use the target"
	Embed bool   // written as ![[…]]

	target Target // filled in by Render's walk, via the Resolver
}

var kindWikilink = ast.NewNodeKind("Wikilink")

func (n *wikilink) Kind() ast.NodeKind { return kindWikilink }

func (n *wikilink) Dump(source []byte, level int) {
	ast.DumpHelper(n, source, level, map[string]string{"Dest": n.Dest, "Label": n.Label}, nil)
}

// wikilinkParser recognises [[…]] and ![[…]] at an opening bracket.
type wikilinkParser struct{}

func (wikilinkParser) Trigger() []byte { return []byte{'[', '!'} }

// maxWikilinkLen bounds how far the parser scans for a closing "]]". A target
// this long is prose that happens to open a bracket pair, not a link.
const maxWikilinkLen = 512

func (wikilinkParser) Parse(_ ast.Node, block text.Reader, _ parser.Context) ast.Node {
	line, seg := block.PeekLine()

	embed := false
	open := 0
	if len(line) > 0 && line[0] == '!' {
		embed = true
		open = 1
	}
	if len(line) < open+4 || line[open] != '[' || line[open+1] != '[' {
		return nil
	}
	inner := line[open+2:]
	if len(inner) > maxWikilinkLen {
		inner = inner[:maxWikilinkLen]
	}
	end := bytes.Index(inner, []byte("]]"))
	if end < 0 {
		return nil
	}
	// A newline before the closer means the brackets are unrelated: a wikilink
	// never spans lines.
	if bytes.IndexByte(inner[:end], '\n') >= 0 {
		return nil
	}
	body := string(inner[:end])
	total := open + 2 + end + 2

	dest, label, hasLabel := strings.Cut(body, "|")
	// Inside a markdown table the alias pipe must be escaped — an author writes
	// [[c3-lang\|C3]] there — and the backslash survives into the inline text.
	// Without this the target reads as "c3-lang\" and the link dangles, which
	// is common in comparison tables.
	dest = strings.TrimSuffix(strings.TrimSpace(dest), `\`)
	dest = strings.TrimSpace(dest)
	if dest == "" {
		return nil // "[[]]" or "[[|x]]" is not a link
	}
	if !hasLabel {
		label = ""
	}

	block.Advance(total)
	_ = seg
	return &wikilink{Dest: dest, Label: strings.TrimSpace(label), Embed: embed}
}

// wikilinkRenderer writes a resolved wikilink as an anchor, an image, or — when
// nothing resolved — a visibly broken span carrying the .wikilink-missing class.
// Missing links are never silently dropped: the read plane doubles as a link
// checker, and a link the reader cannot see is one nobody fixes.
type wikilinkRenderer struct{}

func (wikilinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
	reg.Register(kindWikilink, renderWikilink)
}

func renderWikilink(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if !entering {
		return ast.WalkSkipChildren, nil
	}
	n := node.(*wikilink)
	label := n.Label
	if label == "" {
		label = displayLabel(n.Dest)
	}

	switch {
	case n.target.Missing:
		_, _ = w.WriteString(`<span class="wikilink-missing" title="unresolved link: `)
		_, _ = w.WriteString(escapeAttr(n.Dest))
		_, _ = w.WriteString(`">`)
		_, _ = w.WriteString(escapeText(label))
		_, _ = w.WriteString(`</span>`)

	case n.Embed && n.target.PageID == "" && !n.target.IsExternal:
		// An embedded attachment: an image renders inline, anything else (a PDF,
		// a format this package does not interpret) degrades to a labelled link
		// rather than broken markup.
		if isImageHref(n.target.Href) {
			_, _ = w.WriteString(`<img class="wikilink-embed" src="`)
			_, _ = w.WriteString(escapeAttr(n.target.Href))
			_, _ = w.WriteString(`" alt="`)
			_, _ = w.WriteString(escapeAttr(label))
			_, _ = w.WriteString(`">`)
			break
		}
		writeAnchor(w, n.target.Href, "wikilink wikilink-file", label)

	case n.Embed:
		// An embedded document. There is no transclusion: a document's content
		// has one canonical home, and inlining it would duplicate it into the
		// search index and the reading view alike.
		writeAnchor(w, n.target.Href, "wikilink wikilink-embed-ref", label)

	default:
		class := "wikilink"
		if n.target.IsExternal {
			class = "wikilink wikilink-external"
		}
		writeAnchor(w, n.target.Href, class, label)
	}
	return ast.WalkSkipChildren, nil
}

func writeAnchor(w util.BufWriter, href, class, label string) {
	_, _ = w.WriteString(`<a class="` + class + `" href="`)
	_, _ = w.WriteString(escapeAttr(href))
	_, _ = w.WriteString(`">`)
	_, _ = w.WriteString(escapeText(label))
	_, _ = w.WriteString(`</a>`)
}

// DisplayText is the text a wikilink contributes to the page's searchable plain
// text: its label, or the target when it has none.
func (n *wikilink) DisplayText() string {
	if n.Label != "" {
		return n.Label
	}
	return displayLabel(n.Dest)
}

// displayLabel is what an unlabelled [[wiki/lsm-tree#Compaction]] shows: the
// target's last path segment, without the fragment.
func displayLabel(dest string) string {
	if i := strings.IndexByte(dest, '#'); i >= 0 {
		if i == 0 {
			return dest[1:] // a same-page [[#Heading]] reference
		}
		dest = dest[:i]
	}
	if i := strings.LastIndexByte(dest, '/'); i >= 0 {
		dest = dest[i+1:]
	}
	return strings.TrimSuffix(dest, ".md")
}

var imageExts = []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".bmp"}

func isImageHref(href string) bool {
	lower := strings.ToLower(href)
	if i := strings.IndexAny(lower, "?#"); i >= 0 {
		lower = lower[:i]
	}
	for _, ext := range imageExts {
		if strings.HasSuffix(lower, ext) {
			return true
		}
	}
	return false
}

var textEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
var attrEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")

func escapeText(s string) string { return textEscaper.Replace(s) }
func escapeAttr(s string) string { return attrEscaper.Replace(s) }

// wikilinkExtension wires the parser and renderer into goldmark. The parser
// priority sits above goldmark's own link parser (100) so "[[" is claimed before
// it is read as a link label containing a bracket.
type wikilinkExtension struct{}

func (wikilinkExtension) Extend(m goldmark.Markdown) {
	m.Parser().AddOptions(parser.WithInlineParsers(
		util.Prioritized(wikilinkParser{}, 99),
	))
	m.Renderer().AddOptions(renderer.WithNodeRenderers(
		util.Prioritized(wikilinkRenderer{}, 99),
	))
}