@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/README.md +131 -10
- package/dist/ast.d.ts +113 -0
- package/dist/ast.js +273 -0
- package/dist/ast.js.map +1 -0
- package/dist/block.d.ts +10 -0
- package/dist/block.js +1411 -0
- package/dist/block.js.map +1 -0
- package/dist/common.d.ts +49 -0
- package/dist/common.js +155 -0
- package/dist/common.js.map +1 -0
- package/dist/commonmark.d.ts +14 -0
- package/dist/commonmark.js +31 -0
- package/dist/commonmark.js.map +1 -0
- package/dist/entities.d.ts +1 -0
- package/dist/entities.js +14 -0
- package/dist/entities.js.map +1 -0
- package/dist/html.d.ts +16 -0
- package/dist/html.js +393 -0
- package/dist/html.js.map +1 -0
- package/dist/inline.d.ts +19 -0
- package/dist/inline.js +1331 -0
- package/dist/inline.js.map +1 -0
- package/dist/markdown.d.ts +21 -84
- package/dist/markdown.js +80 -687
- package/dist/markdown.js.map +1 -1
- package/dist/node.d.ts +112 -0
- package/dist/node.js +203 -0
- package/dist/node.js.map +1 -0
- package/dist/options.d.ts +26 -0
- package/dist/options.js +16 -0
- package/dist/options.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -1
- package/src/ast.ts +406 -0
- package/src/block.ts +1579 -0
- package/src/common.ts +166 -0
- package/src/commonmark.ts +34 -0
- package/src/entities.ts +14 -0
- package/src/html.ts +446 -0
- package/src/inline.ts +1495 -0
- package/src/markdown.ts +132 -770
- package/src/node.ts +293 -0
- package/src/options.ts +44 -0
- package/src/tsconfig.json +2 -0
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
|
+
}
|