package doc
import (
"errors"
"fmt"
"net/url"
"path"
"regexp"
"strings"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
var schemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`)
// Archive is one space at one revision: the ordered document set plus the
// lookup structures the resolver, the read plane and the indexer need.
//
// It holds no bodies and touches nothing outside itself. That is the property
// the design leans on — the archive is built from a git tree by Scan, but
// everything below this line would work just as well against a page set that
// arrived some other way.
type Archive struct {
// Space is the space these documents belong to. It is what site hrefs are
// built from and what an indexed document is filtered by at query time. A
// zero SpaceRef yields root-relative hrefs, which is what a caller building
// an archive outside a space context gets.
Space core.SpaceRef
// Rev is the revision the documents were read at, as the caller named it.
// Pass a resolved commit sha when the archive must stay pinned; a branch
// name here means "whatever that branch pointed at when Scan ran".
Rev string
Pages []*Page
byID map[string]*Page
byPath map[string]*Page // repo-relative path, with extension
byStem map[string]*Page // filename stem -> the document that won the stem
// stemsIn maps "<section>/<stem>" to a document, so a bare wikilink written
// in one section prefers a document in the same section — Obsidian's
// proximity rule, which is what colliding stems across sections mean.
stemsIn map[string]*Page
// assets maps an attachment's base name and its full path to that path, so
// `![[image.png]]` resolves the way it is written.
assets map[string]string
// aliases maps a normalised `aliases:` entry to the canonical document ID.
aliases map[string]string
}
// newArchive returns an Archive with empty lookup maps.
func newArchive(sp core.SpaceRef, rev string) *Archive {
return &Archive{
Space: sp,
Rev: rev,
byID: make(map[string]*Page),
byPath: make(map[string]*Page),
byStem: make(map[string]*Page),
stemsIn: make(map[string]*Page),
assets: make(map[string]string),
aliases: make(map[string]string),
}
}
// FromPages rebuilds an Archive's lookup structures from a page set that was
// produced earlier, without reading anything.
//
// This is the seam the git-tree walk feeds: a caller reads a revision's
// documents, [FromDocuments] turns them into pages, and nothing downstream of
// Archive knows the difference. aliases and the attachment index are supplied
// separately because they are not derivable from a Page; pass nil for either
// when they are not needed.
func FromPages(sp core.SpaceRef, rev string, pages []*Page, aliases, assets map[string]string) *Archive {
a := newArchive(sp, rev)
a.Pages = pages
for _, p := range pages {
a.register(p)
}
for k, v := range aliases {
a.aliases[k] = v
}
for k, v := range assets {
a.assets[k] = v
}
return a
}
// register wires one document into the lookup maps.
//
// An ID already claimed is never overwritten: two documents that both resolve
// to one name is exactly the case the design refuses to guess about, and the
// loser stays reachable by path rather than being silently merged into the
// winner.
func (a *Archive) register(p *Page) {
if _, taken := a.byID[p.ID]; !taken {
a.byID[p.ID] = p
}
if p.Path != "" {
a.byPath[p.Path] = p
}
stem := Stem(p.Path)
if stem == "" {
return
}
if cur, ok := a.byStem[stem]; !ok || lessByRank(p.Path, cur.Path) {
a.byStem[stem] = p
}
if key := p.Section + "/" + stem; a.stemsIn[key] == nil {
a.stemsIn[key] = p
}
}
// lessByRank orders two paths competing for the same bare stem: shorter path
// first, then lexicographic — total and deterministic.
//
// warren ranked by a fixed list of the vault's top-level directories
// ("wiki" beat "sources"). A space here has no such vocabulary — its
// directories are whatever the policy's auto_merge globs name — so ranking by
// them would be ranking by names that do not exist.
func lessByRank(a, b string) bool {
if len(a) != len(b) {
return len(a) < len(b)
}
return a < b
}
// Stem returns the filename stem of a path ("specs/storage.md" -> "storage").
// It returns "" for an empty path.
func Stem(p string) string {
if p == "" {
return ""
}
base := path.Base(p)
return strings.TrimSuffix(base, path.Ext(base))
}
// Page returns a document by ID.
func (a *Archive) Page(id string) (*Page, bool) { p, ok := a.byID[id]; return p, ok }
// ByPath returns a document by its path in the tree, extension included. This
// is what the read plane's `GET /~user/space/<path>` resolves through.
func (a *Archive) ByPath(p string) (*Page, bool) { pg, ok := a.byPath[p]; return pg, ok }
// All returns all documents in path order.
func (a *Archive) All() []*Page { return a.Pages }
// Aliases returns the alias -> canonical document ID map.
func (a *Archive) Aliases() map[string]string { return a.aliases }
// Assets returns the attachment lookup (base name and path -> path).
func (a *Archive) Assets() map[string]string { return a.assets }
// Canonical resolves an alias to its document. It reports ok=false when the
// name is not a known alias or the alias points at a document that is gone.
func (a *Archive) Canonical(alias string) (*Page, bool) {
id, ok := a.aliases[normalizeName(alias)]
if !ok {
return nil, false
}
p, ok := a.byID[id]
return p, ok
}
// Children returns the documents whose immediate parent is id, in path order.
func (a *Archive) Children(id string) []*Page {
var out []*Page
for _, p := range a.Pages {
if p.ParentID == id {
out = append(out, p)
}
}
return out
}
// Roots returns top-level documents (those without a parent).
func (a *Archive) Roots() []*Page {
var out []*Page
for _, p := range a.Pages {
if p.ParentID == "" {
out = append(out, p)
}
}
return out
}
// LinkPass fills in Page.Links and Page.WordCount for every document of the
// archive, by rendering each body against the archive itself.
//
// It is a second pass rather than part of Scan because links come out of a
// render, not out of a frontmatter parse: a wikilink inside a fenced code block
// is not a link, and deciding that needs the markdown AST. It is here rather
// than in a caller because Archive.Backlinks reads exactly what this writes —
// left to each surface, one of them renders the revision twice a page view and
// the next one silently reports no backlinks at all.
//
// bodies holds each page's raw markdown, frontmatter included, keyed by
// Page.Path — the map a caller already has from the same tree walk that built
// the archive. A page with no body is an inconsistency between the two and is
// reported rather than skipped: skipping it would drop that document's outbound
// links and under-report backlinks everywhere else, invisibly.
//
// The archive resolves the links, so every href produced here is the plain,
// unpinned site path. A caller rendering for display wraps the resolver to
// carry its own ?rev=; that wrapper must not be used here, or the link graph
// would depend on how the reader arrived.
func (a *Archive) LinkPass(r *Renderer, bodies map[string][]byte) error {
if r == nil {
return errors.New("doc: link pass needs a renderer")
}
for _, p := range a.Pages {
raw, ok := bodies[p.Path]
if !ok {
return fmt.Errorf("doc: %s is in the archive of %s at %s but has no body",
p.Path, a.Space, a.Rev)
}
_, body := ParseFront(raw)
res := r.Render(body, DirOf(p.Path), a)
p.Links = res.LinkedIDs
p.WordCount = res.WordCount
}
return nil
}
// DirOf is the directory a document lives in, space-relative, with "" for the
// space root — the shape Resolve expects as fromDir.
func DirOf(p string) string {
d := path.Dir(p)
if d == "." || d == "/" {
return ""
}
return d
}
// Backlinks returns documents that link to id. Catalog and log documents are
// skipped: they link to nearly everything, so counting them would make every
// document look referenced and orphan detection would never return a result.
//
// It reads Page.Links, which LinkPass fills: an archive that has not been
// through one has no link graph, and every document looks unreferenced.
func (a *Archive) Backlinks(id string) []*Page {
var out []*Page
for _, p := range a.Pages {
if SuppressesEdges(p) {
continue
}
for _, l := range p.Links {
if l == id {
out = append(out, p)
break
}
}
}
return out
}
// SuppressesEdges reports whether a document's outbound links are excluded from
// backlink counts. A catalog links to everything in its section and a log
// summarises everything that happened; left in, no document in the space can
// ever have zero inbound links.
func SuppressesEdges(p *Page) bool {
return p.Kind == KindCatalog || p.Kind == KindLog
}
// base is the site path prefix every href in this archive hangs off:
// "/~owner/space", or "" when the archive has no space.
func (a *Archive) base() string {
if a.Space.Owner == "" || a.Space.Name == "" {
return ""
}
return "/" + a.Space.String()
}
// DocHref is the site path a document renders at: its tree path without the
// ".md" extension, under the space prefix.
//
// The extension is dropped because the read plane negotiates content by it —
// "path.md" is the raw source — and a wikilink means "show me this document",
// not "show me its bytes".
func (a *Archive) DocHref(p *Page) string {
return a.base() + "/" + escapePath(strings.TrimSuffix(p.Path, core.DocExt))
}
// AssetHref is the site path an attachment is served at. Every segment is
// escaped so spaces, Cyrillic and literal percent signs survive.
func (a *Archive) AssetHref(p string) string {
return a.base() + "/" + escapePath(p)
}
func escapePath(p string) string {
parts := strings.Split(p, "/")
for i, seg := range parts {
parts[i] = url.PathEscape(seg)
}
return strings.Join(parts, "/")
}
// Resolve implements Resolver. dest is a link destination as written in a
// document living in fromDir (space-relative, "" for the space root): either a
// wikilink target ("SPEC-0007", "specs/storage", "note#heading") or an ordinary
// markdown destination (a URL, or a path relative to fromDir).
func (a *Archive) Resolve(fromDir, dest string) Target {
dest = strings.TrimSpace(dest)
if dest == "" || strings.HasPrefix(dest, "#") {
return Target{Href: dest}
}
if schemeRe.MatchString(dest) || strings.HasPrefix(dest, "//") {
return Target{Href: dest, IsExternal: true}
}
base, frag := splitFragment(dest)
if base == "" {
return Target{Href: dest}
}
if p := a.lookupPage(fromDir, base); p != nil {
return Target{
Href: a.DocHref(p) + fragmentSuffix(frag),
PageID: p.ID,
Path: p.Path,
Kind: string(p.Kind),
}
}
if rel, ok := a.lookupAsset(fromDir, base); ok {
return Target{Href: a.AssetHref(rel), Path: rel}
}
// Nothing resolved. The destination is handed back exactly as it was
// written rather than pointed at an invented URL: the renderer marks it
// visibly broken, and a caller that wants to repair it needs to see what
// the author actually typed.
return Target{Href: dest, Missing: true}
}
// lookupPage resolves a link target to a document, narrowest scope first: an
// explicit path wins outright, then a document id, then a name beside the
// linking document, then within its section, then space-wide, then aliases.
//
// The id step is what makes [[SPEC-0007]] work and is matched exactly —
// case-insensitive matching would let a lowercase or homograph id resolve to a
// document it is not, which is the confusion core.ParseDocID exists to prevent.
func (a *Archive) lookupPage(fromDir, base string) *Page {
bare := strings.TrimSuffix(base, core.DocExt)
if bare == "" {
return nil
}
if strings.Contains(bare, "/") {
// Space-relative ("specs/storage") or relative to the linking document
// ("../specs/storage", as an ordinary markdown link would write it). A
// path-qualified target that matches nothing is a miss, not an
// invitation to fall back to a bare stem in some other directory.
for _, cand := range []string{bare, cleanJoin(fromDir, bare)} {
if cand == "" {
continue
}
if p, ok := a.byPath[cand+core.DocExt]; ok {
return p
}
if p, ok := a.byID[cand]; ok {
return p
}
}
return nil
}
if p, ok := a.byID[bare]; ok && p.DocID == bare {
return p
}
// A bare name carrying a non-markdown extension ("diagram.png") names an
// attachment. Stem matching would strip the extension and could hand back an
// unrelated document that happens to be called "diagram".
if ext := path.Ext(bare); ext != "" && !strings.EqualFold(ext, core.DocExt) {
return nil
}
if cand := cleanJoin(fromDir, bare); cand != "" {
if p, ok := a.byPath[cand+core.DocExt]; ok {
return p
}
}
if p, ok := a.stemsIn[topSection(fromDir)+"/"+bare]; ok {
return p
}
if p, ok := a.byStem[bare]; ok {
return p
}
if p, ok := a.byID[bare]; ok {
return p
}
if p, ok := a.Canonical(bare); ok {
return p
}
return nil
}
// lookupAsset resolves a link target to a blob in the space that is not a
// document — an image, a PDF. Embeds name attachments by base name alone
// (`![[diagram.png]]`), so the base name is tried after the paths.
func (a *Archive) lookupAsset(fromDir, base string) (string, bool) {
for _, cand := range []string{cleanJoin(fromDir, base), base} {
if cand == "" {
continue
}
if rel, ok := a.assets[cand]; ok {
return rel, true
}
}
if rel, ok := a.assets[path.Base(base)]; ok {
return rel, true
}
return "", false
}
// splitFragment separates a heading or block reference from a link target.
// Document paths never contain '#', so the first one is always the separator.
func splitFragment(dest string) (base, frag string) {
if i := strings.IndexByte(dest, '#'); i >= 0 {
return dest[:i], dest[i+1:]
}
return dest, ""
}
// fragmentSuffix renders a heading reference as a URL fragment matching
// goldmark's auto-generated heading anchors. Block references ("^block-id")
// have no anchor in the rendered HTML, so they are dropped.
func fragmentSuffix(frag string) string {
if frag == "" || strings.HasPrefix(frag, "^") {
return ""
}
return "#" + slugify(frag)
}
// slugify lowercases a heading and replaces every run of non-alphanumerics with
// a single hyphen, matching goldmark's WithAutoHeadingID output for ASCII
// headings.
func slugify(s string) string {
var b strings.Builder
lastDash := true
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastDash = false
case r > 127: // keep non-ASCII letters; goldmark passes them through
b.WriteRune(r)
lastDash = false
default:
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(b.String(), "-")
}
// normalizeName folds an alias or link target for case-insensitive matching.
func normalizeName(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
// cleanJoin joins a relative destination onto the linking document's directory,
// returning "" when the result escapes the space root.
func cleanJoin(fromDir, dest string) string {
joined := path.Join(fromDir, dest)
joined = strings.TrimPrefix(joined, "./")
if joined == "." || joined == ".." || strings.HasPrefix(joined, "../") {
return ""
}
return joined
}
// topSection returns the top-level directory of a space-relative path; a
// document at the space root has the empty section.
func topSection(rel string) string {
if i := strings.IndexByte(rel, '/'); i >= 0 {
return rel[:i]
}
if strings.HasSuffix(rel, core.DocExt) {
return ""
}
return rel
}