coverage~bigbes/sr-ht-spec3cb1c03ddoc/archive.go

Coverage
88.9% 144/162 statements
Δ
Blob
506d2bd
1 package doc
2
3 import (
4 "errors"
5 "fmt"
6 "net/url"
7 "path"
8 "regexp"
9 "strings"
10
11 "sourcecraft.dev/bigbes/sr-ht-spec/core"
12 )
13
14 var schemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`)
15
16 // Archive is one space at one revision: the ordered document set plus the
17 // lookup structures the resolver, the read plane and the indexer need.
18 //
19 // It holds no bodies and touches nothing outside itself. That is the property
20 // the design leans on — the archive is built from a git tree by Scan, but
21 // everything below this line would work just as well against a page set that
22 // arrived some other way.
23 type Archive struct {
24 // Space is the space these documents belong to. It is what site hrefs are
25 // built from and what an indexed document is filtered by at query time. A
26 // zero SpaceRef yields root-relative hrefs, which is what a caller building
27 // an archive outside a space context gets.
28 Space core.SpaceRef
29 // Rev is the revision the documents were read at, as the caller named it.
30 // Pass a resolved commit sha when the archive must stay pinned; a branch
31 // name here means "whatever that branch pointed at when Scan ran".
32 Rev string
33
34 Pages []*Page
35
36 byID map[string]*Page
37 byPath map[string]*Page // repo-relative path, with extension
38 byStem map[string]*Page // filename stem -> the document that won the stem
39 // stemsIn maps "<section>/<stem>" to a document, so a bare wikilink written
40 // in one section prefers a document in the same section — Obsidian's
41 // proximity rule, which is what colliding stems across sections mean.
42 stemsIn map[string]*Page
43 // assets maps an attachment's base name and its full path to that path, so
44 // `![[image.png]]` resolves the way it is written.
45 assets map[string]string
46 // aliases maps a normalised `aliases:` entry to the canonical document ID.
47 aliases map[string]string
48 }
49
50 // newArchive returns an Archive with empty lookup maps.
51 20 func newArchive(sp core.SpaceRef, rev string) *Archive {
52 20 return &Archive{
53 20 Space: sp,
54 20 Rev: rev,
55 20 byID: make(map[string]*Page),
56 20 byPath: make(map[string]*Page),
57 20 byStem: make(map[string]*Page),
58 20 stemsIn: make(map[string]*Page),
59 20 assets: make(map[string]string),
60 20 aliases: make(map[string]string),
61 20 }
62 20 }
63
64 // FromPages rebuilds an Archive's lookup structures from a page set that was
65 // produced earlier, without reading anything.
66 //
67 // This is the seam the git-tree walk feeds: a caller reads a revision's
68 // documents, [FromDocuments] turns them into pages, and nothing downstream of
69 // Archive knows the difference. aliases and the attachment index are supplied
70 // separately because they are not derivable from a Page; pass nil for either
71 // when they are not needed.
72 2 func FromPages(sp core.SpaceRef, rev string, pages []*Page, aliases, assets map[string]string) *Archive {
73 2 a := newArchive(sp, rev)
74 2 a.Pages = pages
75 2 for _, p := range pages {
76 2 a.register(p)
77 2 }
78 2 for k, v := range aliases {
79 0 a.aliases[k] = v
80 0 }
81 2 for k, v := range assets {
82 2 a.assets[k] = v
83 2 }
84 2 return a
85 }
86
87 // register wires one document into the lookup maps.
88 //
89 // An ID already claimed is never overwritten: two documents that both resolve
90 // to one name is exactly the case the design refuses to guess about, and the
91 // loser stays reachable by path rather than being silently merged into the
92 // winner.
93 51 func (a *Archive) register(p *Page) {
94 51 if _, taken := a.byID[p.ID]; !taken {
95 51 a.byID[p.ID] = p
96 51 }
97 51 if p.Path != "" {
98 51 a.byPath[p.Path] = p
99 51 }
100 51 stem := Stem(p.Path)
101 51 if stem == "" {
102 0 return
103 0 }
104 51 if cur, ok := a.byStem[stem]; !ok || lessByRank(p.Path, cur.Path) {
105 50 a.byStem[stem] = p
106 50 }
107 51 if key := p.Section + "/" + stem; a.stemsIn[key] == nil {
108 51 a.stemsIn[key] = p
109 51 }
110 }
111
112 // lessByRank orders two paths competing for the same bare stem: shorter path
113 // first, then lexicographic — total and deterministic.
114 //
115 // warren ranked by a fixed list of the vault's top-level directories
116 // ("wiki" beat "sources"). A space here has no such vocabulary — its
117 // directories are whatever the policy's auto_merge globs name — so ranking by
118 // them would be ranking by names that do not exist.
119 3 func lessByRank(a, b string) bool {
120 3 if len(a) != len(b) {
121 3 return len(a) < len(b)
122 3 }
123 0 return a < b
124 }
125
126 // Stem returns the filename stem of a path ("specs/storage.md" -> "storage").
127 // It returns "" for an empty path.
128 102 func Stem(p string) string {
129 102 if p == "" {
130 0 return ""
131 0 }
132 102 base := path.Base(p)
133 102 return strings.TrimSuffix(base, path.Ext(base))
134 }
135
136 // Page returns a document by ID.
137 2 func (a *Archive) Page(id string) (*Page, bool) { p, ok := a.byID[id]; return p, ok }
138
139 // ByPath returns a document by its path in the tree, extension included. This
140 // is what the read plane's `GET /~user/space/<path>` resolves through.
141 30 func (a *Archive) ByPath(p string) (*Page, bool) { pg, ok := a.byPath[p]; return pg, ok }
142
143 // All returns all documents in path order.
144 0 func (a *Archive) All() []*Page { return a.Pages }
145
146 // Aliases returns the alias -> canonical document ID map.
147 0 func (a *Archive) Aliases() map[string]string { return a.aliases }
148
149 // Assets returns the attachment lookup (base name and path -> path).
150 1 func (a *Archive) Assets() map[string]string { return a.assets }
151
152 // Canonical resolves an alias to its document. It reports ok=false when the
153 // name is not a known alias or the alias points at a document that is gone.
154 6 func (a *Archive) Canonical(alias string) (*Page, bool) {
155 6 id, ok := a.aliases[normalizeName(alias)]
156 6 if !ok {
157 5 return nil, false
158 5 }
159 1 p, ok := a.byID[id]
160 1 return p, ok
161 }
162
163 // Children returns the documents whose immediate parent is id, in path order.
164 1 func (a *Archive) Children(id string) []*Page {
165 1 var out []*Page
166 7 for _, p := range a.Pages {
167 7 if p.ParentID == id {
168 1 out = append(out, p)
169 1 }
170 }
171 1 return out
172 }
173
174 // Roots returns top-level documents (those without a parent).
175 1 func (a *Archive) Roots() []*Page {
176 1 var out []*Page
177 7 for _, p := range a.Pages {
178 7 if p.ParentID == "" {
179 5 out = append(out, p)
180 5 }
181 }
182 1 return out
183 }
184
185 // LinkPass fills in Page.Links and Page.WordCount for every document of the
186 // archive, by rendering each body against the archive itself.
187 //
188 // It is a second pass rather than part of Scan because links come out of a
189 // render, not out of a frontmatter parse: a wikilink inside a fenced code block
190 // is not a link, and deciding that needs the markdown AST. It is here rather
191 // than in a caller because Archive.Backlinks reads exactly what this writes —
192 // left to each surface, one of them renders the revision twice a page view and
193 // the next one silently reports no backlinks at all.
194 //
195 // bodies holds each page's raw markdown, frontmatter included, keyed by
196 // Page.Path — the map a caller already has from the same tree walk that built
197 // the archive. A page with no body is an inconsistency between the two and is
198 // reported rather than skipped: skipping it would drop that document's outbound
199 // links and under-report backlinks everywhere else, invisibly.
200 //
201 // The archive resolves the links, so every href produced here is the plain,
202 // unpinned site path. A caller rendering for display wraps the resolver to
203 // carry its own ?rev=; that wrapper must not be used here, or the link graph
204 // would depend on how the reader arrived.
205 2 func (a *Archive) LinkPass(r *Renderer, bodies map[string][]byte) error {
206 2 if r == nil {
207 0 return errors.New("doc: link pass needs a renderer")
208 0 }
209 4 for _, p := range a.Pages {
210 4 raw, ok := bodies[p.Path]
211 4 if !ok {
212 1 return fmt.Errorf("doc: %s is in the archive of %s at %s but has no body",
213 1 p.Path, a.Space, a.Rev)
214 1 }
215 3 _, body := ParseFront(raw)
216 3 res := r.Render(body, DirOf(p.Path), a)
217 3 p.Links = res.LinkedIDs
218 3 p.WordCount = res.WordCount
219 }
220 1 return nil
221 }
222
223 // DirOf is the directory a document lives in, space-relative, with "" for the
224 // space root — the shape Resolve expects as fromDir.
225 13 func DirOf(p string) string {
226 13 d := path.Dir(p)
227 13 if d == "." || d == "/" {
228 2 return ""
229 2 }
230 11 return d
231 }
232
233 // Backlinks returns documents that link to id. Catalog and log documents are
234 // skipped: they link to nearly everything, so counting them would make every
235 // document look referenced and orphan detection would never return a result.
236 //
237 // It reads Page.Links, which LinkPass fills: an archive that has not been
238 // through one has no link graph, and every document looks unreferenced.
239 4 func (a *Archive) Backlinks(id string) []*Page {
240 4 var out []*Page
241 11 for _, p := range a.Pages {
242 11 if SuppressesEdges(p) {
243 1 continue
244 }
245 10 for _, l := range p.Links {
246 4 if l == id {
247 4 out = append(out, p)
248 4 break
249 }
250 }
251 }
252 4 return out
253 }
254
255 // SuppressesEdges reports whether a document's outbound links are excluded from
256 // backlink counts. A catalog links to everything in its section and a log
257 // summarises everything that happened; left in, no document in the space can
258 // ever have zero inbound links.
259 16 func SuppressesEdges(p *Page) bool {
260 16 return p.Kind == KindCatalog || p.Kind == KindLog
261 16 }
262
263 // base is the site path prefix every href in this archive hangs off:
264 // "/~owner/space", or "" when the archive has no space.
265 22 func (a *Archive) base() string {
266 22 if a.Space.Owner == "" || a.Space.Name == "" {
267 1 return ""
268 1 }
269 21 return "/" + a.Space.String()
270 }
271
272 // DocHref is the site path a document renders at: its tree path without the
273 // ".md" extension, under the space prefix.
274 //
275 // The extension is dropped because the read plane negotiates content by it —
276 // "path.md" is the raw source — and a wikilink means "show me this document",
277 // not "show me its bytes".
278 21 func (a *Archive) DocHref(p *Page) string {
279 21 return a.base() + "/" + escapePath(strings.TrimSuffix(p.Path, core.DocExt))
280 21 }
281
282 // AssetHref is the site path an attachment is served at. Every segment is
283 // escaped so spaces, Cyrillic and literal percent signs survive.
284 1 func (a *Archive) AssetHref(p string) string {
285 1 return a.base() + "/" + escapePath(p)
286 1 }
287
288 22 func escapePath(p string) string {
289 22 parts := strings.Split(p, "/")
290 43 for i, seg := range parts {
291 43 parts[i] = url.PathEscape(seg)
292 43 }
293 22 return strings.Join(parts, "/")
294 }
295
296 // Resolve implements Resolver. dest is a link destination as written in a
297 // document living in fromDir (space-relative, "" for the space root): either a
298 // wikilink target ("SPEC-0007", "specs/storage", "note#heading") or an ordinary
299 // markdown destination (a URL, or a path relative to fromDir).
300 30 func (a *Archive) Resolve(fromDir, dest string) Target {
301 30 dest = strings.TrimSpace(dest)
302 30 if dest == "" || strings.HasPrefix(dest, "#") {
303 1 return Target{Href: dest}
304 1 }
305 29 if schemeRe.MatchString(dest) || strings.HasPrefix(dest, "//") {
306 3 return Target{Href: dest, IsExternal: true}
307 3 }
308
309 26 base, frag := splitFragment(dest)
310 26 if base == "" {
311 0 return Target{Href: dest}
312 0 }
313
314 26 if p := a.lookupPage(fromDir, base); p != nil {
315 19 return Target{
316 19 Href: a.DocHref(p) + fragmentSuffix(frag),
317 19 PageID: p.ID,
318 19 Path: p.Path,
319 19 Kind: string(p.Kind),
320 19 }
321 19 }
322 7 if rel, ok := a.lookupAsset(fromDir, base); ok {
323 1 return Target{Href: a.AssetHref(rel), Path: rel}
324 1 }
325 // Nothing resolved. The destination is handed back exactly as it was
326 // written rather than pointed at an invented URL: the renderer marks it
327 // visibly broken, and a caller that wants to repair it needs to see what
328 // the author actually typed.
329 6 return Target{Href: dest, Missing: true}
330 }
331
332 // lookupPage resolves a link target to a document, narrowest scope first: an
333 // explicit path wins outright, then a document id, then a name beside the
334 // linking document, then within its section, then space-wide, then aliases.
335 //
336 // The id step is what makes [[SPEC-0007]] work and is matched exactly —
337 // case-insensitive matching would let a lowercase or homograph id resolve to a
338 // document it is not, which is the confusion core.ParseDocID exists to prevent.
339 34 func (a *Archive) lookupPage(fromDir, base string) *Page {
340 34 bare := strings.TrimSuffix(base, core.DocExt)
341 34 if bare == "" {
342 0 return nil
343 0 }
344
345 34 if strings.Contains(bare, "/") {
346 3 // Space-relative ("specs/storage") or relative to the linking document
347 3 // ("../specs/storage", as an ordinary markdown link would write it). A
348 3 // path-qualified target that matches nothing is a miss, not an
349 3 // invitation to fall back to a bare stem in some other directory.
350 4 for _, cand := range []string{bare, cleanJoin(fromDir, bare)} {
351 4 if cand == "" {
352 0 continue
353 }
354 4 if p, ok := a.byPath[cand+core.DocExt]; ok {
355 2 return p
356 2 }
357 2 if p, ok := a.byID[cand]; ok {
358 0 return p
359 0 }
360 }
361 1 return nil
362 }
363
364 31 if p, ok := a.byID[bare]; ok && p.DocID == bare {
365 6 return p
366 6 }
367
368 // A bare name carrying a non-markdown extension ("diagram.png") names an
369 // attachment. Stem matching would strip the extension and could hand back an
370 // unrelated document that happens to be called "diagram".
371 25 if ext := path.Ext(bare); ext != "" && !strings.EqualFold(ext, core.DocExt) {
372 2 return nil
373 2 }
374
375 23 if cand := cleanJoin(fromDir, bare); cand != "" {
376 23 if p, ok := a.byPath[cand+core.DocExt]; ok {
377 16 return p
378 16 }
379 }
380 7 if p, ok := a.stemsIn[topSection(fromDir)+"/"+bare]; ok {
381 0 return p
382 0 }
383 7 if p, ok := a.byStem[bare]; ok {
384 1 return p
385 1 }
386 6 if p, ok := a.byID[bare]; ok {
387 0 return p
388 0 }
389 6 if p, ok := a.Canonical(bare); ok {
390 1 return p
391 1 }
392 5 return nil
393 }
394
395 // lookupAsset resolves a link target to a blob in the space that is not a
396 // document — an image, a PDF. Embeds name attachments by base name alone
397 // (`![[diagram.png]]`), so the base name is tried after the paths.
398 7 func (a *Archive) lookupAsset(fromDir, base string) (string, bool) {
399 14 for _, cand := range []string{cleanJoin(fromDir, base), base} {
400 14 if cand == "" {
401 0 continue
402 }
403 14 if rel, ok := a.assets[cand]; ok {
404 1 return rel, true
405 1 }
406 }
407 6 if rel, ok := a.assets[path.Base(base)]; ok {
408 0 return rel, true
409 0 }
410 6 return "", false
411 }
412
413 // splitFragment separates a heading or block reference from a link target.
414 // Document paths never contain '#', so the first one is always the separator.
415 26 func splitFragment(dest string) (base, frag string) {
416 26 if i := strings.IndexByte(dest, '#'); i >= 0 {
417 2 return dest[:i], dest[i+1:]
418 2 }
419 24 return dest, ""
420 }
421
422 // fragmentSuffix renders a heading reference as a URL fragment matching
423 // goldmark's auto-generated heading anchors. Block references ("^block-id")
424 // have no anchor in the rendered HTML, so they are dropped.
425 19 func fragmentSuffix(frag string) string {
426 19 if frag == "" || strings.HasPrefix(frag, "^") {
427 18 return ""
428 18 }
429 1 return "#" + slugify(frag)
430 }
431
432 // slugify lowercases a heading and replaces every run of non-alphanumerics with
433 // a single hyphen, matching goldmark's WithAutoHeadingID output for ASCII
434 // headings.
435 1 func slugify(s string) string {
436 1 var b strings.Builder
437 1 lastDash := true
438 15 for _, r := range strings.ToLower(s) {
439 15 switch {
440 14 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
441 14 b.WriteRune(r)
442 14 lastDash = false
443 0 case r > 127: // keep non-ASCII letters; goldmark passes them through
444 0 b.WriteRune(r)
445 0 lastDash = false
446 1 default:
447 1 if !lastDash {
448 1 b.WriteByte('-')
449 1 lastDash = true
450 1 }
451 }
452 }
453 1 return strings.Trim(b.String(), "-")
454 }
455
456 // normalizeName folds an alias or link target for case-insensitive matching.
457 7 func normalizeName(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
458
459 // cleanJoin joins a relative destination onto the linking document's directory,
460 // returning "" when the result escapes the space root.
461 33 func cleanJoin(fromDir, dest string) string {
462 33 joined := path.Join(fromDir, dest)
463 33 joined = strings.TrimPrefix(joined, "./")
464 33 if joined == "." || joined == ".." || strings.HasPrefix(joined, "../") {
465 0 return ""
466 0 }
467 33 return joined
468 }
469
470 // topSection returns the top-level directory of a space-relative path; a
471 // document at the space root has the empty section.
472 56 func topSection(rel string) string {
473 56 if i := strings.IndexByte(rel, '/'); i >= 0 {
474 46 return rel[:i]
475 46 }
476 10 if strings.HasSuffix(rel, core.DocExt) {
477 3 return ""
478 3 }
479 7 return rel
480 }