@tabnas/markdown 0.6.2 → 0.7.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 CHANGED
@@ -208,9 +208,320 @@ type Bracket = {
208
208
  bracketAfter: boolean
209
209
  }
210
210
 
211
- type DelimScan = { numdelims: number; canOpen: boolean; canClose: boolean }
211
+ export type DelimScan = { numdelims: number; canOpen: boolean; canClose: boolean }
212
212
 
213
- class InlineParser {
213
+ // --- pure recognizers -------------------------------------------------------
214
+ //
215
+ // Recognition split from node building: each function inspects `subject` at
216
+ // `pos` and reports what it found, moving nothing and touching no tree. The
217
+ // scanner methods below delegate to them, and they are exported so any other
218
+ // driver over the same syntax shares this exact recognition logic instead of
219
+ // reimplementing it.
220
+
221
+ /** Match a sticky regex at `pos`; the match text, or null. */
222
+ function stickyAt(re: RegExp, subject: string, pos: number): string | null {
223
+ re.lastIndex = pos
224
+ const m = re.exec(subject)
225
+ return null === m ? null : m[0]
226
+ }
227
+
228
+ /**
229
+ * The whole code point ending at `end`, so an astral character is classified
230
+ * as one character by the flanking rules rather than as a lone surrogate.
231
+ * Start of subject counts as a line ending (§6.4).
232
+ */
233
+ export function codePointBefore(subject: string, end: number): string {
234
+ if (end <= 0) return '\n'
235
+ const lo = subject.charCodeAt(end - 1)
236
+ if (lo >= 0xdc00 && lo <= 0xdfff && end >= 2) {
237
+ const hi = subject.charCodeAt(end - 2)
238
+ if (hi >= 0xd800 && hi <= 0xdbff) return subject.slice(end - 2, end)
239
+ }
240
+ return subject.charAt(end - 1)
241
+ }
242
+
243
+ /** The whole code point at `i`; end of subject counts as a line ending. */
244
+ export function codePointAt(subject: string, i: number): string {
245
+ if (i >= subject.length) return '\n'
246
+ const cp = subject.codePointAt(i)
247
+ return undefined === cp ? '\n' : String.fromCodePoint(cp)
248
+ }
249
+
250
+ /** End of the run of spaces starting at `pos`. */
251
+ export function skipInitialSpaces(subject: string, pos: number): number {
252
+ reInitialSpace.lastIndex = pos
253
+ reInitialSpace.exec(subject)
254
+ return reInitialSpace.lastIndex
255
+ }
256
+
257
+ /** End of "spaces, and at most one line ending" starting at `pos` (§6.3). */
258
+ export function matchSpnl(subject: string, pos: number): number {
259
+ reSpnl.lastIndex = pos
260
+ reSpnl.exec(subject)
261
+ return reSpnl.lastIndex
262
+ }
263
+
264
+ export type CodeSpanScan =
265
+ | { closed: true; end: number; literal: string }
266
+ | { closed: false; end: number; ticks: string }
267
+
268
+ /**
269
+ * §6.3: a code span runs from a backtick string to the next backtick string
270
+ * of *exactly* the same length. `closed: false` reports an unmatched opener,
271
+ * which is literal text; null means no backtick run starts at `pos`.
272
+ */
273
+ export function scanCodeSpan(subject: string, pos: number): CodeSpanScan | null {
274
+ const ticks = stickyAt(reTicksHere, subject, pos)
275
+ if (null === ticks) return null
276
+
277
+ const afterOpenTicks = pos + ticks.length
278
+ reTicks.lastIndex = afterOpenTicks
279
+ let m: RegExpExecArray | null
280
+
281
+ while (null !== (m = reTicks.exec(subject))) {
282
+ if (m[0] === ticks) {
283
+ const end = reTicks.lastIndex
284
+ // Line endings become spaces; then one leading and one trailing space
285
+ // are stripped together, but only if the content is not all spaces
286
+ // (which is what lets a code span hold backticks).
287
+ const contents = subject.slice(afterOpenTicks, end - ticks.length).replace(/\n/g, ' ')
288
+ const literal =
289
+ contents.length > 2 &&
290
+ ' ' === contents[0] &&
291
+ ' ' === contents[contents.length - 1] &&
292
+ null !== /[^ ]/.exec(contents)
293
+ ? contents.slice(1, contents.length - 1)
294
+ : contents
295
+ return { closed: true, end, literal }
296
+ }
297
+ }
298
+
299
+ return { closed: false, end: afterOpenTicks, ticks }
300
+ }
301
+
302
+ export type EscapeScan = {
303
+ kind: 'linebreak' | 'char' | 'literal'
304
+ literal: string
305
+ end: number
306
+ }
307
+
308
+ /**
309
+ * §6.1/§6.7: a backslash escape, with `pos` at the backslash. Always
310
+ * succeeds: a backslash before anything unescapable is a literal backslash.
311
+ * The `linebreak` form consumes the following line's leading spaces.
312
+ */
313
+ export function scanEscape(subject: string, pos: number): EscapeScan {
314
+ const next = pos + 1
315
+ if (next < subject.length && C_NEWLINE === subject.charCodeAt(next)) {
316
+ return { kind: 'linebreak', literal: '', end: skipInitialSpaces(subject, next + 1) }
317
+ }
318
+ const ch = subject.charAt(next)
319
+ if (reEscapable.test(ch)) return { kind: 'char', literal: ch, end: next + 1 }
320
+ return { kind: 'literal', literal: '\\', end: next }
321
+ }
322
+
323
+ export type AngleAutolinkScan = { dest: string; label: string; end: number }
324
+
325
+ /** §6.5: an email or URI autolink in angle brackets. */
326
+ export function scanAngleAutolink(subject: string, pos: number): AngleAutolinkScan | null {
327
+ let m = stickyAt(reEmailAutolink, subject, pos)
328
+ if (null !== m) {
329
+ const dest = m.slice(1, m.length - 1)
330
+ return { dest: 'mailto:' + dest, label: dest, end: pos + m.length }
331
+ }
332
+ m = stickyAt(reAutolink, subject, pos)
333
+ if (null !== m) {
334
+ const dest = m.slice(1, m.length - 1)
335
+ return { dest, label: dest, end: pos + m.length }
336
+ }
337
+ return null
338
+ }
339
+
340
+ /** §6.6: a raw HTML tag (open, close, comment, PI, declaration or CDATA). */
341
+ export function scanHtmlTag(subject: string, pos: number): { raw: string; end: number } | null {
342
+ const m = stickyAt(reHtmlTag, subject, pos)
343
+ return null === m ? null : { raw: m, end: pos + m.length }
344
+ }
345
+
346
+ /** §6.2: an entity or numeric character reference, decoded. */
347
+ export function scanEntity(subject: string, pos: number): { literal: string; end: number } | null {
348
+ const m = stickyAt(reEntityHere, subject, pos)
349
+ return null === m ? null : { literal: decodeEntity(m), end: pos + m.length }
350
+ }
351
+
352
+ /**
353
+ * §6.4: classify the run of `cc` starting at `pos` without consuming it.
354
+ *
355
+ * A run is left-flanking iff it is not followed by Unicode whitespace and
356
+ * either not followed by Unicode punctuation or else preceded by whitespace
357
+ * or punctuation; right-flanking is the mirror image. `_` additionally may
358
+ * not open inside a word (rules 2, 4, 6, 8), which is what keeps
359
+ * `snake_case_words` intact; `*` has no such restriction.
360
+ */
361
+ export function scanDelimiterRun(subject: string, pos: number, cc: number): DelimScan | null {
362
+ let numdelims = 0
363
+ let p = pos
364
+ while (p < subject.length && cc === subject.charCodeAt(p)) {
365
+ numdelims += 1
366
+ p += 1
367
+ }
368
+ if (0 === numdelims) return null
369
+
370
+ const charBefore = codePointBefore(subject, pos)
371
+ const charAfter = codePointAt(subject, p)
372
+
373
+ const afterIsWhitespace = isUnicodeWhitespace(charAfter)
374
+ const afterIsPunctuation = isPunctuation(charAfter)
375
+ const beforeIsWhitespace = isUnicodeWhitespace(charBefore)
376
+ const beforeIsPunctuation = isPunctuation(charBefore)
377
+
378
+ const leftFlanking =
379
+ !afterIsWhitespace &&
380
+ (!afterIsPunctuation || beforeIsWhitespace || beforeIsPunctuation)
381
+ const rightFlanking =
382
+ !beforeIsWhitespace &&
383
+ (!beforeIsPunctuation || afterIsWhitespace || afterIsPunctuation)
384
+
385
+ let canOpen: boolean
386
+ let canClose: boolean
387
+ if (C_UNDERSCORE === cc) {
388
+ canOpen = leftFlanking && (!rightFlanking || beforeIsPunctuation)
389
+ canClose = rightFlanking && (!leftFlanking || afterIsPunctuation)
390
+ } else {
391
+ canOpen = leftFlanking
392
+ canClose = rightFlanking
393
+ }
394
+
395
+ return { numdelims, canOpen, canClose }
396
+ }
397
+
398
+ /**
399
+ * §6.7/§6.8: how a line ending resolves, given the literal of the text node
400
+ * immediately before it. Two or more trailing spaces make a hard break;
401
+ * `trim` says the trailing spaces must be dropped from that node either way.
402
+ */
403
+ export function classifyBreak(prevTextLiteral: string | null): { hard: boolean; trim: boolean } {
404
+ if (
405
+ null === prevTextLiteral ||
406
+ ' ' !== prevTextLiteral.charAt(prevTextLiteral.length - 1)
407
+ ) {
408
+ return { hard: false, trim: false }
409
+ }
410
+ return { hard: ' ' === prevTextLiteral.charAt(prevTextLiteral.length - 2), trim: true }
411
+ }
412
+
413
+ /** §6.3: a link title at `pos`, without its delimiters, unescaped. */
414
+ export function scanLinkTitle(subject: string, pos: number): { title: string; end: number } | null {
415
+ const m = stickyAt(reLinkTitle, subject, pos)
416
+ if (null === m) return null
417
+ return { title: unescapeString(m.slice(1, m.length - 1)), end: pos + m.length }
418
+ }
419
+
420
+ /**
421
+ * §6.3: a link destination at `pos` — either `<...>`, or a bare run with
422
+ * balanced unescaped parentheses and no ASCII control characters or spaces.
423
+ *
424
+ * The destination is backslash-unescaped and entity-decoded, but **not**
425
+ * percent-encoded: `[l](/ä)` yields `/ä`, not `/%C3%A4`. Encoding is a
426
+ * rendering concern and `html.ts` applies `normalizeURI` when it writes the
427
+ * attribute; the public AST promises the decoded form.
428
+ */
429
+ export function scanLinkDestination(
430
+ subject: string,
431
+ pos: number,
432
+ ): { dest: string; end: number } | null {
433
+ const braced = stickyAt(reLinkDestinationBraces, subject, pos)
434
+ if (null !== braced) {
435
+ return { dest: unescapeString(braced.slice(1, braced.length - 1)), end: pos + braced.length }
436
+ }
437
+ if (pos < subject.length && C_LESSTHAN === subject.charCodeAt(pos)) return null
438
+
439
+ let p = pos
440
+ let openparens = 0
441
+ let c = p < subject.length ? subject.charCodeAt(p) : -1
442
+
443
+ while (-1 !== c) {
444
+ if (C_BACKSLASH === c && reEscapable.test(subject.charAt(p + 1))) {
445
+ p += 1
446
+ if (p < subject.length) p += 1
447
+ } else if (C_OPEN_PAREN === c) {
448
+ // Bail rather than scan on once nesting passes the limit. Without
449
+ // this, input like `[a](b` repeated makes every `]` scan to end of
450
+ // input looking for parens that never balance, which is quadratic in
451
+ // the document size. cmark caps nesting at the same depth, and no
452
+ // spec example nests more than two deep.
453
+ if (MAX_LINK_PAREN_NESTING <= openparens) return null
454
+ p += 1
455
+ openparens += 1
456
+ } else if (C_CLOSE_PAREN === c) {
457
+ if (openparens < 1) break
458
+ p += 1
459
+ openparens -= 1
460
+ } else if (c <= C_SPACE || C_DEL === c) {
461
+ // Spaces and ASCII control characters end the bare form.
462
+ break
463
+ } else {
464
+ p += 1
465
+ }
466
+ c = p < subject.length ? subject.charCodeAt(p) : -1
467
+ }
468
+
469
+ // An empty destination is only legal immediately before the closing
470
+ // paren of an inline link, as in `[link]()`.
471
+ if (p === pos && C_CLOSE_PAREN !== c) return null
472
+ if (0 !== openparens) return null
473
+
474
+ return { dest: unescapeString(subject.slice(pos, p)), end: p }
475
+ }
476
+
477
+ /**
478
+ * §4.7/§6.3: a link label at `pos`, including brackets. `rawLength` is what
479
+ * was consumed; `length` is 0 when the label is too long to be valid (§4.7:
480
+ * at most 999 characters between the brackets).
481
+ */
482
+ export function scanLinkLabel(
483
+ subject: string,
484
+ pos: number,
485
+ ): { rawLength: number; length: number } | null {
486
+ const m = stickyAt(reLinkLabel, subject, pos)
487
+ if (null === m) return null
488
+ return { rawLength: m.length, length: m.length > 1001 ? 0 : m.length }
489
+ }
490
+
491
+ export type LinkTailScan = { dest: string; title: string | null; end: number }
492
+
493
+ /**
494
+ * §6.3: the `(destination "title")` tail of an inline link, with `pos` just
495
+ * after the `]`. Null unless the whole tail — through the closing paren —
496
+ * is present.
497
+ */
498
+ export function scanInlineLinkTail(subject: string, pos: number): LinkTailScan | null {
499
+ if (pos >= subject.length || C_OPEN_PAREN !== subject.charCodeAt(pos)) return null
500
+
501
+ let p = matchSpnl(subject, pos + 1)
502
+ const d = scanLinkDestination(subject, p)
503
+ if (null === d) return null
504
+
505
+ p = matchSpnl(subject, d.end)
506
+ let title: string | null = null
507
+ // A title has to be separated from the destination by whitespace, so
508
+ // `[a](/url"t")` is not a titled link.
509
+ if (reWhitespaceChar.test(subject.charAt(p - 1))) {
510
+ const t = scanLinkTitle(subject, p)
511
+ if (null !== t) {
512
+ title = t.title
513
+ p = t.end
514
+ }
515
+ }
516
+ p = matchSpnl(subject, p)
517
+
518
+ if (p < subject.length && C_CLOSE_PAREN === subject.charCodeAt(p)) {
519
+ return { dest: d.dest, title, end: p + 1 }
520
+ }
521
+ return null
522
+ }
523
+
524
+ export class InlineParser {
214
525
  subject = ''
215
526
  pos = 0
216
527
  delimiters: Delimiter | null = null
@@ -250,20 +561,12 @@ class InlineParser {
250
561
  * surrogate. Start of subject counts as a line ending (§6.4).
251
562
  */
252
563
  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)
564
+ return codePointBefore(this.subject, end)
260
565
  }
261
566
 
262
567
  /** The whole code point at `i`; end of subject counts as a line ending. */
263
568
  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)
569
+ return codePointAt(this.subject, i)
267
570
  }
268
571
 
269
572
  // --- §6.3 code spans ---
@@ -273,54 +576,30 @@ class InlineParser {
273
576
  * *exactly* the same length. Unmatched opening backticks are literal text.
274
577
  */
275
578
  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
- }
579
+ const scan = scanCodeSpan(this.subject, this.pos)
580
+ if (null === scan) return false
304
581
 
305
- // No closing run of the same length: the opener is literal text.
306
- this.pos = afterOpenTicks
307
- block.appendChild(text(ticks))
582
+ this.pos = scan.end
583
+ if (scan.closed) {
584
+ const node = new MdNode('code')
585
+ node.literal = scan.literal
586
+ block.appendChild(node)
587
+ } else {
588
+ // No closing run of the same length: the opener is literal text.
589
+ block.appendChild(text(scan.ticks))
590
+ }
308
591
  return true
309
592
  }
310
593
 
311
594
  // --- §6.1 backslash escapes, §6.7 hard line breaks ---
312
595
 
313
596
  parseBackslash(block: MdNode): boolean {
314
- this.pos += 1
315
- if (C_NEWLINE === this.peek()) {
316
- this.pos += 1
597
+ const scan = scanEscape(this.subject, this.pos)
598
+ this.pos = scan.end
599
+ if ('linebreak' === scan.kind) {
317
600
  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
601
  } else {
323
- block.appendChild(text('\\'))
602
+ block.appendChild(text(scan.literal))
324
603
  }
325
604
  return true
326
605
  }
@@ -328,19 +607,11 @@ class InlineParser {
328
607
  // --- §6.5 autolinks ---
329
608
 
330
609
  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
610
+ const scan = scanAngleAutolink(this.subject, this.pos)
611
+ if (null === scan) return false
612
+ this.pos = scan.end
613
+ block.appendChild(this.makeAutolink(scan.dest, scan.label))
614
+ return true
344
615
  }
345
616
 
346
617
  makeAutolink(destination: string, label: string): MdNode {
@@ -356,63 +627,20 @@ class InlineParser {
356
627
  // --- §6.6 raw HTML ---
357
628
 
358
629
  parseHtmlTag(block: MdNode): boolean {
359
- const m = this.match(reHtmlTag)
360
- if (null === m) return false
630
+ const scan = scanHtmlTag(this.subject, this.pos)
631
+ if (null === scan) return false
632
+ this.pos = scan.end
361
633
  const node = new MdNode('html_inline')
362
- node.literal = m
634
+ node.literal = scan.raw
363
635
  block.appendChild(node)
364
636
  return true
365
637
  }
366
638
 
367
639
  // --- §6.4 emphasis: delimiter runs and flanking ---
368
640
 
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
- */
641
+ /** Classify the run of `cc` starting at `pos` without consuming it. */
379
642
  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 }
643
+ return scanDelimiterRun(this.subject, this.pos, cc)
416
644
  }
417
645
 
418
646
  /** Emit a delimiter run as text and push it on the delimiter stack. */
@@ -594,76 +822,33 @@ class InlineParser {
594
822
 
595
823
  /** Link title without its delimiters, unescaped; null if none here. */
596
824
  parseLinkTitle(): string | null {
597
- const title = this.match(reLinkTitle)
598
- if (null === title) return null
599
- return unescapeString(title.slice(1, title.length - 1))
825
+ const scan = scanLinkTitle(this.subject, this.pos)
826
+ if (null === scan) return null
827
+ this.pos = scan.end
828
+ return scan.title
600
829
  }
601
830
 
602
831
  /**
603
832
  * 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.
833
+ * parentheses and no ASCII control characters or spaces. See
834
+ * `scanLinkDestination` for why the value is decoded but not
835
+ * percent-encoded.
615
836
  */
616
837
  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))
838
+ const scan = scanLinkDestination(this.subject, this.pos)
839
+ if (null === scan) return null
840
+ this.pos = scan.end
841
+ return scan.dest
659
842
  }
660
843
 
661
844
  /** Length of the link label at `pos`, including brackets; 0 if invalid. */
662
845
  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
846
+ const scan = scanLinkLabel(this.subject, this.pos)
847
+ if (null === scan) return 0
848
+ // The raw match is consumed even when it is too long to be a valid
849
+ // label; callers that need the position back reset it themselves.
850
+ this.pos += scan.rawLength
851
+ return scan.length
667
852
  }
668
853
 
669
854
  addBracket(node: MdNode, index: number, image: boolean): void {
@@ -737,27 +922,12 @@ class InlineParser {
737
922
  const savepos = this.pos
738
923
 
739
924
  // 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
925
+ const tail = scanInlineLinkTail(this.subject, this.pos)
926
+ if (null !== tail) {
927
+ this.pos = tail.end
928
+ dest = tail.dest
929
+ title = tail.title
930
+ matched = true
761
931
  }
762
932
 
763
933
  if (!matched) {
@@ -826,9 +996,10 @@ class InlineParser {
826
996
  // --- §6.2 entities, §6.8/§6.9 breaks and plain text ---
827
997
 
828
998
  parseEntity(block: MdNode): boolean {
829
- const m = this.match(reEntityHere)
830
- if (null === m) return false
831
- block.appendChild(text(decodeEntity(m)))
999
+ const scan = scanEntity(this.subject, this.pos)
1000
+ if (null === scan) return false
1001
+ this.pos = scan.end
1002
+ block.appendChild(text(scan.literal))
832
1003
  return true
833
1004
  }
834
1005
 
@@ -847,19 +1018,16 @@ class InlineParser {
847
1018
  parseNewline(block: MdNode): boolean {
848
1019
  this.pos += 1
849
1020
  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)
1021
+ const brk = classifyBreak(
1022
+ null !== lastc && 'text' === lastc.type ? lastc.literal : null,
1023
+ )
1024
+ if (brk.trim && null !== lastc && null !== lastc.literal) {
857
1025
  lastc.literal = lastc.literal.replace(reFinalSpace, '')
858
- block.appendChild(new MdNode(hardbreak ? 'linebreak' : 'softbreak'))
1026
+ block.appendChild(new MdNode(brk.hard ? 'linebreak' : 'softbreak'))
859
1027
  } else {
860
1028
  block.appendChild(new MdNode('softbreak'))
861
1029
  }
862
- this.match(reInitialSpace)
1030
+ this.pos = skipInitialSpaces(this.subject, this.pos)
863
1031
  return true
864
1032
  }
865
1033
 
@@ -908,30 +1076,50 @@ class InlineParser {
908
1076
  break
909
1077
  }
910
1078
 
911
- if (!res) {
912
- // Nothing claimed it: the character is literal text.
913
- this.pos += 1
914
- block.appendChild(text(String.fromCharCode(c)))
915
- }
1079
+ if (!res) this.parseLiteralChar(block)
916
1080
 
917
1081
  return true
918
1082
  }
919
1083
 
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.
1084
+ /** Nothing claimed the character at `pos`: consume it as literal text. */
1085
+ parseLiteralChar(block: MdNode): boolean {
1086
+ const c = this.peek()
1087
+ if (-1 === c) return false
1088
+ this.pos += 1
1089
+ block.appendChild(text(String.fromCharCode(c)))
1090
+ return true
1091
+ }
1092
+
1093
+ /**
1094
+ * Reset for one block's raw content; false when the trimmed subject is
1095
+ * empty. Trimming is what stops trailing spaces at the end of a block
1096
+ * from making a hard break, and drops the leading indentation of the
1097
+ * first line.
1098
+ */
1099
+ beginBlock(block: MdNode): boolean {
924
1100
  this.subject = block.stringContent.trim()
925
1101
  this.pos = 0
926
1102
  this.delimiters = null
927
1103
  this.brackets = null
1104
+ return '' !== this.subject
1105
+ }
928
1106
 
929
- while (this.parseInline(block));
930
-
1107
+ /** The tail of a block's inline parse: clear the raw content, resolve
1108
+ * emphasis over the whole delimiter stack. */
1109
+ finishBlock(block: MdNode): void {
931
1110
  block.stringContent = ''
932
1111
  this.processEmphasis(null)
933
1112
  }
934
1113
 
1114
+ /** Parse one paragraph or heading's raw content into inline children. */
1115
+ parse(block: MdNode): void {
1116
+ this.beginBlock(block)
1117
+
1118
+ while (this.parseInline(block));
1119
+
1120
+ this.finishBlock(block)
1121
+ }
1122
+
935
1123
  /**
936
1124
  * §4.7: consume leading link reference definitions. Called by the block
937
1125
  * phase while finalizing a paragraph, repeatedly, until it returns 0.
@@ -1466,13 +1654,29 @@ const INLINE_CONTENT_BLOCKS: Record<string, true> = {
1466
1654
  */
1467
1655
  export function parseInlines(doc: MdNode, refmap: RefMap, options: ParserOptions): void {
1468
1656
  const parser = new InlineParser(refmap, options)
1657
+ forEachInlineBlock(doc, options, (node) => parser.parse(node))
1658
+ }
1659
+
1660
+ /**
1661
+ * Walk the finished block tree, handing every inline content block
1662
+ * (paragraphs, headings, table cells) to `fn` and running the GFM
1663
+ * autolink-literal post-pass after it. This is the one iteration both
1664
+ * inline drivers share — the hand scanner above and the engine path — so
1665
+ * which blocks get inline parsing, and when linkify runs, is decided
1666
+ * exactly once.
1667
+ */
1668
+ export function forEachInlineBlock(
1669
+ doc: MdNode,
1670
+ options: ParserOptions,
1671
+ fn: (block: MdNode) => void,
1672
+ ): void {
1469
1673
  const walker = doc.walker()
1470
1674
 
1471
1675
  let ev = walker.next()
1472
1676
  while (null !== ev) {
1473
1677
  const node = ev.node
1474
1678
  if (!ev.entering && true === INLINE_CONTENT_BLOCKS[node.type]) {
1475
- parser.parse(node)
1679
+ fn(node)
1476
1680
  if (options.gfm) linkifyAutolinks(node)
1477
1681
  }
1478
1682
  ev = walker.next()