@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.
@@ -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
+ }