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,113 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { renderMarkdown } from "../markdown"
|
|
3
|
-
|
|
4
|
-
describe("renderMarkdown", () => {
|
|
5
|
-
it("returns empty result for empty string", async () => {
|
|
6
|
-
const result = await renderMarkdown("")
|
|
7
|
-
expect(result.html).toBe("")
|
|
8
|
-
expect(result.toc).toEqual([])
|
|
9
|
-
expect(result.tags).toEqual([])
|
|
10
|
-
expect(result.links).toEqual([])
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
it("extracts heading into toc", async () => {
|
|
14
|
-
const result = await renderMarkdown("# Hello World")
|
|
15
|
-
expect(result.toc).toHaveLength(1)
|
|
16
|
-
expect(result.toc[0]).toMatchObject({
|
|
17
|
-
depth: 1,
|
|
18
|
-
text: "Hello World",
|
|
19
|
-
})
|
|
20
|
-
expect(result.toc[0].id).toBeTruthy()
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it("builds nested toc tree", async () => {
|
|
24
|
-
const md = "# Top\n## Sub\n### Deep"
|
|
25
|
-
const result = await renderMarkdown(md)
|
|
26
|
-
expect(result.toc).toHaveLength(1)
|
|
27
|
-
expect(result.toc[0].text).toBe("Top")
|
|
28
|
-
expect(result.toc[0].children).toHaveLength(1)
|
|
29
|
-
expect(result.toc[0].children[0].text).toBe("Sub")
|
|
30
|
-
expect(result.toc[0].children[0].children).toHaveLength(1)
|
|
31
|
-
expect(result.toc[0].children[0].children[0].text).toBe("Deep")
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it("parses frontmatter", async () => {
|
|
35
|
-
const md = `---
|
|
36
|
-
title: My Page
|
|
37
|
-
date: 2024-01-01
|
|
38
|
-
---
|
|
39
|
-
|
|
40
|
-
Some content`
|
|
41
|
-
const result = await renderMarkdown(md)
|
|
42
|
-
expect(result.frontmatter.title).toBe("My Page")
|
|
43
|
-
// gray-matter parses YAML dates as Date objects
|
|
44
|
-
const date = result.frontmatter.date
|
|
45
|
-
const dateStr = date instanceof Date ? date.toISOString().split("T")[0] : date
|
|
46
|
-
expect(dateStr).toBe("2024-01-01")
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it("extracts wikilinks into links array", async () => {
|
|
50
|
-
const result = await renderMarkdown("Check [[Some Page]] for details")
|
|
51
|
-
expect(result.links).toContain("Some Page")
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it("extracts wikilink with alias", async () => {
|
|
55
|
-
const result = await renderMarkdown("See [[Target|display text]]")
|
|
56
|
-
expect(result.links).toContain("Target")
|
|
57
|
-
expect(result.html).toContain("display text")
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it("extracts inline tags", async () => {
|
|
61
|
-
const result = await renderMarkdown("This has #mytag in it")
|
|
62
|
-
expect(result.tags).toContain("mytag")
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it("merges frontmatter tags with inline tags and deduplicates", async () => {
|
|
66
|
-
const md = `---
|
|
67
|
-
tags:
|
|
68
|
-
- alpha
|
|
69
|
-
- beta
|
|
70
|
-
---
|
|
71
|
-
|
|
72
|
-
Content with #beta and #gamma`
|
|
73
|
-
const result = await renderMarkdown(md)
|
|
74
|
-
expect(result.tags).toContain("alpha")
|
|
75
|
-
expect(result.tags).toContain("beta")
|
|
76
|
-
expect(result.tags).toContain("gamma")
|
|
77
|
-
// beta should appear only once
|
|
78
|
-
const betaCount = result.tags.filter((t) => t === "beta").length
|
|
79
|
-
expect(betaCount).toBe(1)
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it("renders callout with correct class", async () => {
|
|
83
|
-
const md = "> [!note]\n> Some note content"
|
|
84
|
-
const result = await renderMarkdown(md)
|
|
85
|
-
expect(result.html).toContain("callout")
|
|
86
|
-
expect(result.html).toContain("callout-note")
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it("renders LaTeX with KaTeX", async () => {
|
|
90
|
-
const result = await renderMarkdown("Inline math: $x^2$")
|
|
91
|
-
expect(result.html).toContain("katex")
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it("renders display math with KaTeX", async () => {
|
|
95
|
-
const result = await renderMarkdown("$$\nx^2 + y^2 = z^2\n$$")
|
|
96
|
-
expect(result.html).toContain("katex")
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it("generates html for basic markdown", async () => {
|
|
100
|
-
const result = await renderMarkdown("Hello **world**")
|
|
101
|
-
expect(result.html).toContain("<strong>world</strong>")
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it("does not include embed links in outgoing links", async () => {
|
|
105
|
-
const result = await renderMarkdown("![[image.png]]")
|
|
106
|
-
expect(result.links).not.toContain("image.png")
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it("adds slug ids to headings via rehype-slug", async () => {
|
|
110
|
-
const result = await renderMarkdown("## My Section")
|
|
111
|
-
expect(result.html).toContain('id="my-section"')
|
|
112
|
-
})
|
|
113
|
-
})
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { buildSearchIndex } from "../search"
|
|
3
|
-
import type { MarkdownFile } from "../fs"
|
|
4
|
-
|
|
5
|
-
function makeFile(overrides: Partial<MarkdownFile> & { slug: string }): MarkdownFile {
|
|
6
|
-
return {
|
|
7
|
-
filePath: `/${overrides.slug}.md`,
|
|
8
|
-
frontmatter: {},
|
|
9
|
-
raw: "",
|
|
10
|
-
...overrides,
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
describe("buildSearchIndex", () => {
|
|
15
|
-
it("returns SearchEntry[] with slug, title, content, tags", () => {
|
|
16
|
-
const files: MarkdownFile[] = [
|
|
17
|
-
makeFile({
|
|
18
|
-
slug: "hello",
|
|
19
|
-
frontmatter: { title: "Hello", tags: ["greeting"] },
|
|
20
|
-
raw: "---\ntitle: Hello\ntags:\n - greeting\n---\nSome body text",
|
|
21
|
-
}),
|
|
22
|
-
]
|
|
23
|
-
|
|
24
|
-
const index = buildSearchIndex(files)
|
|
25
|
-
expect(index).toHaveLength(1)
|
|
26
|
-
expect(index[0].slug).toBe("hello")
|
|
27
|
-
expect(index[0].title).toBe("Hello")
|
|
28
|
-
expect(index[0].content).toContain("Some body text")
|
|
29
|
-
expect(index[0].tags).toEqual(["greeting"])
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it("excludes draft:true files", () => {
|
|
33
|
-
const files: MarkdownFile[] = [
|
|
34
|
-
makeFile({
|
|
35
|
-
slug: "published",
|
|
36
|
-
frontmatter: { title: "Published" },
|
|
37
|
-
raw: "Public content",
|
|
38
|
-
}),
|
|
39
|
-
makeFile({
|
|
40
|
-
slug: "draft",
|
|
41
|
-
frontmatter: { title: "Draft", draft: true },
|
|
42
|
-
raw: "---\ntitle: Draft\ndraft: true\n---\nDraft content",
|
|
43
|
-
}),
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
const index = buildSearchIndex(files)
|
|
47
|
-
expect(index).toHaveLength(1)
|
|
48
|
-
expect(index[0].slug).toBe("published")
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it("strips heading markers from content", () => {
|
|
52
|
-
const files: MarkdownFile[] = [
|
|
53
|
-
makeFile({
|
|
54
|
-
slug: "headings",
|
|
55
|
-
raw: "# Heading One\n## Heading Two\nBody",
|
|
56
|
-
}),
|
|
57
|
-
]
|
|
58
|
-
|
|
59
|
-
const index = buildSearchIndex(files)
|
|
60
|
-
expect(index[0].content).not.toMatch(/^#/)
|
|
61
|
-
expect(index[0].content).toContain("Heading One")
|
|
62
|
-
expect(index[0].content).toContain("Body")
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it("strips bold and italic markers", () => {
|
|
66
|
-
const files: MarkdownFile[] = [
|
|
67
|
-
makeFile({
|
|
68
|
-
slug: "emphasis",
|
|
69
|
-
raw: "Some **bold** and *italic* and __also__ text",
|
|
70
|
-
}),
|
|
71
|
-
]
|
|
72
|
-
|
|
73
|
-
const index = buildSearchIndex(files)
|
|
74
|
-
expect(index[0].content).not.toContain("**")
|
|
75
|
-
expect(index[0].content).not.toContain("*")
|
|
76
|
-
expect(index[0].content).not.toContain("__")
|
|
77
|
-
expect(index[0].content).toContain("bold")
|
|
78
|
-
expect(index[0].content).toContain("italic")
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it("strips inline code", () => {
|
|
82
|
-
const files: MarkdownFile[] = [
|
|
83
|
-
makeFile({
|
|
84
|
-
slug: "code",
|
|
85
|
-
raw: "Use `console.log` to debug",
|
|
86
|
-
}),
|
|
87
|
-
]
|
|
88
|
-
|
|
89
|
-
const index = buildSearchIndex(files)
|
|
90
|
-
expect(index[0].content).not.toContain("`")
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it("strips wikilinks keeping display text", () => {
|
|
94
|
-
const files: MarkdownFile[] = [
|
|
95
|
-
makeFile({
|
|
96
|
-
slug: "links",
|
|
97
|
-
raw: "See [[My Page]] and [[Other|display]]",
|
|
98
|
-
}),
|
|
99
|
-
]
|
|
100
|
-
|
|
101
|
-
const index = buildSearchIndex(files)
|
|
102
|
-
// wikilink regex captures target (first group), not alias
|
|
103
|
-
// [[My Page]] → "My Page", [[Other|display]] → "Other"
|
|
104
|
-
expect(index[0].content).toContain("My Page")
|
|
105
|
-
expect(index[0].content).toContain("Other")
|
|
106
|
-
expect(index[0].content).not.toContain("[[")
|
|
107
|
-
expect(index[0].content).not.toContain("]]")
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it("uses last slug segment as title fallback", () => {
|
|
111
|
-
const files: MarkdownFile[] = [
|
|
112
|
-
makeFile({
|
|
113
|
-
slug: "notes/deep/my-page",
|
|
114
|
-
frontmatter: {},
|
|
115
|
-
raw: "Content without title",
|
|
116
|
-
}),
|
|
117
|
-
]
|
|
118
|
-
|
|
119
|
-
const index = buildSearchIndex(files)
|
|
120
|
-
expect(index[0].title).toBe("my-page")
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
it("includes frontmatter tags", () => {
|
|
124
|
-
const files: MarkdownFile[] = [
|
|
125
|
-
makeFile({
|
|
126
|
-
slug: "tagged",
|
|
127
|
-
frontmatter: { tags: ["alpha", "beta"] },
|
|
128
|
-
raw: "---\ntags:\n - alpha\n - beta\n---\nContent",
|
|
129
|
-
}),
|
|
130
|
-
]
|
|
131
|
-
|
|
132
|
-
const index = buildSearchIndex(files)
|
|
133
|
-
expect(index[0].tags).toEqual(["alpha", "beta"])
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
it("includes description from frontmatter", () => {
|
|
137
|
-
const files: MarkdownFile[] = [
|
|
138
|
-
makeFile({
|
|
139
|
-
slug: "desc",
|
|
140
|
-
frontmatter: { title: "Desc", description: "A short summary" },
|
|
141
|
-
raw: "---\ntitle: Desc\ndescription: A short summary\n---\nBody",
|
|
142
|
-
}),
|
|
143
|
-
]
|
|
144
|
-
|
|
145
|
-
const index = buildSearchIndex(files)
|
|
146
|
-
expect(index[0].description).toBe("A short summary")
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
it("returns empty array for empty input", () => {
|
|
150
|
-
const index = buildSearchIndex([])
|
|
151
|
-
expect(index).toEqual([])
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
it("strips code blocks from content", () => {
|
|
155
|
-
const files: MarkdownFile[] = [
|
|
156
|
-
makeFile({
|
|
157
|
-
slug: "codeblock",
|
|
158
|
-
raw: "Before\n```js\nconsole.log('hi')\n```\nAfter",
|
|
159
|
-
}),
|
|
160
|
-
]
|
|
161
|
-
|
|
162
|
-
const index = buildSearchIndex(files)
|
|
163
|
-
expect(index[0].content).not.toContain("console.log")
|
|
164
|
-
expect(index[0].content).toContain("Before")
|
|
165
|
-
expect(index[0].content).toContain("After")
|
|
166
|
-
})
|
|
167
|
-
})
|
package/src/backlinks.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import type { RenderResult } from "./types.js"
|
|
2
|
-
|
|
3
|
-
export interface BacklinkEntry {
|
|
4
|
-
slug: string
|
|
5
|
-
title: string
|
|
6
|
-
excerpt: string
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export type BacklinkIndex = Map<string, BacklinkEntry[]>
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Builds a backlink index from a collection of rendered pages.
|
|
13
|
-
*
|
|
14
|
-
* For each page, its outgoing wikilinks are read and the page is registered
|
|
15
|
-
* as a backlink on every target it points to.
|
|
16
|
-
*
|
|
17
|
-
* @param pages - Map of slug → { result, raw content }
|
|
18
|
-
* @returns Map of slug → pages that link to it
|
|
19
|
-
*/
|
|
20
|
-
export function buildBacklinkIndex(
|
|
21
|
-
pages: Map<string, { result: RenderResult; raw: string }>
|
|
22
|
-
): BacklinkIndex {
|
|
23
|
-
const index: BacklinkIndex = new Map()
|
|
24
|
-
|
|
25
|
-
for (const [slug, { result, raw }] of pages) {
|
|
26
|
-
const title = result.frontmatter.title ?? slug
|
|
27
|
-
const excerpt = raw.replace(/^---[\s\S]*?---/, "").trim().slice(0, 160) + "…"
|
|
28
|
-
|
|
29
|
-
for (const target of result.links) {
|
|
30
|
-
const normalizedTarget = target
|
|
31
|
-
.toLowerCase()
|
|
32
|
-
.replace(/\s+/g, "-")
|
|
33
|
-
.replace(/[^\w-]/g, "")
|
|
34
|
-
|
|
35
|
-
const existing = index.get(normalizedTarget) ?? []
|
|
36
|
-
existing.push({ slug, title, excerpt })
|
|
37
|
-
index.set(normalizedTarget, existing)
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return index
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Looks up backlinks for a given slug.
|
|
46
|
-
*/
|
|
47
|
-
export function getBacklinks(
|
|
48
|
-
index: BacklinkIndex,
|
|
49
|
-
slug: string
|
|
50
|
-
): BacklinkEntry[] {
|
|
51
|
-
return index.get(slug) ?? []
|
|
52
|
-
}
|
package/src/config.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import type { RenderOptions } from "./types.js"
|
|
2
|
-
|
|
3
|
-
export interface NuartzConfig {
|
|
4
|
-
/** Absolute path to the content directory */
|
|
5
|
-
contentDir: string
|
|
6
|
-
|
|
7
|
-
/** Site metadata */
|
|
8
|
-
site: {
|
|
9
|
-
title: string
|
|
10
|
-
description?: string
|
|
11
|
-
/** Production URL, e.g. "https://example.com". Defaults to "http://localhost:3000". */
|
|
12
|
-
baseUrl?: string
|
|
13
|
-
locale?: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Markdown rendering options */
|
|
17
|
-
markdown?: RenderOptions
|
|
18
|
-
|
|
19
|
-
/** Features to enable/disable */
|
|
20
|
-
features?: {
|
|
21
|
-
wikilinks?: boolean
|
|
22
|
-
callouts?: boolean
|
|
23
|
-
tags?: boolean
|
|
24
|
-
backlinks?: boolean
|
|
25
|
-
toc?: boolean
|
|
26
|
-
search?: boolean
|
|
27
|
-
darkMode?: boolean
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* What to show on the home page (`/`).
|
|
32
|
-
* - `"index"` — render `content/index.md` (default). Falls back to recent notes if the file doesn't exist.
|
|
33
|
-
* - `"recent"` — show a list of all notes sorted by date.
|
|
34
|
-
*/
|
|
35
|
-
homePage?: "index" | "recent"
|
|
36
|
-
|
|
37
|
-
/** Navigation */
|
|
38
|
-
nav?: {
|
|
39
|
-
/** Extra links in the header */
|
|
40
|
-
links?: Array<{ label: string; href: string; external?: boolean }>
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const DEFAULT_FEATURES: Required<NuartzConfig["features"]> = {
|
|
45
|
-
wikilinks: true,
|
|
46
|
-
callouts: true,
|
|
47
|
-
tags: true,
|
|
48
|
-
backlinks: true,
|
|
49
|
-
toc: true,
|
|
50
|
-
search: true,
|
|
51
|
-
darkMode: true,
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function defineConfig(config: NuartzConfig): NuartzConfig & { site: { baseUrl: string } } {
|
|
55
|
-
return {
|
|
56
|
-
...config,
|
|
57
|
-
site: {
|
|
58
|
-
baseUrl: "http://localhost:3000",
|
|
59
|
-
...config.site,
|
|
60
|
-
},
|
|
61
|
-
features: { ...DEFAULT_FEATURES, ...config.features },
|
|
62
|
-
}
|
|
63
|
-
}
|
package/src/fs.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises"
|
|
2
|
-
import path from "node:path"
|
|
3
|
-
import matter from "gray-matter"
|
|
4
|
-
import type { Frontmatter } from "./types.js"
|
|
5
|
-
|
|
6
|
-
export interface MarkdownFile {
|
|
7
|
-
slug: string // relative path without .md, e.g. "notes/foo"
|
|
8
|
-
filePath: string // absolute file path
|
|
9
|
-
frontmatter: Frontmatter
|
|
10
|
-
raw: string
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Recursively walks a directory and returns all .md files.
|
|
15
|
-
*/
|
|
16
|
-
export async function getAllMarkdownFiles(
|
|
17
|
-
contentDir: string
|
|
18
|
-
): Promise<MarkdownFile[]> {
|
|
19
|
-
const results: MarkdownFile[] = []
|
|
20
|
-
|
|
21
|
-
async function walk(dir: string) {
|
|
22
|
-
let entries: { name: string; isDirectory(): boolean }[]
|
|
23
|
-
try {
|
|
24
|
-
entries = await fs.readdir(dir, { withFileTypes: true }) as { name: string; isDirectory(): boolean }[]
|
|
25
|
-
} catch {
|
|
26
|
-
return
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
for (const entry of entries) {
|
|
30
|
-
const fullPath = path.join(dir, entry.name)
|
|
31
|
-
if (entry.isDirectory()) {
|
|
32
|
-
await walk(fullPath)
|
|
33
|
-
} else if (entry.name.endsWith(".md") && !entry.name.startsWith("_")) {
|
|
34
|
-
const raw = await fs.readFile(fullPath, "utf-8")
|
|
35
|
-
const { data } = matter(raw)
|
|
36
|
-
|
|
37
|
-
// Skip draft or unpublished files
|
|
38
|
-
if (data.draft === true || data.published === false) continue
|
|
39
|
-
|
|
40
|
-
const relative = path.relative(contentDir, fullPath)
|
|
41
|
-
const slug = relative.replace(/\.md$/, "").replace(/\\/g, "/")
|
|
42
|
-
results.push({
|
|
43
|
-
slug,
|
|
44
|
-
filePath: fullPath,
|
|
45
|
-
frontmatter: data as Frontmatter,
|
|
46
|
-
raw,
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
await walk(contentDir)
|
|
53
|
-
return results
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Reads a single markdown file by slug.
|
|
58
|
-
* Returns null if the file doesn't exist.
|
|
59
|
-
*/
|
|
60
|
-
export async function getMarkdownBySlug(
|
|
61
|
-
contentDir: string,
|
|
62
|
-
slug: string
|
|
63
|
-
): Promise<MarkdownFile | null> {
|
|
64
|
-
const filePath = path.join(contentDir, slug + ".md")
|
|
65
|
-
try {
|
|
66
|
-
const raw = await fs.readFile(filePath, "utf-8")
|
|
67
|
-
const { data } = matter(raw)
|
|
68
|
-
return { slug, filePath, frontmatter: data as Frontmatter, raw }
|
|
69
|
-
} catch {
|
|
70
|
-
return null
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export interface FileTreeNode {
|
|
75
|
-
name: string
|
|
76
|
-
path: string
|
|
77
|
-
type: "file" | "folder"
|
|
78
|
-
children?: FileTreeNode[]
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Builds a nested file tree from a flat list of MarkdownFile entries.
|
|
83
|
-
*/
|
|
84
|
-
export function buildFileTree(files: MarkdownFile[]): FileTreeNode[] {
|
|
85
|
-
const root: FileTreeNode[] = []
|
|
86
|
-
const nodeMap = new Map<string, FileTreeNode>()
|
|
87
|
-
|
|
88
|
-
const sortedFiles = [...files].sort((a, b) => a.slug.localeCompare(b.slug))
|
|
89
|
-
|
|
90
|
-
for (const file of sortedFiles) {
|
|
91
|
-
const parts = file.slug.split("/")
|
|
92
|
-
let parentList = root
|
|
93
|
-
|
|
94
|
-
for (let i = 0; i < parts.length; i++) {
|
|
95
|
-
const part = parts[i]
|
|
96
|
-
const partPath = parts.slice(0, i + 1).join("/")
|
|
97
|
-
const isLast = i === parts.length - 1
|
|
98
|
-
|
|
99
|
-
if (isLast) {
|
|
100
|
-
const node: FileTreeNode = {
|
|
101
|
-
name: file.frontmatter.title ?? part,
|
|
102
|
-
path: file.slug,
|
|
103
|
-
type: "file",
|
|
104
|
-
}
|
|
105
|
-
parentList.push(node)
|
|
106
|
-
} else {
|
|
107
|
-
let folderNode = nodeMap.get(partPath)
|
|
108
|
-
if (!folderNode) {
|
|
109
|
-
folderNode = { name: part, path: partPath, type: "folder", children: [] }
|
|
110
|
-
nodeMap.set(partPath, folderNode)
|
|
111
|
-
parentList.push(folderNode)
|
|
112
|
-
}
|
|
113
|
-
parentList = folderNode.children!
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Sort each level: folders first, then files; alphabetically within each group
|
|
119
|
-
function sortNodes(nodes: FileTreeNode[]): FileTreeNode[] {
|
|
120
|
-
nodes.sort((a, b) => {
|
|
121
|
-
if (a.type !== b.type) return a.type === "folder" ? -1 : 1
|
|
122
|
-
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
123
|
-
})
|
|
124
|
-
for (const node of nodes) {
|
|
125
|
-
if (node.children) sortNodes(node.children)
|
|
126
|
-
}
|
|
127
|
-
return nodes
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return sortNodes(root)
|
|
131
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
export { renderMarkdown } from "./markdown.js"
|
|
2
|
-
export { buildBacklinkIndex, getBacklinks } from "./backlinks.js"
|
|
3
|
-
export { getAllMarkdownFiles, getMarkdownBySlug, buildFileTree } from "./fs.js"
|
|
4
|
-
export { buildSearchIndex } from "./search.js"
|
|
5
|
-
export { defineConfig } from "./config.js"
|
|
6
|
-
export type { NuartzConfig } from "./config.js"
|
|
7
|
-
export type { SearchEntry } from "./search.js"
|
|
8
|
-
export type { MarkdownFile, FileTreeNode } from "./fs.js"
|
|
9
|
-
export type { BacklinkEntry, BacklinkIndex } from "./backlinks.js"
|
|
10
|
-
export { remarkCallout } from "./plugins/callout.js"
|
|
11
|
-
export { remarkTag } from "./plugins/tag.js"
|
|
12
|
-
export { remarkWikilink } from "./plugins/wikilink.js"
|
|
13
|
-
export { remarkHighlight } from "./plugins/highlight.js"
|
|
14
|
-
export { remarkObsidianComment } from "./plugins/comment.js"
|
|
15
|
-
export { remarkArrows } from "./plugins/arrows.js"
|
|
16
|
-
export type { Frontmatter, RenderResult, RenderOptions, TocEntry } from "./types.js"
|
package/src/markdown.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import matter from "gray-matter"
|
|
2
|
-
import { unified } from "unified"
|
|
3
|
-
import remarkParse from "remark-parse"
|
|
4
|
-
import remarkFrontmatter from "remark-frontmatter"
|
|
5
|
-
import remarkGfm from "remark-gfm"
|
|
6
|
-
import remarkMath from "remark-math"
|
|
7
|
-
import remarkRehype from "remark-rehype"
|
|
8
|
-
import rehypeRaw from "rehype-raw"
|
|
9
|
-
import rehypeSlug from "rehype-slug"
|
|
10
|
-
import rehypeAutolinkHeadings from "rehype-autolink-headings"
|
|
11
|
-
import type { ElementContent } from "hast"
|
|
12
|
-
import rehypeKatex from "rehype-katex"
|
|
13
|
-
import rehypeStringify from "rehype-stringify"
|
|
14
|
-
import { visit } from "unist-util-visit"
|
|
15
|
-
import type { Root as HastRoot, Element } from "hast"
|
|
16
|
-
import type { Plugin } from "unified"
|
|
17
|
-
|
|
18
|
-
import remarkBreaks from "remark-breaks"
|
|
19
|
-
import { rehypePrettyCode } from "rehype-pretty-code"
|
|
20
|
-
import { remarkCallout } from "./plugins/callout.js"
|
|
21
|
-
import { remarkTag } from "./plugins/tag.js"
|
|
22
|
-
import { remarkWikilink } from "./plugins/wikilink.js"
|
|
23
|
-
import { remarkHighlight } from "./plugins/highlight.js"
|
|
24
|
-
import { remarkObsidianComment } from "./plugins/comment.js"
|
|
25
|
-
import { remarkArrows } from "./plugins/arrows.js"
|
|
26
|
-
import type { Frontmatter, RenderResult, RenderOptions, TocEntry } from "./types.js"
|
|
27
|
-
|
|
28
|
-
// Rehype plugin that extracts headings into vfile.data.toc
|
|
29
|
-
const rehypeExtractToc: Plugin<[], HastRoot> = () => {
|
|
30
|
-
return (tree, file) => {
|
|
31
|
-
const toc: TocEntry[] = []
|
|
32
|
-
|
|
33
|
-
visit(tree, "element", (node: Element) => {
|
|
34
|
-
const match = node.tagName.match(/^h([1-6])$/)
|
|
35
|
-
if (!match) return
|
|
36
|
-
|
|
37
|
-
const depth = parseInt(match[1])
|
|
38
|
-
const id = (node.properties?.id as string) ?? ""
|
|
39
|
-
const text = extractText(node)
|
|
40
|
-
|
|
41
|
-
toc.push({ depth, text, id, children: [] })
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
file.data.toc = buildTocTree(toc)
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function extractText(node: Element): string {
|
|
49
|
-
let text = ""
|
|
50
|
-
visit(node as unknown as HastRoot, "text", (n: { value: string }) => {
|
|
51
|
-
text += n.value
|
|
52
|
-
})
|
|
53
|
-
return text
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function buildTocTree(flat: TocEntry[]): TocEntry[] {
|
|
57
|
-
const root: TocEntry[] = []
|
|
58
|
-
const stack: TocEntry[] = []
|
|
59
|
-
|
|
60
|
-
for (const entry of flat) {
|
|
61
|
-
while (stack.length > 0 && stack[stack.length - 1].depth >= entry.depth) {
|
|
62
|
-
stack.pop()
|
|
63
|
-
}
|
|
64
|
-
if (stack.length === 0) {
|
|
65
|
-
root.push(entry)
|
|
66
|
-
} else {
|
|
67
|
-
stack[stack.length - 1].children.push(entry)
|
|
68
|
-
}
|
|
69
|
-
stack.push(entry)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return root
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export async function renderMarkdown(
|
|
76
|
-
content: string,
|
|
77
|
-
options: RenderOptions = {}
|
|
78
|
-
): Promise<RenderResult> {
|
|
79
|
-
const { baseUrl = "/", resolveLink = (t) => t, knownSlugs } = options
|
|
80
|
-
|
|
81
|
-
// Parse frontmatter with gray-matter
|
|
82
|
-
const { data: frontmatter, content: body } = matter(content)
|
|
83
|
-
|
|
84
|
-
const file = await unified()
|
|
85
|
-
.use(remarkParse)
|
|
86
|
-
.use(remarkFrontmatter, ["yaml", "toml"])
|
|
87
|
-
.use(remarkObsidianComment)
|
|
88
|
-
.use(remarkGfm)
|
|
89
|
-
.use(remarkMath)
|
|
90
|
-
.use(remarkBreaks)
|
|
91
|
-
.use(remarkWikilink, { baseUrl, resolve: resolveLink, knownSlugs })
|
|
92
|
-
.use(remarkCallout)
|
|
93
|
-
.use(remarkTag)
|
|
94
|
-
.use(remarkHighlight)
|
|
95
|
-
.use(remarkArrows)
|
|
96
|
-
.use(remarkRehype, { allowDangerousHtml: true })
|
|
97
|
-
.use(rehypeRaw)
|
|
98
|
-
.use(rehypePrettyCode, {
|
|
99
|
-
theme: { light: "github-light", dark: "github-dark" },
|
|
100
|
-
keepBackground: false,
|
|
101
|
-
})
|
|
102
|
-
.use(rehypeSlug)
|
|
103
|
-
.use(rehypeAutolinkHeadings, {
|
|
104
|
-
behavior: "append",
|
|
105
|
-
properties: { className: "heading-anchor", ariaLabel: "Copy link to section" },
|
|
106
|
-
content: {
|
|
107
|
-
type: "element", tagName: "svg",
|
|
108
|
-
properties: { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none",
|
|
109
|
-
stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" },
|
|
110
|
-
children: [
|
|
111
|
-
{ type: "element", tagName: "path", properties: { d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }, children: [] },
|
|
112
|
-
{ type: "element", tagName: "path", properties: { d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }, children: [] },
|
|
113
|
-
],
|
|
114
|
-
} as ElementContent,
|
|
115
|
-
})
|
|
116
|
-
.use(rehypeKatex)
|
|
117
|
-
.use(rehypeExtractToc)
|
|
118
|
-
.use(rehypeStringify)
|
|
119
|
-
.process(body)
|
|
120
|
-
|
|
121
|
-
return {
|
|
122
|
-
html: String(file),
|
|
123
|
-
frontmatter: frontmatter as Frontmatter,
|
|
124
|
-
toc: (file.data.toc as TocEntry[]) ?? [],
|
|
125
|
-
links: (file.data.links as string[]) ?? [],
|
|
126
|
-
tags: [
|
|
127
|
-
...((frontmatter.tags as string[]) ?? []),
|
|
128
|
-
...((file.data.tags as string[]) ?? []),
|
|
129
|
-
].filter((t, i, a) => a.indexOf(t) === i),
|
|
130
|
-
}
|
|
131
|
-
}
|
package/src/plugins/arrows.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { visit } from "unist-util-visit"
|
|
2
|
-
import type { Root, Text } from "mdast"
|
|
3
|
-
import type { Plugin } from "unified"
|
|
4
|
-
|
|
5
|
-
export const remarkArrows: Plugin<[], Root> = () => {
|
|
6
|
-
return (tree) => {
|
|
7
|
-
visit(tree, "text", (node: Text) => {
|
|
8
|
-
node.value = node.value
|
|
9
|
-
.replace(/<-->/g, "↔")
|
|
10
|
-
.replace(/-->/g, "→")
|
|
11
|
-
.replace(/<--/g, "←")
|
|
12
|
-
})
|
|
13
|
-
}
|
|
14
|
-
}
|