@brett_lamy/docstream 0.1.0 → 0.3.1

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 ADDED
@@ -0,0 +1,215 @@
1
+ # @brett_lamy/docstream
2
+
3
+ GitBook-aware markdown rendering for React applications and AI streaming surfaces.
4
+
5
+ `@brett_lamy/docstream` combines a small GitBook-flavored markdown parser with React renderers that can display docs blocks while a response is still streaming. It is designed for read-only documentation previews, AI answer panes, and apps that need GitBook-specific syntax without embedding a full editor.
6
+
7
+ ## Features
8
+
9
+ - React renderer for GitBook-style markdown blocks.
10
+ - Streaming-friendly `GitbookStreamdown` component inspired by `vercel/streamdown`.
11
+ - Parser and serializer for round-tripping supported GitBook syntax.
12
+ - Syntax-highlighted code blocks through `lowlight`.
13
+ - GitBook block support for hints, tabs, expandables, steppers, embeds, content refs, columns, figures, tables, math, dividers, updates, and OpenAPI operations.
14
+ - CSS exported as a stable package entrypoint so host apps can theme with CSS variables or shadcn-style design tokens.
15
+
16
+ ## Installation
17
+
18
+ ```sh
19
+ npm install @brett_lamy/docstream react
20
+ ```
21
+
22
+ React is a peer dependency and must be provided by your app.
23
+
24
+ ## Basic Setup
25
+
26
+ Import the package CSS once near your app entrypoint:
27
+
28
+ ```ts
29
+ import "@brett_lamy/docstream/styles.css"
30
+ ```
31
+
32
+ If your TypeScript app checks CSS side-effect imports, include Vite's standard environment declaration or an equivalent CSS module declaration:
33
+
34
+ ```ts
35
+ /// <reference types="vite/client" />
36
+ ```
37
+
38
+ ## Render Streaming Markdown
39
+
40
+ Use `GitbookStreamdown` when markdown may arrive incrementally from an AI stream. The component accepts either `markdown` or string children.
41
+
42
+ ```tsx
43
+ import { GitbookStreamdown } from "@brett_lamy/docstream"
44
+ import "@brett_lamy/docstream/styles.css"
45
+
46
+ export function Answer({ text, isStreaming }: { text: string; isStreaming: boolean }) {
47
+ return (
48
+ <GitbookStreamdown markdown={text} isStreaming={isStreaming} />
49
+ )
50
+ }
51
+ ```
52
+
53
+ `isStreaming` adds `aria-busy` and a `data-streaming` attribute to the wrapper. `isAnimating` is also accepted for compatibility with stream UI state.
54
+
55
+ ## Render Parsed Documents
56
+
57
+ Use `parseMarkdown` and `DocsRenderer` when you want to parse once, inspect the AST, or serialize it later.
58
+
59
+ ```tsx
60
+ import { DocsRenderer, parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream"
61
+
62
+ const doc = parseMarkdown(markdown)
63
+ const roundTripped = serializeMarkdown(doc)
64
+
65
+ export function Preview() {
66
+ return <DocsRenderer doc={doc} />
67
+ }
68
+ ```
69
+
70
+ ## Markdown Helper Component
71
+
72
+ `MarkdownContent` parses and renders a markdown string in one step:
73
+
74
+ ```tsx
75
+ import { MarkdownContent } from "@brett_lamy/docstream"
76
+
77
+ export function Preview({ markdown }: { markdown: string }) {
78
+ return <MarkdownContent markdown={markdown} />
79
+ }
80
+ ```
81
+
82
+ ## GitBook Syntax
83
+
84
+ The parser supports normal Markdown plus GitBook-style block tags.
85
+
86
+ ### Hints
87
+
88
+ ```md
89
+ {% hint style="info" %}
90
+ Helpful context for the reader.
91
+ {% endhint %}
92
+ ```
93
+
94
+ Supported styles are `info`, `success`, `warning`, and `danger`.
95
+
96
+ ### Tabs
97
+
98
+ ````md
99
+ {% tabs %}
100
+ {% tab title="TypeScript" %}
101
+ ```ts
102
+ export const ok = true
103
+ ```
104
+ {% endtab %}
105
+ {% tab title="JSON" %}
106
+ ```json
107
+ { "ok": true }
108
+ ```
109
+ {% endtab %}
110
+ {% endtabs %}
111
+ ````
112
+
113
+ ### Expandables
114
+
115
+ ```md
116
+ {% expandable title="More details" %}
117
+ Hidden content goes here.
118
+ {% endexpandable %}
119
+ ```
120
+
121
+ ### Steppers
122
+
123
+ ```md
124
+ {% stepper %}
125
+ {% step %}
126
+ Create a token.
127
+ {% endstep %}
128
+ {% step %}
129
+ Call the API.
130
+ {% endstep %}
131
+ {% endstepper %}
132
+ ```
133
+
134
+ ### OpenAPI Operations
135
+
136
+ ```md
137
+ {% openapi-operation spec="petstore.yaml" path="/store/orders" method="get" /%}
138
+ ```
139
+
140
+ When a spec cannot be resolved, the renderer displays a fallback asking for an OpenAPI spec URL.
141
+
142
+ ## Assets and OpenAPI Specs
143
+
144
+ Relative image and OpenAPI spec paths can be resolved against an asset base:
145
+
146
+ ```ts
147
+ import { setAssetBase } from "@brett_lamy/docstream"
148
+
149
+ setAssetBase("/docs/assets/")
150
+ ```
151
+
152
+ You can also resolve paths yourself with `resolveAsset`.
153
+
154
+ ## API Reference
155
+
156
+ ### Components
157
+
158
+ - `GitbookStreamdown`: Parses and renders markdown for read-only stream output.
159
+ - `DocsRenderer`: Renders a parsed `DocumentNode`.
160
+ - `MarkdownContent`: Parses and renders a markdown string.
161
+ - `OpenApiOperation`: Renders a parsed OpenAPI operation block.
162
+
163
+ ### Parser and Serializer
164
+
165
+ - `parseMarkdown(markdown)`: Converts a full markdown document into a `DocumentNode`.
166
+ - `parseBlocks(markdown)`: Parses markdown into block nodes.
167
+ - `serializeMarkdown(doc)`: Converts a `DocumentNode` back to markdown.
168
+ - `serializeBlocks(blocks)`: Serializes block nodes.
169
+ - `parseInline(markdown)`: Parses inline markdown nodes.
170
+ - `serializeInline(nodes)`: Serializes inline nodes.
171
+ - `plainText(nodes)`: Extracts plain text from inline nodes.
172
+ - `refDefinitions(markdown)`: Reads reference-style link definitions.
173
+
174
+ ### Types
175
+
176
+ All AST types are exported from the package root, including `DocumentNode`, `Block`, `Inline`, and `HintStyle`.
177
+
178
+ ## Styling and Theming
179
+
180
+ The package CSS is intentionally token-driven. It uses normal CSS variables and class names so host apps can align the renderer with a shadcn-style theme.
181
+
182
+ ```css
183
+ :root {
184
+ --background: 0 0% 100%;
185
+ --foreground: 222.2 84% 4.9%;
186
+ --border: 214.3 31.8% 91.4%;
187
+ --muted: 210 40% 96.1%;
188
+ --muted-foreground: 215.4 16.3% 46.9%;
189
+ --primary: 221.2 83.2% 53.3%;
190
+ }
191
+ ```
192
+
193
+ Import the CSS once, then set tokens globally in your app. Components also expose stable classes such as `docs-code`, `docs-tabs`, `docs-hint`, `docs-table`, and `docs-openapi`.
194
+
195
+ ## Bundler Notes
196
+
197
+ This release ships TypeScript and TSX source through ESM exports:
198
+
199
+ ```json
200
+ {
201
+ "exports": {
202
+ ".": {
203
+ "types": "./src/index.ts",
204
+ "import": "./src/index.ts"
205
+ },
206
+ "./styles.css": "./src/styles.css"
207
+ }
208
+ }
209
+ ```
210
+
211
+ It is validated with Vite and modern TypeScript `moduleResolution: "Bundler"`. Plain Node.js, CommonJS, or tooling that does not transpile TypeScript in dependencies may need a future precompiled JS build.
212
+
213
+ ## Related Package
214
+
215
+ Use `@brett_lamy/docstream-editor` when you need the editable TipTap experience for the same document model.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "files": [
9
- "src"
9
+ "src",
10
+ "README.md"
10
11
  ],
11
12
  "sideEffects": [
12
13
  "**/*.css"
@@ -16,6 +17,30 @@
16
17
  "types": "./src/index.ts",
17
18
  "import": "./src/index.ts"
18
19
  },
20
+ "./gitbook": {
21
+ "types": "./src/gitbook/index.ts",
22
+ "import": "./src/gitbook/index.ts"
23
+ },
24
+ "./assets": {
25
+ "types": "./src/assets.ts",
26
+ "import": "./src/assets.ts"
27
+ },
28
+ "./openapi": {
29
+ "types": "./src/openapi/OpenApiOperation.tsx",
30
+ "import": "./src/openapi/OpenApiOperation.tsx"
31
+ },
32
+ "./replay": {
33
+ "types": "./src/replay/index.ts",
34
+ "import": "./src/replay/index.ts"
35
+ },
36
+ "./video": {
37
+ "types": "./src/video/index.ts",
38
+ "import": "./src/video/index.ts"
39
+ },
40
+ "./playground": {
41
+ "types": "./src/playground/index.ts",
42
+ "import": "./src/playground/index.ts"
43
+ },
19
44
  "./styles.css": "./src/styles.css"
20
45
  },
21
46
  "dependencies": {
@@ -23,10 +48,16 @@
23
48
  "lowlight": "^3.3.0",
24
49
  "lucide-react": "^1.17.0",
25
50
  "mermaid": "^11.15.0",
51
+ "rrweb": "2.0.0-alpha.20",
26
52
  "streamdown": "^2.5.0",
27
53
  "yaml": "^2.9.0"
28
54
  },
29
55
  "peerDependencies": {
30
- "react": ">=18"
56
+ "react": ">=18",
57
+ "@agent-wasm/core": ">=0.4.0"
58
+ },
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "git+https://github.com/BLamy/docstream.git"
31
62
  }
32
63
  }
@@ -11,9 +11,12 @@ import {
11
11
  import type { Block, DocumentNode, Inline } from "../gitbook/ast"
12
12
  import { resolveAsset } from "../assets"
13
13
  import { parseMarkdown } from "../gitbook/parse"
14
+ import { ReplayPreview, isReplayQaUrl } from "../replay"
15
+ import { VideoEmbed } from "../video"
14
16
  import { OpenApiOperation } from "../openapi/OpenApiOperation"
15
17
  import { Mermaid } from "./Mermaid"
16
18
  import { HighlightedCode } from "./HighlightedCode"
19
+ import { ReactCodePreview } from "../playground"
17
20
 
18
21
  function InlineText({ nodes }: { nodes: Inline[] }) {
19
22
  return (
@@ -65,6 +68,10 @@ function embedSrc(url: string): string | null {
65
68
  return yt ? `https://www.youtube.com/embed/${yt[1]}` : null
66
69
  }
67
70
 
71
+ function isDirectVideo(url: string): boolean {
72
+ return /\.(?:mp4|webm|ogg)(?:[?#]|$)/i.test(url)
73
+ }
74
+
68
75
  function Tabs({ block }: { block: Extract<Block, { type: "tabs" }> }) {
69
76
  const [active, setActive] = useState(0)
70
77
  return (
@@ -105,6 +112,16 @@ function BlockView({ block }: { block: Block }) {
105
112
  }
106
113
  case "code":
107
114
  if (block.language === "mermaid") return <Mermaid code={block.code} />
115
+ if (block.live && isReactLanguage(block.language)) {
116
+ const entry = block.entry ?? "/src/main.jsx"
117
+ return (
118
+ <ReactCodePreview
119
+ files={{ [entry]: block.code }}
120
+ entry={entry}
121
+ title={block.title ?? "Live React preview"}
122
+ />
123
+ )
124
+ }
108
125
  return (
109
126
  <div className="docs-code">
110
127
  {(block.title || block.language) && (
@@ -162,9 +179,16 @@ function BlockView({ block }: { block: Block }) {
162
179
  </div>
163
180
  )
164
181
  case "embed": {
182
+ if (isReplayQaUrl(block.url)) {
183
+ return <ReplayPreview source={block.url} title="Replay preview" />
184
+ }
165
185
  const src = embedSrc(block.url)
166
- return src ? (
167
- <iframe className="docs-embed" src={src} title={block.url} allowFullScreen />
186
+ return src || isDirectVideo(block.url) ? (
187
+ <VideoEmbed
188
+ src={src ?? block.url}
189
+ className="docs-embed"
190
+ title={block.url}
191
+ />
168
192
  ) : (
169
193
  <a className="docs-content-ref" href={block.url} target="_blank" rel="noreferrer">
170
194
  <File className="size-4" /> {block.url}
@@ -286,6 +310,10 @@ function BlockView({ block }: { block: Block }) {
286
310
  }
287
311
  }
288
312
 
313
+ function isReactLanguage(language: string | null): boolean {
314
+ return language === "jsx" || language === "tsx" || language === "react"
315
+ }
316
+
289
317
  function Blocks({ blocks, inline }: { blocks: Block[]; inline?: boolean }) {
290
318
  return (
291
319
  <div className={inline ? "docs-blocks-inline" : undefined} style={inline ? { display: "inline" } : undefined}>
@@ -42,6 +42,10 @@ export interface CodeBlockNode {
42
42
  title: string | null
43
43
  lineNumbers: boolean
44
44
  code: string
45
+ /** Run a React/JSX/TSX entry file in the optional almost-node preview. */
46
+ live?: boolean
47
+ /** Project-relative entry file used by a live preview. */
48
+ entry?: string | null
45
49
  }
46
50
 
47
51
  export interface HintNode {
@@ -0,0 +1,6 @@
1
+ // Pure GitBook markdown engine (no React, no mermaid/streamdown). Safe to import
2
+ // in non-DOM environments such as a server-side AI agent or a Cloudflare Worker.
3
+ export type * from "./ast"
4
+ export { parseMarkdown, parseBlocks } from "./parse"
5
+ export { serializeBlocks, serializeMarkdown } from "./serialize"
6
+ export { parseInline, plainText, refDefinitions, serializeInline } from "./inline"
@@ -25,6 +25,36 @@ function htmlToInlineMd(html: string): string {
25
25
  .trim()
26
26
  }
27
27
 
28
+ // Split a GFM pipe-table row into cell strings. Splits only on unescaped pipes
29
+ // that are outside inline-code spans, and unescapes "\|" back to a literal "|".
30
+ function splitTableRow(row: string): string[] {
31
+ const trimmed = row.trim().replace(/^\|/, "").replace(/\|$/, "")
32
+ const cells: string[] = []
33
+ let cur = ""
34
+ let inCode = false
35
+ for (let k = 0; k < trimmed.length; k++) {
36
+ const ch = trimmed[k]
37
+ if (ch === "\\" && trimmed[k + 1] === "|") {
38
+ cur += "|"
39
+ k++
40
+ continue
41
+ }
42
+ if (ch === "`") {
43
+ inCode = !inCode
44
+ cur += ch
45
+ continue
46
+ }
47
+ if (ch === "|" && !inCode) {
48
+ cells.push(cur)
49
+ cur = ""
50
+ continue
51
+ }
52
+ cur += ch
53
+ }
54
+ cells.push(cur)
55
+ return cells
56
+ }
57
+
28
58
  const TEMPLATE_RE = /^\s*\{%\s*(\S+?)(\s+[^%]*?)?\s*%\}\s*$/
29
59
 
30
60
  function parseAttrs(raw: string | undefined): Record<string, string> {
@@ -158,6 +188,8 @@ export function parseBlocks(lines: string[]): Block[] {
158
188
  if (code && code.type === "code") {
159
189
  code.title = tag.attrs.title ?? null
160
190
  code.lineNumbers = tag.attrs.lineNumbers === "true"
191
+ code.live = tag.attrs.live === "true"
192
+ code.entry = tag.attrs.entry ?? null
161
193
  blocks.push(code)
162
194
  }
163
195
  i = next
@@ -414,11 +446,7 @@ export function parseBlocks(lines: string[]): Block[] {
414
446
  if (trimmed.startsWith("|") && lines[i + 1]?.trim().match(/^\|[\s:|-]+\|$/)) {
415
447
  flushParagraph()
416
448
  const parseRow = (row: string): Inline[][] =>
417
- row
418
- .trim()
419
- .replace(/^\||\|$/g, "")
420
- .split("|")
421
- .map((cell) => parseInline(cell.trim()))
449
+ splitTableRow(row).map((cell) => parseInline(cell.trim()))
422
450
  const header = parseRow(lines[i])
423
451
  i += 2
424
452
  const rows: Inline[][][] = []
@@ -29,10 +29,12 @@ function serializeBlock(b: Block): string {
29
29
  case "code": {
30
30
  const fence = "```" + (b.language ?? "")
31
31
  const body = `${fence}\n${b.code}\n\`\`\``
32
- if (b.title || b.lineNumbers) {
32
+ if (b.title || b.lineNumbers || b.live || b.entry) {
33
33
  const attrs = [
34
34
  b.title ? ` title="${b.title}"` : "",
35
35
  b.lineNumbers ? ` lineNumbers="true"` : "",
36
+ b.live ? ` live="true"` : "",
37
+ b.entry ? ` entry="${b.entry}"` : "",
36
38
  ].join("")
37
39
  return `{% code${attrs} %}\n${body}\n{% endcode %}`
38
40
  }
@@ -96,7 +98,9 @@ function serializeBlock(b: Block): string {
96
98
  .join("")}</tbody>`
97
99
  return `<table data-view="${b.view}">${head}${body}</table>`
98
100
  }
99
- const row = (cells: Inline[][]) => `| ${cells.map((c) => serializeInline(c)).join(" | ")} |`
101
+ // Escape pipes inside cell text so they don't break the GFM row.
102
+ const cell = (c: Inline[]) => serializeInline(c).replace(/\|/g, "\\|")
103
+ const row = (cells: Inline[][]) => `| ${cells.map(cell).join(" | ")} |`
100
104
  const sep = `| ${b.header.map(() => "---").join(" | ")} |`
101
105
  return [row(b.header), sep, ...b.rows.map(row)].join("\n")
102
106
  }
package/src/index.ts CHANGED
@@ -1,7 +1,35 @@
1
1
  export { GitbookStreamdown } from "./streamdown"
2
2
  export type { GitbookStreamdownProps } from "./streamdown"
3
3
  export { DocsRenderer, MarkdownContent } from "./docs/DocsRenderer"
4
+ export { ReplayEmbed, ReplayPreview } from "./replay"
5
+ export type {
6
+ ReplayEventsSource,
7
+ ReplayEventsUrlSource,
8
+ ReplayPageSource,
9
+ ReplayPreviewProps,
10
+ ReplaySource,
11
+ } from "./replay"
12
+ export { isReplayQaUrl, normalizeReplayEmbedUrl } from "./replay"
13
+ export { VideoEmbed } from "./video"
14
+ export type { VideoEmbedProps } from "./video"
4
15
  export { OpenApiOperation } from "./openapi/OpenApiOperation"
16
+ export {
17
+ ReactCodePreview,
18
+ ReactDemo,
19
+ createReactDemoFiles,
20
+ createAlmostNodeFilesystem,
21
+ createAlmostNodeWorkspace,
22
+ } from "./playground"
23
+ export type {
24
+ AlmostNodeFile,
25
+ AlmostNodeFileContent,
26
+ AlmostNodeFiles,
27
+ AlmostNodeFilesystemOptions,
28
+ AlmostNodeWorkspace,
29
+ AlmostNodeWorkspaceOptions,
30
+ ReactCodePreviewProps,
31
+ ReactDemoProps,
32
+ } from "./playground"
5
33
  export { resolveAsset, setAssetBase } from "./assets"
6
34
  export { parseMarkdown, parseBlocks } from "./gitbook/parse"
7
35
  export { serializeBlocks, serializeMarkdown } from "./gitbook/serialize"
@@ -0,0 +1,161 @@
1
+ import { useEffect, useState, type CSSProperties } from "react"
2
+ import {
3
+ createAlmostNodeWorkspace,
4
+ createReactDemoFiles,
5
+ type AlmostNodeFiles,
6
+ type AlmostNodeWorkspace,
7
+ } from "./filesystem"
8
+
9
+ export interface ReactDemoProps {
10
+ /** Complete project file map. Relative paths are rooted at `/`. */
11
+ files: AlmostNodeFiles
12
+ /** Vite entry file. Defaults to `/src/main.jsx`. */
13
+ entry?: string
14
+ /** Preferred virtual port. almost-node picks the next free port if needed. */
15
+ port?: number
16
+ /** Height of the embedded preview. */
17
+ height?: number | string
18
+ /** Label shown above the preview. */
19
+ title?: string
20
+ /** Start the runtime on mount (default true). */
21
+ autoStart?: boolean
22
+ /** iframe sandbox value. The same-origin permission lets the almost-node service worker route the preview. */
23
+ sandbox?: string
24
+ className?: string
25
+ onReady?: (url: string) => void
26
+ onError?: (error: Error) => void
27
+ }
28
+
29
+ export type ReactCodePreviewProps = ReactDemoProps
30
+
31
+ type PreviewState = "idle" | "starting" | "ready" | "error"
32
+
33
+ const DETACHED_SERVER_ENV = { ALMOSTNODE_DETACH_DEV_SERVERS: "1" }
34
+
35
+ function errorFromUnknown(error: unknown): Error {
36
+ return error instanceof Error ? error : new Error(String(error))
37
+ }
38
+
39
+ function frameHeight(height: number | string): CSSProperties {
40
+ return { height: typeof height === "number" ? `${height}px` : height }
41
+ }
42
+
43
+ /** Render a multi-file React/JSX/TSX project through almost-node's Vite server. */
44
+ export function ReactDemo({
45
+ files,
46
+ entry = "/src/main.jsx",
47
+ port = 4173,
48
+ height = 360,
49
+ title = "Live React preview",
50
+ autoStart = true,
51
+ sandbox = "allow-scripts allow-same-origin allow-forms allow-modals",
52
+ className,
53
+ onReady,
54
+ onError,
55
+ }: ReactDemoProps) {
56
+ const [run, setRun] = useState(autoStart ? 1 : 0)
57
+ const [state, setState] = useState<PreviewState>(autoStart ? "starting" : "idle")
58
+ const [url, setUrl] = useState<string | null>(null)
59
+ const [error, setError] = useState<string | null>(null)
60
+
61
+ useEffect(() => {
62
+ if (!run) {
63
+ setState("idle")
64
+ setUrl(null)
65
+ setError(null)
66
+ return
67
+ }
68
+
69
+ let cancelled = false
70
+ let workspace: AlmostNodeWorkspace | null = null
71
+ let readyUrl: string | null = null
72
+ setState("starting")
73
+ setUrl(null)
74
+ setError(null)
75
+
76
+ const start = async () => {
77
+ try {
78
+ const projectFiles = createReactDemoFiles(files, { entry })
79
+ workspace = await createAlmostNodeWorkspace(projectFiles, {
80
+ env: DETACHED_SERVER_ENV,
81
+ onServerReady: (_port, serverUrl) => {
82
+ readyUrl = serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`
83
+ },
84
+ })
85
+
86
+ const result = await workspace.container.run(`vite --port ${port}`, {
87
+ env: DETACHED_SERVER_ENV,
88
+ })
89
+ if (result.exitCode !== 0) {
90
+ throw new Error(result.stderr || result.stdout || "almost-node could not start Vite")
91
+ }
92
+
93
+ const serverUrl = readyUrl ?? `${workspace.container.serverBridge.getServerUrl(port)}/`
94
+ if (cancelled) {
95
+ workspace.dispose()
96
+ return
97
+ }
98
+ setUrl(serverUrl)
99
+ setState("ready")
100
+ onReady?.(serverUrl)
101
+ } catch (cause) {
102
+ const nextError = errorFromUnknown(cause)
103
+ if (!cancelled) {
104
+ setState("error")
105
+ setError(nextError.message)
106
+ onError?.(nextError)
107
+ }
108
+ workspace?.dispose()
109
+ }
110
+ }
111
+
112
+ void start()
113
+ return () => {
114
+ cancelled = true
115
+ workspace?.dispose()
116
+ }
117
+ }, [entry, files, onError, onReady, port, run])
118
+
119
+ const wrapperClass = className ? `docs-react-demo ${className}` : "docs-react-demo"
120
+ const running = state === "starting"
121
+
122
+ return (
123
+ <section className={wrapperClass} data-docstream-react-demo="">
124
+ <header className="docs-react-demo-header">
125
+ <span>{title}</span>
126
+ <span className={`docs-react-demo-status docs-react-demo-status-${state}`}>
127
+ {state === "ready" ? "ready" : state === "starting" ? "starting" : state}
128
+ </span>
129
+ {!autoStart || state === "error" ? (
130
+ <button
131
+ type="button"
132
+ className="docs-react-demo-run"
133
+ disabled={running}
134
+ onClick={() => setRun((value) => value + 1)}
135
+ >
136
+ {state === "error" ? "Retry" : "Run demo"}
137
+ </button>
138
+ ) : null}
139
+ </header>
140
+ {error ? (
141
+ <pre className="docs-react-demo-error">{error}</pre>
142
+ ) : url ? (
143
+ <iframe
144
+ title={title}
145
+ className="docs-react-demo-frame"
146
+ src={url}
147
+ sandbox={sandbox}
148
+ style={frameHeight(height)}
149
+ />
150
+ ) : (
151
+ <div className="docs-react-demo-placeholder" style={frameHeight(height)}>
152
+ {running ? "Starting almost-node…" : "Run the demo to open its virtual filesystem."}
153
+ </div>
154
+ )}
155
+ </section>
156
+ )
157
+ }
158
+
159
+ export function ReactCodePreview(props: ReactCodePreviewProps) {
160
+ return <ReactDemo {...props} />
161
+ }