@tabnas/markdown 0.6.1 → 0.7.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.d.ts +257 -0
- package/dist/block.js +213 -68
- package/dist/block.js.map +1 -1
- package/dist/engine-block.d.ts +69 -0
- package/dist/engine-block.js +99 -0
- package/dist/engine-block.js.map +1 -0
- package/dist/engine-inline.d.ts +25 -0
- package/dist/engine-inline.js +237 -0
- package/dist/engine-inline.js.map +1 -0
- package/dist/inline.d.ts +250 -0
- package/dist/inline.js +395 -215
- package/dist/inline.js.map +1 -1
- package/dist/markdown.d.ts +2 -3
- package/dist/markdown.js +91 -63
- package/dist/markdown.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/block.ts +224 -75
- package/src/engine-block.ts +151 -0
- package/src/engine-inline.ts +284 -0
- package/src/inline.ts +422 -218
- package/src/markdown.ts +95 -65
package/src/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
|
-
?
|
|
659
|
+
? matchClosingCodeFence(ln.slice(parser.nextNonspace))
|
|
545
660
|
: null
|
|
546
|
-
if (match && match
|
|
547
|
-
parser.lastLineLength = charCount(ln.slice(0, parser.offset + indent + match
|
|
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
|
|
774
|
-
|
|
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 =
|
|
893
|
+
data.bulletChar = bullet.char
|
|
777
894
|
} else {
|
|
778
|
-
|
|
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 ===
|
|
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 =
|
|
785
|
-
data.delimiter =
|
|
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 +
|
|
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 +
|
|
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(
|
|
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 =
|
|
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 =
|
|
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)
|
|
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
|
|
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
|
|
864
|
-
|
|
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
|
|
878
|
-
if (null ===
|
|
990
|
+
const fence = matchCodeFence(parser.currentLine.slice(parser.nextNonspace))
|
|
991
|
+
if (null === fence) return 0
|
|
879
992
|
|
|
880
|
-
const fenceLength =
|
|
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 =
|
|
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
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
('paragraph' ===
|
|
905
|
-
|
|
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
|
-
|
|
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
|
|
923
|
-
if (
|
|
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 =
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
//
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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,
|
|
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 =
|
|
1705
|
+
const m = matchTaskListMarker(first.stringContent)
|
|
1556
1706
|
if (null !== m) {
|
|
1557
|
-
|
|
1558
|
-
|
|
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
|
+
}
|