package doc
import (
"bytes"
"sort"
"strings"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)
// FromDocuments builds an Archive out of already-read documents. It performs no
// I/O at all, which is the property that keeps this package off git: a caller
// resolves a revision, reads its blobs, and hands the result over.
//
// This package deliberately owns no way to read a revision itself. It used to
// export one — Scan(ctx, DocumentSource, ...), a walk-and-build wrapper over
// *gitx.Repo — and that was the seam a surface used to reach past service/ into
// gitx and build its own archive, which is the layering violation
// [service.Service.Archive] exists to close. One route from a revision to an
// Archive means every surface resolves, links and addresses documents the same
// way; two routes means they agree until one of them is changed.
//
// Attachments are not enumerated: a git 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 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.
//
// `parent:` is resolved through the same lookup as any other wikilink, from the
// linking document's own directory, so a bare `parent: [[storage]]` prefers the
// storage beside it. DirOf, not path.Dir: lookupPage keys sections off "" for
// the space root, and path.Dir's "." would silently skip the section-proximity
// step for every root-level document.
func (a *Archive) linkHierarchy() {
for _, p := range a.Pages {
if p.ParentID == "" {
continue
}
parent := a.lookupPage(DirOf(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
}