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{"<script>"},
+ 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("&", "&", "<", "<", ">", ">")
+var attrEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
+
+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),
+ ))
+}