| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
import { expect, test } from "bun:test"; |
| 7 |
|
| 8 |
import { render } from "../static/md.js"; |
| 9 |
import { |
| 10 |
backlinks, |
| 11 |
excerpt, |
| 12 |
graph, |
| 13 |
headings, |
| 14 |
links, |
| 15 |
outgoing, |
| 16 |
relink, |
| 17 |
renderNote, |
| 18 |
search, |
| 19 |
tagCounts, |
| 20 |
tags, |
| 21 |
type VaultNote, |
| 22 |
} from "./vault.ts"; |
| 23 |
|
| 24 |
const note = (id: number, title: string, body: string, updated_at = `2026-10-0${id}`): VaultNote => ({ |
| 25 |
id, |
| 26 |
title, |
| 27 |
body, |
| 28 |
pinned: 0, |
| 29 |
updated_at, |
| 30 |
}); |
| 31 |
|
| 32 |
test("links and tags are found in prose, never in code", () => { |
| 33 |
const body = "See [[Alpha]], [[beta|B]] and [[Alpha#Part]].\n`[[Not]] #nope`\n```\n[[Fenced]] #fenced\n```\n#real and #nested/tag, not #123 or a#b"; |
| 34 |
expect(links(body)).toEqual(["Alpha", "beta"]); |
| 35 |
expect(tags(body)).toEqual(["real", "nested/tag"]); |
| 36 |
expect(headings("# One\ntext\n## Two ##\n```\n# not\n```")).toEqual([ |
| 37 |
{ level: 1, text: "One", id: "one" }, |
| 38 |
{ level: 2, text: "Two", id: "two" }, |
| 39 |
]); |
| 40 |
}); |
| 41 |
|
| 42 |
test("backlinks, links out, tag counts and the graph agree", () => { |
| 43 |
const a = note(1, "Alpha", "to [[Beta]] and [[Missing]] #x"); |
| 44 |
const b = note(2, "Beta", "back to [[alpha]] #x #y"); |
| 45 |
const c = note(3, "Gamma", "to [[BETA]]"); |
| 46 |
const vault = [a, b, c]; |
| 47 |
expect(backlinks(b, vault).map((n) => n.id)).toEqual([1, 3]); |
| 48 |
expect(backlinks(c, vault)).toEqual([]); |
| 49 |
expect(outgoing(a, vault)).toEqual({ found: [b], missing: ["Missing"] }); |
| 50 |
expect(tagCounts(vault)).toEqual([ |
| 51 |
{ tag: "x", count: 2 }, |
| 52 |
{ tag: "y", count: 1 }, |
| 53 |
]); |
| 54 |
const g = graph(vault); |
| 55 |
expect(g.edges).toEqual([ |
| 56 |
[1, 2], |
| 57 |
[3, 2], |
| 58 |
]); |
| 59 |
expect(g.nodes.find((n) => n.id === 2)?.links).toBe(2); |
| 60 |
}); |
| 61 |
|
| 62 |
test("relink rewrites only links to the old title", () => { |
| 63 |
expect(relink("[[Old]] [[old|alias]] [[Old#h]] ![[Old]] [[Older]] Old", "Old", "New")).toBe( |
| 64 |
"[[New]] [[New|alias]] [[New#h]] ![[New]] [[Older]] Old", |
| 65 |
); |
| 66 |
}); |
| 67 |
|
| 68 |
test("search ranks title matches first, then the most recent", () => { |
| 69 |
const vault = [note(1, "Plant", "water"), note(2, "Garden", "plant beds"), note(3, "Shed", "plant pots")]; |
| 70 |
expect(search(vault, "plant").map((h) => h.note.id)).toEqual([1, 3, 2]); |
| 71 |
expect(search(vault, "")).toEqual([]); |
| 72 |
expect(excerpt("# Head\n**bold** [[A|alias]] text")).toBe("Head bold alias text"); |
| 73 |
}); |
| 74 |
|
| 75 |
test("markdown renders the everyday core", () => { |
| 76 |
const html = render( |
| 77 |
"# Title\n\nSome **bold**, *em*, ~~gone~~, ==lit== and `code`.\n\n- one\n - nested\n- [x] done\n- [ ] todo\n\n1. first\n2. second\n\n> quoted\n\n---\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n\n```js\nlet x = 1 < 2;\n```", |
| 78 |
); |
| 79 |
for (const piece of [ |
| 80 |
'<h1 id="title">Title</h1>', |
| 81 |
"<strong>bold</strong>", |
| 82 |
"<em>em</em>", |
| 83 |
"<del>gone</del>", |
| 84 |
"<mark>lit</mark>", |
| 85 |
"<code>code</code>", |
| 86 |
"<ul><li>one<ul><li>nested</li></ul></li>", |
| 87 |
'aria-checked="true"', |
| 88 |
'aria-checked="false"', |
| 89 |
"<ol><li>first</li><li>second</li></ol>", |
| 90 |
"<blockquote><p>quoted</p>", |
| 91 |
"<hr>", |
| 92 |
'<td style="text-align:right">2</td>', |
| 93 |
'<pre><code class="lang-js">let x = 1 < 2;</code></pre>', |
| 94 |
]) |
| 95 |
expect([piece, html.includes(piece)]).toEqual([piece, true]); |
| 96 |
}); |
| 97 |
|
| 98 |
test("wikilinks point at notes, or at making them; callouts and tags are Obsidian's", () => { |
| 99 |
const vault = [note(7, "Known", "")]; |
| 100 |
const html = renderNote("[[Known]] [[known#Part B|that part]] [[Unknown]]\n\n> [!warning] Careful\n> body\n\n#tag", vault); |
| 101 |
expect(html).toContain('<a class="wikilink" href="/n/7" data-title="Known">Known</a>'); |
| 102 |
expect(html).toContain('href="/n/7#part-b"'); |
| 103 |
expect(html).toContain(">that part</a>"); |
| 104 |
expect(html).toContain('class="wikilink unresolved" href="/open?title=Unknown"'); |
| 105 |
expect(html).toContain('<div class="callout callout-warning"><p class="callout-title">Careful</p><p>body</p></div>'); |
| 106 |
expect(html).toContain('<a class="tag" href="/search?q=%23tag">#tag</a>'); |
| 107 |
}); |
| 108 |
|
| 109 |
test("nothing a note says becomes markup the renderer did not write", () => { |
| 110 |
const evil = [ |
| 111 |
'<script>alert(1)</script>', |
| 112 |
'<img src=x onerror=alert(1)>', |
| 113 |
"[click](javascript:alert(1))", |
| 114 |
'[x](https://a.example/" onmouseover="alert(1))', |
| 115 |
'[[x" onmouseover="alert(1)]]', |
| 116 |
"[[<b>bold</b>]]", |
| 117 |
"| <i>a</i> |\n|---|\n| <svg onload=alert(1)> |", |
| 118 |
"> [!<img src=x onerror=1>] t", |
| 119 |
"```\n</code></pre><script>x</script>\n```", |
| 120 |
].join("\n\n"); |
| 121 |
const html = render(evil); |
| 122 |
expect(html).not.toMatch(/<(script|img|svg|i|b)\b/i); |
| 123 |
expect(html).not.toMatch(/href="(?!https?:|\/|mailto:)/); |
| 124 |
expect(html).not.toMatch(/\son\w+="/i); |
| 125 |
|
| 126 |
expect(render("[a](https://x.example) [b](/n/1) [c](mailto:[email protected])")).toContain('href="https://x.example"'); |
| 127 |
}); |