@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.
@@ -0,0 +1,151 @@
1
+ import type { Block, DocumentNode, Inline, ListItemNode } from "./ast"
2
+ import { serializeInline } from "./inline"
3
+
4
+ export function serializeMarkdown(doc: DocumentNode): string {
5
+ return serializeBlocks(doc.children).trimEnd() + "\n"
6
+ }
7
+
8
+ export function serializeBlocks(blocks: Block[]): string {
9
+ return blocks.map(serializeBlock).join("\n\n")
10
+ }
11
+
12
+ function indent(s: string, pad: string): string {
13
+ return s
14
+ .split("\n")
15
+ .map((l) => (l ? pad + l : l))
16
+ .join("\n")
17
+ }
18
+
19
+ function serializeBlock(b: Block): string {
20
+ switch (b.type) {
21
+ case "paragraph":
22
+ return serializeInline(b.children)
23
+
24
+ case "heading": {
25
+ const inline = serializeInline(b.children)
26
+ return inline ? `${"#".repeat(b.level)} ${inline}` : "#".repeat(b.level)
27
+ }
28
+
29
+ case "code": {
30
+ const fence = "```" + (b.language ?? "")
31
+ const body = `${fence}\n${b.code}\n\`\`\``
32
+ if (b.title || b.lineNumbers) {
33
+ const attrs = [
34
+ b.title ? ` title="${b.title}"` : "",
35
+ b.lineNumbers ? ` lineNumbers="true"` : "",
36
+ ].join("")
37
+ return `{% code${attrs} %}\n${body}\n{% endcode %}`
38
+ }
39
+ return body
40
+ }
41
+
42
+ case "hint":
43
+ return `{% hint style="${b.style}" %}\n${serializeBlocks(b.children)}\n{% endhint %}`
44
+
45
+ case "tabs":
46
+ return `{% tabs %}\n${b.tabs
47
+ .map((t) => `{% tab title="${t.title}" %}\n${serializeBlocks(t.children)}\n{% endtab %}`)
48
+ .join("\n\n")}\n{% endtabs %}`
49
+
50
+ case "expandable":
51
+ return `<details>\n\n<summary>${b.summary}</summary>\n\n${serializeBlocks(b.children)}\n\n</details>`
52
+
53
+ case "stepper":
54
+ return `{% stepper %}\n${b.steps
55
+ .map((s) => {
56
+ const title = s.title ? `### ${s.title}\n\n` : ""
57
+ return `{% step %}\n${title}${serializeBlocks(s.children)}\n{% endstep %}`
58
+ })
59
+ .join("\n\n")}\n{% endstepper %}`
60
+
61
+ case "embed":
62
+ return `{% embed url="${b.url}" %}`
63
+
64
+ case "content-ref":
65
+ return `{% content-ref url="${b.url}" %}\n${serializeInline(b.children)}\n{% endcontent-ref %}`
66
+
67
+ case "columns":
68
+ return `{% columns %}\n${b.columns
69
+ .map((c) => `{% column %}\n${serializeBlocks(c.children)}\n{% endcolumn %}`)
70
+ .join("\n\n")}\n{% endcolumns %}`
71
+
72
+ case "figure": {
73
+ const alt = b.alt ? ` alt="${b.alt}"` : ' alt=""'
74
+ const cap = b.caption ? `<figcaption><p>${b.caption}</p></figcaption>` : "<figcaption></figcaption>"
75
+ return `<figure><img src="${b.src}"${alt}>${cap}</figure>`
76
+ }
77
+
78
+ case "list":
79
+ return b.items.map((item, idx) => serializeListItem(item, b.ordered, b.task, idx)).join("\n")
80
+
81
+ case "blockquote":
82
+ return serializeBlocks(b.children)
83
+ .split("\n")
84
+ .map((l) => (l ? `> ${l}` : ">"))
85
+ .join("\n")
86
+
87
+ case "divider":
88
+ return "---"
89
+
90
+ case "table": {
91
+ if (b.view) {
92
+ const cellHtml = (c: Inline[]) => inlineToHtml(c)
93
+ const head = `<thead><tr>${b.header.map((c) => `<th>${cellHtml(c)}</th>`).join("")}</tr></thead>`
94
+ const body = `<tbody>${b.rows
95
+ .map((r) => `<tr>${r.map((c) => `<td>${cellHtml(c)}</td>`).join("")}</tr>`)
96
+ .join("")}</tbody>`
97
+ return `<table data-view="${b.view}">${head}${body}</table>`
98
+ }
99
+ const row = (cells: Inline[][]) => `| ${cells.map((c) => serializeInline(c)).join(" | ")} |`
100
+ const sep = `| ${b.header.map(() => "---").join(" | ")} |`
101
+ return [row(b.header), sep, ...b.rows.map(row)].join("\n")
102
+ }
103
+
104
+ case "math":
105
+ return `$$\n${b.formula}\n$$`
106
+
107
+ case "updates":
108
+ return `{% updates${b.format ? ` format="${b.format}"` : ""} %}\n${b.updates
109
+ .map(
110
+ (u) =>
111
+ `{% update date="${u.date}" %}\n${serializeBlocks(u.children)}\n{% endupdate %}`
112
+ )
113
+ .join("\n\n")}\n{% endupdates %}`
114
+
115
+ case "openapi-operation": {
116
+ const attrs = [
117
+ b.spec ? ` spec="${b.spec}"` : "",
118
+ b.path ? ` path="${b.path}"` : "",
119
+ b.method ? ` method="${b.method}"` : "",
120
+ ].join("")
121
+ const inner = b.specUrl ? `\n[${b.label || b.spec || "OpenAPI"}](${b.specUrl})` : ""
122
+ return `{% openapi-operation${attrs} %}${inner}\n{% endopenapi-operation %}`
123
+ }
124
+ }
125
+ }
126
+
127
+ function inlineToHtml(nodes: Inline[]): string {
128
+ return nodes
129
+ .map((n) => {
130
+ if (n.type === "image") {
131
+ const img = `<img src="${n.src}"${n.alt ? ` alt="${n.alt}"` : ""}>`
132
+ return n.link ? `<a href="${n.link}">${img}</a>` : img
133
+ }
134
+ let s = n.text
135
+ if (n.code) s = `<code>${s}</code>`
136
+ if (n.bold) s = `<strong>${s}</strong>`
137
+ if (n.italic) s = `<em>${s}</em>`
138
+ if (n.link) s = `<a href="${n.link}">${s}</a>`
139
+ return s
140
+ })
141
+ .join("")
142
+ }
143
+
144
+ function serializeListItem(item: ListItemNode, ordered: boolean, task: boolean, idx: number): string {
145
+ const bullet = ordered ? `${idx + 1}.` : "-"
146
+ const check = task ? `[${item.checked ? "x" : " "}] ` : ""
147
+ const [first, ...rest] = item.children
148
+ const firstText = first?.type === "paragraph" ? serializeInline(first.children) : first ? serializeBlock(first) : ""
149
+ const restText = rest.length ? "\n" + indent(serializeBlocks(rest), " ") : ""
150
+ return `${bullet} ${check}${firstText}${restText}`
151
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { GitbookStreamdown } from "./streamdown"
2
+ export type { GitbookStreamdownProps } from "./streamdown"
3
+ export { DocsRenderer, MarkdownContent } from "./docs/DocsRenderer"
4
+ export { OpenApiOperation } from "./openapi/OpenApiOperation"
5
+ export { resolveAsset, setAssetBase } from "./assets"
6
+ export { parseMarkdown, parseBlocks } from "./gitbook/parse"
7
+ export { serializeBlocks, serializeMarkdown } from "./gitbook/serialize"
8
+ export { parseInline, plainText, refDefinitions, serializeInline } from "./gitbook/inline"
9
+ export { Streamdown } from "streamdown"
10
+ export type { StreamdownProps } from "streamdown"
11
+ export type * from "./gitbook/ast"
@@ -0,0 +1,238 @@
1
+ import { useEffect, useState } from "react"
2
+ import { parse as parseYaml } from "yaml"
3
+
4
+ import { MarkdownContent } from "../docs/DocsRenderer"
5
+ import { HighlightedCode } from "../docs/HighlightedCode"
6
+
7
+ /* eslint-disable @typescript-eslint/no-explicit-any */
8
+
9
+ const specCache = new Map<string, Promise<any>>()
10
+
11
+ function loadSpec(url: string): Promise<any> {
12
+ if (!specCache.has(url)) {
13
+ specCache.set(
14
+ url,
15
+ fetch(url)
16
+ .then((r) => {
17
+ if (!r.ok) throw new Error(`Failed to load spec (${r.status})`)
18
+ return r.text()
19
+ })
20
+ .then((text) => (text.trimStart().startsWith("{") ? JSON.parse(text) : parseYaml(text)))
21
+ )
22
+ }
23
+ return specCache.get(url)!
24
+ }
25
+
26
+ function resolveRef(spec: any, node: any): any {
27
+ if (node && typeof node === "object" && typeof node.$ref === "string") {
28
+ const parts = node.$ref.replace(/^#\//, "").split("/")
29
+ let cur = spec
30
+ for (const p of parts) cur = cur?.[p]
31
+ return resolveRef(spec, cur)
32
+ }
33
+ return node
34
+ }
35
+
36
+ function exampleFromSchema(spec: any, schema: any, depth = 0): any {
37
+ schema = resolveRef(spec, schema)
38
+ if (!schema || depth > 4) return null
39
+ if (schema.example !== undefined) return schema.example
40
+ if (schema.type === "array") return [exampleFromSchema(spec, schema.items, depth + 1)]
41
+ if (schema.type === "object" || schema.properties) {
42
+ const obj: Record<string, any> = {}
43
+ for (const [k, v] of Object.entries<any>(schema.properties ?? {})) {
44
+ obj[k] = exampleFromSchema(spec, v, depth + 1)
45
+ }
46
+ return obj
47
+ }
48
+ if (schema.enum?.length) return schema.enum[0]
49
+ switch (schema.type) {
50
+ case "integer":
51
+ case "number":
52
+ return 1
53
+ case "boolean":
54
+ return true
55
+ default:
56
+ return "string"
57
+ }
58
+ }
59
+
60
+ function SchemaFields({ spec, schema }: { spec: any; schema: any }) {
61
+ schema = resolveRef(spec, schema)
62
+ if (schema?.type === "array") schema = resolveRef(spec, schema.items)
63
+ const props = Object.entries<any>(schema?.properties ?? {})
64
+ if (!props.length) return null
65
+ const required: string[] = schema.required ?? []
66
+ return (
67
+ <div className="oas-fields">
68
+ {props.map(([name, raw]) => {
69
+ const p = resolveRef(spec, raw)
70
+ return (
71
+ <div key={name} className="oas-field">
72
+ <div>
73
+ <code className="oas-field-name">{name}</code>{" "}
74
+ <span className="oas-field-type">
75
+ {p.type ?? "object"}
76
+ {p.format ? ` · ${p.format}` : ""}
77
+ </span>{" "}
78
+ <span className={required.includes(name) ? "oas-required" : "oas-optional"}>
79
+ {required.includes(name) ? "required" : "optional"}
80
+ </span>
81
+ </div>
82
+ {p.description && <p className="oas-field-desc">{p.description}</p>}
83
+ </div>
84
+ )
85
+ })}
86
+ </div>
87
+ )
88
+ }
89
+
90
+ export function OpenApiOperation({
91
+ specUrl,
92
+ path,
93
+ method,
94
+ }: {
95
+ specUrl: string
96
+ path: string
97
+ method: string
98
+ }) {
99
+ const [spec, setSpec] = useState<any>(null)
100
+ const [error, setError] = useState<string | null>(null)
101
+
102
+ useEffect(() => {
103
+ if (!specUrl) return
104
+ setSpec(null)
105
+ setError(null)
106
+ loadSpec(specUrl)
107
+ .then(setSpec)
108
+ .catch((e) => setError(String(e.message ?? e)))
109
+ }, [specUrl])
110
+
111
+ if (!specUrl) return <div className="oas oas-empty">Set an OpenAPI spec URL…</div>
112
+ if (error) return <div className="oas oas-empty">⚠︎ {error}</div>
113
+ if (!spec) return <div className="oas oas-empty">Loading OpenAPI spec…</div>
114
+
115
+ const op = spec.paths?.[path]?.[method.toLowerCase()]
116
+ if (!op) {
117
+ return (
118
+ <div className="oas oas-empty">
119
+ Operation {method.toUpperCase()} {path} not found in spec.
120
+ </div>
121
+ )
122
+ }
123
+
124
+ const server = spec.servers?.[0]?.url ?? ""
125
+ const security: any[] = op.security ?? spec.security ?? []
126
+ const schemes = Object.entries<any>(spec.components?.securitySchemes ?? {}).filter(([name]) =>
127
+ security.some((s) => name in s)
128
+ )
129
+ const responses = Object.entries<any>(op.responses ?? {})
130
+ const okResponse = responses.find(([code]) => code.startsWith("2"))?.[1]
131
+ const okSchema = okResponse?.content?.["application/json"]?.schema
132
+ const example = okSchema ? exampleFromSchema(spec, okSchema) : null
133
+
134
+ const curl = [
135
+ `curl -L ${server}${path} \\`,
136
+ ...(schemes.length ? [` -H 'Authorization: Bearer YOUR_API_KEY'`] : []),
137
+ ].join("\n")
138
+
139
+ return (
140
+ <div className="oas">
141
+ <h3 className="oas-title">{op.summary ?? `${method.toUpperCase()} ${path}`}</h3>
142
+ <div className="oas-endpoint">
143
+ <span className={`oas-method oas-method-${method.toLowerCase()}`}>
144
+ {method.toUpperCase()}
145
+ </span>
146
+ {server && <span className="oas-server">{server}</span>}
147
+ <code className="oas-path">{path}</code>
148
+ </div>
149
+ {op.description && (
150
+ <div className="oas-desc">
151
+ <MarkdownContent markdown={op.description} />
152
+ </div>
153
+ )}
154
+
155
+ {schemes.length > 0 && (
156
+ <section className="oas-section">
157
+ <h4>Authorizations</h4>
158
+ {schemes.map(([name, s]) => (
159
+ <div key={name} className="oas-field">
160
+ <div>
161
+ <code className="oas-field-name">{s.name ?? "Authorization"}</code>{" "}
162
+ <span className="oas-field-type">{s.type}</span>{" "}
163
+ <span className="oas-required">required</span>
164
+ </div>
165
+ {s.description && <p className="oas-field-desc">{s.description}</p>}
166
+ </div>
167
+ ))}
168
+ </section>
169
+ )}
170
+
171
+ {(op.parameters?.length ?? 0) > 0 && (
172
+ <section className="oas-section">
173
+ <h4>Parameters</h4>
174
+ {op.parameters.map((raw: any, i: number) => {
175
+ const p = resolveRef(spec, raw)
176
+ return (
177
+ <div key={i} className="oas-field">
178
+ <div>
179
+ <code className="oas-field-name">{p.name}</code>{" "}
180
+ <span className="oas-field-type">
181
+ {p.schema?.type ?? "string"} · {p.in}
182
+ </span>{" "}
183
+ <span className={p.required ? "oas-required" : "oas-optional"}>
184
+ {p.required ? "required" : "optional"}
185
+ </span>
186
+ </div>
187
+ {p.description && <p className="oas-field-desc">{p.description}</p>}
188
+ </div>
189
+ )
190
+ })}
191
+ </section>
192
+ )}
193
+
194
+ <section className="oas-section">
195
+ <h4>Responses</h4>
196
+ {responses.map(([code, raw]) => {
197
+ const r = resolveRef(spec, raw)
198
+ const schema = r.content?.["application/json"]?.schema
199
+ return (
200
+ <details key={code} className="oas-response" open={code.startsWith("2")}>
201
+ <summary>
202
+ <span className={`oas-code oas-code-${code[0]}`}>{code}</span>
203
+ <span>{r.description}</span>
204
+ </summary>
205
+ {schema && <SchemaFields spec={spec} schema={schema} />}
206
+ </details>
207
+ )
208
+ })}
209
+ </section>
210
+
211
+ <section className="oas-section oas-samples">
212
+ <div className="oas-sample">
213
+ <div className="oas-sample-head">
214
+ <span className={`oas-method oas-method-${method.toLowerCase()}`}>
215
+ {method.toUpperCase()}
216
+ </span>
217
+ <code>{path}</code>
218
+ <span className="oas-sample-label">cURL</span>
219
+ </div>
220
+ <pre>
221
+ <HighlightedCode code={curl} language="bash" />
222
+ </pre>
223
+ </div>
224
+ {example !== null && (
225
+ <div className="oas-sample">
226
+ <div className="oas-sample-head">
227
+ <span className="oas-code oas-code-2">200</span>
228
+ <span>{okResponse?.description}</span>
229
+ </div>
230
+ <pre>
231
+ <HighlightedCode code={JSON.stringify(example, null, 2)} language="json" />
232
+ </pre>
233
+ </div>
234
+ )}
235
+ </section>
236
+ </div>
237
+ )
238
+ }
@@ -0,0 +1,33 @@
1
+ import { useMemo } from "react"
2
+ import { DocsRenderer } from "./docs/DocsRenderer"
3
+ import { parseMarkdown } from "./gitbook/parse"
4
+
5
+ export interface GitbookStreamdownProps {
6
+ children?: string
7
+ className?: string
8
+ isAnimating?: boolean
9
+ isStreaming?: boolean
10
+ markdown?: string
11
+ }
12
+
13
+ export function GitbookStreamdown({
14
+ children,
15
+ className,
16
+ isAnimating,
17
+ isStreaming = false,
18
+ markdown,
19
+ }: GitbookStreamdownProps) {
20
+ const content = markdown ?? children ?? ""
21
+ const gitbookDoc = useMemo(() => parseMarkdown(content), [content])
22
+
23
+ return (
24
+ <div
25
+ aria-busy={isAnimating ?? isStreaming}
26
+ className={className}
27
+ data-docstream=""
28
+ data-streaming={isStreaming ? "" : undefined}
29
+ >
30
+ <DocsRenderer doc={gitbookDoc} />
31
+ </div>
32
+ )
33
+ }