@brett_lamy/docstream 1.18.1 → 1.19.0

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.
@@ -0,0 +1,958 @@
1
+ /* DocsSite — a GitBook-style documentation site from a folder of Markdown files.
2
+
3
+ Hand it every file you have (`import.meta.glob("./docs/**", { query: "?raw", eager: true })`,
4
+ a fetched repo tree, an in-memory VFS); it keeps the Markdown, builds the sidebar from the
5
+ folders (or SUMMARY.md), and renders each page through docstream with a header, search
6
+ (⌘K), an "On this page" outline, breadcrumbs, previous / next and "Copy page".
7
+ Relative `.md` links between pages navigate in place. Routing is the URL hash by default
8
+ (static hosting needs no rewrites), the History API with `routing="history"`, or in-memory
9
+ with `routing="memory"` for a site embedded in another page. */
10
+ import {
11
+ useCallback,
12
+ useContext,
13
+ useEffect,
14
+ useId,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ type CSSProperties,
19
+ type KeyboardEvent as ReactKeyboardEvent,
20
+ type MouseEvent as ReactMouseEvent,
21
+ type ReactNode,
22
+ type RefObject,
23
+ } from "react"
24
+ import { ArrowLeft, ArrowRight, ChevronRight, FileText, Hash, Menu, Moon, PencilLine, Search, Sun, X } from "lucide-react"
25
+
26
+ import { AssetResolverContext, AssetResolverProvider, type AssetResolver } from "../asset-resolver"
27
+ import { MarkdownContent, type DocRenderOptions, type LivePreviewRenderer, type SourceReferenceRenderer } from "../docs/DocsRenderer"
28
+ import { DocPageActions, type DocPageActionsOptions } from "../docs/PageActions"
29
+ import { documentOutline, type OutlineEntry } from "../gitbook/outline"
30
+ import { parseMarkdown } from "../gitbook/parse"
31
+ import { createDocsSearchIndex, type DocsSearchResult } from "./search"
32
+ import { buildDocsTree, findLinkedAsset, findLinkedPage, type BuildDocsTreeOptions, type DocsFileContent, type DocsFiles, type DocsNavItem, type DocsPage, type DocsTree } from "./tree"
33
+
34
+ export type DocsSiteRouting = "hash" | "history" | "memory"
35
+ export type DocsSiteTheme = "light" | "dark"
36
+
37
+ export interface DocsSiteLink {
38
+ label: string
39
+ href: string
40
+ icon?: ReactNode
41
+ }
42
+
43
+ export interface DocsSiteProps extends Omit<DocRenderOptions, "pageActions"> {
44
+ /** The files to publish: `{ path: content }` or `[{ path, content }]`. Only Markdown is kept. */
45
+ files?: DocsFiles
46
+ /** A tree you built yourself with `buildDocsTree` (wins over `files`). */
47
+ tree?: DocsTree
48
+ treeOptions?: BuildDocsTreeOptions
49
+ /** Site name, in the header and the browser tab. */
50
+ title?: string
51
+ /** Mark shown before the title. */
52
+ logo?: ReactNode
53
+ /** Header links (GitHub, npm, …). */
54
+ links?: DocsSiteLink[]
55
+ /** `"hash"` (default): `#/guides/install`. `"history"`: real paths under `basePath`. `"memory"`: no URL. */
56
+ routing?: DocsSiteRouting
57
+ /** URL prefix for `routing="history"` (e.g. `"/docs"`). */
58
+ basePath?: string
59
+ /** First page for `routing="memory"` (a slug, e.g. `"guides/install"`). */
60
+ initialPath?: string
61
+ /** Called after every navigation. */
62
+ onNavigate?: (page: DocsPage, hash: string) => void
63
+ /** Controlled theme. */
64
+ theme?: DocsSiteTheme
65
+ /** Uncontrolled starting theme (default `"system"`). */
66
+ defaultTheme?: DocsSiteTheme | "system"
67
+ onThemeChange?: (theme: DocsSiteTheme) => void
68
+ /** Show the light / dark switch (default true). */
69
+ themeToggle?: boolean
70
+ /** localStorage key for the reader's theme choice; `false` to not persist (default `"docstream-site-theme"`). */
71
+ themeStorageKey?: string | false
72
+ /** Where `data-theme` goes: the whole document (default, unless memory routing) or just the site. */
73
+ applyThemeTo?: "document" | "site"
74
+ /** Use docstream's built-in light / dark colors (default true). Turn off when your app defines shadcn-style tokens. */
75
+ palette?: boolean
76
+ /** Brand color for links, active items and focus rings. */
77
+ accentColor?: string
78
+ /** ⌘K full-text search (default true). */
79
+ search?: boolean
80
+ /** The "On this page" outline (default true). */
81
+ toc?: boolean
82
+ /** "Copy page ▾" in each page header (default true). A function decides per page. */
83
+ pageActions?: boolean | DocPageActionsOptions | ((page: DocsPage) => boolean | DocPageActionsOptions)
84
+ /** "Edit this page" link, e.g. `(p) => \`https://github.com/me/repo/edit/main/docs/${p.path}\``. */
85
+ editUrl?: (page: DocsPage) => string | undefined
86
+ /** Below the sidebar navigation. */
87
+ sidebarFooter?: ReactNode
88
+ /** Below every page. */
89
+ footer?: ReactNode
90
+ /** Wrap or extend a page's rendered content. */
91
+ renderPage?: (page: DocsPage, content: ReactNode) => ReactNode
92
+ /** Content for an unknown path. */
93
+ notFound?: (path: string) => ReactNode
94
+ /** Keep `document.title` in sync (`"Page — Site"`, default unless memory routing), or format it yourself. */
95
+ documentTitle?: boolean | ((page: DocsPage, siteTitle: string | undefined) => string)
96
+ /** The site's height; it scrolls inside. Default `100dvh` — give an embedded site a fixed height. */
97
+ height?: number | string
98
+ liveRenderer?: LivePreviewRenderer
99
+ sourceRenderer?: SourceReferenceRenderer
100
+ className?: string
101
+ style?: CSSProperties
102
+ }
103
+
104
+ interface Location {
105
+ slug: string
106
+ hash: string
107
+ /** Bumped on every navigation so re-clicking the same anchor scrolls again. */
108
+ key: number
109
+ }
110
+
111
+ const trimSlashes = (s: string) => s.replace(/^\/+|\/+$/g, "")
112
+
113
+ function decode(s: string): string {
114
+ try {
115
+ return decodeURIComponent(s)
116
+ } catch {
117
+ return s
118
+ }
119
+ }
120
+
121
+ /** `#/a/b#c` → slug `a/b`, hash `c`. A bare `#name` (old-style link) is looked up as a slug. */
122
+ function readHashLocation(tree: DocsTree): { slug: string; hash: string } {
123
+ const raw = window.location.hash.slice(1)
124
+ if (!raw.startsWith("/")) {
125
+ const loose = decode(raw)
126
+ if (!loose) return { slug: "", hash: "" }
127
+ const match = tree.bySlug.get(loose) ?? tree.pages.find((p) => p.slug.endsWith(`/${loose}`))
128
+ return match ? { slug: match.slug, hash: "" } : { slug: "", hash: loose }
129
+ }
130
+ const at = raw.indexOf("#")
131
+ return { slug: decode(trimSlashes(at >= 0 ? raw.slice(0, at) : raw)), hash: at >= 0 ? decode(raw.slice(at + 1)) : "" }
132
+ }
133
+
134
+ function readHistoryLocation(basePath: string): { slug: string; hash: string } {
135
+ const base = trimSlashes(basePath)
136
+ let path = trimSlashes(window.location.pathname)
137
+ if (base && (path === base || path.startsWith(`${base}/`))) path = path.slice(base.length)
138
+ return { slug: decode(trimSlashes(path)), hash: decode(window.location.hash.slice(1)) }
139
+ }
140
+
141
+ function useDocsRouter(tree: DocsTree, routing: DocsSiteRouting, basePath: string, initialPath: string | undefined) {
142
+ const read = useCallback((): { slug: string; hash: string } => {
143
+ if (routing === "memory" || typeof window === "undefined") {
144
+ const [slug, hash = ""] = trimSlashes(initialPath ?? "").split("#")
145
+ return { slug: slug ?? "", hash }
146
+ }
147
+ return routing === "history" ? readHistoryLocation(basePath) : readHashLocation(tree)
148
+ // eslint-disable-next-line react-hooks/exhaustive-deps
149
+ }, [routing, basePath, tree])
150
+ const [location, setLocation] = useState<Location>(() => ({ ...read(), key: 0 }))
151
+
152
+ useEffect(() => {
153
+ if (routing === "memory") return
154
+ const sync = () => setLocation((l) => ({ ...read(), key: l.key + 1 }))
155
+ window.addEventListener(routing === "history" ? "popstate" : "hashchange", sync)
156
+ return () => window.removeEventListener(routing === "history" ? "popstate" : "hashchange", sync)
157
+ }, [routing, read])
158
+
159
+ const href = useCallback(
160
+ (slug: string, hash = "") => {
161
+ const h = hash ? `#${hash}` : ""
162
+ if (routing === "history") {
163
+ const base = trimSlashes(basePath)
164
+ return `/${[base, slug].filter(Boolean).join("/")}${slug ? "" : base ? "/" : ""}${h}`
165
+ }
166
+ return `#/${slug}${h}`
167
+ },
168
+ [routing, basePath],
169
+ )
170
+
171
+ const navigate = useCallback(
172
+ (slug: string, hash = "") => {
173
+ if (routing === "hash") {
174
+ const next = href(slug, hash)
175
+ if (window.location.hash !== next) window.history.pushState(null, "", next)
176
+ } else if (routing === "history") {
177
+ window.history.pushState(null, "", href(slug, hash))
178
+ }
179
+ setLocation((l) => ({ slug, hash, key: l.key + 1 }))
180
+ },
181
+ [routing, href],
182
+ )
183
+
184
+ return { location, href, navigate }
185
+ }
186
+
187
+ const prefersDark = () => typeof window !== "undefined" && !!window.matchMedia?.("(prefers-color-scheme: dark)").matches
188
+
189
+ function useSiteTheme(props: Pick<DocsSiteProps, "theme" | "defaultTheme" | "onThemeChange" | "themeStorageKey">, urlParam: boolean) {
190
+ const storageKey = props.themeStorageKey === undefined ? "docstream-site-theme" : props.themeStorageKey
191
+ const [choice, setChoice] = useState<DocsSiteTheme | "system">(() => {
192
+ if (typeof window === "undefined") return props.defaultTheme ?? "system"
193
+ if (urlParam) {
194
+ const q = new URLSearchParams(window.location.search).get("theme")
195
+ if (q === "light" || q === "dark") return q
196
+ }
197
+ if (storageKey) {
198
+ try {
199
+ const saved = window.localStorage.getItem(storageKey)
200
+ if (saved === "light" || saved === "dark") return saved
201
+ } catch {
202
+ // storage blocked
203
+ }
204
+ }
205
+ return props.defaultTheme ?? "system"
206
+ })
207
+ const [systemDark, setSystemDark] = useState(prefersDark)
208
+ useEffect(() => {
209
+ const mq = window.matchMedia?.("(prefers-color-scheme: dark)")
210
+ if (!mq) return
211
+ const on = () => setSystemDark(mq.matches)
212
+ mq.addEventListener?.("change", on)
213
+ return () => mq.removeEventListener?.("change", on)
214
+ }, [])
215
+ const resolved: DocsSiteTheme = props.theme ?? (choice === "system" ? (systemDark ? "dark" : "light") : choice)
216
+ const toggle = () => {
217
+ const next: DocsSiteTheme = resolved === "dark" ? "light" : "dark"
218
+ if (props.theme === undefined) setChoice(next)
219
+ if (storageKey) {
220
+ try {
221
+ window.localStorage.setItem(storageKey, next)
222
+ } catch {
223
+ // storage blocked
224
+ }
225
+ }
226
+ props.onThemeChange?.(next)
227
+ }
228
+ return [resolved, toggle] as const
229
+ }
230
+
231
+ /** True for a plain left click that should navigate in place. */
232
+ const plainClick = (e: ReactMouseEvent) => e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey
233
+
234
+ function containsSlug(item: DocsNavItem, slug: string): boolean {
235
+ if (item.kind === "page" && item.slug === slug) return true
236
+ return item.kind !== "link" && item.children.some((c) => containsSlug(c, slug))
237
+ }
238
+
239
+ interface NavProps {
240
+ items: DocsNavItem[]
241
+ current: string
242
+ href: (slug: string) => string
243
+ go: (slug: string) => void
244
+ depth?: number
245
+ }
246
+
247
+ function NavList({ items, current, href, go, depth = 0 }: NavProps) {
248
+ return (
249
+ <ul className="ds-site-nav-list" data-depth={depth}>
250
+ {items.map((item, i) => (
251
+ <NavEntry key={`${item.kind}:${item.title}:${i}`} item={item} current={current} href={href} go={go} depth={depth} />
252
+ ))}
253
+ </ul>
254
+ )
255
+ }
256
+
257
+ function NavEntry({ item, current, href, go, depth = 0 }: Omit<NavProps, "items"> & { item: DocsNavItem }) {
258
+ const active = containsSlug(item, current)
259
+ const [open, setOpen] = useState(active)
260
+ useEffect(() => {
261
+ if (active) setOpen(true)
262
+ }, [active])
263
+ const childrenId = useId()
264
+
265
+ if (item.kind === "group") {
266
+ return (
267
+ <li className="ds-site-nav-group">
268
+ <div className="ds-site-nav-group-title">{item.title}</div>
269
+ <NavList items={item.children} current={current} href={href} go={go} depth={depth} />
270
+ </li>
271
+ )
272
+ }
273
+ if (item.kind === "link") {
274
+ return (
275
+ <li>
276
+ <a className="ds-site-nav-link" href={item.href} target="_blank" rel="noreferrer">
277
+ {item.title}
278
+ <span className="ds-site-nav-external" aria-hidden="true">↗</span>
279
+ </a>
280
+ </li>
281
+ )
282
+ }
283
+ const hasChildren = item.children.length > 0
284
+ const toggle = hasChildren ? (
285
+ <button
286
+ type="button"
287
+ className="ds-site-nav-toggle"
288
+ aria-expanded={open}
289
+ aria-controls={childrenId}
290
+ aria-label={`${open ? "Collapse" : "Expand"} ${item.title}`}
291
+ onClick={(e) => {
292
+ setOpen((o) => !o)
293
+ }}
294
+ >
295
+ <ChevronRight size={14} aria-hidden="true" />
296
+ </button>
297
+ ) : null
298
+ return (
299
+ <li>
300
+ {item.kind === "page" ? (
301
+ <div className="ds-site-nav-row" data-current={item.slug === current || undefined}>
302
+ <a
303
+ className="ds-site-nav-link"
304
+ href={href(item.slug)}
305
+ aria-current={item.slug === current ? "page" : undefined}
306
+ onClick={(e) => {
307
+ if (!plainClick(e)) return
308
+ e.preventDefault()
309
+ go(item.slug)
310
+ if (hasChildren) setOpen(true)
311
+ }}
312
+ >
313
+ <span className="ds-site-nav-label">{item.title}</span>
314
+ </a>
315
+ {toggle}
316
+ </div>
317
+ ) : (
318
+ <button type="button" className="ds-site-nav-link ds-site-nav-folder" aria-expanded={open} aria-controls={childrenId} onClick={() => setOpen((o) => !o)}>
319
+ <span className="ds-site-nav-label">{item.title}</span>
320
+ <span className="ds-site-nav-toggle" aria-hidden="true">
321
+ <ChevronRight size={14} />
322
+ </span>
323
+ </button>
324
+ )}
325
+ {hasChildren && open ? (
326
+ <div id={childrenId} className="ds-site-nav-children">
327
+ <NavList items={item.children} current={current} href={href} go={go} depth={depth + 1} />
328
+ </div>
329
+ ) : null}
330
+ </li>
331
+ )
332
+ }
333
+
334
+ function SearchDialog({
335
+ tree,
336
+ onClose,
337
+ onPick,
338
+ }: {
339
+ tree: DocsTree
340
+ onClose: () => void
341
+ onPick: (r: DocsSearchResult) => void
342
+ }) {
343
+ const index = useMemo(() => createDocsSearchIndex(tree), [tree])
344
+ const [query, setQuery] = useState("")
345
+ const [active, setActive] = useState(0)
346
+ const results = useMemo(() => index.search(query, 30), [index, query])
347
+ const inputRef = useRef<HTMLInputElement>(null)
348
+ const listId = useId()
349
+ useEffect(() => {
350
+ const previous = document.activeElement as HTMLElement | null
351
+ inputRef.current?.focus()
352
+ return () => previous?.focus?.()
353
+ }, [])
354
+ useEffect(() => setActive(0), [query])
355
+ useEffect(() => {
356
+ document.getElementById(`${listId}-${active}`)?.scrollIntoView?.({ block: "nearest" })
357
+ }, [active, listId])
358
+
359
+ const onKeyDown = (e: ReactKeyboardEvent) => {
360
+ if (e.key === "Escape") {
361
+ e.preventDefault()
362
+ onClose()
363
+ } else if (e.key === "ArrowDown") {
364
+ e.preventDefault()
365
+ setActive((a) => Math.min(a + 1, results.length - 1))
366
+ } else if (e.key === "ArrowUp") {
367
+ e.preventDefault()
368
+ setActive((a) => Math.max(a - 1, 0))
369
+ } else if (e.key === "Enter" && results[active]) {
370
+ e.preventDefault()
371
+ onPick(results[active])
372
+ }
373
+ }
374
+
375
+ const suggestions = query ? null : tree.order.slice(0, 8)
376
+ return (
377
+ <div className="ds-site-search-layer" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
378
+ <div className="ds-site-search" role="dialog" aria-modal="true" aria-label="Search documentation" onKeyDown={onKeyDown}>
379
+ <div className="ds-site-search-field">
380
+ <Search size={17} aria-hidden="true" />
381
+ <input
382
+ ref={inputRef}
383
+ type="search"
384
+ placeholder="Search the docs…"
385
+ value={query}
386
+ onChange={(e) => setQuery(e.target.value)}
387
+ role="combobox"
388
+ aria-expanded={results.length > 0}
389
+ aria-controls={listId}
390
+ aria-activedescendant={results[active] ? `${listId}-${active}` : undefined}
391
+ aria-autocomplete="list"
392
+ />
393
+ <kbd>esc</kbd>
394
+ </div>
395
+ {suggestions ? (
396
+ <div className="ds-site-search-results" id={listId} role="listbox" aria-label="Pages">
397
+ <div className="ds-site-search-caption">Pages</div>
398
+ {suggestions.map((p) => (
399
+ <button key={p.slug} type="button" className="ds-site-search-hit" role="option" aria-selected={false} onClick={() => onPick({ page: p, snippet: "", score: 0 })}>
400
+ <FileText size={15} aria-hidden="true" />
401
+ <span className="ds-site-search-hit-main">
402
+ <span className="ds-site-search-hit-title">{p.title}</span>
403
+ {p.breadcrumbs.length ? <span className="ds-site-search-hit-path">{p.breadcrumbs.join(" › ")}</span> : null}
404
+ </span>
405
+ </button>
406
+ ))}
407
+ </div>
408
+ ) : results.length ? (
409
+ <div className="ds-site-search-results" id={listId} role="listbox" aria-label="Results">
410
+ {results.map((r, i) => (
411
+ <button
412
+ key={`${r.page.slug}#${r.anchor ?? ""}`}
413
+ id={`${listId}-${i}`}
414
+ type="button"
415
+ className="ds-site-search-hit"
416
+ role="option"
417
+ aria-selected={i === active}
418
+ onMouseMove={() => setActive(i)}
419
+ onClick={() => onPick(r)}
420
+ >
421
+ {r.heading ? <Hash size={15} aria-hidden="true" /> : <FileText size={15} aria-hidden="true" />}
422
+ <span className="ds-site-search-hit-main">
423
+ <span className="ds-site-search-hit-title">
424
+ {r.heading ? (
425
+ <>
426
+ {r.heading} <span className="ds-site-search-hit-page">· {r.page.title}</span>
427
+ </>
428
+ ) : (
429
+ r.page.title
430
+ )}
431
+ </span>
432
+ {r.snippet ? (
433
+ <span className="ds-site-search-hit-snippet">
434
+ {r.highlight ? (
435
+ <>
436
+ {r.snippet.slice(0, r.highlight[0])}
437
+ <mark>{r.snippet.slice(r.highlight[0], r.highlight[1])}</mark>
438
+ {r.snippet.slice(r.highlight[1])}
439
+ </>
440
+ ) : (
441
+ r.snippet
442
+ )}
443
+ </span>
444
+ ) : null}
445
+ </span>
446
+ </button>
447
+ ))}
448
+ </div>
449
+ ) : (
450
+ <div className="ds-site-search-empty">No results for “{query}”</div>
451
+ )}
452
+ </div>
453
+ </div>
454
+ )
455
+ }
456
+
457
+ function useActiveHeading(scroller: RefObject<HTMLElement | null>, outline: OutlineEntry[], pageKey: string) {
458
+ const [active, setActive] = useState<string | undefined>(outline[0]?.id)
459
+ useEffect(() => {
460
+ const el = scroller.current
461
+ if (!el || !outline.length) return
462
+ let frame = 0
463
+ const measure = () => {
464
+ frame = 0
465
+ const top = el.getBoundingClientRect().top
466
+ let current = outline[0]?.id
467
+ for (const entry of outline) {
468
+ const h = el.querySelector<HTMLElement>(`[id="${CSS.escape(entry.id)}"]`)
469
+ if (!h) continue
470
+ if (h.getBoundingClientRect().top - top <= 96) current = entry.id
471
+ else break
472
+ }
473
+ // At the very bottom the last heading may never reach the top; it is still the one being read.
474
+ if (el.scrollTop + el.clientHeight >= el.scrollHeight - 4) current = outline[outline.length - 1]?.id ?? current
475
+ setActive(current)
476
+ }
477
+ const onScroll = () => {
478
+ if (!frame) frame = requestAnimationFrame(measure)
479
+ }
480
+ measure()
481
+ el.addEventListener("scroll", onScroll, { passive: true })
482
+ return () => {
483
+ el.removeEventListener("scroll", onScroll)
484
+ if (frame) cancelAnimationFrame(frame)
485
+ }
486
+ }, [scroller, outline, pageKey])
487
+ return active
488
+ }
489
+
490
+ const MIME: Record<string, string> = {
491
+ json: "application/json",
492
+ yaml: "application/yaml",
493
+ yml: "application/yaml",
494
+ svg: "image/svg+xml",
495
+ png: "image/png",
496
+ jpg: "image/jpeg",
497
+ jpeg: "image/jpeg",
498
+ gif: "image/gif",
499
+ webp: "image/webp",
500
+ mp4: "video/mp4",
501
+ webm: "video/webm",
502
+ stl: "model/stl",
503
+ rrweb: "application/json",
504
+ txt: "text/plain",
505
+ csv: "text/csv",
506
+ }
507
+
508
+ /**
509
+ * Serves the tree's assets to the current page: a link resolved against the page's path that
510
+ * names a file in the VFS loads as a `blob:` URL of it (made once per file and content, revoked
511
+ * when the content changes or the site unmounts). Anything else goes to the host's resolver.
512
+ */
513
+ function useVfsAssets(tree: DocsTree, page: DocsPage | undefined): AssetResolver | null {
514
+ const outer = useContext(AssetResolverContext)
515
+ const urls = useRef(new Map<string, { content: DocsFileContent; url: string }>())
516
+ useEffect(() => {
517
+ const map = urls.current
518
+ return () => {
519
+ for (const { url } of map.values()) URL.revokeObjectURL(url)
520
+ map.clear()
521
+ }
522
+ }, [])
523
+ return useMemo(() => {
524
+ if (!page || !tree.assets.size || typeof URL.createObjectURL !== "function") return outer
525
+ return (href: string) => {
526
+ const asset = findLinkedAsset(tree, page, href)
527
+ if (!asset) return outer ? outer(href) : undefined
528
+ const cached = urls.current.get(asset.path)
529
+ if (cached?.content === asset.content) return cached.url
530
+ if (cached) URL.revokeObjectURL(cached.url)
531
+ const ext = asset.path.slice(asset.path.lastIndexOf(".") + 1).toLowerCase()
532
+ const blob = typeof asset.content === "string" ? new Blob([asset.content], { type: MIME[ext] ?? "text/plain" }) : asset.content
533
+ const url = URL.createObjectURL(blob)
534
+ urls.current.set(asset.path, { content: asset.content, url })
535
+ return url
536
+ }
537
+ }, [tree, page, outer])
538
+ }
539
+
540
+ function cssLength(v: number | string | undefined, fallback: string) {
541
+ return v === undefined ? fallback : typeof v === "number" ? `${v}px` : v
542
+ }
543
+
544
+ /** A complete docs site from Markdown files: sidebar from the file tree, search, outline, routing. */
545
+ export function DocsSite(props: DocsSiteProps) {
546
+ const {
547
+ files,
548
+ treeOptions,
549
+ title,
550
+ logo,
551
+ links = [],
552
+ routing = "hash",
553
+ basePath = "",
554
+ initialPath,
555
+ onNavigate,
556
+ themeToggle = true,
557
+ palette = true,
558
+ accentColor,
559
+ search = true,
560
+ toc = true,
561
+ pageActions = true,
562
+ editUrl,
563
+ sidebarFooter,
564
+ footer,
565
+ renderPage,
566
+ notFound,
567
+ height,
568
+ liveRenderer,
569
+ sourceRenderer,
570
+ demoResolver,
571
+ demoRuntime,
572
+ demoDependencies,
573
+ demoVFS,
574
+ onTaskStateChange,
575
+ documentProperties,
576
+ className,
577
+ style,
578
+ } = props
579
+ const tree = useMemo(() => props.tree ?? buildDocsTree(files ?? {}, treeOptions), [props.tree, files, treeOptions])
580
+ const { location, href, navigate } = useDocsRouter(tree, routing, basePath, initialPath)
581
+ const [theme, toggleTheme] = useSiteTheme(props, routing !== "memory")
582
+ const applyThemeTo = props.applyThemeTo ?? (routing === "memory" ? "site" : "document")
583
+ useEffect(() => {
584
+ if (applyThemeTo === "document") document.documentElement.dataset.theme = theme
585
+ }, [applyThemeTo, theme])
586
+
587
+ const page = tree.bySlug.get(location.slug)
588
+ const home = tree.bySlug.get("") ?? tree.order[0]
589
+ // A site without a root README opens on its first page.
590
+ const shown = page ?? (location.slug === "" ? home : undefined)
591
+
592
+ const [navOpen, setNavOpen] = useState(false)
593
+ const [searchOpen, setSearchOpen] = useState(false)
594
+ const scrollerRef = useRef<HTMLDivElement>(null)
595
+ const articleRef = useRef<HTMLElement>(null)
596
+ const rootRef = useRef<HTMLDivElement>(null)
597
+
598
+ const go = useCallback(
599
+ (slug: string, hash = "") => {
600
+ navigate(slug, hash)
601
+ setNavOpen(false)
602
+ },
603
+ [navigate],
604
+ )
605
+
606
+ // After a navigation: scroll to the anchor, else to the top, and tell the host.
607
+ useEffect(() => {
608
+ const el = scrollerRef.current
609
+ if (!el) return
610
+ const target = location.hash ? el.querySelector<HTMLElement>(`[id="${CSS.escape(location.hash)}"]`) : null
611
+ if (target) target.scrollIntoView?.({ block: "start" })
612
+ else el.scrollTop = 0
613
+ if (shown) onNavigate?.(shown, location.hash)
614
+ // eslint-disable-next-line react-hooks/exhaustive-deps
615
+ }, [location.key, shown?.slug])
616
+
617
+ const siteTitle = title
618
+ useEffect(() => {
619
+ const opt = props.documentTitle ?? routing !== "memory"
620
+ if (!opt || !shown) return
621
+ document.title =
622
+ typeof opt === "function" ? opt(shown, siteTitle) : siteTitle && shown.title !== siteTitle ? `${shown.title} — ${siteTitle}` : shown.title
623
+ }, [shown, siteTitle, props.documentTitle, routing])
624
+
625
+ // Keyboard: ⌘K / Ctrl+K anywhere, "/" outside text fields.
626
+ useEffect(() => {
627
+ if (!search) return
628
+ const onKey = (e: KeyboardEvent) => {
629
+ const typing = e.target instanceof HTMLElement && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName))
630
+ if ((e.key === "k" || e.key === "K") && (e.metaKey || e.ctrlKey)) {
631
+ // An embedded site only answers while it has focus, so a host page keeps its own ⌘K —
632
+ // and a site leaves ⌘K to a site nested in it that has focus.
633
+ const focusedSite = document.activeElement?.closest(".ds-site")
634
+ if (routing === "memory" ? focusedSite !== rootRef.current : focusedSite && focusedSite !== rootRef.current) return
635
+ e.preventDefault()
636
+ setSearchOpen((o) => !o)
637
+ } else if (e.key === "/" && !typing && routing !== "memory") {
638
+ e.preventDefault()
639
+ setSearchOpen(true)
640
+ }
641
+ }
642
+ window.addEventListener("keydown", onKey)
643
+ return () => window.removeEventListener("keydown", onKey)
644
+ }, [search, routing])
645
+
646
+ const outline = useMemo(() => {
647
+ if (!shown || !toc) return []
648
+ return documentOutline(parseMarkdown(shown.body)).filter((e) => e.level === 2 || e.level === 3)
649
+ }, [shown, toc])
650
+ const activeHeading = useActiveHeading(scrollerRef, outline, shown?.slug ?? "")
651
+
652
+ const resolveAsset = useVfsAssets(tree, shown)
653
+
654
+ // Links written as relative `.md` paths (or `#anchors`) get real route hrefs, so they
655
+ // navigate in place and still open in a new tab. Demos keep their own links.
656
+ useEffect(() => {
657
+ const article = articleRef.current
658
+ if (!article || !shown) return
659
+ const rewrite = () => {
660
+ for (const a of article.querySelectorAll<HTMLAnchorElement>("a[href]")) {
661
+ // Only this site's own demos: a site embedded in a demo still rewrites its links.
662
+ const skip = a.closest(".docs-demo, .ds-site-pager, .ds-site-pagehead")
663
+ if (skip && article.contains(skip)) continue
664
+ if (a.dataset.dsHref !== undefined) continue
665
+ const original = a.getAttribute("href") ?? ""
666
+ const linked = findLinkedPage(tree, shown, original)
667
+ if (!linked) {
668
+ // A link to a file in the VFS (`[the spec](api.json)`) opens that file.
669
+ if (resolveAsset && findLinkedAsset(tree, shown, original)) {
670
+ const url = resolveAsset(original)
671
+ if (typeof url === "string") {
672
+ a.dataset.dsHref = original
673
+ a.setAttribute("href", url)
674
+ a.setAttribute("target", "_blank")
675
+ }
676
+ }
677
+ continue
678
+ }
679
+ a.dataset.dsHref = original
680
+ a.dataset.dsSlug = linked.page.slug
681
+ a.dataset.dsHash = linked.hash
682
+ a.setAttribute("href", href(linked.page.slug, linked.hash))
683
+ a.removeAttribute("target")
684
+ }
685
+ }
686
+ rewrite()
687
+ // Lazy blocks (content refs inside tabs, expandables) mount later.
688
+ const observer = new MutationObserver(rewrite)
689
+ observer.observe(article, { childList: true, subtree: true })
690
+ return () => observer.disconnect()
691
+ }, [shown, tree, href, resolveAsset])
692
+
693
+ const onArticleClick = (e: ReactMouseEvent) => {
694
+ if (!plainClick(e) || e.defaultPrevented) return
695
+ const a = (e.target as HTMLElement).closest<HTMLAnchorElement>("a[data-ds-slug]")
696
+ // A link that belongs to a site nested in this one is that site's to follow.
697
+ if (!a || a.target === "_blank" || a.closest(".ds-site-main") !== articleRef.current) return
698
+ e.preventDefault()
699
+ go(a.dataset.dsSlug ?? "", a.dataset.dsHash ?? "")
700
+ }
701
+
702
+ const index = shown ? tree.order.indexOf(shown) : -1
703
+ const prev = index > 0 ? tree.order[index - 1] : undefined
704
+ const next = index >= 0 ? tree.order[index + 1] : undefined
705
+
706
+ const actions = shown ? (typeof pageActions === "function" ? pageActions(shown) : pageActions) : false
707
+ const pageMarkdown = shown ? `# ${shown.title}\n\n${shown.description ? `${shown.description}\n\n` : ""}${shown.body}` : ""
708
+ const pageUrl = shown && typeof window !== "undefined" && routing !== "memory" ? new URL(href(shown.slug), window.location.href).href : undefined
709
+
710
+ const content = shown ? (
711
+ <AssetResolverProvider resolve={resolveAsset}>
712
+ <MarkdownContent
713
+ key={shown.slug}
714
+ markdown={shown.body}
715
+ liveRenderer={liveRenderer}
716
+ sourceRenderer={sourceRenderer}
717
+ demoResolver={demoResolver}
718
+ demoRuntime={demoRuntime}
719
+ demoDependencies={demoDependencies}
720
+ demoVFS={demoVFS}
721
+ onTaskStateChange={onTaskStateChange}
722
+ documentProperties={documentProperties}
723
+ />
724
+ </AssetResolverProvider>
725
+ ) : null
726
+
727
+ const rootStyle = {
728
+ ...style,
729
+ "--ds-site-height": cssLength(height, "100dvh"),
730
+ ...(accentColor ? { "--primary": accentColor, "--ds-site-accent": accentColor } : {}),
731
+ } as CSSProperties
732
+
733
+ const brand = (
734
+ <a
735
+ className="ds-site-brand"
736
+ href={href("")}
737
+ onClick={(e) => {
738
+ if (!plainClick(e)) return
739
+ e.preventDefault()
740
+ go("")
741
+ }}
742
+ >
743
+ {logo ? <span className="ds-site-logo">{logo}</span> : null}
744
+ {title ? <span className="ds-site-title">{title}</span> : null}
745
+ </a>
746
+ )
747
+ const navTree = <NavList items={tree.nav} current={shown?.slug ?? location.slug} href={(s) => href(s)} go={(s) => go(s)} />
748
+
749
+ return (
750
+ <div
751
+ ref={rootRef}
752
+ className={["ds-site", className].filter(Boolean).join(" ")}
753
+ data-theme={theme}
754
+ data-palette={palette ? "" : undefined}
755
+ data-nav-open={navOpen || undefined}
756
+ style={rootStyle}
757
+ >
758
+ <header className="ds-site-header">
759
+ <button type="button" className="ds-site-icon-btn ds-site-menu" aria-label="Open navigation" aria-expanded={navOpen} onClick={() => setNavOpen(true)}>
760
+ <Menu size={18} aria-hidden="true" />
761
+ </button>
762
+ {brand}
763
+ <div className="ds-site-header-spacer" />
764
+ {search ? (
765
+ <button type="button" className="ds-site-search-btn" onClick={() => setSearchOpen(true)} aria-label="Search documentation" aria-keyshortcuts="Meta+K Control+K">
766
+ <Search size={15} aria-hidden="true" />
767
+ <span className="ds-site-search-btn-label">Search…</span>
768
+ <kbd>⌘K</kbd>
769
+ </button>
770
+ ) : null}
771
+ {links.length ? (
772
+ <nav className="ds-site-links" aria-label="Site links">
773
+ {links.map((l) => (
774
+ <a key={l.href} href={l.href} target="_blank" rel="noreferrer" className="ds-site-header-link">
775
+ {l.icon ?? null}
776
+ <span>{l.label}</span>
777
+ </a>
778
+ ))}
779
+ </nav>
780
+ ) : null}
781
+ {themeToggle ? (
782
+ <button type="button" className="ds-site-icon-btn" onClick={toggleTheme} aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}>
783
+ {theme === "dark" ? <Sun size={16} aria-hidden="true" /> : <Moon size={16} aria-hidden="true" />}
784
+ </button>
785
+ ) : null}
786
+ </header>
787
+
788
+ <div className="ds-site-body">
789
+ <nav className="ds-site-sidebar" aria-label="Documentation">
790
+ <div className="ds-site-sidebar-scroll">{navTree}</div>
791
+ {sidebarFooter ? <div className="ds-site-sidebar-foot">{sidebarFooter}</div> : null}
792
+ </nav>
793
+
794
+ <div className="ds-site-scroll" ref={scrollerRef}>
795
+ <div className="ds-site-columns">
796
+ <main className="ds-site-main" ref={articleRef} onClick={onArticleClick}>
797
+ {shown ? (
798
+ <article className="ds-site-article">
799
+ <div className="ds-site-pagehead">
800
+ <div className="ds-site-pagehead-row">
801
+ {shown.breadcrumbs.length ? (
802
+ <nav className="ds-site-crumbs" aria-label="Breadcrumb">
803
+ {shown.breadcrumbs.map((c, i) => (
804
+ <span key={i}>
805
+ {i ? <ChevronRight size={12} aria-hidden="true" /> : null}
806
+ {c}
807
+ </span>
808
+ ))}
809
+ </nav>
810
+ ) : (
811
+ <span />
812
+ )}
813
+ {actions ? (
814
+ <DocPageActions
815
+ markdown={pageMarkdown}
816
+ resolver={demoResolver}
817
+ pageUrl={pageUrl}
818
+ {...(actions === true ? {} : actions)}
819
+ />
820
+ ) : null}
821
+ </div>
822
+ <h1 className="ds-site-page-title">{shown.title}</h1>
823
+ {shown.description ? <p className="ds-site-lede">{shown.description}</p> : null}
824
+ </div>
825
+ {renderPage ? renderPage(shown, content) : content}
826
+ <footer className="ds-site-pagefoot">
827
+ {editUrl?.(shown) ? (
828
+ <a className="ds-site-edit" href={editUrl(shown)} target="_blank" rel="noreferrer">
829
+ <PencilLine size={14} aria-hidden="true" />
830
+ Edit this page
831
+ </a>
832
+ ) : null}
833
+ <nav className="ds-site-pager" aria-label="Pagination">
834
+ {prev ? (
835
+ <a
836
+ className="ds-site-pager-link"
837
+ data-dir="prev"
838
+ href={href(prev.slug)}
839
+ onClick={(e) => {
840
+ if (!plainClick(e)) return
841
+ e.preventDefault()
842
+ go(prev.slug)
843
+ }}
844
+ >
845
+ <small>
846
+ <ArrowLeft size={12} aria-hidden="true" /> Previous
847
+ </small>
848
+ <strong>{prev.title}</strong>
849
+ </a>
850
+ ) : (
851
+ <span />
852
+ )}
853
+ {next ? (
854
+ <a
855
+ className="ds-site-pager-link"
856
+ data-dir="next"
857
+ href={href(next.slug)}
858
+ onClick={(e) => {
859
+ if (!plainClick(e)) return
860
+ e.preventDefault()
861
+ go(next.slug)
862
+ }}
863
+ >
864
+ <small>
865
+ Next <ArrowRight size={12} aria-hidden="true" />
866
+ </small>
867
+ <strong>{next.title}</strong>
868
+ </a>
869
+ ) : null}
870
+ </nav>
871
+ {footer ? <div className="ds-site-footer">{footer}</div> : null}
872
+ </footer>
873
+ </article>
874
+ ) : (
875
+ <article className="ds-site-article ds-site-notfound">
876
+ {notFound ? (
877
+ notFound(location.slug)
878
+ ) : (
879
+ <>
880
+ <h1 className="ds-site-page-title">Page not found</h1>
881
+ <p className="ds-site-lede">
882
+ Nothing lives at <code>/{location.slug}</code>.{" "}
883
+ <a
884
+ href={href("")}
885
+ onClick={(e) => {
886
+ if (!plainClick(e)) return
887
+ e.preventDefault()
888
+ go("")
889
+ }}
890
+ >
891
+ Back to the start
892
+ </a>
893
+ </p>
894
+ </>
895
+ )}
896
+ </article>
897
+ )}
898
+ </main>
899
+ {toc && outline.length > 1 ? (
900
+ <aside className="ds-site-toc" aria-label="On this page">
901
+ <div className="ds-site-toc-title">On this page</div>
902
+ <ul>
903
+ {outline.map((entry, i) => (
904
+ <li key={`${entry.id}-${i}`} data-level={entry.level}>
905
+ <a
906
+ href={href(shown!.slug, entry.id)}
907
+ aria-current={entry.id === activeHeading ? "location" : undefined}
908
+ onClick={(e) => {
909
+ if (!plainClick(e)) return
910
+ e.preventDefault()
911
+ go(shown!.slug, entry.id)
912
+ }}
913
+ >
914
+ {entry.text}
915
+ </a>
916
+ </li>
917
+ ))}
918
+ </ul>
919
+ </aside>
920
+ ) : null}
921
+ </div>
922
+ </div>
923
+ </div>
924
+
925
+ {navOpen ? (
926
+ <div className="ds-site-drawer-layer" onMouseDown={(e) => e.target === e.currentTarget && setNavOpen(false)}>
927
+ <nav
928
+ className="ds-site-drawer"
929
+ aria-label="Documentation"
930
+ onKeyDown={(e) => {
931
+ if (e.key === "Escape") setNavOpen(false)
932
+ }}
933
+ >
934
+ <div className="ds-site-drawer-head">
935
+ {brand}
936
+ <button type="button" className="ds-site-icon-btn" aria-label="Close navigation" onClick={() => setNavOpen(false)} autoFocus>
937
+ <X size={18} aria-hidden="true" />
938
+ </button>
939
+ </div>
940
+ <div className="ds-site-sidebar-scroll">{navTree}</div>
941
+ {sidebarFooter ? <div className="ds-site-sidebar-foot">{sidebarFooter}</div> : null}
942
+ </nav>
943
+ </div>
944
+ ) : null}
945
+
946
+ {searchOpen ? (
947
+ <SearchDialog
948
+ tree={tree}
949
+ onClose={() => setSearchOpen(false)}
950
+ onPick={(r) => {
951
+ setSearchOpen(false)
952
+ go(r.page.slug, r.anchor ?? "")
953
+ }}
954
+ />
955
+ ) : null}
956
+ </div>
957
+ )
958
+ }