~bigbes/sr-ht-spec

ref: 46048cbc0ada700c621b7c73d8da07deefde2662 sr-ht-spec/doc/scan.go -rw-r--r-- 6.8 KiB
46048cbc — Eugene Blikh chore(beads): spec-ejq.2 re-index verified against repo.bigb.es 13 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
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
}