@brett_lamy/docstream 1.4.0 → 1.6.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.
Files changed (91) hide show
  1. package/README.md +90 -0
  2. package/dist/demo/DemoViewer.d.ts +14 -0
  3. package/dist/demo/DemoViewer.d.ts.map +1 -1
  4. package/dist/demo/DemoViewer.js +50 -9
  5. package/dist/demo/DemoViewer.js.map +1 -1
  6. package/dist/demo/clipboard.d.ts +70 -0
  7. package/dist/demo/clipboard.d.ts.map +1 -0
  8. package/dist/demo/clipboard.js +187 -0
  9. package/dist/demo/clipboard.js.map +1 -0
  10. package/dist/demo/context.d.ts +6 -0
  11. package/dist/demo/context.d.ts.map +1 -1
  12. package/dist/demo/context.js +6 -0
  13. package/dist/demo/context.js.map +1 -1
  14. package/dist/demo/copy-demo.d.ts +23 -0
  15. package/dist/demo/copy-demo.d.ts.map +1 -0
  16. package/dist/demo/copy-demo.js +81 -0
  17. package/dist/demo/copy-demo.js.map +1 -0
  18. package/dist/demo/index.d.ts +8 -2
  19. package/dist/demo/index.d.ts.map +1 -1
  20. package/dist/demo/index.js +5 -2
  21. package/dist/demo/index.js.map +1 -1
  22. package/dist/demo/vfs.d.ts +51 -0
  23. package/dist/demo/vfs.d.ts.map +1 -0
  24. package/dist/demo/vfs.js +156 -0
  25. package/dist/demo/vfs.js.map +1 -0
  26. package/dist/docs/DocsRenderer.d.ts +8 -2
  27. package/dist/docs/DocsRenderer.d.ts.map +1 -1
  28. package/dist/docs/DocsRenderer.js +8 -6
  29. package/dist/docs/DocsRenderer.js.map +1 -1
  30. package/dist/index.d.ts +3 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/replay/NativeReplay.d.ts +2 -2
  35. package/dist/replay/NativeReplay.d.ts.map +1 -1
  36. package/dist/replay/NativeReplay.js +3 -1
  37. package/dist/replay/NativeReplay.js.map +1 -1
  38. package/dist/replay/ReplayPreview.d.ts +3 -3
  39. package/dist/replay/ReplayPreview.d.ts.map +1 -1
  40. package/dist/replay/ReplayPreview.js.map +1 -1
  41. package/dist/replay/ReplayStage.d.ts +10 -0
  42. package/dist/replay/ReplayStage.d.ts.map +1 -0
  43. package/dist/replay/ReplayStage.js +19 -0
  44. package/dist/replay/ReplayStage.js.map +1 -0
  45. package/dist/replay/ReplayStageImpl.d.ts +5 -0
  46. package/dist/replay/ReplayStageImpl.d.ts.map +1 -0
  47. package/dist/replay/ReplayStageImpl.js +249 -0
  48. package/dist/replay/ReplayStageImpl.js.map +1 -0
  49. package/dist/replay/index.d.ts +6 -0
  50. package/dist/replay/index.d.ts.map +1 -1
  51. package/dist/replay/index.js +3 -0
  52. package/dist/replay/index.js.map +1 -1
  53. package/dist/replay/markers.d.ts +55 -0
  54. package/dist/replay/markers.d.ts.map +1 -0
  55. package/dist/replay/markers.js +177 -0
  56. package/dist/replay/markers.js.map +1 -0
  57. package/dist/replay/sanitize.d.ts +2 -2
  58. package/dist/replay/sanitize.d.ts.map +1 -1
  59. package/dist/replay/sanitize.js +1 -1
  60. package/dist/replay/sanitize.js.map +1 -1
  61. package/dist/replay/stage-types.d.ts +65 -0
  62. package/dist/replay/stage-types.d.ts.map +1 -0
  63. package/dist/replay/stage-types.js +2 -0
  64. package/dist/replay/stage-types.js.map +1 -0
  65. package/dist/replay/types.d.ts +39 -0
  66. package/dist/replay/types.d.ts.map +1 -0
  67. package/dist/replay/types.js +36 -0
  68. package/dist/replay/types.js.map +1 -0
  69. package/dist/streamdown.d.ts +1 -1
  70. package/dist/streamdown.d.ts.map +1 -1
  71. package/dist/streamdown.js +2 -2
  72. package/dist/streamdown.js.map +1 -1
  73. package/package.json +9 -2
  74. package/src/demo/DemoViewer.tsx +56 -11
  75. package/src/demo/clipboard.ts +228 -0
  76. package/src/demo/context.ts +7 -0
  77. package/src/demo/copy-demo.tsx +102 -0
  78. package/src/demo/index.ts +29 -2
  79. package/src/demo/vfs.ts +197 -0
  80. package/src/docs/DocsRenderer.tsx +13 -4
  81. package/src/index.ts +20 -0
  82. package/src/replay/NativeReplay.tsx +6 -2
  83. package/src/replay/ReplayPreview.tsx +6 -6
  84. package/src/replay/ReplayStage.tsx +28 -0
  85. package/src/replay/ReplayStageImpl.tsx +281 -0
  86. package/src/replay/index.ts +6 -0
  87. package/src/replay/markers.ts +213 -0
  88. package/src/replay/sanitize.ts +2 -3
  89. package/src/replay/stage-types.ts +63 -0
  90. package/src/replay/types.ts +46 -0
  91. package/src/streamdown.tsx +2 -0
@@ -0,0 +1,102 @@
1
+ /* "Copy demo": writes the docstream clipboard (Markdown with the demo's files inline, plus the
2
+ `application/x-docstream+json` payload) through react-aria's `useClipboard`, so pasting into
3
+ a docstream editor recreates the demo with its files. A button click has no copy event of
4
+ its own, so the button focuses itself and runs the browser's copy command; react-aria's
5
+ listener then fills the event's DataTransfer. Where that is unavailable, the Markdown is
6
+ copied as plain text (editors still parse its inline-file demos). */
7
+ import { useEffect, useRef, useState, type DOMAttributes, type MutableRefObject } from "react"
8
+ import { useClipboard } from "react-aria"
9
+ import { CopyIcon, copyText } from "../docs/copy"
10
+ import { docstreamClipboardItems, type DocstreamClipboard } from "./clipboard"
11
+
12
+ export interface DocstreamCopy<T extends HTMLElement> {
13
+ ref: MutableRefObject<T | null>
14
+ /** react-aria `useClipboard`'s focus handlers: Cmd/Ctrl+C on the focused element copies too. */
15
+ props: DOMAttributes<T>
16
+ /** Copies; resolves false when there was nothing to copy. */
17
+ copy: () => Promise<boolean>
18
+ }
19
+
20
+ /**
21
+ * Copies a `DocstreamClipboard` (all three representations) from a click. Spread `props` on
22
+ * the triggering element (a focusable one, e.g. a `<button>`) and give it `ref`.
23
+ */
24
+ export function useDocstreamCopy<T extends HTMLElement = HTMLButtonElement>(produce: () => DocstreamClipboard | null): DocstreamCopy<T> {
25
+ const ref = useRef<T | null>(null)
26
+ const latest = useRef(produce)
27
+ latest.current = produce
28
+ const wrote = useRef(false)
29
+ const { clipboardProps } = useClipboard({
30
+ getItems: () => {
31
+ const clip = latest.current()
32
+ return clip ? docstreamClipboardItems(clip) : []
33
+ },
34
+ onCopy: () => {
35
+ wrote.current = true
36
+ },
37
+ })
38
+
39
+ const copy = async (): Promise<boolean> => {
40
+ const clip = latest.current()
41
+ if (!clip) return false
42
+ const el = ref.current
43
+ wrote.current = false
44
+ if (el && typeof document !== "undefined" && typeof document.execCommand === "function") {
45
+ // The copy event targets the selection when there is one; clear it so it targets
46
+ // this (focused) element and nothing else — an editor around us — claims the event.
47
+ const selection = typeof window !== "undefined" ? window.getSelection() : null
48
+ const ranges: Range[] = []
49
+ if (selection) for (let i = 0; i < selection.rangeCount; i++) ranges.push(selection.getRangeAt(i))
50
+ try {
51
+ if (document.activeElement !== el) el.focus({ preventScroll: true })
52
+ selection?.removeAllRanges()
53
+ document.execCommand("copy")
54
+ } catch {
55
+ // fall through to the plain-text copy
56
+ } finally {
57
+ if (selection && ranges.length && !selection.rangeCount) for (const range of ranges) selection.addRange(range)
58
+ }
59
+ }
60
+ if (!wrote.current) await copyText(clip.text)
61
+ return true
62
+ }
63
+
64
+ return { ref, props: clipboardProps as DOMAttributes<T>, copy }
65
+ }
66
+
67
+ export interface CopyDemoButtonProps {
68
+ /** The clipboard to write, computed on click. `null` disables the copy. */
69
+ clipboard: () => DocstreamClipboard | null
70
+ label?: string
71
+ className?: string
72
+ }
73
+
74
+ /** Icon button: copies the demo (Markdown + files + docstream payload) and swaps to a tick. */
75
+ export function CopyDemoButton({ clipboard, label = "Copy demo", className }: CopyDemoButtonProps) {
76
+ const { ref, props, copy } = useDocstreamCopy(clipboard)
77
+ const [copied, setCopied] = useState(false)
78
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
79
+ useEffect(() => () => clearTimeout(timer.current), [])
80
+ return (
81
+ <button
82
+ {...props}
83
+ ref={ref}
84
+ type="button"
85
+ className={className ? `docs-copy ${className}` : "docs-copy"}
86
+ onClick={() => {
87
+ void copy().then((ok) => {
88
+ if (!ok) return
89
+ setCopied(true)
90
+ clearTimeout(timer.current)
91
+ timer.current = setTimeout(() => setCopied(false), 1600)
92
+ })
93
+ }}
94
+ aria-label={copied ? "Copied" : label}
95
+ title={copied ? "Copied" : `${label} (Markdown with its files — paste into a docstream editor)`}
96
+ data-copied={copied || undefined}
97
+ data-docstream-copy-demo=""
98
+ >
99
+ <CopyIcon copied={copied} />
100
+ </button>
101
+ )
102
+ }
package/src/demo/index.ts CHANGED
@@ -1,5 +1,32 @@
1
- export { DocstreamDemoContext, DocstreamDemoRuntimeContext, useDemoResolver, useDemoRuntime } from "./context"
2
- export { DemoBlock, DemoFullscreen, DemoViewer, preloadDemos } from "./DemoViewer"
1
+ export { DocstreamDemoContext, DocstreamDemoRuntimeContext, DocstreamDemoVFSContext, useDemoResolver, useDemoRuntime } from "./context"
2
+ export { DemoBlock, DemoFullscreen, DemoViewer, peekDemoSource, prefetchDemoSources, preloadDemos } from "./DemoViewer"
3
+ export {
4
+ DOCSTREAM_CLIPBOARD_TYPE,
5
+ collectDemoNodes,
6
+ createDocstreamClipboard,
7
+ createDocstreamClipboardFromMarkdown,
8
+ demoNodeMeta,
9
+ docstreamClipboardItems,
10
+ docstreamPayloadFromMarkdown,
11
+ hasDocstreamClipboard,
12
+ mapDemoNodes,
13
+ parseDocstreamClipboard,
14
+ readDocstreamClipboard,
15
+ readDocstreamClipboardItems,
16
+ writeDocstreamClipboard,
17
+ } from "./clipboard"
18
+ export type {
19
+ CreateDocstreamClipboardOptions,
20
+ DemoSourceSnapshot,
21
+ DocstreamClipboard,
22
+ DocstreamClipboardItem,
23
+ DocstreamClipboardPayload,
24
+ DocstreamDropItem,
25
+ } from "./clipboard"
26
+ export { CopyDemoButton, useDocstreamCopy } from "./copy-demo"
27
+ export type { CopyDemoButtonProps, DocstreamCopy } from "./copy-demo"
28
+ export { createDemoVFS, useDemoVFS, useDemoVFSContext, useDemoVFSEntry, useDemoVFSFiles } from "./vfs"
29
+ export type { DemoVFS, DemoVFSChange, DemoVFSEntry, DemoVFSResolverOptions } from "./vfs"
3
30
  export type { DemoFullscreenProps, DemoViewerProps } from "./DemoViewer"
4
31
  export {
5
32
  createGlobDemoResolver,
@@ -0,0 +1,197 @@
1
+ /* The demo virtual file system: demo files held in memory by `src`, for demos no host
2
+ resolver knows — typically ones pasted from another docstream page or editor.
3
+
4
+ - `DemoViewer` reads it from context (`DocstreamDemoVFSContext`, or a renderer's `demoVFS`):
5
+ a block with no inline files whose `src` is in the VFS shows the VFS files exactly like
6
+ inline files (the host resolver still wins when it knows `src`; the Preview runs through
7
+ the `demoRuntime`, else the Code view opens with the "needs runtime" note).
8
+ - `vfs.resolver({ resolver, runtime })` is the same thing as a `DemoResolver`, layered over
9
+ a host resolver: `files` / `meta` from the host when it knows `src`, else from the VFS;
10
+ `load` from the host, else a component that runs the VFS files through the runtime.
11
+ - One VFS may be shared by several editors / renderers; `subscribe` reports changes so a
12
+ host can persist pasted files. */
13
+ import { useContext, useRef, useSyncExternalStore } from "react"
14
+ import { DocstreamDemoVFSContext } from "./context"
15
+ import type { DemoComponentProps, DemoFile, DemoMeta, DemoResolver, InlineDemoRuntime } from "./types"
16
+
17
+ export interface DemoVFSEntry {
18
+ /** Entry first (or `meta.entry`), paths relative to the demo folder. */
19
+ files: DemoFile[]
20
+ /** `meta.json`-style defaults, usually the `{% demo %}` tag's attributes. */
21
+ meta?: DemoMeta
22
+ }
23
+
24
+ export interface DemoVFSChange {
25
+ src: string
26
+ /** The new entry; `undefined` when `src` was removed. */
27
+ entry: DemoVFSEntry | undefined
28
+ }
29
+
30
+ export interface DemoVFSResolverOptions {
31
+ /** The host resolver, consulted first. */
32
+ resolver?: DemoResolver
33
+ /** Runs VFS files for `load(src)`. Without one, `load` rejects for VFS-only demos. */
34
+ runtime?: InlineDemoRuntime
35
+ /** Extra npm dependencies handed to the runtime. */
36
+ dependencies?: Record<string, string>
37
+ }
38
+
39
+ export interface DemoVFS {
40
+ get(src: string): DemoVFSEntry | undefined
41
+ has(src: string): boolean
42
+ /** Every `src` held, in insertion order. */
43
+ list(): string[]
44
+ /** Adds or replaces `src`. A no-op (and no change event) when nothing differs. */
45
+ set(src: string, files: DemoFile[], meta?: DemoMeta): void
46
+ delete(src: string): boolean
47
+ clear(): void
48
+ /** Everything held, as `{ [src]: entry }`. The object is stable until the next change. */
49
+ snapshot(): Readonly<Record<string, DemoVFSEntry>>
50
+ /** Files only, as `{ [src]: files }` (what a host usually persists). Stable until the next change. */
51
+ files(): Readonly<Record<string, DemoFile[]>>
52
+ subscribe(listener: (change: DemoVFSChange) => void): () => void
53
+ /** This VFS as a `DemoResolver` layered over a host resolver. */
54
+ resolver(options?: DemoVFSResolverOptions): DemoResolver
55
+ }
56
+
57
+ const RUNTIME_NOTE = "Live preview needs the playground runtime. The code is shown instead."
58
+
59
+ function cleanFiles(files: DemoFile[]): DemoFile[] {
60
+ return files
61
+ .filter((f) => f && typeof f.path === "string" && typeof f.content === "string")
62
+ .map((f) => ({ path: f.path, content: f.content, ...(f.language ? { language: f.language } : {}) }))
63
+ }
64
+
65
+ function entryFirst(files: DemoFile[], entry: string | undefined): DemoFile[] {
66
+ const index = entry ? files.findIndex((f) => f.path === entry) : -1
67
+ return index > 0 ? [files[index], ...files.slice(0, index), ...files.slice(index + 1)] : files
68
+ }
69
+
70
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
71
+
72
+ /** A new, empty (or pre-filled) demo VFS. */
73
+ export function createDemoVFS(initial?: Record<string, DemoFile[] | DemoVFSEntry>): DemoVFS {
74
+ const entries = new Map<string, DemoVFSEntry>()
75
+ const listeners = new Set<(change: DemoVFSChange) => void>()
76
+ let snap: Record<string, DemoVFSEntry> | null = null
77
+ let fileSnap: Record<string, DemoFile[]> | null = null
78
+
79
+ const put = (src: string, files: DemoFile[], meta?: DemoMeta) => {
80
+ const entry: DemoVFSEntry = { files: cleanFiles(files), ...(meta && Object.keys(meta).length ? { meta } : {}) }
81
+ const prev = entries.get(src)
82
+ if (prev && same(prev, entry)) return undefined
83
+ entries.set(src, entry)
84
+ return entry
85
+ }
86
+ const emit = (change: DemoVFSChange) => {
87
+ snap = null
88
+ fileSnap = null
89
+ for (const listener of Array.from(listeners)) listener(change)
90
+ }
91
+
92
+ for (const [src, value] of Object.entries(initial ?? {})) {
93
+ if (Array.isArray(value)) put(src, value)
94
+ else if (value) put(src, value.files, value.meta)
95
+ }
96
+
97
+ const vfs: DemoVFS = {
98
+ get: (src) => entries.get(src),
99
+ has: (src) => entries.has(src),
100
+ list: () => Array.from(entries.keys()),
101
+ set(src, files, meta) {
102
+ const entry = put(src, files, meta)
103
+ if (entry) emit({ src, entry })
104
+ },
105
+ delete(src) {
106
+ if (!entries.delete(src)) return false
107
+ emit({ src, entry: undefined })
108
+ return true
109
+ },
110
+ clear() {
111
+ for (const src of Array.from(entries.keys())) vfs.delete(src)
112
+ },
113
+ snapshot() {
114
+ if (!snap) snap = Object.fromEntries(entries)
115
+ return snap
116
+ },
117
+ files() {
118
+ if (!fileSnap) fileSnap = Object.fromEntries(Array.from(entries, ([src, e]) => [src, e.files]))
119
+ return fileSnap
120
+ },
121
+ subscribe(listener) {
122
+ listeners.add(listener)
123
+ return () => {
124
+ listeners.delete(listener)
125
+ }
126
+ },
127
+ resolver: (options) => layeredResolver(vfs, options ?? {}),
128
+ }
129
+ return vfs
130
+ }
131
+
132
+ function layeredResolver(vfs: DemoVFS, { resolver: host, runtime, dependencies }: DemoVFSResolverOptions): DemoResolver {
133
+ // Whether the host should be asked first: it lists `src`, or it can't list at all.
134
+ const hostFirst = (src: string) => !!host && (!host.list || host.list().includes(src) || !vfs.has(src))
135
+ const fromVfs = (src: string) => {
136
+ const entry = vfs.get(src)
137
+ if (!entry) throw new Error(`Unknown demo: ${src}`)
138
+ return entry
139
+ }
140
+ async function pick<T>(src: string, viaHost: (host: DemoResolver) => Promise<T>, viaVfs: (entry: DemoVFSEntry) => T): Promise<T> {
141
+ if (hostFirst(src)) {
142
+ try {
143
+ return await viaHost(host!)
144
+ } catch (error) {
145
+ if (!vfs.has(src)) throw error
146
+ }
147
+ }
148
+ return viaVfs(fromVfs(src))
149
+ }
150
+ const layered: DemoResolver = {
151
+ list: () => Array.from(new Set([...(host?.list?.() ?? []), ...vfs.list()])),
152
+ meta: (src) => pick(src, (h) => h.meta(src), (e) => e.meta),
153
+ files: (src) => pick(src, (h) => h.files(src), (e) => entryFirst(e.files, e.meta?.entry)),
154
+ load: (src) =>
155
+ pick(src, (h) => h.load(src), (e) => {
156
+ if (!runtime) throw new Error(RUNTIME_NOTE)
157
+ const files = entryFirst(e.files, e.meta?.entry)
158
+ const title = e.meta?.title ?? (src.split("/").pop() || "Demo")
159
+ const VfsDemo = ({ variant }: DemoComponentProps) =>
160
+ runtime({ src, title, files, entry: files[0]?.path ?? "index.tsx", ...(variant ? { variant } : {}), ...(dependencies ? { dependencies } : {}) })
161
+ VfsDemo.displayName = `VfsDemo(${src})`
162
+ return VfsDemo as never
163
+ }),
164
+ }
165
+ if (host?.href) layered.href = (src, options) => (hostFirst(src) ? host.href!(src, options) : undefined)
166
+ return layered
167
+ }
168
+
169
+ /**
170
+ * A demo VFS owned by the calling component (created once). Pass it as `demoVFS` to several
171
+ * editors / renderers to share pasted demo files between them.
172
+ */
173
+ export function useDemoVFS(initial?: Record<string, DemoFile[] | DemoVFSEntry>): DemoVFS {
174
+ const ref = useRef<DemoVFS | null>(null)
175
+ if (!ref.current) ref.current = createDemoVFS(initial)
176
+ return ref.current
177
+ }
178
+
179
+ /** The VFS in context (a renderer's or editor's `demoVFS`), unless `override` is given. */
180
+ export function useDemoVFSContext(override?: DemoVFS): DemoVFS | undefined {
181
+ const inherited = useContext(DocstreamDemoVFSContext)
182
+ return override ?? inherited
183
+ }
184
+
185
+ const EMPTY: Readonly<Record<string, DemoFile[]>> = Object.freeze({})
186
+ const noop = () => () => {}
187
+
188
+ /** `{ [src]: files }` of a VFS, re-rendering on change. */
189
+ export function useDemoVFSFiles(vfs: DemoVFS | undefined): Readonly<Record<string, DemoFile[]>> {
190
+ return useSyncExternalStore(vfs ? vfs.subscribe : noop, () => (vfs ? vfs.files() : EMPTY), () => (vfs ? vfs.files() : EMPTY))
191
+ }
192
+
193
+ /** One VFS entry, re-rendering when it changes. */
194
+ export function useDemoVFSEntry(vfs: DemoVFS | undefined, src: string): DemoVFSEntry | undefined {
195
+ const snapshot = useSyncExternalStore(vfs ? vfs.subscribe : noop, () => vfs?.snapshot(), () => vfs?.snapshot())
196
+ return src ? snapshot?.[src] : undefined
197
+ }
@@ -16,7 +16,8 @@ import { slugify } from "../gitbook/outline"
16
16
  import { resolveAsset } from "../assets"
17
17
  import { parseMarkdown } from "../gitbook/parse"
18
18
  import { serializeMarkdown } from "../gitbook/serialize"
19
- import { DocstreamDemoContext, DocstreamDemoRuntimeContext } from "../demo/context"
19
+ import { DocstreamDemoContext, DocstreamDemoRuntimeContext, DocstreamDemoVFSContext } from "../demo/context"
20
+ import type { DemoVFS } from "../demo/vfs"
20
21
  import { DemoBlock } from "../demo/DemoViewer"
21
22
  import type { DemoResolver, InlineDemoRuntime } from "../demo/types"
22
23
  import { CopyButton } from "./copy"
@@ -592,6 +593,11 @@ export interface DocRenderOptions {
592
593
  demoRuntime?: InlineDemoRuntime
593
594
  /** Extra npm dependencies inline demos may import (`{ "@brett_lamy/ui": "^1.2.0" }`). */
594
595
  demoDependencies?: Record<string, string>
596
+ /**
597
+ * Demo files held in memory by `src` (`createDemoVFS` / `useDemoVFS`), e.g. demos pasted
598
+ * into an editor. Blocks without inline files use them when no resolver knows `src`.
599
+ */
600
+ demoVFS?: DemoVFS
595
601
  /**
596
602
  * Render "Copy page ▾" (Copy / View as Markdown, Open in ChatGPT / Claude) above the content.
597
603
  * `true` uses the defaults; an object passes options to `DocPageActions`.
@@ -599,8 +605,9 @@ export interface DocRenderOptions {
599
605
  pageActions?: boolean | DocPageActionsOptions
600
606
  }
601
607
 
602
- function withDemoContext({ demoResolver, demoRuntime, demoDependencies }: DocRenderOptions, children: ReactNode) {
608
+ function withDemoContext({ demoResolver, demoRuntime, demoDependencies, demoVFS }: DocRenderOptions, children: ReactNode) {
603
609
  let out = children
610
+ if (demoVFS) out = <DocstreamDemoVFSContext.Provider value={demoVFS}>{out}</DocstreamDemoVFSContext.Provider>
604
611
  if (demoRuntime || demoDependencies) {
605
612
  out = <DemoRuntimeProvider runtime={demoRuntime} dependencies={demoDependencies}>{out}</DemoRuntimeProvider>
606
613
  }
@@ -640,12 +647,13 @@ export function MarkdownContent({
640
647
  demoResolver,
641
648
  demoRuntime,
642
649
  demoDependencies,
650
+ demoVFS,
643
651
  pageActions,
644
652
  className,
645
653
  }: { markdown: string; className?: string } & Renderers & DocRenderOptions) {
646
654
  const doc = useMemo(() => parseMarkdown(markdown), [markdown])
647
655
  return withDemoContext(
648
- { demoResolver, demoRuntime, demoDependencies },
656
+ { demoResolver, demoRuntime, demoDependencies, demoVFS },
649
657
  <div data-docstream="" className={className}>
650
658
  <PageActionsBar markdown={markdown} options={pageActions} />
651
659
  <Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
@@ -661,11 +669,12 @@ export function DocsRenderer({
661
669
  demoResolver,
662
670
  demoRuntime,
663
671
  demoDependencies,
672
+ demoVFS,
664
673
  pageActions,
665
674
  markdown,
666
675
  }: { doc: DocumentNode; /** Source for page actions; defaults to `serializeMarkdown(doc)`. */ markdown?: string } & Renderers & DocRenderOptions) {
667
676
  return withDemoContext(
668
- { demoResolver, demoRuntime, demoDependencies },
677
+ { demoResolver, demoRuntime, demoDependencies, demoVFS },
669
678
  <article className="docs-article">
670
679
  <PageActionsBar markdown={markdown ?? (() => serializeMarkdown(doc))} options={pageActions} />
671
680
  <Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
package/src/index.ts CHANGED
@@ -16,7 +16,21 @@ export {
16
16
  DemoViewer,
17
17
  DocstreamDemoContext,
18
18
  DocstreamDemoRuntimeContext,
19
+ DocstreamDemoVFSContext,
20
+ DOCSTREAM_CLIPBOARD_TYPE,
21
+ CopyDemoButton,
22
+ createDemoVFS,
23
+ createDocstreamClipboard,
24
+ createDocstreamClipboardFromMarkdown,
19
25
  createGlobDemoResolver,
26
+ docstreamClipboardItems,
27
+ parseDocstreamClipboard,
28
+ peekDemoSource,
29
+ prefetchDemoSources,
30
+ readDocstreamClipboard,
31
+ readDocstreamClipboardItems,
32
+ useDemoVFS,
33
+ useDemoVFSFiles,
20
34
  defaultDemoMarkdown,
21
35
  inlineDemoMarkdown,
22
36
  inlineDemoNode,
@@ -41,6 +55,11 @@ export type {
41
55
  DemoRuntimeOptions,
42
56
  DemoVariant,
43
57
  DemoViewerProps,
58
+ DemoVFS,
59
+ DemoVFSChange,
60
+ DemoVFSEntry,
61
+ DocstreamClipboard,
62
+ DocstreamClipboardPayload,
44
63
  GlobDemoResolverOptions,
45
64
  InlineDemoRuntime,
46
65
  InlineDemoRuntimeProps,
@@ -57,6 +76,7 @@ export type {
57
76
  ReplayPreviewProps,
58
77
  ReplaySource,
59
78
  } from "./replay/ReplayPreview"
79
+ export type { ReplayEvent } from "./replay/types"
60
80
  export { isReplayQaUrl, normalizeReplayEmbedUrl } from "./replay/url"
61
81
  export { VideoEmbed } from "./video"
62
82
  export type { VideoEmbedProps } from "./video"
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { EventType, Replayer } from 'rrweb';
5
5
  import type { eventWithTime } from 'rrweb';
6
6
  import { sanitizeReplayEvent } from './sanitize';
7
+ import type { ReplayEvent } from './types';
7
8
  import 'rrweb/dist/style.css';
8
9
 
9
10
  const DEFAULT_SPEEDS = [0.5, 1, 2, 4, 8];
@@ -15,7 +16,7 @@ export default function NativeReplay({
15
16
  loop = false,
16
17
  speeds = DEFAULT_SPEEDS,
17
18
  }: {
18
- events: eventWithTime[];
19
+ events: ReplayEvent[];
19
20
  autoplay?: boolean;
20
21
  loop?: boolean;
21
22
  speeds?: number[];
@@ -36,7 +37,10 @@ export default function NativeReplay({
36
37
  const autoplayRef = useRef(autoplay);
37
38
 
38
39
  const prepared = useMemo(
39
- () => events.map((event) => sanitizeReplayEvent(event)).sort((a, b) => a.timestamp - b.timestamp),
40
+ () =>
41
+ events
42
+ .map((event) => sanitizeReplayEvent(event))
43
+ .sort((a, b) => a.timestamp - b.timestamp) as unknown as eventWithTime[],
40
44
  [events],
41
45
  );
42
46
  const hasSnapshot = prepared.some((event) => event.type === EventType.FullSnapshot);
@@ -1,5 +1,5 @@
1
1
  import { Suspense, lazy, useEffect, useState } from 'react';
2
- import type { eventWithTime } from 'rrweb';
2
+ import type { ReplayEvent } from './types';
3
3
  import { normalizeReplayEmbedUrl } from './url';
4
4
 
5
5
  export interface ReplayPageSource {
@@ -10,7 +10,7 @@ export interface ReplayPageSource {
10
10
 
11
11
  export interface ReplayEventsSource {
12
12
  kind: 'events';
13
- events: eventWithTime[];
13
+ events: ReplayEvent[];
14
14
  }
15
15
 
16
16
  export interface ReplayEventsUrlSource {
@@ -21,7 +21,7 @@ export interface ReplayEventsUrlSource {
21
21
 
22
22
  export type ReplaySource =
23
23
  | string
24
- | eventWithTime[]
24
+ | ReplayEvent[]
25
25
  | ReplayPageSource
26
26
  | ReplayEventsSource
27
27
  | ReplayEventsUrlSource;
@@ -84,7 +84,7 @@ function ReplayFrame({ source, title }: { source: ReplayPageSource; title?: stri
84
84
  // rrweb (and its stylesheet) load on first use of an event-based source.
85
85
  const LazyNativeReplay = lazy(() => import('./NativeReplay'));
86
86
 
87
- function NativeReplay(props: { events: eventWithTime[]; autoplay?: boolean; loop?: boolean; speeds?: number[] }) {
87
+ function NativeReplay(props: { events: ReplayEvent[]; autoplay?: boolean; loop?: boolean; speeds?: number[] }) {
88
88
  return (
89
89
  <Suspense fallback={<div className="replay-preview__empty">Loading replay…</div>}>
90
90
  <LazyNativeReplay {...props} />
@@ -103,7 +103,7 @@ function RemoteReplay({
103
103
  loop?: boolean;
104
104
  speeds?: number[];
105
105
  }) {
106
- const [events, setEvents] = useState<eventWithTime[] | null>(null);
106
+ const [events, setEvents] = useState<ReplayEvent[] | null>(null);
107
107
  const [error, setError] = useState<string | null>(null);
108
108
 
109
109
  useEffect(() => {
@@ -120,7 +120,7 @@ function RemoteReplay({
120
120
  ? (body as { events: unknown[] }).events
121
121
  : null;
122
122
  if (!value) throw new Error('Replay event endpoint must return an event array or { events }');
123
- return value as eventWithTime[];
123
+ return value as ReplayEvent[];
124
124
  })
125
125
  .then(setEvents)
126
126
  .catch((reason: unknown) => {
@@ -0,0 +1,28 @@
1
+ /* The eager half of the headless stage: a lazy boundary around `ReplayStageImpl`, so importing
2
+ `ReplayStage` never pulls in rrweb — it (and its stylesheet) load on first render. */
3
+ import { Suspense, forwardRef, lazy } from 'react'
4
+ import type { ReplayStageHandle, ReplayStageProps } from './stage-types'
5
+
6
+ const load = () => import('./ReplayStageImpl')
7
+ const LazyReplayStage = lazy(load)
8
+
9
+ /** Start loading the rrweb player chunk ahead of the first `ReplayStage` render. */
10
+ export function preloadReplayStage(): Promise<unknown> {
11
+ return load()
12
+ }
13
+
14
+ /**
15
+ * A headless rrweb player: the recorded page, fitted to its box, with no controls. Drive it
16
+ * through the ref (`play`, `pause`, `seek`, …) and the `onTimeUpdate` / `onPlayingChange`
17
+ * callbacks to build custom player chrome. Requires the optional `rrweb` peer.
18
+ */
19
+ export const ReplayStage = forwardRef<ReplayStageHandle, ReplayStageProps>(function ReplayStage(
20
+ { fallback = null, ...props },
21
+ ref,
22
+ ) {
23
+ return (
24
+ <Suspense fallback={fallback}>
25
+ <LazyReplayStage ref={ref} {...props} />
26
+ </Suspense>
27
+ )
28
+ })