@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,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
+ // ![alt](src) — 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
+ }