nuartz 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/markdown.test.js +1 -1
- package/dist/__tests__/markdown.test.js.map +1 -1
- package/dist/fs.d.ts +7 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +36 -4
- package/dist/fs.js.map +1 -1
- package/dist/markdown.js +2 -2
- package/dist/markdown.js.map +1 -1
- package/package.json +3 -1
- package/src/__fixtures__/basic.md +0 -20
- package/src/__tests__/backlinks.test.ts +0 -156
- package/src/__tests__/fs.test.ts +0 -179
- package/src/__tests__/markdown.test.ts +0 -113
- package/src/__tests__/search.test.ts +0 -167
- package/src/backlinks.ts +0 -52
- package/src/config.ts +0 -63
- package/src/fs.ts +0 -131
- package/src/index.ts +0 -16
- package/src/markdown.ts +0 -131
- package/src/plugins/arrows.ts +0 -14
- package/src/plugins/callout.test.ts +0 -80
- package/src/plugins/callout.ts +0 -78
- package/src/plugins/comment.test.ts +0 -56
- package/src/plugins/comment.ts +0 -35
- package/src/plugins/highlight.test.ts +0 -52
- package/src/plugins/highlight.ts +0 -32
- package/src/plugins/tag.test.ts +0 -80
- package/src/plugins/tag.ts +0 -58
- package/src/plugins/wikilink.test.ts +0 -109
- package/src/plugins/wikilink.ts +0 -107
- package/src/search.ts +0 -42
- package/src/types.ts +0 -35
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -20
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { unified } from "unified"
|
|
3
|
-
import remarkParse from "remark-parse"
|
|
4
|
-
import remarkRehype from "remark-rehype"
|
|
5
|
-
import rehypeStringify from "rehype-stringify"
|
|
6
|
-
import { remarkCallout } from "./callout.js"
|
|
7
|
-
|
|
8
|
-
async function process(md: string): Promise<string> {
|
|
9
|
-
const result = await unified()
|
|
10
|
-
.use(remarkParse)
|
|
11
|
-
.use(remarkCallout)
|
|
12
|
-
.use(remarkRehype)
|
|
13
|
-
.use(rehypeStringify)
|
|
14
|
-
.process(md)
|
|
15
|
-
return String(result)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
describe("remarkCallout", () => {
|
|
19
|
-
it("transforms a basic note callout", async () => {
|
|
20
|
-
const html = await process("> [!note] My Title\n> Some content here")
|
|
21
|
-
expect(html).toContain('class="callout callout-note"')
|
|
22
|
-
expect(html).toContain('data-callout="note"')
|
|
23
|
-
expect(html).toContain('<div class="callout-title">My Title</div>')
|
|
24
|
-
expect(html).toContain("Some content here")
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it.each([
|
|
28
|
-
"note", "warning", "tip", "info", "success", "danger", "question",
|
|
29
|
-
"abstract", "todo", "hint", "important", "check", "done",
|
|
30
|
-
"help", "faq", "caution", "attention", "failure", "fail",
|
|
31
|
-
"missing", "error", "bug", "example", "quote", "cite",
|
|
32
|
-
])("supports callout type: %s", async (type) => {
|
|
33
|
-
const html = await process(`> [!${type}]\n> Content`)
|
|
34
|
-
expect(html).toContain(`callout-${type}`)
|
|
35
|
-
expect(html).toContain(`data-callout="${type}"`)
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it("handles fold '+' (expanded)", async () => {
|
|
39
|
-
const html = await process("> [!note]+ Expandable\n> Content")
|
|
40
|
-
expect(html).toContain('data-callout-fold="+"')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it("handles fold '-' (collapsed)", async () => {
|
|
44
|
-
const html = await process("> [!tip]- Collapsed\n> Content")
|
|
45
|
-
expect(html).toContain('data-callout-fold="-"')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it("uses capitalized type as default title when no title given", async () => {
|
|
49
|
-
const html = await process("> [!warning]\n> Content")
|
|
50
|
-
expect(html).toContain('<div class="callout-title">Warning</div>')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it("uses custom title text", async () => {
|
|
54
|
-
const html = await process("> [!note] Custom Title Here\n> Content")
|
|
55
|
-
expect(html).toContain('<div class="callout-title">Custom Title Here</div>')
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it("does not transform a regular blockquote", async () => {
|
|
59
|
-
const html = await process("> This is just a regular blockquote")
|
|
60
|
-
expect(html).not.toContain("callout")
|
|
61
|
-
expect(html).toContain("<blockquote>")
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it("does not transform an unsupported callout type", async () => {
|
|
65
|
-
const html = await process("> [!unsupported]\n> Content")
|
|
66
|
-
expect(html).not.toContain("callout")
|
|
67
|
-
expect(html).toContain("<blockquote>")
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it("is case-insensitive for callout type", async () => {
|
|
71
|
-
const html = await process("> [!NOTE] Title\n> Content")
|
|
72
|
-
expect(html).toContain('data-callout="note"')
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it("preserves remaining content after the callout marker line", async () => {
|
|
76
|
-
const html = await process("> [!tip] Title\n> Line one\n> Line two")
|
|
77
|
-
expect(html).toContain("Line one")
|
|
78
|
-
expect(html).toContain("Line two")
|
|
79
|
-
})
|
|
80
|
-
})
|
package/src/plugins/callout.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { visit } from "unist-util-visit"
|
|
2
|
-
import type { Root, Blockquote, Paragraph, Text } from "mdast"
|
|
3
|
-
import type { Plugin } from "unified"
|
|
4
|
-
|
|
5
|
-
// All supported callout types (Obsidian-compatible)
|
|
6
|
-
const CALLOUT_TYPES = new Set([
|
|
7
|
-
"note", "abstract", "summary", "tldr",
|
|
8
|
-
"info", "todo", "tip", "hint", "important",
|
|
9
|
-
"success", "check", "done",
|
|
10
|
-
"question", "help", "faq",
|
|
11
|
-
"warning", "caution", "attention",
|
|
12
|
-
"failure", "fail", "missing",
|
|
13
|
-
"danger", "error", "bug",
|
|
14
|
-
"example", "quote", "cite",
|
|
15
|
-
])
|
|
16
|
-
|
|
17
|
-
// [!type] or [!type]+ or [!type]- optionally followed by title
|
|
18
|
-
const CALLOUT_REGEX = /^\[!(\w+)\]([+-]?)([ \t].*)?$/i
|
|
19
|
-
|
|
20
|
-
export const remarkCallout: Plugin<[], Root> = () => {
|
|
21
|
-
return (tree) => {
|
|
22
|
-
visit(tree, "blockquote", (node: Blockquote) => {
|
|
23
|
-
const firstChild = node.children[0] as Paragraph | undefined
|
|
24
|
-
if (firstChild?.type !== "paragraph") return
|
|
25
|
-
|
|
26
|
-
const firstInline = firstChild.children[0] as Text | undefined
|
|
27
|
-
if (firstInline?.type !== "text") return
|
|
28
|
-
|
|
29
|
-
const firstLine = firstInline.value.split("\n")[0]
|
|
30
|
-
const match = firstLine.match(CALLOUT_REGEX)
|
|
31
|
-
if (!match) return
|
|
32
|
-
|
|
33
|
-
const [, rawType, fold, titleText] = match
|
|
34
|
-
const type = rawType.toLowerCase()
|
|
35
|
-
if (!CALLOUT_TYPES.has(type)) return
|
|
36
|
-
|
|
37
|
-
// Trim [!type] line from the paragraph
|
|
38
|
-
const remainder = firstInline.value.slice(firstLine.length).trimStart()
|
|
39
|
-
if (remainder) {
|
|
40
|
-
firstInline.value = remainder
|
|
41
|
-
} else {
|
|
42
|
-
firstChild.children.shift()
|
|
43
|
-
// remarkBreaks runs before this plugin and converts the line break after
|
|
44
|
-
// [!type] into a break node — remove any leading break nodes
|
|
45
|
-
while (firstChild.children.length > 0 && firstChild.children[0].type === "break") {
|
|
46
|
-
firstChild.children.shift()
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
// If the first paragraph is now empty, remove it entirely
|
|
50
|
-
if (firstChild.children.length === 0) {
|
|
51
|
-
node.children.shift()
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const title = titleText?.trim() ?? type.charAt(0).toUpperCase() + type.slice(1)
|
|
55
|
-
|
|
56
|
-
const titleParagraph: Paragraph = {
|
|
57
|
-
type: "paragraph",
|
|
58
|
-
data: {
|
|
59
|
-
hName: "div",
|
|
60
|
-
hProperties: { className: "callout-title" },
|
|
61
|
-
},
|
|
62
|
-
children: [{ type: "text", value: title }],
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
node.children.unshift(titleParagraph)
|
|
66
|
-
|
|
67
|
-
node.data = {
|
|
68
|
-
...node.data,
|
|
69
|
-
hName: "div",
|
|
70
|
-
hProperties: {
|
|
71
|
-
className: `callout callout-${type}`,
|
|
72
|
-
"data-callout": type,
|
|
73
|
-
"data-callout-fold": fold || null,
|
|
74
|
-
},
|
|
75
|
-
}
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { unified } from "unified"
|
|
3
|
-
import remarkParse from "remark-parse"
|
|
4
|
-
import remarkRehype from "remark-rehype"
|
|
5
|
-
import rehypeStringify from "rehype-stringify"
|
|
6
|
-
import { remarkObsidianComment } from "./comment.js"
|
|
7
|
-
|
|
8
|
-
async function process(md: string): Promise<string> {
|
|
9
|
-
const result = await unified()
|
|
10
|
-
.use(remarkParse)
|
|
11
|
-
.use(remarkObsidianComment)
|
|
12
|
-
.use(remarkRehype)
|
|
13
|
-
.use(rehypeStringify)
|
|
14
|
-
.process(md)
|
|
15
|
-
return String(result)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
describe("remarkObsidianComment", () => {
|
|
19
|
-
it("strips %%comment%% from output", async () => {
|
|
20
|
-
const html = await process("visible %%hidden%% text")
|
|
21
|
-
expect(html).not.toContain("hidden")
|
|
22
|
-
expect(html).toContain("visible")
|
|
23
|
-
expect(html).toContain("text")
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
it("strips multiple comments", async () => {
|
|
27
|
-
const html = await process("a %%one%% b %%two%% c")
|
|
28
|
-
expect(html).not.toContain("one")
|
|
29
|
-
expect(html).not.toContain("two")
|
|
30
|
-
expect(html).toContain("a")
|
|
31
|
-
expect(html).toContain("b")
|
|
32
|
-
expect(html).toContain("c")
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it("leaves text without comments unchanged", async () => {
|
|
36
|
-
const html = await process("No comments here")
|
|
37
|
-
expect(html).toContain("No comments here")
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it("handles comment at start of text", async () => {
|
|
41
|
-
const html = await process("%%removed%%visible")
|
|
42
|
-
expect(html).not.toContain("removed")
|
|
43
|
-
expect(html).toContain("visible")
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it("handles comment at end of text", async () => {
|
|
47
|
-
const html = await process("visible%%removed%%")
|
|
48
|
-
expect(html).not.toContain("removed")
|
|
49
|
-
expect(html).toContain("visible")
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it("does not match single %", async () => {
|
|
53
|
-
const html = await process("100% done")
|
|
54
|
-
expect(html).toContain("100% done")
|
|
55
|
-
})
|
|
56
|
-
})
|
package/src/plugins/comment.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { visit } from "unist-util-visit"
|
|
2
|
-
import type { Root, Text } from "mdast"
|
|
3
|
-
import type { Plugin } from "unified"
|
|
4
|
-
|
|
5
|
-
// matches %%...%% including across newlines within a paragraph node
|
|
6
|
-
const COMMENT_REGEX = /%%[\s\S]*?%%/g
|
|
7
|
-
|
|
8
|
-
export const remarkObsidianComment: Plugin<[], Root> = () => {
|
|
9
|
-
return (tree) => {
|
|
10
|
-
visit(tree, "text", (node: Text, index, parent) => {
|
|
11
|
-
if (!parent || index === undefined) return
|
|
12
|
-
if (!node.value.includes("%%")) return
|
|
13
|
-
|
|
14
|
-
const matches = [...node.value.matchAll(COMMENT_REGEX)]
|
|
15
|
-
if (!matches.length) return
|
|
16
|
-
|
|
17
|
-
const nodes: typeof parent.children = []
|
|
18
|
-
let lastIndex = 0
|
|
19
|
-
|
|
20
|
-
for (const match of matches) {
|
|
21
|
-
const [full] = match
|
|
22
|
-
const matchIndex = match.index!
|
|
23
|
-
if (matchIndex > lastIndex) {
|
|
24
|
-
nodes.push({ type: "text", value: node.value.slice(lastIndex, matchIndex) })
|
|
25
|
-
}
|
|
26
|
-
// strip the comment - push nothing
|
|
27
|
-
lastIndex = matchIndex + full.length
|
|
28
|
-
}
|
|
29
|
-
if (lastIndex < node.value.length) {
|
|
30
|
-
nodes.push({ type: "text", value: node.value.slice(lastIndex) })
|
|
31
|
-
}
|
|
32
|
-
parent.children.splice(index, 1, ...(nodes as any))
|
|
33
|
-
})
|
|
34
|
-
}
|
|
35
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { unified } from "unified"
|
|
3
|
-
import remarkParse from "remark-parse"
|
|
4
|
-
import remarkRehype from "remark-rehype"
|
|
5
|
-
import rehypeStringify from "rehype-stringify"
|
|
6
|
-
import { remarkHighlight } from "./highlight.js"
|
|
7
|
-
|
|
8
|
-
async function process(md: string): Promise<string> {
|
|
9
|
-
const result = await unified()
|
|
10
|
-
.use(remarkParse)
|
|
11
|
-
.use(remarkHighlight)
|
|
12
|
-
.use(remarkRehype, { allowDangerousHtml: true })
|
|
13
|
-
.use(rehypeStringify, { allowDangerousHtml: true })
|
|
14
|
-
.process(md)
|
|
15
|
-
return String(result)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
describe("remarkHighlight", () => {
|
|
19
|
-
it("wraps ==text== in <mark> tags", async () => {
|
|
20
|
-
const html = await process("This is ==highlighted== text")
|
|
21
|
-
expect(html).toContain("<mark>highlighted</mark>")
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it("handles multiple highlights in one line", async () => {
|
|
25
|
-
const html = await process("==first== and ==second==")
|
|
26
|
-
expect(html).toContain("<mark>first</mark>")
|
|
27
|
-
expect(html).toContain("<mark>second</mark>")
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it("preserves surrounding text", async () => {
|
|
31
|
-
const html = await process("before ==middle== after")
|
|
32
|
-
expect(html).toContain("before ")
|
|
33
|
-
expect(html).toContain("<mark>middle</mark>")
|
|
34
|
-
expect(html).toContain(" after")
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
it("does not match single = signs", async () => {
|
|
38
|
-
const html = await process("a = b")
|
|
39
|
-
expect(html).not.toContain("<mark>")
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it("does not match across newlines", async () => {
|
|
43
|
-
const html = await process("==start\nend==")
|
|
44
|
-
expect(html).not.toContain("<mark>")
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it("leaves text without highlights unchanged", async () => {
|
|
48
|
-
const html = await process("No highlights here")
|
|
49
|
-
expect(html).not.toContain("<mark>")
|
|
50
|
-
expect(html).toContain("No highlights here")
|
|
51
|
-
})
|
|
52
|
-
})
|
package/src/plugins/highlight.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { visit } from "unist-util-visit"
|
|
2
|
-
import type { Root, Text } from "mdast"
|
|
3
|
-
import type { Plugin } from "unified"
|
|
4
|
-
|
|
5
|
-
const HIGHLIGHT_REGEX = /==([^=\n]+)==/g
|
|
6
|
-
|
|
7
|
-
export const remarkHighlight: Plugin<[], Root> = () => {
|
|
8
|
-
return (tree) => {
|
|
9
|
-
visit(tree, "text", (node: Text, index, parent) => {
|
|
10
|
-
if (!parent || index === undefined) return
|
|
11
|
-
const matches = [...node.value.matchAll(HIGHLIGHT_REGEX)]
|
|
12
|
-
if (!matches.length) return
|
|
13
|
-
|
|
14
|
-
const nodes: typeof parent.children = []
|
|
15
|
-
let lastIndex = 0
|
|
16
|
-
|
|
17
|
-
for (const match of matches) {
|
|
18
|
-
const [full, content] = match
|
|
19
|
-
const matchIndex = match.index!
|
|
20
|
-
if (matchIndex > lastIndex) {
|
|
21
|
-
nodes.push({ type: "text", value: node.value.slice(lastIndex, matchIndex) })
|
|
22
|
-
}
|
|
23
|
-
nodes.push({ type: "html", value: `<mark>${content}</mark>` })
|
|
24
|
-
lastIndex = matchIndex + full.length
|
|
25
|
-
}
|
|
26
|
-
if (lastIndex < node.value.length) {
|
|
27
|
-
nodes.push({ type: "text", value: node.value.slice(lastIndex) })
|
|
28
|
-
}
|
|
29
|
-
parent.children.splice(index, 1, ...(nodes as any))
|
|
30
|
-
})
|
|
31
|
-
}
|
|
32
|
-
}
|
package/src/plugins/tag.test.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { unified } from "unified"
|
|
3
|
-
import remarkParse from "remark-parse"
|
|
4
|
-
import remarkRehype from "remark-rehype"
|
|
5
|
-
import rehypeStringify from "rehype-stringify"
|
|
6
|
-
import { remarkTag } from "./tag.js"
|
|
7
|
-
|
|
8
|
-
async function process(md: string): Promise<string> {
|
|
9
|
-
const result = await unified()
|
|
10
|
-
.use(remarkParse)
|
|
11
|
-
.use(remarkTag)
|
|
12
|
-
.use(remarkRehype)
|
|
13
|
-
.use(rehypeStringify)
|
|
14
|
-
.process(md)
|
|
15
|
-
return String(result)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async function processWithData(md: string) {
|
|
19
|
-
const file = await unified()
|
|
20
|
-
.use(remarkParse)
|
|
21
|
-
.use(remarkTag)
|
|
22
|
-
.use(remarkRehype)
|
|
23
|
-
.use(rehypeStringify)
|
|
24
|
-
.process(md)
|
|
25
|
-
return { html: String(file), tags: (file.data as Record<string, unknown>).tags as string[] ?? [] }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe("remarkTag", () => {
|
|
29
|
-
it("transforms #mytag into a tag link", async () => {
|
|
30
|
-
const html = await process("Hello #mytag world")
|
|
31
|
-
expect(html).toContain('href="/tags/mytag"')
|
|
32
|
-
expect(html).toContain('class="tag"')
|
|
33
|
-
expect(html).toContain("#mytag")
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it("does not transform #123invalid (starts with number)", async () => {
|
|
37
|
-
const html = await process("Hello #123invalid world")
|
|
38
|
-
expect(html).not.toContain('class="tag"')
|
|
39
|
-
expect(html).toContain("#123invalid")
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it("transforms #tag-with-hyphen", async () => {
|
|
43
|
-
const html = await process("A #tag-with-hyphen here")
|
|
44
|
-
expect(html).toContain('href="/tags/tag-with-hyphen"')
|
|
45
|
-
expect(html).toContain('class="tag"')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it("transforms #tag_with_underscore", async () => {
|
|
49
|
-
const html = await process("A #tag_with_underscore here")
|
|
50
|
-
expect(html).toContain('href="/tags/tag_with_underscore"')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it("handles multiple tags in one paragraph", async () => {
|
|
54
|
-
const html = await process("Tags: #first and #second")
|
|
55
|
-
expect(html).toContain('href="/tags/first"')
|
|
56
|
-
expect(html).toContain('href="/tags/second"')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it("collects tags into file.data.tags", async () => {
|
|
60
|
-
const { tags } = await processWithData("Hello #alpha and #beta")
|
|
61
|
-
expect(tags).toContain("alpha")
|
|
62
|
-
expect(tags).toContain("beta")
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it("deduplicates collected tags", async () => {
|
|
66
|
-
const { tags } = await processWithData("#same #same #same")
|
|
67
|
-
expect(tags).toEqual(["same"])
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it("preserves surrounding text", async () => {
|
|
71
|
-
const html = await process("before #tag after")
|
|
72
|
-
expect(html).toContain("before ")
|
|
73
|
-
expect(html).toContain(" after")
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
it("sets data-tag attribute", async () => {
|
|
77
|
-
const html = await process("#example")
|
|
78
|
-
expect(html).toContain('data-tag="example"')
|
|
79
|
-
})
|
|
80
|
-
})
|
package/src/plugins/tag.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { visit } from "unist-util-visit"
|
|
2
|
-
import type { Root, Text, PhrasingContent } from "mdast"
|
|
3
|
-
import type { Plugin } from "unified"
|
|
4
|
-
|
|
5
|
-
// #tag — must start with letter, can contain letters, digits, -, _
|
|
6
|
-
const TAG_REGEX = /#([a-zA-Z][a-zA-Z0-9_-]*)/g
|
|
7
|
-
|
|
8
|
-
export interface TagPluginResult {
|
|
9
|
-
tags: string[]
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Transforms inline #tags into links.
|
|
14
|
-
* Collected tags are stored on the vfile data: `file.data.tags`
|
|
15
|
-
*/
|
|
16
|
-
export const remarkTag: Plugin<[], Root> = () => {
|
|
17
|
-
return (tree, file) => {
|
|
18
|
-
const collected: string[] = []
|
|
19
|
-
|
|
20
|
-
visit(tree, "text", (node: Text, index, parent) => {
|
|
21
|
-
if (!parent || index === undefined) return
|
|
22
|
-
|
|
23
|
-
const matches = [...node.value.matchAll(TAG_REGEX)]
|
|
24
|
-
if (!matches.length) return
|
|
25
|
-
|
|
26
|
-
const nodes: PhrasingContent[] = []
|
|
27
|
-
let lastIndex = 0
|
|
28
|
-
|
|
29
|
-
for (const match of matches) {
|
|
30
|
-
const [full, tag] = match
|
|
31
|
-
const matchIndex = match.index!
|
|
32
|
-
|
|
33
|
-
if (matchIndex > lastIndex) {
|
|
34
|
-
nodes.push({ type: "text", value: node.value.slice(lastIndex, matchIndex) })
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
collected.push(tag)
|
|
38
|
-
nodes.push({
|
|
39
|
-
type: "link",
|
|
40
|
-
url: `/tags/${tag}`,
|
|
41
|
-
data: { hProperties: { className: "tag", "data-tag": tag } },
|
|
42
|
-
children: [{ type: "text", value: `#${tag}` }],
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
lastIndex = matchIndex + full.length
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (lastIndex < node.value.length) {
|
|
49
|
-
nodes.push({ type: "text", value: node.value.slice(lastIndex) })
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
parent.children.splice(index, 1, ...nodes)
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
const existing = (file.data.tags as string[] | undefined) ?? []
|
|
56
|
-
file.data.tags = [...new Set([...existing, ...collected])]
|
|
57
|
-
}
|
|
58
|
-
}
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { unified } from "unified"
|
|
3
|
-
import remarkParse from "remark-parse"
|
|
4
|
-
import remarkRehype from "remark-rehype"
|
|
5
|
-
import rehypeStringify from "rehype-stringify"
|
|
6
|
-
import { remarkWikilink } from "./wikilink.js"
|
|
7
|
-
|
|
8
|
-
async function process(md: string, options?: Parameters<typeof remarkWikilink>[0]): Promise<string> {
|
|
9
|
-
const result = await unified()
|
|
10
|
-
.use(remarkParse)
|
|
11
|
-
.use(remarkWikilink, options)
|
|
12
|
-
.use(remarkRehype)
|
|
13
|
-
.use(rehypeStringify)
|
|
14
|
-
.process(md)
|
|
15
|
-
return String(result)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async function processWithData(md: string, options?: Parameters<typeof remarkWikilink>[0]) {
|
|
19
|
-
const file = await unified()
|
|
20
|
-
.use(remarkParse)
|
|
21
|
-
.use(remarkWikilink, options)
|
|
22
|
-
.use(remarkRehype)
|
|
23
|
-
.use(rehypeStringify)
|
|
24
|
-
.process(md)
|
|
25
|
-
return { html: String(file), links: (file.data as Record<string, unknown>).links as string[] ?? [] }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe("remarkWikilink", () => {
|
|
29
|
-
it("transforms [[Page Name]] into a wikilink", async () => {
|
|
30
|
-
const html = await process("Visit [[Page Name]] for details")
|
|
31
|
-
expect(html).toContain('href="/page-name"')
|
|
32
|
-
expect(html).toContain('class="wikilink"')
|
|
33
|
-
expect(html).toContain("Page Name")
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it("handles alias [[Page|Alias]]", async () => {
|
|
37
|
-
const html = await process("See [[Page|Custom Display]]")
|
|
38
|
-
expect(html).toContain('href="/page"')
|
|
39
|
-
expect(html).toContain("Custom Display")
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it("handles heading [[Page#Heading]]", async () => {
|
|
43
|
-
const html = await process("Go to [[Page#My Section]]")
|
|
44
|
-
expect(html).toContain('href="/page#my-section"')
|
|
45
|
-
expect(html).toContain("Page > My Section")
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it("handles heading with alias [[Page#Heading|Alias]]", async () => {
|
|
49
|
-
const html = await process("See [[Page#Section|Link Text]]")
|
|
50
|
-
expect(html).toContain('href="/page#section"')
|
|
51
|
-
expect(html).toContain("Link Text")
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it("transforms ![[image.png]] into an embed image", async () => {
|
|
55
|
-
const html = await process("Here: ![[image.png]]")
|
|
56
|
-
expect(html).toContain("<img")
|
|
57
|
-
expect(html).toContain('src="/api/content/image.png"')
|
|
58
|
-
expect(html).toContain('class="embed-image"')
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it("converts spaces to hyphens in href", async () => {
|
|
62
|
-
const html = await process("[[My Long Page Name]]")
|
|
63
|
-
expect(html).toContain('href="/my-long-page-name"')
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it("accumulates links in file.data.links", async () => {
|
|
67
|
-
const { links } = await processWithData("[[Page A]] and [[Page B]]")
|
|
68
|
-
expect(links).toContain("Page A")
|
|
69
|
-
expect(links).toContain("Page B")
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it("deduplicates collected links", async () => {
|
|
73
|
-
const { links } = await processWithData("[[Same]] and [[Same]]")
|
|
74
|
-
expect(links).toEqual(["Same"])
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it("does not add embeds to outgoing links", async () => {
|
|
78
|
-
const { links } = await processWithData("![[image.png]]")
|
|
79
|
-
expect(links).toEqual([])
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it("supports custom resolve function", async () => {
|
|
83
|
-
const html = await process("[[My Page]]", {
|
|
84
|
-
resolve: (target) => `/custom/${target.toLowerCase().replace(/\s+/g, "_")}`,
|
|
85
|
-
})
|
|
86
|
-
expect(html).toContain('href="/custom/my_page"')
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it("supports custom baseUrl", async () => {
|
|
90
|
-
const html = await process("[[Page]]", { baseUrl: "/wiki/" })
|
|
91
|
-
expect(html).toContain('href="/wiki/page"')
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it("sets data-target attribute", async () => {
|
|
95
|
-
const html = await process("[[My Page]]")
|
|
96
|
-
expect(html).toContain('data-target="My Page"')
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it("sets data-heading attribute when heading present", async () => {
|
|
100
|
-
const html = await process("[[Page#Section]]")
|
|
101
|
-
expect(html).toContain('data-heading="Section"')
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it("preserves surrounding text", async () => {
|
|
105
|
-
const html = await process("before [[Link]] after")
|
|
106
|
-
expect(html).toContain("before ")
|
|
107
|
-
expect(html).toContain(" after")
|
|
108
|
-
})
|
|
109
|
-
})
|