@brett_lamy/docstream 0.3.5 → 0.3.7

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
@@ -14,6 +14,7 @@ GitBook-aware markdown rendering for React applications and AI streaming surface
14
14
  - CSS exported as a stable package entrypoint so host apps can theme with CSS variables or shadcn-style design tokens.
15
15
  - Attribute-aware direct video embeds for muted, looping inline clips in long-form posts.
16
16
  - Optional `VizEmbed` integration for mounting deterministic `@brett_lamy/viz-engine` scenes in a document.
17
+ - Vite source mounts for rendering and editing real component or Storybook files without copying implementations into Markdown.
17
18
 
18
19
  ## Installation
19
20
 
@@ -182,6 +183,59 @@ import "@brett_lamy/docstream/styles.css"
182
183
  VizEngine, so a reader can pause or scrub the same explanation used to produce
183
184
  the short clip.
184
185
 
186
+ ### File-backed components and stories
187
+
188
+ Markdown is the composition layer. Keep component and Storybook implementations
189
+ in normal source files, then mount their directory in Vite:
190
+
191
+ ```ts
192
+ // vite.config.ts
193
+ import react from "@vitejs/plugin-react"
194
+ import { defineConfig } from "vite"
195
+ import { docstreamSources } from "@brett_lamy/docstream/vite"
196
+
197
+ export default defineConfig({
198
+ plugins: [
199
+ react(),
200
+ docstreamSources({
201
+ mounts: [{ name: "ui", root: "src/components" }],
202
+ }),
203
+ ],
204
+ })
205
+ ```
206
+
207
+ Reference a named component export or a CSF story from Markdown:
208
+
209
+ ```md
210
+ {% source-ref mount="ui" path="Button.tsx" export="Button" kind="component" title="Button" %}
211
+
212
+ {% source-ref mount="ui" path="Button.stories.tsx" export="Primary" kind="story" title="Primary button" %}
213
+ ```
214
+
215
+ Render references using the Vite client. The AST keeps `mount`, `path`,
216
+ `exportName`, and `kind`, so provenance survives parse/edit/serialize cycles:
217
+
218
+ ```tsx
219
+ import { MarkdownContent, SourcePreview, createViteSourceClient } from "@brett_lamy/docstream"
220
+
221
+ const client = createViteSourceClient()
222
+
223
+ export function Guide({ markdown }: { markdown: string }) {
224
+ return (
225
+ <MarkdownContent
226
+ markdown={markdown}
227
+ sourceRenderer={(reference) => <SourcePreview reference={reference} client={client} />}
228
+ />
229
+ )
230
+ }
231
+ ```
232
+
233
+ `client.read(reference)` returns the current file plus provenance.
234
+ `client.write(reference, content)` writes back to the mounted real file. The
235
+ plugin validates the mount boundary, only edits existing files, and notifies
236
+ Vite after a save so the preview reloads through Vite's normal module pipeline.
237
+ Set `writable: false` on a mount when a documentation site should only render.
238
+
185
239
  ## Assets and OpenAPI Specs
186
240
 
187
241
  Relative image and OpenAPI spec paths can be resolved against an asset base:
@@ -202,6 +256,8 @@ You can also resolve paths yourself with `resolveAsset`.
202
256
  - `DocsRenderer`: Renders a parsed `DocumentNode`.
203
257
  - `MarkdownContent`: Parses and renders a markdown string.
204
258
  - `OpenApiOperation`: Renders a parsed OpenAPI operation block.
259
+ - `SourcePreview`: Imports and renders a mounted component or CSF story export.
260
+ - `createViteSourceClient`: Reads, writes, and imports files exposed by the Vite plugin.
205
261
 
206
262
  ### Parser and Serializer
207
263
 
package/package.json CHANGED
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "type": "module",
6
+ "scripts": {
7
+ "typecheck": "tsc -p tsconfig.json --noEmit",
8
+ "test": "vitest run"
9
+ },
6
10
  "main": "./src/index.ts",
7
11
  "types": "./src/index.ts",
8
12
  "files": [
9
13
  "src",
14
+ "!src/**/*.test.ts",
10
15
  "README.md"
11
16
  ],
12
17
  "sideEffects": [
@@ -49,6 +54,14 @@
49
54
  "types": "./src/playground/index.ts",
50
55
  "import": "./src/playground/index.ts"
51
56
  },
57
+ "./source": {
58
+ "types": "./src/source/index.ts",
59
+ "import": "./src/source/index.ts"
60
+ },
61
+ "./vite": {
62
+ "types": "./src/vite/index.ts",
63
+ "import": "./src/vite/index.ts"
64
+ },
52
65
  "./styles.css": "./src/styles.css"
53
66
  },
54
67
  "dependencies": {
@@ -64,7 +77,13 @@
64
77
  "peerDependencies": {
65
78
  "@agent-wasm/core": ">=0.4.0",
66
79
  "@brett_lamy/viz-engine": ">=0.2.0",
67
- "react": ">=18"
80
+ "react": ">=18",
81
+ "vite": ">=5"
82
+ },
83
+ "peerDependenciesMeta": {
84
+ "vite": {
85
+ "optional": true
86
+ }
68
87
  },
69
88
  "repository": {
70
89
  "type": "git",
@@ -72,7 +91,10 @@
72
91
  },
73
92
  "devDependencies": {
74
93
  "@agent-wasm/core": "^0.4.0",
94
+ "@types/node": "^24.0.0",
75
95
  "@types/react": "^18.3.3",
76
- "typescript": "^5.5.4"
96
+ "typescript": "^5.5.4",
97
+ "vite": "^7.0.0",
98
+ "vitest": "^3.2.4"
77
99
  }
78
100
  }
@@ -8,7 +8,7 @@ import {
8
8
  XCircle,
9
9
  } from "lucide-react"
10
10
 
11
- import type { Block, DocumentNode, Inline } from "../gitbook/ast"
11
+ import type { Block, DocumentNode, Inline, SourceRefNode } from "../gitbook/ast"
12
12
  import { resolveAsset } from "../assets"
13
13
  import { parseMarkdown } from "../gitbook/parse"
14
14
  import { ReplayPreview, isReplayQaUrl } from "../replay"
@@ -25,6 +25,8 @@ export interface LivePreviewProps {
25
25
 
26
26
  export type LivePreviewRenderer = (props: LivePreviewProps) => ReactNode
27
27
 
28
+ export type SourceReferenceRenderer = (reference: SourceRefNode) => ReactNode
29
+
28
30
  function InlineText({ nodes }: { nodes: Inline[] }) {
29
31
  return (
30
32
  <>
@@ -79,7 +81,12 @@ function isDirectVideo(url: string): boolean {
79
81
  return /\.(?:mp4|webm|ogg)(?:[?#]|$)/i.test(url)
80
82
  }
81
83
 
82
- function Tabs({ block, liveRenderer }: { block: Extract<Block, { type: "tabs" }>; liveRenderer?: LivePreviewRenderer }) {
84
+ interface Renderers {
85
+ liveRenderer?: LivePreviewRenderer
86
+ sourceRenderer?: SourceReferenceRenderer
87
+ }
88
+
89
+ function Tabs({ block, liveRenderer, sourceRenderer }: { block: Extract<Block, { type: "tabs" }> } & Renderers) {
83
90
  const [active, setActive] = useState(0)
84
91
  return (
85
92
  <div className="docs-tabs">
@@ -95,13 +102,13 @@ function Tabs({ block, liveRenderer }: { block: Extract<Block, { type: "tabs" }>
95
102
  ))}
96
103
  </div>
97
104
  <div className="docs-tabs-body">
98
- <Blocks blocks={block.tabs[active]?.children ?? []} liveRenderer={liveRenderer} />
105
+ <Blocks blocks={block.tabs[active]?.children ?? []} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
99
106
  </div>
100
107
  </div>
101
108
  )
102
109
  }
103
110
 
104
- function BlockView({ block, liveRenderer }: { block: Block; liveRenderer?: LivePreviewRenderer }) {
111
+ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & Renderers) {
105
112
  switch (block.type) {
106
113
  case "paragraph":
107
114
  return (
@@ -150,19 +157,19 @@ function BlockView({ block, liveRenderer }: { block: Block; liveRenderer?: LiveP
150
157
  <div className={`docs-hint docs-hint-${block.style}`}>
151
158
  <Icon className="docs-hint-icon" />
152
159
  <div>
153
- <Blocks blocks={block.children} liveRenderer={liveRenderer} />
160
+ <Blocks blocks={block.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
154
161
  </div>
155
162
  </div>
156
163
  )
157
164
  }
158
165
  case "tabs":
159
- return <Tabs block={block} liveRenderer={liveRenderer} />
166
+ return <Tabs block={block} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
160
167
  case "expandable":
161
168
  return (
162
169
  <details className="docs-expandable">
163
170
  <summary>{block.summary}</summary>
164
171
  <div className="docs-expandable-body">
165
- <Blocks blocks={block.children} liveRenderer={liveRenderer} />
172
+ <Blocks blocks={block.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
166
173
  </div>
167
174
  </details>
168
175
  )
@@ -177,7 +184,7 @@ function BlockView({ block, liveRenderer }: { block: Block; liveRenderer?: LiveP
177
184
  </div>
178
185
  <div className="docs-step-main">
179
186
  {s.title && <div className="docs-step-title">{s.title}</div>}
180
- <Blocks blocks={s.children} liveRenderer={liveRenderer} />
187
+ <Blocks blocks={s.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
181
188
  </div>
182
189
  </div>
183
190
  ))}
@@ -215,12 +222,22 @@ function BlockView({ block, liveRenderer }: { block: Block; liveRenderer?: LiveP
215
222
  <ChevronRight className="size-4 ml-auto" />
216
223
  </a>
217
224
  )
225
+ case "source-ref":
226
+ return sourceRenderer ? (
227
+ sourceRenderer(block)
228
+ ) : (
229
+ <div className="docs-source-ref" data-docstream-source-ref="">
230
+ <File className="size-4" />
231
+ <span>{block.title ?? `${block.path}#${block.exportName}`}</span>
232
+ <code>{block.mount}:{block.path}</code>
233
+ </div>
234
+ )
218
235
  case "columns":
219
236
  return (
220
237
  <div className="docs-columns">
221
238
  {block.columns.map((c, i) => (
222
239
  <div key={i} className="docs-column">
223
- <Blocks blocks={c.children} liveRenderer={liveRenderer} />
240
+ <Blocks blocks={c.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
224
241
  </div>
225
242
  ))}
226
243
  </div>
@@ -239,7 +256,7 @@ function BlockView({ block, liveRenderer }: { block: Block; liveRenderer?: LiveP
239
256
  {block.items.map((item, i) => (
240
257
  <li key={i}>
241
258
  {block.task && <input type="checkbox" checked={!!item.checked} readOnly />}
242
- <Blocks blocks={item.children} inline liveRenderer={liveRenderer} />
259
+ <Blocks blocks={item.children} inline liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
243
260
  </li>
244
261
  ))}
245
262
  </Tag>
@@ -248,7 +265,7 @@ function BlockView({ block, liveRenderer }: { block: Block; liveRenderer?: LiveP
248
265
  case "blockquote":
249
266
  return (
250
267
  <blockquote>
251
- <Blocks blocks={block.children} liveRenderer={liveRenderer} />
268
+ <Blocks blocks={block.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
252
269
  </blockquote>
253
270
  )
254
271
  case "divider":
@@ -303,7 +320,7 @@ function BlockView({ block, liveRenderer }: { block: Block; liveRenderer?: LiveP
303
320
  <div key={i} className="docs-update">
304
321
  <div className="docs-update-date">{u.date}</div>
305
322
  <div className="docs-update-body">
306
- <Blocks blocks={u.children} liveRenderer={liveRenderer} />
323
+ <Blocks blocks={u.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
307
324
  </div>
308
325
  </div>
309
326
  ))}
@@ -324,11 +341,11 @@ function isReactLanguage(language: string | null): boolean {
324
341
  return language === "jsx" || language === "tsx" || language === "react"
325
342
  }
326
343
 
327
- function Blocks({ blocks, inline, liveRenderer }: { blocks: Block[]; inline?: boolean; liveRenderer?: LivePreviewRenderer }) {
344
+ function Blocks({ blocks, inline, liveRenderer, sourceRenderer }: { blocks: Block[]; inline?: boolean } & Renderers) {
328
345
  return (
329
346
  <div className={inline ? "docs-blocks-inline" : undefined} style={inline ? { display: "inline" } : undefined}>
330
347
  {blocks.map((b, i) => (
331
- <BlockView key={i} block={b} liveRenderer={liveRenderer} />
348
+ <BlockView key={i} block={b} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
332
349
  ))}
333
350
  </div>
334
351
  )
@@ -339,19 +356,20 @@ function Blocks({ blocks, inline, liveRenderer }: { blocks: Block[]; inline?: bo
339
356
  export function MarkdownContent({
340
357
  markdown,
341
358
  liveRenderer,
359
+ sourceRenderer,
342
360
  className,
343
- }: { markdown: string; liveRenderer?: LivePreviewRenderer; className?: string }) {
361
+ }: { markdown: string; className?: string } & Renderers) {
344
362
  return (
345
363
  <div data-docstream="" className={className}>
346
- <Blocks blocks={parseMarkdown(markdown).children} liveRenderer={liveRenderer} />
364
+ <Blocks blocks={parseMarkdown(markdown).children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
347
365
  </div>
348
366
  )
349
367
  }
350
368
 
351
- export function DocsRenderer({ doc, liveRenderer }: { doc: DocumentNode; liveRenderer?: LivePreviewRenderer }) {
369
+ export function DocsRenderer({ doc, liveRenderer, sourceRenderer }: { doc: DocumentNode } & Renderers) {
352
370
  return (
353
371
  <article className="docs-article">
354
- <Blocks blocks={doc.children} liveRenderer={liveRenderer} />
372
+ <Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
355
373
  </article>
356
374
  )
357
375
  }
@@ -99,6 +99,24 @@ export interface ContentRefNode {
99
99
  children: Inline[]
100
100
  }
101
101
 
102
+ export type SourceReferenceKind = "component" | "story"
103
+
104
+ /**
105
+ * A reference to an export in a real source file. The source file remains the
106
+ * authority; Markdown only stores the composition and presentation metadata.
107
+ */
108
+ export interface SourceRefNode {
109
+ type: "source-ref"
110
+ /** Name of the directory mounted by the Docstream Vite plugin. */
111
+ mount: string
112
+ /** POSIX-style path relative to the mounted source directory. */
113
+ path: string
114
+ /** Named export to render. Defaults to `default`. */
115
+ exportName: string
116
+ kind: SourceReferenceKind
117
+ title?: string
118
+ }
119
+
102
120
  export interface ColumnNode {
103
121
  type: "column"
104
122
  children: Block[]
@@ -182,6 +200,7 @@ export type Block =
182
200
  | StepperNode
183
201
  | EmbedNode
184
202
  | ContentRefNode
203
+ | SourceRefNode
185
204
  | ColumnsNode
186
205
  | FigureNode
187
206
  | ListNode
@@ -234,6 +234,21 @@ export function parseBlocks(lines: string[]): Block[] {
234
234
  continue
235
235
  }
236
236
 
237
+ if (tag.name === "source-ref" || tag.name === "component" || tag.name === "story") {
238
+ const kind = tag.name === "story" || tag.attrs.kind === "story" ? "story" : "component"
239
+ blocks.push({
240
+ type: "source-ref",
241
+ mount: tag.attrs.mount ?? "source",
242
+ path: tag.attrs.path ?? tag.attrs.src ?? "",
243
+ exportName: tag.attrs.export ?? (kind === "story" ? "Primary" : "default"),
244
+ kind,
245
+ ...(tag.attrs.title ? { title: tag.attrs.title } : {}),
246
+ })
247
+ i++
248
+ if (i < lines.length && templateTag(lines[i])?.name === `end${tag.name}`) i++
249
+ continue
250
+ }
251
+
237
252
  if (tag.name === "updates") {
238
253
  const { body, next } = collectUntil(lines, i + 1, "updates")
239
254
  blocks.push({
@@ -76,6 +76,17 @@ function serializeBlock(b: Block): string {
76
76
  case "content-ref":
77
77
  return `{% content-ref url="${b.url}" %}\n${serializeInline(b.children)}\n{% endcontent-ref %}`
78
78
 
79
+ case "source-ref": {
80
+ const attrs = [
81
+ ` mount="${b.mount}"`,
82
+ ` path="${b.path}"`,
83
+ ` export="${b.exportName}"`,
84
+ ` kind="${b.kind}"`,
85
+ b.title ? ` title="${b.title}"` : "",
86
+ ].join("")
87
+ return `{% source-ref${attrs} %}`
88
+ }
89
+
79
90
  case "columns":
80
91
  return `{% columns %}\n${b.columns
81
92
  .map((c) => `{% column %}\n${serializeBlocks(c.children)}\n{% endcolumn %}`)
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { PlaygroundStreamdown as GitbookStreamdown } from "./playground/PlaygroundStreamdown"
2
2
  export type { PlaygroundStreamdownProps as GitbookStreamdownProps } from "./playground/PlaygroundStreamdown"
3
3
  export { DocsRenderer, MarkdownContent } from "./docs/DocsRenderer"
4
+ export type { LivePreviewProps, LivePreviewRenderer, SourceReferenceRenderer } from "./docs/DocsRenderer"
4
5
  export { ReplayEmbed, ReplayPreview } from "./replay"
5
6
  export type {
6
7
  ReplayEventsSource,
@@ -33,6 +34,14 @@ export type {
33
34
  ReactDemoProps,
34
35
  } from "./playground"
35
36
  export { resolveAsset, setAssetBase } from "./assets"
37
+ export { createViteSourceClient, SourcePreview } from "./source"
38
+ export type {
39
+ SourceFileSnapshot,
40
+ SourceModule,
41
+ SourcePreviewProps,
42
+ SourceProvenance,
43
+ SourceReferenceClient,
44
+ } from "./source"
36
45
  export { parseMarkdown, parseBlocks } from "./gitbook/parse"
37
46
  export { serializeBlocks, serializeMarkdown } from "./gitbook/serialize"
38
47
  export { parseInline, plainText, refDefinitions, serializeInline } from "./gitbook/inline"
@@ -4,6 +4,7 @@ import {
4
4
  createReactDemoFiles,
5
5
  type AlmostNodeFiles,
6
6
  type AlmostNodeWorkspace,
7
+ type AlmostNodeWorkspaceOptions,
7
8
  } from "./filesystem"
8
9
 
9
10
  export interface ReactDemoProps {
@@ -21,6 +22,8 @@ export interface ReactDemoProps {
21
22
  autoStart?: boolean
22
23
  /** iframe sandbox value. The same-origin permission lets the almost-node service worker route the preview. */
23
24
  sandbox?: string
25
+ /** Extra options spread into `createAlmostNodeWorkspace` (e.g. `basePath` for static hosting). */
26
+ workspaceOptions?: Partial<AlmostNodeWorkspaceOptions>
24
27
  className?: string
25
28
  onReady?: (url: string) => void
26
29
  onError?: (error: Error) => void
@@ -49,6 +52,7 @@ export function ReactDemo({
49
52
  title = "Live React preview",
50
53
  autoStart = true,
51
54
  sandbox = "allow-scripts allow-same-origin allow-forms allow-modals",
55
+ workspaceOptions,
52
56
  className,
53
57
  onReady,
54
58
  onError,
@@ -77,9 +81,11 @@ export function ReactDemo({
77
81
  try {
78
82
  const projectFiles = createReactDemoFiles(files, { entry })
79
83
  workspace = await createAlmostNodeWorkspace(projectFiles, {
80
- env: DETACHED_SERVER_ENV,
81
- onServerReady: (_port, serverUrl) => {
84
+ ...workspaceOptions,
85
+ env: { ...DETACHED_SERVER_ENV, ...workspaceOptions?.env },
86
+ onServerReady: (serverPort, serverUrl) => {
82
87
  readyUrl = serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`
88
+ workspaceOptions?.onServerReady?.(serverPort, serverUrl)
83
89
  },
84
90
  })
85
91
 
@@ -114,7 +120,7 @@ export function ReactDemo({
114
120
  cancelled = true
115
121
  workspace?.dispose()
116
122
  }
117
- }, [entry, files, onError, onReady, port, run])
123
+ }, [entry, files, onError, onReady, port, run, workspaceOptions])
118
124
 
119
125
  const wrapperClass = className ? `docs-react-demo ${className}` : "docs-react-demo"
120
126
  const running = state === "starting"
@@ -0,0 +1,69 @@
1
+ import { createElement, useEffect, useState, type ComponentType, type ReactNode } from "react"
2
+ import type { SourceRefNode } from "../gitbook/ast"
3
+ import type { SourceModule, SourceReferenceClient, StoryLike, StoryMetaLike } from "./types"
4
+
5
+ export interface SourcePreviewProps {
6
+ reference: SourceRefNode
7
+ client: SourceReferenceClient
8
+ className?: string
9
+ fallback?: ReactNode
10
+ onError?: (error: Error) => void
11
+ }
12
+
13
+ function componentFrom(module: SourceModule, reference: SourceRefNode): { component: ComponentType<Record<string, unknown>>; props: Record<string, unknown> } {
14
+ if (reference.kind === "component") {
15
+ const candidate = reference.exportName === "default" ? module.default : module[reference.exportName]
16
+ if (typeof candidate !== "function") throw new Error(`${reference.path} does not export component ${reference.exportName}`)
17
+ return { component: candidate as ComponentType<Record<string, unknown>>, props: {} }
18
+ }
19
+
20
+ const meta = (module.default ?? {}) as StoryMetaLike
21
+ const story = module[reference.exportName] as StoryLike | undefined
22
+ if (!story) throw new Error(`${reference.path} does not export story ${reference.exportName}`)
23
+ const component = story.render ?? meta.render ?? meta.component
24
+ if (typeof component !== "function") throw new Error(`${reference.path}#${reference.exportName} has no renderable component`)
25
+ return {
26
+ component: component as ComponentType<Record<string, unknown>>,
27
+ props: { ...(meta.args ?? {}), ...(story.args ?? {}) },
28
+ }
29
+ }
30
+
31
+ /** Render a component or CSF story directly from a Vite-mounted source file. */
32
+ export function SourcePreview({ reference, client, className, fallback = "Loading source…", onError }: SourcePreviewProps) {
33
+ const [renderable, setRenderable] = useState<ReturnType<typeof componentFrom> | null>(null)
34
+ const [error, setError] = useState<Error | null>(null)
35
+
36
+ useEffect(() => {
37
+ let cancelled = false
38
+ setRenderable(null)
39
+ setError(null)
40
+ void client.importModule(reference).then(
41
+ (module) => {
42
+ if (!cancelled) setRenderable(componentFrom(module, reference))
43
+ },
44
+ (cause: unknown) => {
45
+ const next = cause instanceof Error ? cause : new Error(String(cause))
46
+ if (!cancelled) {
47
+ setError(next)
48
+ onError?.(next)
49
+ }
50
+ },
51
+ )
52
+ return () => {
53
+ cancelled = true
54
+ }
55
+ }, [client, onError, reference])
56
+
57
+ const wrapper = className ? `docs-source-preview ${className}` : "docs-source-preview"
58
+ return (
59
+ <section className={wrapper} data-docstream-source-preview="" data-source-path={reference.path}>
60
+ <header className="docs-source-preview-header">
61
+ <span>{reference.title ?? `${reference.path}#${reference.exportName}`}</span>
62
+ <code>{reference.mount}:{reference.path}</code>
63
+ </header>
64
+ <div className="docs-source-preview-body">
65
+ {error ? <pre className="docs-source-preview-error">{error.message}</pre> : renderable ? createElement(renderable.component, renderable.props) : fallback}
66
+ </div>
67
+ </section>
68
+ )
69
+ }
@@ -0,0 +1,40 @@
1
+ import type { SourceRefNode } from "../gitbook/ast"
2
+ import type { SourceFileSnapshot, SourceReferenceClient, SourceModule } from "./types"
3
+
4
+ function params(reference: SourceRefNode): URLSearchParams {
5
+ return new URLSearchParams({
6
+ mount: reference.mount,
7
+ path: reference.path,
8
+ export: reference.exportName,
9
+ kind: reference.kind,
10
+ })
11
+ }
12
+
13
+ async function json<T>(response: Response): Promise<T> {
14
+ if (!response.ok) throw new Error((await response.text()) || `Docstream source request failed (${response.status})`)
15
+ return response.json() as Promise<T>
16
+ }
17
+
18
+ /** Create a browser client for the endpoints installed by `docstreamSources()`. */
19
+ export function createViteSourceClient(apiBase = "/@docstream"): SourceReferenceClient {
20
+ const base = apiBase.replace(/\/$/, "")
21
+ return {
22
+ async read(reference) {
23
+ return json<SourceFileSnapshot>(await fetch(`${base}/source?${params(reference)}`))
24
+ },
25
+ async write(reference, content) {
26
+ return json<SourceFileSnapshot>(
27
+ await fetch(`${base}/source?${params(reference)}`, {
28
+ method: "PUT",
29
+ headers: { "content-type": "application/json" },
30
+ body: JSON.stringify({ content }),
31
+ }),
32
+ )
33
+ },
34
+ async importModule(reference) {
35
+ const url = `${base}/module?${params(reference)}&t=${Date.now()}`
36
+ const loaded = await import(/* @vite-ignore */ url) as { default?: SourceModule }
37
+ return loaded.default ?? (loaded as SourceModule)
38
+ },
39
+ }
40
+ }
@@ -0,0 +1,11 @@
1
+ export { createViteSourceClient } from "./client"
2
+ export { SourcePreview } from "./SourcePreview"
3
+ export type { SourcePreviewProps } from "./SourcePreview"
4
+ export type {
5
+ SourceFileSnapshot,
6
+ SourceModule,
7
+ SourceProvenance,
8
+ SourceReferenceClient,
9
+ StoryLike,
10
+ StoryMetaLike,
11
+ } from "./types"
@@ -0,0 +1,38 @@
1
+ import type { ComponentType } from "react"
2
+ import type { SourceRefNode } from "../gitbook/ast"
3
+
4
+ export interface SourceProvenance {
5
+ mount: string
6
+ path: string
7
+ exportName: string
8
+ kind: SourceRefNode["kind"]
9
+ absolutePath?: string
10
+ mtimeMs?: number
11
+ }
12
+
13
+ export interface SourceFileSnapshot {
14
+ content: string
15
+ provenance: SourceProvenance
16
+ }
17
+
18
+ export interface SourceModule {
19
+ default?: unknown
20
+ [exportName: string]: unknown
21
+ }
22
+
23
+ export interface SourceReferenceClient {
24
+ read(reference: SourceRefNode): Promise<SourceFileSnapshot>
25
+ write(reference: SourceRefNode, content: string): Promise<SourceFileSnapshot>
26
+ importModule(reference: SourceRefNode): Promise<SourceModule>
27
+ }
28
+
29
+ export interface StoryLike {
30
+ args?: Record<string, unknown>
31
+ render?: ComponentType<Record<string, unknown>> | ((args: Record<string, unknown>) => unknown)
32
+ }
33
+
34
+ export interface StoryMetaLike {
35
+ args?: Record<string, unknown>
36
+ component?: ComponentType<Record<string, unknown>>
37
+ render?: ComponentType<Record<string, unknown>> | ((args: Record<string, unknown>) => unknown)
38
+ }
package/src/styles.css CHANGED
@@ -32,6 +32,42 @@
32
32
  --gb-danger-bg: rgba(239, 68, 68, 0.1);
33
33
  }
34
34
 
35
+ .docs-source-ref,
36
+ .docs-source-preview-header {
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 0.5rem;
40
+ }
41
+
42
+ .docs-source-ref,
43
+ .docs-source-preview {
44
+ border: 1px solid var(--gb-border);
45
+ border-radius: var(--gb-radius);
46
+ overflow: hidden;
47
+ }
48
+
49
+ .docs-source-ref,
50
+ .docs-source-preview-header {
51
+ padding: 0.65rem 0.8rem;
52
+ background: var(--gb-muted);
53
+ }
54
+
55
+ .docs-source-ref code,
56
+ .docs-source-preview-header code {
57
+ margin-left: auto;
58
+ color: var(--gb-muted-foreground);
59
+ font-size: 0.75rem;
60
+ }
61
+
62
+ .docs-source-preview-body {
63
+ padding: 1rem;
64
+ }
65
+
66
+ .docs-source-preview-error {
67
+ color: var(--gb-danger-foreground, #b42318);
68
+ white-space: pre-wrap;
69
+ }
70
+
35
71
  .docs-article {
36
72
  max-width: 760px;
37
73
  margin: 0 auto;
@@ -0,0 +1,165 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import type { IncomingMessage, ServerResponse } from "node:http"
4
+ import type { Plugin, ViteDevServer } from "vite"
5
+
6
+ export interface DocstreamSourceMount {
7
+ /** Stable name used by Markdown source references. */
8
+ name: string
9
+ /** Real source directory. Relative paths are resolved from the Vite root. */
10
+ root: string
11
+ /** Allow PUT requests to edit files in this mount. Defaults to true. */
12
+ writable?: boolean
13
+ }
14
+
15
+ export interface DocstreamSourcesOptions {
16
+ mounts: readonly DocstreamSourceMount[]
17
+ /** HTTP endpoint prefix. Defaults to `/@docstream`. */
18
+ apiBase?: string
19
+ }
20
+
21
+ interface ResolvedMount {
22
+ name: string
23
+ root: string
24
+ writable: boolean
25
+ }
26
+
27
+ const VIRTUAL_ID = "virtual:docstream-sources"
28
+ const RESOLVED_VIRTUAL_ID = "\0virtual:docstream-sources"
29
+
30
+ function send(response: ServerResponse, status: number, body: string, type = "application/json; charset=utf-8") {
31
+ response.statusCode = status
32
+ response.setHeader("content-type", type)
33
+ response.setHeader("cache-control", "no-store")
34
+ response.end(body)
35
+ }
36
+
37
+ function requestUrl(request: IncomingMessage): URL {
38
+ return new URL(request.url ?? "/", "http://docstream.local")
39
+ }
40
+
41
+ async function requestBody(request: IncomingMessage): Promise<string> {
42
+ const chunks: Buffer[] = []
43
+ for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
44
+ return Buffer.concat(chunks).toString("utf8")
45
+ }
46
+
47
+ function normalizeRelativePath(value: string): string {
48
+ const source = value.replaceAll("\\", "/").replace(/^\.\//, "")
49
+ if (!source || source.startsWith("/") || source.split("/").some((part) => part === ".." || part === "")) {
50
+ throw new Error(`Invalid mounted source path: ${value}`)
51
+ }
52
+ return source
53
+ }
54
+
55
+ function sourcePath(mount: ResolvedMount, requestedPath: string): { relativePath: string; absolutePath: string } {
56
+ const relativePath = normalizeRelativePath(requestedPath)
57
+ const absolutePath = path.resolve(mount.root, relativePath)
58
+ const prefix = mount.root.endsWith(path.sep) ? mount.root : `${mount.root}${path.sep}`
59
+ if (!absolutePath.startsWith(prefix)) throw new Error(`Source path escapes mount ${mount.name}: ${requestedPath}`)
60
+ return { relativePath, absolutePath }
61
+ }
62
+
63
+ function provenance(url: URL, mount: ResolvedMount, relativePath: string, absolutePath: string, mtimeMs: number) {
64
+ return {
65
+ mount: mount.name,
66
+ path: relativePath,
67
+ exportName: url.searchParams.get("export") || "default",
68
+ kind: url.searchParams.get("kind") === "story" ? "story" : "component",
69
+ absolutePath,
70
+ mtimeMs,
71
+ }
72
+ }
73
+
74
+ function findMount(url: URL, mounts: readonly ResolvedMount[]): ResolvedMount {
75
+ const name = url.searchParams.get("mount") ?? ""
76
+ const mount = mounts.find((candidate) => candidate.name === name)
77
+ if (!mount) throw new Error(`Unknown Docstream source mount: ${name}`)
78
+ return mount
79
+ }
80
+
81
+ function errorMessage(error: unknown): string {
82
+ return error instanceof Error ? error.message : String(error)
83
+ }
84
+
85
+ function installMiddleware(server: ViteDevServer, apiBase: string, viteBase: string, mounts: readonly ResolvedMount[]) {
86
+ server.middlewares.use(async (request, response, next) => {
87
+ const url = requestUrl(request)
88
+ if (url.pathname !== `${apiBase}/source` && url.pathname !== `${apiBase}/module`) return next()
89
+
90
+ try {
91
+ const mount = findMount(url, mounts)
92
+ const requestedPath = url.searchParams.get("path") ?? ""
93
+ const { relativePath, absolutePath } = sourcePath(mount, requestedPath)
94
+
95
+ if (url.pathname === `${apiBase}/module`) {
96
+ if (request.method !== "GET") return send(response, 405, "Method not allowed", "text/plain; charset=utf-8")
97
+ await fs.access(absolutePath)
98
+ const sourceUrl = `${viteBase.replace(/\/$/, "")}/@fs/${absolutePath.replaceAll(path.sep, "/")}?docstream=${Date.now()}`
99
+ const module = `import * as source from ${JSON.stringify(sourceUrl)};\nexport default source;\n`
100
+ return send(response, 200, module, "text/javascript; charset=utf-8")
101
+ }
102
+
103
+ if (request.method === "GET") {
104
+ const [content, stat] = await Promise.all([fs.readFile(absolutePath, "utf8"), fs.stat(absolutePath)])
105
+ return send(response, 200, JSON.stringify({ content, provenance: provenance(url, mount, relativePath, absolutePath, stat.mtimeMs) }))
106
+ }
107
+
108
+ if (request.method === "PUT") {
109
+ if (!mount.writable) return send(response, 403, `Source mount ${mount.name} is read-only`, "text/plain; charset=utf-8")
110
+ await fs.access(absolutePath)
111
+ const parsed = JSON.parse(await requestBody(request)) as { content?: unknown }
112
+ if (typeof parsed.content !== "string") return send(response, 400, "Expected a JSON string field named content", "text/plain; charset=utf-8")
113
+ await fs.writeFile(absolutePath, parsed.content, "utf8")
114
+ const stat = await fs.stat(absolutePath)
115
+ server.watcher.add(absolutePath)
116
+ server.ws.send({ type: "full-reload", path: "*" })
117
+ return send(response, 200, JSON.stringify({ content: parsed.content, provenance: provenance(url, mount, relativePath, absolutePath, stat.mtimeMs) }))
118
+ }
119
+
120
+ return send(response, 405, "Method not allowed", "text/plain; charset=utf-8")
121
+ } catch (error) {
122
+ const status = error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "ENOENT" ? 404 : 400
123
+ return send(response, status, errorMessage(error), "text/plain; charset=utf-8")
124
+ }
125
+ })
126
+ }
127
+
128
+ /**
129
+ * Mount real source directories behind safe Vite dev-server endpoints.
130
+ * Markdown references import live modules from these files, and editor writes
131
+ * go back to disk so Vite's normal watcher/HMR pipeline remains authoritative.
132
+ */
133
+ export function docstreamSources(options: DocstreamSourcesOptions): Plugin {
134
+ const apiBase = `/${(options.apiBase ?? "/@docstream").replace(/^\/+|\/+$/g, "")}`
135
+ let mounts: ResolvedMount[] = []
136
+ let viteBase = "/"
137
+
138
+ return {
139
+ name: "docstream-sources",
140
+ enforce: "pre",
141
+ configResolved(config) {
142
+ viteBase = config.base || "/"
143
+ const names = new Set<string>()
144
+ mounts = options.mounts.map((mount) => {
145
+ if (!mount.name || names.has(mount.name)) throw new Error(`Docstream source mount names must be unique: ${mount.name}`)
146
+ names.add(mount.name)
147
+ return {
148
+ name: mount.name,
149
+ root: path.resolve(config.root, mount.root),
150
+ writable: mount.writable !== false,
151
+ }
152
+ })
153
+ },
154
+ configureServer(server) {
155
+ installMiddleware(server, apiBase, viteBase, mounts)
156
+ },
157
+ resolveId(id) {
158
+ if (id === VIRTUAL_ID) return RESOLVED_VIRTUAL_ID
159
+ },
160
+ load(id) {
161
+ if (id !== RESOLVED_VIRTUAL_ID) return
162
+ return `export const apiBase = ${JSON.stringify(apiBase)};\nexport const mounts = ${JSON.stringify(options.mounts.map(({ name, writable = true }) => ({ name, writable })))};\n`
163
+ },
164
+ }
165
+ }