@brett_lamy/docstream 1.1.0 → 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.
@@ -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 { defaultDemoMarkdown } from "./markdown"
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
- /** The rendered component on its canvas. */
178
- function DemoCanvas({ src, component, variant, bleed }: {
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
- component: Loaded<DemoComponent>
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 className={bleed ? "docs-demo-canvas docs-demo-canvas-bleed" : "docs-demo-canvas"} data-variant={variant}>
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}/{file?.path}</span>
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 <Segmented options={variants} value={value} onChange={onChange} label={`${title} variant`} className="docs-demo-variants" />
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, component, bleed, href }: {
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
- component: Loaded<DemoComponent>
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} component={component} variant={variant} bleed={bleed} />
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 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
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
- if (!resolver) {
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
- component={component}
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
- <VariantSwitch variants={variants} value={variant} onChange={setVariant} title={title} />
534
- <FullscreenButton onOpen={() => setFullscreen(true)} title={title} />
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
- <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>
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
- component={component}
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, component, near, height, viewport, bleed, href, onFullscreen, dialog }: {
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
- component: Loaded<DemoComponent>
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 [tab, setTab] = useState<ViewTab>("preview")
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
- <VariantSwitch variants={variants} value={variant} onChange={onVariant} title={title} />
624
- {tab === "preview" ? (
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
- {fileList?.length ? <CopyButton text={allFiles} label="Copy all files as Markdown" className="docs-demo-icon-btn" /> : null}
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
- <PreviewStage width={width} onResize={setWidth} height={height}>
649
- {near ? <DemoCanvas src={src} component={component} variant={variant} bleed={bleed} /> : <Spinner />}
650
- </PreviewStage>
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} component={component} variant={chosen} bleed={metaValue?.bleed} />
869
+ <DemoCanvas src={src} preview={{ kind: "component", component }} variant={chosen} bleed={metaValue?.bleed} look={metaValue} />
703
870
  </div>
704
871
  )
705
872
  }
@@ -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<string>>
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<DemoMeta | { default: DemoMeta }>>
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<string> } =>
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
- return { path, content: typeof content === "string" ? content : String(content), language: languageForPath(path) }
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 { DemoComponent, DemoComponentProps, DemoFile, DemoMeta, DemoResolver, DemoVariant } from "./types"
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"