@tabnas/markdown 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -10
- package/dist/ast.d.ts +31 -1
- package/dist/ast.js +26 -1
- package/dist/ast.js.map +1 -1
- package/dist/block.js +448 -8
- package/dist/block.js.map +1 -1
- package/dist/html.d.ts +9 -3
- package/dist/html.js +137 -14
- package/dist/html.js.map +1 -1
- package/dist/inline.d.ts +7 -1
- package/dist/inline.js +431 -2
- package/dist/inline.js.map +1 -1
- package/dist/markdown.d.ts +7 -3
- package/dist/markdown.js +6 -1
- package/dist/markdown.js.map +1 -1
- package/dist/node.d.ts +39 -1
- package/dist/node.js +36 -0
- package/dist/node.js.map +1 -1
- package/dist/options.d.ts +9 -4
- package/dist/options.js.map +1 -1
- package/package.json +2 -1
- package/src/ast.ts +56 -2
- package/src/block.ts +495 -10
- package/src/html.ts +140 -15
- package/src/inline.ts +463 -2
- package/src/markdown.ts +13 -2
- package/src/node.ts +53 -0
- package/src/options.ts +9 -4
package/src/block.ts
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
// paragraphs and headings that the inline phase consumes.
|
|
32
32
|
|
|
33
33
|
import { MdNode } from './node.ts'
|
|
34
|
-
import type { ListData, NodeType } from './node.ts'
|
|
34
|
+
import type { ListData, NodeType, SourcePos, TableAlign } from './node.ts'
|
|
35
35
|
import { unescapeString } from './common.ts'
|
|
36
36
|
import { parseReference } from './inline.ts'
|
|
37
37
|
import type { ParserOptions, RefMap } from './options.ts'
|
|
@@ -44,6 +44,8 @@ const C_SPACE = 32
|
|
|
44
44
|
const C_GREATERTHAN = 62
|
|
45
45
|
const C_LESSTHAN = 60
|
|
46
46
|
const C_OPEN_BRACKET = 91
|
|
47
|
+
const C_BACKSLASH = 92
|
|
48
|
+
const C_PIPE = 124
|
|
47
49
|
|
|
48
50
|
/** §2.1: \r\n, \n and a lone \r are all line endings. */
|
|
49
51
|
const RE_LINE_ENDING = /\r\n|\n|\r/
|
|
@@ -54,6 +56,17 @@ const RE_THEMATIC_BREAK = /^(?:\*[ \t]*){3,}$|^(?:_[ \t]*){3,}$|^(?:-[ \t]*){3,}
|
|
|
54
56
|
/** Fast reject: every block start begins with one of these characters. */
|
|
55
57
|
const RE_MAYBE_SPECIAL = /^[#`~*+_=<>0-9-]/
|
|
56
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The same fast reject with `|` and `:` added, used when `gfm` is on: a GFM
|
|
61
|
+
* table's delimiter row may begin with either (`| --- |`, `:-: | ---:`), and
|
|
62
|
+
* neither is a CommonMark block start.
|
|
63
|
+
*
|
|
64
|
+
* Kept as a separate constant rather than folded into the one above so that a
|
|
65
|
+
* `gfm:false` parse takes exactly the path it always did — same characters
|
|
66
|
+
* rejected, same lines never offered to the block starts at all.
|
|
67
|
+
*/
|
|
68
|
+
const RE_MAYBE_SPECIAL_GFM = /^[#`~*+_=<>0-9:|-]/
|
|
69
|
+
|
|
57
70
|
const RE_NON_SPACE = /[^ \t\f\v\r\n]/
|
|
58
71
|
|
|
59
72
|
/** §5.2 list markers. */
|
|
@@ -207,6 +220,143 @@ function isBlank(s: string): boolean {
|
|
|
207
220
|
return !RE_NON_SPACE.test(s)
|
|
208
221
|
}
|
|
209
222
|
|
|
223
|
+
// --- GFM tables (extension) -------------------------------------------------
|
|
224
|
+
//
|
|
225
|
+
// Everything the extension needs from a line is here: how a row splits into
|
|
226
|
+
// cells, and whether a line is a delimiter row. Both are pure functions of one
|
|
227
|
+
// line of text, which is what lets the block start below stay a few lines
|
|
228
|
+
// long.
|
|
229
|
+
|
|
230
|
+
/** ASCII whitespace, the set `cmark_isspace` trims from a table cell. */
|
|
231
|
+
function isAsciiSpace(c: number): boolean {
|
|
232
|
+
return 32 === c || (9 <= c && c <= 13)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function trimAsciiSpace(s: string): string {
|
|
236
|
+
let start = 0
|
|
237
|
+
let end = s.length
|
|
238
|
+
while (start < end && isAsciiSpace(s.charCodeAt(start))) start++
|
|
239
|
+
while (end > start && isAsciiSpace(s.charCodeAt(end - 1))) end--
|
|
240
|
+
return 0 === start && end === s.length ? s : s.slice(start, end)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Split one row of a table into its cell texts.
|
|
245
|
+
*
|
|
246
|
+
* Cells are separated by *unescaped* pipes; a leading and a trailing pipe are
|
|
247
|
+
* both optional and may differ from row to row, so they are stripped before
|
|
248
|
+
* the split rather than producing empty cells at the ends. A trailing pipe is
|
|
249
|
+
* only a delimiter if it is itself unescaped, which is why the backslash run
|
|
250
|
+
* before it is counted.
|
|
251
|
+
*
|
|
252
|
+
* `\|` is resolved to a literal `|` *here*, as the extension requires ("include
|
|
253
|
+
* a pipe in a cell's content by escaping it, including inside other inline
|
|
254
|
+
* spans"). Doing it at split time is the whole point: the inline phase never
|
|
255
|
+
* sees the backslash, so a code span in the cell yields a real pipe —
|
|
256
|
+
* `` b `\|` az `` is `b <code>|</code> az`, which no amount of unescaping
|
|
257
|
+
* *after* inline parsing could produce. Every other backslash escape is left
|
|
258
|
+
* exactly as written for the inline phase to handle in the usual way, so no
|
|
259
|
+
* cell text is unescaped twice.
|
|
260
|
+
*
|
|
261
|
+
* Scanning skips the character after any backslash, so `\\` cannot shield the
|
|
262
|
+
* pipe that follows it: `a\\|b` is two cells, the first holding `a\\`.
|
|
263
|
+
*
|
|
264
|
+
* A row of nothing but whitespace has no cells at all, which is how a header
|
|
265
|
+
* candidate that is really empty fails the cell-count test instead of matching
|
|
266
|
+
* a one-column delimiter row.
|
|
267
|
+
*/
|
|
268
|
+
function splitTableRow(line: string): string[] {
|
|
269
|
+
let start = 0
|
|
270
|
+
let end = line.length
|
|
271
|
+
while (start < end && isAsciiSpace(line.charCodeAt(start))) start++
|
|
272
|
+
while (end > start && isAsciiSpace(line.charCodeAt(end - 1))) end--
|
|
273
|
+
|
|
274
|
+
if (start === end) return []
|
|
275
|
+
|
|
276
|
+
// Optional leading pipe.
|
|
277
|
+
if (C_PIPE === line.charCodeAt(start)) start++
|
|
278
|
+
|
|
279
|
+
// Optional trailing pipe — but an escaped one is content, not a delimiter.
|
|
280
|
+
if (start < end && C_PIPE === line.charCodeAt(end - 1)) {
|
|
281
|
+
let bs = end - 2
|
|
282
|
+
while (start <= bs && C_BACKSLASH === line.charCodeAt(bs)) bs--
|
|
283
|
+
// An even-length backslash run leaves the pipe unescaped.
|
|
284
|
+
if (0 === (end - 2 - bs) % 2) end--
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const cells: string[] = []
|
|
288
|
+
// `cur` holds the parts of the current cell that have already been rewritten;
|
|
289
|
+
// `seg` is the start of the run since then, so the common case (no escapes)
|
|
290
|
+
// costs one slice per cell rather than one per character.
|
|
291
|
+
let cur = ''
|
|
292
|
+
let seg = start
|
|
293
|
+
let i = start
|
|
294
|
+
|
|
295
|
+
while (i < end) {
|
|
296
|
+
const c = line.charCodeAt(i)
|
|
297
|
+
if (C_BACKSLASH === c && i + 1 < end) {
|
|
298
|
+
if (C_PIPE === line.charCodeAt(i + 1)) {
|
|
299
|
+
cur += line.slice(seg, i) + '|'
|
|
300
|
+
i += 2
|
|
301
|
+
seg = i
|
|
302
|
+
} else {
|
|
303
|
+
i += 2
|
|
304
|
+
}
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
if (C_PIPE === c) {
|
|
308
|
+
cells.push(trimAsciiSpace(cur + line.slice(seg, i)))
|
|
309
|
+
cur = ''
|
|
310
|
+
i++
|
|
311
|
+
seg = i
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
i++
|
|
315
|
+
}
|
|
316
|
+
cells.push(trimAsciiSpace(cur + line.slice(seg, end)))
|
|
317
|
+
|
|
318
|
+
return cells
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A delimiter cell is hyphens with an optional leading and/or trailing colon,
|
|
323
|
+
* and nothing else. One `+`, one space in the middle, one stray character and
|
|
324
|
+
* the line is not a delimiter row.
|
|
325
|
+
*/
|
|
326
|
+
const RE_TABLE_DELIMITER_CELL = /^(:?)-+(:?)$/
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Read a line as a table's delimiter row, returning one alignment per column,
|
|
330
|
+
* or null if it is not one.
|
|
331
|
+
*/
|
|
332
|
+
function parseDelimiterRow(line: string): TableAlign[] | null {
|
|
333
|
+
const cells = splitTableRow(line)
|
|
334
|
+
if (0 === cells.length) return null
|
|
335
|
+
|
|
336
|
+
const align: TableAlign[] = []
|
|
337
|
+
for (const cell of cells) {
|
|
338
|
+
const m = RE_TABLE_DELIMITER_CELL.exec(cell)
|
|
339
|
+
if (null === m) return null
|
|
340
|
+
const left = ':' === m[1]
|
|
341
|
+
const right = ':' === m[2]
|
|
342
|
+
align.push(left && right ? 'center' : left ? 'left' : right ? 'right' : null)
|
|
343
|
+
}
|
|
344
|
+
return align
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Cap on the empty cells inserted to pad short body rows, across a whole
|
|
349
|
+
* document — cmark-gfm's `MAX_AUTOCOMPLETED_CELLS`, and for the same reason.
|
|
350
|
+
*
|
|
351
|
+
* Padding is the one place where a table's node count is not bounded by the
|
|
352
|
+
* size of its source: a 10000-column header followed by 10000 one-cell rows is
|
|
353
|
+
* 60 KB of input asking for 10^8 cells. Every other part of the extension is
|
|
354
|
+
* linear in the input, so this single budget is what keeps the whole of it so.
|
|
355
|
+
* Reaching it needs input that is already pathological; ordinary tables are
|
|
356
|
+
* nowhere near.
|
|
357
|
+
*/
|
|
358
|
+
const MAX_AUTOCOMPLETED_CELLS = 0x80000
|
|
359
|
+
|
|
210
360
|
/** §5.3: two markers make the same list only if type, bullet and delimiter agree. */
|
|
211
361
|
function listsMatch(listData: ListData, itemData: ListData): boolean {
|
|
212
362
|
return (
|
|
@@ -252,8 +402,18 @@ type BlockHandler = {
|
|
|
252
402
|
/** Called once when the block is closed. */
|
|
253
403
|
finalize: (parser: BlockParser, block: MdNode) => void
|
|
254
404
|
canContain: (t: NodeType) => boolean
|
|
255
|
-
/** Leaves that accumulate raw text: code blocks, HTML blocks, paragraphs. */
|
|
405
|
+
/** Leaves that accumulate raw text: code blocks, HTML blocks, paragraphs, tables. */
|
|
256
406
|
acceptsLines: boolean
|
|
407
|
+
/**
|
|
408
|
+
* Lines belonging to this block are literal text, so Appendix A step 2 does
|
|
409
|
+
* not look for block starts while it is the last matched container.
|
|
410
|
+
*
|
|
411
|
+
* True for exactly the two blocks the spec names — code blocks and HTML
|
|
412
|
+
* blocks. Paragraphs and GFM tables also accept lines, but a block start may
|
|
413
|
+
* still interrupt either: that is how `> bar` ends a table, and how `# h`
|
|
414
|
+
* ends a paragraph.
|
|
415
|
+
*/
|
|
416
|
+
verbatim: boolean
|
|
257
417
|
}
|
|
258
418
|
|
|
259
419
|
const notItem = (t: NodeType): boolean => 'item' !== t
|
|
@@ -265,6 +425,7 @@ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
|
|
|
265
425
|
finalize: () => undefined,
|
|
266
426
|
canContain: notItem,
|
|
267
427
|
acceptsLines: false,
|
|
428
|
+
verbatim: false,
|
|
268
429
|
},
|
|
269
430
|
|
|
270
431
|
list: {
|
|
@@ -294,6 +455,7 @@ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
|
|
|
294
455
|
},
|
|
295
456
|
canContain: (t) => 'item' === t,
|
|
296
457
|
acceptsLines: false,
|
|
458
|
+
verbatim: false,
|
|
297
459
|
},
|
|
298
460
|
|
|
299
461
|
block_quote: {
|
|
@@ -311,6 +473,7 @@ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
|
|
|
311
473
|
finalize: () => undefined,
|
|
312
474
|
canContain: notItem,
|
|
313
475
|
acceptsLines: false,
|
|
476
|
+
verbatim: false,
|
|
314
477
|
},
|
|
315
478
|
|
|
316
479
|
item: {
|
|
@@ -346,6 +509,7 @@ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
|
|
|
346
509
|
},
|
|
347
510
|
canContain: notItem,
|
|
348
511
|
acceptsLines: false,
|
|
512
|
+
verbatim: false,
|
|
349
513
|
},
|
|
350
514
|
|
|
351
515
|
heading: {
|
|
@@ -355,6 +519,7 @@ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
|
|
|
355
519
|
finalize: () => undefined,
|
|
356
520
|
canContain: noChildren,
|
|
357
521
|
acceptsLines: false,
|
|
522
|
+
verbatim: false,
|
|
358
523
|
},
|
|
359
524
|
|
|
360
525
|
thematic_break: {
|
|
@@ -362,6 +527,7 @@ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
|
|
|
362
527
|
finalize: () => undefined,
|
|
363
528
|
canContain: noChildren,
|
|
364
529
|
acceptsLines: false,
|
|
530
|
+
verbatim: false,
|
|
365
531
|
},
|
|
366
532
|
|
|
367
533
|
code_block: {
|
|
@@ -418,6 +584,7 @@ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
|
|
|
418
584
|
},
|
|
419
585
|
canContain: noChildren,
|
|
420
586
|
acceptsLines: true,
|
|
587
|
+
verbatim: true,
|
|
421
588
|
},
|
|
422
589
|
|
|
423
590
|
html_block: {
|
|
@@ -433,6 +600,7 @@ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
|
|
|
433
600
|
},
|
|
434
601
|
canContain: noChildren,
|
|
435
602
|
acceptsLines: true,
|
|
603
|
+
verbatim: true,
|
|
436
604
|
},
|
|
437
605
|
|
|
438
606
|
paragraph: {
|
|
@@ -453,7 +621,115 @@ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
|
|
|
453
621
|
},
|
|
454
622
|
canContain: noChildren,
|
|
455
623
|
acceptsLines: true,
|
|
624
|
+
verbatim: false,
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
// GFM tables. The header row is built when the table opens (see
|
|
628
|
+
// `tryOpenTable`); every later line is accumulated raw and split into a body
|
|
629
|
+
// row at finalize, so the table behaves like a paragraph that happens to
|
|
630
|
+
// print as a grid — it ends at a blank line, and any block start interrupts
|
|
631
|
+
// it, because `verbatim` is false and `canContain` refuses everything the
|
|
632
|
+
// block starts can open.
|
|
633
|
+
table: {
|
|
634
|
+
continue: (parser) => (parser.blank ? 1 : 0),
|
|
635
|
+
finalize: (parser, block) => finalizeTable(parser, block),
|
|
636
|
+
canContain: (t) => 'table_row' === t,
|
|
637
|
+
acceptsLines: true,
|
|
638
|
+
verbatim: false,
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
// Rows and cells are created closed, and are never the parser's tip, so
|
|
642
|
+
// nothing here is reached in a normal parse. They exist so that `handlerFor`
|
|
643
|
+
// — which throws for an unknown type — cannot be the way a malformed tree
|
|
644
|
+
// surfaces.
|
|
645
|
+
table_row: {
|
|
646
|
+
continue: () => 1,
|
|
647
|
+
finalize: () => undefined,
|
|
648
|
+
canContain: (t) => 'table_cell' === t,
|
|
649
|
+
acceptsLines: false,
|
|
650
|
+
verbatim: false,
|
|
456
651
|
},
|
|
652
|
+
|
|
653
|
+
table_cell: {
|
|
654
|
+
continue: () => 1,
|
|
655
|
+
finalize: () => undefined,
|
|
656
|
+
canContain: noChildren,
|
|
657
|
+
acceptsLines: false,
|
|
658
|
+
verbatim: false,
|
|
659
|
+
},
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Append one row of `cells` to `table`, padded with empty cells or truncated
|
|
664
|
+
* so that it is exactly as wide as the delimiter row said.
|
|
665
|
+
*
|
|
666
|
+
* Rows and cells are built closed: they are not part of the open spine, and
|
|
667
|
+
* the line walk in `incorporateLine` must stop at the table itself.
|
|
668
|
+
*/
|
|
669
|
+
function addTableRow(
|
|
670
|
+
parser: BlockParser,
|
|
671
|
+
table: MdNode,
|
|
672
|
+
cells: string[],
|
|
673
|
+
isHeader: boolean,
|
|
674
|
+
lineNumber: number,
|
|
675
|
+
endColumn: number,
|
|
676
|
+
): void {
|
|
677
|
+
const width = table.tableAlign?.length ?? 0
|
|
678
|
+
|
|
679
|
+
// See MAX_AUTOCOMPLETED_CELLS: padding is the only unbounded part of a table.
|
|
680
|
+
let limit = width
|
|
681
|
+
if (cells.length < width) {
|
|
682
|
+
const budget = MAX_AUTOCOMPLETED_CELLS - parser.autocompletedCells
|
|
683
|
+
const padding = width - cells.length
|
|
684
|
+
if (padding > budget) limit = cells.length + (0 < budget ? budget : 0)
|
|
685
|
+
parser.autocompletedCells += limit - cells.length
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// A row and its cells occupy one source line. Columns within that line are
|
|
689
|
+
// not tracked: nothing reads them (the AST drops sourcepos and the renderer
|
|
690
|
+
// ignores it), and threading offsets through the cell split to invent them
|
|
691
|
+
// would be cost with no reader. Each node needs its own array — sourcepos is
|
|
692
|
+
// mutable — so the span is rebuilt rather than shared.
|
|
693
|
+
const span = (): SourcePos => [
|
|
694
|
+
[lineNumber, 1],
|
|
695
|
+
[lineNumber, endColumn],
|
|
696
|
+
]
|
|
697
|
+
|
|
698
|
+
const row = new MdNode('table_row', span())
|
|
699
|
+
row.isHeaderRow = isHeader
|
|
700
|
+
row.open = false
|
|
701
|
+
table.appendChild(row)
|
|
702
|
+
|
|
703
|
+
for (let i = 0; i < limit; i++) {
|
|
704
|
+
const cell = new MdNode('table_cell', span())
|
|
705
|
+
cell.open = false
|
|
706
|
+
// Consumed by the inline phase, exactly as a paragraph's content is.
|
|
707
|
+
cell.stringContent = i < cells.length ? cells[i] : ''
|
|
708
|
+
row.appendChild(cell)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Close a table: turn the raw lines it accumulated into body rows.
|
|
714
|
+
*
|
|
715
|
+
* The first accumulated line is the delimiter row that opened the table. It
|
|
716
|
+
* carries no data — its alignments are already on the node — so it is skipped
|
|
717
|
+
* here, which is also what keeps the source line numbering of the rows below
|
|
718
|
+
* it straightforward: the table accepts exactly one line per source line, and
|
|
719
|
+
* a blank line closes it, so there are no gaps.
|
|
720
|
+
*/
|
|
721
|
+
function finalizeTable(parser: BlockParser, table: MdNode): void {
|
|
722
|
+
const lines = table.stringContent.split('\n')
|
|
723
|
+
table.stringContent = ''
|
|
724
|
+
|
|
725
|
+
// Line 0 is the delimiter row; the last element is the empty string after
|
|
726
|
+
// the final newline.
|
|
727
|
+
const headerLine = table.sourcepos[0][0]
|
|
728
|
+
for (let i = 1; i < lines.length; i++) {
|
|
729
|
+
const line = lines[i]
|
|
730
|
+
if ('' === line) continue
|
|
731
|
+
addTableRow(parser, table, splitTableRow(line), false, headerLine + i + 1, charCount(line))
|
|
732
|
+
}
|
|
457
733
|
}
|
|
458
734
|
|
|
459
735
|
function handlerFor(type: NodeType): BlockHandler {
|
|
@@ -707,6 +983,15 @@ const BLOCK_STARTS: BlockStart[] = [
|
|
|
707
983
|
parser.addChild('code_block', parser.offset)
|
|
708
984
|
return 2
|
|
709
985
|
},
|
|
986
|
+
|
|
987
|
+
// GFM table (extension) — a delimiter row directly under an open paragraph.
|
|
988
|
+
//
|
|
989
|
+
// Last, and the position is load-bearing in both directions. It must come
|
|
990
|
+
// after the setext underline so that `foo` over `---` stays an `<h2>` rather
|
|
991
|
+
// than becoming a one-column table, and after the list marker so that `- | -`
|
|
992
|
+
// stays a list item; cmark-gfm reaches its extensions from the same place,
|
|
993
|
+
// once every built-in start has refused the line.
|
|
994
|
+
(parser, container) => (parser.tryOpenTable(container) ? 2 : 0),
|
|
710
995
|
]
|
|
711
996
|
|
|
712
997
|
// --- the parser -------------------------------------------------------------
|
|
@@ -728,6 +1013,17 @@ class BlockParser {
|
|
|
728
1013
|
lineNumber = 0
|
|
729
1014
|
lastLineLength = 0
|
|
730
1015
|
|
|
1016
|
+
/**
|
|
1017
|
+
* The length of the line before the one `lastLineLength` measures.
|
|
1018
|
+
*
|
|
1019
|
+
* `finalize` dates a block's end from `lastLineLength`, which is right for
|
|
1020
|
+
* every block that ends on the line before the current one. A GFM table
|
|
1021
|
+
* splits its paragraph *two* lines back — the header row is the previous
|
|
1022
|
+
* line and stays with the table, so the paragraph left behind ends on the
|
|
1023
|
+
* line before that — and needs this one instead.
|
|
1024
|
+
*/
|
|
1025
|
+
prevLineLength = 0
|
|
1026
|
+
|
|
731
1027
|
// Memoize the last offset in `currentLine` whose character count is known,
|
|
732
1028
|
// so sourcepos columns cost amortized O(1). Both reset per line.
|
|
733
1029
|
// See `sourceColumn`.
|
|
@@ -748,12 +1044,43 @@ class BlockParser {
|
|
|
748
1044
|
indented = false
|
|
749
1045
|
blank = false
|
|
750
1046
|
|
|
1047
|
+
/** Empty cells inserted so far to pad short table rows. See MAX_AUTOCOMPLETED_CELLS. */
|
|
1048
|
+
autocompletedCells = 0
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* The text `addLine` last appended, the column it started in, and the block
|
|
1052
|
+
* it went to.
|
|
1053
|
+
*
|
|
1054
|
+
* `tryOpenTable` needs the *last line* of an open paragraph, and reading it
|
|
1055
|
+
* back out of `stringContent` is not affordable: that content is built by
|
|
1056
|
+
* repeated `+=`, so every index into it flattens a fresh rope — O(n) per
|
|
1057
|
+
* line, O(n²) over a paragraph whose every other line looks like a delimiter
|
|
1058
|
+
* row, which untrusted input can write. Keeping the line itself here costs
|
|
1059
|
+
* two field writes and never touches the accumulated string.
|
|
1060
|
+
*
|
|
1061
|
+
* The node is recorded alongside it because "the previous line went to this
|
|
1062
|
+
* very paragraph" is the extension's own rule, not a cache-validity trick:
|
|
1063
|
+
* a header row is by definition the line immediately above the delimiter
|
|
1064
|
+
* row. If some other block took that line, there is no header row.
|
|
1065
|
+
*
|
|
1066
|
+
* The column is what a table's sourcepos starts at: a table opens on its
|
|
1067
|
+
* header row, which is a line already consumed by the time the delimiter row
|
|
1068
|
+
* identifies it, so its column has to have been recorded when it went past.
|
|
1069
|
+
*/
|
|
1070
|
+
lastAddedLine = ''
|
|
1071
|
+
lastAddedColumn = 1
|
|
1072
|
+
lastAddedTo: MdNode | null = null
|
|
1073
|
+
|
|
1074
|
+
/** Step 2's fast reject, widened for a table's delimiter row when `gfm` is on. */
|
|
1075
|
+
readonly maybeSpecial: RegExp
|
|
1076
|
+
|
|
751
1077
|
// MdNode has no field for the HTML block type, and the tree contract is not
|
|
752
1078
|
// ours to change, so open HTML blocks carry it here.
|
|
753
1079
|
private htmlBlockTypes = new Map<MdNode, number>()
|
|
754
1080
|
|
|
755
1081
|
constructor(options: ParserOptions) {
|
|
756
1082
|
this.options = options
|
|
1083
|
+
this.maybeSpecial = options.gfm ? RE_MAYBE_SPECIAL_GFM : RE_MAYBE_SPECIAL
|
|
757
1084
|
this.doc = new MdNode('document', [
|
|
758
1085
|
[1, 1],
|
|
759
1086
|
[0, 0],
|
|
@@ -847,12 +1174,20 @@ class BlockParser {
|
|
|
847
1174
|
/** Add the rest of the current line to the tip's raw content. */
|
|
848
1175
|
addLine(): void {
|
|
849
1176
|
const tip = this.openTip
|
|
1177
|
+
// Taken before the tab fixup below moves `offset`, so a line's recorded
|
|
1178
|
+
// column is the one `addChild` would report for that same position.
|
|
1179
|
+
const column = this.sourceColumn(this.offset) + 1 // offset 0 is column 1
|
|
1180
|
+
let text = ''
|
|
850
1181
|
if (this.partiallyConsumedTab) {
|
|
851
1182
|
this.offset += 1 // step over the tab
|
|
852
1183
|
const charsToTab = 4 - (this.column % 4)
|
|
853
|
-
|
|
1184
|
+
text = ' '.repeat(charsToTab)
|
|
854
1185
|
}
|
|
855
|
-
|
|
1186
|
+
text += this.currentLine.slice(this.offset)
|
|
1187
|
+
tip.stringContent += text + '\n'
|
|
1188
|
+
this.lastAddedLine = text
|
|
1189
|
+
this.lastAddedColumn = column
|
|
1190
|
+
this.lastAddedTo = tip
|
|
856
1191
|
}
|
|
857
1192
|
|
|
858
1193
|
/**
|
|
@@ -899,6 +1234,74 @@ class BlockParser {
|
|
|
899
1234
|
return node
|
|
900
1235
|
}
|
|
901
1236
|
|
|
1237
|
+
/**
|
|
1238
|
+
* GFM tables: try to read the current line as the delimiter row of a table
|
|
1239
|
+
* whose header row is the last line of the open paragraph `container`.
|
|
1240
|
+
*
|
|
1241
|
+
* The two rows must agree on the number of cells; if they do not, there is
|
|
1242
|
+
* no table and the line is nothing special (spec example 6 keeps the whole
|
|
1243
|
+
* thing a paragraph). The header row is the paragraph's **last** line only,
|
|
1244
|
+
* so a paragraph that had earlier lines is split: those lines stay a
|
|
1245
|
+
* paragraph — finalized here, which is what still lets them contribute link
|
|
1246
|
+
* reference definitions — and the table is opened after it.
|
|
1247
|
+
*
|
|
1248
|
+
* On success the table is the tip and the caller returns "leaf started", so
|
|
1249
|
+
* step 3 adds the delimiter row itself to the table's raw content. It is
|
|
1250
|
+
* skipped again by `finalizeTable`; keeping it costs nothing and makes the
|
|
1251
|
+
* table's accumulated lines line up one-for-one with the source lines.
|
|
1252
|
+
*/
|
|
1253
|
+
tryOpenTable(container: MdNode): boolean {
|
|
1254
|
+
if (!this.options.gfm || this.indented || 'paragraph' !== container.type) return false
|
|
1255
|
+
|
|
1256
|
+
const align = parseDelimiterRow(this.currentLine.slice(this.nextNonspace))
|
|
1257
|
+
if (null === align) return false
|
|
1258
|
+
|
|
1259
|
+
// The header row is the line directly above, which is the line `addLine`
|
|
1260
|
+
// last wrote — to this paragraph, or there is no header row at all.
|
|
1261
|
+
if (this.lastAddedTo !== container) return false
|
|
1262
|
+
const headerCells = splitTableRow(this.lastAddedLine)
|
|
1263
|
+
if (headerCells.length !== align.length) return false
|
|
1264
|
+
|
|
1265
|
+
this.closeUnmatchedBlocks()
|
|
1266
|
+
|
|
1267
|
+
const headerLine = this.lineNumber - 1
|
|
1268
|
+
const headerColumn = this.lastAddedColumn
|
|
1269
|
+
// `stringContent` ends with that same line plus the newline `addLine` put
|
|
1270
|
+
// after it, so the header row starts this far in — no scan needed, and the
|
|
1271
|
+
// content is only ever touched on the split branch, at most once per table.
|
|
1272
|
+
const content = container.stringContent
|
|
1273
|
+
const headerStart = content.length - this.lastAddedLine.length - 1
|
|
1274
|
+
if (0 < headerStart) {
|
|
1275
|
+
// Split: everything above the header row stays a paragraph. It ends two
|
|
1276
|
+
// lines back, not one, so `finalize`'s default end column — the previous
|
|
1277
|
+
// line's, which here is the header row's — would be somebody else's.
|
|
1278
|
+
container.stringContent = content.slice(0, headerStart)
|
|
1279
|
+
this.finalize(container, headerLine - 1, this.prevLineLength)
|
|
1280
|
+
} else {
|
|
1281
|
+
const parent = container.parent
|
|
1282
|
+
container.unlink()
|
|
1283
|
+
this.tip = parent
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const table = this.addChild('table', this.nextNonspace)
|
|
1287
|
+
// `addChild` both dated and placed the table from the delimiter row, and
|
|
1288
|
+
// neither belongs to it: the table begins on the header row, one line
|
|
1289
|
+
// above, in the column where *that* row's content starts. The two rows are
|
|
1290
|
+
// indented independently, so the delimiter row's column is simply somebody
|
|
1291
|
+
// else's — ` a | b` over `| - | -` starts in column 3, and `a | b` over
|
|
1292
|
+
// ` | - | -` starts in column 1.
|
|
1293
|
+
table.sourcepos[0][0] = headerLine
|
|
1294
|
+
table.sourcepos[0][1] = headerColumn
|
|
1295
|
+
table.tableAlign = align
|
|
1296
|
+
|
|
1297
|
+
// The row is measured over its own text, exactly as `finalizeTable`
|
|
1298
|
+
// measures the body rows: `lastLineLength` counts the whole source line,
|
|
1299
|
+
// container markers included, so inside a block quote it would end the
|
|
1300
|
+
// header row two columns past where every body row of the same table ends.
|
|
1301
|
+
addTableRow(this, table, headerCells, true, headerLine, charCount(this.lastAddedLine))
|
|
1302
|
+
return true
|
|
1303
|
+
}
|
|
1304
|
+
|
|
902
1305
|
/** Close every block that failed its continuation condition on this line. */
|
|
903
1306
|
closeUnmatchedBlocks(): void {
|
|
904
1307
|
if (this.allClosed) return
|
|
@@ -911,11 +1314,18 @@ class BlockParser {
|
|
|
911
1314
|
this.allClosed = true
|
|
912
1315
|
}
|
|
913
1316
|
|
|
914
|
-
/**
|
|
915
|
-
|
|
1317
|
+
/**
|
|
1318
|
+
* Close a block, record its end position, and run its finalizer.
|
|
1319
|
+
*
|
|
1320
|
+
* `endColumn` defaults to the previous line's length, which is where every
|
|
1321
|
+
* block that ends on `lineNumber` ends. Only a table's paragraph split
|
|
1322
|
+
* passes it: that one closes a block two lines back, where the default is
|
|
1323
|
+
* one line too recent.
|
|
1324
|
+
*/
|
|
1325
|
+
finalize(block: MdNode, lineNumber: number, endColumn: number = this.lastLineLength): void {
|
|
916
1326
|
const above = block.parent
|
|
917
1327
|
block.open = false
|
|
918
|
-
block.sourcepos[1] = [lineNumber,
|
|
1328
|
+
block.sourcepos[1] = [lineNumber, endColumn]
|
|
919
1329
|
handlerFor(block.type).finalize(this, block)
|
|
920
1330
|
this.htmlBlockTypes.delete(block)
|
|
921
1331
|
this.tip = above
|
|
@@ -928,6 +1338,12 @@ class BlockParser {
|
|
|
928
1338
|
let allMatched = true
|
|
929
1339
|
let container = this.doc
|
|
930
1340
|
|
|
1341
|
+
// `lastLineLength` still measures the previous line here. Captured before
|
|
1342
|
+
// anything can overwrite it — an HTML block closing mid-line does — and
|
|
1343
|
+
// installed as `prevLineLength` at whichever exit updates `lastLineLength`,
|
|
1344
|
+
// so the two always describe consecutive lines.
|
|
1345
|
+
const lengthBeforeThisLine = this.lastLineLength
|
|
1346
|
+
|
|
931
1347
|
this.oldtip = this.openTip
|
|
932
1348
|
this.offset = 0
|
|
933
1349
|
this.column = 0
|
|
@@ -968,13 +1384,15 @@ class BlockParser {
|
|
|
968
1384
|
this.allClosed = container === this.oldtip
|
|
969
1385
|
this.lastMatchedContainer = container
|
|
970
1386
|
|
|
971
|
-
// Step 2: look for new block starts under the last matched container
|
|
972
|
-
|
|
1387
|
+
// Step 2: look for new block starts under the last matched container —
|
|
1388
|
+
// unless its lines are literal text, in which case there is nothing to
|
|
1389
|
+
// look for.
|
|
1390
|
+
let matchedLeaf = handlerFor(container.type).verbatim
|
|
973
1391
|
|
|
974
1392
|
while (!matchedLeaf) {
|
|
975
1393
|
this.findNextNonspace()
|
|
976
1394
|
|
|
977
|
-
if (!this.indented && !
|
|
1395
|
+
if (!this.indented && !this.maybeSpecial.test(ln.slice(this.nextNonspace))) {
|
|
978
1396
|
this.advanceNextNonspace()
|
|
979
1397
|
break
|
|
980
1398
|
}
|
|
@@ -1006,6 +1424,7 @@ class BlockParser {
|
|
|
1006
1424
|
// Lazy continuation (§5.1 rule 2): the markers are missing but an open
|
|
1007
1425
|
// paragraph absorbs the line anyway, and its containers stay open.
|
|
1008
1426
|
this.addLine()
|
|
1427
|
+
this.prevLineLength = lengthBeforeThisLine
|
|
1009
1428
|
this.lastLineLength = charCount(ln)
|
|
1010
1429
|
return
|
|
1011
1430
|
}
|
|
@@ -1058,6 +1477,7 @@ class BlockParser {
|
|
|
1058
1477
|
this.addLine()
|
|
1059
1478
|
}
|
|
1060
1479
|
|
|
1480
|
+
this.prevLineLength = lengthBeforeThisLine
|
|
1061
1481
|
this.lastLineLength = charCount(ln)
|
|
1062
1482
|
}
|
|
1063
1483
|
|
|
@@ -1078,10 +1498,75 @@ class BlockParser {
|
|
|
1078
1498
|
this.finalize(this.tip, len)
|
|
1079
1499
|
}
|
|
1080
1500
|
|
|
1501
|
+
this.doc.gfm = this.options.gfm
|
|
1502
|
+
if (this.options.gfm) markTaskListItems(this.doc)
|
|
1503
|
+
|
|
1081
1504
|
return { doc: this.doc, refmap: this.refmap }
|
|
1082
1505
|
}
|
|
1083
1506
|
}
|
|
1084
1507
|
|
|
1508
|
+
// --- GFM task list items ----------------------------------------------------
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* The task list item marker: optional spaces, `[`, either a whitespace
|
|
1512
|
+
* character or `x`/`X`, `]`, and then at least one whitespace character before
|
|
1513
|
+
* any other content.
|
|
1514
|
+
*
|
|
1515
|
+
* The trailing whitespace is required by the extension ("…and at least one
|
|
1516
|
+
* whitespace character before any other content"), so `- [x]` with nothing
|
|
1517
|
+
* after it is an ordinary item whose text is `[x]`. It is matched as a space
|
|
1518
|
+
* or a tab rather than as any whitespace: a line ending there would mean the
|
|
1519
|
+
* content starts on the *next* line, and consuming it would swallow the
|
|
1520
|
+
* paragraph's first soft break.
|
|
1521
|
+
*/
|
|
1522
|
+
const RE_TASK_LIST_MARKER = /^ *\[([ \tXx])\][ \t]+/
|
|
1523
|
+
|
|
1524
|
+
const TASK_LIST_CONTAINERS: Record<string, true> = {
|
|
1525
|
+
document: true,
|
|
1526
|
+
block_quote: true,
|
|
1527
|
+
list: true,
|
|
1528
|
+
item: true,
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* GFM task list items: mark every item whose first block is a paragraph
|
|
1533
|
+
* beginning with a task list item marker, and consume the marker so it does
|
|
1534
|
+
* not survive into the text.
|
|
1535
|
+
*
|
|
1536
|
+
* Run at the end of the block phase, over `stringContent` — before the inline
|
|
1537
|
+
* phase has seen it. That is what makes the marker win over anything the
|
|
1538
|
+
* inline scanner would otherwise make of the brackets: with a `[x]: /url`
|
|
1539
|
+
* definition in the document, `- [x] foo` is still a task item rather than a
|
|
1540
|
+
* link. It also means the item's `checked` state is settled before `ast.ts` or
|
|
1541
|
+
* `html.ts` looks at the tree.
|
|
1542
|
+
*
|
|
1543
|
+
* Iterative rather than recursive: container nesting depth is chosen by the
|
|
1544
|
+
* input, and `'> '.repeat(20000)` is a document this parser otherwise handles.
|
|
1545
|
+
*/
|
|
1546
|
+
function markTaskListItems(doc: MdNode): void {
|
|
1547
|
+
const stack: MdNode[] = [doc]
|
|
1548
|
+
|
|
1549
|
+
while (0 < stack.length) {
|
|
1550
|
+
const node = stack.pop() as MdNode
|
|
1551
|
+
|
|
1552
|
+
if ('item' === node.type) {
|
|
1553
|
+
const first = node.firstChild
|
|
1554
|
+
if (null !== first && 'paragraph' === first.type) {
|
|
1555
|
+
const m = RE_TASK_LIST_MARKER.exec(first.stringContent)
|
|
1556
|
+
if (null !== m) {
|
|
1557
|
+
// Whitespace between the brackets is unchecked; `x`/`X` is checked.
|
|
1558
|
+
node.checked = ' ' !== m[1] && '\t' !== m[1]
|
|
1559
|
+
first.stringContent = first.stringContent.slice(m[0].length)
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
if (true === TASK_LIST_CONTAINERS[node.type]) {
|
|
1565
|
+
for (let c = node.lastChild; c; c = c.prev) stack.push(c)
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1085
1570
|
/**
|
|
1086
1571
|
* Phase 1: build the block tree and collect link reference definitions.
|
|
1087
1572
|
* Paragraph and heading text is left raw in `stringContent` for phase 2.
|