livemark 0.0.0-dev
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/LICENSE.md +9 -0
- package/README.md +3 -0
- package/build/actions/config/define.d.ts +6 -0
- package/build/actions/config/define.js +8 -0
- package/build/actions/config/define.unit.d.ts +1 -0
- package/build/actions/config/define.unit.js +34 -0
- package/build/actions/config/load.d.ts +5 -0
- package/build/actions/config/load.js +22 -0
- package/build/actions/config/load.unit.d.ts +1 -0
- package/build/actions/config/load.unit.js +23 -0
- package/build/commands/build.d.ts +5 -0
- package/build/commands/build.js +13 -0
- package/build/commands/preview.d.ts +5 -0
- package/build/commands/preview.js +18 -0
- package/build/commands/start.d.ts +5 -0
- package/build/commands/start.js +19 -0
- package/build/docs/markdown/includes/example.d.ts +9 -0
- package/build/docs/markdown/includes/example.js +5 -0
- package/build/entrypoints/main.d.ts +2 -0
- package/build/entrypoints/main.js +12 -0
- package/build/helpers/program.d.ts +2 -0
- package/build/helpers/program.js +5 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +2 -0
- package/build/livemark.config.d.ts +9 -0
- package/build/livemark.config.js +5 -0
- package/build/models/config.d.ts +33 -0
- package/build/models/config.js +14 -0
- package/build/program.d.ts +2 -0
- package/build/program.js +14 -0
- package/build/vite.config.d.ts +2 -0
- package/build/vite.config.js +48 -0
- package/build/website/client.tsx +10 -0
- package/build/website/components/Abbr.tsx +17 -0
- package/build/website/components/AnsiCode.tsx +15 -0
- package/build/website/components/Banner.tsx +32 -0
- package/build/website/components/Breadcrumbs.tsx +19 -0
- package/build/website/components/Callout.tsx +72 -0
- package/build/website/components/Cards.tsx +66 -0
- package/build/website/components/CodeBlock.tsx +118 -0
- package/build/website/components/CodeTabs.tsx +83 -0
- package/build/website/components/Columns.tsx +17 -0
- package/build/website/components/Content.tsx +7 -0
- package/build/website/components/ContentTab.tsx +7 -0
- package/build/website/components/ContentTabs.tsx +82 -0
- package/build/website/components/DefaultCatchBoundary.tsx +54 -0
- package/build/website/components/DefinitionList.tsx +21 -0
- package/build/website/components/Details.tsx +30 -0
- package/build/website/components/FileTree.tsx +52 -0
- package/build/website/components/Footer.tsx +9 -0
- package/build/website/components/FootnoteRef.tsx +52 -0
- package/build/website/components/Heading.tsx +20 -0
- package/build/website/components/InlineBadge.tsx +9 -0
- package/build/website/components/InlineIcon.tsx +21 -0
- package/build/website/components/InlineToc.tsx +31 -0
- package/build/website/components/Layout.tsx +220 -0
- package/build/website/components/LinkButton.tsx +31 -0
- package/build/website/components/Mermaid.tsx +37 -0
- package/build/website/components/NotFound.tsx +27 -0
- package/build/website/components/PackageTabs.tsx +74 -0
- package/build/website/components/PageToolbar.tsx +65 -0
- package/build/website/components/PrevNext.tsx +52 -0
- package/build/website/components/SearchDialog.tsx +245 -0
- package/build/website/components/Section.tsx +24 -0
- package/build/website/components/SectionHeader.tsx +35 -0
- package/build/website/components/SiteTitle.tsx +16 -0
- package/build/website/components/SortableTable.tsx +81 -0
- package/build/website/components/SoundCloud.tsx +16 -0
- package/build/website/components/Theme.tsx +40 -0
- package/build/website/components/Toc.tsx +99 -0
- package/build/website/components/YouTube.tsx +16 -0
- package/build/website/components/ZoomImage.tsx +30 -0
- package/build/website/content-collections.ts +181 -0
- package/build/website/elements/accordion.tsx +78 -0
- package/build/website/elements/alert-dialog.tsx +185 -0
- package/build/website/elements/alert.tsx +74 -0
- package/build/website/elements/aspect-ratio.tsx +22 -0
- package/build/website/elements/avatar.tsx +107 -0
- package/build/website/elements/badge.tsx +51 -0
- package/build/website/elements/breadcrumb.tsx +120 -0
- package/build/website/elements/button-group.tsx +87 -0
- package/build/website/elements/button.tsx +57 -0
- package/build/website/elements/calendar.tsx +229 -0
- package/build/website/elements/card.tsx +102 -0
- package/build/website/elements/carousel.tsx +240 -0
- package/build/website/elements/chart.tsx +372 -0
- package/build/website/elements/checkbox.tsx +26 -0
- package/build/website/elements/collapsible.tsx +19 -0
- package/build/website/elements/command.tsx +210 -0
- package/build/website/elements/context-menu.tsx +270 -0
- package/build/website/elements/dialog.tsx +152 -0
- package/build/website/elements/direction.tsx +4 -0
- package/build/website/elements/drawer.tsx +127 -0
- package/build/website/elements/dropdown-menu.tsx +260 -0
- package/build/website/elements/empty.tsx +104 -0
- package/build/website/elements/field.tsx +235 -0
- package/build/website/elements/hover-card.tsx +49 -0
- package/build/website/elements/input-group.tsx +171 -0
- package/build/website/elements/input-otp.tsx +84 -0
- package/build/website/elements/input.tsx +18 -0
- package/build/website/elements/item.tsx +199 -0
- package/build/website/elements/kbd.tsx +26 -0
- package/build/website/elements/label.tsx +17 -0
- package/build/website/elements/menubar.tsx +282 -0
- package/build/website/elements/native-select.tsx +56 -0
- package/build/website/elements/navigation-menu.tsx +171 -0
- package/build/website/elements/pagination.tsx +133 -0
- package/build/website/elements/popover.tsx +88 -0
- package/build/website/elements/progress.tsx +81 -0
- package/build/website/elements/radio-group.tsx +36 -0
- package/build/website/elements/resizable.tsx +48 -0
- package/build/website/elements/scroll-area.tsx +51 -0
- package/build/website/elements/select.tsx +200 -0
- package/build/website/elements/separator.tsx +22 -0
- package/build/website/elements/sheet.tsx +130 -0
- package/build/website/elements/sidebar.tsx +717 -0
- package/build/website/elements/skeleton.tsx +13 -0
- package/build/website/elements/slider.tsx +57 -0
- package/build/website/elements/sonner.tsx +40 -0
- package/build/website/elements/spinner.tsx +16 -0
- package/build/website/elements/switch.tsx +30 -0
- package/build/website/elements/table.tsx +112 -0
- package/build/website/elements/tabs.tsx +79 -0
- package/build/website/elements/textarea.tsx +16 -0
- package/build/website/elements/toggle-group.tsx +87 -0
- package/build/website/elements/toggle.tsx +43 -0
- package/build/website/elements/tooltip.tsx +67 -0
- package/build/website/env.d.ts +10 -0
- package/build/website/globals.d.ts +8 -0
- package/build/website/helpers/article-icon.ts +59 -0
- package/build/website/helpers/articles.ts +24 -0
- package/build/website/helpers/resolve-includes.ts +131 -0
- package/build/website/helpers/resolve-includes.unit.ts +126 -0
- package/build/website/helpers/search-text.ts +15 -0
- package/build/website/helpers/style.ts +6 -0
- package/build/website/helpers/theme.ts +27 -0
- package/build/website/helpers/toc-context.ts +10 -0
- package/build/website/helpers/toc.ts +51 -0
- package/build/website/helpers/toc.unit.ts +74 -0
- package/build/website/hooks/in-view.ts +30 -0
- package/build/website/hooks/media.ts +19 -0
- package/build/website/models/article.ts +40 -0
- package/build/website/plugins/rehype-inline-code.ts +107 -0
- package/build/website/plugins/remark-abbr.ts +46 -0
- package/build/website/plugins/remark-abbr.unit.ts +99 -0
- package/build/website/plugins/remark-ansi.ts +247 -0
- package/build/website/plugins/remark-ansi.unit.ts +197 -0
- package/build/website/plugins/remark-badge.ts +50 -0
- package/build/website/plugins/remark-badge.unit.ts +96 -0
- package/build/website/plugins/remark-button.ts +60 -0
- package/build/website/plugins/remark-button.unit.ts +147 -0
- package/build/website/plugins/remark-callout.ts +57 -0
- package/build/website/plugins/remark-card.ts +80 -0
- package/build/website/plugins/remark-code-tabs.ts +87 -0
- package/build/website/plugins/remark-columns.ts +66 -0
- package/build/website/plugins/remark-custom-heading-id.ts +70 -0
- package/build/website/plugins/remark-custom-heading-id.unit.ts +131 -0
- package/build/website/plugins/remark-deflist.ts +36 -0
- package/build/website/plugins/remark-details.ts +49 -0
- package/build/website/plugins/remark-filetree.ts +89 -0
- package/build/website/plugins/remark-filetree.unit.ts +137 -0
- package/build/website/plugins/remark-github-callout.ts +71 -0
- package/build/website/plugins/remark-icon.ts +42 -0
- package/build/website/plugins/remark-image.ts +25 -0
- package/build/website/plugins/remark-media.ts +62 -0
- package/build/website/plugins/remark-media.unit.ts +74 -0
- package/build/website/plugins/remark-mermaid.ts +23 -0
- package/build/website/plugins/remark-npm.ts +52 -0
- package/build/website/plugins/remark-steps.ts +36 -0
- package/build/website/plugins/remark-steps.unit.ts +97 -0
- package/build/website/plugins/remark-tab.ts +100 -0
- package/build/website/plugins/remark-tab.unit.ts +181 -0
- package/build/website/plugins/remark-toc.ts +39 -0
- package/build/website/plugins/remark-toc.unit.ts +82 -0
- package/build/website/plugins/shiki-icon.ts +82 -0
- package/build/website/plugins/shiki-line-highlight.ts +174 -0
- package/build/website/public/logo.svg +72 -0
- package/build/website/routeTree.gen.ts +86 -0
- package/build/website/router.tsx +34 -0
- package/build/website/routes/$.tsx +149 -0
- package/build/website/routes/__root.tsx +84 -0
- package/build/website/routes/index.tsx +13 -0
- package/build/website/server.ts +7 -0
- package/build/website/styles/general.css +347 -0
- package/build/website/vite.config.ts +36 -0
- package/entrypoints/main.ts +12 -0
- package/package.json +120 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { dirname, extname, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
const INCLUDE_RE = /^::include\{(.+)\}$/
|
|
5
|
+
const FRONTMATTER_RE = /^---\n[\s\S]*?\n---\n?/
|
|
6
|
+
const FENCE_RE = /^(`{3,}|~{3,})/
|
|
7
|
+
|
|
8
|
+
const extToLang: Record<string, string> = {
|
|
9
|
+
".ts": "typescript",
|
|
10
|
+
".tsx": "tsx",
|
|
11
|
+
".js": "javascript",
|
|
12
|
+
".jsx": "jsx",
|
|
13
|
+
".py": "python",
|
|
14
|
+
".rs": "rust",
|
|
15
|
+
".go": "go",
|
|
16
|
+
".rb": "ruby",
|
|
17
|
+
".java": "java",
|
|
18
|
+
".kt": "kotlin",
|
|
19
|
+
".swift": "swift",
|
|
20
|
+
".c": "c",
|
|
21
|
+
".cpp": "cpp",
|
|
22
|
+
".h": "c",
|
|
23
|
+
".cs": "csharp",
|
|
24
|
+
".css": "css",
|
|
25
|
+
".scss": "scss",
|
|
26
|
+
".html": "html",
|
|
27
|
+
".json": "json",
|
|
28
|
+
".yaml": "yaml",
|
|
29
|
+
".yml": "yaml",
|
|
30
|
+
".toml": "toml",
|
|
31
|
+
".xml": "xml",
|
|
32
|
+
".sql": "sql",
|
|
33
|
+
".sh": "bash",
|
|
34
|
+
".bash": "bash",
|
|
35
|
+
".zsh": "zsh",
|
|
36
|
+
".fish": "fish",
|
|
37
|
+
".ps1": "powershell",
|
|
38
|
+
".dockerfile": "dockerfile",
|
|
39
|
+
".graphql": "graphql",
|
|
40
|
+
".proto": "protobuf",
|
|
41
|
+
".lua": "lua",
|
|
42
|
+
".php": "php",
|
|
43
|
+
".r": "r",
|
|
44
|
+
".zig": "zig",
|
|
45
|
+
".ex": "elixir",
|
|
46
|
+
".exs": "elixir",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseAttributes(raw: string): Record<string, string> {
|
|
50
|
+
const attrs: Record<string, string> = {}
|
|
51
|
+
const re = /(\w+)="([^"]+)"/g
|
|
52
|
+
let m: RegExpExecArray | null = null
|
|
53
|
+
while ((m = re.exec(raw)) !== null) {
|
|
54
|
+
attrs[m[1]!] = m[2]!
|
|
55
|
+
}
|
|
56
|
+
return attrs
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveDirective(
|
|
60
|
+
rawAttrs: string,
|
|
61
|
+
fileDir: string,
|
|
62
|
+
depth: number,
|
|
63
|
+
): string | undefined {
|
|
64
|
+
const attrs = parseAttributes(rawAttrs)
|
|
65
|
+
const file = attrs.file
|
|
66
|
+
if (!file) return undefined
|
|
67
|
+
|
|
68
|
+
const absolutePath = resolve(fileDir, file)
|
|
69
|
+
try {
|
|
70
|
+
let included = readFileSync(absolutePath, "utf-8")
|
|
71
|
+
|
|
72
|
+
const ext = extname(file).toLowerCase()
|
|
73
|
+
const lang = extToLang[ext]
|
|
74
|
+
|
|
75
|
+
if (lang && !file.endsWith(".md")) {
|
|
76
|
+
const filename = file.split("/").pop()!
|
|
77
|
+
const title = `title="${filename}"`
|
|
78
|
+
const meta = attrs.meta ? ` ${attrs.meta}` : ""
|
|
79
|
+
return (
|
|
80
|
+
"```" + lang + " " + title + meta + "\n" + included.trimEnd() + "\n```"
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
included = included.replace(FRONTMATTER_RE, "")
|
|
85
|
+
return resolveIncludes(included.trim(), absolutePath, depth + 1)
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Resolves ::include{file="..."} directives by replacing them with referenced file contents */
|
|
92
|
+
export function resolveIncludes(
|
|
93
|
+
content: string,
|
|
94
|
+
filePath: string,
|
|
95
|
+
depth = 0,
|
|
96
|
+
): string {
|
|
97
|
+
if (depth > 5) return content
|
|
98
|
+
|
|
99
|
+
const fileDir = dirname(filePath)
|
|
100
|
+
const lines = content.split("\n")
|
|
101
|
+
const result: string[] = []
|
|
102
|
+
let fenceMarker: string | undefined
|
|
103
|
+
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
const fenceMatch = FENCE_RE.exec(line)
|
|
106
|
+
if (fenceMatch) {
|
|
107
|
+
if (!fenceMarker) {
|
|
108
|
+
fenceMarker = fenceMatch[1]!
|
|
109
|
+
} else if (line.startsWith(fenceMarker)) {
|
|
110
|
+
fenceMarker = undefined
|
|
111
|
+
}
|
|
112
|
+
result.push(line)
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (fenceMarker) {
|
|
117
|
+
result.push(line)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const includeMatch = INCLUDE_RE.exec(line)
|
|
122
|
+
if (includeMatch) {
|
|
123
|
+
const resolved = resolveDirective(includeMatch[1]!, fileDir, depth)
|
|
124
|
+
result.push(resolved ?? line)
|
|
125
|
+
} else {
|
|
126
|
+
result.push(line)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result.join("\n")
|
|
131
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
4
|
+
import { temporaryDirectory } from "tempy"
|
|
5
|
+
import { resolveIncludes } from "./resolve-includes.ts"
|
|
6
|
+
|
|
7
|
+
describe("resolveIncludes", () => {
|
|
8
|
+
it("should replace include directive with file content", () => {
|
|
9
|
+
const dir = temporaryDirectory()
|
|
10
|
+
writeFileSync(join(dir, "snippet.md"), "Hello from snippet")
|
|
11
|
+
const result = resolveIncludes(
|
|
12
|
+
'::include{file="snippet.md"}',
|
|
13
|
+
join(dir, "main.md"),
|
|
14
|
+
)
|
|
15
|
+
expect(result).toBe("Hello from snippet")
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("should strip frontmatter from included files", () => {
|
|
19
|
+
const dir = temporaryDirectory()
|
|
20
|
+
writeFileSync(
|
|
21
|
+
join(dir, "snippet.md"),
|
|
22
|
+
"---\ntitle: Test\n---\nContent here",
|
|
23
|
+
)
|
|
24
|
+
const result = resolveIncludes(
|
|
25
|
+
'::include{file="snippet.md"}',
|
|
26
|
+
join(dir, "main.md"),
|
|
27
|
+
)
|
|
28
|
+
expect(result).toBe("Content here")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("should preserve surrounding content", () => {
|
|
32
|
+
const dir = temporaryDirectory()
|
|
33
|
+
writeFileSync(join(dir, "snippet.md"), "Included text")
|
|
34
|
+
const result = resolveIncludes(
|
|
35
|
+
'Before\n\n::include{file="snippet.md"}\n\nAfter',
|
|
36
|
+
join(dir, "main.md"),
|
|
37
|
+
)
|
|
38
|
+
expect(result).toBe("Before\n\nIncluded text\n\nAfter")
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("should keep directive when file is missing", () => {
|
|
42
|
+
const result = resolveIncludes(
|
|
43
|
+
'::include{file="nonexistent.md"}',
|
|
44
|
+
"/tmp/main.md",
|
|
45
|
+
)
|
|
46
|
+
expect(result).toBe('::include{file="nonexistent.md"}')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("should resolve nested includes", () => {
|
|
50
|
+
const dir = temporaryDirectory()
|
|
51
|
+
writeFileSync(join(dir, "inner.md"), "Inner content")
|
|
52
|
+
writeFileSync(join(dir, "outer.md"), '::include{file="inner.md"}')
|
|
53
|
+
const result = resolveIncludes(
|
|
54
|
+
'::include{file="outer.md"}',
|
|
55
|
+
join(dir, "main.md"),
|
|
56
|
+
)
|
|
57
|
+
expect(result).toBe("Inner content")
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("should limit recursion depth", () => {
|
|
61
|
+
const dir = temporaryDirectory()
|
|
62
|
+
writeFileSync(join(dir, "recursive.md"), '::include{file="recursive.md"}')
|
|
63
|
+
const result = resolveIncludes(
|
|
64
|
+
'::include{file="recursive.md"}',
|
|
65
|
+
join(dir, "main.md"),
|
|
66
|
+
)
|
|
67
|
+
expect(result).toBe('::include{file="recursive.md"}')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("should resolve relative paths from included file location", () => {
|
|
71
|
+
const dir = temporaryDirectory()
|
|
72
|
+
mkdirSync(join(dir, "sub"))
|
|
73
|
+
writeFileSync(join(dir, "sub", "child.md"), "Child content")
|
|
74
|
+
writeFileSync(join(dir, "parent.md"), '::include{file="sub/child.md"}')
|
|
75
|
+
const result = resolveIncludes(
|
|
76
|
+
'::include{file="parent.md"}',
|
|
77
|
+
join(dir, "main.md"),
|
|
78
|
+
)
|
|
79
|
+
expect(result).toBe("Child content")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("should handle multiple includes in one file", () => {
|
|
83
|
+
const dir = temporaryDirectory()
|
|
84
|
+
writeFileSync(join(dir, "a.md"), "Alpha")
|
|
85
|
+
writeFileSync(join(dir, "b.md"), "Beta")
|
|
86
|
+
const result = resolveIncludes(
|
|
87
|
+
'::include{file="a.md"}\n\n::include{file="b.md"}',
|
|
88
|
+
join(dir, "main.md"),
|
|
89
|
+
)
|
|
90
|
+
expect(result).toBe("Alpha\n\nBeta")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("should wrap code files in fenced blocks", () => {
|
|
94
|
+
const dir = temporaryDirectory()
|
|
95
|
+
writeFileSync(join(dir, "example.ts"), 'const x = 1\nconst y = "hello"')
|
|
96
|
+
const result = resolveIncludes(
|
|
97
|
+
'::include{file="example.ts"}',
|
|
98
|
+
join(dir, "main.md"),
|
|
99
|
+
)
|
|
100
|
+
expect(result).toBe(
|
|
101
|
+
'```typescript title="example.ts"\nconst x = 1\nconst y = "hello"\n```',
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("should pass meta to code fence", () => {
|
|
106
|
+
const dir = temporaryDirectory()
|
|
107
|
+
writeFileSync(join(dir, "app.tsx"), "export default function App() {}")
|
|
108
|
+
const result = resolveIncludes(
|
|
109
|
+
'::include{file="app.tsx" meta="{1} lineNumbers"}',
|
|
110
|
+
join(dir, "main.md"),
|
|
111
|
+
)
|
|
112
|
+
expect(result).toBe(
|
|
113
|
+
'```tsx title="app.tsx" {1} lineNumbers\nexport default function App() {}\n```',
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("should treat unknown extensions as markdown", () => {
|
|
118
|
+
const dir = temporaryDirectory()
|
|
119
|
+
writeFileSync(join(dir, "notes.txt"), "Plain text")
|
|
120
|
+
const result = resolveIncludes(
|
|
121
|
+
'::include{file="notes.txt"}',
|
|
122
|
+
join(dir, "main.md"),
|
|
123
|
+
)
|
|
124
|
+
expect(result).toBe("Plain text")
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Strip markdown syntax to extract plain searchable text */
|
|
2
|
+
export function extractSearchText(markdown: string) {
|
|
3
|
+
return markdown
|
|
4
|
+
.replace(/^---[\s\S]*?---\n*/m, "")
|
|
5
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
6
|
+
.replace(/:::\w+[\s\S]*?:::/g, "")
|
|
7
|
+
.replace(/::\w+(?:\[.*?\])?(?:\{.*?\})?/g, "")
|
|
8
|
+
.replace(/!\[.*?\]\(.*?\)/g, "")
|
|
9
|
+
.replace(/\[([^\]]*)\]\(.*?\)/g, "$1")
|
|
10
|
+
.replace(/<[^>]+>/g, "")
|
|
11
|
+
.replace(/#{1,6}\s*/g, "")
|
|
12
|
+
.replace(/[*_~`>|]/g, "")
|
|
13
|
+
.replace(/\n{2,}/g, "\n")
|
|
14
|
+
.trim()
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const STORAGE_KEY = "livemark-theme"
|
|
2
|
+
|
|
3
|
+
export type Theme = "light" | "dark"
|
|
4
|
+
|
|
5
|
+
export function detectClientTheme(): Theme {
|
|
6
|
+
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
|
|
7
|
+
return stored || "light"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function activateTheme(theme: Theme) {
|
|
11
|
+
if (theme === "dark") {
|
|
12
|
+
globalThis.document.documentElement.classList.add("dark")
|
|
13
|
+
} else {
|
|
14
|
+
globalThis.document.documentElement.classList.remove("dark")
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getCurrentTheme(): Theme {
|
|
19
|
+
return globalThis.document?.documentElement.classList.contains("dark")
|
|
20
|
+
? "dark"
|
|
21
|
+
: "light"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function setTheme(theme: Theme) {
|
|
25
|
+
activateTheme(theme)
|
|
26
|
+
localStorage.setItem(STORAGE_KEY, theme)
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createContext, useContext } from "react"
|
|
2
|
+
import type { TocItem } from "./toc.ts"
|
|
3
|
+
|
|
4
|
+
/** React context that provides TOC items to inline components */
|
|
5
|
+
export const TocContext = createContext<TocItem[]>([])
|
|
6
|
+
|
|
7
|
+
/** Consume the TOC items from the nearest TocContext provider */
|
|
8
|
+
export function useTocItems() {
|
|
9
|
+
return useContext(TocContext)
|
|
10
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import GithubSlugger from "github-slugger"
|
|
2
|
+
|
|
3
|
+
export interface TocItem {
|
|
4
|
+
url: string
|
|
5
|
+
title: string
|
|
6
|
+
depth: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const CUSTOM_ID_PATTERN = /\s*\[#([^\]]+)\]\s*$/
|
|
10
|
+
const TOC_HIDDEN_PATTERN = /\s*\[!toc\]\s*$/
|
|
11
|
+
const TOC_ONLY_PATTERN = /\s*\[toc\]\s*$/
|
|
12
|
+
const STEP_PATTERN = /\s*\[step\]\s*$/
|
|
13
|
+
|
|
14
|
+
/** Extract TOC headings from raw markdown content */
|
|
15
|
+
export function extractToc(content: string): TocItem[] {
|
|
16
|
+
const slugger = new GithubSlugger()
|
|
17
|
+
const items: TocItem[] = []
|
|
18
|
+
const headingRegex = /^(#{2,4})\s+(.+)$/
|
|
19
|
+
const lines = content.split("\n")
|
|
20
|
+
let inCodeBlock = false
|
|
21
|
+
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
if (line.startsWith("```") || line.startsWith("````")) {
|
|
24
|
+
inCodeBlock = !inCodeBlock
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
if (inCodeBlock) continue
|
|
28
|
+
|
|
29
|
+
const match = headingRegex.exec(line)
|
|
30
|
+
if (!match) continue
|
|
31
|
+
|
|
32
|
+
const depth = match[1]!.length
|
|
33
|
+
const rawTitle = match[2]!
|
|
34
|
+
|
|
35
|
+
if (TOC_HIDDEN_PATTERN.test(rawTitle)) continue
|
|
36
|
+
if (STEP_PATTERN.test(rawTitle)) continue
|
|
37
|
+
|
|
38
|
+
const idMatch = CUSTOM_ID_PATTERN.exec(rawTitle)
|
|
39
|
+
const title = rawTitle
|
|
40
|
+
.replace(CUSTOM_ID_PATTERN, "")
|
|
41
|
+
.replace(TOC_ONLY_PATTERN, "")
|
|
42
|
+
.replace(STEP_PATTERN, "")
|
|
43
|
+
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
44
|
+
.replace(/`(.+?)`/g, "$1")
|
|
45
|
+
const slug = idMatch?.[1] ?? slugger.slug(title)
|
|
46
|
+
|
|
47
|
+
items.push({ url: `#${slug}`, title, depth })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return items
|
|
51
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
2
|
+
import { extractToc } from "./toc.ts"
|
|
3
|
+
|
|
4
|
+
describe("extractToc", () => {
|
|
5
|
+
it("should extract headings with auto-generated slugs", () => {
|
|
6
|
+
const content = "## Introduction\n\n### Getting Started"
|
|
7
|
+
const toc = extractToc(content)
|
|
8
|
+
expect(toc).toEqual([
|
|
9
|
+
{ url: "#introduction", title: "Introduction", depth: 2 },
|
|
10
|
+
{ url: "#getting-started", title: "Getting Started", depth: 3 },
|
|
11
|
+
])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("should use custom id when [#custom-id] syntax is present", () => {
|
|
15
|
+
const content = "## My Section [#custom-id]\n\n### Sub Section"
|
|
16
|
+
const toc = extractToc(content)
|
|
17
|
+
expect(toc).toEqual([
|
|
18
|
+
{ url: "#custom-id", title: "My Section", depth: 2 },
|
|
19
|
+
{ url: "#sub-section", title: "Sub Section", depth: 3 },
|
|
20
|
+
])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("should strip [#id] from the displayed title", () => {
|
|
24
|
+
const content = "## Installation [#install]"
|
|
25
|
+
const toc = extractToc(content)
|
|
26
|
+
expect(toc).toEqual([{ url: "#install", title: "Installation", depth: 2 }])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("should handle inline formatting with custom ids", () => {
|
|
30
|
+
const content = "## **Bold** Title [#bold-title]"
|
|
31
|
+
const toc = extractToc(content)
|
|
32
|
+
expect(toc).toEqual([{ url: "#bold-title", title: "Bold Title", depth: 2 }])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("should handle code formatting with custom ids", () => {
|
|
36
|
+
const content = "## `config` Options [#config-opts]"
|
|
37
|
+
const toc = extractToc(content)
|
|
38
|
+
expect(toc).toEqual([
|
|
39
|
+
{ url: "#config-opts", title: "config Options", depth: 2 },
|
|
40
|
+
])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("should skip h1 headings", () => {
|
|
44
|
+
const content = "# Title\n\n## Section"
|
|
45
|
+
const toc = extractToc(content)
|
|
46
|
+
expect(toc).toEqual([{ url: "#section", title: "Section", depth: 2 }])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("should exclude [step] headings from TOC", () => {
|
|
50
|
+
const content =
|
|
51
|
+
"## Overview\n\n### Install Dependencies [step]\n\n### Configure Project [step]"
|
|
52
|
+
const toc = extractToc(content)
|
|
53
|
+
expect(toc).toEqual([{ url: "#overview", title: "Overview", depth: 2 }])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("should skip headings with [!toc] annotation", () => {
|
|
57
|
+
const content = "## Visible\n\n## Hidden From TOC [!toc]\n\n## Also Visible"
|
|
58
|
+
const toc = extractToc(content)
|
|
59
|
+
expect(toc).toEqual([
|
|
60
|
+
{ url: "#visible", title: "Visible", depth: 2 },
|
|
61
|
+
{ url: "#also-visible", title: "Also Visible", depth: 2 },
|
|
62
|
+
])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("should include [toc] headings with annotation stripped from title", () => {
|
|
66
|
+
const content = "## Regular\n\n## TOC Only [toc]\n\n## Another"
|
|
67
|
+
const toc = extractToc(content)
|
|
68
|
+
expect(toc).toEqual([
|
|
69
|
+
{ url: "#regular", title: "Regular", depth: 2 },
|
|
70
|
+
{ url: "#toc-only", title: "TOC Only", depth: 2 },
|
|
71
|
+
{ url: "#another", title: "Another", depth: 2 },
|
|
72
|
+
])
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
2
|
+
|
|
3
|
+
export function useInView() {
|
|
4
|
+
const [isVisible, setIsVisible] = useState(false)
|
|
5
|
+
const elementRef = useRef<Element | null>(null)
|
|
6
|
+
|
|
7
|
+
const ref = useCallback((node: Element | null) => {
|
|
8
|
+
elementRef.current = node
|
|
9
|
+
}, [])
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const node = elementRef.current
|
|
13
|
+
if (!node) return
|
|
14
|
+
|
|
15
|
+
const observer = new IntersectionObserver(
|
|
16
|
+
entries => {
|
|
17
|
+
if (entries[0]?.isIntersecting) {
|
|
18
|
+
setIsVisible(true)
|
|
19
|
+
observer.disconnect()
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{ threshold: 0.1 },
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
observer.observe(node)
|
|
26
|
+
return () => observer.disconnect()
|
|
27
|
+
}, [])
|
|
28
|
+
|
|
29
|
+
return { ref, isVisible }
|
|
30
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 768
|
|
4
|
+
|
|
5
|
+
export function useIsMobile() {
|
|
6
|
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
7
|
+
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
10
|
+
const onChange = () => {
|
|
11
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
12
|
+
}
|
|
13
|
+
mql.addEventListener("change", onChange)
|
|
14
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
15
|
+
return () => mql.removeEventListener("change", onChange)
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
return !!isMobile
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface ArticleNode {
|
|
2
|
+
pathname: string
|
|
3
|
+
title: string
|
|
4
|
+
icon: string
|
|
5
|
+
children: ArticleNode[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Build a tree from a flat sorted list of articles based on pathname hierarchy */
|
|
9
|
+
export function buildArticleTree(
|
|
10
|
+
articles: { pathname: string; title: string; icon: string }[],
|
|
11
|
+
) {
|
|
12
|
+
const nodeMap = new Map<string, ArticleNode>()
|
|
13
|
+
|
|
14
|
+
for (const article of articles) {
|
|
15
|
+
nodeMap.set(article.pathname, {
|
|
16
|
+
pathname: article.pathname,
|
|
17
|
+
title: article.title,
|
|
18
|
+
icon: article.icon,
|
|
19
|
+
children: [],
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const roots: ArticleNode[] = []
|
|
24
|
+
|
|
25
|
+
for (const article of articles) {
|
|
26
|
+
const node = nodeMap.get(article.pathname)!
|
|
27
|
+
const trimmed = article.pathname.replace(/\/$/, "")
|
|
28
|
+
const lastSlash = trimmed.lastIndexOf("/")
|
|
29
|
+
const parentPath = lastSlash > 0 ? `${trimmed.slice(0, lastSlash)}/` : ""
|
|
30
|
+
const parent = parentPath ? nodeMap.get(parentPath) : undefined
|
|
31
|
+
|
|
32
|
+
if (parent) {
|
|
33
|
+
parent.children.push(node)
|
|
34
|
+
} else {
|
|
35
|
+
roots.push(node)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return roots
|
|
40
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Plugin } from "unified"
|
|
2
|
+
import { visit } from "unist-util-visit"
|
|
3
|
+
import {
|
|
4
|
+
type BundledLanguage,
|
|
5
|
+
bundledLanguages,
|
|
6
|
+
getSingletonHighlighter,
|
|
7
|
+
} from "shiki"
|
|
8
|
+
|
|
9
|
+
interface HastText {
|
|
10
|
+
type: "text"
|
|
11
|
+
value: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type PropertyValue =
|
|
15
|
+
| boolean
|
|
16
|
+
| number
|
|
17
|
+
| string
|
|
18
|
+
| null
|
|
19
|
+
| undefined
|
|
20
|
+
| Array<string | number>
|
|
21
|
+
|
|
22
|
+
interface HastElement {
|
|
23
|
+
type: "element"
|
|
24
|
+
tagName: string
|
|
25
|
+
properties: Record<string, PropertyValue>
|
|
26
|
+
children: (HastElement | HastText)[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface HastRoot {
|
|
30
|
+
type: "root"
|
|
31
|
+
children: (HastElement | HastText)[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type HastNode = HastRoot | HastElement | HastText
|
|
35
|
+
|
|
36
|
+
const LANG_PREFIX = /^\{:(\w+)\}/
|
|
37
|
+
|
|
38
|
+
function isBundledLanguage(lang: string): lang is BundledLanguage {
|
|
39
|
+
return lang in bundledLanguages
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Rehype plugin that applies Shiki syntax highlighting to inline code spans with a `{:lang}` prefix. */
|
|
43
|
+
export const rehypeInlineCode: Plugin<[], HastRoot> = () => {
|
|
44
|
+
return async (tree: HastRoot) => {
|
|
45
|
+
const highlighter = await getSingletonHighlighter({
|
|
46
|
+
themes: ["catppuccin-latte", "catppuccin-mocha"],
|
|
47
|
+
langs: [],
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const nodes: {
|
|
51
|
+
node: HastElement
|
|
52
|
+
lang: BundledLanguage
|
|
53
|
+
code: string
|
|
54
|
+
}[] = []
|
|
55
|
+
|
|
56
|
+
visit(
|
|
57
|
+
tree,
|
|
58
|
+
"element",
|
|
59
|
+
(
|
|
60
|
+
node: HastNode,
|
|
61
|
+
_index: number | undefined,
|
|
62
|
+
parent: HastNode | undefined,
|
|
63
|
+
) => {
|
|
64
|
+
if (node.type !== "element") return
|
|
65
|
+
if (node.tagName !== "code") return
|
|
66
|
+
if (parent && parent.type === "element" && parent.tagName === "pre")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
const textChild = node.children[0]
|
|
70
|
+
if (!textChild || textChild.type !== "text") return
|
|
71
|
+
|
|
72
|
+
const match = LANG_PREFIX.exec(textChild.value)
|
|
73
|
+
if (!match) return
|
|
74
|
+
|
|
75
|
+
const lang = match[1]!
|
|
76
|
+
if (!isBundledLanguage(lang)) return
|
|
77
|
+
|
|
78
|
+
const code = textChild.value.slice(match[0].length)
|
|
79
|
+
nodes.push({ node, lang, code })
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
for (const { node, lang, code } of nodes) {
|
|
84
|
+
await highlighter.loadLanguage(lang)
|
|
85
|
+
const result = highlighter.codeToHast(code, {
|
|
86
|
+
themes: { light: "catppuccin-latte", dark: "catppuccin-mocha" },
|
|
87
|
+
lang,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const pre = result.children[0]
|
|
91
|
+
if (pre && pre.type === "element" && "children" in pre) {
|
|
92
|
+
const codeEl = pre.children[0]
|
|
93
|
+
if (codeEl && codeEl.type === "element" && "children" in codeEl) {
|
|
94
|
+
node.children = codeEl.children.filter(
|
|
95
|
+
(c): c is HastElement | HastText =>
|
|
96
|
+
c.type === "element" || c.type === "text",
|
|
97
|
+
)
|
|
98
|
+
node.properties = {
|
|
99
|
+
...node.properties,
|
|
100
|
+
className: "shiki-inline",
|
|
101
|
+
style: codeEl.properties?.style,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Root } from "mdast"
|
|
2
|
+
import type { TextDirective } from "mdast-util-directive"
|
|
3
|
+
import type { Plugin } from "unified"
|
|
4
|
+
import { visit } from "unist-util-visit"
|
|
5
|
+
|
|
6
|
+
/** Walk a mdast tree and convert :abbr text directives into Abbr JSX elements */
|
|
7
|
+
export function transformAbbrDirectives(tree: Root) {
|
|
8
|
+
visit(tree, "textDirective", (node: TextDirective, index, parent) => {
|
|
9
|
+
if (node.name !== "abbr" || index === undefined || !parent) return
|
|
10
|
+
|
|
11
|
+
const text = node.children
|
|
12
|
+
.map(c => ("value" in c ? String(c.value) : ""))
|
|
13
|
+
.join("")
|
|
14
|
+
|
|
15
|
+
const title = node.attributes?.title ?? ""
|
|
16
|
+
|
|
17
|
+
const jsxNode = {
|
|
18
|
+
type: "mdxJsxTextElement" as const,
|
|
19
|
+
name: "Abbr",
|
|
20
|
+
attributes: [
|
|
21
|
+
{
|
|
22
|
+
type: "mdxJsxAttribute" as const,
|
|
23
|
+
name: "text",
|
|
24
|
+
value: text,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: "mdxJsxAttribute" as const,
|
|
28
|
+
name: "title",
|
|
29
|
+
value: title,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
children: [],
|
|
33
|
+
data: { _mdxExplicitJsx: true },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// @ts-expect-error mdxJsxTextElement is not in mdast types
|
|
37
|
+
parent.children.splice(index, 1, jsxNode)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Remark plugin that converts :abbr text directives into Abbr JSX elements */
|
|
42
|
+
export const remarkAbbr: Plugin<[], Root> = () => {
|
|
43
|
+
return tree => {
|
|
44
|
+
transformAbbrDirectives(tree)
|
|
45
|
+
}
|
|
46
|
+
}
|