package doc
import (
"strings"
"testing"
)
// fakeResolver resolves every target except those starting with "missing" and
// those ending in an attachment extension, so the tests can exercise all four
// render paths without a real archive. The hrefs it hands back are deliberately
// arbitrary: what is under test is the renderer, not the resolution rules.
type fakeResolver struct{}
func (fakeResolver) Resolve(fromDir, dest string) Target {
switch {
case strings.HasPrefix(dest, "http"):
return Target{Href: dest, IsExternal: true}
case strings.HasPrefix(dest, "missing"):
return Target{Href: "/p/" + dest, Missing: true}
case strings.HasSuffix(dest, ".png"), strings.HasSuffix(dest, ".pdf"), strings.HasSuffix(dest, ".base"):
return Target{Href: "/assets/" + dest}
}
id, frag, _ := strings.Cut(dest, "#")
href := "/p/" + id
if frag != "" {
href += "#" + strings.ToLower(frag)
}
return Target{Href: href, PageID: id, Kind: "markdown"}
}
func renderMD(t *testing.T, src string) Result {
t.Helper()
return NewRenderer().Render([]byte(src), "specs", fakeResolver{})
}
func TestWikilink(t *testing.T) {
cases := []struct {
name string
src string
wantHTML []string
wantAbsent []string
wantLinked []string
}{
{
name: "plain",
src: "see [[lsm-tree]] for details",
wantHTML: []string{`<a class="wikilink" href="/p/lsm-tree">lsm-tree</a>`},
wantLinked: []string{"lsm-tree"},
},
{
name: "aliased",
src: "see [[lsm-tree|LSM trees]]",
wantHTML: []string{`href="/p/lsm-tree">LSM trees</a>`},
wantLinked: []string{"lsm-tree"},
},
{
name: "path qualified shows the last segment",
src: "[[reports/watchlist]]",
wantHTML: []string{`href="/p/reports/watchlist">watchlist</a>`},
wantLinked: []string{"reports/watchlist"},
},
{
name: "heading reference",
src: "[[ai-too-expensive#Zillow|AI Chernobyl]]",
wantHTML: []string{`href="/p/ai-too-expensive#zillow">AI Chernobyl</a>`},
wantLinked: []string{"ai-too-expensive"},
},
{
name: "image embed renders inline",
src: "![[diagram.png]]",
wantHTML: []string{`<img class="wikilink-embed" src="/assets/diagram.png"`},
},
{
name: "non-image embed degrades to a labelled link",
src: "![[wiki.base]]",
wantHTML: []string{`class="wikilink wikilink-file" href="/assets/wiki.base">wiki.base</a>`},
wantAbsent: []string{"<img"},
},
{
name: "page embed links rather than transcluding",
src: "![[lsm-tree]]",
wantHTML: []string{`wikilink-embed-ref" href="/p/lsm-tree">lsm-tree</a>`},
wantLinked: []string{"lsm-tree"},
},
{
name: "unresolved link is visibly broken, never dropped",
src: "[[missing-page]]",
wantHTML: []string{`<span class="wikilink-missing"`, `>missing-page</span>`},
wantAbsent: []string{"<a "},
},
{
name: "inside a code span it is literal text",
src: "write `[[page]]` to link",
wantHTML: []string{
"<code>[[page]]</code>",
},
wantAbsent: []string{`class="wikilink"`},
},
{
name: "inside a fenced block it is literal text",
src: "```\n[[page]] and ![[transclusions]]\n```\n",
wantHTML: []string{
"[[page]] and ![[transclusions]]",
},
wantAbsent: []string{`class="wikilink"`, "<img"},
},
{
name: "empty target is not a link",
src: "[[]] and [[|x]]",
wantAbsent: []string{`class="wikilink"`},
},
{
name: "unclosed brackets are left alone",
src: "an [[unclosed link",
wantAbsent: []string{`class="wikilink"`},
},
{
// Obsidian escapes the alias pipe inside table cells.
name: "escaped alias pipe in a table cell",
src: "| a | b |\n| --- | --- |\n| [[lsm-tree\\|LSM]] | x |\n",
wantHTML: []string{`href="/p/lsm-tree">LSM</a>`},
wantLinked: []string{"lsm-tree"},
},
{
name: "label is HTML-escaped",
src: "[[lsm-tree|<script>alert(1)</script>]]",
wantAbsent: []string{"<script>"},
wantHTML: []string{"<script>"},
wantLinked: []string{"lsm-tree"},
},
{
name: "an ordinary markdown link still works",
src: "[label](lsm-tree)",
wantHTML: []string{`href="/p/lsm-tree"`},
wantLinked: []string{"lsm-tree"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
res := renderMD(t, c.src)
for _, want := range c.wantHTML {
if !strings.Contains(res.HTML, want) {
t.Errorf("HTML missing %q:\n%s", want, res.HTML)
}
}
for _, absent := range c.wantAbsent {
if strings.Contains(res.HTML, absent) {
t.Errorf("HTML must not contain %q:\n%s", absent, res.HTML)
}
}
if len(res.LinkedIDs) != len(c.wantLinked) {
t.Fatalf("LinkedIDs = %v, want %v", res.LinkedIDs, c.wantLinked)
}
for i, want := range c.wantLinked {
if res.LinkedIDs[i] != want {
t.Errorf("LinkedIDs[%d] = %q, want %q", i, res.LinkedIDs[i], want)
}
}
})
}
}
func TestWikilinkDedupesAndKeepsFirstAppearanceOrder(t *testing.T) {
res := renderMD(t, "[[b]] then [[a]] then [[b]] again")
if got := strings.Join(res.LinkedIDs, ","); got != "b,a" {
t.Fatalf("LinkedIDs = %q, want %q", got, "b,a")
}
}
func TestWikilinkInsideEmphasisAndLists(t *testing.T) {
res := renderMD(t, "- **[[lsm-tree]]** — a note\n- see *[[b-tree|B-trees]]*\n")
for _, want := range []string{`<strong><a class="wikilink" href="/p/lsm-tree"`, `href="/p/b-tree">B-trees</a>`} {
if !strings.Contains(res.HTML, want) {
t.Errorf("HTML missing %q:\n%s", want, res.HTML)
}
}
}
func TestPlainTextExcludesWikilinkMarkup(t *testing.T) {
res := renderMD(t, "see [[lsm-tree|LSM trees]] now")
if strings.Contains(res.PlainText, "[[") {
t.Errorf("plain text leaked wikilink syntax: %q", res.PlainText)
}
}