@tabnas/markdown 0.4.1 → 0.5.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/src/ast.ts ADDED
@@ -0,0 +1,352 @@
1
+ /* Copyright (c) 2021-2026 Richard Rodger, MIT License */
2
+
3
+ // Projection from the native CommonMark tree (node.ts) to the mdast-adjacent
4
+ // JSON AST that `@tabnas/markdown` has always returned.
5
+ //
6
+ // The native tree is what the HTML renderer walks, and it carries things the
7
+ // public AST has no room for (source positions, the block/inline distinction
8
+ // for raw HTML, container open/closed state). This module is the one place
9
+ // that decides what survives the crossing. Two deliberate choices:
10
+ //
11
+ // * Soft breaks collapse into a single space inside the surrounding text
12
+ // run, which is the long-standing documented behaviour of this package
13
+ // (`breaks:true` promotes them to `break` nodes instead). The native tree
14
+ // keeps them as real `softbreak` nodes, so an HTML render is unaffected.
15
+ // * `spread` now follows mdast semantics — a list is spread when it is
16
+ // loose, an item when it holds blank-line-separated blocks. The previous
17
+ // implementation latched this off the file's trailing newline, so the
18
+ // field carried no information; nothing can have depended on it.
19
+
20
+ import { MdNode } from './node.ts'
21
+ import type { ParserOptions } from './options.ts'
22
+
23
+ export type DocumentNode = { type: 'document'; children: Block[] }
24
+
25
+ export type Block =
26
+ | HeadingNode
27
+ | ParagraphNode
28
+ | BlockquoteNode
29
+ | ListNode
30
+ | ListItemNode
31
+ | CodeNode
32
+ | HtmlNode
33
+ | ThematicBreakNode
34
+
35
+ export type HeadingNode = {
36
+ type: 'heading'
37
+ depth: 1 | 2 | 3 | 4 | 5 | 6
38
+ children: Inline[]
39
+ }
40
+ export type ParagraphNode = { type: 'paragraph'; children: Inline[] }
41
+ export type BlockquoteNode = { type: 'blockquote'; children: Block[] }
42
+ export type ListNode = {
43
+ type: 'list'
44
+ ordered: boolean
45
+ start: number | null
46
+ spread: boolean
47
+ children: ListItemNode[]
48
+ }
49
+ export type ListItemNode = { type: 'listItem'; spread: boolean; children: Block[] }
50
+ export type CodeNode = {
51
+ type: 'code'
52
+ lang: string | null
53
+ meta: string | null
54
+ value: string
55
+ }
56
+ export type HtmlNode = { type: 'html'; value: string }
57
+ export type ThematicBreakNode = { type: 'thematicBreak' }
58
+
59
+ export type Inline =
60
+ | TextNode
61
+ | EmphasisNode
62
+ | StrongNode
63
+ | InlineCodeNode
64
+ | LinkNode
65
+ | ImageNode
66
+ | BreakNode
67
+ | DeleteNode
68
+ | HtmlNode
69
+
70
+ export type TextNode = { type: 'text'; value: string }
71
+ export type EmphasisNode = { type: 'emphasis'; children: Inline[] }
72
+ export type StrongNode = { type: 'strong'; children: Inline[] }
73
+ export type InlineCodeNode = { type: 'inlineCode'; value: string }
74
+ export type LinkNode = {
75
+ type: 'link'
76
+ url: string
77
+ title: string | null
78
+ children: Inline[]
79
+ }
80
+ export type ImageNode = {
81
+ type: 'image'
82
+ url: string
83
+ title: string | null
84
+ alt: string
85
+ }
86
+ export type BreakNode = { type: 'break' }
87
+ export type DeleteNode = { type: 'delete'; children: Inline[] }
88
+
89
+ /** Plain-text flattening of an image's inline children, for `alt`. */
90
+ function collectAltText(node: MdNode): string {
91
+ let out = ''
92
+ let child = node.firstChild
93
+ while (child) {
94
+ if ('text' === child.type || 'code' === child.type || 'html_inline' === child.type) {
95
+ out += child.literal ?? ''
96
+ } else if ('softbreak' === child.type || 'linebreak' === child.type) {
97
+ out += '\n'
98
+ } else {
99
+ out += collectAltText(child)
100
+ }
101
+ child = child.next
102
+ }
103
+ return out
104
+ }
105
+
106
+ /**
107
+ * mdast semantics: an item is spread when two of its children are separated by
108
+ * a *blank line* — not merely when it has more than one child. `- a\n - b`
109
+ * holds a paragraph and a nested list with no blank line between them, and is
110
+ * not spread; `- a\n\n - b` is.
111
+ *
112
+ * Derived from the block parser's `sourcepos` rather than tracked separately:
113
+ * consecutive children whose line ranges are not adjacent must have had a
114
+ * blank line between them.
115
+ */
116
+ function itemIsSpread(item: MdNode): boolean {
117
+ let child = item.firstChild
118
+ while (child && child.next) {
119
+ const endLine = child.sourcepos[1][0]
120
+ const nextStartLine = child.next.sourcepos[0][0]
121
+ if (nextStartLine > endLine + 1) return true
122
+ child = child.next
123
+ }
124
+ return false
125
+ }
126
+
127
+ function inlineChildren(node: MdNode, opts: ParserOptions): Inline[] {
128
+ const out: Inline[] = []
129
+
130
+ const pushText = (value: string) => {
131
+ if ('' === value) return
132
+ const last = out[out.length - 1]
133
+ if (last && 'text' === last.type) last.value += value
134
+ else out.push({ type: 'text', value })
135
+ }
136
+
137
+ let child = node.firstChild
138
+ while (child) {
139
+ switch (child.type) {
140
+ case 'text':
141
+ pushText(child.literal ?? '')
142
+ break
143
+
144
+ case 'softbreak':
145
+ // Documented behaviour: a soft break reads as a space in the AST.
146
+ if (opts.breaks) out.push({ type: 'break' })
147
+ else pushText(' ')
148
+ break
149
+
150
+ case 'linebreak':
151
+ out.push({ type: 'break' })
152
+ break
153
+
154
+ case 'code':
155
+ out.push({ type: 'inlineCode', value: child.literal ?? '' })
156
+ break
157
+
158
+ case 'html_inline':
159
+ out.push({ type: 'html', value: child.literal ?? '' })
160
+ break
161
+
162
+ case 'emph':
163
+ out.push({ type: 'emphasis', children: inlineChildren(child, opts) })
164
+ break
165
+
166
+ case 'strong':
167
+ out.push({ type: 'strong', children: inlineChildren(child, opts) })
168
+ break
169
+
170
+ case 'del':
171
+ out.push({ type: 'delete', children: inlineChildren(child, opts) })
172
+ break
173
+
174
+ case 'link':
175
+ out.push({
176
+ type: 'link',
177
+ url: child.destination ?? '',
178
+ title: child.title ?? null,
179
+ children: inlineChildren(child, opts),
180
+ })
181
+ break
182
+
183
+ case 'image':
184
+ out.push({
185
+ type: 'image',
186
+ url: child.destination ?? '',
187
+ title: child.title ?? null,
188
+ alt: collectAltText(child),
189
+ })
190
+ break
191
+
192
+ default:
193
+ // A block node cannot appear here in a well-formed tree; skipping is
194
+ // safer than emitting a node the published types do not allow.
195
+ break
196
+ }
197
+ child = child.next
198
+ }
199
+
200
+ return out
201
+ }
202
+
203
+ /** Containers whose children are blocks rather than inlines. */
204
+ const BLOCK_CONTAINERS: Record<string, true> = {
205
+ document: true,
206
+ block_quote: true,
207
+ list: true,
208
+ item: true,
209
+ }
210
+
211
+ /**
212
+ * Project a container's block children.
213
+ *
214
+ * Iterative rather than recursive, because the recursion depth here is the
215
+ * document's container nesting depth, which untrusted input controls directly:
216
+ * `'> '.repeat(4000)` is 8 KB of Markdown that the block parser and the HTML
217
+ * renderer both handle, but that overflowed the stack in this projection —
218
+ * crashing the package's primary API on input the rest of the pipeline
219
+ * survives.
220
+ *
221
+ * Two passes. The first walks the block tree with an explicit stack, recording
222
+ * nodes in an order where every parent precedes its descendants. Building in
223
+ * reverse then guarantees a node's children are already built when it is
224
+ * reached, so `buildBlock` can look them up instead of recursing.
225
+ *
226
+ * The Go port keeps the recursive form: goroutine stacks grow on demand and it
227
+ * projects 100k-deep nesting without trouble, so the same defect does not
228
+ * arise there.
229
+ */
230
+ function blockChildren(node: MdNode, opts: ParserOptions): Block[] {
231
+ const order: MdNode[] = []
232
+ const stack: MdNode[] = []
233
+
234
+ for (let c = node.lastChild; c; c = c.prev) stack.push(c)
235
+ while (0 < stack.length) {
236
+ const n = stack.pop() as MdNode
237
+ order.push(n)
238
+ if (true === BLOCK_CONTAINERS[n.type]) {
239
+ for (let c = n.lastChild; c; c = c.prev) stack.push(c)
240
+ }
241
+ }
242
+
243
+ const built = new Map<MdNode, Block>()
244
+ for (let i = order.length - 1; 0 <= i; i--) {
245
+ const n = order[i]
246
+ const block = buildBlock(n, opts, built)
247
+ if (null !== block) built.set(n, block)
248
+ }
249
+
250
+ const out: Block[] = []
251
+ for (let c = node.firstChild; c; c = c.next) {
252
+ const b = built.get(c)
253
+ if (undefined !== b) out.push(b)
254
+ }
255
+ return out
256
+ }
257
+
258
+ /** Children of `n` that have already been built, in source order. */
259
+ function builtChildren(n: MdNode, built: Map<MdNode, Block>): Block[] {
260
+ const out: Block[] = []
261
+ for (let c = n.firstChild; c; c = c.next) {
262
+ const b = built.get(c)
263
+ if (undefined !== b) out.push(b)
264
+ }
265
+ return out
266
+ }
267
+
268
+ function buildBlock(
269
+ node: MdNode,
270
+ opts: ParserOptions,
271
+ built: Map<MdNode, Block>,
272
+ ): Block | null {
273
+ switch (node.type) {
274
+ case 'paragraph':
275
+ return { type: 'paragraph', children: inlineChildren(node, opts) }
276
+
277
+ case 'heading':
278
+ return {
279
+ type: 'heading',
280
+ depth: node.level as HeadingNode['depth'],
281
+ children: inlineChildren(node, opts),
282
+ }
283
+
284
+ case 'thematic_break':
285
+ return { type: 'thematicBreak' }
286
+
287
+ case 'block_quote':
288
+ return { type: 'blockquote', children: builtChildren(node, built) }
289
+
290
+ case 'code_block': {
291
+ const info = (node.info ?? '').trim()
292
+ let lang: string | null = null
293
+ let meta: string | null = null
294
+ if ('' !== info) {
295
+ const sp = info.search(/\s/)
296
+ if (-1 === sp) {
297
+ lang = info
298
+ } else {
299
+ lang = info.slice(0, sp)
300
+ meta = info.slice(sp + 1).trim() || null
301
+ }
302
+ }
303
+ // The native tree keeps the trailing newline because the spec's HTML
304
+ // for `<pre><code>` includes it; mdast's `code.value` does not. Strip
305
+ // exactly one, not all, so a block that genuinely ends in a blank line
306
+ // keeps it.
307
+ const raw = node.literal ?? ''
308
+ const value = raw.endsWith('\n') ? raw.slice(0, -1) : raw
309
+ return { type: 'code', lang, meta, value }
310
+ }
311
+
312
+ case 'html_block':
313
+ return { type: 'html', value: node.literal ?? '' }
314
+
315
+ case 'list': {
316
+ const data = node.listData
317
+ const ordered = 'ordered' === data?.type
318
+ const items: ListItemNode[] = []
319
+ let child = node.firstChild
320
+ while (child) {
321
+ if ('item' === child.type) {
322
+ items.push({
323
+ type: 'listItem',
324
+ spread: itemIsSpread(child),
325
+ children: builtChildren(child, built),
326
+ })
327
+ }
328
+ child = child.next
329
+ }
330
+ return {
331
+ type: 'list',
332
+ ordered,
333
+ start: ordered ? (data?.start ?? 1) : null,
334
+ spread: !(data?.tight ?? true),
335
+ children: items,
336
+ }
337
+ }
338
+
339
+ case 'item':
340
+ // Only reachable if an item is somehow detached from its list; a list
341
+ // builds its own items above.
342
+ return { type: 'listItem', spread: itemIsSpread(node), children: builtChildren(node, built) }
343
+
344
+ default:
345
+ return null
346
+ }
347
+ }
348
+
349
+ /** Project a parsed native tree into the public JSON AST. */
350
+ export function toAst(doc: MdNode, opts: ParserOptions): DocumentNode {
351
+ return { type: 'document', children: blockChildren(doc, opts) }
352
+ }