@brett_lamy/docstream 1.18.1 → 1.19.1
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/README.md +65 -19
- package/dist/docs/DocsRenderer.d.ts.map +1 -1
- package/dist/docs/DocsRenderer.js +14 -3
- package/dist/docs/DocsRenderer.js.map +1 -1
- package/dist/gitbook/parse.js +10 -2
- package/dist/gitbook/parse.js.map +1 -1
- package/dist/openapi/OpenApiOperation.d.ts.map +1 -1
- package/dist/openapi/OpenApiOperation.js +13 -5
- package/dist/openapi/OpenApiOperation.js.map +1 -1
- package/dist/replay/ReplayStageImpl.d.ts.map +1 -1
- package/dist/replay/ReplayStageImpl.js +4 -1
- package/dist/replay/ReplayStageImpl.js.map +1 -1
- package/dist/site/DocsSite.d.ts +74 -0
- package/dist/site/DocsSite.d.ts.map +1 -0
- package/dist/site/DocsSite.js +479 -0
- package/dist/site/DocsSite.js.map +1 -0
- package/dist/site/index.d.ts +7 -0
- package/dist/site/index.d.ts.map +1 -0
- package/dist/site/index.js +5 -0
- package/dist/site/index.js.map +1 -0
- package/dist/site/search.d.ts +28 -0
- package/dist/site/search.d.ts.map +1 -0
- package/dist/site/search.js +125 -0
- package/dist/site/search.js.map +1 -0
- package/dist/site/site.css +816 -0
- package/dist/site/tree.d.ts +105 -0
- package/dist/site/tree.d.ts.map +1 -0
- package/dist/site/tree.js +411 -0
- package/dist/site/tree.js.map +1 -0
- package/dist/styles.css +1 -0
- package/package.json +8 -1
- package/src/docs/DocsRenderer.tsx +14 -3
- package/src/gitbook/parse.ts +10 -2
- package/src/openapi/OpenApiOperation.tsx +13 -5
- package/src/replay/ReplayStageImpl.tsx +4 -1
- package/src/site/DocsSite.tsx +958 -0
- package/src/site/index.ts +18 -0
- package/src/site/search.ts +138 -0
- package/src/site/site.css +816 -0
- package/src/site/tree.ts +482 -0
- package/src/styles.css +1 -0
|
@@ -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
|
+
}
|