@tabnas/markdown 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
- tip.stringContent += ' '.repeat(charsToTab)
1184
+ text = ' '.repeat(charsToTab)
854
1185
  }
855
- tip.stringContent += this.currentLine.slice(this.offset) + '\n'
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
- /** Close a block, record its end position, and run its finalizer. */
915
- finalize(block: MdNode, lineNumber: number): void {
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, this.lastLineLength]
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
- let matchedLeaf = 'paragraph' !== container.type && handlerFor(container.type).acceptsLines
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 && !RE_MAYBE_SPECIAL.test(ln.slice(this.nextNonspace))) {
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.