@tabnas/markdown 0.6.2 → 0.7.2
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/dist/block.d.ts +257 -0
- package/dist/block.js +213 -68
- package/dist/block.js.map +1 -1
- package/dist/engine-block.d.ts +69 -0
- package/dist/engine-block.js +99 -0
- package/dist/engine-block.js.map +1 -0
- package/dist/engine-inline.d.ts +25 -0
- package/dist/engine-inline.js +237 -0
- package/dist/engine-inline.js.map +1 -0
- package/dist/inline.d.ts +250 -0
- package/dist/inline.js +395 -215
- package/dist/inline.js.map +1 -1
- package/dist/markdown.d.ts +2 -3
- package/dist/markdown.js +91 -63
- package/dist/markdown.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/block.ts +224 -75
- package/src/engine-block.ts +151 -0
- package/src/engine-inline.ts +284 -0
- package/src/inline.ts +422 -218
- package/src/markdown.ts +95 -65
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
277
|
-
if (null ===
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
332
|
-
if (null
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
360
|
-
if (null ===
|
|
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 =
|
|
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
|
-
|
|
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
|
|
598
|
-
if (null ===
|
|
599
|
-
|
|
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
|
|
618
|
-
if (null
|
|
619
|
-
|
|
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
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
this.
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
|
830
|
-
if (null ===
|
|
831
|
-
|
|
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
|
-
|
|
851
|
-
null !== lastc &&
|
|
852
|
-
|
|
853
|
-
|
|
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(
|
|
1026
|
+
block.appendChild(new MdNode(brk.hard ? 'linebreak' : 'softbreak'))
|
|
859
1027
|
} else {
|
|
860
1028
|
block.appendChild(new MdNode('softbreak'))
|
|
861
1029
|
}
|
|
862
|
-
this.
|
|
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
|
-
/**
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1679
|
+
fn(node)
|
|
1476
1680
|
if (options.gfm) linkifyAutolinks(node)
|
|
1477
1681
|
}
|
|
1478
1682
|
ev = walker.next()
|