@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 +215 -0
- package/package.json +34 -3
- package/src/docs/DocsRenderer.tsx +30 -2
- package/src/gitbook/ast.ts +4 -0
- package/src/gitbook/index.ts +6 -0
- package/src/gitbook/parse.ts +33 -5
- package/src/gitbook/serialize.ts +6 -2
- package/src/index.ts +28 -0
- package/src/playground/ReactCodePreview.tsx +161 -0
- package/src/playground/filesystem.ts +202 -0
- package/src/playground/index.ts +19 -0
- package/src/replay/ReplayPreview.tsx +398 -0
- package/src/replay/index.ts +10 -0
- package/src/replay/sanitize.ts +83 -0
- package/src/replay/styles.css +87 -0
- package/src/replay/url.ts +41 -0
- package/src/styles.css +108 -21
- package/src/video/VideoEmbed.tsx +62 -0
- package/src/video/index.ts +2 -0
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
|
|
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
|
-
<
|
|
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}>
|
package/src/gitbook/ast.ts
CHANGED
|
@@ -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"
|
package/src/gitbook/parse.ts
CHANGED
|
@@ -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[][][] = []
|
package/src/gitbook/serialize.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|