@brett_lamy/docstream 0.3.1 → 0.3.3

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 CHANGED
@@ -12,6 +12,8 @@ GitBook-aware markdown rendering for React applications and AI streaming surface
12
12
  - Syntax-highlighted code blocks through `lowlight`.
13
13
  - GitBook block support for hints, tabs, expandables, steppers, embeds, content refs, columns, figures, tables, math, dividers, updates, and OpenAPI operations.
14
14
  - CSS exported as a stable package entrypoint so host apps can theme with CSS variables or shadcn-style design tokens.
15
+ - Attribute-aware direct video embeds for muted, looping inline clips in long-form posts.
16
+ - Optional `VizEmbed` integration for mounting deterministic `@brett_lamy/viz-engine` scenes in a document.
15
17
 
16
18
  ## Installation
17
19
 
@@ -39,6 +41,13 @@ If your TypeScript app checks CSS side-effect imports, include Vite's standard e
39
41
 
40
42
  Use `GitbookStreamdown` when markdown may arrive incrementally from an AI stream. The component accepts either `markdown` or string children.
41
43
 
44
+ Markdown-only applications can import the renderer from the `streamdown`
45
+ subpath. This keeps optional playground integrations out of the host bundle:
46
+
47
+ ```tsx
48
+ import { GitbookStreamdown } from "@brett_lamy/docstream/streamdown"
49
+ ```
50
+
42
51
  ```tsx
43
52
  import { GitbookStreamdown } from "@brett_lamy/docstream"
44
53
  import "@brett_lamy/docstream/styles.css"
@@ -139,6 +148,40 @@ Call the API.
139
148
 
140
149
  When a spec cannot be resolved, the renderer displays a fallback asking for an OpenAPI spec URL.
141
150
 
151
+ ### Inline video clips
152
+
153
+ Direct media embeds can opt into browser-safe inline playback. `muted` is
154
+ important when `autoplay` is enabled:
155
+
156
+ ```md
157
+ {% embed url="/generated/example/clips/offsets.mp4"
158
+ title="A reader resumes from its bookmark"
159
+ autoplay="true" loop="true" muted="true" controls="false" %}
160
+ ```
161
+
162
+ The attributes round-trip through `parseMarkdown` and `serializeMarkdown` and
163
+ are rendered as a native `<video playsInline>` element.
164
+
165
+ ### VizEngine scenes
166
+
167
+ Install the optional peer dependency when a document needs a live, seekable
168
+ scene rather than a rendered clip:
169
+
170
+ ```sh
171
+ npm install @brett_lamy/docstream @brett_lamy/viz-engine
172
+ ```
173
+
174
+ ```tsx
175
+ import { VizEmbed } from "@brett_lamy/docstream/viz"
176
+ import "@brett_lamy/docstream/styles.css"
177
+
178
+ <VizEmbed scene={scene} title="The same event, replayed into two projections" />
179
+ ```
180
+
181
+ `VizEmbed` keeps the scene's timeline deterministic and delegates the clock to
182
+ VizEngine, so a reader can pause or scrub the same explanation used to produce
183
+ the short clip.
184
+
142
185
  ## Assets and OpenAPI Specs
143
186
 
144
187
  Relative image and OpenAPI spec paths can be resolved against an asset base:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -21,6 +21,10 @@
21
21
  "types": "./src/gitbook/index.ts",
22
22
  "import": "./src/gitbook/index.ts"
23
23
  },
24
+ "./streamdown": {
25
+ "types": "./src/streamdown.tsx",
26
+ "import": "./src/streamdown.tsx"
27
+ },
24
28
  "./assets": {
25
29
  "types": "./src/assets.ts",
26
30
  "import": "./src/assets.ts"
@@ -37,6 +41,10 @@
37
41
  "types": "./src/video/index.ts",
38
42
  "import": "./src/video/index.ts"
39
43
  },
44
+ "./viz": {
45
+ "types": "./src/viz/index.ts",
46
+ "import": "./src/viz/index.ts"
47
+ },
40
48
  "./playground": {
41
49
  "types": "./src/playground/index.ts",
42
50
  "import": "./src/playground/index.ts"
@@ -50,14 +58,21 @@
50
58
  "mermaid": "^11.15.0",
51
59
  "rrweb": "2.0.0-alpha.20",
52
60
  "streamdown": "^2.5.0",
53
- "yaml": "^2.9.0"
61
+ "yaml": "^2.9.0",
62
+ "@brett_lamy/viz-engine": "^0.2.0"
54
63
  },
55
64
  "peerDependencies": {
56
- "react": ">=18",
57
- "@agent-wasm/core": ">=0.4.0"
65
+ "@agent-wasm/core": ">=0.4.0",
66
+ "@brett_lamy/viz-engine": ">=0.2.0",
67
+ "react": ">=18"
58
68
  },
59
69
  "repository": {
60
70
  "type": "git",
61
71
  "url": "git+https://github.com/BLamy/docstream.git"
72
+ },
73
+ "devDependencies": {
74
+ "@agent-wasm/core": "^0.4.0",
75
+ "@types/react": "^18.3.3",
76
+ "typescript": "^5.5.4"
62
77
  }
63
78
  }
@@ -16,7 +16,14 @@ import { VideoEmbed } from "../video"
16
16
  import { OpenApiOperation } from "../openapi/OpenApiOperation"
17
17
  import { Mermaid } from "./Mermaid"
18
18
  import { HighlightedCode } from "./HighlightedCode"
19
- import { ReactCodePreview } from "../playground"
19
+
20
+ export interface LivePreviewProps {
21
+ files: Record<string, string>
22
+ entry: string
23
+ title: string
24
+ }
25
+
26
+ export type LivePreviewRenderer = (props: LivePreviewProps) => ReactNode
20
27
 
21
28
  function InlineText({ nodes }: { nodes: Inline[] }) {
22
29
  return (
@@ -72,7 +79,7 @@ function isDirectVideo(url: string): boolean {
72
79
  return /\.(?:mp4|webm|ogg)(?:[?#]|$)/i.test(url)
73
80
  }
74
81
 
75
- function Tabs({ block }: { block: Extract<Block, { type: "tabs" }> }) {
82
+ function Tabs({ block, liveRenderer }: { block: Extract<Block, { type: "tabs" }>; liveRenderer?: LivePreviewRenderer }) {
76
83
  const [active, setActive] = useState(0)
77
84
  return (
78
85
  <div className="docs-tabs">
@@ -88,13 +95,13 @@ function Tabs({ block }: { block: Extract<Block, { type: "tabs" }> }) {
88
95
  ))}
89
96
  </div>
90
97
  <div className="docs-tabs-body">
91
- <Blocks blocks={block.tabs[active]?.children ?? []} />
98
+ <Blocks blocks={block.tabs[active]?.children ?? []} liveRenderer={liveRenderer} />
92
99
  </div>
93
100
  </div>
94
101
  )
95
102
  }
96
103
 
97
- function BlockView({ block }: { block: Block }) {
104
+ function BlockView({ block, liveRenderer }: { block: Block; liveRenderer?: LivePreviewRenderer }) {
98
105
  switch (block.type) {
99
106
  case "paragraph":
100
107
  return (
@@ -112,15 +119,13 @@ function BlockView({ block }: { block: Block }) {
112
119
  }
113
120
  case "code":
114
121
  if (block.language === "mermaid") return <Mermaid code={block.code} />
115
- if (block.live && isReactLanguage(block.language)) {
122
+ if (block.live && isReactLanguage(block.language) && liveRenderer) {
116
123
  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
+ return liveRenderer({
125
+ files: { [entry]: block.code },
126
+ entry,
127
+ title: block.title ?? "Live React preview",
128
+ })
124
129
  }
125
130
  return (
126
131
  <div className="docs-code">
@@ -145,19 +150,19 @@ function BlockView({ block }: { block: Block }) {
145
150
  <div className={`docs-hint docs-hint-${block.style}`}>
146
151
  <Icon className="docs-hint-icon" />
147
152
  <div>
148
- <Blocks blocks={block.children} />
153
+ <Blocks blocks={block.children} liveRenderer={liveRenderer} />
149
154
  </div>
150
155
  </div>
151
156
  )
152
157
  }
153
158
  case "tabs":
154
- return <Tabs block={block} />
159
+ return <Tabs block={block} liveRenderer={liveRenderer} />
155
160
  case "expandable":
156
161
  return (
157
162
  <details className="docs-expandable">
158
163
  <summary>{block.summary}</summary>
159
164
  <div className="docs-expandable-body">
160
- <Blocks blocks={block.children} />
165
+ <Blocks blocks={block.children} liveRenderer={liveRenderer} />
161
166
  </div>
162
167
  </details>
163
168
  )
@@ -172,7 +177,7 @@ function BlockView({ block }: { block: Block }) {
172
177
  </div>
173
178
  <div className="docs-step-main">
174
179
  {s.title && <div className="docs-step-title">{s.title}</div>}
175
- <Blocks blocks={s.children} />
180
+ <Blocks blocks={s.children} liveRenderer={liveRenderer} />
176
181
  </div>
177
182
  </div>
178
183
  ))}
@@ -187,7 +192,12 @@ function BlockView({ block }: { block: Block }) {
187
192
  <VideoEmbed
188
193
  src={src ?? block.url}
189
194
  className="docs-embed"
190
- title={block.url}
195
+ title={block.title ?? block.url}
196
+ {...(block.autoplay === undefined ? {} : { autoplay: block.autoplay })}
197
+ {...(block.loop === undefined ? {} : { loop: block.loop })}
198
+ {...(block.muted === undefined ? {} : { muted: block.muted })}
199
+ {...(block.controls === undefined ? {} : { controls: block.controls })}
200
+ {...(block.poster === undefined ? {} : { poster: resolveAsset(block.poster) })}
191
201
  />
192
202
  ) : (
193
203
  <a className="docs-content-ref" href={block.url} target="_blank" rel="noreferrer">
@@ -210,7 +220,7 @@ function BlockView({ block }: { block: Block }) {
210
220
  <div className="docs-columns">
211
221
  {block.columns.map((c, i) => (
212
222
  <div key={i} className="docs-column">
213
- <Blocks blocks={c.children} />
223
+ <Blocks blocks={c.children} liveRenderer={liveRenderer} />
214
224
  </div>
215
225
  ))}
216
226
  </div>
@@ -229,7 +239,7 @@ function BlockView({ block }: { block: Block }) {
229
239
  {block.items.map((item, i) => (
230
240
  <li key={i}>
231
241
  {block.task && <input type="checkbox" checked={!!item.checked} readOnly />}
232
- <Blocks blocks={item.children} inline />
242
+ <Blocks blocks={item.children} inline liveRenderer={liveRenderer} />
233
243
  </li>
234
244
  ))}
235
245
  </Tag>
@@ -238,7 +248,7 @@ function BlockView({ block }: { block: Block }) {
238
248
  case "blockquote":
239
249
  return (
240
250
  <blockquote>
241
- <Blocks blocks={block.children} />
251
+ <Blocks blocks={block.children} liveRenderer={liveRenderer} />
242
252
  </blockquote>
243
253
  )
244
254
  case "divider":
@@ -293,7 +303,7 @@ function BlockView({ block }: { block: Block }) {
293
303
  <div key={i} className="docs-update">
294
304
  <div className="docs-update-date">{u.date}</div>
295
305
  <div className="docs-update-body">
296
- <Blocks blocks={u.children} />
306
+ <Blocks blocks={u.children} liveRenderer={liveRenderer} />
297
307
  </div>
298
308
  </div>
299
309
  ))}
@@ -314,11 +324,11 @@ function isReactLanguage(language: string | null): boolean {
314
324
  return language === "jsx" || language === "tsx" || language === "react"
315
325
  }
316
326
 
317
- function Blocks({ blocks, inline }: { blocks: Block[]; inline?: boolean }) {
327
+ function Blocks({ blocks, inline, liveRenderer }: { blocks: Block[]; inline?: boolean; liveRenderer?: LivePreviewRenderer }) {
318
328
  return (
319
329
  <div className={inline ? "docs-blocks-inline" : undefined} style={inline ? { display: "inline" } : undefined}>
320
330
  {blocks.map((b, i) => (
321
- <BlockView key={i} block={b} />
331
+ <BlockView key={i} block={b} liveRenderer={liveRenderer} />
322
332
  ))}
323
333
  </div>
324
334
  )
@@ -326,14 +336,14 @@ function Blocks({ blocks, inline }: { blocks: Block[]; inline?: boolean }) {
326
336
 
327
337
  // Renders a markdown string inline — used for embedded markdown like
328
338
  // OpenAPI descriptions, which can themselves contain GitBook blocks.
329
- export function MarkdownContent({ markdown }: { markdown: string }) {
330
- return <Blocks blocks={parseMarkdown(markdown).children} />
339
+ export function MarkdownContent({ markdown, liveRenderer }: { markdown: string; liveRenderer?: LivePreviewRenderer }) {
340
+ return <Blocks blocks={parseMarkdown(markdown).children} liveRenderer={liveRenderer} />
331
341
  }
332
342
 
333
- export function DocsRenderer({ doc }: { doc: DocumentNode }) {
343
+ export function DocsRenderer({ doc, liveRenderer }: { doc: DocumentNode; liveRenderer?: LivePreviewRenderer }) {
334
344
  return (
335
345
  <article className="docs-article">
336
- <Blocks blocks={doc.children} />
346
+ <Blocks blocks={doc.children} liveRenderer={liveRenderer} />
337
347
  </article>
338
348
  )
339
349
  }
package/src/env.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare module "*.css" {
2
+ const href: string
3
+ export default href
4
+ }
@@ -85,6 +85,12 @@ export interface StepperNode {
85
85
  export interface EmbedNode {
86
86
  type: "embed"
87
87
  url: string
88
+ title?: string
89
+ autoplay?: boolean
90
+ loop?: boolean
91
+ muted?: boolean
92
+ controls?: boolean
93
+ poster?: string
88
94
  }
89
95
 
90
96
  export interface ContentRefNode {
@@ -66,6 +66,11 @@ function parseAttrs(raw: string | undefined): Record<string, string> {
66
66
  return attrs
67
67
  }
68
68
 
69
+ function booleanAttr(attrs: Record<string, string>, key: string): boolean | undefined {
70
+ if (!(key in attrs)) return undefined
71
+ return attrs[key] !== "false"
72
+ }
73
+
69
74
  interface TemplateTag {
70
75
  name: string
71
76
  attrs: Record<string, string>
@@ -197,7 +202,24 @@ export function parseBlocks(lines: string[]): Block[] {
197
202
  }
198
203
 
199
204
  if (tag.name === "embed") {
200
- blocks.push({ type: "embed", url: tag.attrs.url ?? "" })
205
+ blocks.push({
206
+ type: "embed",
207
+ url: tag.attrs.url ?? "",
208
+ ...(tag.attrs.title ? { title: tag.attrs.title } : {}),
209
+ ...(tag.attrs.poster ? { poster: tag.attrs.poster } : {}),
210
+ ...(booleanAttr(tag.attrs, "autoplay") === undefined
211
+ ? {}
212
+ : { autoplay: booleanAttr(tag.attrs, "autoplay") }),
213
+ ...(booleanAttr(tag.attrs, "loop") === undefined
214
+ ? {}
215
+ : { loop: booleanAttr(tag.attrs, "loop") }),
216
+ ...(booleanAttr(tag.attrs, "muted") === undefined
217
+ ? {}
218
+ : { muted: booleanAttr(tag.attrs, "muted") }),
219
+ ...(booleanAttr(tag.attrs, "controls") === undefined
220
+ ? {}
221
+ : { controls: booleanAttr(tag.attrs, "controls") }),
222
+ })
201
223
  i++
202
224
  // tolerate optional {% endembed %}
203
225
  if (i < lines.length && templateTag(lines[i])?.name === "endembed") i++
@@ -60,8 +60,18 @@ function serializeBlock(b: Block): string {
60
60
  })
61
61
  .join("\n\n")}\n{% endstepper %}`
62
62
 
63
- case "embed":
64
- return `{% embed url="${b.url}" %}`
63
+ case "embed": {
64
+ const attrs = [
65
+ ` url="${b.url}"`,
66
+ b.title ? ` title="${b.title}"` : "",
67
+ b.poster ? ` poster="${b.poster}"` : "",
68
+ b.autoplay === undefined ? "" : ` autoplay="${b.autoplay}"`,
69
+ b.loop === undefined ? "" : ` loop="${b.loop}"`,
70
+ b.muted === undefined ? "" : ` muted="${b.muted}"`,
71
+ b.controls === undefined ? "" : ` controls="${b.controls}"`,
72
+ ].join("")
73
+ return `{% embed${attrs} %}`
74
+ }
65
75
 
66
76
  case "content-ref":
67
77
  return `{% content-ref url="${b.url}" %}\n${serializeInline(b.children)}\n{% endcontent-ref %}`
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { GitbookStreamdown } from "./streamdown"
2
- export type { GitbookStreamdownProps } from "./streamdown"
1
+ export { PlaygroundStreamdown as GitbookStreamdown } from "./playground/PlaygroundStreamdown"
2
+ export type { PlaygroundStreamdownProps as GitbookStreamdownProps } from "./playground/PlaygroundStreamdown"
3
3
  export { DocsRenderer, MarkdownContent } from "./docs/DocsRenderer"
4
4
  export { ReplayEmbed, ReplayPreview } from "./replay"
5
5
  export type {
@@ -12,6 +12,8 @@ export type {
12
12
  export { isReplayQaUrl, normalizeReplayEmbedUrl } from "./replay"
13
13
  export { VideoEmbed } from "./video"
14
14
  export type { VideoEmbedProps } from "./video"
15
+ export { VizEmbed } from "./viz"
16
+ export type { VizEmbedProps } from "./viz"
15
17
  export { OpenApiOperation } from "./openapi/OpenApiOperation"
16
18
  export {
17
19
  ReactCodePreview,
@@ -0,0 +1,43 @@
1
+ import { useMemo } from "react"
2
+
3
+ import { DocsRenderer, type LivePreviewRenderer } from "../docs/DocsRenderer"
4
+ import { parseMarkdown } from "../gitbook/parse"
5
+ import { ReactCodePreview } from "./ReactCodePreview"
6
+
7
+ export interface PlaygroundStreamdownProps {
8
+ children?: string
9
+ className?: string
10
+ isAnimating?: boolean
11
+ isStreaming?: boolean
12
+ markdown?: string
13
+ }
14
+
15
+ /**
16
+ * The full renderer variant. It keeps the optional almost-node playground
17
+ * behind an explicit import boundary; markdown-only consumers should use the
18
+ * `@brett_lamy/docstream/streamdown` entry instead.
19
+ */
20
+ export function PlaygroundStreamdown({
21
+ children,
22
+ className,
23
+ isAnimating,
24
+ isStreaming = false,
25
+ markdown,
26
+ }: PlaygroundStreamdownProps) {
27
+ const content = markdown ?? children ?? ""
28
+ const doc = useMemo(() => parseMarkdown(content), [content])
29
+ const liveRenderer: LivePreviewRenderer = ({ files, entry, title }) => (
30
+ <ReactCodePreview files={files} entry={entry} title={title} />
31
+ )
32
+
33
+ return (
34
+ <div
35
+ aria-busy={isAnimating ?? isStreaming}
36
+ className={className}
37
+ data-docstream=""
38
+ data-streaming={isStreaming ? "" : undefined}
39
+ >
40
+ <DocsRenderer doc={doc} liveRenderer={liveRenderer} />
41
+ </div>
42
+ )
43
+ }
package/src/styles.css CHANGED
@@ -122,6 +122,22 @@
122
122
  margin-top: 8px;
123
123
  }
124
124
 
125
+ .docstream-viz {
126
+ margin: 1.5rem 0;
127
+ }
128
+
129
+ .docstream-viz__player {
130
+ overflow: hidden;
131
+ border: 1px solid var(--gb-border);
132
+ border-radius: 12px;
133
+ background: var(--gb-muted);
134
+ }
135
+
136
+ .docstream-viz figcaption {
137
+ margin-top: 8px;
138
+ color: var(--gb-muted-foreground);
139
+ }
140
+
125
141
  .docs-hint {
126
142
  display: flex;
127
143
  gap: 12px;
@@ -0,0 +1,42 @@
1
+ import type { PlayerAudio, VideoScene } from "@brett_lamy/viz-engine"
2
+ import { VizPlayer } from "@brett_lamy/viz-engine"
3
+
4
+ export interface VizEmbedProps {
5
+ scene: VideoScene
6
+ audio?: PlayerAudio
7
+ title?: string
8
+ className?: string
9
+ autoplay?: boolean
10
+ loop?: boolean
11
+ showCaptions?: boolean
12
+ }
13
+
14
+ /**
15
+ * Mount a deterministic VizEngine scene inside a Docstream document. This is
16
+ * intentionally a component API rather than a markdown escape hatch: callers
17
+ * choose the scene and its evidence, while Docstream supplies document chrome
18
+ * and accessible figure semantics.
19
+ */
20
+ export function VizEmbed({
21
+ scene,
22
+ audio,
23
+ title,
24
+ className,
25
+ autoplay = true,
26
+ loop = true,
27
+ showCaptions = false,
28
+ }: VizEmbedProps) {
29
+ return (
30
+ <figure className={`docstream-viz ${className ?? ""}`.trim()}>
31
+ <VizPlayer
32
+ scene={scene}
33
+ {...(audio === undefined ? {} : { audio })}
34
+ autoplay={autoplay}
35
+ loop={loop}
36
+ showCaptions={showCaptions}
37
+ className="docstream-viz__player"
38
+ />
39
+ {title && <figcaption>{title}</figcaption>}
40
+ </figure>
41
+ )
42
+ }
@@ -0,0 +1,4 @@
1
+ import "@brett_lamy/viz-engine/styles.css"
2
+
3
+ export { VizEmbed } from "./VizEmbed"
4
+ export type { VizEmbedProps } from "./VizEmbed"