@brett_lamy/docstream 0.1.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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@brett_lamy/docstream",
3
+ "version": "0.1.0",
4
+ "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "files": [
9
+ "src"
10
+ ],
11
+ "sideEffects": [
12
+ "**/*.css"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.ts",
17
+ "import": "./src/index.ts"
18
+ },
19
+ "./styles.css": "./src/styles.css"
20
+ },
21
+ "dependencies": {
22
+ "hast-util-to-html": "^9.0.5",
23
+ "lowlight": "^3.3.0",
24
+ "lucide-react": "^1.17.0",
25
+ "mermaid": "^11.15.0",
26
+ "streamdown": "^2.5.0",
27
+ "yaml": "^2.9.0"
28
+ },
29
+ "peerDependencies": {
30
+ "react": ">=18"
31
+ }
32
+ }
package/src/assets.ts ADDED
@@ -0,0 +1,20 @@
1
+ // Resolves repo-relative image paths (e.g. "readme-assets/logo.svg") against
2
+ // the raw.githubusercontent.com URL of the file being edited. The AST keeps
3
+ // the original relative src; only rendering resolves it.
4
+
5
+ let assetBase: string | null = null
6
+
7
+ export function setAssetBase(repo: string | null, branch: string | null, filePath: string | null) {
8
+ if (!repo || !branch || !filePath) {
9
+ assetBase = null
10
+ return
11
+ }
12
+ const dir = filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/") + 1) : ""
13
+ assetBase = `https://raw.githubusercontent.com/${repo}/${branch}/${dir}`
14
+ }
15
+
16
+ export function resolveAsset(src: string): string {
17
+ if (!src || /^(https?:|data:|blob:)/i.test(src)) return src
18
+ if (!assetBase) return src
19
+ return assetBase + src.replace(/^\.\//, "")
20
+ }
@@ -0,0 +1,311 @@
1
+ import { useState, type ReactNode } from "react"
2
+ import {
3
+ AlertTriangle,
4
+ CheckCircle2,
5
+ ChevronRight,
6
+ File,
7
+ Info,
8
+ XCircle,
9
+ } from "lucide-react"
10
+
11
+ import type { Block, DocumentNode, Inline } from "../gitbook/ast"
12
+ import { resolveAsset } from "../assets"
13
+ import { parseMarkdown } from "../gitbook/parse"
14
+ import { OpenApiOperation } from "../openapi/OpenApiOperation"
15
+ import { Mermaid } from "./Mermaid"
16
+ import { HighlightedCode } from "./HighlightedCode"
17
+
18
+ function InlineText({ nodes }: { nodes: Inline[] }) {
19
+ return (
20
+ <>
21
+ {nodes.map((n, i) => {
22
+ if (n.type === "image") {
23
+ const img = (
24
+ <img
25
+ src={resolveAsset(n.src)}
26
+ alt={n.alt ?? ""}
27
+ className="docs-inline-img"
28
+ style={{ width: n.width, height: n.height }}
29
+ />
30
+ )
31
+ return n.link ? (
32
+ <a key={i} href={n.link} target="_blank" rel="noreferrer" className="docs-inline-img-link">
33
+ {img}
34
+ </a>
35
+ ) : (
36
+ <span key={i}>{img}</span>
37
+ )
38
+ }
39
+ let el: ReactNode = n.text
40
+ if (n.code) el = <code>{el}</code>
41
+ if (n.bold) el = <strong>{el}</strong>
42
+ if (n.italic) el = <em>{el}</em>
43
+ if (n.strike) el = <s>{el}</s>
44
+ if (n.link)
45
+ el = (
46
+ <a href={n.link} target="_blank" rel="noreferrer">
47
+ {el}
48
+ </a>
49
+ )
50
+ return <span key={i}>{el}</span>
51
+ })}
52
+ </>
53
+ )
54
+ }
55
+
56
+ const HINT_ICONS = {
57
+ info: Info,
58
+ success: CheckCircle2,
59
+ warning: AlertTriangle,
60
+ danger: XCircle,
61
+ }
62
+
63
+ function embedSrc(url: string): string | null {
64
+ const yt = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
65
+ return yt ? `https://www.youtube.com/embed/${yt[1]}` : null
66
+ }
67
+
68
+ function Tabs({ block }: { block: Extract<Block, { type: "tabs" }> }) {
69
+ const [active, setActive] = useState(0)
70
+ return (
71
+ <div className="docs-tabs">
72
+ <div className="docs-tabs-header">
73
+ {block.tabs.map((t, i) => (
74
+ <button
75
+ key={i}
76
+ className={i === active ? "docs-tab-active" : ""}
77
+ onClick={() => setActive(i)}
78
+ >
79
+ {t.title}
80
+ </button>
81
+ ))}
82
+ </div>
83
+ <div className="docs-tabs-body">
84
+ <Blocks blocks={block.tabs[active]?.children ?? []} />
85
+ </div>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ function BlockView({ block }: { block: Block }) {
91
+ switch (block.type) {
92
+ case "paragraph":
93
+ return (
94
+ <p>
95
+ <InlineText nodes={block.children} />
96
+ </p>
97
+ )
98
+ case "heading": {
99
+ const Tag = `h${block.level}` as const
100
+ return (
101
+ <Tag>
102
+ <InlineText nodes={block.children} />
103
+ </Tag>
104
+ )
105
+ }
106
+ case "code":
107
+ if (block.language === "mermaid") return <Mermaid code={block.code} />
108
+ return (
109
+ <div className="docs-code">
110
+ {(block.title || block.language) && (
111
+ <div className="docs-code-header">
112
+ <span>{block.title}</span>
113
+ <span className="docs-code-lang">{block.language}</span>
114
+ </div>
115
+ )}
116
+ <pre className={block.lineNumbers ? "docs-code-numbered" : ""}>
117
+ <HighlightedCode
118
+ code={block.code}
119
+ language={block.language}
120
+ lineNumbers={block.lineNumbers}
121
+ />
122
+ </pre>
123
+ </div>
124
+ )
125
+ case "hint": {
126
+ const Icon = HINT_ICONS[block.style] ?? Info
127
+ return (
128
+ <div className={`docs-hint docs-hint-${block.style}`}>
129
+ <Icon className="docs-hint-icon" />
130
+ <div>
131
+ <Blocks blocks={block.children} />
132
+ </div>
133
+ </div>
134
+ )
135
+ }
136
+ case "tabs":
137
+ return <Tabs block={block} />
138
+ case "expandable":
139
+ return (
140
+ <details className="docs-expandable">
141
+ <summary>{block.summary}</summary>
142
+ <div className="docs-expandable-body">
143
+ <Blocks blocks={block.children} />
144
+ </div>
145
+ </details>
146
+ )
147
+ case "stepper":
148
+ return (
149
+ <div className="docs-stepper">
150
+ {block.steps.map((s, i) => (
151
+ <div key={i} className="docs-step">
152
+ <div className="docs-step-rail">
153
+ <div className="docs-step-badge">{i + 1}</div>
154
+ {i < block.steps.length - 1 && <div className="docs-step-line" />}
155
+ </div>
156
+ <div className="docs-step-main">
157
+ {s.title && <div className="docs-step-title">{s.title}</div>}
158
+ <Blocks blocks={s.children} />
159
+ </div>
160
+ </div>
161
+ ))}
162
+ </div>
163
+ )
164
+ case "embed": {
165
+ const src = embedSrc(block.url)
166
+ return src ? (
167
+ <iframe className="docs-embed" src={src} title={block.url} allowFullScreen />
168
+ ) : (
169
+ <a className="docs-content-ref" href={block.url} target="_blank" rel="noreferrer">
170
+ <File className="size-4" /> {block.url}
171
+ </a>
172
+ )
173
+ }
174
+ case "content-ref":
175
+ return (
176
+ <a className="docs-content-ref" href={block.url}>
177
+ <File className="size-4" />
178
+ <span>
179
+ <InlineText nodes={block.children} />
180
+ </span>
181
+ <ChevronRight className="size-4 ml-auto" />
182
+ </a>
183
+ )
184
+ case "columns":
185
+ return (
186
+ <div className="docs-columns">
187
+ {block.columns.map((c, i) => (
188
+ <div key={i} className="docs-column">
189
+ <Blocks blocks={c.children} />
190
+ </div>
191
+ ))}
192
+ </div>
193
+ )
194
+ case "figure":
195
+ return (
196
+ <figure className="docs-figure">
197
+ {block.src && <img src={resolveAsset(block.src)} alt={block.alt} />}
198
+ {block.caption && <figcaption>{block.caption}</figcaption>}
199
+ </figure>
200
+ )
201
+ case "list": {
202
+ const Tag = block.ordered ? "ol" : "ul"
203
+ return (
204
+ <Tag className={block.task ? "docs-tasklist" : undefined}>
205
+ {block.items.map((item, i) => (
206
+ <li key={i}>
207
+ {block.task && <input type="checkbox" checked={!!item.checked} readOnly />}
208
+ <Blocks blocks={item.children} inline />
209
+ </li>
210
+ ))}
211
+ </Tag>
212
+ )
213
+ }
214
+ case "blockquote":
215
+ return (
216
+ <blockquote>
217
+ <Blocks blocks={block.children} />
218
+ </blockquote>
219
+ )
220
+ case "divider":
221
+ return <hr />
222
+ case "table":
223
+ if (block.view === "cards") {
224
+ const cards = block.rows.length ? block.rows : [block.header]
225
+ return (
226
+ <div className="docs-cards">
227
+ {cards.map((row, i) => (
228
+ <div key={i} className="docs-card">
229
+ {row.map((cell, j) => (
230
+ <div key={j} className={j === 0 ? "docs-card-title" : "docs-card-body"}>
231
+ <InlineText nodes={cell} />
232
+ </div>
233
+ ))}
234
+ </div>
235
+ ))}
236
+ </div>
237
+ )
238
+ }
239
+ return (
240
+ <table className="docs-table">
241
+ <thead>
242
+ <tr>
243
+ {block.header.map((cell, i) => (
244
+ <th key={i}>
245
+ <InlineText nodes={cell} />
246
+ </th>
247
+ ))}
248
+ </tr>
249
+ </thead>
250
+ <tbody>
251
+ {block.rows.map((row, i) => (
252
+ <tr key={i}>
253
+ {row.map((cell, j) => (
254
+ <td key={j}>
255
+ <InlineText nodes={cell} />
256
+ </td>
257
+ ))}
258
+ </tr>
259
+ ))}
260
+ </tbody>
261
+ </table>
262
+ )
263
+ case "math":
264
+ return <pre className="docs-math">{block.formula}</pre>
265
+ case "updates":
266
+ return (
267
+ <div className="docs-updates">
268
+ {block.updates.map((u, i) => (
269
+ <div key={i} className="docs-update">
270
+ <div className="docs-update-date">{u.date}</div>
271
+ <div className="docs-update-body">
272
+ <Blocks blocks={u.children} />
273
+ </div>
274
+ </div>
275
+ ))}
276
+ </div>
277
+ )
278
+ case "openapi-operation":
279
+ return (
280
+ <OpenApiOperation
281
+ specUrl={block.specUrl}
282
+ path={block.path}
283
+ method={block.method || "get"}
284
+ />
285
+ )
286
+ }
287
+ }
288
+
289
+ function Blocks({ blocks, inline }: { blocks: Block[]; inline?: boolean }) {
290
+ return (
291
+ <div className={inline ? "docs-blocks-inline" : undefined} style={inline ? { display: "inline" } : undefined}>
292
+ {blocks.map((b, i) => (
293
+ <BlockView key={i} block={b} />
294
+ ))}
295
+ </div>
296
+ )
297
+ }
298
+
299
+ // Renders a markdown string inline — used for embedded markdown like
300
+ // OpenAPI descriptions, which can themselves contain GitBook blocks.
301
+ export function MarkdownContent({ markdown }: { markdown: string }) {
302
+ return <Blocks blocks={parseMarkdown(markdown).children} />
303
+ }
304
+
305
+ export function DocsRenderer({ doc }: { doc: DocumentNode }) {
306
+ return (
307
+ <article className="docs-article">
308
+ <Blocks blocks={doc.children} />
309
+ </article>
310
+ )
311
+ }
@@ -0,0 +1,71 @@
1
+ import { useMemo } from "react"
2
+ import { common, createLowlight } from "lowlight"
3
+ import { toHtml } from "hast-util-to-html"
4
+
5
+ const lowlight = createLowlight(common)
6
+
7
+ const LANGUAGE_ALIASES: Record<string, string> = {
8
+ js: "javascript",
9
+ jsx: "javascript",
10
+ sh: "bash",
11
+ shell: "bash",
12
+ ts: "typescript",
13
+ tsx: "typescript",
14
+ yml: "yaml",
15
+ }
16
+
17
+ function normalizeLanguage(language: string | null) {
18
+ if (!language) return null
19
+ const normalized = language.toLowerCase()
20
+ return LANGUAGE_ALIASES[normalized] ?? normalized
21
+ }
22
+
23
+ function highlightedHtml(code: string, language: string | null) {
24
+ const normalized = normalizeLanguage(language)
25
+ if (normalized && lowlight.registered(normalized)) {
26
+ try {
27
+ return toHtml(lowlight.highlight(normalized, code))
28
+ } catch {
29
+ /* fall through to plain */
30
+ }
31
+ }
32
+ return null
33
+ }
34
+
35
+ export function HighlightedCode({
36
+ code,
37
+ language,
38
+ lineNumbers = false,
39
+ }: {
40
+ code: string
41
+ language: string | null
42
+ lineNumbers?: boolean
43
+ }) {
44
+ const lines = useMemo(() => code.split("\n"), [code])
45
+ const html = useMemo(() => {
46
+ if (lineNumbers) {
47
+ return lines.map((line) => highlightedHtml(line || " ", language))
48
+ }
49
+ return highlightedHtml(code, language)
50
+ }, [code, language, lineNumbers, lines])
51
+
52
+ if (lineNumbers) {
53
+ return (
54
+ <code className="hljs">
55
+ {(html as Array<string | null>).map((lineHtml, index) => (
56
+ <span key={index} className="docs-code-line">
57
+ <span className="docs-code-lineno">{index + 1}</span>
58
+ {lineHtml === null ? (
59
+ <span>{lines[index]}</span>
60
+ ) : (
61
+ <span dangerouslySetInnerHTML={{ __html: lineHtml }} />
62
+ )}
63
+ </span>
64
+ ))}
65
+ </code>
66
+ )
67
+ }
68
+
69
+ if (html === null) return <code>{code}</code>
70
+ return <code className="hljs" dangerouslySetInnerHTML={{ __html: html as string }} />
71
+ }
@@ -0,0 +1,37 @@
1
+ import { useEffect, useRef, useState } from "react"
2
+
3
+ let seq = 0
4
+
5
+ export function Mermaid({ code }: { code: string }) {
6
+ const [svg, setSvg] = useState<string | null>(null)
7
+ const [error, setError] = useState<string | null>(null)
8
+ const idRef = useRef(`mermaid-${++seq}`)
9
+
10
+ useEffect(() => {
11
+ let cancelled = false
12
+ import("mermaid")
13
+ .then(async ({ default: mermaid }) => {
14
+ mermaid.initialize({ startOnLoad: false, securityLevel: "strict" })
15
+ const { svg } = await mermaid.render(idRef.current, code)
16
+ if (!cancelled) setSvg(svg)
17
+ })
18
+ .catch((e) => {
19
+ if (!cancelled) setError(String(e.message ?? e))
20
+ })
21
+ return () => {
22
+ cancelled = true
23
+ }
24
+ }, [code])
25
+
26
+ if (error) {
27
+ return (
28
+ <pre className="docs-mermaid-error">
29
+ mermaid error: {error}
30
+ {"\n"}
31
+ {code}
32
+ </pre>
33
+ )
34
+ }
35
+ if (!svg) return <div className="docs-mermaid">Rendering diagram…</div>
36
+ return <div className="docs-mermaid" dangerouslySetInnerHTML={{ __html: svg }} />
37
+ }
@@ -0,0 +1,194 @@
1
+ // AST for GitBook-flavored markdown. This is the single source of truth that
2
+ // the parser, serializer, TipTap converters, and docs renderer all share.
3
+
4
+ export type HintStyle = "info" | "success" | "warning" | "danger"
5
+
6
+ export interface TextNode {
7
+ type: "text"
8
+ text: string
9
+ bold?: boolean
10
+ italic?: boolean
11
+ strike?: boolean
12
+ code?: boolean
13
+ link?: string
14
+ }
15
+
16
+ /** Inline HTML image, optionally wrapped in a link — GitHub README badge style. */
17
+ export interface InlineImageNode {
18
+ type: "image"
19
+ src: string
20
+ alt?: string
21
+ width?: string
22
+ height?: string
23
+ link?: string
24
+ }
25
+
26
+ export type Inline = TextNode | InlineImageNode
27
+
28
+ export interface ParagraphNode {
29
+ type: "paragraph"
30
+ children: Inline[]
31
+ }
32
+
33
+ export interface HeadingNode {
34
+ type: "heading"
35
+ level: 1 | 2 | 3 | 4 | 5 | 6
36
+ children: Inline[]
37
+ }
38
+
39
+ export interface CodeBlockNode {
40
+ type: "code"
41
+ language: string | null
42
+ title: string | null
43
+ lineNumbers: boolean
44
+ code: string
45
+ }
46
+
47
+ export interface HintNode {
48
+ type: "hint"
49
+ style: HintStyle
50
+ children: Block[]
51
+ }
52
+
53
+ export interface TabNode {
54
+ type: "tab"
55
+ title: string
56
+ children: Block[]
57
+ }
58
+
59
+ export interface TabsNode {
60
+ type: "tabs"
61
+ tabs: TabNode[]
62
+ }
63
+
64
+ export interface ExpandableNode {
65
+ type: "expandable"
66
+ summary: string
67
+ children: Block[]
68
+ }
69
+
70
+ export interface StepNode {
71
+ type: "step"
72
+ title: string
73
+ children: Block[]
74
+ }
75
+
76
+ export interface StepperNode {
77
+ type: "stepper"
78
+ steps: StepNode[]
79
+ }
80
+
81
+ export interface EmbedNode {
82
+ type: "embed"
83
+ url: string
84
+ }
85
+
86
+ export interface ContentRefNode {
87
+ type: "content-ref"
88
+ url: string
89
+ children: Inline[]
90
+ }
91
+
92
+ export interface ColumnNode {
93
+ type: "column"
94
+ children: Block[]
95
+ }
96
+
97
+ export interface ColumnsNode {
98
+ type: "columns"
99
+ columns: ColumnNode[]
100
+ }
101
+
102
+ export interface FigureNode {
103
+ type: "figure"
104
+ src: string
105
+ alt: string
106
+ caption: string
107
+ }
108
+
109
+ export interface ListItemNode {
110
+ type: "listItem"
111
+ children: Block[]
112
+ checked?: boolean // present only in task lists
113
+ }
114
+
115
+ export interface ListNode {
116
+ type: "list"
117
+ ordered: boolean
118
+ task: boolean
119
+ items: ListItemNode[]
120
+ }
121
+
122
+ export interface BlockquoteNode {
123
+ type: "blockquote"
124
+ children: Block[]
125
+ }
126
+
127
+ export interface DividerNode {
128
+ type: "divider"
129
+ }
130
+
131
+ export interface TableNode {
132
+ type: "table"
133
+ header: Inline[][]
134
+ rows: Inline[][][]
135
+ /** GitBook table view, e.g. "cards" for <table data-view="cards"> */
136
+ view?: string
137
+ }
138
+
139
+ export interface UpdateNode {
140
+ type: "update"
141
+ date: string
142
+ children: Block[]
143
+ }
144
+
145
+ export interface UpdatesNode {
146
+ type: "updates"
147
+ format: string | null
148
+ updates: UpdateNode[]
149
+ }
150
+
151
+ export interface OpenApiOperationNode {
152
+ type: "openapi-operation"
153
+ spec: string
154
+ path: string
155
+ method: string
156
+ specUrl: string
157
+ label: string
158
+ }
159
+
160
+ export interface MathNode {
161
+ type: "math"
162
+ formula: string
163
+ }
164
+
165
+ export type Block =
166
+ | ParagraphNode
167
+ | HeadingNode
168
+ | CodeBlockNode
169
+ | HintNode
170
+ | TabsNode
171
+ | ExpandableNode
172
+ | StepperNode
173
+ | EmbedNode
174
+ | ContentRefNode
175
+ | ColumnsNode
176
+ | FigureNode
177
+ | ListNode
178
+ | BlockquoteNode
179
+ | DividerNode
180
+ | TableNode
181
+ | MathNode
182
+ | UpdatesNode
183
+ | OpenApiOperationNode
184
+
185
+ export interface DocumentNode {
186
+ type: "doc"
187
+ children: Block[]
188
+ }
189
+
190
+ export const text = (t: string, marks: Partial<Omit<TextNode, "type" | "text">> = {}): TextNode => ({
191
+ type: "text",
192
+ text: t,
193
+ ...marks,
194
+ })