@brett_lamy/docstream 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,295 @@
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
+ - Optional live React/JSX/TSX previews powered by the almost-node runtime.
14
+ - A complete virtual-filesystem helper for multi-file React demos.
15
+ - GitBook block support for hints, tabs, expandables, steppers, embeds, content refs, columns, figures, tables, math, dividers, updates, and OpenAPI operations.
16
+ - Embed blocks render YouTube/direct video URLs through the built-in `VideoEmbed` and public Loop QA task, journey, exploration, or test-run URLs through `ReplayPreview`.
17
+ - CSS exported as a stable package entrypoint so host apps can theme with CSS variables or shadcn-style design tokens.
18
+
19
+ ## Installation
20
+
21
+ ```sh
22
+ npm install @brett_lamy/docstream react
23
+ ```
24
+
25
+ React is a peer dependency and must be provided by your app.
26
+
27
+ ## Basic Setup
28
+
29
+ Import the package CSS once near your app entrypoint:
30
+
31
+ ```ts
32
+ import "@brett_lamy/docstream/styles.css"
33
+ ```
34
+
35
+ If your TypeScript app checks CSS side-effect imports, include Vite's standard environment declaration or an equivalent CSS module declaration:
36
+
37
+ ```ts
38
+ /// <reference types="vite/client" />
39
+ ```
40
+
41
+ ## Render Streaming Markdown
42
+
43
+ Use `GitbookStreamdown` when markdown may arrive incrementally from an AI stream. The component accepts either `markdown` or string children.
44
+
45
+ ```tsx
46
+ import { GitbookStreamdown } from "@brett_lamy/docstream"
47
+ import "@brett_lamy/docstream/styles.css"
48
+
49
+ export function Answer({ text, isStreaming }: { text: string; isStreaming: boolean }) {
50
+ return (
51
+ <GitbookStreamdown markdown={text} isStreaming={isStreaming} />
52
+ )
53
+ }
54
+ ```
55
+
56
+ `isStreaming` adds `aria-busy` and a `data-streaming` attribute to the wrapper. `isAnimating` is also accepted for compatibility with stream UI state.
57
+
58
+ ## Render Parsed Documents
59
+
60
+ Use `parseMarkdown` and `DocsRenderer` when you want to parse once, inspect the AST, or serialize it later.
61
+
62
+ ```tsx
63
+ import { DocsRenderer, parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream"
64
+
65
+ const doc = parseMarkdown(markdown)
66
+ const roundTripped = serializeMarkdown(doc)
67
+
68
+ export function Preview() {
69
+ return <DocsRenderer doc={doc} />
70
+ }
71
+ ```
72
+
73
+ ## Run React and JSX demos
74
+
75
+ Install `@agent-wasm/core` in the host app and serve its service worker through
76
+ the Vite plugin:
77
+
78
+ ```ts
79
+ import { almostnodePlugin } from "@agent-wasm/core/vite"
80
+
81
+ export default defineConfig({
82
+ plugins: [almostnodePlugin()],
83
+ })
84
+ ```
85
+
86
+ `ReactDemo` creates the complete file tree in almost-node, starts its Vite
87
+ server, and renders the resulting app in a preview iframe. The file map can
88
+ contain any number of files:
89
+
90
+ ```tsx
91
+ import { ReactDemo } from "@brett_lamy/docstream"
92
+
93
+ const files = {
94
+ "/src/main.jsx": `
95
+ import React from "react"
96
+ import { createRoot } from "react-dom/client"
97
+ import App from "./App.jsx"
98
+ createRoot(document.getElementById("root")).render(<App />)
99
+ `,
100
+ "/src/App.jsx": `
101
+ import Card from "./Card.jsx"
102
+ export default function App() { return <Card title="Hello" /> }
103
+ `,
104
+ "/src/Card.jsx": `
105
+ export default function Card({ title }) { return <button>{title}</button> }
106
+ `,
107
+ }
108
+
109
+ <ReactDemo files={files} entry="/src/main.jsx" />
110
+ ```
111
+
112
+ For lower-level integrations, `createAlmostNodeFilesystem(files)` creates a
113
+ `VirtualFS`, while `createAlmostNodeWorkspace(files)` creates a container and
114
+ populates its complete filesystem so callers can run shell commands, npm, or
115
+ Vite themselves.
116
+
117
+ Single-file live code blocks opt in explicitly so ordinary documentation
118
+ snippets remain inert:
119
+
120
+ ````md
121
+ {% code language="jsx" live="true" entry="/src/main.jsx" %}
122
+ ```jsx
123
+ import { createRoot } from "react-dom/client"
124
+ createRoot(document.getElementById("root")).render(<h1>Hello</h1>)
125
+ ```
126
+ {% endcode %}
127
+ ````
128
+
129
+ ## Markdown Helper Component
130
+
131
+ `MarkdownContent` parses and renders a markdown string in one step:
132
+
133
+ ```tsx
134
+ import { MarkdownContent } from "@brett_lamy/docstream"
135
+
136
+ export function Preview({ markdown }: { markdown: string }) {
137
+ return <MarkdownContent markdown={markdown} />
138
+ }
139
+ ```
140
+
141
+ ## Video and Replay QA embeds
142
+
143
+ `embed` blocks accept direct `.mp4`, `.webm`, and `.ogg` assets, YouTube URLs,
144
+ and public Loop QA project URLs. Loop QA project URLs are converted to the
145
+ chrome-free `/p/...` route automatically:
146
+
147
+ ```md
148
+ {% embed url="https://loop-qa.replay.io/projects/project-id/journeys/journey-id" /%}
149
+ ```
150
+
151
+ The replay component is also available directly when a document needs a
152
+ custom source or native rrweb events:
153
+
154
+ ```tsx
155
+ import { ReplayPreview } from "@brett_lamy/docstream"
156
+
157
+ <ReplayPreview source="https://loop-qa.replay.io/projects/project-id/tasks/task-id" />
158
+ ```
159
+
160
+ ## GitBook Syntax
161
+
162
+ The parser supports normal Markdown plus GitBook-style block tags.
163
+
164
+ ### Hints
165
+
166
+ ```md
167
+ {% hint style="info" %}
168
+ Helpful context for the reader.
169
+ {% endhint %}
170
+ ```
171
+
172
+ Supported styles are `info`, `success`, `warning`, and `danger`.
173
+
174
+ ### Tabs
175
+
176
+ ````md
177
+ {% tabs %}
178
+ {% tab title="TypeScript" %}
179
+ ```ts
180
+ export const ok = true
181
+ ```
182
+ {% endtab %}
183
+ {% tab title="JSON" %}
184
+ ```json
185
+ { "ok": true }
186
+ ```
187
+ {% endtab %}
188
+ {% endtabs %}
189
+ ````
190
+
191
+ ### Expandables
192
+
193
+ ```md
194
+ {% expandable title="More details" %}
195
+ Hidden content goes here.
196
+ {% endexpandable %}
197
+ ```
198
+
199
+ ### Steppers
200
+
201
+ ```md
202
+ {% stepper %}
203
+ {% step %}
204
+ Create a token.
205
+ {% endstep %}
206
+ {% step %}
207
+ Call the API.
208
+ {% endstep %}
209
+ {% endstepper %}
210
+ ```
211
+
212
+ ### OpenAPI Operations
213
+
214
+ ```md
215
+ {% openapi-operation spec="petstore.yaml" path="/store/orders" method="get" /%}
216
+ ```
217
+
218
+ When a spec cannot be resolved, the renderer displays a fallback asking for an OpenAPI spec URL.
219
+
220
+ ## Assets and OpenAPI Specs
221
+
222
+ Relative image and OpenAPI spec paths can be resolved against an asset base:
223
+
224
+ ```ts
225
+ import { setAssetBase } from "@brett_lamy/docstream"
226
+
227
+ setAssetBase("/docs/assets/")
228
+ ```
229
+
230
+ You can also resolve paths yourself with `resolveAsset`.
231
+
232
+ ## API Reference
233
+
234
+ ### Components
235
+
236
+ - `GitbookStreamdown`: Parses and renders markdown for read-only stream output.
237
+ - `DocsRenderer`: Renders a parsed `DocumentNode`.
238
+ - `MarkdownContent`: Parses and renders a markdown string.
239
+ - `ReplayPreview` / `ReplayEmbed`: Embeds public Loop QA pages or plays CORS-enabled rrweb event data.
240
+ - `VideoEmbed`: Renders direct video assets or hosted video players.
241
+ - `OpenApiOperation`: Renders a parsed OpenAPI operation block.
242
+
243
+ ### Parser and Serializer
244
+
245
+ - `parseMarkdown(markdown)`: Converts a full markdown document into a `DocumentNode`.
246
+ - `parseBlocks(markdown)`: Parses markdown into block nodes.
247
+ - `serializeMarkdown(doc)`: Converts a `DocumentNode` back to markdown.
248
+ - `serializeBlocks(blocks)`: Serializes block nodes.
249
+ - `parseInline(markdown)`: Parses inline markdown nodes.
250
+ - `serializeInline(nodes)`: Serializes inline nodes.
251
+ - `plainText(nodes)`: Extracts plain text from inline nodes.
252
+ - `refDefinitions(markdown)`: Reads reference-style link definitions.
253
+
254
+ ### Types
255
+
256
+ All AST types are exported from the package root, including `DocumentNode`, `Block`, `Inline`, and `HintStyle`.
257
+
258
+ ## Styling and Theming
259
+
260
+ 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.
261
+
262
+ ```css
263
+ :root {
264
+ --background: 0 0% 100%;
265
+ --foreground: 222.2 84% 4.9%;
266
+ --border: 214.3 31.8% 91.4%;
267
+ --muted: 210 40% 96.1%;
268
+ --muted-foreground: 215.4 16.3% 46.9%;
269
+ --primary: 221.2 83.2% 53.3%;
270
+ }
271
+ ```
272
+
273
+ 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`.
274
+
275
+ ## Bundler Notes
276
+
277
+ This release ships TypeScript and TSX source through ESM exports:
278
+
279
+ ```json
280
+ {
281
+ "exports": {
282
+ ".": {
283
+ "types": "./src/index.ts",
284
+ "import": "./src/index.ts"
285
+ },
286
+ "./styles.css": "./src/styles.css"
287
+ }
288
+ }
289
+ ```
290
+
291
+ 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.
292
+
293
+ ## Related Package
294
+
295
+ 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.0",
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,17 @@
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
+ "peerDependenciesMeta": {
60
+ "@agent-wasm/core": {
61
+ "optional": true
62
+ }
31
63
  }
32
64
  }
@@ -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"