@tabnas/markdown 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/block.ts ADDED
@@ -0,0 +1,1094 @@
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 } 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
+
48
+ /** §2.1: \r\n, \n and a lone \r are all line endings. */
49
+ const RE_LINE_ENDING = /\r\n|\n|\r/
50
+
51
+ /** §4.1: 3+ matching `*`, `_` or `-`, each optionally followed by spaces/tabs. */
52
+ const RE_THEMATIC_BREAK = /^(?:\*[ \t]*){3,}$|^(?:_[ \t]*){3,}$|^(?:-[ \t]*){3,}$/
53
+
54
+ /** Fast reject: every block start begins with one of these characters. */
55
+ const RE_MAYBE_SPECIAL = /^[#`~*+_=<>0-9-]/
56
+
57
+ const RE_NON_SPACE = /[^ \t\f\v\r\n]/
58
+
59
+ /** §5.2 list markers. */
60
+ const RE_BULLET_LIST_MARKER = /^[*+-]/
61
+ const RE_ORDERED_LIST_MARKER = /^(\d{1,9})([.)])/
62
+
63
+ /** §4.2: 1–6 `#` followed by a space, a tab, or the end of the line. */
64
+ const RE_ATX_HEADING_MARKER = /^#{1,6}(?:[ \t]+|$)/
65
+
66
+ /** §4.5: an info string after a backtick fence may not contain a backtick. */
67
+ const RE_CODE_FENCE = /^`{3,}(?!.*`)|^~{3,}/
68
+ const RE_CLOSING_CODE_FENCE = /^(?:`{3,}|~{3,})(?=[ \t]*$)/
69
+
70
+ /** §4.3: a run of `=` or `-` with only spaces/tabs after it. */
71
+ const RE_SETEXT_HEADING_LINE = /^(?:=+|-+)[ \t]*$/
72
+
73
+ // --- HTML blocks (§4.6) -----------------------------------------------------
74
+ //
75
+ // Seven kinds, each with its own start and end condition. Types 1–5 end on a
76
+ // line *containing* their closing string (checked in `incorporateLine` after
77
+ // the line is added); types 6 and 7 end at a blank line (checked in the
78
+ // html_block continuation condition). Type 7 alone may not interrupt a
79
+ // paragraph.
80
+
81
+ /** Tag-shape fragments shared by HTML block type 7 (§6.6 raw HTML). */
82
+ const TAGNAME = '[A-Za-z][A-Za-z0-9-]*'
83
+ const ATTRIBUTENAME = '[a-zA-Z_:][a-zA-Z0-9:._-]*'
84
+ const UNQUOTEDVALUE = "[^\"'=<>`\\x00-\\x20]+"
85
+ const SINGLEQUOTEDVALUE = "'[^']*'"
86
+ const DOUBLEQUOTEDVALUE = '"[^"]*"'
87
+ const ATTRIBUTEVALUE =
88
+ '(?:' + UNQUOTEDVALUE + '|' + SINGLEQUOTEDVALUE + '|' + DOUBLEQUOTEDVALUE + ')'
89
+ const ATTRIBUTEVALUESPEC = '(?:[ \\t]*=[ \\t]*' + ATTRIBUTEVALUE + ')'
90
+ const ATTRIBUTE = '(?:[ \\t]+' + ATTRIBUTENAME + ATTRIBUTEVALUESPEC + '?)'
91
+ const OPENTAG = '<' + TAGNAME + ATTRIBUTE + '*[ \\t]*/?>'
92
+ const CLOSETAG = '</' + TAGNAME + '[ \\t]*>'
93
+
94
+ /** The type 6 tag list, verbatim from §4.6 (0.31.2 has `search`, not `source`). */
95
+ const BLOCK_TAGS = [
96
+ 'address',
97
+ 'article',
98
+ 'aside',
99
+ 'base',
100
+ 'basefont',
101
+ 'blockquote',
102
+ 'body',
103
+ 'caption',
104
+ 'center',
105
+ 'col',
106
+ 'colgroup',
107
+ 'dd',
108
+ 'details',
109
+ 'dialog',
110
+ 'dir',
111
+ 'div',
112
+ 'dl',
113
+ 'dt',
114
+ 'fieldset',
115
+ 'figcaption',
116
+ 'figure',
117
+ 'footer',
118
+ 'form',
119
+ 'frame',
120
+ 'frameset',
121
+ 'h[1-6]',
122
+ 'head',
123
+ 'header',
124
+ 'hr',
125
+ 'html',
126
+ 'iframe',
127
+ 'legend',
128
+ 'li',
129
+ 'link',
130
+ 'main',
131
+ 'menu',
132
+ 'menuitem',
133
+ 'nav',
134
+ 'noframes',
135
+ 'ol',
136
+ 'optgroup',
137
+ 'option',
138
+ 'p',
139
+ 'param',
140
+ 'search',
141
+ 'section',
142
+ 'summary',
143
+ 'table',
144
+ 'tbody',
145
+ 'td',
146
+ 'tfoot',
147
+ 'th',
148
+ 'thead',
149
+ 'title',
150
+ 'tr',
151
+ 'track',
152
+ 'ul',
153
+ ].join('|')
154
+
155
+ /** Indexed by HTML block type 1–7; slot 0 is unused. */
156
+ const RE_HTML_BLOCK_OPEN: RegExp[] = [
157
+ /(?:)/,
158
+ /^<(?:script|pre|textarea|style)(?:[ \t]|>|$)/i,
159
+ /^<!--/,
160
+ /^<[?]/,
161
+ /^<![A-Za-z]/,
162
+ /^<!\[CDATA\[/,
163
+ new RegExp('^</?(?:' + BLOCK_TAGS + ')(?:[ \\t]|/?>|$)', 'i'),
164
+ new RegExp('^(?:' + OPENTAG + '|' + CLOSETAG + ')[ \\t]*$', 'i'),
165
+ ]
166
+
167
+ /** End conditions for types 1–5; types 6 and 7 end at a blank line instead. */
168
+ const RE_HTML_BLOCK_CLOSE: RegExp[] = [
169
+ /(?:)/,
170
+ /<\/(?:script|pre|textarea|style)>/i,
171
+ /-->/,
172
+ /\?>/,
173
+ />/,
174
+ /\]\]>/,
175
+ ]
176
+
177
+ // --- small lexical helpers --------------------------------------------------
178
+
179
+ /** Character code at `pos`, or -1 past the end of the line. */
180
+ function peek(ln: string, pos: number): number {
181
+ return 0 <= pos && pos < ln.length ? ln.charCodeAt(pos) : -1
182
+ }
183
+
184
+ /**
185
+ * Number of characters (code points) in `s`. `s.length` counts UTF-16 units,
186
+ * which double-counts astral characters; sourcepos columns count characters.
187
+ */
188
+ function charCount(s: string): number {
189
+ let n = 0
190
+ for (let i = 0; i < s.length; i++) {
191
+ const c = s.charCodeAt(i)
192
+ // Skip the low half of a surrogate pair; a lone surrogate still counts once.
193
+ if (0xd800 <= c && c <= 0xdbff && i + 1 < s.length) {
194
+ const d = s.charCodeAt(i + 1)
195
+ if (0xdc00 <= d && d <= 0xdfff) i++
196
+ }
197
+ n++
198
+ }
199
+ return n
200
+ }
201
+
202
+ function isSpaceOrTab(c: number): boolean {
203
+ return C_SPACE === c || C_TAB === c
204
+ }
205
+
206
+ function isBlank(s: string): boolean {
207
+ return !RE_NON_SPACE.test(s)
208
+ }
209
+
210
+ /** §5.3: two markers make the same list only if type, bullet and delimiter agree. */
211
+ function listsMatch(listData: ListData, itemData: ListData): boolean {
212
+ return (
213
+ listData.type === itemData.type &&
214
+ listData.delimiter === itemData.delimiter &&
215
+ listData.bulletChar === itemData.bulletChar
216
+ )
217
+ }
218
+
219
+ /**
220
+ * §5.3 looseness input: does this block end with a blank line? For lists and
221
+ * items the question recurses into the last child, since the blank line is
222
+ * recorded on the deepest block that saw it. The `lastLineChecked` flag makes
223
+ * the recursion linear over the whole document rather than quadratic.
224
+ */
225
+ function endsWithBlankLine(block: MdNode | null): boolean {
226
+ let cur = block
227
+ while (cur) {
228
+ if (cur.lastLineBlank) return true
229
+ const t = cur.type
230
+ if (!cur.lastLineChecked && ('list' === t || 'item' === t)) {
231
+ cur.lastLineChecked = true
232
+ cur = cur.lastChild
233
+ } else {
234
+ cur.lastLineChecked = true
235
+ return false
236
+ }
237
+ }
238
+ return false
239
+ }
240
+
241
+ // --- per-block behaviour ----------------------------------------------------
242
+
243
+ /**
244
+ * `continue` result: 0 = matched, 1 = not matched, 2 = line fully consumed
245
+ * (fenced code close), stop processing this line entirely.
246
+ */
247
+ type ContinueResult = 0 | 1 | 2
248
+
249
+ type BlockHandler = {
250
+ /** Can this block absorb the current line? */
251
+ continue: (parser: BlockParser, block: MdNode) => ContinueResult
252
+ /** Called once when the block is closed. */
253
+ finalize: (parser: BlockParser, block: MdNode) => void
254
+ canContain: (t: NodeType) => boolean
255
+ /** Leaves that accumulate raw text: code blocks, HTML blocks, paragraphs. */
256
+ acceptsLines: boolean
257
+ }
258
+
259
+ const notItem = (t: NodeType): boolean => 'item' !== t
260
+ const noChildren = (): boolean => false
261
+
262
+ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
263
+ document: {
264
+ continue: () => 0,
265
+ finalize: () => undefined,
266
+ canContain: notItem,
267
+ acceptsLines: false,
268
+ },
269
+
270
+ list: {
271
+ continue: () => 0,
272
+ finalize: (_parser, block) => {
273
+ // §5.3: the list is loose if any item is followed by a blank line, or if
274
+ // any item directly contains two blocks separated by one. A blank line
275
+ // at the very end of the last item does not count — hence the
276
+ // `item.next` / `subitem.next` guards.
277
+ let item = block.firstChild
278
+ while (item) {
279
+ if (endsWithBlankLine(item) && item.next) {
280
+ if (block.listData) block.listData.tight = false
281
+ break
282
+ }
283
+ let subitem = item.firstChild
284
+ while (subitem) {
285
+ if (endsWithBlankLine(subitem) && (item.next || subitem.next)) {
286
+ if (block.listData) block.listData.tight = false
287
+ break
288
+ }
289
+ subitem = subitem.next
290
+ }
291
+ item = item.next
292
+ }
293
+ if (block.lastChild) block.sourcepos[1] = block.lastChild.sourcepos[1]
294
+ },
295
+ canContain: (t) => 'item' === t,
296
+ acceptsLines: false,
297
+ },
298
+
299
+ block_quote: {
300
+ continue: (parser) => {
301
+ const ln = parser.currentLine
302
+ if (!parser.indented && C_GREATERTHAN === peek(ln, parser.nextNonspace)) {
303
+ parser.advanceNextNonspace()
304
+ parser.advanceOffset(1, false)
305
+ // §5.1: one optional space of indentation after `>`, tab-aware.
306
+ if (isSpaceOrTab(peek(ln, parser.offset))) parser.advanceOffset(1, true)
307
+ return 0
308
+ }
309
+ return 1
310
+ },
311
+ finalize: () => undefined,
312
+ canContain: notItem,
313
+ acceptsLines: false,
314
+ },
315
+
316
+ item: {
317
+ continue: (parser, container) => {
318
+ const data = container.listData
319
+ if (null === data) return 1
320
+
321
+ if (parser.blank) {
322
+ // An item whose first line is blank cannot be continued by a second
323
+ // blank line (§5.2 rule 3: at most one leading blank line).
324
+ if (null === container.firstChild) return 1
325
+ parser.advanceNextNonspace()
326
+ return 0
327
+ }
328
+
329
+ // §5.2: subsequent lines belong to the item if indented to the content
330
+ // column derived from the marker.
331
+ if (parser.indent >= data.markerOffset + data.padding) {
332
+ parser.advanceOffset(data.markerOffset + data.padding, true)
333
+ return 0
334
+ }
335
+ return 1
336
+ },
337
+ finalize: (_parser, block) => {
338
+ if (block.lastChild) {
339
+ block.sourcepos[1] = block.lastChild.sourcepos[1]
340
+ } else if (block.listData) {
341
+ block.sourcepos[1] = [
342
+ block.sourcepos[0][0],
343
+ block.listData.markerOffset + block.listData.padding,
344
+ ]
345
+ }
346
+ },
347
+ canContain: notItem,
348
+ acceptsLines: false,
349
+ },
350
+
351
+ heading: {
352
+ // A heading is always exactly one line (setext headings are converted from
353
+ // an already-complete paragraph).
354
+ continue: () => 1,
355
+ finalize: () => undefined,
356
+ canContain: noChildren,
357
+ acceptsLines: false,
358
+ },
359
+
360
+ thematic_break: {
361
+ continue: () => 1,
362
+ finalize: () => undefined,
363
+ canContain: noChildren,
364
+ acceptsLines: false,
365
+ },
366
+
367
+ code_block: {
368
+ continue: (parser, container) => {
369
+ const ln = parser.currentLine
370
+ const indent = parser.indent
371
+
372
+ if (container.isFenced) {
373
+ // §4.5: the closing fence is the same character, at least as long, at
374
+ // most 3 columns indented, and followed only by spaces/tabs.
375
+ const rest = ln.slice(parser.nextNonspace)
376
+ const match =
377
+ indent <= 3 && ln.charAt(parser.nextNonspace) === container.fenceChar
378
+ ? rest.match(RE_CLOSING_CODE_FENCE)
379
+ : null
380
+ if (match && match[0].length >= container.fenceLength) {
381
+ parser.lastLineLength = charCount(ln.slice(0, parser.offset + indent + match[0].length))
382
+ parser.finalize(container, parser.lineNumber)
383
+ return 2
384
+ }
385
+ // Content is de-indented by up to the opening fence's own indent.
386
+ let i = container.fenceOffset
387
+ while (0 < i && isSpaceOrTab(peek(ln, parser.offset))) {
388
+ parser.advanceOffset(1, true)
389
+ i--
390
+ }
391
+ return 0
392
+ }
393
+
394
+ // §4.4 indented code: four columns, or a blank line (which is kept).
395
+ if (indent >= CODE_INDENT) {
396
+ parser.advanceOffset(CODE_INDENT, true)
397
+ return 0
398
+ }
399
+ if (parser.blank) {
400
+ parser.advanceNextNonspace()
401
+ return 0
402
+ }
403
+ return 1
404
+ },
405
+ finalize: (_parser, block) => {
406
+ if (block.isFenced) {
407
+ // The first accumulated line is the info string, not content.
408
+ const content = block.stringContent
409
+ const nl = content.indexOf('\n')
410
+ const firstLine = -1 === nl ? content : content.slice(0, nl)
411
+ block.info = unescapeString(firstLine.trim())
412
+ block.literal = -1 === nl ? '' : content.slice(nl + 1)
413
+ } else {
414
+ // §4.4: trailing blank lines are not part of an indented code block.
415
+ block.literal = block.stringContent.replace(/(\n *)+$/, '\n')
416
+ }
417
+ block.stringContent = ''
418
+ },
419
+ canContain: noChildren,
420
+ acceptsLines: true,
421
+ },
422
+
423
+ html_block: {
424
+ continue: (parser, container) => {
425
+ // Types 6 and 7 end at a blank line; 1–5 end on their closing string,
426
+ // which `incorporateLine` checks after adding the line.
427
+ const type = parser.htmlBlockType(container)
428
+ return parser.blank && (6 === type || 7 === type) ? 1 : 0
429
+ },
430
+ finalize: (_parser, block) => {
431
+ block.literal = block.stringContent.replace(/(\n *)+$/, '')
432
+ block.stringContent = ''
433
+ },
434
+ canContain: noChildren,
435
+ acceptsLines: true,
436
+ },
437
+
438
+ paragraph: {
439
+ continue: (parser) => (parser.blank ? 1 : 0),
440
+ finalize: (parser, block) => {
441
+ // §4.7: a paragraph may begin with link reference definitions. Consume
442
+ // them from the front; if nothing is left, the paragraph disappears.
443
+ let hasReferenceDefs = false
444
+ let pos = 0
445
+ while (
446
+ C_OPEN_BRACKET === block.stringContent.charCodeAt(0) &&
447
+ 0 !== (pos = parseReference(block.stringContent, parser.refmap))
448
+ ) {
449
+ block.stringContent = block.stringContent.slice(pos)
450
+ hasReferenceDefs = true
451
+ }
452
+ if (hasReferenceDefs && isBlank(block.stringContent)) block.unlink()
453
+ },
454
+ canContain: noChildren,
455
+ acceptsLines: true,
456
+ },
457
+ }
458
+
459
+ function handlerFor(type: NodeType): BlockHandler {
460
+ const handler: BlockHandler | undefined = BLOCK_HANDLERS[type]
461
+ if (undefined === handler) throw new Error('markdown: no block handler for ' + type)
462
+ return handler
463
+ }
464
+
465
+ // --- list markers (§5.2) ----------------------------------------------------
466
+
467
+ /**
468
+ * Parse a list marker at `nextNonspace` and derive the item's content indent.
469
+ *
470
+ * The content indent is marker width + the spaces that follow it, with two
471
+ * exceptions that both fall back to marker width + 1:
472
+ *
473
+ * - rule 2, item starting with indented code: 5+ spaces after the marker
474
+ * means only the first space belongs to the marker, the rest is code;
475
+ * - rule 3, item starting with a blank line: nothing follows the marker at
476
+ * all, so there are no spaces to measure.
477
+ *
478
+ * Returns null when this is not a list item start. Advances the parser to the
479
+ * item's content column when it is.
480
+ */
481
+ function parseListMarker(parser: BlockParser, container: MdNode): ListData | null {
482
+ // Four columns of indentation is indented code, never a list marker.
483
+ if (parser.indent >= CODE_INDENT) return null
484
+
485
+ const rest = parser.currentLine.slice(parser.nextNonspace)
486
+
487
+ const data: ListData = {
488
+ type: 'bullet',
489
+ tight: true, // lists are tight until finalize proves otherwise
490
+ start: 1,
491
+ delimiter: '',
492
+ bulletChar: '',
493
+ padding: 0,
494
+ markerOffset: parser.indent,
495
+ }
496
+
497
+ let match = rest.match(RE_BULLET_LIST_MARKER)
498
+ if (match) {
499
+ data.type = 'bullet'
500
+ data.bulletChar = match[0][0]
501
+ } else {
502
+ match = rest.match(RE_ORDERED_LIST_MARKER)
503
+ // §5.2 exception 1(b): an ordered item interrupting a paragraph must start at 1.
504
+ if (null === match || ('paragraph' === container.type && 1 !== parseInt(match[1], 10))) {
505
+ return null
506
+ }
507
+ data.type = 'ordered'
508
+ data.start = parseInt(match[1], 10)
509
+ data.delimiter = match[2]
510
+ }
511
+
512
+ // At least one space or tab (or the end of the line) must follow the marker.
513
+ let nextc = peek(parser.currentLine, parser.nextNonspace + match[0].length)
514
+ if (!(-1 === nextc || C_TAB === nextc || C_SPACE === nextc)) return null
515
+
516
+ // §5.2 exception 1(a): an item interrupting a paragraph may not start blank.
517
+ if (
518
+ 'paragraph' === container.type &&
519
+ !RE_NON_SPACE.test(parser.currentLine.slice(parser.nextNonspace + match[0].length))
520
+ ) {
521
+ return null
522
+ }
523
+
524
+ parser.advanceNextNonspace() // to the marker
525
+ parser.advanceOffset(match[0].length, true) // past the marker
526
+
527
+ const spacesStartCol = parser.column
528
+ const spacesStartOffset = parser.offset
529
+ do {
530
+ parser.advanceOffset(1, true)
531
+ nextc = peek(parser.currentLine, parser.offset)
532
+ } while (parser.column - spacesStartCol < 5 && isSpaceOrTab(nextc))
533
+
534
+ const blankItem = -1 === peek(parser.currentLine, parser.offset)
535
+ const spacesAfterMarker = parser.column - spacesStartCol
536
+
537
+ if (spacesAfterMarker >= 5 || spacesAfterMarker < 1 || blankItem) {
538
+ data.padding = match[0].length + 1
539
+ parser.column = spacesStartCol
540
+ parser.offset = spacesStartOffset
541
+ if (isSpaceOrTab(peek(parser.currentLine, parser.offset))) parser.advanceOffset(1, true)
542
+ } else {
543
+ data.padding = match[0].length + spacesAfterMarker
544
+ }
545
+
546
+ return data
547
+ }
548
+
549
+ // --- block starts -----------------------------------------------------------
550
+
551
+ /** 0 = no match, 1 = container started, 2 = leaf started (stop looking). */
552
+ type StartResult = 0 | 1 | 2
553
+
554
+ type BlockStart = (parser: BlockParser, container: MdNode) => StartResult
555
+
556
+ /**
557
+ * Tried in order, and the order is the spec's: a setext underline beats a
558
+ * thematic break (so `Foo\n---` is a heading), a thematic break beats a list
559
+ * item (so `- - -` is a break), and indented code comes last because any of
560
+ * the others may be indented up to three columns.
561
+ */
562
+ const BLOCK_STARTS: BlockStart[] = [
563
+ // Block quote (§5.1).
564
+ (parser) => {
565
+ if (!parser.indented && C_GREATERTHAN === peek(parser.currentLine, parser.nextNonspace)) {
566
+ parser.advanceNextNonspace()
567
+ parser.advanceOffset(1, false)
568
+ if (isSpaceOrTab(peek(parser.currentLine, parser.offset))) parser.advanceOffset(1, true)
569
+ parser.closeUnmatchedBlocks()
570
+ parser.addChild('block_quote', parser.nextNonspace)
571
+ return 1
572
+ }
573
+ return 0
574
+ },
575
+
576
+ // ATX heading (§4.2).
577
+ (parser) => {
578
+ if (parser.indented) return 0
579
+ const match = parser.currentLine.slice(parser.nextNonspace).match(RE_ATX_HEADING_MARKER)
580
+ if (null === match) return 0
581
+
582
+ parser.advanceNextNonspace()
583
+ parser.advanceOffset(match[0].length, false)
584
+ parser.closeUnmatchedBlocks()
585
+
586
+ const container = parser.addChild('heading', parser.nextNonspace)
587
+ container.level = match[0].replace(/[ \t]+$/, '').length
588
+ // An optional closing sequence of `#`s, preceded by spaces or tabs, is
589
+ // dropped; a line of nothing but `#`s has empty content.
590
+ container.stringContent = parser.currentLine
591
+ .slice(parser.offset)
592
+ .replace(/^[ \t]*#+[ \t]*$/, '')
593
+ .replace(/[ \t]+#+[ \t]*$/, '')
594
+ parser.advanceOffset(parser.currentLine.length - parser.offset, false)
595
+ return 2
596
+ },
597
+
598
+ // Fenced code block (§4.5).
599
+ (parser) => {
600
+ if (parser.indented) return 0
601
+ const match = parser.currentLine.slice(parser.nextNonspace).match(RE_CODE_FENCE)
602
+ if (null === match) return 0
603
+
604
+ const fenceLength = match[0].length
605
+ parser.closeUnmatchedBlocks()
606
+ const container = parser.addChild('code_block', parser.nextNonspace)
607
+ container.isFenced = true
608
+ container.fenceLength = fenceLength
609
+ container.fenceChar = match[0][0]
610
+ container.fenceOffset = parser.indent
611
+ parser.advanceNextNonspace()
612
+ parser.advanceOffset(fenceLength, false)
613
+ // The rest of the line is added as the block's first line: the info string.
614
+ return 2
615
+ },
616
+
617
+ // HTML block (§4.6), types 1-7.
618
+ (parser, container) => {
619
+ if (parser.indented || C_LESSTHAN !== peek(parser.currentLine, parser.nextNonspace)) return 0
620
+
621
+ const s = parser.currentLine.slice(parser.nextNonspace)
622
+ for (let blockType = 1; blockType <= 7; blockType++) {
623
+ if (!RE_HTML_BLOCK_OPEN[blockType].test(s)) continue
624
+ // Type 7 may not interrupt a paragraph — including a paragraph we are
625
+ // only about to continue lazily.
626
+ if (
627
+ 7 === blockType &&
628
+ ('paragraph' === container.type ||
629
+ (!parser.allClosed && !parser.blank && 'paragraph' === parser.openTip.type))
630
+ ) {
631
+ continue
632
+ }
633
+ parser.closeUnmatchedBlocks()
634
+ // The offset is deliberately not advanced: leading spaces are part of
635
+ // the raw HTML.
636
+ const block = parser.addChild('html_block', parser.offset)
637
+ parser.setHtmlBlockType(block, blockType)
638
+ return 2
639
+ }
640
+ return 0
641
+ },
642
+
643
+ // Setext heading underline (§4.3) — converts the whole open paragraph.
644
+ (parser, container) => {
645
+ if (parser.indented || 'paragraph' !== container.type) return 0
646
+ const match = parser.currentLine.slice(parser.nextNonspace).match(RE_SETEXT_HEADING_LINE)
647
+ if (null === match) return 0
648
+
649
+ parser.closeUnmatchedBlocks()
650
+
651
+ // §4.7: the paragraph may still have led with reference definitions; only
652
+ // what remains becomes the heading. If nothing remains this is not a
653
+ // setext underline at all (it falls through to thematic break / paragraph).
654
+ let pos = 0
655
+ while (
656
+ C_OPEN_BRACKET === container.stringContent.charCodeAt(0) &&
657
+ 0 !== (pos = parseReference(container.stringContent, parser.refmap))
658
+ ) {
659
+ container.stringContent = container.stringContent.slice(pos)
660
+ }
661
+ if (0 === container.stringContent.length) return 0
662
+
663
+ const start = container.sourcepos[0]
664
+ const heading = new MdNode('heading', [[start[0], start[1]], [0, 0]])
665
+ heading.level = '=' === match[0][0] ? 1 : 2
666
+ heading.stringContent = container.stringContent
667
+ container.insertAfter(heading)
668
+ container.unlink()
669
+ parser.tip = heading
670
+ parser.advanceOffset(parser.currentLine.length - parser.offset, false)
671
+ return 2
672
+ },
673
+
674
+ // Thematic break (§4.1).
675
+ (parser) => {
676
+ if (parser.indented) return 0
677
+ if (!RE_THEMATIC_BREAK.test(parser.currentLine.slice(parser.nextNonspace))) return 0
678
+ parser.closeUnmatchedBlocks()
679
+ parser.addChild('thematic_break', parser.nextNonspace)
680
+ parser.advanceOffset(parser.currentLine.length - parser.offset, false)
681
+ return 2
682
+ },
683
+
684
+ // List item (§5.2). `parseListMarker` rejects a marker indented four or more
685
+ // columns on its own, so there is no `indented` test here.
686
+ (parser, container) => {
687
+ const data = parseListMarker(parser, container)
688
+ if (null === data) return 0
689
+
690
+ parser.closeUnmatchedBlocks()
691
+
692
+ // §5.3: a change of bullet character or ordered delimiter starts a new list.
693
+ const tip = parser.openTip
694
+ if ('list' !== tip.type || null === tip.listData || !listsMatch(tip.listData, data)) {
695
+ parser.addChild('list', parser.nextNonspace).listData = data
696
+ }
697
+
698
+ parser.addChild('item', parser.nextNonspace).listData = data
699
+ return 1
700
+ },
701
+
702
+ // Indented code block (§4.4) — may not interrupt a paragraph.
703
+ (parser) => {
704
+ if (!parser.indented || 'paragraph' === parser.openTip.type || parser.blank) return 0
705
+ parser.advanceOffset(CODE_INDENT, true)
706
+ parser.closeUnmatchedBlocks()
707
+ parser.addChild('code_block', parser.offset)
708
+ return 2
709
+ },
710
+ ]
711
+
712
+ // --- the parser -------------------------------------------------------------
713
+
714
+ class BlockParser {
715
+ options: ParserOptions
716
+ refmap: RefMap = {}
717
+
718
+ doc: MdNode
719
+ /** Deepest open block; null only once the document itself is finalized. */
720
+ tip: MdNode | null
721
+ /** The tip as it was before the current line was walked. */
722
+ oldtip: MdNode
723
+ lastMatchedContainer: MdNode
724
+ /** False while some open block failed its continuation condition. */
725
+ allClosed = true
726
+
727
+ currentLine = ''
728
+ lineNumber = 0
729
+ lastLineLength = 0
730
+
731
+ // Memoize the last offset in `currentLine` whose character count is known,
732
+ // so sourcepos columns cost amortized O(1). Both reset per line.
733
+ // See `sourceColumn`.
734
+ colAnchorOffset = 0
735
+ colAnchorChars = 0
736
+
737
+ /** UTF-16 index into `currentLine`. */
738
+ offset = 0
739
+ /** Tab-expanded display column matching `offset` (§2.2). */
740
+ column = 0
741
+ /** True when `offset` sits inside a tab whose columns were partly consumed. */
742
+ partiallyConsumedTab = false
743
+
744
+ nextNonspace = 0
745
+ nextNonspaceColumn = 0
746
+ /** Columns between `column` and the next non-space character. */
747
+ indent = 0
748
+ indented = false
749
+ blank = false
750
+
751
+ // MdNode has no field for the HTML block type, and the tree contract is not
752
+ // ours to change, so open HTML blocks carry it here.
753
+ private htmlBlockTypes = new Map<MdNode, number>()
754
+
755
+ constructor(options: ParserOptions) {
756
+ this.options = options
757
+ this.doc = new MdNode('document', [
758
+ [1, 1],
759
+ [0, 0],
760
+ ])
761
+ this.tip = this.doc
762
+ this.oldtip = this.doc
763
+ this.lastMatchedContainer = this.doc
764
+ }
765
+
766
+ /** The deepest open block. Only valid while a line is being processed. */
767
+ get openTip(): MdNode {
768
+ if (null === this.tip) throw new Error('markdown: no open block')
769
+ return this.tip
770
+ }
771
+
772
+ htmlBlockType(block: MdNode): number {
773
+ return this.htmlBlockTypes.get(block) ?? 0
774
+ }
775
+
776
+ setHtmlBlockType(block: MdNode, type: number): void {
777
+ this.htmlBlockTypes.set(block, type)
778
+ }
779
+
780
+ /**
781
+ * Advance `count` characters (`columns` false) or `count` columns (`columns`
782
+ * true), expanding tabs to the next 4-column tab stop (§2.2). A tab whose
783
+ * columns are only partly consumed leaves `offset` on the tab itself and
784
+ * sets `partiallyConsumedTab`, so `addLine` can re-emit the remainder as
785
+ * spaces.
786
+ */
787
+ advanceOffset(count: number, columns: boolean): void {
788
+ const ln = this.currentLine
789
+ while (0 < count && this.offset < ln.length) {
790
+ const c = ln.charAt(this.offset)
791
+ if ('\t' === c) {
792
+ const charsToTab = 4 - (this.column % 4)
793
+ if (columns) {
794
+ this.partiallyConsumedTab = charsToTab > count
795
+ const charsToAdvance = charsToTab > count ? count : charsToTab
796
+ this.column += charsToAdvance
797
+ this.offset += this.partiallyConsumedTab ? 0 : 1
798
+ count -= charsToAdvance
799
+ } else {
800
+ this.partiallyConsumedTab = false
801
+ this.column += charsToTab
802
+ this.offset += 1
803
+ count -= 1
804
+ }
805
+ } else {
806
+ this.partiallyConsumedTab = false
807
+ this.offset += 1
808
+ this.column += 1 // block markers are ASCII, so one char is one column
809
+ count -= 1
810
+ }
811
+ }
812
+ }
813
+
814
+ advanceNextNonspace(): void {
815
+ this.offset = this.nextNonspace
816
+ this.column = this.nextNonspaceColumn
817
+ this.partiallyConsumedTab = false
818
+ }
819
+
820
+ /** Locate the next non-space character and the indent leading up to it. */
821
+ findNextNonspace(): void {
822
+ const ln = this.currentLine
823
+ let i = this.offset
824
+ let cols = this.column
825
+ let c = ''
826
+
827
+ for (;;) {
828
+ c = ln.charAt(i)
829
+ if (' ' === c) {
830
+ i++
831
+ cols++
832
+ } else if ('\t' === c) {
833
+ i++
834
+ cols += 4 - (cols % 4)
835
+ } else {
836
+ break
837
+ }
838
+ }
839
+
840
+ this.blank = '' === c || '\n' === c || '\r' === c
841
+ this.nextNonspace = i
842
+ this.nextNonspaceColumn = cols
843
+ this.indent = this.nextNonspaceColumn - this.column
844
+ this.indented = this.indent >= CODE_INDENT
845
+ }
846
+
847
+ /** Add the rest of the current line to the tip's raw content. */
848
+ addLine(): void {
849
+ const tip = this.openTip
850
+ if (this.partiallyConsumedTab) {
851
+ this.offset += 1 // step over the tab
852
+ const charsToTab = 4 - (this.column % 4)
853
+ tip.stringContent += ' '.repeat(charsToTab)
854
+ }
855
+ tip.stringContent += this.currentLine.slice(this.offset) + '\n'
856
+ }
857
+
858
+ /**
859
+ * Open a block of `tag` as a child of the tip, closing blocks that cannot
860
+ * contain it first.
861
+ */
862
+ /**
863
+ * Characters of `currentLine` before UTF-16 offset `offset` — what a
864
+ * sourcepos column counts.
865
+ *
866
+ * Not simply `offset`: a JavaScript string index counts UTF-16 units, so an
867
+ * astral character (emoji, rare CJK) would advance the column by two. A
868
+ * column means a character, so `\u{1F600} *x*` must report the same columns
869
+ * in both runtimes.
870
+ *
871
+ * Counted incrementally from the last offset already measured. `addChild`
872
+ * runs once per container opened, so counting from the start of the line
873
+ * every time would make a line of n nested containers — `> - > - …` — cost
874
+ * O(n²); untrusted input picks n. Offsets advance monotonically as a line is
875
+ * consumed, making this amortized O(1); a smaller offset than the anchor is
876
+ * still correct, just recounted from the start.
877
+ */
878
+ sourceColumn(offset: number): number {
879
+ if (offset < this.colAnchorOffset) {
880
+ this.colAnchorOffset = 0
881
+ this.colAnchorChars = 0
882
+ }
883
+ this.colAnchorChars += charCount(this.currentLine.slice(this.colAnchorOffset, offset))
884
+ this.colAnchorOffset = offset
885
+ return this.colAnchorChars
886
+ }
887
+
888
+ addChild(tag: NodeType, offset: number): MdNode {
889
+ while (!handlerFor(this.openTip.type).canContain(tag)) {
890
+ this.finalize(this.openTip, this.lineNumber - 1)
891
+ }
892
+ const columnNumber = this.sourceColumn(offset) + 1 // offset 0 is column 1
893
+ const node = new MdNode(tag, [
894
+ [this.lineNumber, columnNumber],
895
+ [0, 0],
896
+ ])
897
+ this.openTip.appendChild(node)
898
+ this.tip = node
899
+ return node
900
+ }
901
+
902
+ /** Close every block that failed its continuation condition on this line. */
903
+ closeUnmatchedBlocks(): void {
904
+ if (this.allClosed) return
905
+ while (this.oldtip !== this.lastMatchedContainer) {
906
+ const parent = this.oldtip.parent
907
+ this.finalize(this.oldtip, this.lineNumber - 1)
908
+ if (null === parent) break
909
+ this.oldtip = parent
910
+ }
911
+ this.allClosed = true
912
+ }
913
+
914
+ /** Close a block, record its end position, and run its finalizer. */
915
+ finalize(block: MdNode, lineNumber: number): void {
916
+ const above = block.parent
917
+ block.open = false
918
+ block.sourcepos[1] = [lineNumber, this.lastLineLength]
919
+ handlerFor(block.type).finalize(this, block)
920
+ this.htmlBlockTypes.delete(block)
921
+ this.tip = above
922
+ }
923
+
924
+ /**
925
+ * Incorporate one line into the tree — the three steps of Appendix A.
926
+ */
927
+ incorporateLine(ln: string): void {
928
+ let allMatched = true
929
+ let container = this.doc
930
+
931
+ this.oldtip = this.openTip
932
+ this.offset = 0
933
+ this.column = 0
934
+ this.blank = false
935
+ this.partiallyConsumedTab = false
936
+ this.lineNumber += 1
937
+ this.currentLine = ln
938
+ // The column anchor memoizes offsets into `currentLine`; a new line
939
+ // invalidates it. See `sourceColumn`.
940
+ this.colAnchorOffset = 0
941
+ this.colAnchorChars = 0
942
+
943
+ // Step 1: walk the open blocks, consuming continuation markers. Stop at
944
+ // the first block that does not match; `container` is then the last
945
+ // matched one.
946
+ let lastChild = container.lastChild
947
+ while (lastChild && lastChild.open) {
948
+ container = lastChild
949
+ this.findNextNonspace()
950
+
951
+ const result = handlerFor(container.type).continue(this, container)
952
+ if (1 === result) {
953
+ allMatched = false
954
+ } else if (2 === result) {
955
+ // A fenced code block was closed by this line; nothing else to do.
956
+ return
957
+ }
958
+
959
+ if (!allMatched) {
960
+ const parent = container.parent
961
+ if (parent) container = parent
962
+ break
963
+ }
964
+
965
+ lastChild = container.lastChild
966
+ }
967
+
968
+ this.allClosed = container === this.oldtip
969
+ this.lastMatchedContainer = container
970
+
971
+ // Step 2: look for new block starts under the last matched container.
972
+ let matchedLeaf = 'paragraph' !== container.type && handlerFor(container.type).acceptsLines
973
+
974
+ while (!matchedLeaf) {
975
+ this.findNextNonspace()
976
+
977
+ if (!this.indented && !RE_MAYBE_SPECIAL.test(ln.slice(this.nextNonspace))) {
978
+ this.advanceNextNonspace()
979
+ break
980
+ }
981
+
982
+ let i = 0
983
+ while (i < BLOCK_STARTS.length) {
984
+ const result = BLOCK_STARTS[i](this, container)
985
+ if (1 === result) {
986
+ container = this.openTip
987
+ break
988
+ }
989
+ if (2 === result) {
990
+ container = this.openTip
991
+ matchedLeaf = true
992
+ break
993
+ }
994
+ i++
995
+ }
996
+
997
+ if (i === BLOCK_STARTS.length) {
998
+ // Nothing matched: the rest of the line is plain text.
999
+ this.advanceNextNonspace()
1000
+ break
1001
+ }
1002
+ }
1003
+
1004
+ // Step 3: whatever is left of the line is text for the deepest open block.
1005
+ if (!this.allClosed && !this.blank && 'paragraph' === this.openTip.type) {
1006
+ // Lazy continuation (§5.1 rule 2): the markers are missing but an open
1007
+ // paragraph absorbs the line anyway, and its containers stay open.
1008
+ this.addLine()
1009
+ this.lastLineLength = charCount(ln)
1010
+ return
1011
+ }
1012
+
1013
+ this.closeUnmatchedBlocks()
1014
+
1015
+ if (this.blank && container.lastChild) container.lastChild.lastLineBlank = true
1016
+
1017
+ const t = container.type
1018
+
1019
+ // A block quote line is never blank (it has a `>`), blank lines inside
1020
+ // fenced code do not affect list looseness, and an item's own first blank
1021
+ // line is part of the item, not a separator.
1022
+ const lastLineBlank =
1023
+ this.blank &&
1024
+ !(
1025
+ 'block_quote' === t ||
1026
+ ('code_block' === t && container.isFenced) ||
1027
+ ('item' === t &&
1028
+ null === container.firstChild &&
1029
+ container.sourcepos[0][0] === this.lineNumber)
1030
+ )
1031
+
1032
+ // Propagate up: a container whose last line was blank ends with a blank line.
1033
+ let cont: MdNode | null = container
1034
+ while (cont) {
1035
+ cont.lastLineBlank = lastLineBlank
1036
+ cont = cont.parent
1037
+ }
1038
+
1039
+ if (handlerFor(t).acceptsLines) {
1040
+ this.addLine()
1041
+ // §4.6: HTML block types 1-5 end on the line that contains their closing
1042
+ // string, which may be the line that opened them.
1043
+ if ('html_block' === t) {
1044
+ const type = this.htmlBlockType(container)
1045
+ if (
1046
+ 1 <= type &&
1047
+ type <= 5 &&
1048
+ RE_HTML_BLOCK_CLOSE[type].test(this.currentLine.slice(this.offset))
1049
+ ) {
1050
+ this.lastLineLength = charCount(ln)
1051
+ this.finalize(container, this.lineNumber)
1052
+ }
1053
+ }
1054
+ } else if (this.offset < ln.length && !this.blank) {
1055
+ // Nowhere else for the text to go: open a paragraph for it.
1056
+ this.addChild('paragraph', this.offset)
1057
+ this.advanceNextNonspace()
1058
+ this.addLine()
1059
+ }
1060
+
1061
+ this.lastLineLength = charCount(ln)
1062
+ }
1063
+
1064
+ parse(input: string): { doc: MdNode; refmap: RefMap } {
1065
+ // §2.3: insecure characters are replaced before anything else looks at them.
1066
+ const text = -1 === input.indexOf('\u0000') ? input : input.replace(/\0/g, '\uFFFD')
1067
+
1068
+ const lines = text.split(RE_LINE_ENDING)
1069
+ let len = lines.length
1070
+ // A final line ending does not introduce a trailing blank line.
1071
+ if (0 < text.length && '' === lines[len - 1]) len -= 1
1072
+
1073
+ for (let i = 0; i < len; i++) {
1074
+ this.incorporateLine(lines[i])
1075
+ }
1076
+
1077
+ while (this.tip) {
1078
+ this.finalize(this.tip, len)
1079
+ }
1080
+
1081
+ return { doc: this.doc, refmap: this.refmap }
1082
+ }
1083
+ }
1084
+
1085
+ /**
1086
+ * Phase 1: build the block tree and collect link reference definitions.
1087
+ * Paragraph and heading text is left raw in `stringContent` for phase 2.
1088
+ */
1089
+ export function parseBlocks(
1090
+ input: string,
1091
+ options: ParserOptions,
1092
+ ): { doc: MdNode; refmap: RefMap } {
1093
+ return new BlockParser(options).parse(input)
1094
+ }