@brett_lamy/docstream 1.1.1 → 1.2.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 +147 -9
- package/package.json +3 -1
- package/src/demo/DemoViewer.tsx +213 -46
- package/src/demo/context.ts +17 -1
- package/src/demo/glob.ts +12 -4
- package/src/demo/index.ts +15 -5
- package/src/demo/markdown.ts +138 -41
- package/src/demo/types.ts +46 -2
- package/src/docs/DocsRenderer.tsx +147 -34
- package/src/docs/PageActions.tsx +9 -5
- package/src/docs/controls.tsx +4 -1
- package/src/gitbook/ast.ts +63 -4
- package/src/gitbook/flatten.ts +47 -0
- package/src/gitbook/index.ts +6 -2
- package/src/gitbook/inline.ts +5 -2
- package/src/gitbook/outline.ts +45 -0
- package/src/gitbook/package-managers.ts +98 -0
- package/src/gitbook/parse.ts +179 -8
- package/src/gitbook/serialize.ts +40 -4
- package/src/index.ts +23 -1
- package/src/playground/InlineDemoPreview.tsx +77 -0
- package/src/playground/PlaygroundStreamdown.tsx +12 -2
- package/src/playground/ReactCodePreview.tsx +67 -36
- package/src/playground/filesystem.ts +83 -1
- package/src/playground/index.ts +7 -2
- package/src/streamdown.tsx +6 -2
- package/src/styles.css +117 -8
package/src/demo/DemoViewer.tsx
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
/* The demo viewer for `{% demo src="<page>/<example>" %}`.
|
|
2
2
|
|
|
3
|
+
Where the files come from:
|
|
4
|
+
- a host `DemoResolver` that knows `src`: the component renders in-page; the Code view shows
|
|
5
|
+
the resolver's files (they win over any inline copy);
|
|
6
|
+
- otherwise the block's inline files (`{% demo %}` + fences + `{% enddemo %}`): the Preview
|
|
7
|
+
runs them through the `InlineDemoRuntime` in context (almost-node in the playground entry),
|
|
8
|
+
or, without one, the viewer opens on the Code view with a short note — never an empty box.
|
|
9
|
+
|
|
3
10
|
Multi-file demos get the gallery viewer: a header (title, description, Preview | Code, variants,
|
|
4
11
|
viewport widths, copy, fullscreen), a resizable preview frame on a dotted stage, and a Code view
|
|
5
12
|
with a file tree and highlighted source. Single-file demos keep the ReactDemo card: the preview,
|
|
@@ -28,10 +35,10 @@ import { CollapsibleCode } from "../docs/CollapsibleCode"
|
|
|
28
35
|
import { HighlightedCode } from "../docs/HighlightedCode"
|
|
29
36
|
import { CopyButton } from "../docs/copy"
|
|
30
37
|
import { PillTabs, Segmented, rovingKeyDown } from "../docs/controls"
|
|
31
|
-
import { useDemoResolver } from "./context"
|
|
38
|
+
import { useDemoResolver, useDemoRuntime } from "./context"
|
|
32
39
|
import { languageForPath } from "./glob"
|
|
33
|
-
import {
|
|
34
|
-
import type { DemoComponent, DemoFile, DemoMeta, DemoResolver, DemoVariant } from "./types"
|
|
40
|
+
import { inlineDemoMarkdown } from "./markdown"
|
|
41
|
+
import type { DemoComponent, DemoFile, DemoMeta, DemoResolver, DemoVariant, InlineDemoRuntime } from "./types"
|
|
35
42
|
|
|
36
43
|
export interface DemoViewerProps {
|
|
37
44
|
/** `<page>/<example>` id handed to the resolver. */
|
|
@@ -46,6 +53,19 @@ export interface DemoViewerProps {
|
|
|
46
53
|
variants?: DemoVariant[]
|
|
47
54
|
/** Initial viewport of the multi-file preview. */
|
|
48
55
|
viewport?: DemoViewport
|
|
56
|
+
/**
|
|
57
|
+
* Files carried inline by the block (entry first unless `entry` says otherwise). Used when
|
|
58
|
+
* no resolver knows `src`, or the resolver fails for it.
|
|
59
|
+
*/
|
|
60
|
+
files?: DemoFile[]
|
|
61
|
+
/** Entry among `files`. Defaults to the first file. */
|
|
62
|
+
entry?: string
|
|
63
|
+
/** The block is still streaming in: show its code, but don't run it yet. */
|
|
64
|
+
streaming?: boolean
|
|
65
|
+
/** Runs inline files in the Preview. Defaults to the runtime in context (`demoRuntime`). */
|
|
66
|
+
runtime?: InlineDemoRuntime
|
|
67
|
+
/** Extra npm dependencies for the runtime, merged over `demoDependencies` from context. */
|
|
68
|
+
dependencies?: Record<string, string>
|
|
49
69
|
className?: string
|
|
50
70
|
}
|
|
51
71
|
|
|
@@ -60,6 +80,29 @@ function cacheFor(resolver: DemoResolver) {
|
|
|
60
80
|
return cache
|
|
61
81
|
}
|
|
62
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Resolve demos ahead of rendering (meta, files and component), so the first render — including
|
|
85
|
+
* server rendering and static export — shows them resolved instead of loading. Failures are
|
|
86
|
+
* remembered too, so inline-file fallbacks apply immediately.
|
|
87
|
+
*/
|
|
88
|
+
export async function preloadDemos(resolver: DemoResolver, srcs: string[]): Promise<void> {
|
|
89
|
+
const cache = cacheFor(resolver)
|
|
90
|
+
const settle = async (key: string, run: () => Promise<unknown>) => {
|
|
91
|
+
try {
|
|
92
|
+
cache.set(key, { status: "ready", value: await run() })
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
cache.set(key, { status: "error", error: cause instanceof Error ? cause : new Error(String(cause)) })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
await Promise.all(
|
|
98
|
+
srcs.flatMap((src) => [
|
|
99
|
+
settle(`meta:${src}`, () => resolver.meta(src)),
|
|
100
|
+
settle(`files:${src}`, () => resolver.files(src)),
|
|
101
|
+
settle(`load:${src}`, () => resolver.load(src)),
|
|
102
|
+
]),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
63
106
|
/** Resolves `run()` once `active`, remembering settled values so remounts don't flash. */
|
|
64
107
|
function useResolved<T>(resolver: DemoResolver | undefined, key: string, active: boolean, run: () => Promise<T>): Loaded<T> {
|
|
65
108
|
const cached = resolver ? (cacheFor(resolver).get(key) as Loaded<T> | undefined) : undefined
|
|
@@ -174,15 +217,50 @@ function cssLength(value: number | string | undefined, fallback: string): string
|
|
|
174
217
|
return /^\d+(?:\.\d+)?$/.test(value.trim()) ? `${value.trim()}px` : value
|
|
175
218
|
}
|
|
176
219
|
|
|
177
|
-
/**
|
|
178
|
-
|
|
220
|
+
/** What fills the preview: a resolved component, an inline-demo runtime, or a note. */
|
|
221
|
+
type PreviewSource =
|
|
222
|
+
| { kind: "component"; component: Loaded<DemoComponent> }
|
|
223
|
+
| { kind: "runtime"; render: (variant: string | undefined) => ReactNode }
|
|
224
|
+
| { kind: "streaming" }
|
|
225
|
+
| { kind: "unavailable" }
|
|
226
|
+
|
|
227
|
+
export const NO_RUNTIME_NOTE = "Live preview needs the playground runtime. The code is shown instead."
|
|
228
|
+
|
|
229
|
+
/** The rendered demo on its canvas. */
|
|
230
|
+
/** The canvas's class and inline style from `meta.className` / `meta.surface`. */
|
|
231
|
+
interface CanvasLook {
|
|
232
|
+
className?: string
|
|
233
|
+
surface?: Record<string, string | number>
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function DemoCanvas({ src, preview, variant, bleed, look }: {
|
|
179
237
|
src: string
|
|
180
|
-
|
|
238
|
+
preview: PreviewSource
|
|
181
239
|
variant: string | undefined
|
|
182
240
|
bleed?: boolean
|
|
241
|
+
look?: CanvasLook
|
|
183
242
|
}) {
|
|
243
|
+
if (preview.kind === "unavailable" || preview.kind === "streaming") {
|
|
244
|
+
return (
|
|
245
|
+
<div className="docs-demo-note" role="note" data-variant={variant}>
|
|
246
|
+
{preview.kind === "streaming" ? "The demo is still arriving; its preview starts once the block is complete." : NO_RUNTIME_NOTE}
|
|
247
|
+
</div>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
if (preview.kind === "runtime") {
|
|
251
|
+
return (
|
|
252
|
+
<div className="docs-demo-canvas docs-demo-canvas-bleed docs-demo-canvas-runtime" data-variant={variant}>
|
|
253
|
+
<DemoErrorBoundary src={src || "demo"}>{preview.render(variant)}</DemoErrorBoundary>
|
|
254
|
+
</div>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
const { component } = preview
|
|
184
258
|
return (
|
|
185
|
-
<div
|
|
259
|
+
<div
|
|
260
|
+
className={["docs-demo-canvas", bleed ? "docs-demo-canvas-bleed" : "", look?.className ?? ""].filter(Boolean).join(" ")}
|
|
261
|
+
style={look?.surface as CSSProperties | undefined}
|
|
262
|
+
data-variant={variant}
|
|
263
|
+
>
|
|
186
264
|
{component.status === "ready" ? (
|
|
187
265
|
<DemoErrorBoundary src={src}>
|
|
188
266
|
{createElement(component.value, variant === undefined ? {} : { variant })}
|
|
@@ -371,7 +449,7 @@ function CodeView({ src, files, height, id }: { src: string; files: DemoFile[];
|
|
|
371
449
|
</div>
|
|
372
450
|
<div className="docs-demo-source" role="tabpanel" id={panelId} aria-labelledby={file ? tabId(file.path) : undefined}>
|
|
373
451
|
<div className="docs-demo-source-h">
|
|
374
|
-
<span className="docs-demo-source-path">{src}
|
|
452
|
+
<span className="docs-demo-source-path">{src && file ? `${src}/${file.path}` : file?.path}</span>
|
|
375
453
|
{file ? <CopyButton text={file.content} label={`Copy ${file.path}`} /> : null}
|
|
376
454
|
</div>
|
|
377
455
|
<div className="docs-demo-source-body" tabIndex={0} aria-label={file ? `${file.path} source` : undefined}>
|
|
@@ -405,18 +483,32 @@ function NewTabLink({ href, title }: { href: string | undefined; title: string }
|
|
|
405
483
|
)
|
|
406
484
|
}
|
|
407
485
|
|
|
408
|
-
function VariantSwitch({ variants, value, onChange, title }: {
|
|
486
|
+
function VariantSwitch({ variants, value, onChange, title, width }: {
|
|
409
487
|
variants: DemoVariant[] | undefined
|
|
410
488
|
value: string | undefined
|
|
411
489
|
onChange: (id: string) => void
|
|
412
490
|
title: string
|
|
491
|
+
width?: number | string
|
|
413
492
|
}) {
|
|
414
493
|
if (!variants?.length || value === undefined) return null
|
|
415
|
-
return
|
|
494
|
+
return (
|
|
495
|
+
<Segmented
|
|
496
|
+
options={variants}
|
|
497
|
+
value={value}
|
|
498
|
+
onChange={onChange}
|
|
499
|
+
label={`${title} variant`}
|
|
500
|
+
className={width === undefined ? "docs-demo-variants" : "docs-demo-variants docs-demo-variants-sized"}
|
|
501
|
+
style={width === undefined ? undefined : { width: typeof width === "number" ? `${width}px` : width }}
|
|
502
|
+
/>
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function StatusBadge({ status }: { status: string | undefined }) {
|
|
507
|
+
return status ? <span className="docs-demo-status">{status}</span> : null
|
|
416
508
|
}
|
|
417
509
|
|
|
418
510
|
/** A full-viewport overlay (native modal dialog: top layer, focus trap, Esc to close). */
|
|
419
|
-
function FullscreenDialog({ open, onClose, title, src, variants, variant, onVariant,
|
|
511
|
+
function FullscreenDialog({ open, onClose, title, src, variants, variant, onVariant, preview, bleed, look, variantsWidth, href }: {
|
|
420
512
|
open: boolean
|
|
421
513
|
onClose: () => void
|
|
422
514
|
title: string
|
|
@@ -424,8 +516,10 @@ function FullscreenDialog({ open, onClose, title, src, variants, variant, onVari
|
|
|
424
516
|
variants: DemoVariant[] | undefined
|
|
425
517
|
variant: string | undefined
|
|
426
518
|
onVariant: (id: string) => void
|
|
427
|
-
|
|
519
|
+
preview: PreviewSource
|
|
428
520
|
bleed?: boolean
|
|
521
|
+
look?: CanvasLook
|
|
522
|
+
variantsWidth?: number | string
|
|
429
523
|
href?: string
|
|
430
524
|
}) {
|
|
431
525
|
const ref = useRef<HTMLDialogElement | null>(null)
|
|
@@ -448,14 +542,14 @@ function FullscreenDialog({ open, onClose, title, src, variants, variant, onVari
|
|
|
448
542
|
<span id={titleId} className="docs-demo-title">{title}</span>
|
|
449
543
|
<span className="docs-demo-dialog-src">{src}</span>
|
|
450
544
|
<span className="docs-demo-spacer" />
|
|
451
|
-
<VariantSwitch variants={variants} value={variant} onChange={onVariant} title={title} />
|
|
545
|
+
<VariantSwitch variants={variants} value={variant} onChange={onVariant} title={title} width={variantsWidth} />
|
|
452
546
|
<NewTabLink href={href} title={title} />
|
|
453
547
|
<button type="button" className="docs-demo-icon-btn" onClick={onClose} aria-label="Close fullscreen" title="Close (Esc)" autoFocus>
|
|
454
548
|
<X width={16} height={16} aria-hidden="true" />
|
|
455
549
|
</button>
|
|
456
550
|
</div>
|
|
457
551
|
<div className="docs-demo-dialog-stage">
|
|
458
|
-
<DemoCanvas src={src}
|
|
552
|
+
<DemoCanvas src={src} preview={preview} variant={variant} bleed={bleed} look={look} />
|
|
459
553
|
</div>
|
|
460
554
|
</div>
|
|
461
555
|
) : null}
|
|
@@ -467,16 +561,34 @@ function FullscreenDialog({ open, onClose, title, src, variants, variant, onVari
|
|
|
467
561
|
|
|
468
562
|
type ViewTab = "preview" | "code"
|
|
469
563
|
|
|
564
|
+
/** Inline files, entry first. */
|
|
565
|
+
function orderInline(files: DemoFile[] | undefined, entry: string | undefined): DemoFile[] | undefined {
|
|
566
|
+
if (!files?.length) return undefined
|
|
567
|
+
const index = entry ? files.findIndex((f) => f.path === entry) : -1
|
|
568
|
+
return index > 0 ? [files[index], ...files.slice(0, index), ...files.slice(index + 1)] : files
|
|
569
|
+
}
|
|
570
|
+
|
|
470
571
|
export function DemoViewer(props: DemoViewerProps) {
|
|
471
572
|
const resolver = useDemoResolver(props.resolver)
|
|
573
|
+
const runtimeOptions = useDemoRuntime({ runtime: props.runtime, dependencies: props.dependencies })
|
|
472
574
|
const { src } = props
|
|
473
575
|
const [ref, near] = useNearViewport<HTMLElement>()
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
576
|
+
const inlineFiles = useMemo(() => orderInline(props.files, props.entry), [props.files, props.entry])
|
|
577
|
+
|
|
578
|
+
// A resolver is consulted unless the block brings its own files and the resolver
|
|
579
|
+
// says (via `list()`) that it does not know this src.
|
|
580
|
+
const tryResolver = !!resolver && !(inlineFiles && (!src || (resolver.list ? !resolver.list().includes(src) : false)))
|
|
581
|
+
const hookResolver = tryResolver ? resolver : undefined
|
|
582
|
+
const meta = useMeta(hookResolver, src)
|
|
583
|
+
const resolvedFiles = useFiles(hookResolver, src, near)
|
|
584
|
+
const component = useComponent(hookResolver, src, near)
|
|
585
|
+
const resolverFailed = meta.status === "error" || resolvedFiles.status === "error" || component.status === "error"
|
|
586
|
+
const mode: "resolver" | "inline" | "none" =
|
|
587
|
+
tryResolver && !(inlineFiles && resolverFailed) ? "resolver" : inlineFiles ? "inline" : "none"
|
|
588
|
+
|
|
589
|
+
const metaValue = mode === "resolver" && meta.status === "ready" ? meta.value : undefined
|
|
590
|
+
const files: Loaded<DemoFile[]> = mode === "inline" ? { status: "ready", value: inlineFiles! } : resolvedFiles
|
|
591
|
+
const title = props.title ?? metaValue?.title ?? (src.split("/").pop() || "Demo")
|
|
480
592
|
const description = props.description ?? metaValue?.description
|
|
481
593
|
const variants = props.variants?.length ? props.variants : metaValue?.variants
|
|
482
594
|
const [chosenVariant, setVariant] = useState<string | undefined>(undefined)
|
|
@@ -489,11 +601,24 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
489
601
|
layoutPref !== "auto" ? layoutPref : fileList ? (fileList.length > 1 ? "multi" : "single") : null
|
|
490
602
|
const heightValue = props.height ?? metaValue?.height
|
|
491
603
|
const bleed = metaValue?.bleed
|
|
604
|
+
const look: CanvasLook | undefined = metaValue
|
|
605
|
+
const variantsWidth = metaValue?.variantsWidth
|
|
606
|
+
const status = metaValue?.status
|
|
492
607
|
const [fullscreen, setFullscreen] = useState(false)
|
|
493
|
-
const href = resolver?.href?.(src, variant ? { variant } : undefined)
|
|
608
|
+
const href = mode === "resolver" ? resolver?.href?.(src, variant ? { variant } : undefined) : undefined
|
|
494
609
|
const baseId = useId()
|
|
495
610
|
|
|
496
|
-
|
|
611
|
+
const { runtime, dependencies } = runtimeOptions
|
|
612
|
+
const preview: PreviewSource = useMemo(() => {
|
|
613
|
+
if (mode !== "inline") return { kind: "component", component }
|
|
614
|
+
if (props.streaming) return { kind: "streaming" }
|
|
615
|
+
if (!runtime || !inlineFiles) return { kind: "unavailable" }
|
|
616
|
+
const entry = inlineFiles[0].path
|
|
617
|
+
return { kind: "runtime", render: (v) => runtime({ src, title, files: inlineFiles, entry, variant: v, dependencies }) }
|
|
618
|
+
}, [mode, component, props.streaming, runtime, inlineFiles, src, title, dependencies])
|
|
619
|
+
const runnable = preview.kind !== "unavailable"
|
|
620
|
+
|
|
621
|
+
if (mode === "none") {
|
|
497
622
|
return (
|
|
498
623
|
<div className="docs-source-ref" data-docstream-demo="" data-demo-src={src}>
|
|
499
624
|
<FileCode2 width={16} height={16} aria-hidden="true" />
|
|
@@ -503,6 +628,18 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
503
628
|
)
|
|
504
629
|
}
|
|
505
630
|
|
|
631
|
+
const node: DemoNode = {
|
|
632
|
+
type: "demo",
|
|
633
|
+
src,
|
|
634
|
+
...(props.title ? { title: props.title } : {}),
|
|
635
|
+
...(props.description ? { description: props.description } : {}),
|
|
636
|
+
...(props.height !== undefined ? { height: String(props.height) } : {}),
|
|
637
|
+
...(props.layout ? { layout: props.layout } : {}),
|
|
638
|
+
...(props.variants?.length ? { variants: props.variants } : {}),
|
|
639
|
+
...(props.viewport ? { viewport: props.viewport } : {}),
|
|
640
|
+
}
|
|
641
|
+
const copyDemo = () => inlineDemoMarkdown(node, fileList ?? [], metaValue)
|
|
642
|
+
|
|
506
643
|
const dialog = (
|
|
507
644
|
<FullscreenDialog
|
|
508
645
|
open={fullscreen}
|
|
@@ -512,8 +649,10 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
512
649
|
variants={variants}
|
|
513
650
|
variant={variant}
|
|
514
651
|
onVariant={setVariant}
|
|
515
|
-
|
|
652
|
+
preview={preview}
|
|
516
653
|
bleed={bleed}
|
|
654
|
+
look={look}
|
|
655
|
+
variantsWidth={variantsWidth}
|
|
517
656
|
href={href}
|
|
518
657
|
/>
|
|
519
658
|
)
|
|
@@ -522,7 +661,7 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
522
661
|
if (layout === "single") {
|
|
523
662
|
const file = fileList?.[0]
|
|
524
663
|
return (
|
|
525
|
-
<section ref={ref} className={wrapper("single")} data-docstream-demo="" data-demo-src={src} aria-labelledby={`${baseId}-title`}>
|
|
664
|
+
<section ref={ref} className={wrapper("single")} data-docstream-demo="" data-demo-src={src} data-demo-mode={mode} aria-labelledby={`${baseId}-title`}>
|
|
526
665
|
<div className="docs-react-demo">
|
|
527
666
|
<header className="docs-react-demo-header docs-demo-single-head">
|
|
528
667
|
<span className="docs-demo-heading">
|
|
@@ -530,17 +669,27 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
530
669
|
{description ? <span className="docs-demo-desc"><InlineMarkdown text={description} /></span> : null}
|
|
531
670
|
</span>
|
|
532
671
|
<span className="docs-demo-spacer" />
|
|
533
|
-
<
|
|
534
|
-
<
|
|
672
|
+
<StatusBadge status={status} />
|
|
673
|
+
<VariantSwitch variants={variants} value={variant} onChange={setVariant} title={title} width={variantsWidth} />
|
|
674
|
+
{runnable ? <FullscreenButton onOpen={() => setFullscreen(true)} title={title} /> : null}
|
|
535
675
|
</header>
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
676
|
+
{runnable ? (
|
|
677
|
+
<div
|
|
678
|
+
className="docs-react-demo-preview docs-demo-single-preview"
|
|
679
|
+
// A hint, not a cap: in-page components may grow past it. An iframe needs a real height.
|
|
680
|
+
style={preview.kind === "runtime" ? { height: cssLength(heightValue, "360px") } : { minHeight: cssLength(heightValue, "120px") }}
|
|
681
|
+
>
|
|
682
|
+
{near ? <DemoCanvas src={src} preview={preview} variant={variant} bleed={bleed} look={look} /> : <Spinner />}
|
|
683
|
+
</div>
|
|
684
|
+
) : (
|
|
685
|
+
<DemoCanvas src={src} preview={preview} variant={variant} />
|
|
686
|
+
)}
|
|
539
687
|
{file ? (
|
|
540
688
|
<CollapsibleCode
|
|
541
689
|
code={file.content.replace(/\n+$/, "")}
|
|
542
690
|
language={file.language ?? languageForPath(file.path) ?? null}
|
|
543
691
|
copyLabel={`Copy ${file.path}`}
|
|
692
|
+
{...(runnable ? {} : { collapsedLines: 12 })}
|
|
544
693
|
/>
|
|
545
694
|
) : null}
|
|
546
695
|
</div>
|
|
@@ -554,6 +703,7 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
554
703
|
rootRef={ref}
|
|
555
704
|
className={wrapper(layout ?? "pending")}
|
|
556
705
|
baseId={baseId}
|
|
706
|
+
mode={mode}
|
|
557
707
|
src={src}
|
|
558
708
|
title={title}
|
|
559
709
|
description={description}
|
|
@@ -561,22 +711,27 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
561
711
|
variant={variant}
|
|
562
712
|
onVariant={setVariant}
|
|
563
713
|
files={files}
|
|
564
|
-
|
|
714
|
+
preview={preview}
|
|
565
715
|
near={near}
|
|
566
716
|
height={cssLength(heightValue, "480px")}
|
|
567
717
|
viewport={props.viewport}
|
|
568
718
|
bleed={bleed}
|
|
719
|
+
look={look}
|
|
720
|
+
status={status}
|
|
721
|
+
variantsWidth={variantsWidth}
|
|
569
722
|
href={href}
|
|
570
723
|
onFullscreen={() => setFullscreen(true)}
|
|
724
|
+
copyDemo={fileList?.length ? copyDemo : undefined}
|
|
571
725
|
dialog={dialog}
|
|
572
726
|
/>
|
|
573
727
|
)
|
|
574
728
|
}
|
|
575
729
|
|
|
576
|
-
function MultiFileViewer({ rootRef, className, baseId, src, title, description, variants, variant, onVariant, files,
|
|
730
|
+
function MultiFileViewer({ rootRef, className, baseId, mode, src, title, description, variants, variant, onVariant, files, preview, near, height, viewport, bleed, look, status, variantsWidth, href, onFullscreen, copyDemo, dialog }: {
|
|
577
731
|
rootRef: MutableRefObject<HTMLElement | null>
|
|
578
732
|
className: string
|
|
579
733
|
baseId: string
|
|
734
|
+
mode: "resolver" | "inline"
|
|
580
735
|
src: string
|
|
581
736
|
title: string
|
|
582
737
|
description: string | undefined
|
|
@@ -584,28 +739,31 @@ function MultiFileViewer({ rootRef, className, baseId, src, title, description,
|
|
|
584
739
|
variant: string | undefined
|
|
585
740
|
onVariant: (id: string) => void
|
|
586
741
|
files: Loaded<DemoFile[]>
|
|
587
|
-
|
|
742
|
+
preview: PreviewSource
|
|
588
743
|
near: boolean
|
|
589
744
|
height: string
|
|
590
745
|
viewport: DemoViewport | undefined
|
|
591
746
|
bleed: boolean | undefined
|
|
747
|
+
look: CanvasLook | undefined
|
|
748
|
+
status: string | undefined
|
|
749
|
+
variantsWidth: number | string | undefined
|
|
592
750
|
href: string | undefined
|
|
593
751
|
onFullscreen: () => void
|
|
752
|
+
copyDemo: (() => string) | undefined
|
|
594
753
|
dialog: ReactNode
|
|
595
754
|
}) {
|
|
596
|
-
const
|
|
755
|
+
const runnable = preview.kind !== "unavailable"
|
|
756
|
+
// Nothing to preview without a runtime: open on the code rather than an empty frame.
|
|
757
|
+
const [tab, setTab] = useState<ViewTab>(runnable ? "preview" : "code")
|
|
597
758
|
const [width, setWidth] = useState<number | null>(() => VIEWPORTS.find((v) => v.id === viewport)?.width ?? null)
|
|
598
759
|
const [codeSeen, setCodeSeen] = useState(false)
|
|
599
760
|
useEffect(() => {
|
|
600
761
|
if (tab === "code") setCodeSeen(true)
|
|
601
762
|
}, [tab])
|
|
602
763
|
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
764
|
|
|
607
765
|
return (
|
|
608
|
-
<section ref={rootRef} className={className} data-docstream-demo="" data-demo-src={src} aria-labelledby={`${baseId}-title`}>
|
|
766
|
+
<section ref={rootRef} className={className} data-docstream-demo="" data-demo-src={src} data-demo-mode={mode} aria-labelledby={`${baseId}-title`}>
|
|
609
767
|
<div className="docs-demo-head">
|
|
610
768
|
<div className="docs-demo-heading">
|
|
611
769
|
<div id={`${baseId}-title`} className="docs-demo-title">{title}</div>
|
|
@@ -620,8 +778,9 @@ function MultiFileViewer({ rootRef, className, baseId, src, title, description,
|
|
|
620
778
|
tabId={(id) => `${baseId}-tab-${id}`}
|
|
621
779
|
panelId={(id) => `${baseId}-panel-${id}`}
|
|
622
780
|
/>
|
|
623
|
-
<
|
|
624
|
-
{
|
|
781
|
+
<StatusBadge status={status} />
|
|
782
|
+
<VariantSwitch variants={variants} value={variant} onChange={onVariant} title={title} width={variantsWidth} />
|
|
783
|
+
{tab === "preview" && runnable ? (
|
|
625
784
|
<div className="docs-demo-viewports" role="group" aria-label="Preview width">
|
|
626
785
|
{VIEWPORTS.map(({ id, label, width: w, Icon }) => (
|
|
627
786
|
<button
|
|
@@ -638,18 +797,23 @@ function MultiFileViewer({ rootRef, className, baseId, src, title, description,
|
|
|
638
797
|
))}
|
|
639
798
|
</div>
|
|
640
799
|
) : null}
|
|
641
|
-
{
|
|
642
|
-
<FullscreenButton onOpen={onFullscreen} title={title} />
|
|
800
|
+
{copyDemo ? <CopyButton text={copyDemo} label="Copy demo as Markdown" className="docs-demo-icon-btn" /> : null}
|
|
801
|
+
{runnable ? <FullscreenButton onOpen={onFullscreen} title={title} /> : null}
|
|
643
802
|
<NewTabLink href={href} title={title} />
|
|
644
803
|
</div>
|
|
645
804
|
</div>
|
|
646
805
|
<div className="docs-demo-body">
|
|
647
806
|
<div id={`${baseId}-panel-preview`} role="tabpanel" aria-labelledby={`${baseId}-tab-preview`} hidden={tab !== "preview"}>
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
807
|
+
{runnable ? (
|
|
808
|
+
<PreviewStage width={width} onResize={setWidth} height={height}>
|
|
809
|
+
{near ? <DemoCanvas src={src} preview={preview} variant={variant} bleed={bleed} look={look} /> : <Spinner />}
|
|
810
|
+
</PreviewStage>
|
|
811
|
+
) : (
|
|
812
|
+
<DemoCanvas src={src} preview={preview} variant={variant} />
|
|
813
|
+
)}
|
|
651
814
|
</div>
|
|
652
815
|
<div id={`${baseId}-panel-code`} role="tabpanel" aria-labelledby={`${baseId}-tab-code`} hidden={tab !== "code"}>
|
|
816
|
+
{!runnable && tab === "code" ? <DemoCanvas src={src} preview={preview} variant={variant} /> : null}
|
|
653
817
|
{codeSeen || tab === "code" ? (
|
|
654
818
|
files.status === "ready" ? (
|
|
655
819
|
<CodeView src={src} files={files.value} height={height} id={baseId} />
|
|
@@ -666,7 +830,7 @@ function MultiFileViewer({ rootRef, className, baseId, src, title, description,
|
|
|
666
830
|
)
|
|
667
831
|
}
|
|
668
832
|
|
|
669
|
-
/** Renders a parsed `{% demo %}` block with the resolver from context. */
|
|
833
|
+
/** Renders a parsed `{% demo %}` block with the resolver and runtime from context. */
|
|
670
834
|
export function DemoBlock({ node }: { node: DemoNode }) {
|
|
671
835
|
return (
|
|
672
836
|
<DemoViewer
|
|
@@ -677,6 +841,9 @@ export function DemoBlock({ node }: { node: DemoNode }) {
|
|
|
677
841
|
layout={node.layout}
|
|
678
842
|
variants={node.variants}
|
|
679
843
|
viewport={node.viewport}
|
|
844
|
+
files={node.files}
|
|
845
|
+
entry={node.entry}
|
|
846
|
+
streaming={node.open}
|
|
680
847
|
/>
|
|
681
848
|
)
|
|
682
849
|
}
|
|
@@ -699,7 +866,7 @@ export function DemoFullscreen({ src, resolver: override, variant, className, st
|
|
|
699
866
|
if (!resolver) return <DemoError message={`No demo resolver for ${src}`} />
|
|
700
867
|
return (
|
|
701
868
|
<div className={className ? `docs-demo-fullscreen ${className}` : "docs-demo-fullscreen"} style={style} data-demo-src={src}>
|
|
702
|
-
<DemoCanvas src={src}
|
|
869
|
+
<DemoCanvas src={src} preview={{ kind: "component", component }} variant={chosen} bleed={metaValue?.bleed} look={metaValue} />
|
|
703
870
|
</div>
|
|
704
871
|
)
|
|
705
872
|
}
|
package/src/demo/context.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, useContext } from "react"
|
|
2
|
-
import type { DemoResolver } from "./types"
|
|
2
|
+
import type { DemoResolver, DemoRuntimeOptions } from "./types"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The host's demo resolver. Renderers given a `demoResolver` prop provide it, so
|
|
@@ -11,3 +11,19 @@ export function useDemoResolver(override?: DemoResolver): DemoResolver | undefin
|
|
|
11
11
|
const inherited = useContext(DocstreamDemoContext)
|
|
12
12
|
return override ?? inherited
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How inline-file demos without a resolver run their Preview (`demoRuntime`) and which
|
|
17
|
+
* extra npm packages they may import (`demoDependencies`). Provided by the renderers.
|
|
18
|
+
*/
|
|
19
|
+
export const DocstreamDemoRuntimeContext = createContext<DemoRuntimeOptions>({})
|
|
20
|
+
|
|
21
|
+
export function useDemoRuntime(override?: DemoRuntimeOptions): DemoRuntimeOptions {
|
|
22
|
+
const inherited = useContext(DocstreamDemoRuntimeContext)
|
|
23
|
+
return {
|
|
24
|
+
runtime: override?.runtime ?? inherited.runtime,
|
|
25
|
+
dependencies: override?.dependencies || inherited.dependencies
|
|
26
|
+
? { ...inherited.dependencies, ...override?.dependencies }
|
|
27
|
+
: undefined,
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/demo/glob.ts
CHANGED
|
@@ -12,13 +12,15 @@ export interface GlobDemoResolverOptions {
|
|
|
12
12
|
/**
|
|
13
13
|
* Raw file contents, e.g.
|
|
14
14
|
* `import.meta.glob("./examples/**\/*.{ts,tsx,css,json}", { query: "?raw", import: "default" })`.
|
|
15
|
+
* Values are file text (or loaders of it); no `<string>` generic is needed. A module
|
|
16
|
+
* object with a string `default` (a `?raw` glob without `import: "default"`) also works.
|
|
15
17
|
*/
|
|
16
|
-
sources: Record<string, MaybeLazy<
|
|
18
|
+
sources: Record<string, MaybeLazy<unknown>>
|
|
17
19
|
/**
|
|
18
20
|
* Folder metadata, e.g.
|
|
19
21
|
* `import.meta.glob("./examples/*\/*\/meta.json", { eager: true, import: "default" })`.
|
|
20
22
|
*/
|
|
21
|
-
metas?: Record<string, MaybeLazy<
|
|
23
|
+
metas?: Record<string, MaybeLazy<unknown>>
|
|
22
24
|
/**
|
|
23
25
|
* The glob prefix the `<page>/<example>` ids are relative to, e.g. `"./examples"`.
|
|
24
26
|
* When omitted, a demo's id is the last two folders of its entry path.
|
|
@@ -135,11 +137,17 @@ export function createGlobDemoResolver(options: GlobDemoResolverOptions): DemoRe
|
|
|
135
137
|
const entry = await entryFor(src)
|
|
136
138
|
const found = Object.entries(options.sources)
|
|
137
139
|
.map(([key, value]) => ({ path: inFolder(src, key), value }))
|
|
138
|
-
.filter((f): f is { path: string; value: MaybeLazy<
|
|
140
|
+
.filter((f): f is { path: string; value: MaybeLazy<unknown> } =>
|
|
139
141
|
!!f.path && f.path !== "meta.json" && !options.exclude?.(f.path, src))
|
|
140
142
|
const files = await Promise.all(found.map(async ({ path, value }) => {
|
|
141
143
|
const content = await unwrap(value)
|
|
142
|
-
|
|
144
|
+
const text =
|
|
145
|
+
typeof content === "string"
|
|
146
|
+
? content
|
|
147
|
+
: content && typeof content === "object" && typeof (content as { default?: unknown }).default === "string"
|
|
148
|
+
? (content as { default: string }).default
|
|
149
|
+
: String(content)
|
|
150
|
+
return { path, content: text, language: languageForPath(path) }
|
|
143
151
|
}))
|
|
144
152
|
return sortDemoFiles(files, entry)
|
|
145
153
|
})()
|
package/src/demo/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { DocstreamDemoContext, useDemoResolver } from "./context"
|
|
2
|
-
export { DemoBlock, DemoFullscreen, DemoViewer } from "./DemoViewer"
|
|
1
|
+
export { DocstreamDemoContext, DocstreamDemoRuntimeContext, useDemoResolver, useDemoRuntime } from "./context"
|
|
2
|
+
export { DemoBlock, DemoFullscreen, DemoViewer, preloadDemos } from "./DemoViewer"
|
|
3
3
|
export type { DemoFullscreenProps, DemoViewerProps } from "./DemoViewer"
|
|
4
4
|
export {
|
|
5
5
|
createGlobDemoResolver,
|
|
@@ -10,6 +10,16 @@ export {
|
|
|
10
10
|
sortDemoFiles,
|
|
11
11
|
} from "./glob"
|
|
12
12
|
export type { GlobDemoResolverOptions } from "./glob"
|
|
13
|
-
export { defaultDemoMarkdown, resolveDemosToMarkdown } from "./markdown"
|
|
14
|
-
export type { ResolveDemosOptions, ResolvedDemo } from "./markdown"
|
|
15
|
-
export type {
|
|
13
|
+
export { defaultDemoMarkdown, inlineDemoMarkdown, inlineDemoNode, resolveDemosToMarkdown, toInlineFiles } from "./markdown"
|
|
14
|
+
export type { DemoMarkdownFormat, ResolveDemosOptions, ResolvedDemo } from "./markdown"
|
|
15
|
+
export type {
|
|
16
|
+
DemoComponent,
|
|
17
|
+
DemoComponentProps,
|
|
18
|
+
DemoFile,
|
|
19
|
+
DemoMeta,
|
|
20
|
+
DemoResolver,
|
|
21
|
+
DemoRuntimeOptions,
|
|
22
|
+
DemoVariant,
|
|
23
|
+
InlineDemoRuntime,
|
|
24
|
+
InlineDemoRuntimeProps,
|
|
25
|
+
} from "./types"
|