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
}