@brett_lamy/docstream 1.0.0 → 1.1.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.
- package/README.md +102 -0
- package/package.json +5 -1
- package/src/demo/DemoViewer.tsx +705 -0
- package/src/demo/context.ts +13 -0
- package/src/demo/glob.ts +188 -0
- package/src/demo/index.ts +15 -0
- package/src/demo/markdown.ts +102 -0
- package/src/demo/types.ts +53 -0
- package/src/docs/CollapsibleCode.tsx +54 -0
- package/src/docs/DocsRenderer.tsx +147 -36
- package/src/docs/PageActions.tsx +219 -0
- package/src/docs/controls.tsx +138 -0
- package/src/docs/copy.tsx +82 -0
- package/src/docs/tabs-sync.ts +61 -0
- package/src/gitbook/ast.ts +35 -0
- package/src/gitbook/index.ts +1 -1
- package/src/gitbook/parse.ts +48 -1
- package/src/gitbook/serialize.ts +16 -1
- package/src/index.ts +35 -2
- package/src/playground/PlaygroundStreamdown.tsx +14 -3
- package/src/playground/ReactCodePreview.tsx +8 -37
- package/src/streamdown.tsx +21 -3
- package/src/styles.css +917 -1
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/* The demo viewer for `{% demo src="<page>/<example>" %}`.
|
|
2
|
+
|
|
3
|
+
Multi-file demos get the gallery viewer: a header (title, description, Preview | Code, variants,
|
|
4
|
+
viewport widths, copy, fullscreen), a resizable preview frame on a dotted stage, and a Code view
|
|
5
|
+
with a file tree and highlighted source. Single-file demos keep the ReactDemo card: the preview,
|
|
6
|
+
then a collapsible, line-numbered code panel. Either way the component renders in-page (lazy
|
|
7
|
+
`load()`, mounted once the card nears the viewport) and the code shown is the real file. */
|
|
8
|
+
import {
|
|
9
|
+
Component,
|
|
10
|
+
createElement,
|
|
11
|
+
useEffect,
|
|
12
|
+
useId,
|
|
13
|
+
useMemo,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
type CSSProperties,
|
|
17
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
18
|
+
type PointerEvent as ReactPointerEvent,
|
|
19
|
+
type ReactNode,
|
|
20
|
+
type MutableRefObject,
|
|
21
|
+
} from "react"
|
|
22
|
+
import { motion, useReducedMotion } from "framer-motion"
|
|
23
|
+
import { ExternalLink, FileCode2, Folder, Maximize2, Monitor, Smartphone, Tablet, X } from "lucide-react"
|
|
24
|
+
|
|
25
|
+
import type { DemoLayout, DemoNode, DemoViewport } from "../gitbook/ast"
|
|
26
|
+
import { parseInline } from "../gitbook/inline"
|
|
27
|
+
import { CollapsibleCode } from "../docs/CollapsibleCode"
|
|
28
|
+
import { HighlightedCode } from "../docs/HighlightedCode"
|
|
29
|
+
import { CopyButton } from "../docs/copy"
|
|
30
|
+
import { PillTabs, Segmented, rovingKeyDown } from "../docs/controls"
|
|
31
|
+
import { useDemoResolver } from "./context"
|
|
32
|
+
import { languageForPath } from "./glob"
|
|
33
|
+
import { defaultDemoMarkdown } from "./markdown"
|
|
34
|
+
import type { DemoComponent, DemoFile, DemoMeta, DemoResolver, DemoVariant } from "./types"
|
|
35
|
+
|
|
36
|
+
export interface DemoViewerProps {
|
|
37
|
+
/** `<page>/<example>` id handed to the resolver. */
|
|
38
|
+
src: string
|
|
39
|
+
/** Defaults to the nearest `demoResolver` provided to a renderer. */
|
|
40
|
+
resolver?: DemoResolver
|
|
41
|
+
title?: string
|
|
42
|
+
description?: string
|
|
43
|
+
/** Preview height (px number, digits, or any CSS length). */
|
|
44
|
+
height?: number | string
|
|
45
|
+
layout?: DemoLayout
|
|
46
|
+
variants?: DemoVariant[]
|
|
47
|
+
/** Initial viewport of the multi-file preview. */
|
|
48
|
+
viewport?: DemoViewport
|
|
49
|
+
className?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ---------- data ---------- */
|
|
53
|
+
|
|
54
|
+
type Loaded<T> = { status: "loading" } | { status: "ready"; value: T } | { status: "error"; error: Error }
|
|
55
|
+
|
|
56
|
+
const settled = new WeakMap<DemoResolver, Map<string, Loaded<unknown>>>()
|
|
57
|
+
function cacheFor(resolver: DemoResolver) {
|
|
58
|
+
let cache = settled.get(resolver)
|
|
59
|
+
if (!cache) settled.set(resolver, (cache = new Map()))
|
|
60
|
+
return cache
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolves `run()` once `active`, remembering settled values so remounts don't flash. */
|
|
64
|
+
function useResolved<T>(resolver: DemoResolver | undefined, key: string, active: boolean, run: () => Promise<T>): Loaded<T> {
|
|
65
|
+
const cached = resolver ? (cacheFor(resolver).get(key) as Loaded<T> | undefined) : undefined
|
|
66
|
+
const [state, setState] = useState<Loaded<T>>(cached ?? { status: "loading" })
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!resolver) return
|
|
69
|
+
const hit = cacheFor(resolver).get(key) as Loaded<T> | undefined
|
|
70
|
+
if (hit && hit.status !== "loading") {
|
|
71
|
+
setState(hit)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (!active) {
|
|
75
|
+
setState({ status: "loading" })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
let live = true
|
|
79
|
+
run().then(
|
|
80
|
+
(value) => {
|
|
81
|
+
const next: Loaded<T> = { status: "ready", value }
|
|
82
|
+
cacheFor(resolver).set(key, next)
|
|
83
|
+
if (live) setState(next)
|
|
84
|
+
},
|
|
85
|
+
(cause: unknown) => {
|
|
86
|
+
const error = cause instanceof Error ? cause : new Error(String(cause))
|
|
87
|
+
if (live) setState({ status: "error", error })
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
return () => {
|
|
91
|
+
live = false
|
|
92
|
+
}
|
|
93
|
+
// `run` closes over resolver + key only.
|
|
94
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
95
|
+
}, [resolver, key, active])
|
|
96
|
+
return state
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const useMeta = (resolver: DemoResolver | undefined, src: string) =>
|
|
100
|
+
useResolved<DemoMeta | undefined>(resolver, `meta:${src}`, true, () => resolver!.meta(src))
|
|
101
|
+
const useFiles = (resolver: DemoResolver | undefined, src: string, active: boolean) =>
|
|
102
|
+
useResolved<DemoFile[]>(resolver, `files:${src}`, active, () => resolver!.files(src))
|
|
103
|
+
const useComponent = (resolver: DemoResolver | undefined, src: string, active: boolean) =>
|
|
104
|
+
useResolved<DemoComponent>(resolver, `load:${src}`, active, () => resolver!.load(src))
|
|
105
|
+
|
|
106
|
+
/** True once the element has come within `margin` of the viewport (stays true). */
|
|
107
|
+
function useNearViewport<T extends Element>(margin = "400px 0px") {
|
|
108
|
+
const ref = useRef<T | null>(null)
|
|
109
|
+
const [near, setNear] = useState(false)
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const el = ref.current
|
|
112
|
+
if (!el || near) return
|
|
113
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
114
|
+
setNear(true)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
118
|
+
if (entry?.isIntersecting) setNear(true)
|
|
119
|
+
}, { rootMargin: margin })
|
|
120
|
+
observer.observe(el)
|
|
121
|
+
return () => observer.disconnect()
|
|
122
|
+
}, [near, margin])
|
|
123
|
+
return [ref, near] as const
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* ---------- pieces ---------- */
|
|
127
|
+
|
|
128
|
+
class DemoErrorBoundary extends Component<{ src: string; children?: ReactNode }, { error: Error | null }> {
|
|
129
|
+
override state: { error: Error | null } = { error: null }
|
|
130
|
+
static getDerivedStateFromError(error: unknown) {
|
|
131
|
+
return { error: error instanceof Error ? error : new Error(String(error)) }
|
|
132
|
+
}
|
|
133
|
+
override componentDidUpdate(prev: { src: string }) {
|
|
134
|
+
if (prev.src !== this.props.src && this.state.error) this.setState({ error: null })
|
|
135
|
+
}
|
|
136
|
+
override render() {
|
|
137
|
+
return this.state.error ? <DemoError message={`${this.props.src} failed to render: ${this.state.error.message}`} /> : this.props.children
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function DemoError({ message }: { message: string }) {
|
|
142
|
+
return <pre className="docs-demo-error" role="alert">{message}</pre>
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function Spinner({ label = "Loading demo" }: { label?: string }) {
|
|
146
|
+
return (
|
|
147
|
+
<div className="docs-demo-loading" role="status" aria-label={label}>
|
|
148
|
+
<span className="docs-demo-spinner" aria-hidden="true" />
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Descriptions allow inline Markdown (`code`, **bold**, _em_, links). */
|
|
154
|
+
function InlineMarkdown({ text }: { text: string }) {
|
|
155
|
+
const nodes = useMemo(() => parseInline(text), [text])
|
|
156
|
+
return (
|
|
157
|
+
<>
|
|
158
|
+
{nodes.map((n, i) => {
|
|
159
|
+
if (n.type !== "text") return null
|
|
160
|
+
let el: ReactNode = n.text
|
|
161
|
+
if (n.code) el = <code>{el}</code>
|
|
162
|
+
if (n.bold) el = <strong>{el}</strong>
|
|
163
|
+
if (n.italic) el = <em>{el}</em>
|
|
164
|
+
if (n.link) el = <a href={n.link}>{el}</a>
|
|
165
|
+
return <span key={i}>{el}</span>
|
|
166
|
+
})}
|
|
167
|
+
</>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function cssLength(value: number | string | undefined, fallback: string): string {
|
|
172
|
+
if (value === undefined || value === "") return fallback
|
|
173
|
+
if (typeof value === "number") return `${value}px`
|
|
174
|
+
return /^\d+(?:\.\d+)?$/.test(value.trim()) ? `${value.trim()}px` : value
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The rendered component on its canvas. */
|
|
178
|
+
function DemoCanvas({ src, component, variant, bleed }: {
|
|
179
|
+
src: string
|
|
180
|
+
component: Loaded<DemoComponent>
|
|
181
|
+
variant: string | undefined
|
|
182
|
+
bleed?: boolean
|
|
183
|
+
}) {
|
|
184
|
+
return (
|
|
185
|
+
<div className={bleed ? "docs-demo-canvas docs-demo-canvas-bleed" : "docs-demo-canvas"} data-variant={variant}>
|
|
186
|
+
{component.status === "ready" ? (
|
|
187
|
+
<DemoErrorBoundary src={src}>
|
|
188
|
+
{createElement(component.value, variant === undefined ? {} : { variant })}
|
|
189
|
+
</DemoErrorBoundary>
|
|
190
|
+
) : component.status === "error" ? (
|
|
191
|
+
<DemoError message={`${src} failed to load: ${component.error.message}`} />
|
|
192
|
+
) : (
|
|
193
|
+
<Spinner />
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const VIEWPORTS: { id: DemoViewport; label: string; width: number | null; Icon: typeof Monitor }[] = [
|
|
200
|
+
{ id: "desktop", label: "Desktop", width: null, Icon: Monitor },
|
|
201
|
+
{ id: "tablet", label: "Tablet", width: 768, Icon: Tablet },
|
|
202
|
+
{ id: "phone", label: "Phone", width: 390, Icon: Smartphone },
|
|
203
|
+
]
|
|
204
|
+
const MIN_WIDTH = 280
|
|
205
|
+
|
|
206
|
+
/** The resizable preview frame on its stage. `width === null` fills the stage. */
|
|
207
|
+
function PreviewStage({ width, onResize, height, children }: {
|
|
208
|
+
width: number | null
|
|
209
|
+
onResize: (width: number | null) => void
|
|
210
|
+
height: string
|
|
211
|
+
children: ReactNode
|
|
212
|
+
}) {
|
|
213
|
+
const reduced = useReducedMotion()
|
|
214
|
+
const frame = useRef<HTMLDivElement | null>(null)
|
|
215
|
+
const [dragging, setDragging] = useState(false)
|
|
216
|
+
const [measured, setMeasured] = useState<number | null>(null)
|
|
217
|
+
const [max, setMax] = useState<number>(0)
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
const el = frame.current
|
|
221
|
+
if (!el || typeof ResizeObserver === "undefined") return
|
|
222
|
+
const observer = new ResizeObserver(() => {
|
|
223
|
+
setMeasured(Math.round(el.getBoundingClientRect().width))
|
|
224
|
+
setMax(el.parentElement?.clientWidth ?? 0)
|
|
225
|
+
})
|
|
226
|
+
observer.observe(el)
|
|
227
|
+
if (el.parentElement) observer.observe(el.parentElement)
|
|
228
|
+
return () => observer.disconnect()
|
|
229
|
+
}, [])
|
|
230
|
+
|
|
231
|
+
const clamp = (next: number) => {
|
|
232
|
+
const limit = frame.current?.parentElement?.clientWidth ?? next
|
|
233
|
+
const value = Math.round(Math.min(limit, Math.max(MIN_WIDTH, next)))
|
|
234
|
+
return value >= limit - 4 ? null : value
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
238
|
+
const el = frame.current
|
|
239
|
+
if (!el || event.button !== 0) return
|
|
240
|
+
event.preventDefault()
|
|
241
|
+
const left = el.getBoundingClientRect().left
|
|
242
|
+
setDragging(true)
|
|
243
|
+
const move = (ev: PointerEvent) => onResize(clamp(ev.clientX - left))
|
|
244
|
+
const up = () => {
|
|
245
|
+
setDragging(false)
|
|
246
|
+
window.removeEventListener("pointermove", move)
|
|
247
|
+
window.removeEventListener("pointerup", up)
|
|
248
|
+
window.removeEventListener("pointercancel", up)
|
|
249
|
+
}
|
|
250
|
+
window.addEventListener("pointermove", move)
|
|
251
|
+
window.addEventListener("pointerup", up)
|
|
252
|
+
window.addEventListener("pointercancel", up)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
256
|
+
const current = width ?? measured ?? max
|
|
257
|
+
const step = event.shiftKey ? 50 : 10
|
|
258
|
+
let next: number | null | undefined
|
|
259
|
+
if (event.key === "ArrowLeft") next = clamp(current - step)
|
|
260
|
+
else if (event.key === "ArrowRight") next = clamp(current + step)
|
|
261
|
+
else if (event.key === "Home") next = clamp(MIN_WIDTH)
|
|
262
|
+
else if (event.key === "End") next = null
|
|
263
|
+
if (next === undefined) return
|
|
264
|
+
event.preventDefault()
|
|
265
|
+
onResize(next)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div className="docs-demo-stage" style={{ height }} data-dragging={dragging || undefined}>
|
|
270
|
+
<motion.div
|
|
271
|
+
ref={frame}
|
|
272
|
+
className="docs-demo-frame"
|
|
273
|
+
data-sized={width !== null || undefined}
|
|
274
|
+
initial={false}
|
|
275
|
+
animate={{ width: width ?? "100%" }}
|
|
276
|
+
transition={dragging || reduced ? { duration: 0 } : { type: "spring", bounce: 0.12, duration: 0.5 }}
|
|
277
|
+
>
|
|
278
|
+
{children}
|
|
279
|
+
</motion.div>
|
|
280
|
+
<div
|
|
281
|
+
className="docs-demo-handle"
|
|
282
|
+
role="separator"
|
|
283
|
+
tabIndex={0}
|
|
284
|
+
aria-orientation="vertical"
|
|
285
|
+
aria-label="Resize preview"
|
|
286
|
+
aria-valuemin={MIN_WIDTH}
|
|
287
|
+
aria-valuemax={max || undefined}
|
|
288
|
+
aria-valuenow={measured ?? undefined}
|
|
289
|
+
aria-valuetext={measured ? `${measured} pixels wide` : undefined}
|
|
290
|
+
data-dragging={dragging || undefined}
|
|
291
|
+
onPointerDown={startDrag}
|
|
292
|
+
onKeyDown={onKeyDown}
|
|
293
|
+
onDoubleClick={() => onResize(null)}
|
|
294
|
+
/>
|
|
295
|
+
<span className="docs-demo-readout" aria-hidden="true">{measured ? `${measured}px` : ""}</span>
|
|
296
|
+
</div>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface TreeRow {
|
|
301
|
+
kind: "dir" | "file"
|
|
302
|
+
name: string
|
|
303
|
+
depth: number
|
|
304
|
+
path: string
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Files as a tree: the entry and root files first, then each folder with its files indented. */
|
|
308
|
+
function fileTree(files: DemoFile[]): TreeRow[] {
|
|
309
|
+
const rows: TreeRow[] = []
|
|
310
|
+
const seen = new Set<string>()
|
|
311
|
+
for (const file of files) {
|
|
312
|
+
const parts = file.path.split("/")
|
|
313
|
+
for (let d = 0; d < parts.length - 1; d++) {
|
|
314
|
+
const dir = parts.slice(0, d + 1).join("/")
|
|
315
|
+
if (!seen.has(dir)) {
|
|
316
|
+
seen.add(dir)
|
|
317
|
+
rows.push({ kind: "dir", name: parts[d], depth: d, path: dir })
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
rows.push({ kind: "file", name: parts[parts.length - 1], depth: parts.length - 1, path: file.path })
|
|
321
|
+
}
|
|
322
|
+
return rows
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function CodeView({ src, files, height, id }: { src: string; files: DemoFile[]; height: string; id: string }) {
|
|
326
|
+
const [active, setActive] = useState(files[0]?.path ?? "")
|
|
327
|
+
useEffect(() => {
|
|
328
|
+
if (!files.some((f) => f.path === active)) setActive(files[0]?.path ?? "")
|
|
329
|
+
}, [files, active])
|
|
330
|
+
const file = files.find((f) => f.path === active) ?? files[0]
|
|
331
|
+
const rows = useMemo(() => fileTree(files), [files])
|
|
332
|
+
const paths = files.map((f) => f.path)
|
|
333
|
+
const tabId = (path: string) => `${id}-file-${paths.indexOf(path)}`
|
|
334
|
+
const panelId = `${id}-source`
|
|
335
|
+
return (
|
|
336
|
+
<div className="docs-demo-code" style={{ height }}>
|
|
337
|
+
<div
|
|
338
|
+
className="docs-demo-files"
|
|
339
|
+
role="tablist"
|
|
340
|
+
aria-orientation="vertical"
|
|
341
|
+
aria-label="Files"
|
|
342
|
+
onKeyDown={(event) => rovingKeyDown(event, paths, active, setActive, "vertical")}
|
|
343
|
+
>
|
|
344
|
+
<div className="docs-demo-files-h" aria-hidden="true">Files</div>
|
|
345
|
+
{rows.map((row) =>
|
|
346
|
+
row.kind === "dir" ? (
|
|
347
|
+
<div key={`d:${row.path}`} className="docs-demo-dir" style={{ "--depth": row.depth } as CSSProperties} aria-hidden="true">
|
|
348
|
+
<Folder width={13} height={13} aria-hidden="true" />
|
|
349
|
+
{row.name}
|
|
350
|
+
</div>
|
|
351
|
+
) : (
|
|
352
|
+
<button
|
|
353
|
+
key={row.path}
|
|
354
|
+
id={tabId(row.path)}
|
|
355
|
+
type="button"
|
|
356
|
+
role="tab"
|
|
357
|
+
data-roving=""
|
|
358
|
+
aria-selected={row.path === active}
|
|
359
|
+
aria-controls={panelId}
|
|
360
|
+
tabIndex={row.path === active ? 0 : -1}
|
|
361
|
+
title={row.path}
|
|
362
|
+
className="docs-demo-file"
|
|
363
|
+
style={{ "--depth": row.depth } as CSSProperties}
|
|
364
|
+
onClick={() => setActive(row.path)}
|
|
365
|
+
>
|
|
366
|
+
<FileCode2 width={13} height={13} aria-hidden="true" />
|
|
367
|
+
<span>{row.name}</span>
|
|
368
|
+
</button>
|
|
369
|
+
),
|
|
370
|
+
)}
|
|
371
|
+
</div>
|
|
372
|
+
<div className="docs-demo-source" role="tabpanel" id={panelId} aria-labelledby={file ? tabId(file.path) : undefined}>
|
|
373
|
+
<div className="docs-demo-source-h">
|
|
374
|
+
<span className="docs-demo-source-path">{src}/{file?.path}</span>
|
|
375
|
+
{file ? <CopyButton text={file.content} label={`Copy ${file.path}`} /> : null}
|
|
376
|
+
</div>
|
|
377
|
+
<div className="docs-demo-source-body" tabIndex={0} aria-label={file ? `${file.path} source` : undefined}>
|
|
378
|
+
{file ? (
|
|
379
|
+
<pre className="docs-code-numbered">
|
|
380
|
+
<HighlightedCode code={file.content.replace(/\n+$/, "")} language={file.language ?? languageForPath(file.path) ?? null} lineNumbers />
|
|
381
|
+
</pre>
|
|
382
|
+
) : (
|
|
383
|
+
<Spinner label="Loading files" />
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function FullscreenButton({ onOpen, title }: { onOpen: () => void; title: string }) {
|
|
392
|
+
return (
|
|
393
|
+
<button type="button" className="docs-demo-icon-btn" onClick={onOpen} aria-label={`Open ${title} fullscreen`} title="Fullscreen">
|
|
394
|
+
<Maximize2 width={15} height={15} aria-hidden="true" />
|
|
395
|
+
</button>
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function NewTabLink({ href, title }: { href: string | undefined; title: string }) {
|
|
400
|
+
if (!href) return null
|
|
401
|
+
return (
|
|
402
|
+
<a className="docs-demo-icon-btn" href={href} target="_blank" rel="noreferrer" aria-label={`Open ${title} in a new tab`} title="Open in new tab">
|
|
403
|
+
<ExternalLink width={15} height={15} aria-hidden="true" />
|
|
404
|
+
</a>
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function VariantSwitch({ variants, value, onChange, title }: {
|
|
409
|
+
variants: DemoVariant[] | undefined
|
|
410
|
+
value: string | undefined
|
|
411
|
+
onChange: (id: string) => void
|
|
412
|
+
title: string
|
|
413
|
+
}) {
|
|
414
|
+
if (!variants?.length || value === undefined) return null
|
|
415
|
+
return <Segmented options={variants} value={value} onChange={onChange} label={`${title} variant`} className="docs-demo-variants" />
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** A full-viewport overlay (native modal dialog: top layer, focus trap, Esc to close). */
|
|
419
|
+
function FullscreenDialog({ open, onClose, title, src, variants, variant, onVariant, component, bleed, href }: {
|
|
420
|
+
open: boolean
|
|
421
|
+
onClose: () => void
|
|
422
|
+
title: string
|
|
423
|
+
src: string
|
|
424
|
+
variants: DemoVariant[] | undefined
|
|
425
|
+
variant: string | undefined
|
|
426
|
+
onVariant: (id: string) => void
|
|
427
|
+
component: Loaded<DemoComponent>
|
|
428
|
+
bleed?: boolean
|
|
429
|
+
href?: string
|
|
430
|
+
}) {
|
|
431
|
+
const ref = useRef<HTMLDialogElement | null>(null)
|
|
432
|
+
const titleId = useId()
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
const dialog = ref.current
|
|
435
|
+
if (!dialog) return
|
|
436
|
+
if (open && !dialog.open) {
|
|
437
|
+
if (typeof dialog.showModal === "function") dialog.showModal()
|
|
438
|
+
else dialog.setAttribute("open", "")
|
|
439
|
+
} else if (!open && dialog.open) {
|
|
440
|
+
dialog.close()
|
|
441
|
+
}
|
|
442
|
+
}, [open])
|
|
443
|
+
return (
|
|
444
|
+
<dialog ref={ref} className="docs-demo-dialog" aria-labelledby={titleId} onClose={onClose} onCancel={onClose}>
|
|
445
|
+
{open ? (
|
|
446
|
+
<div className="docs-demo-dialog-inner">
|
|
447
|
+
<div className="docs-demo-dialog-bar">
|
|
448
|
+
<span id={titleId} className="docs-demo-title">{title}</span>
|
|
449
|
+
<span className="docs-demo-dialog-src">{src}</span>
|
|
450
|
+
<span className="docs-demo-spacer" />
|
|
451
|
+
<VariantSwitch variants={variants} value={variant} onChange={onVariant} title={title} />
|
|
452
|
+
<NewTabLink href={href} title={title} />
|
|
453
|
+
<button type="button" className="docs-demo-icon-btn" onClick={onClose} aria-label="Close fullscreen" title="Close (Esc)" autoFocus>
|
|
454
|
+
<X width={16} height={16} aria-hidden="true" />
|
|
455
|
+
</button>
|
|
456
|
+
</div>
|
|
457
|
+
<div className="docs-demo-dialog-stage">
|
|
458
|
+
<DemoCanvas src={src} component={component} variant={variant} bleed={bleed} />
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
) : null}
|
|
462
|
+
</dialog>
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* ---------- viewer ---------- */
|
|
467
|
+
|
|
468
|
+
type ViewTab = "preview" | "code"
|
|
469
|
+
|
|
470
|
+
export function DemoViewer(props: DemoViewerProps) {
|
|
471
|
+
const resolver = useDemoResolver(props.resolver)
|
|
472
|
+
const { src } = props
|
|
473
|
+
const [ref, near] = useNearViewport<HTMLElement>()
|
|
474
|
+
const meta = useMeta(resolver, src)
|
|
475
|
+
const files = useFiles(resolver, src, near)
|
|
476
|
+
const component = useComponent(resolver, src, near)
|
|
477
|
+
const metaValue = meta.status === "ready" ? meta.value : undefined
|
|
478
|
+
|
|
479
|
+
const title = props.title ?? metaValue?.title ?? src.split("/").pop() ?? src
|
|
480
|
+
const description = props.description ?? metaValue?.description
|
|
481
|
+
const variants = props.variants?.length ? props.variants : metaValue?.variants
|
|
482
|
+
const [chosenVariant, setVariant] = useState<string | undefined>(undefined)
|
|
483
|
+
const variant = variants?.length
|
|
484
|
+
? (variants.some((v) => v.id === chosenVariant) ? chosenVariant : variants[0].id)
|
|
485
|
+
: undefined
|
|
486
|
+
const layoutPref = props.layout ?? metaValue?.layout ?? "auto"
|
|
487
|
+
const fileList = files.status === "ready" ? files.value : null
|
|
488
|
+
const layout: "single" | "multi" | null =
|
|
489
|
+
layoutPref !== "auto" ? layoutPref : fileList ? (fileList.length > 1 ? "multi" : "single") : null
|
|
490
|
+
const heightValue = props.height ?? metaValue?.height
|
|
491
|
+
const bleed = metaValue?.bleed
|
|
492
|
+
const [fullscreen, setFullscreen] = useState(false)
|
|
493
|
+
const href = resolver?.href?.(src, variant ? { variant } : undefined)
|
|
494
|
+
const baseId = useId()
|
|
495
|
+
|
|
496
|
+
if (!resolver) {
|
|
497
|
+
return (
|
|
498
|
+
<div className="docs-source-ref" data-docstream-demo="" data-demo-src={src}>
|
|
499
|
+
<FileCode2 width={16} height={16} aria-hidden="true" />
|
|
500
|
+
<span>{title}</span>
|
|
501
|
+
<code>demo:{src}</code>
|
|
502
|
+
</div>
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const dialog = (
|
|
507
|
+
<FullscreenDialog
|
|
508
|
+
open={fullscreen}
|
|
509
|
+
onClose={() => setFullscreen(false)}
|
|
510
|
+
title={title}
|
|
511
|
+
src={src}
|
|
512
|
+
variants={variants}
|
|
513
|
+
variant={variant}
|
|
514
|
+
onVariant={setVariant}
|
|
515
|
+
component={component}
|
|
516
|
+
bleed={bleed}
|
|
517
|
+
href={href}
|
|
518
|
+
/>
|
|
519
|
+
)
|
|
520
|
+
const wrapper = (kind: string) => ["docs-demo", `docs-demo-${kind}`, props.className].filter(Boolean).join(" ")
|
|
521
|
+
|
|
522
|
+
if (layout === "single") {
|
|
523
|
+
const file = fileList?.[0]
|
|
524
|
+
return (
|
|
525
|
+
<section ref={ref} className={wrapper("single")} data-docstream-demo="" data-demo-src={src} aria-labelledby={`${baseId}-title`}>
|
|
526
|
+
<div className="docs-react-demo">
|
|
527
|
+
<header className="docs-react-demo-header docs-demo-single-head">
|
|
528
|
+
<span className="docs-demo-heading">
|
|
529
|
+
<span id={`${baseId}-title`} className="docs-demo-title">{title}</span>
|
|
530
|
+
{description ? <span className="docs-demo-desc"><InlineMarkdown text={description} /></span> : null}
|
|
531
|
+
</span>
|
|
532
|
+
<span className="docs-demo-spacer" />
|
|
533
|
+
<VariantSwitch variants={variants} value={variant} onChange={setVariant} title={title} />
|
|
534
|
+
<FullscreenButton onOpen={() => setFullscreen(true)} title={title} />
|
|
535
|
+
</header>
|
|
536
|
+
<div className="docs-react-demo-preview docs-demo-single-preview" style={{ height: cssLength(heightValue, "auto") }}>
|
|
537
|
+
{near ? <DemoCanvas src={src} component={component} variant={variant} bleed={bleed} /> : <Spinner />}
|
|
538
|
+
</div>
|
|
539
|
+
{file ? (
|
|
540
|
+
<CollapsibleCode
|
|
541
|
+
code={file.content.replace(/\n+$/, "")}
|
|
542
|
+
language={file.language ?? languageForPath(file.path) ?? null}
|
|
543
|
+
copyLabel={`Copy ${file.path}`}
|
|
544
|
+
/>
|
|
545
|
+
) : null}
|
|
546
|
+
</div>
|
|
547
|
+
{dialog}
|
|
548
|
+
</section>
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return (
|
|
553
|
+
<MultiFileViewer
|
|
554
|
+
rootRef={ref}
|
|
555
|
+
className={wrapper(layout ?? "pending")}
|
|
556
|
+
baseId={baseId}
|
|
557
|
+
src={src}
|
|
558
|
+
title={title}
|
|
559
|
+
description={description}
|
|
560
|
+
variants={variants}
|
|
561
|
+
variant={variant}
|
|
562
|
+
onVariant={setVariant}
|
|
563
|
+
files={files}
|
|
564
|
+
component={component}
|
|
565
|
+
near={near}
|
|
566
|
+
height={cssLength(heightValue, "480px")}
|
|
567
|
+
viewport={props.viewport}
|
|
568
|
+
bleed={bleed}
|
|
569
|
+
href={href}
|
|
570
|
+
onFullscreen={() => setFullscreen(true)}
|
|
571
|
+
dialog={dialog}
|
|
572
|
+
/>
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function MultiFileViewer({ rootRef, className, baseId, src, title, description, variants, variant, onVariant, files, component, near, height, viewport, bleed, href, onFullscreen, dialog }: {
|
|
577
|
+
rootRef: MutableRefObject<HTMLElement | null>
|
|
578
|
+
className: string
|
|
579
|
+
baseId: string
|
|
580
|
+
src: string
|
|
581
|
+
title: string
|
|
582
|
+
description: string | undefined
|
|
583
|
+
variants: DemoVariant[] | undefined
|
|
584
|
+
variant: string | undefined
|
|
585
|
+
onVariant: (id: string) => void
|
|
586
|
+
files: Loaded<DemoFile[]>
|
|
587
|
+
component: Loaded<DemoComponent>
|
|
588
|
+
near: boolean
|
|
589
|
+
height: string
|
|
590
|
+
viewport: DemoViewport | undefined
|
|
591
|
+
bleed: boolean | undefined
|
|
592
|
+
href: string | undefined
|
|
593
|
+
onFullscreen: () => void
|
|
594
|
+
dialog: ReactNode
|
|
595
|
+
}) {
|
|
596
|
+
const [tab, setTab] = useState<ViewTab>("preview")
|
|
597
|
+
const [width, setWidth] = useState<number | null>(() => VIEWPORTS.find((v) => v.id === viewport)?.width ?? null)
|
|
598
|
+
const [codeSeen, setCodeSeen] = useState(false)
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
if (tab === "code") setCodeSeen(true)
|
|
601
|
+
}, [tab])
|
|
602
|
+
const activeViewport = width === null ? "desktop" : VIEWPORTS.find((v) => v.width === width)?.id
|
|
603
|
+
const fileList = files.status === "ready" ? files.value : null
|
|
604
|
+
const allFiles = () =>
|
|
605
|
+
defaultDemoMarkdown({ node: { type: "demo", src } as DemoNode, meta: undefined, files: fileList ?? [], title })
|
|
606
|
+
|
|
607
|
+
return (
|
|
608
|
+
<section ref={rootRef} className={className} data-docstream-demo="" data-demo-src={src} aria-labelledby={`${baseId}-title`}>
|
|
609
|
+
<div className="docs-demo-head">
|
|
610
|
+
<div className="docs-demo-heading">
|
|
611
|
+
<div id={`${baseId}-title`} className="docs-demo-title">{title}</div>
|
|
612
|
+
{description ? <p className="docs-demo-desc"><InlineMarkdown text={description} /></p> : null}
|
|
613
|
+
</div>
|
|
614
|
+
<div className="docs-demo-tools">
|
|
615
|
+
<PillTabs
|
|
616
|
+
options={[{ id: "preview", label: "Preview" }, { id: "code", label: "Code" }] as const}
|
|
617
|
+
value={tab}
|
|
618
|
+
onChange={setTab}
|
|
619
|
+
label={`${title} view`}
|
|
620
|
+
tabId={(id) => `${baseId}-tab-${id}`}
|
|
621
|
+
panelId={(id) => `${baseId}-panel-${id}`}
|
|
622
|
+
/>
|
|
623
|
+
<VariantSwitch variants={variants} value={variant} onChange={onVariant} title={title} />
|
|
624
|
+
{tab === "preview" ? (
|
|
625
|
+
<div className="docs-demo-viewports" role="group" aria-label="Preview width">
|
|
626
|
+
{VIEWPORTS.map(({ id, label, width: w, Icon }) => (
|
|
627
|
+
<button
|
|
628
|
+
key={id}
|
|
629
|
+
type="button"
|
|
630
|
+
className="docs-demo-vp"
|
|
631
|
+
aria-pressed={activeViewport === id}
|
|
632
|
+
aria-label={w ? `${label} (${w}px)` : label}
|
|
633
|
+
title={w ? `${label} · ${w}px` : label}
|
|
634
|
+
onClick={() => setWidth(w)}
|
|
635
|
+
>
|
|
636
|
+
<Icon width={15} height={15} aria-hidden="true" />
|
|
637
|
+
</button>
|
|
638
|
+
))}
|
|
639
|
+
</div>
|
|
640
|
+
) : null}
|
|
641
|
+
{fileList?.length ? <CopyButton text={allFiles} label="Copy all files as Markdown" className="docs-demo-icon-btn" /> : null}
|
|
642
|
+
<FullscreenButton onOpen={onFullscreen} title={title} />
|
|
643
|
+
<NewTabLink href={href} title={title} />
|
|
644
|
+
</div>
|
|
645
|
+
</div>
|
|
646
|
+
<div className="docs-demo-body">
|
|
647
|
+
<div id={`${baseId}-panel-preview`} role="tabpanel" aria-labelledby={`${baseId}-tab-preview`} hidden={tab !== "preview"}>
|
|
648
|
+
<PreviewStage width={width} onResize={setWidth} height={height}>
|
|
649
|
+
{near ? <DemoCanvas src={src} component={component} variant={variant} bleed={bleed} /> : <Spinner />}
|
|
650
|
+
</PreviewStage>
|
|
651
|
+
</div>
|
|
652
|
+
<div id={`${baseId}-panel-code`} role="tabpanel" aria-labelledby={`${baseId}-tab-code`} hidden={tab !== "code"}>
|
|
653
|
+
{codeSeen || tab === "code" ? (
|
|
654
|
+
files.status === "ready" ? (
|
|
655
|
+
<CodeView src={src} files={files.value} height={height} id={baseId} />
|
|
656
|
+
) : files.status === "error" ? (
|
|
657
|
+
<DemoError message={`${src}: ${files.error.message}`} />
|
|
658
|
+
) : (
|
|
659
|
+
<div className="docs-demo-code" style={{ height }}><Spinner label="Loading files" /></div>
|
|
660
|
+
)
|
|
661
|
+
) : null}
|
|
662
|
+
</div>
|
|
663
|
+
</div>
|
|
664
|
+
{dialog}
|
|
665
|
+
</section>
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Renders a parsed `{% demo %}` block with the resolver from context. */
|
|
670
|
+
export function DemoBlock({ node }: { node: DemoNode }) {
|
|
671
|
+
return (
|
|
672
|
+
<DemoViewer
|
|
673
|
+
src={node.src}
|
|
674
|
+
title={node.title}
|
|
675
|
+
description={node.description}
|
|
676
|
+
height={node.height}
|
|
677
|
+
layout={node.layout}
|
|
678
|
+
variants={node.variants}
|
|
679
|
+
viewport={node.viewport}
|
|
680
|
+
/>
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export interface DemoFullscreenProps {
|
|
685
|
+
src: string
|
|
686
|
+
resolver?: DemoResolver
|
|
687
|
+
variant?: string
|
|
688
|
+
className?: string
|
|
689
|
+
style?: CSSProperties
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** One demo filling the viewport, for a `?demo=<src>` deep link the host routes. */
|
|
693
|
+
export function DemoFullscreen({ src, resolver: override, variant, className, style }: DemoFullscreenProps) {
|
|
694
|
+
const resolver = useDemoResolver(override)
|
|
695
|
+
const meta = useMeta(resolver, src)
|
|
696
|
+
const component = useComponent(resolver, src, true)
|
|
697
|
+
const metaValue = meta.status === "ready" ? meta.value : undefined
|
|
698
|
+
const chosen = variant ?? metaValue?.variants?.[0]?.id
|
|
699
|
+
if (!resolver) return <DemoError message={`No demo resolver for ${src}`} />
|
|
700
|
+
return (
|
|
701
|
+
<div className={className ? `docs-demo-fullscreen ${className}` : "docs-demo-fullscreen"} style={style} data-demo-src={src}>
|
|
702
|
+
<DemoCanvas src={src} component={component} variant={chosen} bleed={metaValue?.bleed} />
|
|
703
|
+
</div>
|
|
704
|
+
)
|
|
705
|
+
}
|