| 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 |
|
} |