@tabnas/markdown 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/inline.ts ADDED
@@ -0,0 +1,1495 @@
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_TAB = 9
40
+ const C_NEWLINE = 10
41
+ const C_VTAB = 11
42
+ const C_FORMFEED = 12
43
+ const C_CARRIAGE_RETURN = 13
44
+ const C_SPACE = 32
45
+ const C_BANG = 33
46
+ const C_AMPERSAND = 38
47
+ const C_OPEN_PAREN = 40
48
+ const C_CLOSE_PAREN = 41
49
+ const C_ASTERISK = 42
50
+ const C_PLUS = 43
51
+ const C_COMMA = 44
52
+ const C_HYPHEN = 45
53
+ const C_PERIOD = 46
54
+ const C_COLON = 58
55
+ const C_SEMICOLON = 59
56
+ const C_LESSTHAN = 60
57
+ const C_QUESTION = 63
58
+ const C_AT = 64
59
+ const C_OPEN_BRACKET = 91
60
+ const C_BACKSLASH = 92
61
+ const C_CLOSE_BRACKET = 93
62
+ const C_UNDERSCORE = 95
63
+ const C_BACKTICK = 96
64
+ const C_TILDE = 126
65
+ const C_DEL = 127
66
+
67
+ // --- §6.6 Raw HTML: the tag grammar, spelled out because it is stricter than
68
+ // `<[^>]*>` in every part. Attribute names are XML-restricted-to-ASCII;
69
+ // unquoted attribute values exclude quotes, `=`, `<`, `>`, backtick and all
70
+ // control characters; comments/PIs/declarations/CDATA each have their own
71
+ // terminator. `\s*` stands in for "spaces, tabs, and up to one line ending":
72
+ // a paragraph can never contain a blank line, so a single `\s*` can only ever
73
+ // span one line ending anyway.
74
+
75
+ const TAGNAME = '[A-Za-z][A-Za-z0-9-]*'
76
+ const ATTRIBUTENAME = '[a-zA-Z_:][a-zA-Z0-9:._-]*'
77
+ const UNQUOTEDVALUE = "[^\"'=<>`\\x00-\\x20]+"
78
+ const SINGLEQUOTEDVALUE = "'[^']*'"
79
+ const DOUBLEQUOTEDVALUE = '"[^"]*"'
80
+ const ATTRIBUTEVALUE =
81
+ '(?:' + UNQUOTEDVALUE + '|' + SINGLEQUOTEDVALUE + '|' + DOUBLEQUOTEDVALUE + ')'
82
+ const ATTRIBUTEVALUESPEC = '(?:\\s*=\\s*' + ATTRIBUTEVALUE + ')'
83
+ const ATTRIBUTE = '(?:\\s+' + ATTRIBUTENAME + ATTRIBUTEVALUESPEC + '?)'
84
+ const OPENTAG = '<' + TAGNAME + ATTRIBUTE + '*\\s*/?>'
85
+ const CLOSETAG = '</' + TAGNAME + '\\s*>'
86
+ // §6.6: `<!-->` and `<!--->` are comments in their own right; otherwise the
87
+ // body simply may not contain `-->`, which the lazy quantifier enforces.
88
+ const HTMLCOMMENT = '<!-->|<!--->|<!--[\\s\\S]*?-->'
89
+ const PROCESSINGINSTRUCTION = '[<][?][\\s\\S]*?[?][>]'
90
+ const DECLARATION = '<![A-Za-z][^>]*>'
91
+ const CDATA = '<!\\[CDATA\\[[\\s\\S]*?\\]\\]>'
92
+ const HTMLTAG =
93
+ '(?:' +
94
+ OPENTAG +
95
+ '|' +
96
+ CLOSETAG +
97
+ '|' +
98
+ HTMLCOMMENT +
99
+ '|' +
100
+ PROCESSINGINSTRUCTION +
101
+ '|' +
102
+ DECLARATION +
103
+ '|' +
104
+ CDATA +
105
+ ')'
106
+
107
+ const ESCAPED_CHAR = '\\\\' + ESCAPABLE
108
+
109
+ // Every regex handed to `match()` is sticky: it must match at `pos`, and
110
+ // `lastIndex` then gives the new position. (The equivalent of anchoring with
111
+ // `^` on a sliced subject, without the slicing.)
112
+ const reHtmlTag = new RegExp(HTMLTAG, 'iy')
113
+ const reEntityHere = new RegExp(ENTITY, 'iy')
114
+ const reEscapable = new RegExp('^' + ESCAPABLE)
115
+
116
+ const reTicksHere = /`+/y
117
+ const reTicks = /`+/g
118
+
119
+ // Chunks of ordinary text: everything up to the next character that could
120
+ // start an inline construct. `~` is only special under GFM, hence two.
121
+ const reMain = /[^\n`[\]\\!<&*_]+/y
122
+ const reMainGfm = /[^\n`[\]\\!<&*_~]+/y
123
+
124
+ /**
125
+ * Maximum `(` nesting inside a bare link destination. The spec describes
126
+ * balanced parentheses without stating a bound; cmark uses 32, and the bound
127
+ * is what keeps an unbalanced run from being quadratic (see
128
+ * parseLinkDestination).
129
+ */
130
+ const MAX_LINK_PAREN_NESTING = 32
131
+
132
+ const reSpnl = / *(?:\n *)?/y
133
+ const reInitialSpace = / */y
134
+ const reIndent = / {0,3}/y
135
+ const reFinalSpace = / *$/
136
+ const reSpaceAtEndOfLine = / *(?:\n|$)/y
137
+ const reWhitespaceChar = /[ \t\n\v\f\r]/
138
+
139
+ // §4.7: at most 999 characters between the brackets, brackets inside only if
140
+ // backslash-escaped. `s` so that an escaped line ending counts as an escape.
141
+ const reLinkLabel = /\[(?:[^\\[\]]|\\.){0,1000}\]/sy
142
+
143
+ // §6.3 link destination, pointy-bracket form: no line endings, no unescaped
144
+ // `<` or `>`.
145
+ const reLinkDestinationBraces = /<(?:[^<>\n\\\x00]|\\.)*>/y
146
+
147
+ // §6.3 link title in `"`, `'` or `(...)` delimiters. The escape alternative
148
+ // comes first so that `\"` inside a double-quoted title is consumed as an
149
+ // escape rather than closing the title.
150
+ const reLinkTitle = new RegExp(
151
+ '(?:"(?:' +
152
+ ESCAPED_CHAR +
153
+ '|[^"\\x00])*"' +
154
+ "|'(?:" +
155
+ ESCAPED_CHAR +
156
+ "|[^'\\x00])*'" +
157
+ '|\\((?:' +
158
+ ESCAPED_CHAR +
159
+ '|[^()\\x00])*\\))',
160
+ 'y'
161
+ )
162
+
163
+ // §6.5 autolinks. Scheme: an ASCII letter then 1–31 more of letter/digit/
164
+ // `+`/`.`/`-`, i.e. 2–32 characters in total, case-insensitive.
165
+ const reAutolink = /<[A-Za-z][A-Za-z0-9.+-]{1,31}:[^<>\x00-\x20]*>/y
166
+ const reEmailAutolink =
167
+ /<([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
168
+
169
+ function text(literal: string): MdNode {
170
+ const node = new MdNode('text')
171
+ node.literal = literal
172
+ return node
173
+ }
174
+
175
+ // §2.1 punctuation is P* ∪ S*, which `isUnicodePunctuation` already covers.
176
+ // `*$*alpha.` is the case that needs the symbol half: with `$` counted as
177
+ // punctuation the closing `*` is not right-flanking, so the line stays
178
+ // literal text.
179
+ const isPunctuation = isUnicodePunctuation
180
+
181
+ /** One `*`, `_` or `~` run on the delimiter stack (§6.4). */
182
+ type Delimiter = {
183
+ /** Character code of the delimiter character. */
184
+ cc: number
185
+ /** Delimiters still unused; drops as pairs are consumed. */
186
+ numdelims: number
187
+ /** Run length as originally scanned — what the rule of three is computed on. */
188
+ origdelims: number
189
+ node: MdNode
190
+ previous: Delimiter | null
191
+ next: Delimiter | null
192
+ canOpen: boolean
193
+ canClose: boolean
194
+ }
195
+
196
+ /** One `[` or `![` on the bracket stack (§6.3). */
197
+ type Bracket = {
198
+ node: MdNode
199
+ previous: Bracket | null
200
+ /** Top of the delimiter stack when this bracket opened — the emphasis floor. */
201
+ previousDelimiter: Delimiter | null
202
+ /** Offset of the `[` in the subject. */
203
+ index: number
204
+ image: boolean
205
+ /** Cleared on earlier openers once a link is built: no links in links. */
206
+ active: boolean
207
+ /** Another bracket opened after this one, so its text contains a `[`. */
208
+ bracketAfter: boolean
209
+ }
210
+
211
+ type DelimScan = { numdelims: number; canOpen: boolean; canClose: boolean }
212
+
213
+ class InlineParser {
214
+ subject = ''
215
+ pos = 0
216
+ delimiters: Delimiter | null = null
217
+ brackets: Bracket | null = null
218
+ refmap: RefMap
219
+ options: ParserOptions
220
+
221
+ constructor(refmap: RefMap, options: ParserOptions) {
222
+ this.refmap = refmap
223
+ this.options = options
224
+ }
225
+
226
+ // --- scanning primitives ---
227
+
228
+ /** Character code at `pos`, or -1 at the end of the subject. */
229
+ peek(): number {
230
+ return this.pos < this.subject.length ? this.subject.charCodeAt(this.pos) : -1
231
+ }
232
+
233
+ /** Match a sticky regex at `pos`; on success advance past it. */
234
+ match(re: RegExp): string | null {
235
+ re.lastIndex = this.pos
236
+ const m = re.exec(this.subject)
237
+ if (null === m) return null
238
+ this.pos = re.lastIndex
239
+ return m[0]
240
+ }
241
+
242
+ /** Spaces, and at most one line ending, between the parts of a link. */
243
+ spnl(): void {
244
+ this.match(reSpnl)
245
+ }
246
+
247
+ /**
248
+ * The whole code point ending at `end`, so an astral character is
249
+ * classified as one character by the flanking rules rather than as a lone
250
+ * surrogate. Start of subject counts as a line ending (§6.4).
251
+ */
252
+ charBefore(end: number): string {
253
+ if (end <= 0) return '\n'
254
+ const lo = this.subject.charCodeAt(end - 1)
255
+ if (lo >= 0xdc00 && lo <= 0xdfff && end >= 2) {
256
+ const hi = this.subject.charCodeAt(end - 2)
257
+ if (hi >= 0xd800 && hi <= 0xdbff) return this.subject.slice(end - 2, end)
258
+ }
259
+ return this.subject.charAt(end - 1)
260
+ }
261
+
262
+ /** The whole code point at `i`; end of subject counts as a line ending. */
263
+ charAtPos(i: number): string {
264
+ if (i >= this.subject.length) return '\n'
265
+ const cp = this.subject.codePointAt(i)
266
+ return undefined === cp ? '\n' : String.fromCodePoint(cp)
267
+ }
268
+
269
+ // --- §6.3 code spans ---
270
+
271
+ /**
272
+ * A code span runs from a backtick string to the next backtick string of
273
+ * *exactly* the same length. Unmatched opening backticks are literal text.
274
+ */
275
+ parseBackticks(block: MdNode): boolean {
276
+ const ticks = this.match(reTicksHere)
277
+ if (null === ticks) return false
278
+
279
+ const afterOpenTicks = this.pos
280
+ reTicks.lastIndex = this.pos
281
+ let m: RegExpExecArray | null
282
+
283
+ while (null !== (m = reTicks.exec(this.subject))) {
284
+ this.pos = reTicks.lastIndex
285
+ if (m[0] === ticks) {
286
+ const node = new MdNode('code')
287
+ // Line endings become spaces; then one leading and one trailing
288
+ // space are stripped together, but only if the content is not all
289
+ // spaces (which is what lets a code span hold backticks).
290
+ const contents = this.subject
291
+ .slice(afterOpenTicks, this.pos - ticks.length)
292
+ .replace(/\n/g, ' ')
293
+ node.literal =
294
+ contents.length > 2 &&
295
+ ' ' === contents[0] &&
296
+ ' ' === contents[contents.length - 1] &&
297
+ null !== /[^ ]/.exec(contents)
298
+ ? contents.slice(1, contents.length - 1)
299
+ : contents
300
+ block.appendChild(node)
301
+ return true
302
+ }
303
+ }
304
+
305
+ // No closing run of the same length: the opener is literal text.
306
+ this.pos = afterOpenTicks
307
+ block.appendChild(text(ticks))
308
+ return true
309
+ }
310
+
311
+ // --- §6.1 backslash escapes, §6.7 hard line breaks ---
312
+
313
+ parseBackslash(block: MdNode): boolean {
314
+ this.pos += 1
315
+ if (C_NEWLINE === this.peek()) {
316
+ this.pos += 1
317
+ block.appendChild(new MdNode('linebreak'))
318
+ this.match(reInitialSpace)
319
+ } else if (reEscapable.test(this.subject.charAt(this.pos))) {
320
+ block.appendChild(text(this.subject.charAt(this.pos)))
321
+ this.pos += 1
322
+ } else {
323
+ block.appendChild(text('\\'))
324
+ }
325
+ return true
326
+ }
327
+
328
+ // --- §6.5 autolinks ---
329
+
330
+ parseAutolink(block: MdNode): boolean {
331
+ let m = this.match(reEmailAutolink)
332
+ if (null !== m) {
333
+ const dest = m.slice(1, m.length - 1)
334
+ block.appendChild(this.makeAutolink('mailto:' + dest, dest))
335
+ return true
336
+ }
337
+ m = this.match(reAutolink)
338
+ if (null !== m) {
339
+ const dest = m.slice(1, m.length - 1)
340
+ block.appendChild(this.makeAutolink(dest, dest))
341
+ return true
342
+ }
343
+ return false
344
+ }
345
+
346
+ makeAutolink(destination: string, label: string): MdNode {
347
+ // Autolink text is literal: no backslash escapes, no entity references.
348
+ const node = new MdNode('link')
349
+ // Not percent-encoded here — see parseLinkDestination.
350
+ node.destination = destination
351
+ node.title = null
352
+ node.appendChild(text(label))
353
+ return node
354
+ }
355
+
356
+ // --- §6.6 raw HTML ---
357
+
358
+ parseHtmlTag(block: MdNode): boolean {
359
+ const m = this.match(reHtmlTag)
360
+ if (null === m) return false
361
+ const node = new MdNode('html_inline')
362
+ node.literal = m
363
+ block.appendChild(node)
364
+ return true
365
+ }
366
+
367
+ // --- §6.4 emphasis: delimiter runs and flanking ---
368
+
369
+ /**
370
+ * Classify the run of `cc` starting at `pos` without consuming it.
371
+ *
372
+ * §6.4: a run is left-flanking iff it is not followed by Unicode
373
+ * whitespace and either not followed by Unicode punctuation or else
374
+ * preceded by whitespace or punctuation; right-flanking is the mirror
375
+ * image. `_` additionally may not open inside a word (rules 2, 4, 6, 8),
376
+ * which is what keeps `snake_case_words` intact; `*` has no such
377
+ * restriction.
378
+ */
379
+ scanDelims(cc: number): DelimScan | null {
380
+ const startpos = this.pos
381
+ let numdelims = 0
382
+
383
+ while (cc === this.peek()) {
384
+ numdelims += 1
385
+ this.pos += 1
386
+ }
387
+ if (0 === numdelims) return null
388
+
389
+ const charBefore = this.charBefore(startpos)
390
+ const charAfter = this.charAtPos(this.pos)
391
+
392
+ const afterIsWhitespace = isUnicodeWhitespace(charAfter)
393
+ const afterIsPunctuation = isPunctuation(charAfter)
394
+ const beforeIsWhitespace = isUnicodeWhitespace(charBefore)
395
+ const beforeIsPunctuation = isPunctuation(charBefore)
396
+
397
+ const leftFlanking =
398
+ !afterIsWhitespace &&
399
+ (!afterIsPunctuation || beforeIsWhitespace || beforeIsPunctuation)
400
+ const rightFlanking =
401
+ !beforeIsWhitespace &&
402
+ (!beforeIsPunctuation || afterIsWhitespace || afterIsPunctuation)
403
+
404
+ let canOpen: boolean
405
+ let canClose: boolean
406
+ if (C_UNDERSCORE === cc) {
407
+ canOpen = leftFlanking && (!rightFlanking || beforeIsPunctuation)
408
+ canClose = rightFlanking && (!leftFlanking || afterIsPunctuation)
409
+ } else {
410
+ canOpen = leftFlanking
411
+ canClose = rightFlanking
412
+ }
413
+
414
+ this.pos = startpos
415
+ return { numdelims, canOpen, canClose }
416
+ }
417
+
418
+ /** Emit a delimiter run as text and push it on the delimiter stack. */
419
+ handleDelim(cc: number, block: MdNode): boolean {
420
+ const res = this.scanDelims(cc)
421
+ if (null === res) return false
422
+
423
+ const numdelims = res.numdelims
424
+ const startpos = this.pos
425
+ this.pos += numdelims
426
+
427
+ const node = text(this.subject.slice(startpos, this.pos))
428
+ block.appendChild(node)
429
+
430
+ // GFM strikethrough only recognises runs of one or two tildes; longer
431
+ // runs stay literal.
432
+ const stackable = C_TILDE === cc ? numdelims <= 2 : true
433
+
434
+ if ((res.canOpen || res.canClose) && stackable) {
435
+ const delim: Delimiter = {
436
+ cc,
437
+ numdelims,
438
+ origdelims: numdelims,
439
+ node,
440
+ previous: this.delimiters,
441
+ next: null,
442
+ canOpen: res.canOpen,
443
+ canClose: res.canClose,
444
+ }
445
+ if (null !== delim.previous) delim.previous.next = delim
446
+ this.delimiters = delim
447
+ }
448
+
449
+ return true
450
+ }
451
+
452
+ removeDelimiter(delim: Delimiter): void {
453
+ if (null !== delim.previous) delim.previous.next = delim.next
454
+ if (null === delim.next) {
455
+ // Top of stack.
456
+ this.delimiters = delim.previous
457
+ } else {
458
+ delim.next.previous = delim.previous
459
+ }
460
+ }
461
+
462
+ /**
463
+ * The spec's "process emphasis" procedure. `stackBottom` is the floor: for
464
+ * the whole-block call it is null, and for the inlines of a link it is the
465
+ * delimiter that was on top when the link's `[` opened.
466
+ */
467
+ processEmphasis(stackBottom: Delimiter | null): void {
468
+ // openers_bottom, keyed by (delimiter char, whether the closer can also
469
+ // open, closer run length mod 3). Once a closer of some class fails to
470
+ // find an opener, no later closer of the same class needs to look past
471
+ // that point either — this is what keeps the search linear.
472
+ const openersBottom = new Map<string, Delimiter | null>()
473
+
474
+ // First closer above stackBottom.
475
+ let closer = this.delimiters
476
+ while (null !== closer && closer.previous !== stackBottom) {
477
+ closer = closer.previous
478
+ }
479
+
480
+ while (null !== closer) {
481
+ if (!closer.canClose) {
482
+ closer = closer.next
483
+ continue
484
+ }
485
+
486
+ const closercc = closer.cc
487
+ const key = closercc + ':' + (closer.canOpen ? 1 : 0) + ':' + (closer.origdelims % 3)
488
+ const bottom = openersBottom.has(key)
489
+ ? (openersBottom.get(key) as Delimiter | null)
490
+ : stackBottom
491
+
492
+ let opener = closer.previous
493
+ let openerFound = false
494
+ while (null !== opener && opener !== stackBottom && opener !== bottom) {
495
+ if (opener.cc === closercc && opener.canOpen) {
496
+ if (C_TILDE === closercc) {
497
+ // GFM strikethrough: opener and closer runs must be the same
498
+ // length, so `~~x~` is not a match.
499
+ if (opener.numdelims === closer.numdelims) {
500
+ openerFound = true
501
+ break
502
+ }
503
+ } else {
504
+ // §6.4 rules 9 and 10, the "rule of three": when either side of
505
+ // the pair could both open and close, the two run lengths may
506
+ // not sum to a multiple of 3 — unless both are themselves
507
+ // multiples of 3.
508
+ const oddMatch =
509
+ (closer.canOpen || opener.canClose) &&
510
+ 0 !== closer.origdelims % 3 &&
511
+ 0 === (opener.origdelims + closer.origdelims) % 3
512
+ if (!oddMatch) {
513
+ openerFound = true
514
+ break
515
+ }
516
+ }
517
+ }
518
+ opener = opener.previous
519
+ }
520
+
521
+ const oldCloser = closer
522
+
523
+ if (openerFound && null !== opener) {
524
+ // Two delimiters make strong, one makes emph; strikethrough always
525
+ // consumes the whole (equal-length) run.
526
+ const useDelims =
527
+ C_TILDE === closercc
528
+ ? closer.numdelims
529
+ : closer.numdelims >= 2 && opener.numdelims >= 2
530
+ ? 2
531
+ : 1
532
+ const nodeType: NodeType =
533
+ C_TILDE === closercc ? 'del' : 1 === useDelims ? 'emph' : 'strong'
534
+
535
+ const openerInl = opener.node
536
+ const closerInl = closer.node
537
+
538
+ opener.numdelims -= useDelims
539
+ closer.numdelims -= useDelims
540
+ openerInl.literal = (openerInl.literal ?? '').slice(
541
+ 0,
542
+ (openerInl.literal ?? '').length - useDelims
543
+ )
544
+ closerInl.literal = (closerInl.literal ?? '').slice(
545
+ 0,
546
+ (closerInl.literal ?? '').length - useDelims
547
+ )
548
+
549
+ // Everything between the two delimiter text nodes becomes the
550
+ // content of the new node.
551
+ const emph = new MdNode(nodeType)
552
+ let tmp = openerInl.next
553
+ while (null !== tmp && tmp !== closerInl) {
554
+ const nxt = tmp.next
555
+ tmp.unlink()
556
+ emph.appendChild(tmp)
557
+ tmp = nxt
558
+ }
559
+ openerInl.insertAfter(emph)
560
+
561
+ // Delimiters between the pair can never match anything now.
562
+ if (opener.next !== closer) {
563
+ opener.next = closer
564
+ closer.previous = opener
565
+ }
566
+
567
+ if (0 === opener.numdelims) {
568
+ openerInl.unlink()
569
+ this.removeDelimiter(opener)
570
+ }
571
+ if (0 === closer.numdelims) {
572
+ closerInl.unlink()
573
+ const tempstack = closer.next
574
+ this.removeDelimiter(closer)
575
+ closer = tempstack
576
+ }
577
+ } else {
578
+ closer = closer.next
579
+ openersBottom.set(key, oldCloser.previous)
580
+ if (!oldCloser.canOpen) {
581
+ // It found no opener and cannot be one: it is inert.
582
+ this.removeDelimiter(oldCloser)
583
+ }
584
+ }
585
+ }
586
+
587
+ // Drop everything above the floor.
588
+ while (null !== this.delimiters && this.delimiters !== stackBottom) {
589
+ this.removeDelimiter(this.delimiters)
590
+ }
591
+ }
592
+
593
+ // --- §6.3 links and images ---
594
+
595
+ /** Link title without its delimiters, unescaped; null if none here. */
596
+ parseLinkTitle(): string | null {
597
+ const title = this.match(reLinkTitle)
598
+ if (null === title) return null
599
+ return unescapeString(title.slice(1, title.length - 1))
600
+ }
601
+
602
+ /**
603
+ * Link destination: either `<...>`, or a bare run with balanced unescaped
604
+ * parentheses and no ASCII control characters or spaces.
605
+ */
606
+ /**
607
+ * A destination is backslash-unescaped and entity-decoded, but **not**
608
+ * percent-encoded: `[l](/ä)` yields `/ä`, not `/%C3%A4`.
609
+ *
610
+ * Encoding is a rendering concern and `html.ts` applies `normalizeURI` when
611
+ * it writes the attribute. Doing it here too would be invisible in the HTML
612
+ * (`normalizeURI` is idempotent over its own output) but would put the
613
+ * encoded form in the public AST, where mdast — and every previous release
614
+ * of this package — promises the decoded one.
615
+ */
616
+ parseLinkDestination(): string | null {
617
+ const braced = this.match(reLinkDestinationBraces)
618
+ if (null !== braced) {
619
+ return unescapeString(braced.slice(1, braced.length - 1))
620
+ }
621
+ if (C_LESSTHAN === this.peek()) return null
622
+
623
+ const savepos = this.pos
624
+ let openparens = 0
625
+ let c = this.peek()
626
+
627
+ while (-1 !== c) {
628
+ if (C_BACKSLASH === c && reEscapable.test(this.subject.charAt(this.pos + 1))) {
629
+ this.pos += 1
630
+ if (-1 !== this.peek()) this.pos += 1
631
+ } else if (C_OPEN_PAREN === c) {
632
+ // Bail rather than scan on once nesting passes the limit. Without
633
+ // this, input like `[a](b` repeated makes every `]` scan to end of
634
+ // input looking for parens that never balance, which is quadratic in
635
+ // the document size. cmark caps nesting at the same depth, and no
636
+ // spec example nests more than two deep.
637
+ if (MAX_LINK_PAREN_NESTING <= openparens) return null
638
+ this.pos += 1
639
+ openparens += 1
640
+ } else if (C_CLOSE_PAREN === c) {
641
+ if (openparens < 1) break
642
+ this.pos += 1
643
+ openparens -= 1
644
+ } else if (c <= C_SPACE || C_DEL === c) {
645
+ // Spaces and ASCII control characters end the bare form.
646
+ break
647
+ } else {
648
+ this.pos += 1
649
+ }
650
+ c = this.peek()
651
+ }
652
+
653
+ // An empty destination is only legal immediately before the closing
654
+ // paren of an inline link, as in `[link]()`.
655
+ if (this.pos === savepos && C_CLOSE_PAREN !== c) return null
656
+ if (0 !== openparens) return null
657
+
658
+ return unescapeString(this.subject.slice(savepos, this.pos))
659
+ }
660
+
661
+ /** Length of the link label at `pos`, including brackets; 0 if invalid. */
662
+ parseLinkLabel(): number {
663
+ const m = this.match(reLinkLabel)
664
+ // §4.7: at most 999 characters between the brackets.
665
+ if (null === m || m.length > 1001) return 0
666
+ return m.length
667
+ }
668
+
669
+ addBracket(node: MdNode, index: number, image: boolean): void {
670
+ if (null !== this.brackets) this.brackets.bracketAfter = true
671
+ this.brackets = {
672
+ node,
673
+ previous: this.brackets,
674
+ previousDelimiter: this.delimiters,
675
+ index,
676
+ image,
677
+ active: true,
678
+ bracketAfter: false,
679
+ }
680
+ }
681
+
682
+ removeBracket(): void {
683
+ if (null !== this.brackets) this.brackets = this.brackets.previous
684
+ }
685
+
686
+ parseOpenBracket(block: MdNode): boolean {
687
+ const startpos = this.pos
688
+ this.pos += 1
689
+ const node = text('[')
690
+ block.appendChild(node)
691
+ this.addBracket(node, startpos, false)
692
+ return true
693
+ }
694
+
695
+ /** `!` only matters when it introduces an image. */
696
+ parseBang(block: MdNode): boolean {
697
+ const startpos = this.pos
698
+ this.pos += 1
699
+ if (C_OPEN_BRACKET === this.peek()) {
700
+ this.pos += 1
701
+ const node = text('![')
702
+ block.appendChild(node)
703
+ this.addBracket(node, startpos + 1, true)
704
+ } else {
705
+ block.appendChild(text('!'))
706
+ }
707
+ return true
708
+ }
709
+
710
+ /**
711
+ * The spec's "look for link or image" procedure. On a `]`, walk back to
712
+ * the newest bracket opener and try, in order: an inline `(dest "title")`,
713
+ * a full `[label]`, a collapsed `[]` or a shortcut reference. Anything that
714
+ * fails backtracks to just after the `]` and leaves a literal `]`.
715
+ */
716
+ parseCloseBracket(block: MdNode): boolean {
717
+ this.pos += 1
718
+ const startpos = this.pos
719
+
720
+ const opener = this.brackets
721
+ if (null === opener) {
722
+ block.appendChild(text(']'))
723
+ return true
724
+ }
725
+ if (!opener.active) {
726
+ // Deactivated by an enclosing link: not an opener any more.
727
+ block.appendChild(text(']'))
728
+ this.removeBracket()
729
+ return true
730
+ }
731
+
732
+ const isImage = opener.image
733
+ let dest: string | null = null
734
+ let title: string | null = null
735
+ let matched = false
736
+
737
+ const savepos = this.pos
738
+
739
+ // Inline link: `](` destination title `)`.
740
+ if (C_OPEN_PAREN === this.peek()) {
741
+ this.pos += 1
742
+ this.spnl()
743
+ const d = this.parseLinkDestination()
744
+ if (null !== d) {
745
+ this.spnl()
746
+ // A title has to be separated from the destination by whitespace,
747
+ // so `[a](/url"t")` is not a titled link.
748
+ let t: string | null = null
749
+ if (reWhitespaceChar.test(this.subject.charAt(this.pos - 1))) {
750
+ t = this.parseLinkTitle()
751
+ }
752
+ this.spnl()
753
+ if (C_CLOSE_PAREN === this.peek()) {
754
+ this.pos += 1
755
+ dest = d
756
+ title = t
757
+ matched = true
758
+ }
759
+ }
760
+ if (!matched) this.pos = savepos
761
+ }
762
+
763
+ if (!matched) {
764
+ // Reference forms. A second label gives the full form; an empty or
765
+ // absent one means the link text is itself the label (collapsed and
766
+ // shortcut) — which is only possible if that text holds no bracket.
767
+ let reflabel: string | null = null
768
+ const beforelabel = this.pos
769
+ const n = this.parseLinkLabel()
770
+ if (n > 2) {
771
+ reflabel = this.subject.slice(beforelabel + 1, beforelabel + n - 1)
772
+ } else if (!opener.bracketAfter) {
773
+ reflabel = this.subject.slice(opener.index + 1, startpos - 1)
774
+ }
775
+ if (0 === n) this.pos = savepos
776
+
777
+ if (null !== reflabel) {
778
+ const link = this.refmap[normalizeReference(reflabel)]
779
+ if (undefined !== link) {
780
+ dest = link.destination
781
+ title = link.title
782
+ matched = true
783
+ }
784
+ }
785
+ }
786
+
787
+ if (!matched) {
788
+ this.removeBracket()
789
+ this.pos = startpos
790
+ block.appendChild(text(']'))
791
+ return true
792
+ }
793
+
794
+ const node = new MdNode(isImage ? 'image' : 'link')
795
+ node.destination = dest
796
+ node.title = null === title || '' === title ? null : title
797
+
798
+ let tmp = opener.node.next
799
+ while (null !== tmp) {
800
+ const nxt = tmp.next
801
+ tmp.unlink()
802
+ node.appendChild(tmp)
803
+ tmp = nxt
804
+ }
805
+ block.appendChild(node)
806
+
807
+ // Emphasis inside the link text resolves now, and only down to the
808
+ // delimiter that was current when the `[` opened.
809
+ this.processEmphasis(opener.previousDelimiter)
810
+ this.removeBracket()
811
+ opener.node.unlink()
812
+
813
+ // Links may not contain links (§6.3): every earlier link opener is
814
+ // spent. Image openers survive, since images may nest in links.
815
+ if (!isImage) {
816
+ let b = this.brackets
817
+ while (null !== b) {
818
+ if (!b.image) b.active = false
819
+ b = b.previous
820
+ }
821
+ }
822
+
823
+ return true
824
+ }
825
+
826
+ // --- §6.2 entities, §6.8/§6.9 breaks and plain text ---
827
+
828
+ parseEntity(block: MdNode): boolean {
829
+ const m = this.match(reEntityHere)
830
+ if (null === m) return false
831
+ block.appendChild(text(decodeEntity(m)))
832
+ return true
833
+ }
834
+
835
+ parseString(block: MdNode): boolean {
836
+ const m = this.match(this.options.gfm ? reMainGfm : reMain)
837
+ if (null === m) return false
838
+ block.appendChild(text(m))
839
+ return true
840
+ }
841
+
842
+ /**
843
+ * §6.7/§6.8: two or more spaces before the line ending make a hard break,
844
+ * anything else a soft one. Trailing spaces are dropped either way, as is
845
+ * the indentation of the next line.
846
+ */
847
+ parseNewline(block: MdNode): boolean {
848
+ this.pos += 1
849
+ const lastc = block.lastChild
850
+ if (
851
+ null !== lastc &&
852
+ 'text' === lastc.type &&
853
+ null !== lastc.literal &&
854
+ ' ' === lastc.literal.charAt(lastc.literal.length - 1)
855
+ ) {
856
+ const hardbreak = ' ' === lastc.literal.charAt(lastc.literal.length - 2)
857
+ lastc.literal = lastc.literal.replace(reFinalSpace, '')
858
+ block.appendChild(new MdNode(hardbreak ? 'linebreak' : 'softbreak'))
859
+ } else {
860
+ block.appendChild(new MdNode('softbreak'))
861
+ }
862
+ this.match(reInitialSpace)
863
+ return true
864
+ }
865
+
866
+ /** One inline construct at `pos`; false only at the end of the subject. */
867
+ parseInline(block: MdNode): boolean {
868
+ const c = this.peek()
869
+ if (-1 === c) return false
870
+
871
+ let res = false
872
+ switch (c) {
873
+ case C_NEWLINE:
874
+ res = this.parseNewline(block)
875
+ break
876
+ case C_BACKSLASH:
877
+ res = this.parseBackslash(block)
878
+ break
879
+ case C_BACKTICK:
880
+ res = this.parseBackticks(block)
881
+ break
882
+ case C_ASTERISK:
883
+ case C_UNDERSCORE:
884
+ res = this.handleDelim(c, block)
885
+ break
886
+ case C_TILDE:
887
+ // Only GFM gives `~` a meaning; otherwise it is ordinary text (and
888
+ // the non-GFM `reMain` lets parseString swallow whole runs of it).
889
+ res = this.options.gfm ? this.handleDelim(c, block) : this.parseString(block)
890
+ break
891
+ case C_OPEN_BRACKET:
892
+ res = this.parseOpenBracket(block)
893
+ break
894
+ case C_BANG:
895
+ res = this.parseBang(block)
896
+ break
897
+ case C_CLOSE_BRACKET:
898
+ res = this.parseCloseBracket(block)
899
+ break
900
+ case C_LESSTHAN:
901
+ res = this.parseAutolink(block) || this.parseHtmlTag(block)
902
+ break
903
+ case C_AMPERSAND:
904
+ res = this.parseEntity(block)
905
+ break
906
+ default:
907
+ res = this.parseString(block)
908
+ break
909
+ }
910
+
911
+ if (!res) {
912
+ // Nothing claimed it: the character is literal text.
913
+ this.pos += 1
914
+ block.appendChild(text(String.fromCharCode(c)))
915
+ }
916
+
917
+ return true
918
+ }
919
+
920
+ /** Parse one paragraph or heading's raw content into inline children. */
921
+ parse(block: MdNode): void {
922
+ // Trimming is what stops trailing spaces at the end of a block from
923
+ // making a hard break, and drops the leading indentation of the first line.
924
+ this.subject = block.stringContent.trim()
925
+ this.pos = 0
926
+ this.delimiters = null
927
+ this.brackets = null
928
+
929
+ while (this.parseInline(block));
930
+
931
+ block.stringContent = ''
932
+ this.processEmphasis(null)
933
+ }
934
+
935
+ /**
936
+ * §4.7: consume leading link reference definitions. Called by the block
937
+ * phase while finalizing a paragraph, repeatedly, until it returns 0.
938
+ */
939
+ parseReference(s: string): number {
940
+ this.subject = s
941
+ this.pos = 0
942
+
943
+ const startpos = this.pos
944
+
945
+ // §4.7 allows up to three spaces of indentation before the label. The
946
+ // block phase normally strips a paragraph line's indentation before it
947
+ // gets here, so this usually consumes nothing.
948
+ this.match(reIndent)
949
+
950
+ const labelStart = this.pos
951
+ const matchChars = this.parseLinkLabel()
952
+ if (0 === matchChars) return 0
953
+ const rawlabel = this.subject.slice(labelStart + 1, labelStart + matchChars - 1)
954
+
955
+ if (C_COLON === this.peek()) {
956
+ this.pos += 1
957
+ } else {
958
+ this.pos = startpos
959
+ return 0
960
+ }
961
+
962
+ this.spnl()
963
+
964
+ const dest = this.parseLinkDestination()
965
+ if (null === dest) {
966
+ this.pos = startpos
967
+ return 0
968
+ }
969
+
970
+ const beforetitle = this.pos
971
+ this.spnl()
972
+ let title: string | null = null
973
+ if (this.pos !== beforetitle) {
974
+ title = this.parseLinkTitle()
975
+ }
976
+ if (null === title) {
977
+ this.pos = beforetitle
978
+ }
979
+
980
+ // Nothing but spaces may follow on the definition's last line.
981
+ let atLineEnd = true
982
+ if (null === this.match(reSpaceAtEndOfLine)) {
983
+ if (null === title) {
984
+ atLineEnd = false
985
+ } else {
986
+ // What looked like a title is something else; the definition may
987
+ // still be valid without it.
988
+ title = null
989
+ this.pos = beforetitle
990
+ atLineEnd = null !== this.match(reSpaceAtEndOfLine)
991
+ }
992
+ }
993
+ if (!atLineEnd) {
994
+ this.pos = startpos
995
+ return 0
996
+ }
997
+
998
+ const normlabel = normalizeReference(rawlabel)
999
+ if ('' === normlabel) {
1000
+ // A label must hold at least one non-whitespace character.
1001
+ this.pos = startpos
1002
+ return 0
1003
+ }
1004
+
1005
+ // First definition wins.
1006
+ if (undefined === this.refmap[normlabel]) {
1007
+ this.refmap[normlabel] = { destination: dest, title }
1008
+ }
1009
+
1010
+ return this.pos - startpos
1011
+ }
1012
+ }
1013
+
1014
+ // --- GFM extended autolinks (autolink literals) -----------------------------
1015
+ //
1016
+ // GFM recognises `www.x.com`, `https://x.com` and `a@x.com` without the `<…>`
1017
+ // CommonMark requires. This runs as a post-pass over the finished inline tree
1018
+ // rather than as another branch of the scan above, which is how cmark-gfm does
1019
+ // it and is the only shape that keeps 652/652 safe: the scanner that decides
1020
+ // code spans, raw HTML, emphasis and links is not touched at all, and an
1021
+ // autolink can therefore never win against a construct CommonMark says comes
1022
+ // first.
1023
+ //
1024
+ // The pass rewrites `text` nodes into text/link/text runs. It does not descend
1025
+ // into `link` (links may not contain links) or `image` (its children are the
1026
+ // alt text), and `code`/`html_inline` are leaves it never looks inside.
1027
+
1028
+ /** RFC 1035's limit on a fully-qualified domain name. See `scanDomainEnd`. */
1029
+ const MAX_AUTOLINK_DOMAIN = 253
1030
+
1031
+ /** RFC 5321's limit on the local part of an address, before the `@`. */
1032
+ const MAX_EMAIL_LOCAL = 64
1033
+
1034
+ /** The three schemes the extension recognises without angle brackets. */
1035
+ const AUTOLINK_SCHEMES = ['https://', 'http://', 'ftp://']
1036
+
1037
+ function isAsciiAlnum(c: number): boolean {
1038
+ return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122)
1039
+ }
1040
+
1041
+ function isAutolinkSpace(c: number): boolean {
1042
+ return (
1043
+ C_SPACE === c ||
1044
+ C_TAB === c ||
1045
+ C_NEWLINE === c ||
1046
+ C_VTAB === c ||
1047
+ C_FORMFEED === c ||
1048
+ C_CARRIAGE_RETURN === c
1049
+ )
1050
+ }
1051
+
1052
+ /** A domain segment holds alphanumerics, `_` and `-`; `.` separates them. */
1053
+ function isDomainChar(c: number): boolean {
1054
+ return isAsciiAlnum(c) || C_UNDERSCORE === c || C_HYPHEN === c || C_PERIOD === c
1055
+ }
1056
+
1057
+ /** Before the `@`: alphanumerics and `.`, `-`, `_`, `+`. */
1058
+ function isEmailLocalChar(c: number): boolean {
1059
+ return isAsciiAlnum(c) || C_PERIOD === c || C_HYPHEN === c || C_UNDERSCORE === c || C_PLUS === c
1060
+ }
1061
+
1062
+ /**
1063
+ * "All such recognized autolinks can only come at the beginning of a line,
1064
+ * after whitespace, or any of the delimiting characters `*`, `_`, `~`, `(`."
1065
+ *
1066
+ * Offset 0 has no preceding character in `s` at all, so the caller answers for
1067
+ * it through `atStart` — see `startsAfterDelimiter`.
1068
+ */
1069
+ function isAutolinkStart(s: string, i: number, atStart: boolean): boolean {
1070
+ if (0 === i) return atStart
1071
+ const c = s.charCodeAt(i - 1)
1072
+ return (
1073
+ isAutolinkSpace(c) ||
1074
+ C_ASTERISK === c ||
1075
+ C_UNDERSCORE === c ||
1076
+ C_TILDE === c ||
1077
+ C_OPEN_PAREN === c
1078
+ )
1079
+ }
1080
+
1081
+ /**
1082
+ * Whether offset 0 of a text node may begin an autolink, given the sibling in
1083
+ * front of it.
1084
+ *
1085
+ * The rule above is about the source character immediately before the match,
1086
+ * and by the time this pass runs the tree no longer carries source offsets —
1087
+ * but the previous sibling's *type* names that character exactly:
1088
+ *
1089
+ * * `emph` and `strong` end on the `*` or `_` that closed them, and `del` on
1090
+ * its second `~`. All three are delimiters the rule allows, so
1091
+ * `*a*www.b.com` links.
1092
+ * * `code` ends on a backtick, `link` and `image` on `)`, `]` or `>`, and
1093
+ * `html_inline` on `>`. None are delimiters, so `` `x`www.a.com ``,
1094
+ * `[l](/u)www.a.com` and `<b>www.a.com` do not link.
1095
+ * * a `softbreak` or `linebreak` puts offset 0 at the beginning of a line,
1096
+ * and so does having no previous sibling — that is the first inline of the
1097
+ * block, or of the emphasis span this pass descended into.
1098
+ *
1099
+ * A `text` node never appears here: `linkifyChildren` merges every run of
1100
+ * adjacent text siblings before handing the first of them over.
1101
+ */
1102
+ function startsAfterDelimiter(prev: MdNode | null): boolean {
1103
+ if (null === prev) return true
1104
+ const t = prev.type
1105
+ return 'softbreak' === t || 'linebreak' === t || 'emph' === t || 'strong' === t || 'del' === t
1106
+ }
1107
+
1108
+ /** ASCII case-insensitive comparison against an already-lowercase literal. */
1109
+ function matchesIgnoreCase(s: string, at: number, lower: string): boolean {
1110
+ if (at + lower.length > s.length) return false
1111
+ for (let k = 0; k < lower.length; k++) {
1112
+ let c = s.charCodeAt(at + k)
1113
+ if (c >= 65 && c <= 90) c += 32
1114
+ if (c !== lower.charCodeAt(k)) return false
1115
+ }
1116
+ return true
1117
+ }
1118
+
1119
+ /**
1120
+ * End of the run of domain characters at `from`, capped at
1121
+ * `MAX_AUTOLINK_DOMAIN`.
1122
+ *
1123
+ * The cap is what keeps this pass linear. `_` both continues a domain and
1124
+ * introduces a valid autolink start, so without a bound an input like
1125
+ * `'_www.a_b.c'.repeat(n)` gives every underscore a candidate whose domain run
1126
+ * reaches the end of the text — quadratic. Anything past the cap is still
1127
+ * scanned, just as the path rather than as the domain, and no real domain
1128
+ * comes near it.
1129
+ */
1130
+ function scanDomainEnd(s: string, from: number): number {
1131
+ const max = Math.min(s.length, from + MAX_AUTOLINK_DOMAIN)
1132
+ let i = from
1133
+ while (i < max && isDomainChar(s.charCodeAt(i))) i++
1134
+ return i
1135
+ }
1136
+
1137
+ /**
1138
+ * A valid domain: at least one period, and no underscore in either of the last
1139
+ * two segments. `from`..`to` must already be a run of domain characters.
1140
+ */
1141
+ function isValidDomain(s: string, from: number, to: number): boolean {
1142
+ if (to <= from) return false
1143
+ let periods = 0
1144
+ let underscoresInLast = 0
1145
+ let underscoresInPrev = 0
1146
+ for (let i = from; i < to; i++) {
1147
+ const c = s.charCodeAt(i)
1148
+ if (C_UNDERSCORE === c) {
1149
+ underscoresInLast++
1150
+ } else if (C_PERIOD === c) {
1151
+ underscoresInPrev = underscoresInLast
1152
+ underscoresInLast = 0
1153
+ periods++
1154
+ }
1155
+ }
1156
+ return 0 < periods && 0 === underscoresInLast && 0 === underscoresInPrev
1157
+ }
1158
+
1159
+ /**
1160
+ * "Extended autolink path validation" — pull the end of the match back off
1161
+ * trailing characters that read as sentence punctuation rather than as part of
1162
+ * a URL. One loop rather than three passes, because dropping a `)` can expose
1163
+ * a `.` and vice versa.
1164
+ *
1165
+ * * `?` `!` `.` `,` `:` `*` `_` `~` are dropped wherever they trail;
1166
+ * * a trailing `)` is dropped only while the match holds more `)` than `(`,
1167
+ * so `(www.a.com/x_(y))` keeps its inner pair and loses the outer one;
1168
+ * * a trailing `;` preceded by something shaped like an entity reference
1169
+ * takes the whole `&…;` with it.
1170
+ *
1171
+ * The parenthesis counts are taken once and then maintained, not recomputed
1172
+ * per drop: a deeply parenthesised URL is otherwise quadratic in its own
1173
+ * length.
1174
+ */
1175
+ function trimAutolinkEnd(s: string, start: number, end: number): number {
1176
+ let opening = -1
1177
+ let closing = -1
1178
+
1179
+ while (start < end) {
1180
+ const c = s.charCodeAt(end - 1)
1181
+
1182
+ if (
1183
+ C_QUESTION === c ||
1184
+ C_BANG === c ||
1185
+ C_PERIOD === c ||
1186
+ C_COMMA === c ||
1187
+ C_COLON === c ||
1188
+ C_ASTERISK === c ||
1189
+ C_UNDERSCORE === c ||
1190
+ C_TILDE === c
1191
+ ) {
1192
+ end--
1193
+ continue
1194
+ }
1195
+
1196
+ if (C_CLOSE_PAREN === c) {
1197
+ if (opening < 0) {
1198
+ opening = 0
1199
+ closing = 0
1200
+ for (let i = start; i < end; i++) {
1201
+ const d = s.charCodeAt(i)
1202
+ if (C_OPEN_PAREN === d) opening++
1203
+ else if (C_CLOSE_PAREN === d) closing++
1204
+ }
1205
+ }
1206
+ if (closing <= opening) return end
1207
+ closing--
1208
+ end--
1209
+ continue
1210
+ }
1211
+
1212
+ if (C_SEMICOLON === c) {
1213
+ // `&` followed by one or more alphanumerics, then this `;`.
1214
+ let j = end - 2
1215
+ while (j > start && isAsciiAlnum(s.charCodeAt(j))) j--
1216
+ if (j < end - 2 && C_AMPERSAND === s.charCodeAt(j)) {
1217
+ end = j
1218
+ continue
1219
+ }
1220
+ return end
1221
+ }
1222
+
1223
+ return end
1224
+ }
1225
+
1226
+ return end
1227
+ }
1228
+
1229
+ /** One recognised autolink literal: `s.slice(start, end)` linking to `href`. */
1230
+ type AutolinkMatch = { start: number; end: number; href: string }
1231
+
1232
+ /**
1233
+ * Every autolink literal in one text run, left to right and non-overlapping.
1234
+ * Returns null when there is nothing to do, which is the overwhelmingly common
1235
+ * case and saves the caller an array and a splice.
1236
+ */
1237
+ function scanAutolinks(s: string, atStart: boolean): AutolinkMatch[] | null {
1238
+ const len = s.length
1239
+ let out: AutolinkMatch[] | null = null
1240
+
1241
+ // The maximal run of non-space, non-`<` characters containing the current
1242
+ // position — "zero or more non-space non-`<` characters may follow" the
1243
+ // domain. Cached because candidates are tried left to right, so each run is
1244
+ // scanned once however many candidates start inside it.
1245
+ let runStart = -1
1246
+ let runEnd = -1
1247
+
1248
+ let i = 0
1249
+ let lastEnd = 0
1250
+
1251
+ while (i < len) {
1252
+ const c = s.charCodeAt(i)
1253
+
1254
+ // --- email: found by its `@`, then rewound to the start of the local part
1255
+ if (C_AT === c) {
1256
+ // The rewind is bounded to keep this pass linear, and RFC 5321 bounds a
1257
+ // local part in the same place anyway, so the cap costs no real address.
1258
+ const capped = i - MAX_EMAIL_LOCAL
1259
+ const floor = Math.max(lastEnd, capped)
1260
+ let k = i
1261
+ while (k > floor && isEmailLocalChar(s.charCodeAt(k - 1))) k--
1262
+
1263
+ // Stopping *on* the cap is not the same as finding a boundary. If the
1264
+ // character just outside it still belongs to the local part, the local
1265
+ // part is longer than the cap allows and there is no address here at
1266
+ // all: accepting the match would link the 64-character tail of a longer
1267
+ // run and leave the rest as text, inventing an address the source never
1268
+ // wrote. `k` can only reach `capped` when the cap, rather than `lastEnd`,
1269
+ // is what ended the loop; and `k` of 0 is the start of the string, where
1270
+ // there is no character outside the cap to disqualify anything.
1271
+ if (k === capped && 0 < k && isEmailLocalChar(s.charCodeAt(k - 1))) {
1272
+ i++
1273
+ continue
1274
+ }
1275
+
1276
+ if (k < i && isAutolinkStart(s, k, atStart)) {
1277
+ // After the `@`: alphanumerics, `.`, `-`, `_`; at least one period; a
1278
+ // trailing `.` is not part of the address, and a trailing `-` or `_`
1279
+ // invalidates the whole thing.
1280
+ let end = i + 1
1281
+ while (end < len && isDomainChar(s.charCodeAt(end))) end++
1282
+ while (end > i + 1 && C_PERIOD === s.charCodeAt(end - 1)) end--
1283
+
1284
+ const last = end > i + 1 ? s.charCodeAt(end - 1) : 0
1285
+ if (
1286
+ end > i + 1 &&
1287
+ C_HYPHEN !== last &&
1288
+ C_UNDERSCORE !== last &&
1289
+ hasPeriod(s, i + 1, end)
1290
+ ) {
1291
+ if (null === out) out = []
1292
+ out.push({ start: k, end, href: 'mailto:' + s.slice(k, end) })
1293
+ lastEnd = end
1294
+ i = end
1295
+ continue
1296
+ }
1297
+ }
1298
+ i++
1299
+ continue
1300
+ }
1301
+
1302
+ // --- www / scheme: both need a valid start and a valid domain
1303
+ if (!couldStartUrl(c) || !isAutolinkStart(s, i, atStart)) {
1304
+ i++
1305
+ continue
1306
+ }
1307
+
1308
+ let domainStart = -1
1309
+ let prefix = ''
1310
+ if (matchesIgnoreCase(s, i, 'www.')) {
1311
+ // The domain of a `www.` autolink includes the `www.` itself, so the
1312
+ // required period is already there and `www.foo_bar.com` is rejected on
1313
+ // the same footing as `http://foo_bar.com`.
1314
+ domainStart = i
1315
+ prefix = 'http://'
1316
+ } else {
1317
+ for (const scheme of AUTOLINK_SCHEMES) {
1318
+ if (matchesIgnoreCase(s, i, scheme)) {
1319
+ domainStart = i + scheme.length
1320
+ break
1321
+ }
1322
+ }
1323
+ }
1324
+ if (0 > domainStart) {
1325
+ i++
1326
+ continue
1327
+ }
1328
+
1329
+ const domainEnd = scanDomainEnd(s, domainStart)
1330
+ // Checked before the run scan below, so a candidate that cannot possibly
1331
+ // match costs only its own domain run.
1332
+ if (!isValidDomain(s, domainStart, domainEnd)) {
1333
+ i++
1334
+ continue
1335
+ }
1336
+
1337
+ if (i >= runEnd || i < runStart) {
1338
+ runStart = i
1339
+ runEnd = i
1340
+ while (runEnd < len) {
1341
+ const d = s.charCodeAt(runEnd)
1342
+ if (isAutolinkSpace(d) || C_LESSTHAN === d) break
1343
+ runEnd++
1344
+ }
1345
+ }
1346
+
1347
+ const end = trimAutolinkEnd(s, i, runEnd)
1348
+ // Trailing punctuation can eat back into the domain — `www.commonmark.org.`
1349
+ // loses its last period — so the domain is validated again over what
1350
+ // actually survived.
1351
+ if (end <= domainStart || !isValidDomain(s, domainStart, Math.min(domainEnd, end))) {
1352
+ i++
1353
+ continue
1354
+ }
1355
+
1356
+ if (null === out) out = []
1357
+ out.push({ start: i, end, href: prefix + s.slice(i, end) })
1358
+ lastEnd = end
1359
+ i = end
1360
+ }
1361
+
1362
+ return out
1363
+ }
1364
+
1365
+ /** `www.`, `http://`, `https://` and `ftp://` all start with one of these. */
1366
+ function couldStartUrl(c: number): boolean {
1367
+ return 119 === c || 87 === c || 104 === c || 72 === c || 102 === c || 70 === c
1368
+ }
1369
+
1370
+ function hasPeriod(s: string, from: number, to: number): boolean {
1371
+ for (let i = from; i < to; i++) {
1372
+ if (C_PERIOD === s.charCodeAt(i)) return true
1373
+ }
1374
+ return false
1375
+ }
1376
+
1377
+ /** Replace one text node with the text/link/text run its literal implies. */
1378
+ function linkifyTextNode(node: MdNode): void {
1379
+ const s = node.literal ?? ''
1380
+ const matches = scanAutolinks(s, startsAfterDelimiter(node.prev))
1381
+ if (null === matches) return
1382
+
1383
+ let at = 0
1384
+ for (const m of matches) {
1385
+ if (at < m.start) node.insertBefore(text(s.slice(at, m.start)))
1386
+ const link = new MdNode('link')
1387
+ // Not percent-encoded here, for the same reason as parseLinkDestination:
1388
+ // encoding belongs to the renderer, and the AST promises the decoded form.
1389
+ link.destination = m.href
1390
+ link.title = null
1391
+ link.appendChild(text(s.slice(m.start, m.end)))
1392
+ node.insertBefore(link)
1393
+ at = m.end
1394
+ }
1395
+ if (at < s.length) node.insertBefore(text(s.slice(at)))
1396
+
1397
+ node.unlink()
1398
+ }
1399
+
1400
+ /**
1401
+ * Autolink the text children of one container, and report the child containers
1402
+ * still to visit.
1403
+ */
1404
+ function linkifyChildren(parent: MdNode, pending: MdNode[]): void {
1405
+ let child: MdNode | null = parent.firstChild
1406
+
1407
+ while (null !== child) {
1408
+ if ('text' === child.type) {
1409
+ // Consolidate the run of adjacent text nodes first. The scan above emits
1410
+ // a node per delimiter run and per character it could not claim, so
1411
+ // `a.b-c_d@a.b` arrives as three siblings and the address is only
1412
+ // visible once they are one string. Merging changes nothing downstream:
1413
+ // the renderer writes text literals back to back, and the AST projection
1414
+ // already coalesces adjacent runs.
1415
+ let sibling: MdNode | null = child.next
1416
+ if (null !== sibling && 'text' === sibling.type) {
1417
+ let merged = child.literal ?? ''
1418
+ while (null !== sibling && 'text' === sibling.type) {
1419
+ merged += sibling.literal ?? ''
1420
+ const next: MdNode | null = sibling.next
1421
+ sibling.unlink()
1422
+ sibling = next
1423
+ }
1424
+ child.literal = merged
1425
+ }
1426
+
1427
+ const after: MdNode | null = child.next
1428
+ linkifyTextNode(child)
1429
+ child = after
1430
+ continue
1431
+ }
1432
+
1433
+ // No links inside links (§6.3), and an image's children are its alt text.
1434
+ if ('link' !== child.type && 'image' !== child.type && child.isContainer()) {
1435
+ pending.push(child)
1436
+ }
1437
+ child = child.next
1438
+ }
1439
+ }
1440
+
1441
+ /**
1442
+ * Recognise GFM autolink literals throughout one finished block's inlines.
1443
+ *
1444
+ * Iterative rather than recursive: emphasis nests as deeply as the input says.
1445
+ */
1446
+ export function linkifyAutolinks(block: MdNode): void {
1447
+ const pending: MdNode[] = [block]
1448
+ while (0 < pending.length) {
1449
+ linkifyChildren(pending.pop() as MdNode, pending)
1450
+ }
1451
+ }
1452
+
1453
+ /** Blocks whose raw content the block phase left for this phase to parse. */
1454
+ const INLINE_CONTENT_BLOCKS: Record<string, true> = {
1455
+ paragraph: true,
1456
+ heading: true,
1457
+ // GFM table cells hold inlines and nothing else — "block-level elements
1458
+ // cannot be inserted in a table" — so a cell is parsed exactly as a
1459
+ // paragraph is, autolink post-pass included.
1460
+ table_cell: true,
1461
+ }
1462
+
1463
+ /**
1464
+ * Phase 2 entry point: walk the block tree and parse the string content of
1465
+ * every paragraph, heading and table cell into inline children.
1466
+ */
1467
+ export function parseInlines(doc: MdNode, refmap: RefMap, options: ParserOptions): void {
1468
+ const parser = new InlineParser(refmap, options)
1469
+ const walker = doc.walker()
1470
+
1471
+ let ev = walker.next()
1472
+ while (null !== ev) {
1473
+ const node = ev.node
1474
+ if (!ev.entering && true === INLINE_CONTENT_BLOCKS[node.type]) {
1475
+ parser.parse(node)
1476
+ if (options.gfm) linkifyAutolinks(node)
1477
+ }
1478
+ ev = walker.next()
1479
+ }
1480
+ }
1481
+
1482
+ // Reference definitions are parsed during the block phase, before any
1483
+ // InlineParser for the document exists, so this one is kept aside for it.
1484
+ // Nothing here depends on the parse options.
1485
+ const referenceParser = new InlineParser({}, { gfm: false, breaks: false })
1486
+
1487
+ /**
1488
+ * Consume link reference definitions from the front of a paragraph's raw
1489
+ * content, adding them to `refmap`. Returns the number of characters
1490
+ * consumed, or 0 if `s` does not start with a definition.
1491
+ */
1492
+ export function parseReference(s: string, refmap: RefMap): number {
1493
+ referenceParser.refmap = refmap
1494
+ return referenceParser.parseReference(s)
1495
+ }