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.
Files changed (187) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +3 -0
  3. package/build/actions/config/define.d.ts +6 -0
  4. package/build/actions/config/define.js +8 -0
  5. package/build/actions/config/define.unit.d.ts +1 -0
  6. package/build/actions/config/define.unit.js +34 -0
  7. package/build/actions/config/load.d.ts +5 -0
  8. package/build/actions/config/load.js +22 -0
  9. package/build/actions/config/load.unit.d.ts +1 -0
  10. package/build/actions/config/load.unit.js +23 -0
  11. package/build/commands/build.d.ts +5 -0
  12. package/build/commands/build.js +13 -0
  13. package/build/commands/preview.d.ts +5 -0
  14. package/build/commands/preview.js +18 -0
  15. package/build/commands/start.d.ts +5 -0
  16. package/build/commands/start.js +19 -0
  17. package/build/docs/markdown/includes/example.d.ts +9 -0
  18. package/build/docs/markdown/includes/example.js +5 -0
  19. package/build/entrypoints/main.d.ts +2 -0
  20. package/build/entrypoints/main.js +12 -0
  21. package/build/helpers/program.d.ts +2 -0
  22. package/build/helpers/program.js +5 -0
  23. package/build/index.d.ts +1 -0
  24. package/build/index.js +2 -0
  25. package/build/livemark.config.d.ts +9 -0
  26. package/build/livemark.config.js +5 -0
  27. package/build/models/config.d.ts +33 -0
  28. package/build/models/config.js +14 -0
  29. package/build/program.d.ts +2 -0
  30. package/build/program.js +14 -0
  31. package/build/vite.config.d.ts +2 -0
  32. package/build/vite.config.js +48 -0
  33. package/build/website/client.tsx +10 -0
  34. package/build/website/components/Abbr.tsx +17 -0
  35. package/build/website/components/AnsiCode.tsx +15 -0
  36. package/build/website/components/Banner.tsx +32 -0
  37. package/build/website/components/Breadcrumbs.tsx +19 -0
  38. package/build/website/components/Callout.tsx +72 -0
  39. package/build/website/components/Cards.tsx +66 -0
  40. package/build/website/components/CodeBlock.tsx +118 -0
  41. package/build/website/components/CodeTabs.tsx +83 -0
  42. package/build/website/components/Columns.tsx +17 -0
  43. package/build/website/components/Content.tsx +7 -0
  44. package/build/website/components/ContentTab.tsx +7 -0
  45. package/build/website/components/ContentTabs.tsx +82 -0
  46. package/build/website/components/DefaultCatchBoundary.tsx +54 -0
  47. package/build/website/components/DefinitionList.tsx +21 -0
  48. package/build/website/components/Details.tsx +30 -0
  49. package/build/website/components/FileTree.tsx +52 -0
  50. package/build/website/components/Footer.tsx +9 -0
  51. package/build/website/components/FootnoteRef.tsx +52 -0
  52. package/build/website/components/Heading.tsx +20 -0
  53. package/build/website/components/InlineBadge.tsx +9 -0
  54. package/build/website/components/InlineIcon.tsx +21 -0
  55. package/build/website/components/InlineToc.tsx +31 -0
  56. package/build/website/components/Layout.tsx +220 -0
  57. package/build/website/components/LinkButton.tsx +31 -0
  58. package/build/website/components/Mermaid.tsx +37 -0
  59. package/build/website/components/NotFound.tsx +27 -0
  60. package/build/website/components/PackageTabs.tsx +74 -0
  61. package/build/website/components/PageToolbar.tsx +65 -0
  62. package/build/website/components/PrevNext.tsx +52 -0
  63. package/build/website/components/SearchDialog.tsx +245 -0
  64. package/build/website/components/Section.tsx +24 -0
  65. package/build/website/components/SectionHeader.tsx +35 -0
  66. package/build/website/components/SiteTitle.tsx +16 -0
  67. package/build/website/components/SortableTable.tsx +81 -0
  68. package/build/website/components/SoundCloud.tsx +16 -0
  69. package/build/website/components/Theme.tsx +40 -0
  70. package/build/website/components/Toc.tsx +99 -0
  71. package/build/website/components/YouTube.tsx +16 -0
  72. package/build/website/components/ZoomImage.tsx +30 -0
  73. package/build/website/content-collections.ts +181 -0
  74. package/build/website/elements/accordion.tsx +78 -0
  75. package/build/website/elements/alert-dialog.tsx +185 -0
  76. package/build/website/elements/alert.tsx +74 -0
  77. package/build/website/elements/aspect-ratio.tsx +22 -0
  78. package/build/website/elements/avatar.tsx +107 -0
  79. package/build/website/elements/badge.tsx +51 -0
  80. package/build/website/elements/breadcrumb.tsx +120 -0
  81. package/build/website/elements/button-group.tsx +87 -0
  82. package/build/website/elements/button.tsx +57 -0
  83. package/build/website/elements/calendar.tsx +229 -0
  84. package/build/website/elements/card.tsx +102 -0
  85. package/build/website/elements/carousel.tsx +240 -0
  86. package/build/website/elements/chart.tsx +372 -0
  87. package/build/website/elements/checkbox.tsx +26 -0
  88. package/build/website/elements/collapsible.tsx +19 -0
  89. package/build/website/elements/command.tsx +210 -0
  90. package/build/website/elements/context-menu.tsx +270 -0
  91. package/build/website/elements/dialog.tsx +152 -0
  92. package/build/website/elements/direction.tsx +4 -0
  93. package/build/website/elements/drawer.tsx +127 -0
  94. package/build/website/elements/dropdown-menu.tsx +260 -0
  95. package/build/website/elements/empty.tsx +104 -0
  96. package/build/website/elements/field.tsx +235 -0
  97. package/build/website/elements/hover-card.tsx +49 -0
  98. package/build/website/elements/input-group.tsx +171 -0
  99. package/build/website/elements/input-otp.tsx +84 -0
  100. package/build/website/elements/input.tsx +18 -0
  101. package/build/website/elements/item.tsx +199 -0
  102. package/build/website/elements/kbd.tsx +26 -0
  103. package/build/website/elements/label.tsx +17 -0
  104. package/build/website/elements/menubar.tsx +282 -0
  105. package/build/website/elements/native-select.tsx +56 -0
  106. package/build/website/elements/navigation-menu.tsx +171 -0
  107. package/build/website/elements/pagination.tsx +133 -0
  108. package/build/website/elements/popover.tsx +88 -0
  109. package/build/website/elements/progress.tsx +81 -0
  110. package/build/website/elements/radio-group.tsx +36 -0
  111. package/build/website/elements/resizable.tsx +48 -0
  112. package/build/website/elements/scroll-area.tsx +51 -0
  113. package/build/website/elements/select.tsx +200 -0
  114. package/build/website/elements/separator.tsx +22 -0
  115. package/build/website/elements/sheet.tsx +130 -0
  116. package/build/website/elements/sidebar.tsx +717 -0
  117. package/build/website/elements/skeleton.tsx +13 -0
  118. package/build/website/elements/slider.tsx +57 -0
  119. package/build/website/elements/sonner.tsx +40 -0
  120. package/build/website/elements/spinner.tsx +16 -0
  121. package/build/website/elements/switch.tsx +30 -0
  122. package/build/website/elements/table.tsx +112 -0
  123. package/build/website/elements/tabs.tsx +79 -0
  124. package/build/website/elements/textarea.tsx +16 -0
  125. package/build/website/elements/toggle-group.tsx +87 -0
  126. package/build/website/elements/toggle.tsx +43 -0
  127. package/build/website/elements/tooltip.tsx +67 -0
  128. package/build/website/env.d.ts +10 -0
  129. package/build/website/globals.d.ts +8 -0
  130. package/build/website/helpers/article-icon.ts +59 -0
  131. package/build/website/helpers/articles.ts +24 -0
  132. package/build/website/helpers/resolve-includes.ts +131 -0
  133. package/build/website/helpers/resolve-includes.unit.ts +126 -0
  134. package/build/website/helpers/search-text.ts +15 -0
  135. package/build/website/helpers/style.ts +6 -0
  136. package/build/website/helpers/theme.ts +27 -0
  137. package/build/website/helpers/toc-context.ts +10 -0
  138. package/build/website/helpers/toc.ts +51 -0
  139. package/build/website/helpers/toc.unit.ts +74 -0
  140. package/build/website/hooks/in-view.ts +30 -0
  141. package/build/website/hooks/media.ts +19 -0
  142. package/build/website/models/article.ts +40 -0
  143. package/build/website/plugins/rehype-inline-code.ts +107 -0
  144. package/build/website/plugins/remark-abbr.ts +46 -0
  145. package/build/website/plugins/remark-abbr.unit.ts +99 -0
  146. package/build/website/plugins/remark-ansi.ts +247 -0
  147. package/build/website/plugins/remark-ansi.unit.ts +197 -0
  148. package/build/website/plugins/remark-badge.ts +50 -0
  149. package/build/website/plugins/remark-badge.unit.ts +96 -0
  150. package/build/website/plugins/remark-button.ts +60 -0
  151. package/build/website/plugins/remark-button.unit.ts +147 -0
  152. package/build/website/plugins/remark-callout.ts +57 -0
  153. package/build/website/plugins/remark-card.ts +80 -0
  154. package/build/website/plugins/remark-code-tabs.ts +87 -0
  155. package/build/website/plugins/remark-columns.ts +66 -0
  156. package/build/website/plugins/remark-custom-heading-id.ts +70 -0
  157. package/build/website/plugins/remark-custom-heading-id.unit.ts +131 -0
  158. package/build/website/plugins/remark-deflist.ts +36 -0
  159. package/build/website/plugins/remark-details.ts +49 -0
  160. package/build/website/plugins/remark-filetree.ts +89 -0
  161. package/build/website/plugins/remark-filetree.unit.ts +137 -0
  162. package/build/website/plugins/remark-github-callout.ts +71 -0
  163. package/build/website/plugins/remark-icon.ts +42 -0
  164. package/build/website/plugins/remark-image.ts +25 -0
  165. package/build/website/plugins/remark-media.ts +62 -0
  166. package/build/website/plugins/remark-media.unit.ts +74 -0
  167. package/build/website/plugins/remark-mermaid.ts +23 -0
  168. package/build/website/plugins/remark-npm.ts +52 -0
  169. package/build/website/plugins/remark-steps.ts +36 -0
  170. package/build/website/plugins/remark-steps.unit.ts +97 -0
  171. package/build/website/plugins/remark-tab.ts +100 -0
  172. package/build/website/plugins/remark-tab.unit.ts +181 -0
  173. package/build/website/plugins/remark-toc.ts +39 -0
  174. package/build/website/plugins/remark-toc.unit.ts +82 -0
  175. package/build/website/plugins/shiki-icon.ts +82 -0
  176. package/build/website/plugins/shiki-line-highlight.ts +174 -0
  177. package/build/website/public/logo.svg +72 -0
  178. package/build/website/routeTree.gen.ts +86 -0
  179. package/build/website/router.tsx +34 -0
  180. package/build/website/routes/$.tsx +149 -0
  181. package/build/website/routes/__root.tsx +84 -0
  182. package/build/website/routes/index.tsx +13 -0
  183. package/build/website/server.ts +7 -0
  184. package/build/website/styles/general.css +347 -0
  185. package/build/website/vite.config.ts +36 -0
  186. package/entrypoints/main.ts +12 -0
  187. 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,6 @@
1
+ import { type ClassValue, clsx } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -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
+ }