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,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
|
+
}
|