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,245 @@
1
+ import { useState, useRef, useCallback, useMemo } from "react"
2
+ import { useNavigate } from "@tanstack/react-router"
3
+ import { FileText } from "lucide-react"
4
+ import { create, insertMultiple, search } from "@orama/orama"
5
+ import { sortedArticles } from "../helpers/articles.ts"
6
+ import { articleIcons } from "../helpers/article-icon.ts"
7
+ import {
8
+ CommandDialog,
9
+ CommandEmpty,
10
+ CommandGroup,
11
+ CommandInput,
12
+ CommandItem,
13
+ CommandList,
14
+ } from "../elements/command.tsx"
15
+
16
+ interface SearchResult {
17
+ title: string
18
+ pathname: string
19
+ icon: string
20
+ snippet: string
21
+ group: string
22
+ }
23
+
24
+ /** Full-text search dialog powered by Orama */
25
+ export function SearchDialog(props: {
26
+ open: boolean
27
+ onOpenChange: (open: boolean) => void
28
+ }) {
29
+ const [results, setResults] = useState<SearchResult[]>([])
30
+ const [query, setQuery] = useState("")
31
+ const dbRef = useRef<Awaited<ReturnType<typeof create>> | null>(null)
32
+ const navigate = useNavigate()
33
+
34
+ const ensureIndex = useCallback(async () => {
35
+ if (dbRef.current) return dbRef.current
36
+ const db = await create({
37
+ schema: {
38
+ title: "string",
39
+ description: "string",
40
+ searchText: "string",
41
+ },
42
+ })
43
+ await insertMultiple(
44
+ db,
45
+ sortedArticles.map(a => ({
46
+ title: a.title,
47
+ description: a.description ?? "",
48
+ searchText: a.searchText,
49
+ })),
50
+ )
51
+ dbRef.current = db
52
+ return db
53
+ }, [])
54
+
55
+ const toResult = useCallback(
56
+ (article: (typeof sortedArticles)[number], term: string): SearchResult => ({
57
+ title: article.title,
58
+ pathname: article.pathname,
59
+ icon: article.icon,
60
+ snippet: term
61
+ ? extractSnippet(article.searchText, term)
62
+ : (article.description ?? ""),
63
+ group: groupFor(article.pathname),
64
+ }),
65
+ [],
66
+ )
67
+
68
+ const handleSearch = useCallback(
69
+ async (term: string) => {
70
+ setQuery(term)
71
+ if (!term.trim()) {
72
+ setResults([])
73
+ return
74
+ }
75
+ const db = await ensureIndex()
76
+ const hits = await search(db, {
77
+ term,
78
+ properties: ["title", "description", "searchText"],
79
+ tolerance: 1,
80
+ boost: { title: 3, description: 2 },
81
+ })
82
+ setResults(
83
+ hits.hits.map(hit => {
84
+ const article = sortedArticles.find(
85
+ a => a.title === hit.document.title,
86
+ )
87
+ if (!article)
88
+ return toResult(
89
+ {
90
+ ...sortedArticles[0],
91
+ title: hit.document.title,
92
+ pathname: "",
93
+ searchText: hit.document.description,
94
+ description: hit.document.description,
95
+ icon: "",
96
+ },
97
+ term,
98
+ )
99
+ return toResult(article, term)
100
+ }),
101
+ )
102
+ },
103
+ [ensureIndex, toResult],
104
+ )
105
+
106
+ const handleSelect = useCallback(
107
+ (pathname: string) => {
108
+ props.onOpenChange(false)
109
+ setQuery("")
110
+ setResults([])
111
+ navigate({
112
+ to: "/$",
113
+ params: { _splat: pathname.replace(/^\/|\/$/g, "") },
114
+ })
115
+ },
116
+ [navigate, props.onOpenChange],
117
+ )
118
+
119
+ const grouped = useMemo(() => {
120
+ const groups = new Map<string, SearchResult[]>()
121
+ for (const r of results) {
122
+ const list = groups.get(r.group) ?? []
123
+ list.push(r)
124
+ groups.set(r.group, list)
125
+ }
126
+ return groups
127
+ }, [results])
128
+
129
+ return (
130
+ <CommandDialog
131
+ title="Search"
132
+ description="Search documentation"
133
+ open={props.open}
134
+ onOpenChange={props.onOpenChange}
135
+ shouldFilter={false}
136
+ loop
137
+ >
138
+ <CommandInput
139
+ placeholder="Search docs..."
140
+ value={query}
141
+ onValueChange={handleSearch}
142
+ onClear={() => handleSearch("")}
143
+ />
144
+ <CommandList className="max-h-80">
145
+ <CommandEmpty>
146
+ {query.trim()
147
+ ? "No results found."
148
+ : "Type to search across all documentation."}
149
+ </CommandEmpty>
150
+ {[...grouped.entries()].map(([group, items]) => (
151
+ <CommandGroup key={group} heading={group}>
152
+ {items.map(result => {
153
+ const Icon = articleIcons[result.icon] ?? FileText
154
+ return (
155
+ <CommandItem
156
+ key={result.pathname}
157
+ value={result.pathname}
158
+ onSelect={() => handleSelect(result.pathname)}
159
+ className="py-2.5"
160
+ >
161
+ <Icon className="size-5 shrink-0 mt-0.5 self-start opacity-60" />
162
+ <div className="flex flex-col gap-0.5 min-w-0">
163
+ <span className="text-base font-medium">
164
+ {result.title}
165
+ </span>
166
+ {result.snippet && (
167
+ <span className="text-sm text-muted-foreground line-clamp-1">
168
+ <HighlightedText text={result.snippet} term={query} />
169
+ </span>
170
+ )}
171
+ </div>
172
+ </CommandItem>
173
+ )
174
+ })}
175
+ </CommandGroup>
176
+ ))}
177
+ </CommandList>
178
+ <div className="flex items-center gap-4 border-t mt-2 px-4 py-2.5 text-sm text-muted-foreground">
179
+ <span className="flex items-center gap-1.5">
180
+ <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 text-xs font-mono">
181
+ ↑↓
182
+ </kbd>
183
+ navigate
184
+ </span>
185
+ <span className="flex items-center gap-1.5">
186
+ <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 text-xs font-mono">
187
+ ↵
188
+ </kbd>
189
+ select
190
+ </span>
191
+ <span className="flex items-center gap-1.5">
192
+ <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 text-xs font-mono">
193
+ esc
194
+ </kbd>
195
+ close
196
+ </span>
197
+ </div>
198
+ </CommandDialog>
199
+ )
200
+ }
201
+
202
+ function groupFor(pathname: string) {
203
+ const segments = pathname.replace(/^\/|\/$/g, "").split("/")
204
+ if (segments.length <= 2) return "Pages"
205
+ return segments
206
+ .slice(1, -1)
207
+ .map(s =>
208
+ s
209
+ .split("-")
210
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
211
+ .join(" "),
212
+ )
213
+ .join(" › ")
214
+ }
215
+
216
+ function extractSnippet(text: string, term: string) {
217
+ const lower = text.toLowerCase()
218
+ const idx = lower.indexOf(term.toLowerCase())
219
+ if (idx === -1) return text.slice(0, 120)
220
+ const start = Math.max(0, idx - 40)
221
+ const end = Math.min(text.length, idx + term.length + 80)
222
+ const prefix = start > 0 ? "…" : ""
223
+ const suffix = end < text.length ? "…" : ""
224
+ return `${prefix}${text.slice(start, end)}${suffix}`
225
+ }
226
+
227
+ function HighlightedText(props: { text: string; term: string }) {
228
+ if (!props.term.trim()) return <>{props.text}</>
229
+ const escaped = props.term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
230
+ const regex = new RegExp(`(${escaped})`, "gi")
231
+ const parts = props.text.split(regex)
232
+ return (
233
+ <>
234
+ {parts.map((part, i) =>
235
+ regex.test(part) ? (
236
+ <span key={i} className="text-foreground font-medium">
237
+ {part}
238
+ </span>
239
+ ) : (
240
+ part
241
+ ),
242
+ )}
243
+ </>
244
+ )
245
+ }
@@ -0,0 +1,24 @@
1
+ import { cn } from "../helpers/style.ts"
2
+ import { useInView } from "../hooks/in-view.ts"
3
+
4
+ export function Section(props: {
5
+ children: React.ReactNode
6
+ className?: string
7
+ id?: string
8
+ }) {
9
+ const { ref, isVisible } = useInView()
10
+
11
+ return (
12
+ <section
13
+ ref={ref}
14
+ id={props.id}
15
+ className={cn(
16
+ "py-16 md:py-24 transition-all duration-700",
17
+ isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8",
18
+ props.className,
19
+ )}
20
+ >
21
+ {props.children}
22
+ </section>
23
+ )
24
+ }
@@ -0,0 +1,35 @@
1
+ import { cn } from "../helpers/style.ts"
2
+
3
+ export function SectionHeader(props: {
4
+ label?: string
5
+ title: string
6
+ subtitle?: string
7
+ className?: string
8
+ align?: "left" | "center"
9
+ }) {
10
+ const align = props.align ?? "center"
11
+
12
+ return (
13
+ <div
14
+ className={cn(
15
+ align === "center" && "text-center",
16
+ "mb-12",
17
+ props.className,
18
+ )}
19
+ >
20
+ {props.label && (
21
+ <p className="text-primary text-xs uppercase tracking-[0.2em] font-medium mb-3">
22
+ {props.label}
23
+ </p>
24
+ )}
25
+ <h2 className="font-serif text-3xl md:text-4xl lg:text-5xl font-semibold italic mb-4">
26
+ {props.title}
27
+ </h2>
28
+ {props.subtitle && (
29
+ <p className="text-muted-foreground text-lg max-w-2xl mx-auto">
30
+ {props.subtitle}
31
+ </p>
32
+ )}
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1,16 @@
1
+ /** Renders the site title and description in the sidebar header */
2
+ export function SiteTitle() {
3
+ return (
4
+ <>
5
+ <img
6
+ src="/logo.svg"
7
+ alt={import.meta.env.SITE_TITLE}
8
+ className="size-8 rounded-lg"
9
+ />
10
+ <div className="flex flex-col gap-0.5 leading-none">
11
+ <span className="font-semibold">{import.meta.env.SITE_TITLE}</span>
12
+ <span className="text-xs">{import.meta.env.SITE_DESCRIPTION}</span>
13
+ </div>
14
+ </>
15
+ )
16
+ }
@@ -0,0 +1,81 @@
1
+ import { useEffect, useRef, type ComponentProps } from "react"
2
+ import {
3
+ Table,
4
+ TableHeader,
5
+ TableBody,
6
+ TableRow,
7
+ TableHead,
8
+ TableCell,
9
+ } from "../elements/table.tsx"
10
+
11
+ /** Wraps markdown tables with click-to-sort column headers */
12
+ export function SortableTable(props: ComponentProps<"table">) {
13
+ const ref = useRef<HTMLDivElement>(null)
14
+
15
+ useEffect(() => {
16
+ const container = ref.current
17
+ if (!container) return
18
+
19
+ let activeCol = -1
20
+ let ascending = true
21
+
22
+ const headers = container.querySelectorAll<HTMLElement>("th")
23
+ const cleanups: (() => void)[] = []
24
+
25
+ headers.forEach((th, colIndex) => {
26
+ th.style.cursor = "pointer"
27
+ th.classList.add("select-none")
28
+
29
+ const handler = () => {
30
+ if (activeCol === colIndex) {
31
+ ascending = !ascending
32
+ } else {
33
+ activeCol = colIndex
34
+ ascending = true
35
+ }
36
+
37
+ const tbody = container.querySelector("tbody")
38
+ if (!tbody) return
39
+
40
+ const rows = Array.from(tbody.querySelectorAll("tr"))
41
+ rows.sort((a, b) => {
42
+ const aVal = a.children[colIndex]?.textContent?.trim() ?? ""
43
+ const bVal = b.children[colIndex]?.textContent?.trim() ?? ""
44
+ const aNum = Number(aVal)
45
+ const bNum = Number(bVal)
46
+ const cmp =
47
+ aVal !== "" && bVal !== "" && !isNaN(aNum) && !isNaN(bNum)
48
+ ? aNum - bNum
49
+ : aVal.localeCompare(bVal)
50
+ return ascending ? cmp : -cmp
51
+ })
52
+ for (const row of rows) tbody.appendChild(row)
53
+
54
+ headers.forEach((h, i) => {
55
+ const indicator = h.querySelector("[data-sort]")
56
+ if (indicator) indicator.remove()
57
+ if (i === colIndex) {
58
+ const span = document.createElement("span")
59
+ span.dataset.sort = ""
60
+ span.className = "ml-1 text-muted-foreground"
61
+ span.textContent = ascending ? "↑" : "↓"
62
+ h.appendChild(span)
63
+ }
64
+ })
65
+ }
66
+
67
+ th.addEventListener("click", handler)
68
+ cleanups.push(() => th.removeEventListener("click", handler))
69
+ })
70
+
71
+ return () => cleanups.forEach(fn => fn())
72
+ }, [])
73
+
74
+ return (
75
+ <div ref={ref} className="not-prose">
76
+ <Table {...props} />
77
+ </div>
78
+ )
79
+ }
80
+
81
+ export { TableHeader, TableBody, TableRow, TableHead, TableCell }
@@ -0,0 +1,16 @@
1
+ /** Renders a SoundCloud iframe embed */
2
+ export function SoundCloud(props: { url: string }) {
3
+ const src = `https://w.soundcloud.com/player/?url=${encodeURIComponent(props.url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`
4
+
5
+ return (
6
+ <div className="not-prose my-4 overflow-hidden rounded-lg border">
7
+ <iframe
8
+ className="w-full"
9
+ height={166}
10
+ src={src}
11
+ title="SoundCloud audio"
12
+ allow="autoplay"
13
+ />
14
+ </div>
15
+ )
16
+ }
@@ -0,0 +1,40 @@
1
+ import { Moon, Sun } from "lucide-react"
2
+ import { useHotkey } from "@tanstack/react-hotkeys"
3
+ import { Button } from "../elements/button.tsx"
4
+ import { getCurrentTheme, setTheme } from "../helpers/theme.ts"
5
+
6
+ export function Theme() {
7
+ const handleToggle = () => {
8
+ const oldTheme = getCurrentTheme()
9
+ const newTheme = oldTheme === "light" ? "dark" : "light"
10
+ setTheme(newTheme)
11
+ }
12
+
13
+ useHotkey("T", handleToggle)
14
+
15
+ return (
16
+ <Button
17
+ variant="ghost"
18
+ size="sm"
19
+ onClick={handleToggle}
20
+ title="Change Theme"
21
+ aria-label="Toggle color scheme"
22
+ className="w-full h-auto rounded-xl cursor-pointer text-xs font-normal justify-start text-muted-foreground bg-sidebar-accent/70 border border-border shadow-xs hover:!bg-background px-3 py-2"
23
+ >
24
+ <div className="flex flex-1 gap-2 items-center dark:hidden">
25
+ <Sun className="size-4" />
26
+ <span className="flex-1 text-left">Light Theme</span>
27
+ <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-mono">
28
+ T
29
+ </kbd>
30
+ </div>
31
+ <div className="flex-1 gap-2 items-center hidden dark:flex">
32
+ <Moon className="size-4" />
33
+ <span className="flex-1 text-left">Dark Theme</span>
34
+ <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-mono">
35
+ T
36
+ </kbd>
37
+ </div>
38
+ </Button>
39
+ )
40
+ }
@@ -0,0 +1,99 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react"
2
+ import type { TocItem } from "../helpers/toc.ts"
3
+
4
+ export function Toc(props: { items: TocItem[]; children?: ReactNode }) {
5
+ const activeId = useActiveHeading(props.items)
6
+
7
+ if (props.items.length === 0 && !props.children) return null
8
+
9
+ return (
10
+ <aside className="hidden xl:block w-56 shrink-0">
11
+ <div className="sticky top-20 max-h-[calc(100vh-5rem)] overflow-auto [scrollbar-width:none]">
12
+ {props.children}
13
+ {props.items.length > 0 && (
14
+ <nav>
15
+ <p className="text-sm font-medium mb-3">Contents</p>
16
+ <ul className="text-sm border-l border-border">
17
+ {props.items.map(item => (
18
+ <li key={item.url}>
19
+ <a
20
+ href={item.url}
21
+ data-active={activeId === item.url.slice(1) || undefined}
22
+ className={`-ms-px block py-1.5 border-l border-transparent text-muted-foreground transition-colors hover:text-foreground data-[active]:border-primary data-[active]:text-primary data-[active]:font-medium ${item.depth <= 2 ? "ps-3" : item.depth === 3 ? "ps-6" : "ps-8"}`}
23
+ >
24
+ {item.title}
25
+ </a>
26
+ </li>
27
+ ))}
28
+ </ul>
29
+ </nav>
30
+ )}
31
+ </div>
32
+ </aside>
33
+ )
34
+ }
35
+
36
+ function useActiveHeading(items: TocItem[]) {
37
+ const [activeId, setActiveId] = useState<string>("")
38
+ const observer = useRef<IntersectionObserver | null>(null)
39
+
40
+ useEffect(() => {
41
+ const ids = items.map(item => item.url.slice(1))
42
+ const elements = ids
43
+ .map(id => document.getElementById(id))
44
+ .filter(Boolean) as HTMLElement[]
45
+
46
+ if (elements.length === 0) return
47
+
48
+ const visibleSet = new Map<string, number>()
49
+
50
+ observer.current = new IntersectionObserver(
51
+ entries => {
52
+ for (const entry of entries) {
53
+ if (entry.isIntersecting) {
54
+ visibleSet.set(entry.target.id, entry.time)
55
+ } else {
56
+ visibleSet.delete(entry.target.id)
57
+ }
58
+ }
59
+
60
+ if (visibleSet.size > 0) {
61
+ let latest = ""
62
+ let latestTime = 0
63
+ for (const [id, time] of visibleSet) {
64
+ if (time > latestTime) {
65
+ latest = id
66
+ latestTime = time
67
+ }
68
+ }
69
+ setActiveId(latest)
70
+ } else {
71
+ const viewTop = window.scrollY
72
+ let closest = ""
73
+ let closestDist = Number.POSITIVE_INFINITY
74
+ for (const el of elements) {
75
+ const dist = Math.abs(
76
+ viewTop - el.getBoundingClientRect().top - window.scrollY,
77
+ )
78
+ if (dist < closestDist) {
79
+ closest = el.id
80
+ closestDist = dist
81
+ }
82
+ }
83
+ setActiveId(closest)
84
+ }
85
+ },
86
+ { threshold: 0.98 },
87
+ )
88
+
89
+ for (const el of elements) {
90
+ observer.current.observe(el)
91
+ }
92
+
93
+ return () => {
94
+ observer.current?.disconnect()
95
+ }
96
+ }, [items])
97
+
98
+ return activeId
99
+ }
@@ -0,0 +1,16 @@
1
+ /** Renders a responsive YouTube iframe embed */
2
+ export function YouTube(props: { id: string }) {
3
+ return (
4
+ <div className="not-prose my-4 overflow-hidden rounded-lg border">
5
+ <div className="aspect-video">
6
+ <iframe
7
+ className="h-full w-full"
8
+ src={`https://www.youtube.com/embed/${props.id}`}
9
+ title="YouTube video"
10
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
11
+ allowFullScreen
12
+ />
13
+ </div>
14
+ </div>
15
+ )
16
+ }
@@ -0,0 +1,30 @@
1
+ import type { ComponentProps } from "react"
2
+ import Zoom from "react-medium-image-zoom"
3
+ import "react-medium-image-zoom/dist/styles.css"
4
+
5
+ /** Image component with click-to-zoom and light/dark theme variant support */
6
+ export function ZoomImage(props: ComponentProps<"img">) {
7
+ const src = props.src ?? ""
8
+
9
+ if (src.endsWith("#light")) {
10
+ return (
11
+ <Zoom>
12
+ <img {...props} src={src.slice(0, -6)} className="dark:hidden" />
13
+ </Zoom>
14
+ )
15
+ }
16
+
17
+ if (src.endsWith("#dark")) {
18
+ return (
19
+ <Zoom>
20
+ <img {...props} src={src.slice(0, -5)} className="hidden dark:block" />
21
+ </Zoom>
22
+ )
23
+ }
24
+
25
+ return (
26
+ <Zoom>
27
+ <img {...props} />
28
+ </Zoom>
29
+ )
30
+ }