@tabnas/markdown 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -32
- package/dist/ast.d.ts +83 -0
- package/dist/ast.js +248 -0
- package/dist/ast.js.map +1 -0
- package/dist/block.d.ts +10 -0
- package/dist/block.js +971 -0
- package/dist/block.js.map +1 -0
- package/dist/common.d.ts +49 -0
- package/dist/common.js +155 -0
- package/dist/common.js.map +1 -0
- package/dist/commonmark.d.ts +14 -0
- package/dist/commonmark.js +31 -0
- package/dist/commonmark.js.map +1 -0
- package/dist/entities.d.ts +1 -0
- package/dist/entities.js +14 -0
- package/dist/entities.js.map +1 -0
- package/dist/html.d.ts +10 -0
- package/dist/html.js +270 -0
- package/dist/html.js.map +1 -0
- package/dist/inline.d.ts +13 -0
- package/dist/inline.js +902 -0
- package/dist/inline.js.map +1 -0
- package/dist/markdown.d.ts +22 -28
- package/dist/markdown.js +105 -571
- package/dist/markdown.js.map +1 -1
- package/dist/node.d.ts +74 -0
- package/dist/node.js +167 -0
- package/dist/node.js.map +1 -0
- package/dist/options.d.ts +21 -0
- package/dist/options.js +16 -0
- package/dist/options.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +14 -9
- package/src/ast.ts +352 -0
- package/src/block.ts +1094 -0
- package/src/common.ts +166 -0
- package/src/commonmark.ts +34 -0
- package/src/entities.ts +14 -0
- package/src/html.ts +321 -0
- package/src/inline.ts +1034 -0
- package/src/markdown.ts +161 -680
- package/src/node.ts +240 -0
- package/src/options.ts +39 -0
- package/src/tsconfig.json +2 -0
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
|
+
}
|