@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 +32 -0
- package/src/assets.ts +20 -0
- package/src/docs/DocsRenderer.tsx +311 -0
- package/src/docs/HighlightedCode.tsx +71 -0
- package/src/docs/Mermaid.tsx +37 -0
- package/src/gitbook/ast.ts +194 -0
- package/src/gitbook/inline.ts +202 -0
- package/src/gitbook/parse.ts +594 -0
- package/src/gitbook/serialize.ts +151 -0
- package/src/index.ts +11 -0
- package/src/openapi/OpenApiOperation.tsx +238 -0
- package/src/streamdown.tsx +33 -0
- package/src/styles.css +727 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { Inline, InlineImageNode, TextNode } from "./ast"
|
|
2
|
+
|
|
3
|
+
type Marks = Partial<Omit<TextNode, "type" | "text">>
|
|
4
|
+
|
|
5
|
+
// Reference-style link definitions ([ref]: url), populated by parseMarkdown
|
|
6
|
+
// and consumed here for [text][ref] / [text][] forms.
|
|
7
|
+
export const refDefinitions = new Map<string, string>()
|
|
8
|
+
|
|
9
|
+
function imgAttrs(attrStr: string): Omit<InlineImageNode, "type" | "link"> {
|
|
10
|
+
const attr = (name: string) => attrStr.match(new RegExp(`${name}="([^"]*)"`, "i"))?.[1]
|
|
11
|
+
const out: Omit<InlineImageNode, "type" | "link"> = { src: attr("src") ?? "" }
|
|
12
|
+
const alt = attr("alt")
|
|
13
|
+
const width = attr("width")
|
|
14
|
+
const height = attr("height")
|
|
15
|
+
if (alt) out.alt = alt
|
|
16
|
+
if (width) out.width = width
|
|
17
|
+
if (height) out.height = height
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Parses GitBook/GFM inline markdown into flat TextNodes with marks.
|
|
22
|
+
// Supported: **bold**, _italic_ / *italic*, ~~strike~~, `code`, [text](url),
|
|
23
|
+
// plus GitHub-style inline HTML: <img …>, <a href><img …></a>, <a href>text</a>.
|
|
24
|
+
export function parseInline(src: string, marks: Marks = {}): Inline[] {
|
|
25
|
+
const out: Inline[] = []
|
|
26
|
+
let buf = ""
|
|
27
|
+
|
|
28
|
+
const flush = () => {
|
|
29
|
+
if (buf) {
|
|
30
|
+
out.push({ type: "text", text: buf, ...marks })
|
|
31
|
+
buf = ""
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let i = 0
|
|
36
|
+
while (i < src.length) {
|
|
37
|
+
const rest = src.slice(i)
|
|
38
|
+
|
|
39
|
+
if (rest.startsWith("\\") && rest.length > 1) {
|
|
40
|
+
buf += rest[1]
|
|
41
|
+
i += 2
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// inline code: no nested marks inside
|
|
46
|
+
if (rest[0] === "`") {
|
|
47
|
+
const end = src.indexOf("`", i + 1)
|
|
48
|
+
if (end !== -1) {
|
|
49
|
+
flush()
|
|
50
|
+
out.push({ type: "text", text: src.slice(i + 1, end), ...marks, code: true })
|
|
51
|
+
i = end + 1
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// <a href="…"><img …></a> — linked image (GitHub badge style)
|
|
57
|
+
const linkedImg = rest.match(/^<a\s[^>]*href="([^"]*)"[^>]*>\s*<img\s([^>]*?)\/?>\s*<\/a>/i)
|
|
58
|
+
if (linkedImg) {
|
|
59
|
+
flush()
|
|
60
|
+
out.push({ type: "image", ...imgAttrs(linkedImg[2]), link: linkedImg[1] })
|
|
61
|
+
i += linkedImg[0].length
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// <img …> — bare inline image
|
|
66
|
+
const htmlImg = rest.match(/^<img\s([^>]*?)\/?>/i)
|
|
67
|
+
if (htmlImg) {
|
|
68
|
+
flush()
|
|
69
|
+
out.push({ type: "image", ...imgAttrs(htmlImg[1]) })
|
|
70
|
+
i += htmlImg[0].length
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// <a href="…">text</a> — html link
|
|
75
|
+
const htmlLink = rest.match(/^<a\s[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/i)
|
|
76
|
+
if (htmlLink) {
|
|
77
|
+
flush()
|
|
78
|
+
out.push(...parseInline(htmlLink[2], { ...marks, link: htmlLink[1] }))
|
|
79
|
+
i += htmlLink[0].length
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
//  — markdown inline image
|
|
84
|
+
const mdImg = rest.match(/^!\[([^\]]*)\]\(([^)\s]+)\)/)
|
|
85
|
+
if (mdImg) {
|
|
86
|
+
flush()
|
|
87
|
+
out.push({ type: "image", src: mdImg[2], ...(mdImg[1] ? { alt: mdImg[1] } : {}) })
|
|
88
|
+
i += mdImg[0].length
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const delims: Array<[string, Marks]> = [
|
|
93
|
+
["**", { bold: true }],
|
|
94
|
+
["~~", { strike: true }],
|
|
95
|
+
["*", { italic: true }],
|
|
96
|
+
["_", { italic: true }],
|
|
97
|
+
]
|
|
98
|
+
let matched = false
|
|
99
|
+
for (const [d, mark] of delims) {
|
|
100
|
+
if (rest.startsWith(d)) {
|
|
101
|
+
const end = src.indexOf(d, i + d.length)
|
|
102
|
+
if (end > i + d.length - 1 && end !== -1 && src.slice(i + d.length, end).length > 0) {
|
|
103
|
+
flush()
|
|
104
|
+
out.push(...parseInline(src.slice(i + d.length, end), { ...marks, ...mark }))
|
|
105
|
+
i = end + d.length
|
|
106
|
+
matched = true
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (matched) continue
|
|
112
|
+
|
|
113
|
+
// [text](url)
|
|
114
|
+
if (rest[0] === "[") {
|
|
115
|
+
const m = rest.match(/^\[([^\]]*)\]\(([^)\s]+)\)/)
|
|
116
|
+
if (m) {
|
|
117
|
+
flush()
|
|
118
|
+
out.push(...parseInline(m[1], { ...marks, link: m[2] }))
|
|
119
|
+
i += m[0].length
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
// [text][ref] / [text][] — reference-style links
|
|
123
|
+
const ref = rest.match(/^\[([^\]]*)\]\[([^\]]*)\]/)
|
|
124
|
+
if (ref) {
|
|
125
|
+
const key = (ref[2] || ref[1]).toLowerCase()
|
|
126
|
+
const url = refDefinitions.get(key)
|
|
127
|
+
if (url) {
|
|
128
|
+
flush()
|
|
129
|
+
out.push(...parseInline(ref[1], { ...marks, link: url }))
|
|
130
|
+
i += ref[0].length
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// <https://…> — angle-bracket autolink
|
|
137
|
+
const angleLink = rest.match(/^<(https?:\/\/[^>\s]+)>/i)
|
|
138
|
+
if (angleLink) {
|
|
139
|
+
flush()
|
|
140
|
+
out.push({ type: "text", text: angleLink[1], ...marks, link: angleLink[1] })
|
|
141
|
+
i += angleLink[0].length
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// bare URL autolink (GFM) — at start or after whitespace
|
|
146
|
+
if (
|
|
147
|
+
/^https?:\/\//i.test(rest) &&
|
|
148
|
+
(buf === "" || /\s$/.test(buf))
|
|
149
|
+
) {
|
|
150
|
+
const m = rest.match(/^https?:\/\/[^\s<>"')\]]+/i)!
|
|
151
|
+
const url = m[0].replace(/[.,;:!?]+$/, "")
|
|
152
|
+
flush()
|
|
153
|
+
out.push({ type: "text", text: url, ...marks, link: url })
|
|
154
|
+
i += url.length
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
buf += src[i]
|
|
159
|
+
i++
|
|
160
|
+
}
|
|
161
|
+
flush()
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const escapeText = (t: string) => t.replace(/([*_~`[\]\\])/g, "\\$1")
|
|
166
|
+
|
|
167
|
+
function serializeImage(n: InlineImageNode): string {
|
|
168
|
+
const attrs = [
|
|
169
|
+
`src="${n.src}"`,
|
|
170
|
+
n.alt ? `alt="${n.alt}"` : "",
|
|
171
|
+
n.width ? `width="${n.width}"` : "",
|
|
172
|
+
n.height ? `height="${n.height}"` : "",
|
|
173
|
+
]
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.join(" ")
|
|
176
|
+
const img = `<img ${attrs} />`
|
|
177
|
+
return n.link ? `<a href="${n.link}">${img}</a>` : img
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Serializes inline nodes back to markdown.
|
|
181
|
+
export function serializeInline(nodes: Inline[]): string {
|
|
182
|
+
return nodes
|
|
183
|
+
.map((n) => {
|
|
184
|
+
if (n.type === "image") return serializeImage(n)
|
|
185
|
+
// bare autolink: text identical to the URL, no other marks
|
|
186
|
+
if (n.link && n.text === n.link && !n.bold && !n.italic && !n.strike && !n.code) {
|
|
187
|
+
return n.link
|
|
188
|
+
}
|
|
189
|
+
let s = n.code ? n.text : escapeText(n.text)
|
|
190
|
+
if (n.code) s = `\`${s}\``
|
|
191
|
+
if (n.bold) s = `**${s}**`
|
|
192
|
+
if (n.italic) s = `_${s}_`
|
|
193
|
+
if (n.strike) s = `~~${s}~~`
|
|
194
|
+
if (n.link) s = `[${s}](${n.link})`
|
|
195
|
+
return s
|
|
196
|
+
})
|
|
197
|
+
.join("")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function plainText(nodes: Inline[]): string {
|
|
201
|
+
return nodes.map((n) => (n.type === "text" ? n.text : (n.alt ?? ""))).join("")
|
|
202
|
+
}
|