@tabnas/markdown 0.6.2 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/block.ts CHANGED
@@ -47,9 +47,6 @@ const C_OPEN_BRACKET = 91
47
47
  const C_BACKSLASH = 92
48
48
  const C_PIPE = 124
49
49
 
50
- /** §2.1: \r\n, \n and a lone \r are all line endings. */
51
- const RE_LINE_ENDING = /\r\n|\n|\r/
52
-
53
50
  /** §4.1: 3+ matching `*`, `_` or `-`, each optionally followed by spaces/tabs. */
54
51
  const RE_THEMATIC_BREAK = /^(?:\*[ \t]*){3,}$|^(?:_[ \t]*){3,}$|^(?:-[ \t]*){3,}$/
55
52
 
@@ -216,10 +213,129 @@ function isSpaceOrTab(c: number): boolean {
216
213
  return C_SPACE === c || C_TAB === c
217
214
  }
218
215
 
219
- function isBlank(s: string): boolean {
216
+ export function isBlank(s: string): boolean {
220
217
  return !RE_NON_SPACE.test(s)
221
218
  }
222
219
 
220
+ // --- line recognizers -------------------------------------------------------
221
+ //
222
+ // Pure functions over one line of text (usually the slice from the first
223
+ // non-space character on). Recognition split from the block starts and
224
+ // handlers that act on it, and exported so any other driver over the same
225
+ // syntax shares this exact recognition logic instead of reimplementing it.
226
+ // Each is the single home of the regex it wraps.
227
+
228
+ /** One physical line of `src` starting at `pos`, with §2.3's NUL replacement. */
229
+ export type LineSegment = { text: string; next: number }
230
+
231
+ /**
232
+ * Cut the next physical line out of `src` at `pos`: up to (not including)
233
+ * the next `\n`, `\r` or `\r\n`, with `next` past the terminator. Null at
234
+ * the end of the source — which is why a final line ending does not
235
+ * introduce a trailing blank line. Insecure NULs are replaced here, before
236
+ * anything else looks at the text.
237
+ */
238
+ export function segmentNextLine(src: string, pos: number): LineSegment | null {
239
+ if (pos >= src.length) return null
240
+
241
+ let i = pos
242
+ while (i < src.length) {
243
+ const c = src.charCodeAt(i)
244
+ if (10 === c || 13 === c) break
245
+ i++
246
+ }
247
+
248
+ let next = i
249
+ if (next < src.length) {
250
+ if (
251
+ 13 === src.charCodeAt(next) &&
252
+ next + 1 < src.length &&
253
+ 10 === src.charCodeAt(next + 1)
254
+ ) {
255
+ next += 2
256
+ } else {
257
+ next += 1
258
+ }
259
+ }
260
+
261
+ let text = src.slice(pos, i)
262
+ if (-1 !== text.indexOf('\u0000')) text = text.replace(/\0/g, '\uFFFD')
263
+ return { text, next }
264
+ }
265
+
266
+ /** §4.1: is `rest` a thematic break? */
267
+ export function isThematicBreakLine(rest: string): boolean {
268
+ return RE_THEMATIC_BREAK.test(rest)
269
+ }
270
+
271
+ /** §4.2: the ATX marker opening `rest`, with its heading level. */
272
+ export function matchAtxHeading(rest: string): { marker: string; level: number } | null {
273
+ const m = rest.match(RE_ATX_HEADING_MARKER)
274
+ if (null === m) return null
275
+ return { marker: m[0], level: m[0].replace(/[ \t]+$/, '').length }
276
+ }
277
+
278
+ /**
279
+ * §4.2: drop an optional closing sequence of `#`s, preceded by spaces or
280
+ * tabs; a line of nothing but `#`s has empty content.
281
+ */
282
+ export function stripAtxClosing(content: string): string {
283
+ return content.replace(/^[ \t]*#+[ \t]*$/, '').replace(/[ \t]+#+[ \t]*$/, '')
284
+ }
285
+
286
+ /** §4.5: the opening code fence at the start of `rest`, or null. */
287
+ export function matchCodeFence(rest: string): string | null {
288
+ const m = rest.match(RE_CODE_FENCE)
289
+ return null === m ? null : m[0]
290
+ }
291
+
292
+ /**
293
+ * §4.5: the closing-fence candidate at the start of `rest`, or null. The
294
+ * caller still checks the fence character and length against the open block.
295
+ */
296
+ export function matchClosingCodeFence(rest: string): string | null {
297
+ const m = rest.match(RE_CLOSING_CODE_FENCE)
298
+ return null === m ? null : m[0]
299
+ }
300
+
301
+ /** §4.3: setext underline level — 1 for `=`, 2 for `-`, 0 if neither. */
302
+ export function setextHeadingLevel(rest: string): number {
303
+ const m = rest.match(RE_SETEXT_HEADING_LINE)
304
+ if (null === m) return 0
305
+ return '=' === m[0][0] ? 1 : 2
306
+ }
307
+
308
+ /**
309
+ * §4.6: which of the seven HTML block kinds `rest` opens, or 0. The type-7
310
+ * paragraph-interrupt restriction is contextual and stays with the caller.
311
+ */
312
+ export function htmlBlockOpenKind(rest: string): number {
313
+ for (let blockType = 1; blockType <= 7; blockType++) {
314
+ if (RE_HTML_BLOCK_OPEN[blockType].test(rest)) return blockType
315
+ }
316
+ return 0
317
+ }
318
+
319
+ /** §4.6: does `rest` contain the closing condition for HTML block `type`? */
320
+ export function htmlBlockCloses(rest: string, type: number): boolean {
321
+ return RE_HTML_BLOCK_CLOSE[type].test(rest)
322
+ }
323
+
324
+ /** §5.2: the bullet list marker opening `rest`, or null. */
325
+ export function matchBulletListMarker(rest: string): { marker: string; char: string } | null {
326
+ const m = rest.match(RE_BULLET_LIST_MARKER)
327
+ return null === m ? null : { marker: m[0], char: m[0][0] }
328
+ }
329
+
330
+ /** §5.2: the ordered list marker opening `rest`, or null. */
331
+ export function matchOrderedListMarker(
332
+ rest: string,
333
+ ): { marker: string; start: number; delimiter: string } | null {
334
+ const m = rest.match(RE_ORDERED_LIST_MARKER)
335
+ if (null === m) return null
336
+ return { marker: m[0], start: parseInt(m[1], 10), delimiter: m[2] }
337
+ }
338
+
223
339
  // --- GFM tables (extension) -------------------------------------------------
224
340
  //
225
341
  // Everything the extension needs from a line is here: how a row splits into
@@ -265,7 +381,7 @@ function trimAsciiSpace(s: string): string {
265
381
  * candidate that is really empty fails the cell-count test instead of matching
266
382
  * a one-column delimiter row.
267
383
  */
268
- function splitTableRow(line: string): string[] {
384
+ export function splitTableRow(line: string): string[] {
269
385
  let start = 0
270
386
  let end = line.length
271
387
  while (start < end && isAsciiSpace(line.charCodeAt(start))) start++
@@ -329,7 +445,7 @@ const RE_TABLE_DELIMITER_CELL = /^(:?)-+(:?)$/
329
445
  * Read a line as a table's delimiter row, returning one alignment per column,
330
446
  * or null if it is not one.
331
447
  */
332
- function parseDelimiterRow(line: string): TableAlign[] | null {
448
+ export function parseDelimiterRow(line: string): TableAlign[] | null {
333
449
  const cells = splitTableRow(line)
334
450
  if (0 === cells.length) return null
335
451
 
@@ -538,13 +654,12 @@ const BLOCK_HANDLERS: Record<string, BlockHandler> = {
538
654
  if (container.isFenced) {
539
655
  // §4.5: the closing fence is the same character, at least as long, at
540
656
  // most 3 columns indented, and followed only by spaces/tabs.
541
- const rest = ln.slice(parser.nextNonspace)
542
657
  const match =
543
658
  indent <= 3 && ln.charAt(parser.nextNonspace) === container.fenceChar
544
- ? rest.match(RE_CLOSING_CODE_FENCE)
659
+ ? matchClosingCodeFence(ln.slice(parser.nextNonspace))
545
660
  : null
546
- if (match && match[0].length >= container.fenceLength) {
547
- parser.lastLineLength = charCount(ln.slice(0, parser.offset + indent + match[0].length))
661
+ if (null !== match && match.length >= container.fenceLength) {
662
+ parser.lastLineLength = charCount(ln.slice(0, parser.offset + indent + match.length))
548
663
  parser.finalize(container, parser.lineNumber)
549
664
  return 2
550
665
  }
@@ -770,35 +885,38 @@ function parseListMarker(parser: BlockParser, container: MdNode): ListData | nul
770
885
  markerOffset: parser.indent,
771
886
  }
772
887
 
773
- let match = rest.match(RE_BULLET_LIST_MARKER)
774
- if (match) {
888
+ let marker: string
889
+ const bullet = matchBulletListMarker(rest)
890
+ if (null !== bullet) {
891
+ marker = bullet.marker
775
892
  data.type = 'bullet'
776
- data.bulletChar = match[0][0]
893
+ data.bulletChar = bullet.char
777
894
  } else {
778
- match = rest.match(RE_ORDERED_LIST_MARKER)
895
+ const ordered = matchOrderedListMarker(rest)
779
896
  // §5.2 exception 1(b): an ordered item interrupting a paragraph must start at 1.
780
- if (null === match || ('paragraph' === container.type && 1 !== parseInt(match[1], 10))) {
897
+ if (null === ordered || ('paragraph' === container.type && 1 !== ordered.start)) {
781
898
  return null
782
899
  }
900
+ marker = ordered.marker
783
901
  data.type = 'ordered'
784
- data.start = parseInt(match[1], 10)
785
- data.delimiter = match[2]
902
+ data.start = ordered.start
903
+ data.delimiter = ordered.delimiter
786
904
  }
787
905
 
788
906
  // At least one space or tab (or the end of the line) must follow the marker.
789
- let nextc = peek(parser.currentLine, parser.nextNonspace + match[0].length)
907
+ let nextc = peek(parser.currentLine, parser.nextNonspace + marker.length)
790
908
  if (!(-1 === nextc || C_TAB === nextc || C_SPACE === nextc)) return null
791
909
 
792
910
  // §5.2 exception 1(a): an item interrupting a paragraph may not start blank.
793
911
  if (
794
912
  'paragraph' === container.type &&
795
- !RE_NON_SPACE.test(parser.currentLine.slice(parser.nextNonspace + match[0].length))
913
+ !RE_NON_SPACE.test(parser.currentLine.slice(parser.nextNonspace + marker.length))
796
914
  ) {
797
915
  return null
798
916
  }
799
917
 
800
918
  parser.advanceNextNonspace() // to the marker
801
- parser.advanceOffset(match[0].length, true) // past the marker
919
+ parser.advanceOffset(marker.length, true) // past the marker
802
920
 
803
921
  const spacesStartCol = parser.column
804
922
  const spacesStartOffset = parser.offset
@@ -811,12 +929,12 @@ function parseListMarker(parser: BlockParser, container: MdNode): ListData | nul
811
929
  const spacesAfterMarker = parser.column - spacesStartCol
812
930
 
813
931
  if (spacesAfterMarker >= 5 || spacesAfterMarker < 1 || blankItem) {
814
- data.padding = match[0].length + 1
932
+ data.padding = marker.length + 1
815
933
  parser.column = spacesStartCol
816
934
  parser.offset = spacesStartOffset
817
935
  if (isSpaceOrTab(peek(parser.currentLine, parser.offset))) parser.advanceOffset(1, true)
818
936
  } else {
819
- data.padding = match[0].length + spacesAfterMarker
937
+ data.padding = marker.length + spacesAfterMarker
820
938
  }
821
939
 
822
940
  return data
@@ -852,21 +970,16 @@ const BLOCK_STARTS: BlockStart[] = [
852
970
  // ATX heading (§4.2).
853
971
  (parser) => {
854
972
  if (parser.indented) return 0
855
- const match = parser.currentLine.slice(parser.nextNonspace).match(RE_ATX_HEADING_MARKER)
973
+ const match = matchAtxHeading(parser.currentLine.slice(parser.nextNonspace))
856
974
  if (null === match) return 0
857
975
 
858
976
  parser.advanceNextNonspace()
859
- parser.advanceOffset(match[0].length, false)
977
+ parser.advanceOffset(match.marker.length, false)
860
978
  parser.closeUnmatchedBlocks()
861
979
 
862
980
  const container = parser.addChild('heading', parser.nextNonspace)
863
- container.level = match[0].replace(/[ \t]+$/, '').length
864
- // An optional closing sequence of `#`s, preceded by spaces or tabs, is
865
- // dropped; a line of nothing but `#`s has empty content.
866
- container.stringContent = parser.currentLine
867
- .slice(parser.offset)
868
- .replace(/^[ \t]*#+[ \t]*$/, '')
869
- .replace(/[ \t]+#+[ \t]*$/, '')
981
+ container.level = match.level
982
+ container.stringContent = stripAtxClosing(parser.currentLine.slice(parser.offset))
870
983
  parser.advanceOffset(parser.currentLine.length - parser.offset, false)
871
984
  return 2
872
985
  },
@@ -874,15 +987,15 @@ const BLOCK_STARTS: BlockStart[] = [
874
987
  // Fenced code block (§4.5).
875
988
  (parser) => {
876
989
  if (parser.indented) return 0
877
- const match = parser.currentLine.slice(parser.nextNonspace).match(RE_CODE_FENCE)
878
- if (null === match) return 0
990
+ const fence = matchCodeFence(parser.currentLine.slice(parser.nextNonspace))
991
+ if (null === fence) return 0
879
992
 
880
- const fenceLength = match[0].length
993
+ const fenceLength = fence.length
881
994
  parser.closeUnmatchedBlocks()
882
995
  const container = parser.addChild('code_block', parser.nextNonspace)
883
996
  container.isFenced = true
884
997
  container.fenceLength = fenceLength
885
- container.fenceChar = match[0][0]
998
+ container.fenceChar = fence[0]
886
999
  container.fenceOffset = parser.indent
887
1000
  parser.advanceNextNonspace()
888
1001
  parser.advanceOffset(fenceLength, false)
@@ -894,33 +1007,30 @@ const BLOCK_STARTS: BlockStart[] = [
894
1007
  (parser, container) => {
895
1008
  if (parser.indented || C_LESSTHAN !== peek(parser.currentLine, parser.nextNonspace)) return 0
896
1009
 
897
- const s = parser.currentLine.slice(parser.nextNonspace)
898
- for (let blockType = 1; blockType <= 7; blockType++) {
899
- if (!RE_HTML_BLOCK_OPEN[blockType].test(s)) continue
900
- // Type 7 may not interrupt a paragraph — including a paragraph we are
901
- // only about to continue lazily.
902
- if (
903
- 7 === blockType &&
904
- ('paragraph' === container.type ||
905
- (!parser.allClosed && !parser.blank && 'paragraph' === parser.openTip.type))
906
- ) {
907
- continue
908
- }
909
- parser.closeUnmatchedBlocks()
910
- // The offset is deliberately not advanced: leading spaces are part of
911
- // the raw HTML.
912
- const block = parser.addChild('html_block', parser.offset)
913
- parser.setHtmlBlockType(block, blockType)
914
- return 2
1010
+ const blockType = htmlBlockOpenKind(parser.currentLine.slice(parser.nextNonspace))
1011
+ if (0 === blockType) return 0
1012
+ // Type 7 may not interrupt a paragraph — including a paragraph we are
1013
+ // only about to continue lazily.
1014
+ if (
1015
+ 7 === blockType &&
1016
+ ('paragraph' === container.type ||
1017
+ (!parser.allClosed && !parser.blank && 'paragraph' === parser.openTip.type))
1018
+ ) {
1019
+ return 0
915
1020
  }
916
- return 0
1021
+ parser.closeUnmatchedBlocks()
1022
+ // The offset is deliberately not advanced: leading spaces are part of
1023
+ // the raw HTML.
1024
+ const block = parser.addChild('html_block', parser.offset)
1025
+ parser.setHtmlBlockType(block, blockType)
1026
+ return 2
917
1027
  },
918
1028
 
919
1029
  // Setext heading underline (§4.3) — converts the whole open paragraph.
920
1030
  (parser, container) => {
921
1031
  if (parser.indented || 'paragraph' !== container.type) return 0
922
- const match = parser.currentLine.slice(parser.nextNonspace).match(RE_SETEXT_HEADING_LINE)
923
- if (null === match) return 0
1032
+ const level = setextHeadingLevel(parser.currentLine.slice(parser.nextNonspace))
1033
+ if (0 === level) return 0
924
1034
 
925
1035
  parser.closeUnmatchedBlocks()
926
1036
 
@@ -938,7 +1048,7 @@ const BLOCK_STARTS: BlockStart[] = [
938
1048
 
939
1049
  const start = container.sourcepos[0]
940
1050
  const heading = new MdNode('heading', [[start[0], start[1]], [0, 0]])
941
- heading.level = '=' === match[0][0] ? 1 : 2
1051
+ heading.level = level
942
1052
  heading.stringContent = container.stringContent
943
1053
  container.insertAfter(heading)
944
1054
  container.unlink()
@@ -950,7 +1060,7 @@ const BLOCK_STARTS: BlockStart[] = [
950
1060
  // Thematic break (§4.1).
951
1061
  (parser) => {
952
1062
  if (parser.indented) return 0
953
- if (!RE_THEMATIC_BREAK.test(parser.currentLine.slice(parser.nextNonspace))) return 0
1063
+ if (!isThematicBreakLine(parser.currentLine.slice(parser.nextNonspace))) return 0
954
1064
  parser.closeUnmatchedBlocks()
955
1065
  parser.addChild('thematic_break', parser.nextNonspace)
956
1066
  parser.advanceOffset(parser.currentLine.length - parser.offset, false)
@@ -996,7 +1106,7 @@ const BLOCK_STARTS: BlockStart[] = [
996
1106
 
997
1107
  // --- the parser -------------------------------------------------------------
998
1108
 
999
- class BlockParser {
1109
+ export class BlockParser {
1000
1110
  options: ParserOptions
1001
1111
  refmap: RefMap = {}
1002
1112
 
@@ -1013,6 +1123,16 @@ class BlockParser {
1013
1123
  lineNumber = 0
1014
1124
  lastLineLength = 0
1015
1125
 
1126
+ /**
1127
+ * GFM table probe gate. True by default — the batch driver probes every
1128
+ * candidate line, as it always has. The engine driver sets it per line
1129
+ * from the `#LB` token's `tblArm` bit (a provable superset of "could be
1130
+ * a delimiter row"; see engine-block.ts), which is what lets the GFM alt
1131
+ * be a genuine, subtractable extension seam without moving any table
1132
+ * decision out of this file.
1133
+ */
1134
+ tableArmed = true
1135
+
1016
1136
  /**
1017
1137
  * The length of the line before the one `lastLineLength` measures.
1018
1138
  *
@@ -1251,6 +1371,7 @@ class BlockParser {
1251
1371
  * table's accumulated lines line up one-for-one with the source lines.
1252
1372
  */
1253
1373
  tryOpenTable(container: MdNode): boolean {
1374
+ if (!this.tableArmed) return false
1254
1375
  if (!this.options.gfm || this.indented || 'paragraph' !== container.type) return false
1255
1376
 
1256
1377
  const align = parseDelimiterRow(this.currentLine.slice(this.nextNonspace))
@@ -1464,7 +1585,7 @@ class BlockParser {
1464
1585
  if (
1465
1586
  1 <= type &&
1466
1587
  type <= 5 &&
1467
- RE_HTML_BLOCK_CLOSE[type].test(this.currentLine.slice(this.offset))
1588
+ htmlBlockCloses(this.currentLine.slice(this.offset), type)
1468
1589
  ) {
1469
1590
  this.lastLineLength = charCount(ln)
1470
1591
  this.finalize(container, this.lineNumber)
@@ -1482,20 +1603,36 @@ class BlockParser {
1482
1603
  }
1483
1604
 
1484
1605
  parse(input: string): { doc: MdNode; refmap: RefMap } {
1485
- // §2.3: insecure characters are replaced before anything else looks at them.
1486
- const text = -1 === input.indexOf('\u0000') ? input : input.replace(/\0/g, '\uFFFD')
1487
-
1488
- const lines = text.split(RE_LINE_ENDING)
1489
- let len = lines.length
1490
- // A final line ending does not introduce a trailing blank line.
1491
- if (0 < text.length && '' === lines[len - 1]) len -= 1
1492
-
1493
- for (let i = 0; i < len; i++) {
1494
- this.incorporateLine(lines[i])
1606
+ // An empty document still incorporates one empty line, exactly as the
1607
+ // historical split-based loop did — line count and sourcepos depend on
1608
+ // it. Line cutting and §2.3 NUL replacement both live in `segmentNextLine`.
1609
+ let len = 0
1610
+ if (0 === input.length) {
1611
+ this.incorporateLine('')
1612
+ len = 1
1613
+ } else {
1614
+ let pos = 0
1615
+ let seg: LineSegment | null
1616
+ while (null !== (seg = segmentNextLine(input, pos))) {
1617
+ this.incorporateLine(seg.text)
1618
+ pos = seg.next
1619
+ len += 1
1620
+ }
1495
1621
  }
1496
1622
 
1623
+ return this.finish()
1624
+ }
1625
+
1626
+ /**
1627
+ * Close every still-open block, stamp the parse-time `gfm` flag, and run
1628
+ * the task-list post-pass. `lineNumber` is the count of incorporated
1629
+ * lines, so an incremental driver — one that feeds `incorporateLine`
1630
+ * itself instead of calling `parse` — finishes with exactly the same
1631
+ * finalization the batch loop above gets.
1632
+ */
1633
+ finish(): { doc: MdNode; refmap: RefMap } {
1497
1634
  while (this.tip) {
1498
- this.finalize(this.tip, len)
1635
+ this.finalize(this.tip, this.lineNumber)
1499
1636
  }
1500
1637
 
1501
1638
  this.doc.gfm = this.options.gfm
@@ -1521,6 +1658,19 @@ class BlockParser {
1521
1658
  */
1522
1659
  const RE_TASK_LIST_MARKER = /^ *\[([ \tXx])\][ \t]+/
1523
1660
 
1661
+ /**
1662
+ * GFM: the task list item marker opening `content`, or null. Whitespace
1663
+ * between the brackets is unchecked; `x`/`X` is checked. `length` is what the
1664
+ * caller strips, trailing whitespace included.
1665
+ */
1666
+ export function matchTaskListMarker(
1667
+ content: string,
1668
+ ): { checked: boolean; length: number } | null {
1669
+ const m = RE_TASK_LIST_MARKER.exec(content)
1670
+ if (null === m) return null
1671
+ return { checked: ' ' !== m[1] && '\t' !== m[1], length: m[0].length }
1672
+ }
1673
+
1524
1674
  const TASK_LIST_CONTAINERS: Record<string, true> = {
1525
1675
  document: true,
1526
1676
  block_quote: true,
@@ -1552,11 +1702,10 @@ function markTaskListItems(doc: MdNode): void {
1552
1702
  if ('item' === node.type) {
1553
1703
  const first = node.firstChild
1554
1704
  if (null !== first && 'paragraph' === first.type) {
1555
- const m = RE_TASK_LIST_MARKER.exec(first.stringContent)
1705
+ const m = matchTaskListMarker(first.stringContent)
1556
1706
  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)
1707
+ node.checked = m.checked
1708
+ first.stringContent = first.stringContent.slice(m.length)
1560
1709
  }
1561
1710
  }
1562
1711
  }
@@ -0,0 +1,151 @@
1
+ /* Copyright (c) 2026 Richard Rodger, MIT License */
2
+
3
+ // The engine-facing block driver: a custom lexer matcher that turns the
4
+ // engine's scan into one `#LB` token per physical line, and the actions that
5
+ // feed those lines to the shared `BlockParser`.
6
+ //
7
+ // This is the seam of the anti-drift rule (dx-report §42): nothing here
8
+ // recognizes anything. Line cutting and NUL replacement come from
9
+ // `segmentNextLine`, blank detection from `isBlank`, and every block
10
+ // decision — continuation, starts, lazy continuation, finalization — from
11
+ // the same `BlockParser` the engine-free path runs. The engine owns
12
+ // tokenization, dispatch, per-parse state carriage and observability; the
13
+ // algorithm stays in the shared core.
14
+ //
15
+ // This module may import engine types: it is reachable only from
16
+ // `markdown.ts`, never from `commonmark.ts`, so the engine-free layering
17
+ // rule holds (see AGENTS.md).
18
+
19
+ import type { Context, MakeLexMatcher, Rule, Tabnas } from '@tabnas/parser'
20
+
21
+ import { BlockParser, isBlank, segmentNextLine } from './block.ts'
22
+ import { toAst } from './ast.ts'
23
+ import type { MdNode } from './node.ts'
24
+ import type { ParserOptions } from './options.ts'
25
+
26
+ /** The `#LB` token's `use` payload: one physical line, pre-classified. */
27
+ export type LineInfo = {
28
+ /** The line's text, terminator excluded, NULs already replaced. */
29
+ text: string
30
+ /** True when the line holds nothing but spaces and tabs. */
31
+ blank: boolean
32
+ /**
33
+ * GFM table arming: true when the line contains a `|` or a `-` anywhere.
34
+ * A deliberate, *provable* superset of "could be a delimiter row": every
35
+ * delimiter cell requires at least one `-` (`:-:` rows open pipe-less
36
+ * single-column tables), and multi-column rows carry `|`. The real
37
+ * decision needs the container-adjusted offset only the block algorithm
38
+ * knows — a delimiter row nested in a block quote matches after the `>`
39
+ * marker — so the `gfm`-tagged alt reading this bit only *arms* the
40
+ * probe, and `tryOpenTable` re-verifies (see test/spec/mixed.tsv).
41
+ */
42
+ tblArm: boolean
43
+ }
44
+
45
+ /** The per-parse state carried on `ctx.u.md` — seeded by `parse.prepare`. */
46
+ export type MdParseState = {
47
+ bp: BlockParser
48
+ meta: Record<string, any> | undefined
49
+ }
50
+
51
+ /**
52
+ * Build the `mdLine` matcher: each invocation consumes one physical line —
53
+ * terminator included — and emits a single `#LB` token carrying the line as
54
+ * `LineInfo`. The matcher owns the engine's point: `sI` advances past the
55
+ * terminator, `rI` counts rows, and `cI` resets per line (the engine's
56
+ * column is not used for indentation — the shared `BlockParser` computes
57
+ * spec §2.2 tab-expanded columns itself).
58
+ */
59
+ export const makeMdLineMatcher: MakeLexMatcher = (_cfg, _opts) => {
60
+ return (lex: any, _rule: Rule) => {
61
+ const pnt = lex.pnt
62
+ const src: string = lex.src
63
+
64
+ const seg = segmentNextLine(src, pnt.sI)
65
+ if (null === seg) return undefined
66
+
67
+ const info: LineInfo = {
68
+ text: seg.text,
69
+ blank: isBlank(seg.text),
70
+ tblArm: -1 !== seg.text.indexOf('|') || -1 !== seg.text.indexOf('-'),
71
+ }
72
+
73
+ const tkn = lex.token('#LB', seg.text, src.slice(pnt.sI, seg.next), pnt, info)
74
+
75
+ // Row/column bookkeeping is observability data (#ZZ position, traces):
76
+ // a consumed terminator starts a new row; an unterminated final line
77
+ // just widens the current one.
78
+ const terminated = seg.next > pnt.sI + seg.text.length
79
+ pnt.sI = seg.next
80
+ if (terminated) {
81
+ pnt.rI += 1
82
+ pnt.cI = 1
83
+ } else {
84
+ pnt.cI += seg.text.length
85
+ }
86
+
87
+ return tkn
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The three named actions of the `markdown`/`line` rules. Bound to resolved
93
+ * options at plugin-install time, exactly as the engine-free entry points
94
+ * are. `runInlines` is the phase-2 driver — the engine inline path from
95
+ * `engine-inline.ts` — injected so this module stays a block-phase concern.
96
+ */
97
+ export function makeMdActions(
98
+ opts: ParserOptions,
99
+ runInlines: (doc: MdNode, refmap: Record<string, any>) => void,
100
+ ) {
101
+ return {
102
+ /** `parse.prepare`: fresh block-parser state for every parse. */
103
+ prepare: (_tn: Tabnas, ctx: Context, meta?: any) => {
104
+ const state: MdParseState = { bp: new BlockParser(opts), meta }
105
+ ;(ctx.u as any).md = state
106
+ },
107
+
108
+ /**
109
+ * `line` base open alt: disarm the table probe and feed the matched
110
+ * `#LB` line to the shared algorithm. Reaching this alt means the
111
+ * `gfm`-tagged alt above it did not match — the line cannot contain a
112
+ * delimiter row — or GFM is off entirely (`rule.exclude` removed the
113
+ * tagged alt, and `tryOpenTable` is closed by the option anyway).
114
+ */
115
+ line: (r: Rule, ctx: Context) => {
116
+ const state: MdParseState = (ctx.u as any).md
117
+ state.bp.tableArmed = false
118
+ state.bp.incorporateLine((r.o0 as any).use.text)
119
+ },
120
+
121
+ /**
122
+ * `line` GFM open alt (tagged `g: 'gfm'`, condition on the token's
123
+ * `tblArm` bit): arm the table probe for this line, then feed it to the
124
+ * same shared algorithm. This is the extension seam a downstream
125
+ * dialect would use — an alt injected ahead of the base one, gated on
126
+ * a group tag so `gfm: false` can subtract it wholesale.
127
+ */
128
+ lineGfm: (r: Rule, ctx: Context) => {
129
+ const state: MdParseState = (ctx.u as any).md
130
+ state.bp.tableArmed = true
131
+ state.bp.incorporateLine((r.o0 as any).use.text)
132
+ },
133
+
134
+ /**
135
+ * `markdown` close alt (`#ZZ`): finalize blocks, run the shared inline
136
+ * phase, and project the public AST into the rule's node. When the
137
+ * caller passed `meta.md.keepTree`, the native tree is handed back on
138
+ * the meta object — the engine-path conformance harness renders it.
139
+ */
140
+ finish: (r: Rule, ctx: Context) => {
141
+ const state: MdParseState = (ctx.u as any).md
142
+ const { doc, refmap } = state.bp.finish()
143
+ runInlines(doc, refmap)
144
+
145
+ const md = state.meta && state.meta.md
146
+ if (md && true === md.keepTree) md.tree = doc as MdNode
147
+
148
+ r.node = toAst(doc, opts)
149
+ },
150
+ }
151
+ }