@tabnas/markdown 0.4.2 → 0.5.1

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,406 @@
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 { TableAlign } from './node.ts'
22
+ import type { ParserOptions } from './options.ts'
23
+
24
+ export type DocumentNode = { type: 'document'; children: Block[] }
25
+
26
+ export type Block =
27
+ | HeadingNode
28
+ | ParagraphNode
29
+ | BlockquoteNode
30
+ | ListNode
31
+ | ListItemNode
32
+ | CodeNode
33
+ | HtmlNode
34
+ | ThematicBreakNode
35
+ | TableNode
36
+
37
+ export type HeadingNode = {
38
+ type: 'heading'
39
+ depth: 1 | 2 | 3 | 4 | 5 | 6
40
+ children: Inline[]
41
+ }
42
+ export type ParagraphNode = { type: 'paragraph'; children: Inline[] }
43
+ export type BlockquoteNode = { type: 'blockquote'; children: Block[] }
44
+ export type ListNode = {
45
+ type: 'list'
46
+ ordered: boolean
47
+ start: number | null
48
+ spread: boolean
49
+ children: ListItemNode[]
50
+ }
51
+ export type ListItemNode = {
52
+ type: 'listItem'
53
+ spread: boolean
54
+ /**
55
+ * mdast's task list field: `true`/`false` for a GFM task list item
56
+ * (`- [x] foo`), `null` for an ordinary item — and therefore `null`
57
+ * everywhere when `gfm` is off.
58
+ */
59
+ checked: boolean | null
60
+ children: Block[]
61
+ }
62
+ export type CodeNode = {
63
+ type: 'code'
64
+ lang: string | null
65
+ meta: string | null
66
+ value: string
67
+ }
68
+ export type HtmlNode = { type: 'html'; value: string }
69
+ export type ThematicBreakNode = { type: 'thematicBreak' }
70
+
71
+ /**
72
+ * A GFM table, in mdast's shape.
73
+ *
74
+ * `align` has one entry per column — `null` where the delimiter row gave no
75
+ * colon — and every row has exactly that many cells, since the block phase
76
+ * pads short rows and truncates long ones.
77
+ *
78
+ * mdast has no header flag: the **first** row is the header row, by
79
+ * convention, and this projection preserves that ordering from the tree.
80
+ */
81
+ export type TableNode = {
82
+ type: 'table'
83
+ align: TableAlign[]
84
+ children: TableRowNode[]
85
+ }
86
+ export type TableRowNode = { type: 'tableRow'; children: TableCellNode[] }
87
+ export type TableCellNode = { type: 'tableCell'; children: Inline[] }
88
+
89
+ export type Inline =
90
+ | TextNode
91
+ | EmphasisNode
92
+ | StrongNode
93
+ | InlineCodeNode
94
+ | LinkNode
95
+ | ImageNode
96
+ | BreakNode
97
+ | DeleteNode
98
+ | HtmlNode
99
+
100
+ export type TextNode = { type: 'text'; value: string }
101
+ export type EmphasisNode = { type: 'emphasis'; children: Inline[] }
102
+ export type StrongNode = { type: 'strong'; children: Inline[] }
103
+ export type InlineCodeNode = { type: 'inlineCode'; value: string }
104
+ export type LinkNode = {
105
+ type: 'link'
106
+ url: string
107
+ title: string | null
108
+ children: Inline[]
109
+ }
110
+ export type ImageNode = {
111
+ type: 'image'
112
+ url: string
113
+ title: string | null
114
+ alt: string
115
+ }
116
+ export type BreakNode = { type: 'break' }
117
+ export type DeleteNode = { type: 'delete'; children: Inline[] }
118
+
119
+ /** Plain-text flattening of an image's inline children, for `alt`. */
120
+ function collectAltText(node: MdNode): string {
121
+ let out = ''
122
+ let child = node.firstChild
123
+ while (child) {
124
+ if ('text' === child.type || 'code' === child.type || 'html_inline' === child.type) {
125
+ out += child.literal ?? ''
126
+ } else if ('softbreak' === child.type || 'linebreak' === child.type) {
127
+ out += '\n'
128
+ } else {
129
+ out += collectAltText(child)
130
+ }
131
+ child = child.next
132
+ }
133
+ return out
134
+ }
135
+
136
+ /**
137
+ * mdast semantics: an item is spread when two of its children are separated by
138
+ * a *blank line* — not merely when it has more than one child. `- a\n - b`
139
+ * holds a paragraph and a nested list with no blank line between them, and is
140
+ * not spread; `- a\n\n - b` is.
141
+ *
142
+ * Derived from the block parser's `sourcepos` rather than tracked separately:
143
+ * consecutive children whose line ranges are not adjacent must have had a
144
+ * blank line between them.
145
+ */
146
+ function itemIsSpread(item: MdNode): boolean {
147
+ let child = item.firstChild
148
+ while (child && child.next) {
149
+ const endLine = child.sourcepos[1][0]
150
+ const nextStartLine = child.next.sourcepos[0][0]
151
+ if (nextStartLine > endLine + 1) return true
152
+ child = child.next
153
+ }
154
+ return false
155
+ }
156
+
157
+ function inlineChildren(node: MdNode, opts: ParserOptions): Inline[] {
158
+ const out: Inline[] = []
159
+
160
+ const pushText = (value: string) => {
161
+ if ('' === value) return
162
+ const last = out[out.length - 1]
163
+ if (last && 'text' === last.type) last.value += value
164
+ else out.push({ type: 'text', value })
165
+ }
166
+
167
+ let child = node.firstChild
168
+ while (child) {
169
+ switch (child.type) {
170
+ case 'text':
171
+ pushText(child.literal ?? '')
172
+ break
173
+
174
+ case 'softbreak':
175
+ // Documented behaviour: a soft break reads as a space in the AST.
176
+ if (opts.breaks) out.push({ type: 'break' })
177
+ else pushText(' ')
178
+ break
179
+
180
+ case 'linebreak':
181
+ out.push({ type: 'break' })
182
+ break
183
+
184
+ case 'code':
185
+ out.push({ type: 'inlineCode', value: child.literal ?? '' })
186
+ break
187
+
188
+ case 'html_inline':
189
+ out.push({ type: 'html', value: child.literal ?? '' })
190
+ break
191
+
192
+ case 'emph':
193
+ out.push({ type: 'emphasis', children: inlineChildren(child, opts) })
194
+ break
195
+
196
+ case 'strong':
197
+ out.push({ type: 'strong', children: inlineChildren(child, opts) })
198
+ break
199
+
200
+ case 'del':
201
+ out.push({ type: 'delete', children: inlineChildren(child, opts) })
202
+ break
203
+
204
+ case 'link':
205
+ out.push({
206
+ type: 'link',
207
+ url: child.destination ?? '',
208
+ title: child.title ?? null,
209
+ children: inlineChildren(child, opts),
210
+ })
211
+ break
212
+
213
+ case 'image':
214
+ out.push({
215
+ type: 'image',
216
+ url: child.destination ?? '',
217
+ title: child.title ?? null,
218
+ alt: collectAltText(child),
219
+ })
220
+ break
221
+
222
+ default:
223
+ // A block node cannot appear here in a well-formed tree; skipping is
224
+ // safer than emitting a node the published types do not allow.
225
+ break
226
+ }
227
+ child = child.next
228
+ }
229
+
230
+ return out
231
+ }
232
+
233
+ /** Containers whose children are blocks rather than inlines. */
234
+ const BLOCK_CONTAINERS: Record<string, true> = {
235
+ document: true,
236
+ block_quote: true,
237
+ list: true,
238
+ item: true,
239
+ }
240
+
241
+ /**
242
+ * Project a container's block children.
243
+ *
244
+ * Iterative rather than recursive, because the recursion depth here is the
245
+ * document's container nesting depth, which untrusted input controls directly:
246
+ * `'> '.repeat(4000)` is 8 KB of Markdown that the block parser and the HTML
247
+ * renderer both handle, but that overflowed the stack in this projection —
248
+ * crashing the package's primary API on input the rest of the pipeline
249
+ * survives.
250
+ *
251
+ * Two passes. The first walks the block tree with an explicit stack, recording
252
+ * nodes in an order where every parent precedes its descendants. Building in
253
+ * reverse then guarantees a node's children are already built when it is
254
+ * reached, so `buildBlock` can look them up instead of recursing.
255
+ *
256
+ * The Go port keeps the recursive form: goroutine stacks grow on demand and it
257
+ * projects 100k-deep nesting without trouble, so the same defect does not
258
+ * arise there.
259
+ */
260
+ function blockChildren(node: MdNode, opts: ParserOptions): Block[] {
261
+ const order: MdNode[] = []
262
+ const stack: MdNode[] = []
263
+
264
+ for (let c = node.lastChild; c; c = c.prev) stack.push(c)
265
+ while (0 < stack.length) {
266
+ const n = stack.pop() as MdNode
267
+ order.push(n)
268
+ if (true === BLOCK_CONTAINERS[n.type]) {
269
+ for (let c = n.lastChild; c; c = c.prev) stack.push(c)
270
+ }
271
+ }
272
+
273
+ const built = new Map<MdNode, Block>()
274
+ for (let i = order.length - 1; 0 <= i; i--) {
275
+ const n = order[i]
276
+ const block = buildBlock(n, opts, built)
277
+ if (null !== block) built.set(n, block)
278
+ }
279
+
280
+ const out: Block[] = []
281
+ for (let c = node.firstChild; c; c = c.next) {
282
+ const b = built.get(c)
283
+ if (undefined !== b) out.push(b)
284
+ }
285
+ return out
286
+ }
287
+
288
+ /** Children of `n` that have already been built, in source order. */
289
+ function builtChildren(n: MdNode, built: Map<MdNode, Block>): Block[] {
290
+ const out: Block[] = []
291
+ for (let c = n.firstChild; c; c = c.next) {
292
+ const b = built.get(c)
293
+ if (undefined !== b) out.push(b)
294
+ }
295
+ return out
296
+ }
297
+
298
+ function buildBlock(
299
+ node: MdNode,
300
+ opts: ParserOptions,
301
+ built: Map<MdNode, Block>,
302
+ ): Block | null {
303
+ switch (node.type) {
304
+ case 'paragraph':
305
+ return { type: 'paragraph', children: inlineChildren(node, opts) }
306
+
307
+ case 'heading':
308
+ return {
309
+ type: 'heading',
310
+ depth: node.level as HeadingNode['depth'],
311
+ children: inlineChildren(node, opts),
312
+ }
313
+
314
+ case 'thematic_break':
315
+ return { type: 'thematicBreak' }
316
+
317
+ case 'block_quote':
318
+ return { type: 'blockquote', children: builtChildren(node, built) }
319
+
320
+ case 'code_block': {
321
+ const info = (node.info ?? '').trim()
322
+ let lang: string | null = null
323
+ let meta: string | null = null
324
+ if ('' !== info) {
325
+ const sp = info.search(/\s/)
326
+ if (-1 === sp) {
327
+ lang = info
328
+ } else {
329
+ lang = info.slice(0, sp)
330
+ meta = info.slice(sp + 1).trim() || null
331
+ }
332
+ }
333
+ // The native tree keeps the trailing newline because the spec's HTML
334
+ // for `<pre><code>` includes it; mdast's `code.value` does not. Strip
335
+ // exactly one, not all, so a block that genuinely ends in a blank line
336
+ // keeps it.
337
+ const raw = node.literal ?? ''
338
+ const value = raw.endsWith('\n') ? raw.slice(0, -1) : raw
339
+ return { type: 'code', lang, meta, value }
340
+ }
341
+
342
+ case 'html_block':
343
+ return { type: 'html', value: node.literal ?? '' }
344
+
345
+ case 'table': {
346
+ // Built here rather than through `built`, because a table's children are
347
+ // rows and cells rather than blocks — `blockChildren` never descends
348
+ // into one. The nesting is a fixed three levels deep, so unlike the
349
+ // block containers this cannot be driven past the stack by input.
350
+ const rows: TableRowNode[] = []
351
+ for (let row = node.firstChild; row; row = row.next) {
352
+ if ('table_row' !== row.type) continue
353
+ const cells: TableCellNode[] = []
354
+ for (let cell = row.firstChild; cell; cell = cell.next) {
355
+ if ('table_cell' !== cell.type) continue
356
+ cells.push({ type: 'tableCell', children: inlineChildren(cell, opts) })
357
+ }
358
+ rows.push({ type: 'tableRow', children: cells })
359
+ }
360
+ return { type: 'table', align: node.tableAlign ?? [], children: rows }
361
+ }
362
+
363
+ case 'list': {
364
+ const data = node.listData
365
+ const ordered = 'ordered' === data?.type
366
+ const items: ListItemNode[] = []
367
+ let child = node.firstChild
368
+ while (child) {
369
+ if ('item' === child.type) {
370
+ items.push({
371
+ type: 'listItem',
372
+ spread: itemIsSpread(child),
373
+ checked: child.checked,
374
+ children: builtChildren(child, built),
375
+ })
376
+ }
377
+ child = child.next
378
+ }
379
+ return {
380
+ type: 'list',
381
+ ordered,
382
+ start: ordered ? (data?.start ?? 1) : null,
383
+ spread: !(data?.tight ?? true),
384
+ children: items,
385
+ }
386
+ }
387
+
388
+ case 'item':
389
+ // Only reachable if an item is somehow detached from its list; a list
390
+ // builds its own items above.
391
+ return {
392
+ type: 'listItem',
393
+ spread: itemIsSpread(node),
394
+ checked: node.checked,
395
+ children: builtChildren(node, built),
396
+ }
397
+
398
+ default:
399
+ return null
400
+ }
401
+ }
402
+
403
+ /** Project a parsed native tree into the public JSON AST. */
404
+ export function toAst(doc: MdNode, opts: ParserOptions): DocumentNode {
405
+ return { type: 'document', children: blockChildren(doc, opts) }
406
+ }