@brett_lamy/docstream 0.3.6 → 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 +56 -0
- package/package.json +26 -4
- package/src/docs/DocsRenderer.tsx +36 -18
- package/src/gitbook/ast.ts +19 -0
- package/src/gitbook/parse.ts +15 -0
- package/src/gitbook/serialize.ts +11 -0
- package/src/index.ts +9 -1
- package/src/source/SourcePreview.tsx +69 -0
- package/src/source/client.ts +40 -0
- package/src/source/index.ts +11 -0
- package/src/source/types.ts +38 -0
- package/src/styles.css +36 -0
- package/src/vite/index.ts +165 -0
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
|
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
|
|
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
|
}
|
package/src/gitbook/ast.ts
CHANGED
|
@@ -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
|
package/src/gitbook/parse.ts
CHANGED
|
@@ -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({
|
package/src/gitbook/serialize.ts
CHANGED
|
@@ -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,7 +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 } from "./docs/DocsRenderer"
|
|
4
|
+
export type { LivePreviewProps, LivePreviewRenderer, SourceReferenceRenderer } from "./docs/DocsRenderer"
|
|
5
5
|
export { ReplayEmbed, ReplayPreview } from "./replay"
|
|
6
6
|
export type {
|
|
7
7
|
ReplayEventsSource,
|
|
@@ -34,6 +34,14 @@ export type {
|
|
|
34
34
|
ReactDemoProps,
|
|
35
35
|
} from "./playground"
|
|
36
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"
|
|
37
45
|
export { parseMarkdown, parseBlocks } from "./gitbook/parse"
|
|
38
46
|
export { serializeBlocks, serializeMarkdown } from "./gitbook/serialize"
|
|
39
47
|
export { parseInline, plainText, refDefinitions, serializeInline } from "./gitbook/inline"
|
|
@@ -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
|
+
}
|