@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/src/inline.ts ADDED
@@ -0,0 +1,1034 @@
1
+ /* Copyright (c) 2021-2026 Richard Rodger, MIT License */
2
+
3
+ // Phase 2 of the CommonMark parse (spec 0.31.2, Appendix A, "Phase 2: inline
4
+ // structure"). The block phase left the raw text of every paragraph and
5
+ // heading in `stringContent`; this module turns that text into inline
6
+ // children and clears it.
7
+ //
8
+ // The shape is the spec's own algorithm: one left-to-right scan over the
9
+ // subject that appends nodes as it goes, plus two auxiliary stacks that are
10
+ // resolved out of band, because neither construct can be decided at the
11
+ // point where its opener is seen:
12
+ //
13
+ // * the delimiter stack (§6.4) holds `*`/`_` (and, under GFM, `~`) runs,
14
+ // and is resolved by `processEmphasis`;
15
+ // * the bracket stack (§6.3) holds `[` and `![`, and is resolved by
16
+ // `parseCloseBracket` when a `]` turns up.
17
+ //
18
+ // Everything else — code spans, autolinks, raw HTML, entities, escapes,
19
+ // breaks — is decided locally at the scan position. That is what produces
20
+ // the precedence §6.3 requires: a code span, autolink or raw HTML tag is
21
+ // consumed whole during the scan, so it binds more tightly than emphasis and
22
+ // more tightly than the brackets of a link label. Conversely brackets bind
23
+ // more tightly than emphasis, because bracket matching happens during the
24
+ // scan while emphasis matching happens afterwards over the delimiter stack.
25
+
26
+ import { MdNode } from './node.ts'
27
+ import type { NodeType } from './node.ts'
28
+ import type { ParserOptions, RefMap } from './options.ts'
29
+ import {
30
+ ENTITY,
31
+ ESCAPABLE,
32
+ decodeEntity,
33
+ isUnicodePunctuation,
34
+ isUnicodeWhitespace,
35
+ normalizeReference,
36
+ unescapeString,
37
+ } from './common.ts'
38
+
39
+ const C_NEWLINE = 10
40
+ const C_SPACE = 32
41
+ const C_BANG = 33
42
+ const C_AMPERSAND = 38
43
+ const C_OPEN_PAREN = 40
44
+ const C_CLOSE_PAREN = 41
45
+ const C_ASTERISK = 42
46
+ const C_COLON = 58
47
+ const C_LESSTHAN = 60
48
+ const C_OPEN_BRACKET = 91
49
+ const C_BACKSLASH = 92
50
+ const C_CLOSE_BRACKET = 93
51
+ const C_UNDERSCORE = 95
52
+ const C_BACKTICK = 96
53
+ const C_TILDE = 126
54
+ const C_DEL = 127
55
+
56
+ // --- §6.6 Raw HTML: the tag grammar, spelled out because it is stricter than
57
+ // `<[^>]*>` in every part. Attribute names are XML-restricted-to-ASCII;
58
+ // unquoted attribute values exclude quotes, `=`, `<`, `>`, backtick and all
59
+ // control characters; comments/PIs/declarations/CDATA each have their own
60
+ // terminator. `\s*` stands in for "spaces, tabs, and up to one line ending":
61
+ // a paragraph can never contain a blank line, so a single `\s*` can only ever
62
+ // span one line ending anyway.
63
+
64
+ const TAGNAME = '[A-Za-z][A-Za-z0-9-]*'
65
+ const ATTRIBUTENAME = '[a-zA-Z_:][a-zA-Z0-9:._-]*'
66
+ const UNQUOTEDVALUE = "[^\"'=<>`\\x00-\\x20]+"
67
+ const SINGLEQUOTEDVALUE = "'[^']*'"
68
+ const DOUBLEQUOTEDVALUE = '"[^"]*"'
69
+ const ATTRIBUTEVALUE =
70
+ '(?:' + UNQUOTEDVALUE + '|' + SINGLEQUOTEDVALUE + '|' + DOUBLEQUOTEDVALUE + ')'
71
+ const ATTRIBUTEVALUESPEC = '(?:\\s*=\\s*' + ATTRIBUTEVALUE + ')'
72
+ const ATTRIBUTE = '(?:\\s+' + ATTRIBUTENAME + ATTRIBUTEVALUESPEC + '?)'
73
+ const OPENTAG = '<' + TAGNAME + ATTRIBUTE + '*\\s*/?>'
74
+ const CLOSETAG = '</' + TAGNAME + '\\s*>'
75
+ // §6.6: `<!-->` and `<!--->` are comments in their own right; otherwise the
76
+ // body simply may not contain `-->`, which the lazy quantifier enforces.
77
+ const HTMLCOMMENT = '<!-->|<!--->|<!--[\\s\\S]*?-->'
78
+ const PROCESSINGINSTRUCTION = '[<][?][\\s\\S]*?[?][>]'
79
+ const DECLARATION = '<![A-Za-z][^>]*>'
80
+ const CDATA = '<!\\[CDATA\\[[\\s\\S]*?\\]\\]>'
81
+ const HTMLTAG =
82
+ '(?:' +
83
+ OPENTAG +
84
+ '|' +
85
+ CLOSETAG +
86
+ '|' +
87
+ HTMLCOMMENT +
88
+ '|' +
89
+ PROCESSINGINSTRUCTION +
90
+ '|' +
91
+ DECLARATION +
92
+ '|' +
93
+ CDATA +
94
+ ')'
95
+
96
+ const ESCAPED_CHAR = '\\\\' + ESCAPABLE
97
+
98
+ // Every regex handed to `match()` is sticky: it must match at `pos`, and
99
+ // `lastIndex` then gives the new position. (The equivalent of anchoring with
100
+ // `^` on a sliced subject, without the slicing.)
101
+ const reHtmlTag = new RegExp(HTMLTAG, 'iy')
102
+ const reEntityHere = new RegExp(ENTITY, 'iy')
103
+ const reEscapable = new RegExp('^' + ESCAPABLE)
104
+
105
+ const reTicksHere = /`+/y
106
+ const reTicks = /`+/g
107
+
108
+ // Chunks of ordinary text: everything up to the next character that could
109
+ // start an inline construct. `~` is only special under GFM, hence two.
110
+ const reMain = /[^\n`[\]\\!<&*_]+/y
111
+ const reMainGfm = /[^\n`[\]\\!<&*_~]+/y
112
+
113
+ /**
114
+ * Maximum `(` nesting inside a bare link destination. The spec describes
115
+ * balanced parentheses without stating a bound; cmark uses 32, and the bound
116
+ * is what keeps an unbalanced run from being quadratic (see
117
+ * parseLinkDestination).
118
+ */
119
+ const MAX_LINK_PAREN_NESTING = 32
120
+
121
+ const reSpnl = / *(?:\n *)?/y
122
+ const reInitialSpace = / */y
123
+ const reIndent = / {0,3}/y
124
+ const reFinalSpace = / *$/
125
+ const reSpaceAtEndOfLine = / *(?:\n|$)/y
126
+ const reWhitespaceChar = /[ \t\n\v\f\r]/
127
+
128
+ // §4.7: at most 999 characters between the brackets, brackets inside only if
129
+ // backslash-escaped. `s` so that an escaped line ending counts as an escape.
130
+ const reLinkLabel = /\[(?:[^\\[\]]|\\.){0,1000}\]/sy
131
+
132
+ // §6.3 link destination, pointy-bracket form: no line endings, no unescaped
133
+ // `<` or `>`.
134
+ const reLinkDestinationBraces = /<(?:[^<>\n\\\x00]|\\.)*>/y
135
+
136
+ // §6.3 link title in `"`, `'` or `(...)` delimiters. The escape alternative
137
+ // comes first so that `\"` inside a double-quoted title is consumed as an
138
+ // escape rather than closing the title.
139
+ const reLinkTitle = new RegExp(
140
+ '(?:"(?:' +
141
+ ESCAPED_CHAR +
142
+ '|[^"\\x00])*"' +
143
+ "|'(?:" +
144
+ ESCAPED_CHAR +
145
+ "|[^'\\x00])*'" +
146
+ '|\\((?:' +
147
+ ESCAPED_CHAR +
148
+ '|[^()\\x00])*\\))',
149
+ 'y'
150
+ )
151
+
152
+ // §6.5 autolinks. Scheme: an ASCII letter then 1–31 more of letter/digit/
153
+ // `+`/`.`/`-`, i.e. 2–32 characters in total, case-insensitive.
154
+ const reAutolink = /<[A-Za-z][A-Za-z0-9.+-]{1,31}:[^<>\x00-\x20]*>/y
155
+ const reEmailAutolink =
156
+ /<([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>/y
157
+
158
+ function text(literal: string): MdNode {
159
+ const node = new MdNode('text')
160
+ node.literal = literal
161
+ return node
162
+ }
163
+
164
+ // §2.1 punctuation is P* ∪ S*, which `isUnicodePunctuation` already covers.
165
+ // `*$*alpha.` is the case that needs the symbol half: with `$` counted as
166
+ // punctuation the closing `*` is not right-flanking, so the line stays
167
+ // literal text.
168
+ const isPunctuation = isUnicodePunctuation
169
+
170
+ /** One `*`, `_` or `~` run on the delimiter stack (§6.4). */
171
+ type Delimiter = {
172
+ /** Character code of the delimiter character. */
173
+ cc: number
174
+ /** Delimiters still unused; drops as pairs are consumed. */
175
+ numdelims: number
176
+ /** Run length as originally scanned — what the rule of three is computed on. */
177
+ origdelims: number
178
+ node: MdNode
179
+ previous: Delimiter | null
180
+ next: Delimiter | null
181
+ canOpen: boolean
182
+ canClose: boolean
183
+ }
184
+
185
+ /** One `[` or `![` on the bracket stack (§6.3). */
186
+ type Bracket = {
187
+ node: MdNode
188
+ previous: Bracket | null
189
+ /** Top of the delimiter stack when this bracket opened — the emphasis floor. */
190
+ previousDelimiter: Delimiter | null
191
+ /** Offset of the `[` in the subject. */
192
+ index: number
193
+ image: boolean
194
+ /** Cleared on earlier openers once a link is built: no links in links. */
195
+ active: boolean
196
+ /** Another bracket opened after this one, so its text contains a `[`. */
197
+ bracketAfter: boolean
198
+ }
199
+
200
+ type DelimScan = { numdelims: number; canOpen: boolean; canClose: boolean }
201
+
202
+ class InlineParser {
203
+ subject = ''
204
+ pos = 0
205
+ delimiters: Delimiter | null = null
206
+ brackets: Bracket | null = null
207
+ refmap: RefMap
208
+ options: ParserOptions
209
+
210
+ constructor(refmap: RefMap, options: ParserOptions) {
211
+ this.refmap = refmap
212
+ this.options = options
213
+ }
214
+
215
+ // --- scanning primitives ---
216
+
217
+ /** Character code at `pos`, or -1 at the end of the subject. */
218
+ peek(): number {
219
+ return this.pos < this.subject.length ? this.subject.charCodeAt(this.pos) : -1
220
+ }
221
+
222
+ /** Match a sticky regex at `pos`; on success advance past it. */
223
+ match(re: RegExp): string | null {
224
+ re.lastIndex = this.pos
225
+ const m = re.exec(this.subject)
226
+ if (null === m) return null
227
+ this.pos = re.lastIndex
228
+ return m[0]
229
+ }
230
+
231
+ /** Spaces, and at most one line ending, between the parts of a link. */
232
+ spnl(): void {
233
+ this.match(reSpnl)
234
+ }
235
+
236
+ /**
237
+ * The whole code point ending at `end`, so an astral character is
238
+ * classified as one character by the flanking rules rather than as a lone
239
+ * surrogate. Start of subject counts as a line ending (§6.4).
240
+ */
241
+ charBefore(end: number): string {
242
+ if (end <= 0) return '\n'
243
+ const lo = this.subject.charCodeAt(end - 1)
244
+ if (lo >= 0xdc00 && lo <= 0xdfff && end >= 2) {
245
+ const hi = this.subject.charCodeAt(end - 2)
246
+ if (hi >= 0xd800 && hi <= 0xdbff) return this.subject.slice(end - 2, end)
247
+ }
248
+ return this.subject.charAt(end - 1)
249
+ }
250
+
251
+ /** The whole code point at `i`; end of subject counts as a line ending. */
252
+ charAtPos(i: number): string {
253
+ if (i >= this.subject.length) return '\n'
254
+ const cp = this.subject.codePointAt(i)
255
+ return undefined === cp ? '\n' : String.fromCodePoint(cp)
256
+ }
257
+
258
+ // --- §6.3 code spans ---
259
+
260
+ /**
261
+ * A code span runs from a backtick string to the next backtick string of
262
+ * *exactly* the same length. Unmatched opening backticks are literal text.
263
+ */
264
+ parseBackticks(block: MdNode): boolean {
265
+ const ticks = this.match(reTicksHere)
266
+ if (null === ticks) return false
267
+
268
+ const afterOpenTicks = this.pos
269
+ reTicks.lastIndex = this.pos
270
+ let m: RegExpExecArray | null
271
+
272
+ while (null !== (m = reTicks.exec(this.subject))) {
273
+ this.pos = reTicks.lastIndex
274
+ if (m[0] === ticks) {
275
+ const node = new MdNode('code')
276
+ // Line endings become spaces; then one leading and one trailing
277
+ // space are stripped together, but only if the content is not all
278
+ // spaces (which is what lets a code span hold backticks).
279
+ const contents = this.subject
280
+ .slice(afterOpenTicks, this.pos - ticks.length)
281
+ .replace(/\n/g, ' ')
282
+ node.literal =
283
+ contents.length > 2 &&
284
+ ' ' === contents[0] &&
285
+ ' ' === contents[contents.length - 1] &&
286
+ null !== /[^ ]/.exec(contents)
287
+ ? contents.slice(1, contents.length - 1)
288
+ : contents
289
+ block.appendChild(node)
290
+ return true
291
+ }
292
+ }
293
+
294
+ // No closing run of the same length: the opener is literal text.
295
+ this.pos = afterOpenTicks
296
+ block.appendChild(text(ticks))
297
+ return true
298
+ }
299
+
300
+ // --- §6.1 backslash escapes, §6.7 hard line breaks ---
301
+
302
+ parseBackslash(block: MdNode): boolean {
303
+ this.pos += 1
304
+ if (C_NEWLINE === this.peek()) {
305
+ this.pos += 1
306
+ block.appendChild(new MdNode('linebreak'))
307
+ this.match(reInitialSpace)
308
+ } else if (reEscapable.test(this.subject.charAt(this.pos))) {
309
+ block.appendChild(text(this.subject.charAt(this.pos)))
310
+ this.pos += 1
311
+ } else {
312
+ block.appendChild(text('\\'))
313
+ }
314
+ return true
315
+ }
316
+
317
+ // --- §6.5 autolinks ---
318
+
319
+ parseAutolink(block: MdNode): boolean {
320
+ let m = this.match(reEmailAutolink)
321
+ if (null !== m) {
322
+ const dest = m.slice(1, m.length - 1)
323
+ block.appendChild(this.makeAutolink('mailto:' + dest, dest))
324
+ return true
325
+ }
326
+ m = this.match(reAutolink)
327
+ if (null !== m) {
328
+ const dest = m.slice(1, m.length - 1)
329
+ block.appendChild(this.makeAutolink(dest, dest))
330
+ return true
331
+ }
332
+ return false
333
+ }
334
+
335
+ makeAutolink(destination: string, label: string): MdNode {
336
+ // Autolink text is literal: no backslash escapes, no entity references.
337
+ const node = new MdNode('link')
338
+ // Not percent-encoded here — see parseLinkDestination.
339
+ node.destination = destination
340
+ node.title = null
341
+ node.appendChild(text(label))
342
+ return node
343
+ }
344
+
345
+ // --- §6.6 raw HTML ---
346
+
347
+ parseHtmlTag(block: MdNode): boolean {
348
+ const m = this.match(reHtmlTag)
349
+ if (null === m) return false
350
+ const node = new MdNode('html_inline')
351
+ node.literal = m
352
+ block.appendChild(node)
353
+ return true
354
+ }
355
+
356
+ // --- §6.4 emphasis: delimiter runs and flanking ---
357
+
358
+ /**
359
+ * Classify the run of `cc` starting at `pos` without consuming it.
360
+ *
361
+ * §6.4: a run is left-flanking iff it is not followed by Unicode
362
+ * whitespace and either not followed by Unicode punctuation or else
363
+ * preceded by whitespace or punctuation; right-flanking is the mirror
364
+ * image. `_` additionally may not open inside a word (rules 2, 4, 6, 8),
365
+ * which is what keeps `snake_case_words` intact; `*` has no such
366
+ * restriction.
367
+ */
368
+ scanDelims(cc: number): DelimScan | null {
369
+ const startpos = this.pos
370
+ let numdelims = 0
371
+
372
+ while (cc === this.peek()) {
373
+ numdelims += 1
374
+ this.pos += 1
375
+ }
376
+ if (0 === numdelims) return null
377
+
378
+ const charBefore = this.charBefore(startpos)
379
+ const charAfter = this.charAtPos(this.pos)
380
+
381
+ const afterIsWhitespace = isUnicodeWhitespace(charAfter)
382
+ const afterIsPunctuation = isPunctuation(charAfter)
383
+ const beforeIsWhitespace = isUnicodeWhitespace(charBefore)
384
+ const beforeIsPunctuation = isPunctuation(charBefore)
385
+
386
+ const leftFlanking =
387
+ !afterIsWhitespace &&
388
+ (!afterIsPunctuation || beforeIsWhitespace || beforeIsPunctuation)
389
+ const rightFlanking =
390
+ !beforeIsWhitespace &&
391
+ (!beforeIsPunctuation || afterIsWhitespace || afterIsPunctuation)
392
+
393
+ let canOpen: boolean
394
+ let canClose: boolean
395
+ if (C_UNDERSCORE === cc) {
396
+ canOpen = leftFlanking && (!rightFlanking || beforeIsPunctuation)
397
+ canClose = rightFlanking && (!leftFlanking || afterIsPunctuation)
398
+ } else {
399
+ canOpen = leftFlanking
400
+ canClose = rightFlanking
401
+ }
402
+
403
+ this.pos = startpos
404
+ return { numdelims, canOpen, canClose }
405
+ }
406
+
407
+ /** Emit a delimiter run as text and push it on the delimiter stack. */
408
+ handleDelim(cc: number, block: MdNode): boolean {
409
+ const res = this.scanDelims(cc)
410
+ if (null === res) return false
411
+
412
+ const numdelims = res.numdelims
413
+ const startpos = this.pos
414
+ this.pos += numdelims
415
+
416
+ const node = text(this.subject.slice(startpos, this.pos))
417
+ block.appendChild(node)
418
+
419
+ // GFM strikethrough only recognises runs of one or two tildes; longer
420
+ // runs stay literal.
421
+ const stackable = C_TILDE === cc ? numdelims <= 2 : true
422
+
423
+ if ((res.canOpen || res.canClose) && stackable) {
424
+ const delim: Delimiter = {
425
+ cc,
426
+ numdelims,
427
+ origdelims: numdelims,
428
+ node,
429
+ previous: this.delimiters,
430
+ next: null,
431
+ canOpen: res.canOpen,
432
+ canClose: res.canClose,
433
+ }
434
+ if (null !== delim.previous) delim.previous.next = delim
435
+ this.delimiters = delim
436
+ }
437
+
438
+ return true
439
+ }
440
+
441
+ removeDelimiter(delim: Delimiter): void {
442
+ if (null !== delim.previous) delim.previous.next = delim.next
443
+ if (null === delim.next) {
444
+ // Top of stack.
445
+ this.delimiters = delim.previous
446
+ } else {
447
+ delim.next.previous = delim.previous
448
+ }
449
+ }
450
+
451
+ /**
452
+ * The spec's "process emphasis" procedure. `stackBottom` is the floor: for
453
+ * the whole-block call it is null, and for the inlines of a link it is the
454
+ * delimiter that was on top when the link's `[` opened.
455
+ */
456
+ processEmphasis(stackBottom: Delimiter | null): void {
457
+ // openers_bottom, keyed by (delimiter char, whether the closer can also
458
+ // open, closer run length mod 3). Once a closer of some class fails to
459
+ // find an opener, no later closer of the same class needs to look past
460
+ // that point either — this is what keeps the search linear.
461
+ const openersBottom = new Map<string, Delimiter | null>()
462
+
463
+ // First closer above stackBottom.
464
+ let closer = this.delimiters
465
+ while (null !== closer && closer.previous !== stackBottom) {
466
+ closer = closer.previous
467
+ }
468
+
469
+ while (null !== closer) {
470
+ if (!closer.canClose) {
471
+ closer = closer.next
472
+ continue
473
+ }
474
+
475
+ const closercc = closer.cc
476
+ const key = closercc + ':' + (closer.canOpen ? 1 : 0) + ':' + (closer.origdelims % 3)
477
+ const bottom = openersBottom.has(key)
478
+ ? (openersBottom.get(key) as Delimiter | null)
479
+ : stackBottom
480
+
481
+ let opener = closer.previous
482
+ let openerFound = false
483
+ while (null !== opener && opener !== stackBottom && opener !== bottom) {
484
+ if (opener.cc === closercc && opener.canOpen) {
485
+ if (C_TILDE === closercc) {
486
+ // GFM strikethrough: opener and closer runs must be the same
487
+ // length, so `~~x~` is not a match.
488
+ if (opener.numdelims === closer.numdelims) {
489
+ openerFound = true
490
+ break
491
+ }
492
+ } else {
493
+ // §6.4 rules 9 and 10, the "rule of three": when either side of
494
+ // the pair could both open and close, the two run lengths may
495
+ // not sum to a multiple of 3 — unless both are themselves
496
+ // multiples of 3.
497
+ const oddMatch =
498
+ (closer.canOpen || opener.canClose) &&
499
+ 0 !== closer.origdelims % 3 &&
500
+ 0 === (opener.origdelims + closer.origdelims) % 3
501
+ if (!oddMatch) {
502
+ openerFound = true
503
+ break
504
+ }
505
+ }
506
+ }
507
+ opener = opener.previous
508
+ }
509
+
510
+ const oldCloser = closer
511
+
512
+ if (openerFound && null !== opener) {
513
+ // Two delimiters make strong, one makes emph; strikethrough always
514
+ // consumes the whole (equal-length) run.
515
+ const useDelims =
516
+ C_TILDE === closercc
517
+ ? closer.numdelims
518
+ : closer.numdelims >= 2 && opener.numdelims >= 2
519
+ ? 2
520
+ : 1
521
+ const nodeType: NodeType =
522
+ C_TILDE === closercc ? 'del' : 1 === useDelims ? 'emph' : 'strong'
523
+
524
+ const openerInl = opener.node
525
+ const closerInl = closer.node
526
+
527
+ opener.numdelims -= useDelims
528
+ closer.numdelims -= useDelims
529
+ openerInl.literal = (openerInl.literal ?? '').slice(
530
+ 0,
531
+ (openerInl.literal ?? '').length - useDelims
532
+ )
533
+ closerInl.literal = (closerInl.literal ?? '').slice(
534
+ 0,
535
+ (closerInl.literal ?? '').length - useDelims
536
+ )
537
+
538
+ // Everything between the two delimiter text nodes becomes the
539
+ // content of the new node.
540
+ const emph = new MdNode(nodeType)
541
+ let tmp = openerInl.next
542
+ while (null !== tmp && tmp !== closerInl) {
543
+ const nxt = tmp.next
544
+ tmp.unlink()
545
+ emph.appendChild(tmp)
546
+ tmp = nxt
547
+ }
548
+ openerInl.insertAfter(emph)
549
+
550
+ // Delimiters between the pair can never match anything now.
551
+ if (opener.next !== closer) {
552
+ opener.next = closer
553
+ closer.previous = opener
554
+ }
555
+
556
+ if (0 === opener.numdelims) {
557
+ openerInl.unlink()
558
+ this.removeDelimiter(opener)
559
+ }
560
+ if (0 === closer.numdelims) {
561
+ closerInl.unlink()
562
+ const tempstack = closer.next
563
+ this.removeDelimiter(closer)
564
+ closer = tempstack
565
+ }
566
+ } else {
567
+ closer = closer.next
568
+ openersBottom.set(key, oldCloser.previous)
569
+ if (!oldCloser.canOpen) {
570
+ // It found no opener and cannot be one: it is inert.
571
+ this.removeDelimiter(oldCloser)
572
+ }
573
+ }
574
+ }
575
+
576
+ // Drop everything above the floor.
577
+ while (null !== this.delimiters && this.delimiters !== stackBottom) {
578
+ this.removeDelimiter(this.delimiters)
579
+ }
580
+ }
581
+
582
+ // --- §6.3 links and images ---
583
+
584
+ /** Link title without its delimiters, unescaped; null if none here. */
585
+ parseLinkTitle(): string | null {
586
+ const title = this.match(reLinkTitle)
587
+ if (null === title) return null
588
+ return unescapeString(title.slice(1, title.length - 1))
589
+ }
590
+
591
+ /**
592
+ * Link destination: either `<...>`, or a bare run with balanced unescaped
593
+ * parentheses and no ASCII control characters or spaces.
594
+ */
595
+ /**
596
+ * A destination is backslash-unescaped and entity-decoded, but **not**
597
+ * percent-encoded: `[l](/ä)` yields `/ä`, not `/%C3%A4`.
598
+ *
599
+ * Encoding is a rendering concern and `html.ts` applies `normalizeURI` when
600
+ * it writes the attribute. Doing it here too would be invisible in the HTML
601
+ * (`normalizeURI` is idempotent over its own output) but would put the
602
+ * encoded form in the public AST, where mdast — and every previous release
603
+ * of this package — promises the decoded one.
604
+ */
605
+ parseLinkDestination(): string | null {
606
+ const braced = this.match(reLinkDestinationBraces)
607
+ if (null !== braced) {
608
+ return unescapeString(braced.slice(1, braced.length - 1))
609
+ }
610
+ if (C_LESSTHAN === this.peek()) return null
611
+
612
+ const savepos = this.pos
613
+ let openparens = 0
614
+ let c = this.peek()
615
+
616
+ while (-1 !== c) {
617
+ if (C_BACKSLASH === c && reEscapable.test(this.subject.charAt(this.pos + 1))) {
618
+ this.pos += 1
619
+ if (-1 !== this.peek()) this.pos += 1
620
+ } else if (C_OPEN_PAREN === c) {
621
+ // Bail rather than scan on once nesting passes the limit. Without
622
+ // this, input like `[a](b` repeated makes every `]` scan to end of
623
+ // input looking for parens that never balance, which is quadratic in
624
+ // the document size. cmark caps nesting at the same depth, and no
625
+ // spec example nests more than two deep.
626
+ if (MAX_LINK_PAREN_NESTING <= openparens) return null
627
+ this.pos += 1
628
+ openparens += 1
629
+ } else if (C_CLOSE_PAREN === c) {
630
+ if (openparens < 1) break
631
+ this.pos += 1
632
+ openparens -= 1
633
+ } else if (c <= C_SPACE || C_DEL === c) {
634
+ // Spaces and ASCII control characters end the bare form.
635
+ break
636
+ } else {
637
+ this.pos += 1
638
+ }
639
+ c = this.peek()
640
+ }
641
+
642
+ // An empty destination is only legal immediately before the closing
643
+ // paren of an inline link, as in `[link]()`.
644
+ if (this.pos === savepos && C_CLOSE_PAREN !== c) return null
645
+ if (0 !== openparens) return null
646
+
647
+ return unescapeString(this.subject.slice(savepos, this.pos))
648
+ }
649
+
650
+ /** Length of the link label at `pos`, including brackets; 0 if invalid. */
651
+ parseLinkLabel(): number {
652
+ const m = this.match(reLinkLabel)
653
+ // §4.7: at most 999 characters between the brackets.
654
+ if (null === m || m.length > 1001) return 0
655
+ return m.length
656
+ }
657
+
658
+ addBracket(node: MdNode, index: number, image: boolean): void {
659
+ if (null !== this.brackets) this.brackets.bracketAfter = true
660
+ this.brackets = {
661
+ node,
662
+ previous: this.brackets,
663
+ previousDelimiter: this.delimiters,
664
+ index,
665
+ image,
666
+ active: true,
667
+ bracketAfter: false,
668
+ }
669
+ }
670
+
671
+ removeBracket(): void {
672
+ if (null !== this.brackets) this.brackets = this.brackets.previous
673
+ }
674
+
675
+ parseOpenBracket(block: MdNode): boolean {
676
+ const startpos = this.pos
677
+ this.pos += 1
678
+ const node = text('[')
679
+ block.appendChild(node)
680
+ this.addBracket(node, startpos, false)
681
+ return true
682
+ }
683
+
684
+ /** `!` only matters when it introduces an image. */
685
+ parseBang(block: MdNode): boolean {
686
+ const startpos = this.pos
687
+ this.pos += 1
688
+ if (C_OPEN_BRACKET === this.peek()) {
689
+ this.pos += 1
690
+ const node = text('![')
691
+ block.appendChild(node)
692
+ this.addBracket(node, startpos + 1, true)
693
+ } else {
694
+ block.appendChild(text('!'))
695
+ }
696
+ return true
697
+ }
698
+
699
+ /**
700
+ * The spec's "look for link or image" procedure. On a `]`, walk back to
701
+ * the newest bracket opener and try, in order: an inline `(dest "title")`,
702
+ * a full `[label]`, a collapsed `[]` or a shortcut reference. Anything that
703
+ * fails backtracks to just after the `]` and leaves a literal `]`.
704
+ */
705
+ parseCloseBracket(block: MdNode): boolean {
706
+ this.pos += 1
707
+ const startpos = this.pos
708
+
709
+ const opener = this.brackets
710
+ if (null === opener) {
711
+ block.appendChild(text(']'))
712
+ return true
713
+ }
714
+ if (!opener.active) {
715
+ // Deactivated by an enclosing link: not an opener any more.
716
+ block.appendChild(text(']'))
717
+ this.removeBracket()
718
+ return true
719
+ }
720
+
721
+ const isImage = opener.image
722
+ let dest: string | null = null
723
+ let title: string | null = null
724
+ let matched = false
725
+
726
+ const savepos = this.pos
727
+
728
+ // Inline link: `](` destination title `)`.
729
+ if (C_OPEN_PAREN === this.peek()) {
730
+ this.pos += 1
731
+ this.spnl()
732
+ const d = this.parseLinkDestination()
733
+ if (null !== d) {
734
+ this.spnl()
735
+ // A title has to be separated from the destination by whitespace,
736
+ // so `[a](/url"t")` is not a titled link.
737
+ let t: string | null = null
738
+ if (reWhitespaceChar.test(this.subject.charAt(this.pos - 1))) {
739
+ t = this.parseLinkTitle()
740
+ }
741
+ this.spnl()
742
+ if (C_CLOSE_PAREN === this.peek()) {
743
+ this.pos += 1
744
+ dest = d
745
+ title = t
746
+ matched = true
747
+ }
748
+ }
749
+ if (!matched) this.pos = savepos
750
+ }
751
+
752
+ if (!matched) {
753
+ // Reference forms. A second label gives the full form; an empty or
754
+ // absent one means the link text is itself the label (collapsed and
755
+ // shortcut) — which is only possible if that text holds no bracket.
756
+ let reflabel: string | null = null
757
+ const beforelabel = this.pos
758
+ const n = this.parseLinkLabel()
759
+ if (n > 2) {
760
+ reflabel = this.subject.slice(beforelabel + 1, beforelabel + n - 1)
761
+ } else if (!opener.bracketAfter) {
762
+ reflabel = this.subject.slice(opener.index + 1, startpos - 1)
763
+ }
764
+ if (0 === n) this.pos = savepos
765
+
766
+ if (null !== reflabel) {
767
+ const link = this.refmap[normalizeReference(reflabel)]
768
+ if (undefined !== link) {
769
+ dest = link.destination
770
+ title = link.title
771
+ matched = true
772
+ }
773
+ }
774
+ }
775
+
776
+ if (!matched) {
777
+ this.removeBracket()
778
+ this.pos = startpos
779
+ block.appendChild(text(']'))
780
+ return true
781
+ }
782
+
783
+ const node = new MdNode(isImage ? 'image' : 'link')
784
+ node.destination = dest
785
+ node.title = null === title || '' === title ? null : title
786
+
787
+ let tmp = opener.node.next
788
+ while (null !== tmp) {
789
+ const nxt = tmp.next
790
+ tmp.unlink()
791
+ node.appendChild(tmp)
792
+ tmp = nxt
793
+ }
794
+ block.appendChild(node)
795
+
796
+ // Emphasis inside the link text resolves now, and only down to the
797
+ // delimiter that was current when the `[` opened.
798
+ this.processEmphasis(opener.previousDelimiter)
799
+ this.removeBracket()
800
+ opener.node.unlink()
801
+
802
+ // Links may not contain links (§6.3): every earlier link opener is
803
+ // spent. Image openers survive, since images may nest in links.
804
+ if (!isImage) {
805
+ let b = this.brackets
806
+ while (null !== b) {
807
+ if (!b.image) b.active = false
808
+ b = b.previous
809
+ }
810
+ }
811
+
812
+ return true
813
+ }
814
+
815
+ // --- §6.2 entities, §6.8/§6.9 breaks and plain text ---
816
+
817
+ parseEntity(block: MdNode): boolean {
818
+ const m = this.match(reEntityHere)
819
+ if (null === m) return false
820
+ block.appendChild(text(decodeEntity(m)))
821
+ return true
822
+ }
823
+
824
+ parseString(block: MdNode): boolean {
825
+ const m = this.match(this.options.gfm ? reMainGfm : reMain)
826
+ if (null === m) return false
827
+ block.appendChild(text(m))
828
+ return true
829
+ }
830
+
831
+ /**
832
+ * §6.7/§6.8: two or more spaces before the line ending make a hard break,
833
+ * anything else a soft one. Trailing spaces are dropped either way, as is
834
+ * the indentation of the next line.
835
+ */
836
+ parseNewline(block: MdNode): boolean {
837
+ this.pos += 1
838
+ const lastc = block.lastChild
839
+ if (
840
+ null !== lastc &&
841
+ 'text' === lastc.type &&
842
+ null !== lastc.literal &&
843
+ ' ' === lastc.literal.charAt(lastc.literal.length - 1)
844
+ ) {
845
+ const hardbreak = ' ' === lastc.literal.charAt(lastc.literal.length - 2)
846
+ lastc.literal = lastc.literal.replace(reFinalSpace, '')
847
+ block.appendChild(new MdNode(hardbreak ? 'linebreak' : 'softbreak'))
848
+ } else {
849
+ block.appendChild(new MdNode('softbreak'))
850
+ }
851
+ this.match(reInitialSpace)
852
+ return true
853
+ }
854
+
855
+ /** One inline construct at `pos`; false only at the end of the subject. */
856
+ parseInline(block: MdNode): boolean {
857
+ const c = this.peek()
858
+ if (-1 === c) return false
859
+
860
+ let res = false
861
+ switch (c) {
862
+ case C_NEWLINE:
863
+ res = this.parseNewline(block)
864
+ break
865
+ case C_BACKSLASH:
866
+ res = this.parseBackslash(block)
867
+ break
868
+ case C_BACKTICK:
869
+ res = this.parseBackticks(block)
870
+ break
871
+ case C_ASTERISK:
872
+ case C_UNDERSCORE:
873
+ res = this.handleDelim(c, block)
874
+ break
875
+ case C_TILDE:
876
+ // Only GFM gives `~` a meaning; otherwise it is ordinary text (and
877
+ // the non-GFM `reMain` lets parseString swallow whole runs of it).
878
+ res = this.options.gfm ? this.handleDelim(c, block) : this.parseString(block)
879
+ break
880
+ case C_OPEN_BRACKET:
881
+ res = this.parseOpenBracket(block)
882
+ break
883
+ case C_BANG:
884
+ res = this.parseBang(block)
885
+ break
886
+ case C_CLOSE_BRACKET:
887
+ res = this.parseCloseBracket(block)
888
+ break
889
+ case C_LESSTHAN:
890
+ res = this.parseAutolink(block) || this.parseHtmlTag(block)
891
+ break
892
+ case C_AMPERSAND:
893
+ res = this.parseEntity(block)
894
+ break
895
+ default:
896
+ res = this.parseString(block)
897
+ break
898
+ }
899
+
900
+ if (!res) {
901
+ // Nothing claimed it: the character is literal text.
902
+ this.pos += 1
903
+ block.appendChild(text(String.fromCharCode(c)))
904
+ }
905
+
906
+ return true
907
+ }
908
+
909
+ /** Parse one paragraph or heading's raw content into inline children. */
910
+ parse(block: MdNode): void {
911
+ // Trimming is what stops trailing spaces at the end of a block from
912
+ // making a hard break, and drops the leading indentation of the first line.
913
+ this.subject = block.stringContent.trim()
914
+ this.pos = 0
915
+ this.delimiters = null
916
+ this.brackets = null
917
+
918
+ while (this.parseInline(block));
919
+
920
+ block.stringContent = ''
921
+ this.processEmphasis(null)
922
+ }
923
+
924
+ /**
925
+ * §4.7: consume leading link reference definitions. Called by the block
926
+ * phase while finalizing a paragraph, repeatedly, until it returns 0.
927
+ */
928
+ parseReference(s: string): number {
929
+ this.subject = s
930
+ this.pos = 0
931
+
932
+ const startpos = this.pos
933
+
934
+ // §4.7 allows up to three spaces of indentation before the label. The
935
+ // block phase normally strips a paragraph line's indentation before it
936
+ // gets here, so this usually consumes nothing.
937
+ this.match(reIndent)
938
+
939
+ const labelStart = this.pos
940
+ const matchChars = this.parseLinkLabel()
941
+ if (0 === matchChars) return 0
942
+ const rawlabel = this.subject.slice(labelStart + 1, labelStart + matchChars - 1)
943
+
944
+ if (C_COLON === this.peek()) {
945
+ this.pos += 1
946
+ } else {
947
+ this.pos = startpos
948
+ return 0
949
+ }
950
+
951
+ this.spnl()
952
+
953
+ const dest = this.parseLinkDestination()
954
+ if (null === dest) {
955
+ this.pos = startpos
956
+ return 0
957
+ }
958
+
959
+ const beforetitle = this.pos
960
+ this.spnl()
961
+ let title: string | null = null
962
+ if (this.pos !== beforetitle) {
963
+ title = this.parseLinkTitle()
964
+ }
965
+ if (null === title) {
966
+ this.pos = beforetitle
967
+ }
968
+
969
+ // Nothing but spaces may follow on the definition's last line.
970
+ let atLineEnd = true
971
+ if (null === this.match(reSpaceAtEndOfLine)) {
972
+ if (null === title) {
973
+ atLineEnd = false
974
+ } else {
975
+ // What looked like a title is something else; the definition may
976
+ // still be valid without it.
977
+ title = null
978
+ this.pos = beforetitle
979
+ atLineEnd = null !== this.match(reSpaceAtEndOfLine)
980
+ }
981
+ }
982
+ if (!atLineEnd) {
983
+ this.pos = startpos
984
+ return 0
985
+ }
986
+
987
+ const normlabel = normalizeReference(rawlabel)
988
+ if ('' === normlabel) {
989
+ // A label must hold at least one non-whitespace character.
990
+ this.pos = startpos
991
+ return 0
992
+ }
993
+
994
+ // First definition wins.
995
+ if (undefined === this.refmap[normlabel]) {
996
+ this.refmap[normlabel] = { destination: dest, title }
997
+ }
998
+
999
+ return this.pos - startpos
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * Phase 2 entry point: walk the block tree and parse the string content of
1005
+ * every paragraph and heading into inline children.
1006
+ */
1007
+ export function parseInlines(doc: MdNode, refmap: RefMap, options: ParserOptions): void {
1008
+ const parser = new InlineParser(refmap, options)
1009
+ const walker = doc.walker()
1010
+
1011
+ let ev = walker.next()
1012
+ while (null !== ev) {
1013
+ const node = ev.node
1014
+ if (!ev.entering && ('paragraph' === node.type || 'heading' === node.type)) {
1015
+ parser.parse(node)
1016
+ }
1017
+ ev = walker.next()
1018
+ }
1019
+ }
1020
+
1021
+ // Reference definitions are parsed during the block phase, before any
1022
+ // InlineParser for the document exists, so this one is kept aside for it.
1023
+ // Nothing here depends on the parse options.
1024
+ const referenceParser = new InlineParser({}, { gfm: false, breaks: false })
1025
+
1026
+ /**
1027
+ * Consume link reference definitions from the front of a paragraph's raw
1028
+ * content, adding them to `refmap`. Returns the number of characters
1029
+ * consumed, or 0 if `s` does not start with a definition.
1030
+ */
1031
+ export function parseReference(s: string, refmap: RefMap): number {
1032
+ referenceParser.refmap = refmap
1033
+ return referenceParser.parseReference(s)
1034
+ }