package doc import ( "strings" "testing" "sourcecraft.dev/bigbes/sr-ht-spec/core" ) func TestTitleFallbackChain(t *testing.T) { arc := archiveOf(t, map[string]string{ "notes/from-frontmatter.md": "---\ntitle: From Frontmatter\n---\n\n# Ignored H1\n", "notes/from-h1.md": "# From H1\n\nbody\n", "notes/from-filename.md": "just prose, no heading\n", "notes/fenced-h1.md": "```\n# Not A Heading\n```\n\ntext\n", }) want := map[string]string{ "notes/from-frontmatter.md": "From Frontmatter", "notes/from-h1.md": "From H1", "notes/from-filename.md": "from-filename", "notes/fenced-h1.md": "fenced-h1", } for path, title := range want { if got := mustPage(t, arc, path).Title; got != title { t.Errorf("%s title = %q, want %q", path, got, title) } } } func TestPageKindClassification(t *testing.T) { arc := archiveOf(t, map[string]string{ // No marker at all — the file-name fallback. "notes/index.md": "# Notes Index\n", "notes/log.md": "# Notes Log\n", // The explicit frontmatter markers. "specs/catalogue.md": "---\ntitle: Catalogue\ntype: catalog\n---\n", "specs/journal.md": "---\ntitle: Journal\ntype: log\n---\n", "specs/ordinary.md": "---\ntitle: Ordinary\ntype: concept\n---\n", }) want := map[string]PageKind{ "notes/index.md": KindCatalog, "notes/log.md": KindLog, "specs/catalogue.md": KindCatalog, "specs/journal.md": KindLog, "specs/ordinary.md": KindMarkdown, } for path, kind := range want { p := mustPage(t, arc, path) if p.Kind != kind { t.Errorf("%s kind = %q, want %q", path, p.Kind, kind) } if SuppressesEdges(p) != (kind != KindMarkdown) { t.Errorf("%s: SuppressesEdges = %v for kind %q", path, SuppressesEdges(p), kind) } } } func TestParentChainAndCycleGuard(t *testing.T) { arc := archiveOf(t, map[string]string{ "notes/root.md": "---\ntitle: Root\n---\n", "notes/mid.md": "---\ntitle: Mid\nparent: \"[[root]]\"\n---\n", "notes/leaf.md": "---\ntitle: Leaf\nparent: \"[[mid]]\"\n---\n", // A deliberate two-document cycle, plus one that parents itself. "notes/cyc-a.md": "---\ntitle: A\nparent: \"[[cyc-b]]\"\n---\n", "notes/cyc-b.md": "---\ntitle: B\nparent: \"[[cyc-a]]\"\n---\n", "notes/self.md": "---\ntitle: Self\nparent: \"[[self]]\"\n---\n", // A parent that does not exist. "notes/orphan.md": "---\ntitle: Orphan\nparent: \"[[nowhere]]\"\n---\n", }) leaf := mustPage(t, arc, "notes/leaf.md") if got := strings.Join(leaf.Crumbs, " > "); got != "notes/root > notes/mid" { t.Errorf("leaf crumbs = %q", got) } if leaf.ParentID != "notes/mid" { t.Errorf("leaf parent = %q", leaf.ParentID) } if kids := arc.Children("notes/mid"); len(kids) != 1 || kids[0] != leaf { t.Errorf("Children(notes/mid) = %v", kids) } for _, path := range []string{"notes/cyc-a.md", "notes/cyc-b.md", "notes/self.md", "notes/orphan.md"} { p := mustPage(t, arc, path) if p.ParentID != "" || len(p.Crumbs) != 0 { t.Errorf("%s: cyclic/broken parent must detach, got parent=%q crumbs=%v", path, p.ParentID, p.Crumbs) } } if len(arc.Roots()) != len(arc.Pages)-2 { // mid and leaf have parents t.Errorf("Roots() = %d of %d documents", len(arc.Roots()), len(arc.Pages)) } } func TestResolveWikilinks(t *testing.T) { arc := archiveOf(t, map[string]string{ "index.md": "---\ntitle: Home\n---\n", "specs/index.md": "# Specs Index\n", "specs/lsm-tree.md": "---\ntitle: LSM Tree\naliases:\n - log-structured-merge\n---\n", "reports/lsm-tree.md": "Clipped report.\n", "specs/0007-store.md": spec("SPEC-0007", "Storage model", "body"), }) cases := []struct { name string fromDir string dest string want string missing bool }{ {"bare stem prefers the shorter path", "specs", "lsm-tree", "/~bigbes/rfcs/specs/lsm-tree", false}, {"path-qualified hits the other section", "specs", "reports/lsm-tree", "/~bigbes/rfcs/reports/lsm-tree", false}, {"proximity: a report links its own section first", "reports", "lsm-tree", "/~bigbes/rfcs/reports/lsm-tree", false}, {"a bare name prefers the linking document's own folder", "specs", "index", "/~bigbes/rfcs/specs/index", false}, {"path-qualified index", "reports", "specs/index", "/~bigbes/rfcs/specs/index", false}, {"a bare stem from the root falls to the stem contest", "", "lsm-tree", "/~bigbes/rfcs/specs/lsm-tree", false}, {"document id resolves from anywhere", "reports", "SPEC-0007", "/~bigbes/rfcs/specs/0007-store", false}, {"alias resolves to the canonical document", "specs", "log-structured-merge", "/~bigbes/rfcs/specs/lsm-tree", false}, {"heading reference becomes an anchor", "specs", "lsm-tree#Core Components", "/~bigbes/rfcs/specs/lsm-tree#core-components", false}, {"block reference has no anchor", "specs", "lsm-tree#^abc123", "/~bigbes/rfcs/specs/lsm-tree", false}, {"trailing .md is stripped", "specs", "lsm-tree.md", "/~bigbes/rfcs/specs/lsm-tree", false}, {"a lowercase id is not the id", "specs", "spec-0007", "", true}, {"unknown target is missing", "specs", "no-such-document", "", true}, {"path-qualified miss does not fall back to the stem", "specs", "notes/lsm-tree", "", true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := arc.Resolve(c.fromDir, c.dest) if c.missing { if !got.Missing { t.Fatalf("Resolve(%q) = %+v, want Missing", c.dest, got) } if got.Href != c.dest { t.Errorf("a missing target must be handed back as written, got %q", got.Href) } return } if got.Missing { t.Fatalf("Resolve(%q) unexpectedly missing", c.dest) } if got.Href != c.want { t.Errorf("Resolve(%q).Href = %q, want %q", c.dest, got.Href, c.want) } }) } } func TestResolveExternalAndAnchors(t *testing.T) { arc := archiveOf(t, map[string]string{"notes/a.md": "# A\n"}) cases := []struct { dest string external bool href string }{ {"https://example.com/x", true, "https://example.com/x"}, {"mailto:a@b.c", true, "mailto:a@b.c"}, {"//cdn.example.com/x", true, "//cdn.example.com/x"}, {"#same-page", false, "#same-page"}, } for _, c := range cases { got := arc.Resolve("notes", c.dest) if got.IsExternal != c.external || got.Href != c.href { t.Errorf("Resolve(%q) = %+v, want href=%q external=%v", c.dest, got, c.href, c.external) } } } // Every href is scoped to the space, because a document only means anything // inside one, and every segment is escaped because paths carry spaces and // Cyrillic. func TestHrefsAreSpaceScopedAndEscaped(t *testing.T) { arc := archiveOf(t, map[string]string{"notes/тех долг.md": "# Debt\n"}) p := mustPage(t, arc, "notes/тех долг.md") if got := arc.DocHref(p); got != "/~bigbes/rfcs/notes/%D1%82%D0%B5%D1%85%20%D0%B4%D0%BE%D0%BB%D0%B3" { t.Errorf("DocHref = %q", got) } if got := FromPages(core.SpaceRef{}, "main", []*Page{p}, nil, nil).DocHref(p); !strings.HasPrefix(got, "/notes/") { t.Errorf("an archive with no space must yield root-relative hrefs, got %q", got) } } // The link graph is what Backlinks reads, and nothing but LinkPass writes it. func TestLinkPassFillsLinksAndWordCount(t *testing.T) { files := map[string]string{ "specs/storage.md": "---\nid: SPEC-0001\ntitle: Storage\n---\n\n# Storage\n\nfour words of prose\n", "specs/review.md": "---\nid: SPEC-0002\ntitle: Review\n---\n\n" + "Supersedes [[SPEC-0001]], and again [[SPEC-0001]].\n\n" + "```\n[[SPEC-0009]]\n```\n", "notes/root.md": "See [[SPEC-0001]] from a note.\n", } arc := archiveOf(t, files) bodies := make(map[string][]byte, len(files)) for p, body := range files { bodies[p] = []byte(body) } // Before the pass there is no graph at all — the state every caller of // Backlinks was silently in. if got := arc.Backlinks("SPEC-0001"); len(got) != 0 { t.Fatalf("Backlinks before LinkPass = %v, want none", got) } if err := arc.LinkPass(NewRenderer(), bodies); err != nil { t.Fatalf("LinkPass: %v", err) } review := mustPage(t, arc, "specs/review.md") // Deduped, and the fenced [[SPEC-0009]] is code, not a link. if len(review.Links) != 1 || review.Links[0] != "SPEC-0001" { t.Errorf("review links = %v, want [SPEC-0001]", review.Links) } if mustPage(t, arc, "specs/storage.md").WordCount != 5 { t.Errorf("storage word count = %d, want 5", mustPage(t, arc, "specs/storage.md").WordCount) } var backPaths []string for _, p := range arc.Backlinks("SPEC-0001") { backPaths = append(backPaths, p.Path) } if len(backPaths) != 2 || backPaths[0] != "notes/root.md" || backPaths[1] != "specs/review.md" { t.Errorf("backlinks = %v, want notes/root.md and specs/review.md", backPaths) } } // A page with no body is the archive and the bodies disagreeing, which cannot // happen while both come off one tree walk. Reported, never skipped: a skip // drops that document's outbound links and under-reports backlinks elsewhere. func TestLinkPassRefusesAMissingBody(t *testing.T) { arc := archiveOf(t, map[string]string{"notes/a.md": "# A\n"}) err := arc.LinkPass(NewRenderer(), map[string][]byte{}) if err == nil || !strings.Contains(err.Error(), "notes/a.md") { t.Fatalf("LinkPass with no bodies = %v, want an error naming the document", err) } } func TestBacklinksIgnoreCatalogsAndLogs(t *testing.T) { arc := archiveOf(t, map[string]string{ "notes/a.md": "# A\n", "notes/b.md": "# B\n", "notes/index.md": "# Index\n", }) mustPage(t, arc, "notes/b.md").Links = []string{"notes/a"} mustPage(t, arc, "notes/index.md").Links = []string{"notes/a"} back := arc.Backlinks("notes/a") if len(back) != 1 || back[0].Path != "notes/b.md" { t.Fatalf("Backlinks = %v, want only notes/b.md", back) } }