@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/block.ts ADDED
@@ -0,0 +1,1579 @@
1
+ /* Copyright (c) 2021-2026 Richard Rodger, MIT License */
2
+
3
+ // Phase 1 of the CommonMark parse: block structure.
4
+ // Spec 0.31.2, Appendix A "A parsing strategy".
5
+ //
6
+ // The document is a tree of blocks whose right spine — the document, then last
7
+ // child, then last child of that, and so on — is *open*: each open block
8
+ // imposes a continuation condition on the next line. For every line we
9
+ //
10
+ // 1. walk that spine, consuming each block's continuation marker (`>` for a
11
+ // block quote, the content indent for a list item, ...) and remember the
12
+ // last container that matched;
13
+ // 2. look for new block starts at the offset that walk reached, closing the
14
+ // unmatched tail of the spine as soon as a start actually matches;
15
+ // 3. add whatever is left of the line to the deepest open block, opening a
16
+ // paragraph if there is nowhere else for the text to go.
17
+ //
18
+ // Unmatched blocks are closed in step 2/3 rather than in step 1 because a
19
+ // paragraph line may be a *lazy continuation*: its containers' markers are
20
+ // missing, but the paragraph carries on regardless (§5.1 rule 2).
21
+ //
22
+ // Positions on a line are tracked twice: `offset`, a UTF-16 index used to
23
+ // slice the text we keep, and `column`, the tab-expanded display column used
24
+ // for every indent decision (§2.2). The two must move together, including the
25
+ // case where a marker consumes only part of a tab — see `advanceOffset` and
26
+ // `addLine`.
27
+ //
28
+ // This file owns everything the block phase sets on a node: type, level,
29
+ // literal (code_block/html_block), info/isFenced, listData (including the
30
+ // final `tight`), sourcepos, and `stringContent` — the raw, unparsed text of
31
+ // paragraphs and headings that the inline phase consumes.
32
+
33
+ import { MdNode } from './node.ts'
34
+ import type { ListData, NodeType, SourcePos, TableAlign } from './node.ts'
35
+ import { unescapeString } from './common.ts'
36
+ import { parseReference } from './inline.ts'
37
+ import type { ParserOptions, RefMap } from './options.ts'
38
+
39
+ /** §4.4: four columns of indentation open an indented code block. */
40
+ const CODE_INDENT = 4
41
+
42
+ const C_TAB = 9
43
+ const C_SPACE = 32
44
+ const C_GREATERTHAN = 62
45
+ const C_LESSTHAN = 60
46
+ const C_OPEN_BRACKET = 91
47
+ const C_BACKSLASH = 92
48
+ const C_PIPE = 124
49
+
50
+ /** §2.1: \r\n, \n and a lone \r are all line endings. */
51
+ const RE_LINE_ENDING = /\r\n|\n|\r/
52
+
53
+ /** §4.1: 3+ matching `*`, `_` or `-`, each optionally followed by spaces/tabs. */
54
+ const RE_THEMATIC_BREAK = /^(?:\*[ \t]*){3,}$|^(?:_[ \t]*){3,}$|^(?:-[ \t]*){3,}$/
55
+
56
+ /** Fast reject: every block start begins with one of these characters. */
57
+ const RE_MAYBE_SPECIAL = /^[#`~*+_=<>0-9-]/
58
+
59
+ /**
60
+ * The same fast reject with `|` and `:` added, used when `gfm` is on: a GFM
61
+ * table's delimiter row may begin with either (`| --- |`, `:-: | ---:`), and
62
+ * neither is a CommonMark block start.
63
+ *
64
+ * Kept as a separate constant rather than folded into the one above so that a
65
+ * `gfm:false` parse takes exactly the path it always did — same characters
66
+ * rejected, same lines never offered to the block starts at all.
67
+ */
68
+ const RE_MAYBE_SPECIAL_GFM = /^[#`~*+_=<>0-9:|-]/
69
+
70
+ const RE_NON_SPACE = /[^ \t\f\v\r\n]/
71
+
72
+ /** §5.2 list markers. */
73
+ const RE_BULLET_LIST_MARKER = /^[*+-]/
74
+ const RE_ORDERED_LIST_MARKER = /^(\d{1,9})([.)])/
75
+
76
+ /** §4.2: 1–6 `#` followed by a space, a tab, or the end of the line. */
77
+ const RE_ATX_HEADING_MARKER = /^#{1,6}(?:[ \t]+|$)/
78
+
79
+ /** §4.5: an info string after a backtick fence may not contain a backtick. */
80
+ const RE_CODE_FENCE = /^`{3,}(?!.*`)|^~{3,}/
81
+ const RE_CLOSING_CODE_FENCE = /^(?:`{3,}|~{3,})(?=[ \t]*$)/
82
+
83
+ /** §4.3: a run of `=` or `-` with only spaces/tabs after it. */
84
+ const RE_SETEXT_HEADING_LINE = /^(?:=+|-+)[ \t]*$/
85
+
86
+ // --- HTML blocks (§4.6) -----------------------------------------------------
87
+ //
88
+ // Seven kinds, each with its own start and end condition. Types 1–5 end on a
89
+ // line *containing* their closing string (checked in `incorporateLine` after
90
+ // the line is added); types 6 and 7 end at a blank line (checked in the
91
+ // html_block continuation condition). Type 7 alone may not interrupt a
92
+ // paragraph.
93
+
94
+ /** Tag-shape fragments shared by HTML block type 7 (§6.6 raw HTML). */
95
+ const TAGNAME = '[A-Za-z][A-Za-z0-9-]*'
96
+ const ATTRIBUTENAME = '[a-zA-Z_:][a-zA-Z0-9:._-]*'
97
+ const UNQUOTEDVALUE = "[^\"'=<>`\\x00-\\x20]+"
98
+ const SINGLEQUOTEDVALUE = "'[^']*'"
99
+ const DOUBLEQUOTEDVALUE = '"[^"]*"'
100
+ const ATTRIBUTEVALUE =
101
+ '(?:' + UNQUOTEDVALUE + '|' + SINGLEQUOTEDVALUE + '|' + DOUBLEQUOTEDVALUE + ')'
102
+ const ATTRIBUTEVALUESPEC = '(?:[ \\t]*=[ \\t]*' + ATTRIBUTEVALUE + ')'
103
+ const ATTRIBUTE = '(?:[ \\t]+' + ATTRIBUTENAME + ATTRIBUTEVALUESPEC + '?)'
104
+ const OPENTAG = '<' + TAGNAME + ATTRIBUTE + '*[ \\t]*/?>'
105
+ const CLOSETAG = '</' + TAGNAME + '[ \\t]*>'
106
+
107
+ /** The type 6 tag list, verbatim from §4.6 (0.31.2 has `search`, not `source`). */
108
+ const BLOCK_TAGS = [
109
+ 'address',
110
+ 'article',
111
+ 'aside',
112
+ 'base',
113
+ 'basefont',
114
+ 'blockquote',
115
+ 'body',
116
+ 'caption',
117
+ 'center',
118
+ 'col',
119
+ 'colgroup',
120
+ 'dd',
121
+ 'details',
122
+ 'dialog',
123
+ 'dir',
124
+ 'div',
125
+ 'dl',
126
+ 'dt',
127
+ 'fieldset',
128
+ 'figcaption',
129
+ 'figure',
130
+ 'footer',
131
+ 'form',
132
+ 'frame',
133
+ 'frameset',
134
+ 'h[1-6]',
135
+ 'head',
136
+ 'header',
137
+ 'hr',
138
+ 'html',
139
+ 'iframe',
140
+ 'legend',
141
+ 'li',
142
+ 'link',
143
+ 'main',
144
+ 'menu',
145
+ 'menuitem',
146
+ 'nav',
147
+ 'noframes',
148
+ 'ol',
149
+ 'optgroup',
150
+ 'option',
151
+ 'p',
152
+ 'param',
153
+ 'search',
154
+ 'section',
155
+ 'summary',
156
+ 'table',
157
+ 'tbody',
158
+ 'td',
159
+ 'tfoot',
160
+ 'th',
161
+ 'thead',
162
+ 'title',
163
+ 'tr',
164
+ 'track',
165
+ 'ul',
166
+ ].join('|')
167
+
168
+ /** Indexed by HTML block type 1–7; slot 0 is unused. */
169
+ const RE_HTML_BLOCK_OPEN: RegExp[] = [
170
+ /(?:)/,
171
+ /^<(?:script|pre|textarea|style)(?:[ \t]|>|$)/i,
172
+ /^<!--/,
173
+ /^<[?]/,
174
+ /^<![A-Za-z]/,
175
+ /^<!\[CDATA\[/,
176
+ new RegExp('^</?(?:' + BLOCK_TAGS + ')(?:[ \\t]|/?>|$)', 'i'),
177
+ new RegExp('^(?:' + OPENTAG + '|' + CLOSETAG + ')[ \\t]*$', 'i'),
178
+ ]
179
+
180
+ /** End conditions for types 1–5; types 6 and 7 end at a blank line instead. */
181
+ const RE_HTML_BLOCK_CLOSE: RegExp[] = [
182
+ /(?:)/,
183
+ /<\/(?:script|pre|textarea|style)>/i,
184
+ /-->/,
185
+ /\?>/,
186
+ />/,
187
+ /\]\]>/,
188
+ ]
189
+
190
+ // --- small lexical helpers --------------------------------------------------
191
+
192
+ /** Character code at `pos`, or -1 past the end of the line. */
193
+ function peek(ln: string, pos: number): number {
194
+ return 0 <= pos && pos < ln.length ? ln.charCodeAt(pos) : -1
195
+ }
196
+
197
+ /**
198
+ * Number of characters (code points) in `s`. `s.length` counts UTF-16 units,
199
+ * which double-counts astral characters; sourcepos columns count characters.
200
+ */
201
+ function charCount(s: string): number {
202
+ let n = 0
203
+ for (let i = 0; i < s.length; i++) {
204
+ const c = s.charCodeAt(i)
205
+ // Skip the low half of a surrogate pair; a lone surrogate still counts once.
206
+ if (0xd800 <= c && c <= 0xdbff && i + 1 < s.length) {
207
+ const d = s.charCodeAt(i + 1)
208
+ if (0xdc00 <= d && d <= 0xdfff) i++
209
+ }
210
+ n++
211
+ }
212
+ return n
213
+ }
214
+
215
+ function isSpaceOrTab(c: number): boolean {
216
+ return C_SPACE === c || C_TAB === c
217
+ }
218
+
219
+ function isBlank(s: string): boolean {
220
+ return !RE_NON_SPACE.test(s)
221
+ }
222
+
223
+ // --- GFM tables (extension) -------------------------------------------------
224
+ //
225
+ // Everything the extension needs from a line is here: how a row splits into
226
+ // cells, and whether a line is a delimiter row. Both are pure functions of one
227
+ // line of text, which is what lets the block start below stay a few lines
228
+ // long.
229
+
230
+ /** ASCII whitespace, the set `cmark_isspace` trims from a table cell. */
231
+ function isAsciiSpace(c: number): boolean {
232
+ return 32 === c || (9 <= c && c <= 13)
233
+ }
234
+
235
+ function trimAsciiSpace(s: string): string {
236
+ let start = 0
237
+ let end = s.length
238
+ while (start < end && isAsciiSpace(s.charCodeAt(start))) start++
239
+ while (end > start && isAsciiSpace(s.charCodeAt(end - 1))) end--
240
+ return 0 === start && end === s.length ? s : s.slice(start, end)
241
+ }
242
+
243
+ /**
244
+ * Split one row of a table into its cell texts.
245
+ *
246
+ * Cells are separated by *unescaped* pipes; a leading and a trailing pipe are
247
+ * both optional and may differ from row to row, so they are stripped before
248
+ * the split rather than producing empty cells at the ends. A trailing pipe is
249
+ * only a delimiter if it is itself unescaped, which is why the backslash run
250
+ * before it is counted.
251
+ *
252
+ * `\|` is resolved to a literal `|` *here*, as the extension requires ("include
253
+ * a pipe in a cell's content by escaping it, including inside other inline
254
+ * spans"). Doing it at split time is the whole point: the inline phase never
255
+ * sees the backslash, so a code span in the cell yields a real pipe —
256
+ * `` b `\|` az `` is `b <code>|</code> az`, which no amount of unescaping
257
+ * *after* inline parsing could produce. Every other backslash escape is left
258
+ * exactly as written for the inline phase to handle in the usual way, so no
259
+ * cell text is unescaped twice.
260
+ *
261
+ * Scanning skips the character after any backslash, so `\\` cannot shield the
262
+ * pipe that follows it: `a\\|b` is two cells, the first holding `a\\`.
263
+ *
264
+ * A row of nothing but whitespace has no cells at all, which is how a header
265
+ * candidate that is really empty fails the cell-count test instead of matching
266
+ * a one-column delimiter row.
267
+ */
268
+ function splitTableRow(line: string): string[] {
269
+ let start = 0
270
+ let end = line.length
271
+ while (start < end && isAsciiSpace(line.charCodeAt(start))) start++
272
+ while (end > start && isAsciiSpace(line.charCodeAt(end - 1))) end--
273
+
274
+ if (start === end) return []
275
+
276
+ // Optional leading pipe.
277
+ if (C_PIPE === line.charCodeAt(start)) start++
278
+
279
+ // Optional trailing pipe — but an escaped one is content, not a delimiter.
280
+ if (start < end && C_PIPE === line.charCodeAt(end - 1)) {
281
+ let bs = end - 2
282
+ while (start <= bs && C_BACKSLASH === line.charCodeAt(bs)) bs--
283
+ // An even-length backslash run leaves the pipe unescaped.
284
+ if (0 === (end - 2 - bs) % 2) end--
285
+ }
286
+
287
+ const cells: string[] = []
288
+ // `cur` holds the parts of the current cell that have already been rewritten;
289
+ // `seg` is the start of the run since then, so the common case (no escapes)
290
+ // costs one slice per cell rather than one per character.
291
+ let cur = ''
292
+ let seg = start
293
+ let i = start
294
+
295
+ while (i < end) {
296
+ const c = line.charCodeAt(i)
297
+ if (C_BACKSLASH === c && i + 1 < end) {
298
+ if (C_PIPE === line.charCodeAt(i + 1)) {
299
+ cur += line.slice(seg, i) + '|'
300
+ i += 2
301
+ seg = i
302
+ } else {
303
+ i += 2
304
+ }
305
+ continue
306
+ }
307
+ if (C_PIPE === c) {
308
+ cells.push(trimAsciiSpace(cur + line.slice(seg, i)))
309
+ cur = ''
310
+ i++
311
+ seg = i
312
+ continue
313
+ }
314
+ i++
315
+ }
316
+ cells.push(trimAsciiSpace(cur + line.slice(seg, end)))
317
+
318
+ return cells
319
+ }
320
+
321
+ /**
322
+ * A delimiter cell is hyphens with an optional leading and/or trailing colon,
323
+ * and nothing else. One `+`, one space in the middle, one stray character and
324
+ * the line is not a delimiter row.
325
+ */
326
+ const RE_TABLE_DELIMITER_CELL = /^(:?)-+(:?)$/
327
+
328
+ /**
329
+ * Read a line as a table's delimiter row, returning one alignment per column,
330
+ * or null if it is not one.
331
+ */
332
+ function parseDelimiterRow(line: string): TableAlign[] | null {
333
+ const cells = splitTableRow(line)
334
+ if (0 === cells.length) return null
335
+
336
+ const align: TableAlign[] = []
337
+ for (const cell of cells) {
338
+ const m = RE_TABLE_DELIMITER_CELL.exec(cell)
339
+ if (null === m) return null
340
+ const left = ':' === m[1]
341
+ const right = ':' === m[2]
342
+ align.push(left && right ? 'center' : left ? 'left' : right ? 'right' : null)
343
+ }
344
+ return align
345
+ }
346
+
347
+ /**
348
+ * Cap on the empty cells inserted to pad short body rows, across a whole
349
+ * document — cmark-gfm's `MAX_AUTOCOMPLETED_CELLS`, and for the same reason.
350
+ *
351
+ * Padding is the one place where a table's node count is not bounded by the
352
+ * size of its source: a 10000-column header followed by 10000 one-cell rows is
353
+ * 60 KB of input asking for 10^8 cells. Every other part of the extension is
354
+ * linear in the input, so this single budget is what keeps the whole of it so.
355
+ * Reaching it needs input that is already pathological; ordinary tables are
356
+ * nowhere near.
357
+ */
358
+ const MAX_AUTOCOMPLETED_CELLS = 0x80000
359
+
360
+ /** §5.3: two markers make the same list only if type, bullet and delimiter agree. */
361
+ function listsMatch(listData: ListData, itemData: ListData): boolean {
362
+ return (
363
+ listData.type === itemData.type &&
364
+ listData.delimiter === itemData.delimiter &&
365
+ listData.bulletChar === itemData.bulletChar
366
+ )
367
+ }
368
+
369
+ /**
370
+ * §5.3 looseness input: does this block end with a blank line? For lists and
371
+ * items the question recurses into the last child, since the blank line is
372
+ * recorded on the deepest block that saw it. The `lastLineChecked` flag makes
373
+ * the recursion linear over the whole document rather than quadratic.
374
+ */
375
+ function endsWithBlankLine(block: MdNode | null): boolean {
376
+ let cur = block
377
+ while (cur) {
378
+ if (cur.lastLineBlank) return true
379
+ const t = cur.type
380
+ if (!cur.lastLineChecked && ('list' === t || 'item' === t)) {
381
+ cur.lastLineChecked = true
382
+ cur = cur.lastChild
383
+ } else {
384
+ cur.lastLineChecked = true
385
+ return false
386
+ }
387
+ }
388
+ return false
389
+ }
390
+
391
+ // --- per-block behaviour ----------------------------------------------------
392
+
393
+ /**
394
+ * `continue` result: 0 = matched, 1 = not matched, 2 = line fully consumed
395
+ * (fenced code close), stop processing this line entirely.
396
+ */
397
+ type ContinueResult = 0 | 1 | 2
398
+
399
+ type BlockHandler = {
400
+ /** Can this block absorb the current line? */
401
+ continue: (parser: BlockParser, block: MdNode) => ContinueResult
402
+ /** Called once when the block is closed. */
403
+ finalize: (parser: BlockParser, block: MdNode) => void
404
+ canContain: (t: NodeType) => boolean
405
+ /** Leaves that accumulate raw text: code blocks, HTML blocks, paragraphs, tables. */
406
+ acceptsLines: boolean
407
+ /**
408
+ * Lines belonging to this block are literal text, so Appendix A step 2 does
409
+ * not look for block starts while it is the last matched container.
410
+ *
411
+ * True for exactly the two blocks the spec names — code blocks and HTML
412
+ * blocks. Paragraphs and GFM tables also accept lines, but a block start may
413
+ * still interrupt either: that is how `> bar` ends a table, and how `# h`
414
+ * ends a paragraph.
415
+ */
416
+ verbatim: boolean
417
+ }
418
+
419
+ const notItem = (t: NodeType): boolean => 'item' !== t
420
+ const noChildren = (): boolean => false
421
+
422
+ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
423
+ document: {
424
+ continue: () => 0,
425
+ finalize: () => undefined,
426
+ canContain: notItem,
427
+ acceptsLines: false,
428
+ verbatim: false,
429
+ },
430
+
431
+ list: {
432
+ continue: () => 0,
433
+ finalize: (_parser, block) => {
434
+ // §5.3: the list is loose if any item is followed by a blank line, or if
435
+ // any item directly contains two blocks separated by one. A blank line
436
+ // at the very end of the last item does not count — hence the
437
+ // `item.next` / `subitem.next` guards.
438
+ let item = block.firstChild
439
+ while (item) {
440
+ if (endsWithBlankLine(item) && item.next) {
441
+ if (block.listData) block.listData.tight = false
442
+ break
443
+ }
444
+ let subitem = item.firstChild
445
+ while (subitem) {
446
+ if (endsWithBlankLine(subitem) && (item.next || subitem.next)) {
447
+ if (block.listData) block.listData.tight = false
448
+ break
449
+ }
450
+ subitem = subitem.next
451
+ }
452
+ item = item.next
453
+ }
454
+ if (block.lastChild) block.sourcepos[1] = block.lastChild.sourcepos[1]
455
+ },
456
+ canContain: (t) => 'item' === t,
457
+ acceptsLines: false,
458
+ verbatim: false,
459
+ },
460
+
461
+ block_quote: {
462
+ continue: (parser) => {
463
+ const ln = parser.currentLine
464
+ if (!parser.indented && C_GREATERTHAN === peek(ln, parser.nextNonspace)) {
465
+ parser.advanceNextNonspace()
466
+ parser.advanceOffset(1, false)
467
+ // §5.1: one optional space of indentation after `>`, tab-aware.
468
+ if (isSpaceOrTab(peek(ln, parser.offset))) parser.advanceOffset(1, true)
469
+ return 0
470
+ }
471
+ return 1
472
+ },
473
+ finalize: () => undefined,
474
+ canContain: notItem,
475
+ acceptsLines: false,
476
+ verbatim: false,
477
+ },
478
+
479
+ item: {
480
+ continue: (parser, container) => {
481
+ const data = container.listData
482
+ if (null === data) return 1
483
+
484
+ if (parser.blank) {
485
+ // An item whose first line is blank cannot be continued by a second
486
+ // blank line (§5.2 rule 3: at most one leading blank line).
487
+ if (null === container.firstChild) return 1
488
+ parser.advanceNextNonspace()
489
+ return 0
490
+ }
491
+
492
+ // §5.2: subsequent lines belong to the item if indented to the content
493
+ // column derived from the marker.
494
+ if (parser.indent >= data.markerOffset + data.padding) {
495
+ parser.advanceOffset(data.markerOffset + data.padding, true)
496
+ return 0
497
+ }
498
+ return 1
499
+ },
500
+ finalize: (_parser, block) => {
501
+ if (block.lastChild) {
502
+ block.sourcepos[1] = block.lastChild.sourcepos[1]
503
+ } else if (block.listData) {
504
+ block.sourcepos[1] = [
505
+ block.sourcepos[0][0],
506
+ block.listData.markerOffset + block.listData.padding,
507
+ ]
508
+ }
509
+ },
510
+ canContain: notItem,
511
+ acceptsLines: false,
512
+ verbatim: false,
513
+ },
514
+
515
+ heading: {
516
+ // A heading is always exactly one line (setext headings are converted from
517
+ // an already-complete paragraph).
518
+ continue: () => 1,
519
+ finalize: () => undefined,
520
+ canContain: noChildren,
521
+ acceptsLines: false,
522
+ verbatim: false,
523
+ },
524
+
525
+ thematic_break: {
526
+ continue: () => 1,
527
+ finalize: () => undefined,
528
+ canContain: noChildren,
529
+ acceptsLines: false,
530
+ verbatim: false,
531
+ },
532
+
533
+ code_block: {
534
+ continue: (parser, container) => {
535
+ const ln = parser.currentLine
536
+ const indent = parser.indent
537
+
538
+ if (container.isFenced) {
539
+ // §4.5: the closing fence is the same character, at least as long, at
540
+ // most 3 columns indented, and followed only by spaces/tabs.
541
+ const rest = ln.slice(parser.nextNonspace)
542
+ const match =
543
+ indent <= 3 && ln.charAt(parser.nextNonspace) === container.fenceChar
544
+ ? rest.match(RE_CLOSING_CODE_FENCE)
545
+ : null
546
+ if (match && match[0].length >= container.fenceLength) {
547
+ parser.lastLineLength = charCount(ln.slice(0, parser.offset + indent + match[0].length))
548
+ parser.finalize(container, parser.lineNumber)
549
+ return 2
550
+ }
551
+ // Content is de-indented by up to the opening fence's own indent.
552
+ let i = container.fenceOffset
553
+ while (0 < i && isSpaceOrTab(peek(ln, parser.offset))) {
554
+ parser.advanceOffset(1, true)
555
+ i--
556
+ }
557
+ return 0
558
+ }
559
+
560
+ // §4.4 indented code: four columns, or a blank line (which is kept).
561
+ if (indent >= CODE_INDENT) {
562
+ parser.advanceOffset(CODE_INDENT, true)
563
+ return 0
564
+ }
565
+ if (parser.blank) {
566
+ parser.advanceNextNonspace()
567
+ return 0
568
+ }
569
+ return 1
570
+ },
571
+ finalize: (_parser, block) => {
572
+ if (block.isFenced) {
573
+ // The first accumulated line is the info string, not content.
574
+ const content = block.stringContent
575
+ const nl = content.indexOf('\n')
576
+ const firstLine = -1 === nl ? content : content.slice(0, nl)
577
+ block.info = unescapeString(firstLine.trim())
578
+ block.literal = -1 === nl ? '' : content.slice(nl + 1)
579
+ } else {
580
+ // §4.4: trailing blank lines are not part of an indented code block.
581
+ block.literal = block.stringContent.replace(/(\n *)+$/, '\n')
582
+ }
583
+ block.stringContent = ''
584
+ },
585
+ canContain: noChildren,
586
+ acceptsLines: true,
587
+ verbatim: true,
588
+ },
589
+
590
+ html_block: {
591
+ continue: (parser, container) => {
592
+ // Types 6 and 7 end at a blank line; 1–5 end on their closing string,
593
+ // which `incorporateLine` checks after adding the line.
594
+ const type = parser.htmlBlockType(container)
595
+ return parser.blank && (6 === type || 7 === type) ? 1 : 0
596
+ },
597
+ finalize: (_parser, block) => {
598
+ block.literal = block.stringContent.replace(/(\n *)+$/, '')
599
+ block.stringContent = ''
600
+ },
601
+ canContain: noChildren,
602
+ acceptsLines: true,
603
+ verbatim: true,
604
+ },
605
+
606
+ paragraph: {
607
+ continue: (parser) => (parser.blank ? 1 : 0),
608
+ finalize: (parser, block) => {
609
+ // §4.7: a paragraph may begin with link reference definitions. Consume
610
+ // them from the front; if nothing is left, the paragraph disappears.
611
+ let hasReferenceDefs = false
612
+ let pos = 0
613
+ while (
614
+ C_OPEN_BRACKET === block.stringContent.charCodeAt(0) &&
615
+ 0 !== (pos = parseReference(block.stringContent, parser.refmap))
616
+ ) {
617
+ block.stringContent = block.stringContent.slice(pos)
618
+ hasReferenceDefs = true
619
+ }
620
+ if (hasReferenceDefs && isBlank(block.stringContent)) block.unlink()
621
+ },
622
+ canContain: noChildren,
623
+ acceptsLines: true,
624
+ verbatim: false,
625
+ },
626
+
627
+ // GFM tables. The header row is built when the table opens (see
628
+ // `tryOpenTable`); every later line is accumulated raw and split into a body
629
+ // row at finalize, so the table behaves like a paragraph that happens to
630
+ // print as a grid — it ends at a blank line, and any block start interrupts
631
+ // it, because `verbatim` is false and `canContain` refuses everything the
632
+ // block starts can open.
633
+ table: {
634
+ continue: (parser) => (parser.blank ? 1 : 0),
635
+ finalize: (parser, block) => finalizeTable(parser, block),
636
+ canContain: (t) => 'table_row' === t,
637
+ acceptsLines: true,
638
+ verbatim: false,
639
+ },
640
+
641
+ // Rows and cells are created closed, and are never the parser's tip, so
642
+ // nothing here is reached in a normal parse. They exist so that `handlerFor`
643
+ // — which throws for an unknown type — cannot be the way a malformed tree
644
+ // surfaces.
645
+ table_row: {
646
+ continue: () => 1,
647
+ finalize: () => undefined,
648
+ canContain: (t) => 'table_cell' === t,
649
+ acceptsLines: false,
650
+ verbatim: false,
651
+ },
652
+
653
+ table_cell: {
654
+ continue: () => 1,
655
+ finalize: () => undefined,
656
+ canContain: noChildren,
657
+ acceptsLines: false,
658
+ verbatim: false,
659
+ },
660
+ }
661
+
662
+ /**
663
+ * Append one row of `cells` to `table`, padded with empty cells or truncated
664
+ * so that it is exactly as wide as the delimiter row said.
665
+ *
666
+ * Rows and cells are built closed: they are not part of the open spine, and
667
+ * the line walk in `incorporateLine` must stop at the table itself.
668
+ */
669
+ function addTableRow(
670
+ parser: BlockParser,
671
+ table: MdNode,
672
+ cells: string[],
673
+ isHeader: boolean,
674
+ lineNumber: number,
675
+ endColumn: number,
676
+ ): void {
677
+ const width = table.tableAlign?.length ?? 0
678
+
679
+ // See MAX_AUTOCOMPLETED_CELLS: padding is the only unbounded part of a table.
680
+ let limit = width
681
+ if (cells.length < width) {
682
+ const budget = MAX_AUTOCOMPLETED_CELLS - parser.autocompletedCells
683
+ const padding = width - cells.length
684
+ if (padding > budget) limit = cells.length + (0 < budget ? budget : 0)
685
+ parser.autocompletedCells += limit - cells.length
686
+ }
687
+
688
+ // A row and its cells occupy one source line. Columns within that line are
689
+ // not tracked: nothing reads them (the AST drops sourcepos and the renderer
690
+ // ignores it), and threading offsets through the cell split to invent them
691
+ // would be cost with no reader. Each node needs its own array — sourcepos is
692
+ // mutable — so the span is rebuilt rather than shared.
693
+ const span = (): SourcePos => [
694
+ [lineNumber, 1],
695
+ [lineNumber, endColumn],
696
+ ]
697
+
698
+ const row = new MdNode('table_row', span())
699
+ row.isHeaderRow = isHeader
700
+ row.open = false
701
+ table.appendChild(row)
702
+
703
+ for (let i = 0; i < limit; i++) {
704
+ const cell = new MdNode('table_cell', span())
705
+ cell.open = false
706
+ // Consumed by the inline phase, exactly as a paragraph's content is.
707
+ cell.stringContent = i < cells.length ? cells[i] : ''
708
+ row.appendChild(cell)
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Close a table: turn the raw lines it accumulated into body rows.
714
+ *
715
+ * The first accumulated line is the delimiter row that opened the table. It
716
+ * carries no data — its alignments are already on the node — so it is skipped
717
+ * here, which is also what keeps the source line numbering of the rows below
718
+ * it straightforward: the table accepts exactly one line per source line, and
719
+ * a blank line closes it, so there are no gaps.
720
+ */
721
+ function finalizeTable(parser: BlockParser, table: MdNode): void {
722
+ const lines = table.stringContent.split('\n')
723
+ table.stringContent = ''
724
+
725
+ // Line 0 is the delimiter row; the last element is the empty string after
726
+ // the final newline.
727
+ const headerLine = table.sourcepos[0][0]
728
+ for (let i = 1; i < lines.length; i++) {
729
+ const line = lines[i]
730
+ if ('' === line) continue
731
+ addTableRow(parser, table, splitTableRow(line), false, headerLine + i + 1, charCount(line))
732
+ }
733
+ }
734
+
735
+ function handlerFor(type: NodeType): BlockHandler {
736
+ const handler: BlockHandler | undefined = BLOCK_HANDLERS[type]
737
+ if (undefined === handler) throw new Error('markdown: no block handler for ' + type)
738
+ return handler
739
+ }
740
+
741
+ // --- list markers (§5.2) ----------------------------------------------------
742
+
743
+ /**
744
+ * Parse a list marker at `nextNonspace` and derive the item's content indent.
745
+ *
746
+ * The content indent is marker width + the spaces that follow it, with two
747
+ * exceptions that both fall back to marker width + 1:
748
+ *
749
+ * - rule 2, item starting with indented code: 5+ spaces after the marker
750
+ * means only the first space belongs to the marker, the rest is code;
751
+ * - rule 3, item starting with a blank line: nothing follows the marker at
752
+ * all, so there are no spaces to measure.
753
+ *
754
+ * Returns null when this is not a list item start. Advances the parser to the
755
+ * item's content column when it is.
756
+ */
757
+ function parseListMarker(parser: BlockParser, container: MdNode): ListData | null {
758
+ // Four columns of indentation is indented code, never a list marker.
759
+ if (parser.indent >= CODE_INDENT) return null
760
+
761
+ const rest = parser.currentLine.slice(parser.nextNonspace)
762
+
763
+ const data: ListData = {
764
+ type: 'bullet',
765
+ tight: true, // lists are tight until finalize proves otherwise
766
+ start: 1,
767
+ delimiter: '',
768
+ bulletChar: '',
769
+ padding: 0,
770
+ markerOffset: parser.indent,
771
+ }
772
+
773
+ let match = rest.match(RE_BULLET_LIST_MARKER)
774
+ if (match) {
775
+ data.type = 'bullet'
776
+ data.bulletChar = match[0][0]
777
+ } else {
778
+ match = rest.match(RE_ORDERED_LIST_MARKER)
779
+ // §5.2 exception 1(b): an ordered item interrupting a paragraph must start at 1.
780
+ if (null === match || ('paragraph' === container.type && 1 !== parseInt(match[1], 10))) {
781
+ return null
782
+ }
783
+ data.type = 'ordered'
784
+ data.start = parseInt(match[1], 10)
785
+ data.delimiter = match[2]
786
+ }
787
+
788
+ // At least one space or tab (or the end of the line) must follow the marker.
789
+ let nextc = peek(parser.currentLine, parser.nextNonspace + match[0].length)
790
+ if (!(-1 === nextc || C_TAB === nextc || C_SPACE === nextc)) return null
791
+
792
+ // §5.2 exception 1(a): an item interrupting a paragraph may not start blank.
793
+ if (
794
+ 'paragraph' === container.type &&
795
+ !RE_NON_SPACE.test(parser.currentLine.slice(parser.nextNonspace + match[0].length))
796
+ ) {
797
+ return null
798
+ }
799
+
800
+ parser.advanceNextNonspace() // to the marker
801
+ parser.advanceOffset(match[0].length, true) // past the marker
802
+
803
+ const spacesStartCol = parser.column
804
+ const spacesStartOffset = parser.offset
805
+ do {
806
+ parser.advanceOffset(1, true)
807
+ nextc = peek(parser.currentLine, parser.offset)
808
+ } while (parser.column - spacesStartCol < 5 && isSpaceOrTab(nextc))
809
+
810
+ const blankItem = -1 === peek(parser.currentLine, parser.offset)
811
+ const spacesAfterMarker = parser.column - spacesStartCol
812
+
813
+ if (spacesAfterMarker >= 5 || spacesAfterMarker < 1 || blankItem) {
814
+ data.padding = match[0].length + 1
815
+ parser.column = spacesStartCol
816
+ parser.offset = spacesStartOffset
817
+ if (isSpaceOrTab(peek(parser.currentLine, parser.offset))) parser.advanceOffset(1, true)
818
+ } else {
819
+ data.padding = match[0].length + spacesAfterMarker
820
+ }
821
+
822
+ return data
823
+ }
824
+
825
+ // --- block starts -----------------------------------------------------------
826
+
827
+ /** 0 = no match, 1 = container started, 2 = leaf started (stop looking). */
828
+ type StartResult = 0 | 1 | 2
829
+
830
+ type BlockStart = (parser: BlockParser, container: MdNode) => StartResult
831
+
832
+ /**
833
+ * Tried in order, and the order is the spec's: a setext underline beats a
834
+ * thematic break (so `Foo\n---` is a heading), a thematic break beats a list
835
+ * item (so `- - -` is a break), and indented code comes last because any of
836
+ * the others may be indented up to three columns.
837
+ */
838
+ const BLOCK_STARTS: BlockStart[] = [
839
+ // Block quote (§5.1).
840
+ (parser) => {
841
+ if (!parser.indented && C_GREATERTHAN === peek(parser.currentLine, parser.nextNonspace)) {
842
+ parser.advanceNextNonspace()
843
+ parser.advanceOffset(1, false)
844
+ if (isSpaceOrTab(peek(parser.currentLine, parser.offset))) parser.advanceOffset(1, true)
845
+ parser.closeUnmatchedBlocks()
846
+ parser.addChild('block_quote', parser.nextNonspace)
847
+ return 1
848
+ }
849
+ return 0
850
+ },
851
+
852
+ // ATX heading (§4.2).
853
+ (parser) => {
854
+ if (parser.indented) return 0
855
+ const match = parser.currentLine.slice(parser.nextNonspace).match(RE_ATX_HEADING_MARKER)
856
+ if (null === match) return 0
857
+
858
+ parser.advanceNextNonspace()
859
+ parser.advanceOffset(match[0].length, false)
860
+ parser.closeUnmatchedBlocks()
861
+
862
+ const container = parser.addChild('heading', parser.nextNonspace)
863
+ container.level = match[0].replace(/[ \t]+$/, '').length
864
+ // An optional closing sequence of `#`s, preceded by spaces or tabs, is
865
+ // dropped; a line of nothing but `#`s has empty content.
866
+ container.stringContent = parser.currentLine
867
+ .slice(parser.offset)
868
+ .replace(/^[ \t]*#+[ \t]*$/, '')
869
+ .replace(/[ \t]+#+[ \t]*$/, '')
870
+ parser.advanceOffset(parser.currentLine.length - parser.offset, false)
871
+ return 2
872
+ },
873
+
874
+ // Fenced code block (§4.5).
875
+ (parser) => {
876
+ if (parser.indented) return 0
877
+ const match = parser.currentLine.slice(parser.nextNonspace).match(RE_CODE_FENCE)
878
+ if (null === match) return 0
879
+
880
+ const fenceLength = match[0].length
881
+ parser.closeUnmatchedBlocks()
882
+ const container = parser.addChild('code_block', parser.nextNonspace)
883
+ container.isFenced = true
884
+ container.fenceLength = fenceLength
885
+ container.fenceChar = match[0][0]
886
+ container.fenceOffset = parser.indent
887
+ parser.advanceNextNonspace()
888
+ parser.advanceOffset(fenceLength, false)
889
+ // The rest of the line is added as the block's first line: the info string.
890
+ return 2
891
+ },
892
+
893
+ // HTML block (§4.6), types 1-7.
894
+ (parser, container) => {
895
+ if (parser.indented || C_LESSTHAN !== peek(parser.currentLine, parser.nextNonspace)) return 0
896
+
897
+ const s = parser.currentLine.slice(parser.nextNonspace)
898
+ for (let blockType = 1; blockType <= 7; blockType++) {
899
+ if (!RE_HTML_BLOCK_OPEN[blockType].test(s)) continue
900
+ // Type 7 may not interrupt a paragraph — including a paragraph we are
901
+ // only about to continue lazily.
902
+ if (
903
+ 7 === blockType &&
904
+ ('paragraph' === container.type ||
905
+ (!parser.allClosed && !parser.blank && 'paragraph' === parser.openTip.type))
906
+ ) {
907
+ continue
908
+ }
909
+ parser.closeUnmatchedBlocks()
910
+ // The offset is deliberately not advanced: leading spaces are part of
911
+ // the raw HTML.
912
+ const block = parser.addChild('html_block', parser.offset)
913
+ parser.setHtmlBlockType(block, blockType)
914
+ return 2
915
+ }
916
+ return 0
917
+ },
918
+
919
+ // Setext heading underline (§4.3) — converts the whole open paragraph.
920
+ (parser, container) => {
921
+ if (parser.indented || 'paragraph' !== container.type) return 0
922
+ const match = parser.currentLine.slice(parser.nextNonspace).match(RE_SETEXT_HEADING_LINE)
923
+ if (null === match) return 0
924
+
925
+ parser.closeUnmatchedBlocks()
926
+
927
+ // §4.7: the paragraph may still have led with reference definitions; only
928
+ // what remains becomes the heading. If nothing remains this is not a
929
+ // setext underline at all (it falls through to thematic break / paragraph).
930
+ let pos = 0
931
+ while (
932
+ C_OPEN_BRACKET === container.stringContent.charCodeAt(0) &&
933
+ 0 !== (pos = parseReference(container.stringContent, parser.refmap))
934
+ ) {
935
+ container.stringContent = container.stringContent.slice(pos)
936
+ }
937
+ if (0 === container.stringContent.length) return 0
938
+
939
+ const start = container.sourcepos[0]
940
+ const heading = new MdNode('heading', [[start[0], start[1]], [0, 0]])
941
+ heading.level = '=' === match[0][0] ? 1 : 2
942
+ heading.stringContent = container.stringContent
943
+ container.insertAfter(heading)
944
+ container.unlink()
945
+ parser.tip = heading
946
+ parser.advanceOffset(parser.currentLine.length - parser.offset, false)
947
+ return 2
948
+ },
949
+
950
+ // Thematic break (§4.1).
951
+ (parser) => {
952
+ if (parser.indented) return 0
953
+ if (!RE_THEMATIC_BREAK.test(parser.currentLine.slice(parser.nextNonspace))) return 0
954
+ parser.closeUnmatchedBlocks()
955
+ parser.addChild('thematic_break', parser.nextNonspace)
956
+ parser.advanceOffset(parser.currentLine.length - parser.offset, false)
957
+ return 2
958
+ },
959
+
960
+ // List item (§5.2). `parseListMarker` rejects a marker indented four or more
961
+ // columns on its own, so there is no `indented` test here.
962
+ (parser, container) => {
963
+ const data = parseListMarker(parser, container)
964
+ if (null === data) return 0
965
+
966
+ parser.closeUnmatchedBlocks()
967
+
968
+ // §5.3: a change of bullet character or ordered delimiter starts a new list.
969
+ const tip = parser.openTip
970
+ if ('list' !== tip.type || null === tip.listData || !listsMatch(tip.listData, data)) {
971
+ parser.addChild('list', parser.nextNonspace).listData = data
972
+ }
973
+
974
+ parser.addChild('item', parser.nextNonspace).listData = data
975
+ return 1
976
+ },
977
+
978
+ // Indented code block (§4.4) — may not interrupt a paragraph.
979
+ (parser) => {
980
+ if (!parser.indented || 'paragraph' === parser.openTip.type || parser.blank) return 0
981
+ parser.advanceOffset(CODE_INDENT, true)
982
+ parser.closeUnmatchedBlocks()
983
+ parser.addChild('code_block', parser.offset)
984
+ return 2
985
+ },
986
+
987
+ // GFM table (extension) — a delimiter row directly under an open paragraph.
988
+ //
989
+ // Last, and the position is load-bearing in both directions. It must come
990
+ // after the setext underline so that `foo` over `---` stays an `<h2>` rather
991
+ // than becoming a one-column table, and after the list marker so that `- | -`
992
+ // stays a list item; cmark-gfm reaches its extensions from the same place,
993
+ // once every built-in start has refused the line.
994
+ (parser, container) => (parser.tryOpenTable(container) ? 2 : 0),
995
+ ]
996
+
997
+ // --- the parser -------------------------------------------------------------
998
+
999
+ class BlockParser {
1000
+ options: ParserOptions
1001
+ refmap: RefMap = {}
1002
+
1003
+ doc: MdNode
1004
+ /** Deepest open block; null only once the document itself is finalized. */
1005
+ tip: MdNode | null
1006
+ /** The tip as it was before the current line was walked. */
1007
+ oldtip: MdNode
1008
+ lastMatchedContainer: MdNode
1009
+ /** False while some open block failed its continuation condition. */
1010
+ allClosed = true
1011
+
1012
+ currentLine = ''
1013
+ lineNumber = 0
1014
+ lastLineLength = 0
1015
+
1016
+ /**
1017
+ * The length of the line before the one `lastLineLength` measures.
1018
+ *
1019
+ * `finalize` dates a block's end from `lastLineLength`, which is right for
1020
+ * every block that ends on the line before the current one. A GFM table
1021
+ * splits its paragraph *two* lines back — the header row is the previous
1022
+ * line and stays with the table, so the paragraph left behind ends on the
1023
+ * line before that — and needs this one instead.
1024
+ */
1025
+ prevLineLength = 0
1026
+
1027
+ // Memoize the last offset in `currentLine` whose character count is known,
1028
+ // so sourcepos columns cost amortized O(1). Both reset per line.
1029
+ // See `sourceColumn`.
1030
+ colAnchorOffset = 0
1031
+ colAnchorChars = 0
1032
+
1033
+ /** UTF-16 index into `currentLine`. */
1034
+ offset = 0
1035
+ /** Tab-expanded display column matching `offset` (§2.2). */
1036
+ column = 0
1037
+ /** True when `offset` sits inside a tab whose columns were partly consumed. */
1038
+ partiallyConsumedTab = false
1039
+
1040
+ nextNonspace = 0
1041
+ nextNonspaceColumn = 0
1042
+ /** Columns between `column` and the next non-space character. */
1043
+ indent = 0
1044
+ indented = false
1045
+ blank = false
1046
+
1047
+ /** Empty cells inserted so far to pad short table rows. See MAX_AUTOCOMPLETED_CELLS. */
1048
+ autocompletedCells = 0
1049
+
1050
+ /**
1051
+ * The text `addLine` last appended, the column it started in, and the block
1052
+ * it went to.
1053
+ *
1054
+ * `tryOpenTable` needs the *last line* of an open paragraph, and reading it
1055
+ * back out of `stringContent` is not affordable: that content is built by
1056
+ * repeated `+=`, so every index into it flattens a fresh rope — O(n) per
1057
+ * line, O(n²) over a paragraph whose every other line looks like a delimiter
1058
+ * row, which untrusted input can write. Keeping the line itself here costs
1059
+ * two field writes and never touches the accumulated string.
1060
+ *
1061
+ * The node is recorded alongside it because "the previous line went to this
1062
+ * very paragraph" is the extension's own rule, not a cache-validity trick:
1063
+ * a header row is by definition the line immediately above the delimiter
1064
+ * row. If some other block took that line, there is no header row.
1065
+ *
1066
+ * The column is what a table's sourcepos starts at: a table opens on its
1067
+ * header row, which is a line already consumed by the time the delimiter row
1068
+ * identifies it, so its column has to have been recorded when it went past.
1069
+ */
1070
+ lastAddedLine = ''
1071
+ lastAddedColumn = 1
1072
+ lastAddedTo: MdNode | null = null
1073
+
1074
+ /** Step 2's fast reject, widened for a table's delimiter row when `gfm` is on. */
1075
+ readonly maybeSpecial: RegExp
1076
+
1077
+ // MdNode has no field for the HTML block type, and the tree contract is not
1078
+ // ours to change, so open HTML blocks carry it here.
1079
+ private htmlBlockTypes = new Map<MdNode, number>()
1080
+
1081
+ constructor(options: ParserOptions) {
1082
+ this.options = options
1083
+ this.maybeSpecial = options.gfm ? RE_MAYBE_SPECIAL_GFM : RE_MAYBE_SPECIAL
1084
+ this.doc = new MdNode('document', [
1085
+ [1, 1],
1086
+ [0, 0],
1087
+ ])
1088
+ this.tip = this.doc
1089
+ this.oldtip = this.doc
1090
+ this.lastMatchedContainer = this.doc
1091
+ }
1092
+
1093
+ /** The deepest open block. Only valid while a line is being processed. */
1094
+ get openTip(): MdNode {
1095
+ if (null === this.tip) throw new Error('markdown: no open block')
1096
+ return this.tip
1097
+ }
1098
+
1099
+ htmlBlockType(block: MdNode): number {
1100
+ return this.htmlBlockTypes.get(block) ?? 0
1101
+ }
1102
+
1103
+ setHtmlBlockType(block: MdNode, type: number): void {
1104
+ this.htmlBlockTypes.set(block, type)
1105
+ }
1106
+
1107
+ /**
1108
+ * Advance `count` characters (`columns` false) or `count` columns (`columns`
1109
+ * true), expanding tabs to the next 4-column tab stop (§2.2). A tab whose
1110
+ * columns are only partly consumed leaves `offset` on the tab itself and
1111
+ * sets `partiallyConsumedTab`, so `addLine` can re-emit the remainder as
1112
+ * spaces.
1113
+ */
1114
+ advanceOffset(count: number, columns: boolean): void {
1115
+ const ln = this.currentLine
1116
+ while (0 < count && this.offset < ln.length) {
1117
+ const c = ln.charAt(this.offset)
1118
+ if ('\t' === c) {
1119
+ const charsToTab = 4 - (this.column % 4)
1120
+ if (columns) {
1121
+ this.partiallyConsumedTab = charsToTab > count
1122
+ const charsToAdvance = charsToTab > count ? count : charsToTab
1123
+ this.column += charsToAdvance
1124
+ this.offset += this.partiallyConsumedTab ? 0 : 1
1125
+ count -= charsToAdvance
1126
+ } else {
1127
+ this.partiallyConsumedTab = false
1128
+ this.column += charsToTab
1129
+ this.offset += 1
1130
+ count -= 1
1131
+ }
1132
+ } else {
1133
+ this.partiallyConsumedTab = false
1134
+ this.offset += 1
1135
+ this.column += 1 // block markers are ASCII, so one char is one column
1136
+ count -= 1
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ advanceNextNonspace(): void {
1142
+ this.offset = this.nextNonspace
1143
+ this.column = this.nextNonspaceColumn
1144
+ this.partiallyConsumedTab = false
1145
+ }
1146
+
1147
+ /** Locate the next non-space character and the indent leading up to it. */
1148
+ findNextNonspace(): void {
1149
+ const ln = this.currentLine
1150
+ let i = this.offset
1151
+ let cols = this.column
1152
+ let c = ''
1153
+
1154
+ for (;;) {
1155
+ c = ln.charAt(i)
1156
+ if (' ' === c) {
1157
+ i++
1158
+ cols++
1159
+ } else if ('\t' === c) {
1160
+ i++
1161
+ cols += 4 - (cols % 4)
1162
+ } else {
1163
+ break
1164
+ }
1165
+ }
1166
+
1167
+ this.blank = '' === c || '\n' === c || '\r' === c
1168
+ this.nextNonspace = i
1169
+ this.nextNonspaceColumn = cols
1170
+ this.indent = this.nextNonspaceColumn - this.column
1171
+ this.indented = this.indent >= CODE_INDENT
1172
+ }
1173
+
1174
+ /** Add the rest of the current line to the tip's raw content. */
1175
+ addLine(): void {
1176
+ const tip = this.openTip
1177
+ // Taken before the tab fixup below moves `offset`, so a line's recorded
1178
+ // column is the one `addChild` would report for that same position.
1179
+ const column = this.sourceColumn(this.offset) + 1 // offset 0 is column 1
1180
+ let text = ''
1181
+ if (this.partiallyConsumedTab) {
1182
+ this.offset += 1 // step over the tab
1183
+ const charsToTab = 4 - (this.column % 4)
1184
+ text = ' '.repeat(charsToTab)
1185
+ }
1186
+ text += this.currentLine.slice(this.offset)
1187
+ tip.stringContent += text + '\n'
1188
+ this.lastAddedLine = text
1189
+ this.lastAddedColumn = column
1190
+ this.lastAddedTo = tip
1191
+ }
1192
+
1193
+ /**
1194
+ * Open a block of `tag` as a child of the tip, closing blocks that cannot
1195
+ * contain it first.
1196
+ */
1197
+ /**
1198
+ * Characters of `currentLine` before UTF-16 offset `offset` — what a
1199
+ * sourcepos column counts.
1200
+ *
1201
+ * Not simply `offset`: a JavaScript string index counts UTF-16 units, so an
1202
+ * astral character (emoji, rare CJK) would advance the column by two. A
1203
+ * column means a character, so `\u{1F600} *x*` must report the same columns
1204
+ * in both runtimes.
1205
+ *
1206
+ * Counted incrementally from the last offset already measured. `addChild`
1207
+ * runs once per container opened, so counting from the start of the line
1208
+ * every time would make a line of n nested containers — `> - > - …` — cost
1209
+ * O(n²); untrusted input picks n. Offsets advance monotonically as a line is
1210
+ * consumed, making this amortized O(1); a smaller offset than the anchor is
1211
+ * still correct, just recounted from the start.
1212
+ */
1213
+ sourceColumn(offset: number): number {
1214
+ if (offset < this.colAnchorOffset) {
1215
+ this.colAnchorOffset = 0
1216
+ this.colAnchorChars = 0
1217
+ }
1218
+ this.colAnchorChars += charCount(this.currentLine.slice(this.colAnchorOffset, offset))
1219
+ this.colAnchorOffset = offset
1220
+ return this.colAnchorChars
1221
+ }
1222
+
1223
+ addChild(tag: NodeType, offset: number): MdNode {
1224
+ while (!handlerFor(this.openTip.type).canContain(tag)) {
1225
+ this.finalize(this.openTip, this.lineNumber - 1)
1226
+ }
1227
+ const columnNumber = this.sourceColumn(offset) + 1 // offset 0 is column 1
1228
+ const node = new MdNode(tag, [
1229
+ [this.lineNumber, columnNumber],
1230
+ [0, 0],
1231
+ ])
1232
+ this.openTip.appendChild(node)
1233
+ this.tip = node
1234
+ return node
1235
+ }
1236
+
1237
+ /**
1238
+ * GFM tables: try to read the current line as the delimiter row of a table
1239
+ * whose header row is the last line of the open paragraph `container`.
1240
+ *
1241
+ * The two rows must agree on the number of cells; if they do not, there is
1242
+ * no table and the line is nothing special (spec example 6 keeps the whole
1243
+ * thing a paragraph). The header row is the paragraph's **last** line only,
1244
+ * so a paragraph that had earlier lines is split: those lines stay a
1245
+ * paragraph — finalized here, which is what still lets them contribute link
1246
+ * reference definitions — and the table is opened after it.
1247
+ *
1248
+ * On success the table is the tip and the caller returns "leaf started", so
1249
+ * step 3 adds the delimiter row itself to the table's raw content. It is
1250
+ * skipped again by `finalizeTable`; keeping it costs nothing and makes the
1251
+ * table's accumulated lines line up one-for-one with the source lines.
1252
+ */
1253
+ tryOpenTable(container: MdNode): boolean {
1254
+ if (!this.options.gfm || this.indented || 'paragraph' !== container.type) return false
1255
+
1256
+ const align = parseDelimiterRow(this.currentLine.slice(this.nextNonspace))
1257
+ if (null === align) return false
1258
+
1259
+ // The header row is the line directly above, which is the line `addLine`
1260
+ // last wrote — to this paragraph, or there is no header row at all.
1261
+ if (this.lastAddedTo !== container) return false
1262
+ const headerCells = splitTableRow(this.lastAddedLine)
1263
+ if (headerCells.length !== align.length) return false
1264
+
1265
+ this.closeUnmatchedBlocks()
1266
+
1267
+ const headerLine = this.lineNumber - 1
1268
+ const headerColumn = this.lastAddedColumn
1269
+ // `stringContent` ends with that same line plus the newline `addLine` put
1270
+ // after it, so the header row starts this far in — no scan needed, and the
1271
+ // content is only ever touched on the split branch, at most once per table.
1272
+ const content = container.stringContent
1273
+ const headerStart = content.length - this.lastAddedLine.length - 1
1274
+ if (0 < headerStart) {
1275
+ // Split: everything above the header row stays a paragraph. It ends two
1276
+ // lines back, not one, so `finalize`'s default end column — the previous
1277
+ // line's, which here is the header row's — would be somebody else's.
1278
+ container.stringContent = content.slice(0, headerStart)
1279
+ this.finalize(container, headerLine - 1, this.prevLineLength)
1280
+ } else {
1281
+ const parent = container.parent
1282
+ container.unlink()
1283
+ this.tip = parent
1284
+ }
1285
+
1286
+ const table = this.addChild('table', this.nextNonspace)
1287
+ // `addChild` both dated and placed the table from the delimiter row, and
1288
+ // neither belongs to it: the table begins on the header row, one line
1289
+ // above, in the column where *that* row's content starts. The two rows are
1290
+ // indented independently, so the delimiter row's column is simply somebody
1291
+ // else's — ` a | b` over `| - | -` starts in column 3, and `a | b` over
1292
+ // ` | - | -` starts in column 1.
1293
+ table.sourcepos[0][0] = headerLine
1294
+ table.sourcepos[0][1] = headerColumn
1295
+ table.tableAlign = align
1296
+
1297
+ // The row is measured over its own text, exactly as `finalizeTable`
1298
+ // measures the body rows: `lastLineLength` counts the whole source line,
1299
+ // container markers included, so inside a block quote it would end the
1300
+ // header row two columns past where every body row of the same table ends.
1301
+ addTableRow(this, table, headerCells, true, headerLine, charCount(this.lastAddedLine))
1302
+ return true
1303
+ }
1304
+
1305
+ /** Close every block that failed its continuation condition on this line. */
1306
+ closeUnmatchedBlocks(): void {
1307
+ if (this.allClosed) return
1308
+ while (this.oldtip !== this.lastMatchedContainer) {
1309
+ const parent = this.oldtip.parent
1310
+ this.finalize(this.oldtip, this.lineNumber - 1)
1311
+ if (null === parent) break
1312
+ this.oldtip = parent
1313
+ }
1314
+ this.allClosed = true
1315
+ }
1316
+
1317
+ /**
1318
+ * Close a block, record its end position, and run its finalizer.
1319
+ *
1320
+ * `endColumn` defaults to the previous line's length, which is where every
1321
+ * block that ends on `lineNumber` ends. Only a table's paragraph split
1322
+ * passes it: that one closes a block two lines back, where the default is
1323
+ * one line too recent.
1324
+ */
1325
+ finalize(block: MdNode, lineNumber: number, endColumn: number = this.lastLineLength): void {
1326
+ const above = block.parent
1327
+ block.open = false
1328
+ block.sourcepos[1] = [lineNumber, endColumn]
1329
+ handlerFor(block.type).finalize(this, block)
1330
+ this.htmlBlockTypes.delete(block)
1331
+ this.tip = above
1332
+ }
1333
+
1334
+ /**
1335
+ * Incorporate one line into the tree — the three steps of Appendix A.
1336
+ */
1337
+ incorporateLine(ln: string): void {
1338
+ let allMatched = true
1339
+ let container = this.doc
1340
+
1341
+ // `lastLineLength` still measures the previous line here. Captured before
1342
+ // anything can overwrite it — an HTML block closing mid-line does — and
1343
+ // installed as `prevLineLength` at whichever exit updates `lastLineLength`,
1344
+ // so the two always describe consecutive lines.
1345
+ const lengthBeforeThisLine = this.lastLineLength
1346
+
1347
+ this.oldtip = this.openTip
1348
+ this.offset = 0
1349
+ this.column = 0
1350
+ this.blank = false
1351
+ this.partiallyConsumedTab = false
1352
+ this.lineNumber += 1
1353
+ this.currentLine = ln
1354
+ // The column anchor memoizes offsets into `currentLine`; a new line
1355
+ // invalidates it. See `sourceColumn`.
1356
+ this.colAnchorOffset = 0
1357
+ this.colAnchorChars = 0
1358
+
1359
+ // Step 1: walk the open blocks, consuming continuation markers. Stop at
1360
+ // the first block that does not match; `container` is then the last
1361
+ // matched one.
1362
+ let lastChild = container.lastChild
1363
+ while (lastChild && lastChild.open) {
1364
+ container = lastChild
1365
+ this.findNextNonspace()
1366
+
1367
+ const result = handlerFor(container.type).continue(this, container)
1368
+ if (1 === result) {
1369
+ allMatched = false
1370
+ } else if (2 === result) {
1371
+ // A fenced code block was closed by this line; nothing else to do.
1372
+ return
1373
+ }
1374
+
1375
+ if (!allMatched) {
1376
+ const parent = container.parent
1377
+ if (parent) container = parent
1378
+ break
1379
+ }
1380
+
1381
+ lastChild = container.lastChild
1382
+ }
1383
+
1384
+ this.allClosed = container === this.oldtip
1385
+ this.lastMatchedContainer = container
1386
+
1387
+ // Step 2: look for new block starts under the last matched container —
1388
+ // unless its lines are literal text, in which case there is nothing to
1389
+ // look for.
1390
+ let matchedLeaf = handlerFor(container.type).verbatim
1391
+
1392
+ while (!matchedLeaf) {
1393
+ this.findNextNonspace()
1394
+
1395
+ if (!this.indented && !this.maybeSpecial.test(ln.slice(this.nextNonspace))) {
1396
+ this.advanceNextNonspace()
1397
+ break
1398
+ }
1399
+
1400
+ let i = 0
1401
+ while (i < BLOCK_STARTS.length) {
1402
+ const result = BLOCK_STARTS[i](this, container)
1403
+ if (1 === result) {
1404
+ container = this.openTip
1405
+ break
1406
+ }
1407
+ if (2 === result) {
1408
+ container = this.openTip
1409
+ matchedLeaf = true
1410
+ break
1411
+ }
1412
+ i++
1413
+ }
1414
+
1415
+ if (i === BLOCK_STARTS.length) {
1416
+ // Nothing matched: the rest of the line is plain text.
1417
+ this.advanceNextNonspace()
1418
+ break
1419
+ }
1420
+ }
1421
+
1422
+ // Step 3: whatever is left of the line is text for the deepest open block.
1423
+ if (!this.allClosed && !this.blank && 'paragraph' === this.openTip.type) {
1424
+ // Lazy continuation (§5.1 rule 2): the markers are missing but an open
1425
+ // paragraph absorbs the line anyway, and its containers stay open.
1426
+ this.addLine()
1427
+ this.prevLineLength = lengthBeforeThisLine
1428
+ this.lastLineLength = charCount(ln)
1429
+ return
1430
+ }
1431
+
1432
+ this.closeUnmatchedBlocks()
1433
+
1434
+ if (this.blank && container.lastChild) container.lastChild.lastLineBlank = true
1435
+
1436
+ const t = container.type
1437
+
1438
+ // A block quote line is never blank (it has a `>`), blank lines inside
1439
+ // fenced code do not affect list looseness, and an item's own first blank
1440
+ // line is part of the item, not a separator.
1441
+ const lastLineBlank =
1442
+ this.blank &&
1443
+ !(
1444
+ 'block_quote' === t ||
1445
+ ('code_block' === t && container.isFenced) ||
1446
+ ('item' === t &&
1447
+ null === container.firstChild &&
1448
+ container.sourcepos[0][0] === this.lineNumber)
1449
+ )
1450
+
1451
+ // Propagate up: a container whose last line was blank ends with a blank line.
1452
+ let cont: MdNode | null = container
1453
+ while (cont) {
1454
+ cont.lastLineBlank = lastLineBlank
1455
+ cont = cont.parent
1456
+ }
1457
+
1458
+ if (handlerFor(t).acceptsLines) {
1459
+ this.addLine()
1460
+ // §4.6: HTML block types 1-5 end on the line that contains their closing
1461
+ // string, which may be the line that opened them.
1462
+ if ('html_block' === t) {
1463
+ const type = this.htmlBlockType(container)
1464
+ if (
1465
+ 1 <= type &&
1466
+ type <= 5 &&
1467
+ RE_HTML_BLOCK_CLOSE[type].test(this.currentLine.slice(this.offset))
1468
+ ) {
1469
+ this.lastLineLength = charCount(ln)
1470
+ this.finalize(container, this.lineNumber)
1471
+ }
1472
+ }
1473
+ } else if (this.offset < ln.length && !this.blank) {
1474
+ // Nowhere else for the text to go: open a paragraph for it.
1475
+ this.addChild('paragraph', this.offset)
1476
+ this.advanceNextNonspace()
1477
+ this.addLine()
1478
+ }
1479
+
1480
+ this.prevLineLength = lengthBeforeThisLine
1481
+ this.lastLineLength = charCount(ln)
1482
+ }
1483
+
1484
+ parse(input: string): { doc: MdNode; refmap: RefMap } {
1485
+ // §2.3: insecure characters are replaced before anything else looks at them.
1486
+ const text = -1 === input.indexOf('\u0000') ? input : input.replace(/\0/g, '\uFFFD')
1487
+
1488
+ const lines = text.split(RE_LINE_ENDING)
1489
+ let len = lines.length
1490
+ // A final line ending does not introduce a trailing blank line.
1491
+ if (0 < text.length && '' === lines[len - 1]) len -= 1
1492
+
1493
+ for (let i = 0; i < len; i++) {
1494
+ this.incorporateLine(lines[i])
1495
+ }
1496
+
1497
+ while (this.tip) {
1498
+ this.finalize(this.tip, len)
1499
+ }
1500
+
1501
+ this.doc.gfm = this.options.gfm
1502
+ if (this.options.gfm) markTaskListItems(this.doc)
1503
+
1504
+ return { doc: this.doc, refmap: this.refmap }
1505
+ }
1506
+ }
1507
+
1508
+ // --- GFM task list items ----------------------------------------------------
1509
+
1510
+ /**
1511
+ * The task list item marker: optional spaces, `[`, either a whitespace
1512
+ * character or `x`/`X`, `]`, and then at least one whitespace character before
1513
+ * any other content.
1514
+ *
1515
+ * The trailing whitespace is required by the extension ("…and at least one
1516
+ * whitespace character before any other content"), so `- [x]` with nothing
1517
+ * after it is an ordinary item whose text is `[x]`. It is matched as a space
1518
+ * or a tab rather than as any whitespace: a line ending there would mean the
1519
+ * content starts on the *next* line, and consuming it would swallow the
1520
+ * paragraph's first soft break.
1521
+ */
1522
+ const RE_TASK_LIST_MARKER = /^ *\[([ \tXx])\][ \t]+/
1523
+
1524
+ const TASK_LIST_CONTAINERS: Record<string, true> = {
1525
+ document: true,
1526
+ block_quote: true,
1527
+ list: true,
1528
+ item: true,
1529
+ }
1530
+
1531
+ /**
1532
+ * GFM task list items: mark every item whose first block is a paragraph
1533
+ * beginning with a task list item marker, and consume the marker so it does
1534
+ * not survive into the text.
1535
+ *
1536
+ * Run at the end of the block phase, over `stringContent` — before the inline
1537
+ * phase has seen it. That is what makes the marker win over anything the
1538
+ * inline scanner would otherwise make of the brackets: with a `[x]: /url`
1539
+ * definition in the document, `- [x] foo` is still a task item rather than a
1540
+ * link. It also means the item's `checked` state is settled before `ast.ts` or
1541
+ * `html.ts` looks at the tree.
1542
+ *
1543
+ * Iterative rather than recursive: container nesting depth is chosen by the
1544
+ * input, and `'> '.repeat(20000)` is a document this parser otherwise handles.
1545
+ */
1546
+ function markTaskListItems(doc: MdNode): void {
1547
+ const stack: MdNode[] = [doc]
1548
+
1549
+ while (0 < stack.length) {
1550
+ const node = stack.pop() as MdNode
1551
+
1552
+ if ('item' === node.type) {
1553
+ const first = node.firstChild
1554
+ if (null !== first && 'paragraph' === first.type) {
1555
+ const m = RE_TASK_LIST_MARKER.exec(first.stringContent)
1556
+ if (null !== m) {
1557
+ // Whitespace between the brackets is unchecked; `x`/`X` is checked.
1558
+ node.checked = ' ' !== m[1] && '\t' !== m[1]
1559
+ first.stringContent = first.stringContent.slice(m[0].length)
1560
+ }
1561
+ }
1562
+ }
1563
+
1564
+ if (true === TASK_LIST_CONTAINERS[node.type]) {
1565
+ for (let c = node.lastChild; c; c = c.prev) stack.push(c)
1566
+ }
1567
+ }
1568
+ }
1569
+
1570
+ /**
1571
+ * Phase 1: build the block tree and collect link reference definitions.
1572
+ * Paragraph and heading text is left raw in `stringContent` for phase 2.
1573
+ */
1574
+ export function parseBlocks(
1575
+ input: string,
1576
+ options: ParserOptions,
1577
+ ): { doc: MdNode; refmap: RefMap } {
1578
+ return new BlockParser(options).parse(input)
1579
+ }