coverage~bigbes/sr-ht-spec64cae3afdoc/scan.go

Coverage
96.5% 83/86 statements
Δ
+0.0
Blob
56bc9de
Uncovered L80L83L204
1 package doc
2
3 import (
4 "bytes"
5 "sort"
6 "strings"
7
8 "sourcecraft.dev/bigbes/sr-ht-spec/core"
9 "sourcecraft.dev/bigbes/sr-ht-spec/gitx"
10 )
11
12 // FromDocuments builds an Archive out of already-read documents. It performs no
13 // I/O at all, which is the property that keeps this package off git: a caller
14 // resolves a revision, reads its blobs, and hands the result over.
15 //
16 // This package deliberately owns no way to read a revision itself. It used to
17 // export one — Scan(ctx, DocumentSource, ...), a walk-and-build wrapper over
18 // *gitx.Repo — and that was the seam a surface used to reach past service/ into
19 // gitx and build its own archive, which is the layering violation
20 // [service.Service.Archive] exists to close. One route from a revision to an
21 // Archive means every surface resolves, links and addresses documents the same
22 // way; two routes means they agree until one of them is changed.
23 //
24 // Attachments are not enumerated: a git walk yields documents only, so an
25 // archive built this way resolves `![[diagram.png]]` to a visibly missing link
26 // rather than to an attachment it cannot see. Use FromPages when a caller has
27 // an attachment index to supply.
28 18 func FromDocuments(sp core.SpaceRef, rev string, docs []gitx.Document) *Archive {
29 18 sorted := make([]gitx.Document, len(docs))
30 18 copy(sorted, docs)
31 31 sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path })
32
33 18 a := newArchive(sp, rev)
34 18 fronts := make([]Front, len(sorted))
35 18
36 18 // First pass: parse every header, so the id contest below is decided over
37 18 // the whole revision rather than in scan order.
38 18 docIDs := make(map[string]int, len(sorted))
39 49 for i, d := range sorted {
40 49 front, body := ParseFront(d.Data)
41 49 fronts[i] = front
42 49
43 49 p := &Page{
44 49 Kind: pageKind(front, d.Path),
45 49 Title: pageTitle(front, body, d.Path),
46 49 Path: d.Path,
47 49 Blob: d.Blob.String(),
48 49 Status: front.Status,
49 49 Summary: front.Summary,
50 49 Tags: front.Tags,
51 49 Section: topSection(d.Path),
52 49 ParentID: LinkTarget(front.Parent), // resolved to an ID by linkHierarchy
53 49 }
54 49 if err := core.ValidateDocID(front.ID); err == nil {
55 17 p.DocID = front.ID
56 17 docIDs[front.ID]++
57 17 }
58 49 a.Pages = append(a.Pages, p)
59 }
60
61 // A document id claimed by two documents is not resolved to either of them.
62 // The design says exactly this about the approved branch: a duplicate id is
63 // tolerated so one typo cannot block a space, the paths stay occupied, and
64 // the id is refused only where something actually needs to resolve it.
65 49 for _, p := range a.Pages {
66 49 if p.DocID != "" && docIDs[p.DocID] == 1 {
67 15 p.ID = p.DocID
68 15 continue
69 }
70 34 p.ID = strings.TrimSuffix(p.Path, core.DocExt)
71 }
72 49 for _, p := range a.Pages {
73 49 a.register(p)
74 49 }
75
76 49 for i, p := range a.Pages {
77 49 for _, alias := range fronts[i].Aliases {
78 1 key := normalizeName(alias)
79 1 if key == "" {
80 0 continue
81 }
82 1 if _, taken := a.byID[key]; taken {
83 0 continue // a real document owns this name; never shadow it
84 }
85 1 a.aliases[key] = p.ID
86 }
87 }
88
89 18 a.linkHierarchy()
90 18 return a
91 }
92
93 // pageTitle applies the title fallback chain: frontmatter `title:`, then the
94 // first `# H1`, then the file name. A document whose header failed to parse has
95 // no title of its own and must still get one — that is the whole point of the
96 // chain here, rather than reporting an untitled document.
97 49 func pageTitle(front Front, body []byte, p string) string {
98 49 if t := strings.TrimSpace(front.Title); t != "" {
99 31 return t
100 31 }
101 18 if h1 := firstH1(body); h1 != "" {
102 14 return h1
103 14 }
104 4 return Stem(p)
105 }
106
107 // firstH1 returns the text of the first ATX level-1 heading in the body, or ""
108 // if there is none. Headings inside a fenced block are not headings.
109 18 func firstH1(body []byte) string {
110 18 inFence := false
111 26 for _, line := range bytes.Split(body, []byte("\n")) {
112 26 t := bytes.TrimSpace(line)
113 26 if bytes.HasPrefix(t, []byte("```")) || bytes.HasPrefix(t, []byte("~~~")) {
114 2 inFence = !inFence
115 2 continue
116 }
117 24 if inFence {
118 1 continue
119 }
120 23 if rest, ok := bytes.CutPrefix(t, []byte("# ")); ok {
121 14 return strings.TrimSpace(string(bytes.TrimRight(rest, " #")))
122 14 }
123 }
124 4 return ""
125 }
126
127 // pageKind classifies the two structurally unusual kinds. The explicit
128 // frontmatter marker wins; the file-name rule is the fallback, kept from warren
129 // because the same corpus conventions produce the same index.md and log.md.
130 49 func pageKind(front Front, p string) PageKind {
131 49 switch strings.ToLower(strings.TrimSpace(front.Type)) {
132 1 case "catalog":
133 1 return KindCatalog
134 1 case "log":
135 1 return KindLog
136 }
137 47 switch Stem(p) {
138 4 case "index":
139 4 return KindCatalog
140 1 case "log":
141 1 return KindLog
142 }
143 42 return KindMarkdown
144 }
145
146 // maxCrumbDepth bounds a parent chain. `parent:` is a wikilink and nothing
147 // stops it forming a cycle, so the walk is both cycle-guarded and depth-capped.
148 const maxCrumbDepth = 32
149
150 // linkHierarchy resolves each document's raw `parent:` target to an ID and
151 // walks the chain upward to build its crumbs. A cycle, a self-parent, or a
152 // parent that resolves to nothing leaves the document at the top level rather
153 // than failing the scan: a broken `parent:` is a defect in one document, not a
154 // reason to serve none.
155 //
156 // `parent:` is resolved through the same lookup as any other wikilink, from the
157 // linking document's own directory, so a bare `parent: [[storage]]` prefers the
158 // storage beside it. DirOf, not path.Dir: lookupPage keys sections off "" for
159 // the space root, and path.Dir's "." would silently skip the section-proximity
160 // step for every root-level document.
161 18 func (a *Archive) linkHierarchy() {
162 49 for _, p := range a.Pages {
163 49 if p.ParentID == "" {
164 41 continue
165 }
166 8 parent := a.lookupPage(DirOf(p.Path), p.ParentID)
167 8 if parent == nil || parent.ID == p.ID {
168 2 p.ParentID = ""
169 2 continue
170 }
171 6 p.ParentID = parent.ID
172 }
173 // Crumbs are computed for every document before any is detached: a cycle
174 // must be seen as a cycle by each document in it, and detaching one mid-loop
175 // would make the next document's walk terminate at the break and look sound.
176 18 chains := make([][]string, len(a.Pages))
177 49 for i, p := range a.Pages {
178 49 chains[i] = a.crumbs(p)
179 49 }
180 49 for i, p := range a.Pages {
181 49 p.Crumbs = chains[i]
182 49 if len(chains[i]) == 0 {
183 45 p.ParentID = "" // cyclic or dangling chain; detach rather than loop
184 45 }
185 }
186 }
187
188 // crumbs walks a document's ancestors root-most first, stopping on a repeat or
189 // at maxCrumbDepth. It returns nil when the chain is cyclic.
190 49 func (a *Archive) crumbs(p *Page) []string {
191 49 if p.ParentID == "" {
192 43 return nil
193 43 }
194 6 seen := map[string]bool{p.ID: true}
195 6 var chain []string
196 9 for id := p.ParentID; id != ""; {
197 9 if seen[id] || len(chain) >= maxCrumbDepth {
198 2 return nil
199 2 }
200 7 seen[id] = true
201 7 chain = append(chain, id)
202 7 parent, ok := a.byID[id]
203 7 if !ok {
204 0 break
205 }
206 7 id = parent.ParentID
207 }
208 // Reverse: crumbs run root -> immediate parent.
209 4 for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 {
210 1 chain[i], chain[j] = chain[j], chain[i]
211 1 }
212 4 return chain
213 }