@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/dist/block.js CHANGED
@@ -41,12 +41,24 @@ const C_SPACE = 32;
41
41
  const C_GREATERTHAN = 62;
42
42
  const C_LESSTHAN = 60;
43
43
  const C_OPEN_BRACKET = 91;
44
+ const C_BACKSLASH = 92;
45
+ const C_PIPE = 124;
44
46
  /** §2.1: \r\n, \n and a lone \r are all line endings. */
45
47
  const RE_LINE_ENDING = /\r\n|\n|\r/;
46
48
  /** §4.1: 3+ matching `*`, `_` or `-`, each optionally followed by spaces/tabs. */
47
49
  const RE_THEMATIC_BREAK = /^(?:\*[ \t]*){3,}$|^(?:_[ \t]*){3,}$|^(?:-[ \t]*){3,}$/;
48
50
  /** Fast reject: every block start begins with one of these characters. */
49
51
  const RE_MAYBE_SPECIAL = /^[#`~*+_=<>0-9-]/;
52
+ /**
53
+ * The same fast reject with `|` and `:` added, used when `gfm` is on: a GFM
54
+ * table's delimiter row may begin with either (`| --- |`, `:-: | ---:`), and
55
+ * neither is a CommonMark block start.
56
+ *
57
+ * Kept as a separate constant rather than folded into the one above so that a
58
+ * `gfm:false` parse takes exactly the path it always did — same characters
59
+ * rejected, same lines never offered to the block starts at all.
60
+ */
61
+ const RE_MAYBE_SPECIAL_GFM = /^[#`~*+_=<>0-9:|-]/;
50
62
  const RE_NON_SPACE = /[^ \t\f\v\r\n]/;
51
63
  /** §5.2 list markers. */
52
64
  const RE_BULLET_LIST_MARKER = /^[*+-]/;
@@ -185,6 +197,140 @@ function isSpaceOrTab(c) {
185
197
  function isBlank(s) {
186
198
  return !RE_NON_SPACE.test(s);
187
199
  }
200
+ // --- GFM tables (extension) -------------------------------------------------
201
+ //
202
+ // Everything the extension needs from a line is here: how a row splits into
203
+ // cells, and whether a line is a delimiter row. Both are pure functions of one
204
+ // line of text, which is what lets the block start below stay a few lines
205
+ // long.
206
+ /** ASCII whitespace, the set `cmark_isspace` trims from a table cell. */
207
+ function isAsciiSpace(c) {
208
+ return 32 === c || (9 <= c && c <= 13);
209
+ }
210
+ function trimAsciiSpace(s) {
211
+ let start = 0;
212
+ let end = s.length;
213
+ while (start < end && isAsciiSpace(s.charCodeAt(start)))
214
+ start++;
215
+ while (end > start && isAsciiSpace(s.charCodeAt(end - 1)))
216
+ end--;
217
+ return 0 === start && end === s.length ? s : s.slice(start, end);
218
+ }
219
+ /**
220
+ * Split one row of a table into its cell texts.
221
+ *
222
+ * Cells are separated by *unescaped* pipes; a leading and a trailing pipe are
223
+ * both optional and may differ from row to row, so they are stripped before
224
+ * the split rather than producing empty cells at the ends. A trailing pipe is
225
+ * only a delimiter if it is itself unescaped, which is why the backslash run
226
+ * before it is counted.
227
+ *
228
+ * `\|` is resolved to a literal `|` *here*, as the extension requires ("include
229
+ * a pipe in a cell's content by escaping it, including inside other inline
230
+ * spans"). Doing it at split time is the whole point: the inline phase never
231
+ * sees the backslash, so a code span in the cell yields a real pipe —
232
+ * `` b `\|` az `` is `b <code>|</code> az`, which no amount of unescaping
233
+ * *after* inline parsing could produce. Every other backslash escape is left
234
+ * exactly as written for the inline phase to handle in the usual way, so no
235
+ * cell text is unescaped twice.
236
+ *
237
+ * Scanning skips the character after any backslash, so `\\` cannot shield the
238
+ * pipe that follows it: `a\\|b` is two cells, the first holding `a\\`.
239
+ *
240
+ * A row of nothing but whitespace has no cells at all, which is how a header
241
+ * candidate that is really empty fails the cell-count test instead of matching
242
+ * a one-column delimiter row.
243
+ */
244
+ function splitTableRow(line) {
245
+ let start = 0;
246
+ let end = line.length;
247
+ while (start < end && isAsciiSpace(line.charCodeAt(start)))
248
+ start++;
249
+ while (end > start && isAsciiSpace(line.charCodeAt(end - 1)))
250
+ end--;
251
+ if (start === end)
252
+ return [];
253
+ // Optional leading pipe.
254
+ if (C_PIPE === line.charCodeAt(start))
255
+ start++;
256
+ // Optional trailing pipe — but an escaped one is content, not a delimiter.
257
+ if (start < end && C_PIPE === line.charCodeAt(end - 1)) {
258
+ let bs = end - 2;
259
+ while (start <= bs && C_BACKSLASH === line.charCodeAt(bs))
260
+ bs--;
261
+ // An even-length backslash run leaves the pipe unescaped.
262
+ if (0 === (end - 2 - bs) % 2)
263
+ end--;
264
+ }
265
+ const cells = [];
266
+ // `cur` holds the parts of the current cell that have already been rewritten;
267
+ // `seg` is the start of the run since then, so the common case (no escapes)
268
+ // costs one slice per cell rather than one per character.
269
+ let cur = '';
270
+ let seg = start;
271
+ let i = start;
272
+ while (i < end) {
273
+ const c = line.charCodeAt(i);
274
+ if (C_BACKSLASH === c && i + 1 < end) {
275
+ if (C_PIPE === line.charCodeAt(i + 1)) {
276
+ cur += line.slice(seg, i) + '|';
277
+ i += 2;
278
+ seg = i;
279
+ }
280
+ else {
281
+ i += 2;
282
+ }
283
+ continue;
284
+ }
285
+ if (C_PIPE === c) {
286
+ cells.push(trimAsciiSpace(cur + line.slice(seg, i)));
287
+ cur = '';
288
+ i++;
289
+ seg = i;
290
+ continue;
291
+ }
292
+ i++;
293
+ }
294
+ cells.push(trimAsciiSpace(cur + line.slice(seg, end)));
295
+ return cells;
296
+ }
297
+ /**
298
+ * A delimiter cell is hyphens with an optional leading and/or trailing colon,
299
+ * and nothing else. One `+`, one space in the middle, one stray character and
300
+ * the line is not a delimiter row.
301
+ */
302
+ const RE_TABLE_DELIMITER_CELL = /^(:?)-+(:?)$/;
303
+ /**
304
+ * Read a line as a table's delimiter row, returning one alignment per column,
305
+ * or null if it is not one.
306
+ */
307
+ function parseDelimiterRow(line) {
308
+ const cells = splitTableRow(line);
309
+ if (0 === cells.length)
310
+ return null;
311
+ const align = [];
312
+ for (const cell of cells) {
313
+ const m = RE_TABLE_DELIMITER_CELL.exec(cell);
314
+ if (null === m)
315
+ return null;
316
+ const left = ':' === m[1];
317
+ const right = ':' === m[2];
318
+ align.push(left && right ? 'center' : left ? 'left' : right ? 'right' : null);
319
+ }
320
+ return align;
321
+ }
322
+ /**
323
+ * Cap on the empty cells inserted to pad short body rows, across a whole
324
+ * document — cmark-gfm's `MAX_AUTOCOMPLETED_CELLS`, and for the same reason.
325
+ *
326
+ * Padding is the one place where a table's node count is not bounded by the
327
+ * size of its source: a 10000-column header followed by 10000 one-cell rows is
328
+ * 60 KB of input asking for 10^8 cells. Every other part of the extension is
329
+ * linear in the input, so this single budget is what keeps the whole of it so.
330
+ * Reaching it needs input that is already pathological; ordinary tables are
331
+ * nowhere near.
332
+ */
333
+ const MAX_AUTOCOMPLETED_CELLS = 0x80000;
188
334
  /** §5.3: two markers make the same list only if type, bullet and delimiter agree. */
189
335
  function listsMatch(listData, itemData) {
190
336
  return (listData.type === itemData.type &&
@@ -222,6 +368,7 @@ const BLOCK_HANDLERS = {
222
368
  finalize: () => undefined,
223
369
  canContain: notItem,
224
370
  acceptsLines: false,
371
+ verbatim: false,
225
372
  },
226
373
  list: {
227
374
  continue: () => 0,
@@ -253,6 +400,7 @@ const BLOCK_HANDLERS = {
253
400
  },
254
401
  canContain: (t) => 'item' === t,
255
402
  acceptsLines: false,
403
+ verbatim: false,
256
404
  },
257
405
  block_quote: {
258
406
  continue: (parser) => {
@@ -270,6 +418,7 @@ const BLOCK_HANDLERS = {
270
418
  finalize: () => undefined,
271
419
  canContain: notItem,
272
420
  acceptsLines: false,
421
+ verbatim: false,
273
422
  },
274
423
  item: {
275
424
  continue: (parser, container) => {
@@ -305,6 +454,7 @@ const BLOCK_HANDLERS = {
305
454
  },
306
455
  canContain: notItem,
307
456
  acceptsLines: false,
457
+ verbatim: false,
308
458
  },
309
459
  heading: {
310
460
  // A heading is always exactly one line (setext headings are converted from
@@ -313,12 +463,14 @@ const BLOCK_HANDLERS = {
313
463
  finalize: () => undefined,
314
464
  canContain: noChildren,
315
465
  acceptsLines: false,
466
+ verbatim: false,
316
467
  },
317
468
  thematic_break: {
318
469
  continue: () => 1,
319
470
  finalize: () => undefined,
320
471
  canContain: noChildren,
321
472
  acceptsLines: false,
473
+ verbatim: false,
322
474
  },
323
475
  code_block: {
324
476
  continue: (parser, container) => {
@@ -372,6 +524,7 @@ const BLOCK_HANDLERS = {
372
524
  },
373
525
  canContain: noChildren,
374
526
  acceptsLines: true,
527
+ verbatim: true,
375
528
  },
376
529
  html_block: {
377
530
  continue: (parser, container) => {
@@ -386,6 +539,7 @@ const BLOCK_HANDLERS = {
386
539
  },
387
540
  canContain: noChildren,
388
541
  acceptsLines: true,
542
+ verbatim: true,
389
543
  },
390
544
  paragraph: {
391
545
  continue: (parser) => (parser.blank ? 1 : 0),
@@ -404,8 +558,101 @@ const BLOCK_HANDLERS = {
404
558
  },
405
559
  canContain: noChildren,
406
560
  acceptsLines: true,
561
+ verbatim: false,
562
+ },
563
+ // GFM tables. The header row is built when the table opens (see
564
+ // `tryOpenTable`); every later line is accumulated raw and split into a body
565
+ // row at finalize, so the table behaves like a paragraph that happens to
566
+ // print as a grid — it ends at a blank line, and any block start interrupts
567
+ // it, because `verbatim` is false and `canContain` refuses everything the
568
+ // block starts can open.
569
+ table: {
570
+ continue: (parser) => (parser.blank ? 1 : 0),
571
+ finalize: (parser, block) => finalizeTable(parser, block),
572
+ canContain: (t) => 'table_row' === t,
573
+ acceptsLines: true,
574
+ verbatim: false,
575
+ },
576
+ // Rows and cells are created closed, and are never the parser's tip, so
577
+ // nothing here is reached in a normal parse. They exist so that `handlerFor`
578
+ // — which throws for an unknown type — cannot be the way a malformed tree
579
+ // surfaces.
580
+ table_row: {
581
+ continue: () => 1,
582
+ finalize: () => undefined,
583
+ canContain: (t) => 'table_cell' === t,
584
+ acceptsLines: false,
585
+ verbatim: false,
586
+ },
587
+ table_cell: {
588
+ continue: () => 1,
589
+ finalize: () => undefined,
590
+ canContain: noChildren,
591
+ acceptsLines: false,
592
+ verbatim: false,
407
593
  },
408
594
  };
595
+ /**
596
+ * Append one row of `cells` to `table`, padded with empty cells or truncated
597
+ * so that it is exactly as wide as the delimiter row said.
598
+ *
599
+ * Rows and cells are built closed: they are not part of the open spine, and
600
+ * the line walk in `incorporateLine` must stop at the table itself.
601
+ */
602
+ function addTableRow(parser, table, cells, isHeader, lineNumber, endColumn) {
603
+ const width = table.tableAlign?.length ?? 0;
604
+ // See MAX_AUTOCOMPLETED_CELLS: padding is the only unbounded part of a table.
605
+ let limit = width;
606
+ if (cells.length < width) {
607
+ const budget = MAX_AUTOCOMPLETED_CELLS - parser.autocompletedCells;
608
+ const padding = width - cells.length;
609
+ if (padding > budget)
610
+ limit = cells.length + (0 < budget ? budget : 0);
611
+ parser.autocompletedCells += limit - cells.length;
612
+ }
613
+ // A row and its cells occupy one source line. Columns within that line are
614
+ // not tracked: nothing reads them (the AST drops sourcepos and the renderer
615
+ // ignores it), and threading offsets through the cell split to invent them
616
+ // would be cost with no reader. Each node needs its own array — sourcepos is
617
+ // mutable — so the span is rebuilt rather than shared.
618
+ const span = () => [
619
+ [lineNumber, 1],
620
+ [lineNumber, endColumn],
621
+ ];
622
+ const row = new node_ts_1.MdNode('table_row', span());
623
+ row.isHeaderRow = isHeader;
624
+ row.open = false;
625
+ table.appendChild(row);
626
+ for (let i = 0; i < limit; i++) {
627
+ const cell = new node_ts_1.MdNode('table_cell', span());
628
+ cell.open = false;
629
+ // Consumed by the inline phase, exactly as a paragraph's content is.
630
+ cell.stringContent = i < cells.length ? cells[i] : '';
631
+ row.appendChild(cell);
632
+ }
633
+ }
634
+ /**
635
+ * Close a table: turn the raw lines it accumulated into body rows.
636
+ *
637
+ * The first accumulated line is the delimiter row that opened the table. It
638
+ * carries no data — its alignments are already on the node — so it is skipped
639
+ * here, which is also what keeps the source line numbering of the rows below
640
+ * it straightforward: the table accepts exactly one line per source line, and
641
+ * a blank line closes it, so there are no gaps.
642
+ */
643
+ function finalizeTable(parser, table) {
644
+ const lines = table.stringContent.split('\n');
645
+ table.stringContent = '';
646
+ // Line 0 is the delimiter row; the last element is the empty string after
647
+ // the final newline.
648
+ const headerLine = table.sourcepos[0][0];
649
+ for (let i = 1; i < lines.length; i++) {
650
+ const line = lines[i];
651
+ if ('' === line)
652
+ continue;
653
+ addTableRow(parser, table, splitTableRow(line), false, headerLine + i + 1, charCount(line));
654
+ }
655
+ }
409
656
  function handlerFor(type) {
410
657
  const handler = BLOCK_HANDLERS[type];
411
658
  if (undefined === handler)
@@ -634,6 +881,14 @@ const BLOCK_STARTS = [
634
881
  parser.addChild('code_block', parser.offset);
635
882
  return 2;
636
883
  },
884
+ // GFM table (extension) — a delimiter row directly under an open paragraph.
885
+ //
886
+ // Last, and the position is load-bearing in both directions. It must come
887
+ // after the setext underline so that `foo` over `---` stays an `<h2>` rather
888
+ // than becoming a one-column table, and after the list marker so that `- | -`
889
+ // stays a list item; cmark-gfm reaches its extensions from the same place,
890
+ // once every built-in start has refused the line.
891
+ (parser, container) => (parser.tryOpenTable(container) ? 2 : 0),
637
892
  ];
638
893
  // --- the parser -------------------------------------------------------------
639
894
  class BlockParser {
@@ -644,6 +899,16 @@ class BlockParser {
644
899
  this.currentLine = '';
645
900
  this.lineNumber = 0;
646
901
  this.lastLineLength = 0;
902
+ /**
903
+ * The length of the line before the one `lastLineLength` measures.
904
+ *
905
+ * `finalize` dates a block's end from `lastLineLength`, which is right for
906
+ * every block that ends on the line before the current one. A GFM table
907
+ * splits its paragraph *two* lines back — the header row is the previous
908
+ * line and stays with the table, so the paragraph left behind ends on the
909
+ * line before that — and needs this one instead.
910
+ */
911
+ this.prevLineLength = 0;
647
912
  // Memoize the last offset in `currentLine` whose character count is known,
648
913
  // so sourcepos columns cost amortized O(1). Both reset per line.
649
914
  // See `sourceColumn`.
@@ -661,10 +926,36 @@ class BlockParser {
661
926
  this.indent = 0;
662
927
  this.indented = false;
663
928
  this.blank = false;
929
+ /** Empty cells inserted so far to pad short table rows. See MAX_AUTOCOMPLETED_CELLS. */
930
+ this.autocompletedCells = 0;
931
+ /**
932
+ * The text `addLine` last appended, the column it started in, and the block
933
+ * it went to.
934
+ *
935
+ * `tryOpenTable` needs the *last line* of an open paragraph, and reading it
936
+ * back out of `stringContent` is not affordable: that content is built by
937
+ * repeated `+=`, so every index into it flattens a fresh rope — O(n) per
938
+ * line, O(n²) over a paragraph whose every other line looks like a delimiter
939
+ * row, which untrusted input can write. Keeping the line itself here costs
940
+ * two field writes and never touches the accumulated string.
941
+ *
942
+ * The node is recorded alongside it because "the previous line went to this
943
+ * very paragraph" is the extension's own rule, not a cache-validity trick:
944
+ * a header row is by definition the line immediately above the delimiter
945
+ * row. If some other block took that line, there is no header row.
946
+ *
947
+ * The column is what a table's sourcepos starts at: a table opens on its
948
+ * header row, which is a line already consumed by the time the delimiter row
949
+ * identifies it, so its column has to have been recorded when it went past.
950
+ */
951
+ this.lastAddedLine = '';
952
+ this.lastAddedColumn = 1;
953
+ this.lastAddedTo = null;
664
954
  // MdNode has no field for the HTML block type, and the tree contract is not
665
955
  // ours to change, so open HTML blocks carry it here.
666
956
  this.htmlBlockTypes = new Map();
667
957
  this.options = options;
958
+ this.maybeSpecial = options.gfm ? RE_MAYBE_SPECIAL_GFM : RE_MAYBE_SPECIAL;
668
959
  this.doc = new node_ts_1.MdNode('document', [
669
960
  [1, 1],
670
961
  [0, 0],
@@ -754,12 +1045,20 @@ class BlockParser {
754
1045
  /** Add the rest of the current line to the tip's raw content. */
755
1046
  addLine() {
756
1047
  const tip = this.openTip;
1048
+ // Taken before the tab fixup below moves `offset`, so a line's recorded
1049
+ // column is the one `addChild` would report for that same position.
1050
+ const column = this.sourceColumn(this.offset) + 1; // offset 0 is column 1
1051
+ let text = '';
757
1052
  if (this.partiallyConsumedTab) {
758
1053
  this.offset += 1; // step over the tab
759
1054
  const charsToTab = 4 - (this.column % 4);
760
- tip.stringContent += ' '.repeat(charsToTab);
1055
+ text = ' '.repeat(charsToTab);
761
1056
  }
762
- tip.stringContent += this.currentLine.slice(this.offset) + '\n';
1057
+ text += this.currentLine.slice(this.offset);
1058
+ tip.stringContent += text + '\n';
1059
+ this.lastAddedLine = text;
1060
+ this.lastAddedColumn = column;
1061
+ this.lastAddedTo = tip;
763
1062
  }
764
1063
  /**
765
1064
  * Open a block of `tag` as a child of the tip, closing blocks that cannot
@@ -803,6 +1102,72 @@ class BlockParser {
803
1102
  this.tip = node;
804
1103
  return node;
805
1104
  }
1105
+ /**
1106
+ * GFM tables: try to read the current line as the delimiter row of a table
1107
+ * whose header row is the last line of the open paragraph `container`.
1108
+ *
1109
+ * The two rows must agree on the number of cells; if they do not, there is
1110
+ * no table and the line is nothing special (spec example 6 keeps the whole
1111
+ * thing a paragraph). The header row is the paragraph's **last** line only,
1112
+ * so a paragraph that had earlier lines is split: those lines stay a
1113
+ * paragraph — finalized here, which is what still lets them contribute link
1114
+ * reference definitions — and the table is opened after it.
1115
+ *
1116
+ * On success the table is the tip and the caller returns "leaf started", so
1117
+ * step 3 adds the delimiter row itself to the table's raw content. It is
1118
+ * skipped again by `finalizeTable`; keeping it costs nothing and makes the
1119
+ * table's accumulated lines line up one-for-one with the source lines.
1120
+ */
1121
+ tryOpenTable(container) {
1122
+ if (!this.options.gfm || this.indented || 'paragraph' !== container.type)
1123
+ return false;
1124
+ const align = parseDelimiterRow(this.currentLine.slice(this.nextNonspace));
1125
+ if (null === align)
1126
+ return false;
1127
+ // The header row is the line directly above, which is the line `addLine`
1128
+ // last wrote — to this paragraph, or there is no header row at all.
1129
+ if (this.lastAddedTo !== container)
1130
+ return false;
1131
+ const headerCells = splitTableRow(this.lastAddedLine);
1132
+ if (headerCells.length !== align.length)
1133
+ return false;
1134
+ this.closeUnmatchedBlocks();
1135
+ const headerLine = this.lineNumber - 1;
1136
+ const headerColumn = this.lastAddedColumn;
1137
+ // `stringContent` ends with that same line plus the newline `addLine` put
1138
+ // after it, so the header row starts this far in — no scan needed, and the
1139
+ // content is only ever touched on the split branch, at most once per table.
1140
+ const content = container.stringContent;
1141
+ const headerStart = content.length - this.lastAddedLine.length - 1;
1142
+ if (0 < headerStart) {
1143
+ // Split: everything above the header row stays a paragraph. It ends two
1144
+ // lines back, not one, so `finalize`'s default end column — the previous
1145
+ // line's, which here is the header row's — would be somebody else's.
1146
+ container.stringContent = content.slice(0, headerStart);
1147
+ this.finalize(container, headerLine - 1, this.prevLineLength);
1148
+ }
1149
+ else {
1150
+ const parent = container.parent;
1151
+ container.unlink();
1152
+ this.tip = parent;
1153
+ }
1154
+ const table = this.addChild('table', this.nextNonspace);
1155
+ // `addChild` both dated and placed the table from the delimiter row, and
1156
+ // neither belongs to it: the table begins on the header row, one line
1157
+ // above, in the column where *that* row's content starts. The two rows are
1158
+ // indented independently, so the delimiter row's column is simply somebody
1159
+ // else's — ` a | b` over `| - | -` starts in column 3, and `a | b` over
1160
+ // ` | - | -` starts in column 1.
1161
+ table.sourcepos[0][0] = headerLine;
1162
+ table.sourcepos[0][1] = headerColumn;
1163
+ table.tableAlign = align;
1164
+ // The row is measured over its own text, exactly as `finalizeTable`
1165
+ // measures the body rows: `lastLineLength` counts the whole source line,
1166
+ // container markers included, so inside a block quote it would end the
1167
+ // header row two columns past where every body row of the same table ends.
1168
+ addTableRow(this, table, headerCells, true, headerLine, charCount(this.lastAddedLine));
1169
+ return true;
1170
+ }
806
1171
  /** Close every block that failed its continuation condition on this line. */
807
1172
  closeUnmatchedBlocks() {
808
1173
  if (this.allClosed)
@@ -816,11 +1181,18 @@ class BlockParser {
816
1181
  }
817
1182
  this.allClosed = true;
818
1183
  }
819
- /** Close a block, record its end position, and run its finalizer. */
820
- finalize(block, lineNumber) {
1184
+ /**
1185
+ * Close a block, record its end position, and run its finalizer.
1186
+ *
1187
+ * `endColumn` defaults to the previous line's length, which is where every
1188
+ * block that ends on `lineNumber` ends. Only a table's paragraph split
1189
+ * passes it: that one closes a block two lines back, where the default is
1190
+ * one line too recent.
1191
+ */
1192
+ finalize(block, lineNumber, endColumn = this.lastLineLength) {
821
1193
  const above = block.parent;
822
1194
  block.open = false;
823
- block.sourcepos[1] = [lineNumber, this.lastLineLength];
1195
+ block.sourcepos[1] = [lineNumber, endColumn];
824
1196
  handlerFor(block.type).finalize(this, block);
825
1197
  this.htmlBlockTypes.delete(block);
826
1198
  this.tip = above;
@@ -831,6 +1203,11 @@ class BlockParser {
831
1203
  incorporateLine(ln) {
832
1204
  let allMatched = true;
833
1205
  let container = this.doc;
1206
+ // `lastLineLength` still measures the previous line here. Captured before
1207
+ // anything can overwrite it — an HTML block closing mid-line does — and
1208
+ // installed as `prevLineLength` at whichever exit updates `lastLineLength`,
1209
+ // so the two always describe consecutive lines.
1210
+ const lengthBeforeThisLine = this.lastLineLength;
834
1211
  this.oldtip = this.openTip;
835
1212
  this.offset = 0;
836
1213
  this.column = 0;
@@ -867,11 +1244,13 @@ class BlockParser {
867
1244
  }
868
1245
  this.allClosed = container === this.oldtip;
869
1246
  this.lastMatchedContainer = container;
870
- // Step 2: look for new block starts under the last matched container.
871
- let matchedLeaf = 'paragraph' !== container.type && handlerFor(container.type).acceptsLines;
1247
+ // Step 2: look for new block starts under the last matched container —
1248
+ // unless its lines are literal text, in which case there is nothing to
1249
+ // look for.
1250
+ let matchedLeaf = handlerFor(container.type).verbatim;
872
1251
  while (!matchedLeaf) {
873
1252
  this.findNextNonspace();
874
- if (!this.indented && !RE_MAYBE_SPECIAL.test(ln.slice(this.nextNonspace))) {
1253
+ if (!this.indented && !this.maybeSpecial.test(ln.slice(this.nextNonspace))) {
875
1254
  this.advanceNextNonspace();
876
1255
  break;
877
1256
  }
@@ -900,6 +1279,7 @@ class BlockParser {
900
1279
  // Lazy continuation (§5.1 rule 2): the markers are missing but an open
901
1280
  // paragraph absorbs the line anyway, and its containers stay open.
902
1281
  this.addLine();
1282
+ this.prevLineLength = lengthBeforeThisLine;
903
1283
  this.lastLineLength = charCount(ln);
904
1284
  return;
905
1285
  }
@@ -942,6 +1322,7 @@ class BlockParser {
942
1322
  this.advanceNextNonspace();
943
1323
  this.addLine();
944
1324
  }
1325
+ this.prevLineLength = lengthBeforeThisLine;
945
1326
  this.lastLineLength = charCount(ln);
946
1327
  }
947
1328
  parse(input) {
@@ -958,9 +1339,68 @@ class BlockParser {
958
1339
  while (this.tip) {
959
1340
  this.finalize(this.tip, len);
960
1341
  }
1342
+ this.doc.gfm = this.options.gfm;
1343
+ if (this.options.gfm)
1344
+ markTaskListItems(this.doc);
961
1345
  return { doc: this.doc, refmap: this.refmap };
962
1346
  }
963
1347
  }
1348
+ // --- GFM task list items ----------------------------------------------------
1349
+ /**
1350
+ * The task list item marker: optional spaces, `[`, either a whitespace
1351
+ * character or `x`/`X`, `]`, and then at least one whitespace character before
1352
+ * any other content.
1353
+ *
1354
+ * The trailing whitespace is required by the extension ("…and at least one
1355
+ * whitespace character before any other content"), so `- [x]` with nothing
1356
+ * after it is an ordinary item whose text is `[x]`. It is matched as a space
1357
+ * or a tab rather than as any whitespace: a line ending there would mean the
1358
+ * content starts on the *next* line, and consuming it would swallow the
1359
+ * paragraph's first soft break.
1360
+ */
1361
+ const RE_TASK_LIST_MARKER = /^ *\[([ \tXx])\][ \t]+/;
1362
+ const TASK_LIST_CONTAINERS = {
1363
+ document: true,
1364
+ block_quote: true,
1365
+ list: true,
1366
+ item: true,
1367
+ };
1368
+ /**
1369
+ * GFM task list items: mark every item whose first block is a paragraph
1370
+ * beginning with a task list item marker, and consume the marker so it does
1371
+ * not survive into the text.
1372
+ *
1373
+ * Run at the end of the block phase, over `stringContent` — before the inline
1374
+ * phase has seen it. That is what makes the marker win over anything the
1375
+ * inline scanner would otherwise make of the brackets: with a `[x]: /url`
1376
+ * definition in the document, `- [x] foo` is still a task item rather than a
1377
+ * link. It also means the item's `checked` state is settled before `ast.ts` or
1378
+ * `html.ts` looks at the tree.
1379
+ *
1380
+ * Iterative rather than recursive: container nesting depth is chosen by the
1381
+ * input, and `'> '.repeat(20000)` is a document this parser otherwise handles.
1382
+ */
1383
+ function markTaskListItems(doc) {
1384
+ const stack = [doc];
1385
+ while (0 < stack.length) {
1386
+ const node = stack.pop();
1387
+ if ('item' === node.type) {
1388
+ const first = node.firstChild;
1389
+ if (null !== first && 'paragraph' === first.type) {
1390
+ const m = RE_TASK_LIST_MARKER.exec(first.stringContent);
1391
+ if (null !== m) {
1392
+ // Whitespace between the brackets is unchecked; `x`/`X` is checked.
1393
+ node.checked = ' ' !== m[1] && '\t' !== m[1];
1394
+ first.stringContent = first.stringContent.slice(m[0].length);
1395
+ }
1396
+ }
1397
+ }
1398
+ if (true === TASK_LIST_CONTAINERS[node.type]) {
1399
+ for (let c = node.lastChild; c; c = c.prev)
1400
+ stack.push(c);
1401
+ }
1402
+ }
1403
+ }
964
1404
  /**
965
1405
  * Phase 1: build the block tree and collect link reference definitions.
966
1406
  * Paragraph and heading text is left raw in `stringContent` for phase 2.