@tabnas/markdown 0.2.1 → 0.4.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/README.md +10 -38
- package/dist/markdown.d.ts +88 -28
- package/dist/markdown.js +692 -551
- package/dist/markdown.js.map +1 -1
- package/package.json +13 -9
- package/src/markdown.ts +754 -629
package/src/markdown.ts
CHANGED
|
@@ -1,714 +1,839 @@
|
|
|
1
|
-
/* Copyright (c) 2021-
|
|
1
|
+
/* Copyright (c) 2021-2026 Richard Rodger, MIT License */
|
|
2
|
+
|
|
3
|
+
// @tabnas/markdown — CommonMark/GFM-subset prose parser, built as a
|
|
4
|
+
// tabnas plugin on the bare engine. See `dx-report.md` for the rescope
|
|
5
|
+
// rationale (was CSV-family, now prose).
|
|
2
6
|
|
|
3
|
-
// The engine is the tabnas parser; jsonic supplies the relaxed-JSON
|
|
4
|
-
// grammar that the embedded grammar text is authored in.
|
|
5
7
|
import {
|
|
6
8
|
Tabnas,
|
|
7
|
-
Rule,
|
|
8
|
-
RuleSpec,
|
|
9
9
|
Plugin,
|
|
10
|
+
Rule,
|
|
10
11
|
Context,
|
|
11
12
|
Config,
|
|
12
13
|
TabnasOptions,
|
|
13
|
-
Lex,
|
|
14
|
-
Token,
|
|
15
14
|
} from '@tabnas/parser'
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
object: boolean
|
|
26
|
-
stream: null | ((what: string, record?: Record<string, any> | Error) => void)
|
|
27
|
-
strict: boolean
|
|
28
|
-
field: {
|
|
29
|
-
separation: null | string
|
|
30
|
-
nonameprefix: string
|
|
31
|
-
empty: any
|
|
32
|
-
names: undefined | string[]
|
|
33
|
-
exact: boolean
|
|
34
|
-
}
|
|
35
|
-
record: {
|
|
36
|
-
separators: null | string
|
|
37
|
-
empty: boolean
|
|
38
|
-
}
|
|
39
|
-
string: {
|
|
40
|
-
quote: string
|
|
41
|
-
markdown: null | boolean
|
|
42
|
-
}
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Public options
|
|
18
|
+
|
|
19
|
+
export type MarkdownOptions = {
|
|
20
|
+
/** Enable GFM extensions (strikethrough `~~`, task lists, tables — tables deferred, see dx-report). */
|
|
21
|
+
gfm?: boolean
|
|
22
|
+
/** When true, a single soft break becomes a hard break. Default false. */
|
|
23
|
+
breaks?: boolean
|
|
43
24
|
}
|
|
44
25
|
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// AST types — mdast-adjacent, JSON-serialisable
|
|
28
|
+
|
|
29
|
+
export type DocumentNode = { type: 'document'; children: Block[] }
|
|
30
|
+
export type Block =
|
|
31
|
+
| HeadingNode
|
|
32
|
+
| ParagraphNode
|
|
33
|
+
| BlockquoteNode
|
|
34
|
+
| ListNode
|
|
35
|
+
| ListItemNode // also used as list child internally; top-level children are never bare items
|
|
36
|
+
| CodeNode
|
|
37
|
+
| HtmlNode
|
|
38
|
+
| ThematicBreakNode
|
|
39
|
+
|
|
40
|
+
export type HeadingNode = { type: 'heading'; depth: 1 | 2 | 3 | 4 | 5 | 6; children: Inline[] }
|
|
41
|
+
export type ParagraphNode = { type: 'paragraph'; children: Inline[] }
|
|
42
|
+
export type BlockquoteNode = { type: 'blockquote'; children: Block[] }
|
|
43
|
+
export type ListNode = {
|
|
44
|
+
type: 'list'
|
|
45
|
+
ordered: boolean
|
|
46
|
+
start: number | null
|
|
47
|
+
spread: boolean
|
|
48
|
+
children: ListItemNode[]
|
|
49
|
+
}
|
|
50
|
+
export type ListItemNode = {
|
|
51
|
+
type: 'listItem'
|
|
52
|
+
spread: boolean
|
|
53
|
+
children: Block[]
|
|
54
|
+
}
|
|
55
|
+
export type CodeNode = {
|
|
56
|
+
type: 'code'
|
|
57
|
+
lang: string | null
|
|
58
|
+
meta: string | null
|
|
59
|
+
value: string
|
|
60
|
+
}
|
|
61
|
+
export type HtmlNode = { type: 'html'; value: string }
|
|
62
|
+
export type ThematicBreakNode = { type: 'thematicBreak' }
|
|
63
|
+
|
|
64
|
+
export type Inline =
|
|
65
|
+
| TextNode
|
|
66
|
+
| EmphasisNode
|
|
67
|
+
| StrongNode
|
|
68
|
+
| InlineCodeNode
|
|
69
|
+
| LinkNode
|
|
70
|
+
| ImageNode
|
|
71
|
+
| BreakNode
|
|
72
|
+
| DeleteNode
|
|
73
|
+
|
|
74
|
+
export type TextNode = { type: 'text'; value: string }
|
|
75
|
+
export type EmphasisNode = { type: 'emphasis'; children: Inline[] }
|
|
76
|
+
export type StrongNode = { type: 'strong'; children: Inline[] }
|
|
77
|
+
export type InlineCodeNode = { type: 'inlineCode'; value: string }
|
|
78
|
+
export type LinkNode = { type: 'link'; url: string; title: string | null; children: Inline[] }
|
|
79
|
+
export type ImageNode = { type: 'image'; url: string; title: string | null; alt: string }
|
|
80
|
+
export type BreakNode = { type: 'break' }
|
|
81
|
+
export type DeleteNode = { type: 'delete'; children: Inline[] }
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Grammar source — single-document entry rule `markdown`. The rule is tiny:
|
|
85
|
+
// blocks are parsed in JS inside the `bo` action via `parseDocument()`.
|
|
86
|
+
// The block structure therefore shows up as one rule in `debug.model()`
|
|
87
|
+
// and as one fanned `markdown → block` diagram from `@tabnas/railroad`.
|
|
88
|
+
// Keep the embedded grammar text trivial so `embed-grammar.js` stays happy
|
|
89
|
+
// and the source of truth is clearly JS (see dx-report §2.1).
|
|
90
|
+
|
|
45
91
|
// --- BEGIN EMBEDDED markdown-grammar.jsonic ---
|
|
46
92
|
const grammarText = `
|
|
47
|
-
# Markdown
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
# #ZZ - end of input
|
|
56
|
-
# #VAL - token set: text, string, number, value literals
|
|
57
|
-
#
|
|
58
|
-
# Rules markdown, newline, record, text are fully defined here.
|
|
59
|
-
# Rules list, elem, val are modified in code (strict mode defines from scratch;
|
|
60
|
-
# non-strict prepends to existing defaults to preserve JSON parsing).
|
|
61
|
-
|
|
93
|
+
# Markdown prose grammar — CommonMark/GFM subset
|
|
94
|
+
# See dx-report.md section 2 and ts/src/markdown.ts header.
|
|
95
|
+
# This grammar is intentionally trivial: the prose parser is implemented
|
|
96
|
+
# in JS inside the markdown rule bo action (parseDocument). The
|
|
97
|
+
# block structure therefore shows as a single markdown -> block fan-out
|
|
98
|
+
# in the railroad diagram, which is honest about where the complexity
|
|
99
|
+
# lives. The file is kept so ts/embed-grammar.js continues to embed a
|
|
100
|
+
# grammar verbatim into both runtimes (see AGENTS.md).
|
|
62
101
|
{
|
|
63
102
|
rule: markdown: open: [
|
|
64
103
|
{ s: '#ZZ' }
|
|
65
|
-
{ s: '#LN' p: newline c: '@not-record-empty' }
|
|
66
|
-
{ p: record }
|
|
67
|
-
]
|
|
68
|
-
|
|
69
|
-
rule: newline: open: [
|
|
70
|
-
{ s: '#LN #LN' r: newline }
|
|
71
|
-
{ s: '#LN' r: newline }
|
|
72
|
-
{ s: '#ZZ' }
|
|
73
|
-
{ r: record }
|
|
74
104
|
]
|
|
75
|
-
rule:
|
|
76
|
-
{ s: '#LN #LN' r: newline }
|
|
77
|
-
{ s: '#LN' r: newline }
|
|
105
|
+
rule: markdown: close: [
|
|
78
106
|
{ s: '#ZZ' }
|
|
79
|
-
{ r: record }
|
|
80
|
-
]
|
|
81
|
-
|
|
82
|
-
rule: record: open: [
|
|
83
|
-
{ p: list }
|
|
84
|
-
]
|
|
85
|
-
rule: record: close: [
|
|
86
|
-
{ s: '#ZZ' }
|
|
87
|
-
{ s: '#LN #ZZ' b: 1 }
|
|
88
|
-
{ s: '#LN' r: '@record-close-next' }
|
|
89
|
-
]
|
|
90
|
-
|
|
91
|
-
rule: text: open: [
|
|
92
|
-
{ s: ['#VAL' '#SP'] b: 1 r: text n: { text: 1 } g: 'markdown,space,follows' a: '@text-follows' }
|
|
93
|
-
{ s: ['#SP' '#VAL'] r: text n: { text: 1 } g: 'markdown,space,leads' a: '@text-leads' }
|
|
94
|
-
{ s: ['#SP' '#CA #LN #ZZ'] b: 1 n: { text: 1 } g: 'markdown,end' a: '@text-end' }
|
|
95
|
-
{ s: '#SP' n: { text: 1 } g: 'markdown,space' a: '@text-space' p: '@text-space-push' }
|
|
96
|
-
{}
|
|
97
107
|
]
|
|
98
108
|
}
|
|
99
109
|
`
|
|
100
110
|
// --- END EMBEDDED markdown-grammar.jsonic ---
|
|
101
111
|
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
// Normalize boolean options.
|
|
129
|
-
const strict = !!options.strict
|
|
130
|
-
const objres = !!options.object
|
|
131
|
-
const header = !!options.header
|
|
132
|
-
|
|
133
|
-
// These may be changed below by superior options.
|
|
134
|
-
let trim = !!options.trim
|
|
135
|
-
let comment = !!options.comment
|
|
136
|
-
let opt_number = !!options.number
|
|
137
|
-
let opt_value = !!options.value
|
|
138
|
-
let record_empty = !!options.record?.empty
|
|
139
|
-
|
|
140
|
-
const stream = options.stream
|
|
141
|
-
|
|
142
|
-
// In strict mode, Jsonic field content is not parsed.
|
|
143
|
-
if (strict) {
|
|
144
|
-
if (false !== options.string.markdown) {
|
|
145
|
-
tn.options({
|
|
146
|
-
lex: {
|
|
147
|
-
match: {
|
|
148
|
-
stringmarkdown: { order: 1e5, make: buildMarkdownStringMatcher(options) },
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
})
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Inline helpers
|
|
114
|
+
|
|
115
|
+
function parseLinkDestination(inside: string): { url: string; title: string | null } {
|
|
116
|
+
let s = inside.trim()
|
|
117
|
+
if ('' === s) return { url: '', title: null }
|
|
118
|
+
|
|
119
|
+
// Angle-bracketed URL: <http://example.com>
|
|
120
|
+
if (s.startsWith('<')) {
|
|
121
|
+
const close = s.indexOf('>', 1)
|
|
122
|
+
if (-1 !== close) {
|
|
123
|
+
const url = s.slice(1, close)
|
|
124
|
+
const rest = s.slice(close + 1).trim()
|
|
125
|
+
if ('' === rest) return { url, title: null }
|
|
126
|
+
if (
|
|
127
|
+
(rest.startsWith('"') && rest.endsWith('"') && 1 < rest.length) ||
|
|
128
|
+
(rest.startsWith("'") && rest.endsWith("'") && 1 < rest.length) ||
|
|
129
|
+
(rest.startsWith('(') && rest.endsWith(')') && 1 < rest.length)
|
|
130
|
+
) {
|
|
131
|
+
return { url, title: rest.slice(1, -1) }
|
|
132
|
+
}
|
|
133
|
+
return { url: s, title: null } // malformed title → treat whole inside as url
|
|
152
134
|
}
|
|
153
|
-
tn.options({
|
|
154
|
-
rule: { exclude: 'jsonic,imp' },
|
|
155
|
-
})
|
|
156
135
|
}
|
|
157
136
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
137
|
+
// Unbracketed URL: first token is url, remainder (if quoted) is title
|
|
138
|
+
// Split on whitespace not inside quotes.
|
|
139
|
+
let url = ''
|
|
140
|
+
let title: string | null = null
|
|
141
|
+
let i = 0
|
|
142
|
+
while (i < s.length && !/\s/.test(s[i])) i++
|
|
143
|
+
url = s.slice(0, i)
|
|
144
|
+
const rest = s.slice(i).trim()
|
|
145
|
+
if ('' !== rest) {
|
|
146
|
+
if (
|
|
147
|
+
(rest.startsWith('"') && rest.endsWith('"') && 1 < rest.length) ||
|
|
148
|
+
(rest.startsWith("'") && rest.endsWith("'") && 1 < rest.length) ||
|
|
149
|
+
(rest.startsWith('(') && rest.endsWith(')') && 1 < rest.length)
|
|
150
|
+
) {
|
|
151
|
+
title = rest.slice(1, -1)
|
|
152
|
+
} else {
|
|
153
|
+
// Malformed — treat whole inside as url
|
|
154
|
+
url = s
|
|
155
|
+
title = null
|
|
168
156
|
}
|
|
169
|
-
trim = null === options.trim ? true : trim
|
|
170
|
-
comment = null === options.comment ? true : comment
|
|
171
|
-
opt_number = null === options.number ? true : opt_number
|
|
172
|
-
opt_value = null === options.value ? true : opt_value
|
|
173
|
-
tn.options({
|
|
174
|
-
rule: { exclude: 'imp' },
|
|
175
|
-
})
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Stream rows as they are parsed, do not store in result.
|
|
179
|
-
if (stream) {
|
|
180
|
-
let parser = tn.internal().parser
|
|
181
|
-
let origStart = parser.start.bind(parser) as (...args: any[]) => any
|
|
182
|
-
parser.start = ((...args: any[]) => {
|
|
183
|
-
try {
|
|
184
|
-
return origStart(...args)
|
|
185
|
-
} catch (e: any) {
|
|
186
|
-
stream('error', e)
|
|
187
|
-
}
|
|
188
|
-
}) as typeof parser.start
|
|
189
157
|
}
|
|
158
|
+
return { url, title }
|
|
159
|
+
}
|
|
190
160
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
161
|
+
function findClosingBracket(text: string, start: number): number {
|
|
162
|
+
let depth = 0
|
|
163
|
+
for (let i = start; i < text.length; i++) {
|
|
164
|
+
const c = text[i]
|
|
165
|
+
if ('\\' === c && i + 1 < text.length) { i++; continue }
|
|
166
|
+
if ('[' === c) depth++
|
|
167
|
+
else if (']' === c) {
|
|
168
|
+
if (0 === depth) return i
|
|
169
|
+
depth--
|
|
200
170
|
}
|
|
201
171
|
}
|
|
172
|
+
return -1
|
|
173
|
+
}
|
|
202
174
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
fixed: {
|
|
214
|
-
token,
|
|
215
|
-
},
|
|
216
|
-
tokenSet: {
|
|
217
|
-
IGNORE: [
|
|
218
|
-
strict ? null : undefined, // Handle #SP space
|
|
219
|
-
null, // Handle #LN newlines
|
|
220
|
-
undefined, // Still ignore #CM comments
|
|
221
|
-
],
|
|
222
|
-
},
|
|
223
|
-
number: {
|
|
224
|
-
lex: opt_number,
|
|
225
|
-
},
|
|
226
|
-
value: {
|
|
227
|
-
lex: opt_value,
|
|
228
|
-
},
|
|
229
|
-
comment: {
|
|
230
|
-
lex: comment,
|
|
231
|
-
},
|
|
232
|
-
lex: {
|
|
233
|
-
emptyResult: [],
|
|
234
|
-
},
|
|
235
|
-
line: {
|
|
236
|
-
single: record_empty,
|
|
237
|
-
chars:
|
|
238
|
-
null == options.record.separators
|
|
239
|
-
? undefined
|
|
240
|
-
: options.record.separators,
|
|
241
|
-
rowChars:
|
|
242
|
-
null == options.record.separators
|
|
243
|
-
? undefined
|
|
244
|
-
: options.record.separators,
|
|
245
|
-
},
|
|
246
|
-
error: {
|
|
247
|
-
markdown_extra_field: 'unexpected extra field value: $fsrc',
|
|
248
|
-
markdown_missing_field: 'missing field',
|
|
249
|
-
},
|
|
250
|
-
hint: {
|
|
251
|
-
markdown_extra_field: `Row $row has too many fields (the first of which is: $fsrc). Only $len
|
|
252
|
-
fields per row are expected.`,
|
|
253
|
-
markdown_missing_field: `Row $row has too few fields. $len fields per row are expected.`,
|
|
254
|
-
},
|
|
175
|
+
function findClosingParen(text: string, start: number): number {
|
|
176
|
+
let depth = 0
|
|
177
|
+
for (let i = start; i < text.length; i++) {
|
|
178
|
+
const c = text[i]
|
|
179
|
+
if ('\\' === c && i + 1 < text.length) { i++; continue }
|
|
180
|
+
if ('(' === c) depth++
|
|
181
|
+
else if (')' === c) {
|
|
182
|
+
if (0 === depth) return i
|
|
183
|
+
depth--
|
|
184
|
+
}
|
|
255
185
|
}
|
|
186
|
+
return -1
|
|
187
|
+
}
|
|
256
188
|
|
|
257
|
-
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Inline scanner — produces Inline[] from a text string.
|
|
191
|
+
// `text` may contain '\n' for soft breaks (paragraph lines joined with \n).
|
|
258
192
|
|
|
193
|
+
function parseInline(text: string, opts: Required<MarkdownOptions>): Inline[] {
|
|
194
|
+
const nodes: Inline[] = []
|
|
195
|
+
let i = 0
|
|
196
|
+
let buf = ''
|
|
259
197
|
|
|
260
|
-
|
|
261
|
-
|
|
198
|
+
const flush = () => {
|
|
199
|
+
if ('' !== buf) { nodes.push({ type: 'text', value: buf }); buf = '' }
|
|
200
|
+
}
|
|
262
201
|
|
|
263
|
-
|
|
202
|
+
while (i < text.length) {
|
|
203
|
+
const ch = text[i]
|
|
264
204
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
205
|
+
// Escapes — \* \_ \~ \` etc.
|
|
206
|
+
if ('\\' === ch && i + 1 < text.length && /[\\`*_\[\]{}()#+\-.!|>~]/.test(text[i + 1])) {
|
|
207
|
+
buf += text[i + 1]
|
|
208
|
+
i += 2
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
270
211
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
212
|
+
// Inline code — `code` or ``code with ` inside`` etc.
|
|
213
|
+
if ('`' === ch) {
|
|
214
|
+
let j = i
|
|
215
|
+
while (j < text.length && '`' === text[j]) j++
|
|
216
|
+
const ticks = j - i
|
|
217
|
+
const fence = '`'.repeat(ticks)
|
|
218
|
+
const close = text.indexOf(fence, j)
|
|
219
|
+
if (-1 !== close) {
|
|
220
|
+
const raw = text.slice(j, close)
|
|
221
|
+
// Collapse newlines/spaces: trim at ends, internal newlines → space
|
|
222
|
+
const code = raw.replace(/\r?\n/g, ' ').replace(/^\s+|\s+$/g, '')
|
|
223
|
+
// Per CommonMark, `` `` → ``? Already handled by fence length.
|
|
224
|
+
flush()
|
|
225
|
+
nodes.push({ type: 'inlineCode', value: code })
|
|
226
|
+
i = close + ticks
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
}
|
|
274
230
|
|
|
275
|
-
|
|
276
|
-
|
|
231
|
+
// Image — 
|
|
232
|
+
if (text.startsWith('![', i)) {
|
|
233
|
+
const closeAlt = findClosingBracket(text, i + 2)
|
|
234
|
+
if (-1 !== closeAlt && '(' === text[closeAlt + 1]) {
|
|
235
|
+
const closeParen = findClosingParen(text, closeAlt + 2)
|
|
236
|
+
if (-1 !== closeParen) {
|
|
237
|
+
const alt = text.slice(i + 2, closeAlt)
|
|
238
|
+
const inside = text.slice(closeAlt + 2, closeParen)
|
|
239
|
+
const { url, title } = parseLinkDestination(inside)
|
|
240
|
+
if ('' !== url) {
|
|
241
|
+
flush()
|
|
242
|
+
nodes.push({ type: 'image', url, title, alt })
|
|
243
|
+
i = closeParen + 1
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
277
249
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
250
|
+
// Autolink — <http://…> or <https://…>
|
|
251
|
+
if ('<' === ch) {
|
|
252
|
+
const close = text.indexOf('>', i)
|
|
253
|
+
if (-1 !== close) {
|
|
254
|
+
const url = text.slice(i + 1, close)
|
|
255
|
+
if (/^https?:\/\/\S+$/.test(url) || /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/.test(url)) {
|
|
256
|
+
flush()
|
|
257
|
+
const href = url.startsWith('mailto:') ? url : url
|
|
258
|
+
nodes.push({ type: 'link', url: href, title: null, children: [{ type: 'text', value: url }] })
|
|
259
|
+
i = close + 1
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
// Email autolink <a@b.com> etc. — treat same as http for now (already covered)
|
|
263
|
+
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(url)) {
|
|
264
|
+
flush()
|
|
265
|
+
nodes.push({ type: 'link', url: 'mailto:' + url, title: null, children: [{ type: 'text', value: url }] })
|
|
266
|
+
i = close + 1
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
297
271
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
272
|
+
// Link — [label](url "title")
|
|
273
|
+
if ('[' === ch) {
|
|
274
|
+
const closeBracket = findClosingBracket(text, i + 1)
|
|
275
|
+
if (-1 !== closeBracket) {
|
|
276
|
+
const label = text.slice(i + 1, closeBracket)
|
|
277
|
+
if ('(' === text[closeBracket + 1]) {
|
|
278
|
+
const closeParen = findClosingParen(text, closeBracket + 2)
|
|
279
|
+
if (-1 !== closeParen) {
|
|
280
|
+
const inside = text.slice(closeBracket + 2, closeParen)
|
|
281
|
+
const { url, title } = parseLinkDestination(inside)
|
|
282
|
+
if ('' !== url) {
|
|
283
|
+
flush()
|
|
284
|
+
nodes.push({ type: 'link', url, title, children: parseInline(label, opts) })
|
|
285
|
+
i = closeParen + 1
|
|
286
|
+
continue
|
|
302
287
|
}
|
|
303
|
-
i = fI
|
|
304
288
|
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
305
292
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
293
|
+
// Strong — **text** or __text__ (must be non-empty)
|
|
294
|
+
if (text.startsWith('**', i) || text.startsWith('__', i)) {
|
|
295
|
+
const delim = text.slice(i, i + 2)
|
|
296
|
+
const close = text.indexOf(delim, i + 2)
|
|
297
|
+
if (-1 !== close && close > i + 2) {
|
|
298
|
+
const inner = text.slice(i + 2, close)
|
|
299
|
+
if ('' !== inner.trim()) {
|
|
300
|
+
flush()
|
|
301
|
+
nodes.push({ type: 'strong', children: parseInline(inner, opts) })
|
|
302
|
+
i = close + 2
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
311
307
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
308
|
+
// Strikethrough GFM — ~~text~~
|
|
309
|
+
if (opts.gfm && text.startsWith('~~', i)) {
|
|
310
|
+
const close = text.indexOf('~~', i + 2)
|
|
311
|
+
if (-1 !== close && close > i + 2) {
|
|
312
|
+
const inner = text.slice(i + 2, close)
|
|
313
|
+
if ('' !== inner.trim()) {
|
|
314
|
+
flush()
|
|
315
|
+
nodes.push({ type: 'delete', children: parseInline(inner, opts) })
|
|
316
|
+
i = close + 2
|
|
317
|
+
continue
|
|
318
318
|
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
319
321
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
322
|
+
// Emphasis — *text* or _text_ (single char)
|
|
323
|
+
if ('*' === ch || '_' === ch) {
|
|
324
|
+
// Avoid handling ** as *: already handled above, but single * inside ** shouldn't reach here alone
|
|
325
|
+
// If next char is same delim, we would have taken strong branch; so this is single.
|
|
326
|
+
// For '_' inside a word like foo_bar_baz, CommonMark says not emphasis — we simplify to not split inside alphanum_ sequence.
|
|
327
|
+
if ('_' === ch) {
|
|
328
|
+
const prevIsAlnum = i > 0 && /[\w]/.test(text[i - 1])
|
|
329
|
+
const nextIsAlnum = i + 1 < text.length && /[\w]/.test(text[i + 1])
|
|
330
|
+
if (prevIsAlnum && nextIsAlnum) { buf += ch; i++; continue }
|
|
331
|
+
// also check close side will have same word-boundary issue, but we check at open and close
|
|
332
|
+
}
|
|
333
|
+
const delim = ch
|
|
334
|
+
let close = -1
|
|
335
|
+
// Find closing delim not escaped and with word-boundary handling for _
|
|
336
|
+
for (let k = i + 1; k < text.length; k++) {
|
|
337
|
+
if ('\\' === text[k] && k + 1 < text.length) { k++; continue }
|
|
338
|
+
if (delim === text[k]) {
|
|
339
|
+
if ('_' === delim) {
|
|
340
|
+
const beforeIsAlnum = k > 0 && /[\w]/.test(text[k - 1])
|
|
341
|
+
const afterIsAlnum = k + 1 < text.length && /[\w]/.test(text[k + 1])
|
|
342
|
+
if (beforeIsAlnum && afterIsAlnum) continue
|
|
343
|
+
}
|
|
344
|
+
// Ensure not part of strong's double
|
|
345
|
+
if (k + 1 < text.length && delim === text[k + 1]) continue
|
|
346
|
+
if (k > 0 && delim === text[k - 1] && text.startsWith(delim + delim, k - 1)) continue
|
|
347
|
+
close = k
|
|
348
|
+
break
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (-1 !== close && close > i + 1) {
|
|
352
|
+
const inner = text.slice(i + 1, close)
|
|
353
|
+
if ('' !== inner.trim()) {
|
|
354
|
+
flush()
|
|
355
|
+
nodes.push({ type: 'emphasis', children: parseInline(inner, opts) })
|
|
356
|
+
i = close + 1
|
|
357
|
+
continue
|
|
324
358
|
}
|
|
325
359
|
}
|
|
360
|
+
}
|
|
326
361
|
|
|
327
|
-
|
|
328
|
-
|
|
362
|
+
// Hard break — two spaces + \n or backslash + \n, or plain \n when breaks:true
|
|
363
|
+
if ('\n' === ch) {
|
|
364
|
+
const prevTwoSpaces = i >= 2 && ' ' === text[i - 1] && ' ' === text[i - 2]
|
|
365
|
+
if (prevTwoSpaces) {
|
|
366
|
+
// Hard break via trailing spaces: remove the two spaces from buffered text
|
|
367
|
+
// They are still in buf (not yet flushed), so trim them there.
|
|
368
|
+
if (buf.endsWith(' ')) buf = buf.slice(0, -2)
|
|
369
|
+
else if (buf.endsWith(' ')) buf = buf.slice(0, -1)
|
|
370
|
+
flush()
|
|
371
|
+
nodes.push({ type: 'break' })
|
|
372
|
+
} else if (opts.breaks) {
|
|
373
|
+
flush()
|
|
374
|
+
nodes.push({ type: 'break' })
|
|
375
|
+
} else {
|
|
376
|
+
// Soft break → single space inside the current text run
|
|
377
|
+
buf += ' '
|
|
378
|
+
}
|
|
379
|
+
i++
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
329
382
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
383
|
+
// Fallthrough — plain char
|
|
384
|
+
buf += ch
|
|
385
|
+
i++
|
|
386
|
+
}
|
|
333
387
|
|
|
388
|
+
flush()
|
|
389
|
+
// If nothing was produced but non-empty text, return single text node (preserve empty paragraph case ? — caller handles)
|
|
390
|
+
return nodes
|
|
391
|
+
}
|
|
334
392
|
|
|
335
|
-
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Block helpers
|
|
336
395
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
},
|
|
396
|
+
function isBlank(line: string): boolean {
|
|
397
|
+
return /^[\t ]*$/.test(line)
|
|
398
|
+
}
|
|
341
399
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
400
|
+
function isATXHeading(line: string): RegExpMatchArray | null {
|
|
401
|
+
// Up to 3 leading spaces, 1-6 #'s, then space, then content, then optional trailing #'s
|
|
402
|
+
return line.match(/^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/)
|
|
403
|
+
}
|
|
345
404
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
},
|
|
405
|
+
function isSetextUnderline(line: string): RegExpMatchArray | null {
|
|
406
|
+
return line.match(/^ {0,3}(=+|-+)[ \t]*$/)
|
|
407
|
+
}
|
|
350
408
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
409
|
+
function isThematicBreak(line: string): boolean {
|
|
410
|
+
if (!/^ {0,3}((\*([ \t]*\*)*)|(-([ \t]*-)*)|(_([ \t]*_)*))[ \t]*$/.test(line)) return false
|
|
411
|
+
const stripped = line.replace(/[ \t]/g, '')
|
|
412
|
+
if (3 > stripped.length) return false
|
|
413
|
+
const c = stripped[0]
|
|
414
|
+
return [...stripped].every(ch => c === ch)
|
|
415
|
+
}
|
|
358
416
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
(1 === r.n.text ? '' : r.prev.node) + (!trim ? r.o0.src : '')
|
|
363
|
-
},
|
|
417
|
+
function isFencedCodeStart(line: string): RegExpMatchArray | null {
|
|
418
|
+
return line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*.*$/)
|
|
419
|
+
}
|
|
364
420
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
421
|
+
function isIndentedCodeLine(line: string): boolean {
|
|
422
|
+
return /^ {4}|\t/.test(line)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isBlockquoteLine(line: string): boolean {
|
|
426
|
+
return /^ {0,3}>\s?/.test(line)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function isUnorderedListItem(line: string): RegExpMatchArray | null {
|
|
430
|
+
return line.match(/^ {0,3}([*+-])[ \t]+(.*)$/)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function isOrderedListItem(line: string): RegExpMatchArray | null {
|
|
434
|
+
return line.match(/^ {0,3}(\d+)([.)])[ \t]+(.*)$/)
|
|
435
|
+
}
|
|
372
436
|
|
|
437
|
+
function isHtmlBlockLine(line: string): boolean {
|
|
438
|
+
const t = line.trimStart()
|
|
439
|
+
return t.startsWith('<') && (
|
|
440
|
+
/^<(?:!--|!\[CDATA\[|!DOCTYPE|\?[a-z])/i.test(t) ||
|
|
441
|
+
/^<\/?[a-zA-Z][\w-]*(?:\s[^>]*)?>/.test(t)
|
|
442
|
+
)
|
|
443
|
+
}
|
|
373
444
|
|
|
374
|
-
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Block parsing
|
|
375
447
|
|
|
376
|
-
|
|
448
|
+
function parseDocument(src: string, opts: Required<MarkdownOptions>): DocumentNode {
|
|
449
|
+
// Normalize newlines, keep trailing newline behaviour: split on \r?\n
|
|
450
|
+
const lines = src.split(/\r?\n/)
|
|
451
|
+
// CommonMark: final newline not significant for block parsing — but an
|
|
452
|
+
// empty trailing line from split should not produce an extra block.
|
|
453
|
+
const { nodes } = parseBlocks(lines, 0, opts, 0)
|
|
454
|
+
return { type: 'document', children: nodes }
|
|
455
|
+
}
|
|
377
456
|
|
|
457
|
+
function parseBlocks(
|
|
458
|
+
lines: string[],
|
|
459
|
+
start: number,
|
|
460
|
+
opts: Required<MarkdownOptions>,
|
|
461
|
+
baseIndent: number,
|
|
462
|
+
): { nodes: Block[]; next: number } {
|
|
463
|
+
const nodes: Block[] = []
|
|
464
|
+
let i = start
|
|
465
|
+
|
|
466
|
+
while (i < lines.length) {
|
|
467
|
+
let line = lines[i]
|
|
468
|
+
|
|
469
|
+
// End if dedented below baseIndent for list handling — caller manages.
|
|
470
|
+
// Here we just skip blanks that are not meaningful inside prose.
|
|
471
|
+
if (isBlank(line)) {
|
|
472
|
+
i++
|
|
473
|
+
continue
|
|
474
|
+
}
|
|
378
475
|
|
|
379
|
-
//
|
|
476
|
+
// Fenced code — takes precedence over other block types
|
|
477
|
+
const fenced = isFencedCodeStart(line)
|
|
478
|
+
if (fenced) {
|
|
479
|
+
const fence = fenced[1]
|
|
480
|
+
const fenceChar = fence[0]
|
|
481
|
+
const langAndMeta = line.slice(line.indexOf(fence) + fence.length).trim()
|
|
482
|
+
let lang: string | null = null
|
|
483
|
+
let meta: string | null = null
|
|
484
|
+
if ('' !== langAndMeta) {
|
|
485
|
+
const spaceIdx = langAndMeta.search(/\s/)
|
|
486
|
+
if (-1 === spaceIdx) lang = langAndMeta
|
|
487
|
+
else { lang = langAndMeta.slice(0, spaceIdx); meta = langAndMeta.slice(spaceIdx + 1).trim() || null }
|
|
488
|
+
}
|
|
489
|
+
const closingRe = new RegExp('^ {0,3}' + fenceChar + '{' + fence.length + ',}[ \\t]*$')
|
|
490
|
+
const content: string[] = []
|
|
491
|
+
i++
|
|
492
|
+
while (i < lines.length && !closingRe.test(lines[i])) {
|
|
493
|
+
content.push(lines[i])
|
|
494
|
+
i++
|
|
495
|
+
}
|
|
496
|
+
if (i < lines.length) i++ // consume closing fence
|
|
497
|
+
nodes.push({ type: 'code', lang: lang || null, meta: meta || null, value: content.join('\n') })
|
|
498
|
+
continue
|
|
499
|
+
}
|
|
380
500
|
|
|
381
|
-
|
|
501
|
+
// Indented code — line indented by ≥4 spaces and not already handled as list
|
|
502
|
+
if (isIndentedCodeLine(line) && !isUnorderedListItem(line) && !isOrderedListItem(line)) {
|
|
503
|
+
const content: string[] = []
|
|
504
|
+
while (i < lines.length && (isIndentedCodeLine(lines[i]) || isBlank(lines[i]))) {
|
|
505
|
+
if (isBlank(lines[i])) {
|
|
506
|
+
// Blank line inside indented code is kept as empty line
|
|
507
|
+
// but we must peek ahead for next indented line to decide if blank is part of code
|
|
508
|
+
// Look ahead to next non-blank
|
|
509
|
+
let j = i + 1
|
|
510
|
+
while (j < lines.length && isBlank(lines[j])) j++
|
|
511
|
+
if (j < lines.length && isIndentedCodeLine(lines[j])) {
|
|
512
|
+
content.push('')
|
|
513
|
+
i++
|
|
514
|
+
} else break
|
|
515
|
+
} else {
|
|
516
|
+
const raw = lines[i]
|
|
517
|
+
content.push(raw.startsWith('\t') ? raw.slice(1) : raw.slice(4))
|
|
518
|
+
i++
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
nodes.push({ type: 'code', lang: null, meta: null, value: content.join('\n') })
|
|
522
|
+
continue
|
|
523
|
+
}
|
|
382
524
|
|
|
383
|
-
|
|
384
|
-
|
|
525
|
+
// ATX heading
|
|
526
|
+
const atx = isATXHeading(line)
|
|
527
|
+
if (atx) {
|
|
528
|
+
const depth = atx[1].length as HeadingNode['depth']
|
|
529
|
+
const content = atx[2].trim()
|
|
530
|
+
nodes.push({ type: 'heading', depth, children: parseInline(content, opts) })
|
|
531
|
+
i++
|
|
532
|
+
continue
|
|
533
|
+
}
|
|
385
534
|
|
|
535
|
+
// Thematic break — before paragraph/setext because --- can be both
|
|
536
|
+
if (isThematicBreak(line)) {
|
|
537
|
+
// But `---` after a paragraph line could be a setext underline for heading level 2.
|
|
538
|
+
// Handle setext case one line lookahead before emitting thematic break.
|
|
539
|
+
// Setext check is done inside paragraph path below via peek.
|
|
540
|
+
// Here we only emit thematic break if not a setext context.
|
|
541
|
+
// For simplicity, if previous block was a paragraph's single-line source that could be setext,
|
|
542
|
+
// paragraph collector would have already consumed it.
|
|
543
|
+
// So safe to emit.
|
|
544
|
+
nodes.push({ type: 'thematicBreak' })
|
|
545
|
+
i++
|
|
546
|
+
continue
|
|
547
|
+
}
|
|
386
548
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
{ p: 'val' },
|
|
457
|
-
])
|
|
458
|
-
.close([
|
|
459
|
-
// An empty element at the end of the line / input.
|
|
460
|
-
{
|
|
461
|
-
s: [CA, [LN, ZZ]],
|
|
462
|
-
b: 1,
|
|
463
|
-
a: (r: Rule) => r.node.push(options.field.empty),
|
|
464
|
-
},
|
|
465
|
-
|
|
466
|
-
// Next element.
|
|
467
|
-
{ s: [CA], r: 'elem' },
|
|
468
|
-
|
|
469
|
-
// LN ends record
|
|
470
|
-
{ s: [LN], b: 1 },
|
|
471
|
-
|
|
472
|
-
// ZZ ends input
|
|
473
|
-
{ s: [ZZ] },
|
|
474
|
-
])
|
|
475
|
-
// Push the parsed field value, unless an open alt already handled it
|
|
476
|
-
// (done flag) or there was no value.
|
|
477
|
-
.bc((r: Rule) => {
|
|
478
|
-
if (true !== r.u.done && undefined !== r.child.node) {
|
|
479
|
-
r.node.push(r.child.node)
|
|
480
|
-
}
|
|
481
|
-
})
|
|
482
|
-
})
|
|
483
|
-
|
|
484
|
-
tn.rule('val', (rs: RuleSpec) => {
|
|
485
|
-
return rs
|
|
486
|
-
.clear()
|
|
487
|
-
// A value rule owns its node; start undefined so bc can tell whether
|
|
488
|
-
// a child / token supplied a value (implicit-null otherwise).
|
|
489
|
-
.bo((r: Rule) => {
|
|
490
|
-
r.node = undefined
|
|
491
|
-
})
|
|
492
|
-
.open([
|
|
493
|
-
// Handle text and space concatenation.
|
|
494
|
-
{ s: [VAL, SP], b: 2, p: 'text' },
|
|
495
|
-
{ s: [SP], b: 1, p: 'text' },
|
|
496
|
-
|
|
497
|
-
// A plain value token.
|
|
498
|
-
{ s: [VAL] },
|
|
499
|
-
|
|
500
|
-
// LN ends record
|
|
501
|
-
{ s: [LN], b: 1 },
|
|
502
|
-
])
|
|
503
|
-
// Coalesce: child value > matched token > implicit null (undefined).
|
|
504
|
-
.bc((r: Rule, ctx: Context) => {
|
|
505
|
-
if (undefined === r.node) {
|
|
506
|
-
if (undefined !== r.child.node) {
|
|
507
|
-
r.node = r.child.node
|
|
508
|
-
} else if (0 !== r.os) {
|
|
509
|
-
r.node = r.o0.resolveVal(r, ctx)
|
|
510
|
-
}
|
|
549
|
+
// Blockquote — collect consecutive >-prefixed lines
|
|
550
|
+
if (isBlockquoteLine(line)) {
|
|
551
|
+
const bqLines: string[] = []
|
|
552
|
+
while (i < lines.length && (isBlockquoteLine(lines[i]) || isBlank(lines[i]))) {
|
|
553
|
+
// Blank line inside blockquote is kept, but only if next line is also blockquoted or we are continuing quote
|
|
554
|
+
if (isBlank(lines[i])) {
|
|
555
|
+
// Peek next: if next is blockquote line, this blank is inside quote
|
|
556
|
+
const nxt = i + 1 < lines.length ? lines[i + 1] : ''
|
|
557
|
+
if (isBlockquoteLine(nxt)) { bqLines.push(''); i++; continue }
|
|
558
|
+
// else blank ends blockquote when followed by non-quote content
|
|
559
|
+
// but per CommonMark blank inside quote is allowed even without > marker if we are inside
|
|
560
|
+
// we treat blank as part of quote for recursion
|
|
561
|
+
bqLines.push('')
|
|
562
|
+
i++
|
|
563
|
+
// If next line is blank and then non-quote, break to let outer parse handle
|
|
564
|
+
let j = i
|
|
565
|
+
while (j < lines.length && isBlank(lines[j])) j++
|
|
566
|
+
if (j < lines.length && !isBlockquoteLine(lines[j]) && !isBlank(lines[j])) break
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
569
|
+
const m = lines[i].match(/^ {0,3}>[ \t]?(.*)$/)
|
|
570
|
+
bqLines.push(m ? m[1] : '')
|
|
571
|
+
i++
|
|
572
|
+
}
|
|
573
|
+
// Trim trailing blank
|
|
574
|
+
while (bqLines.length && '' === bqLines[bqLines.length - 1]) bqLines.pop()
|
|
575
|
+
const inner = parseBlocks(bqLines, 0, opts, 0).nodes
|
|
576
|
+
nodes.push({ type: 'blockquote', children: inner })
|
|
577
|
+
continue
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// HTML block — raw line starting with < until blank line
|
|
581
|
+
if (isHtmlBlockLine(line)) {
|
|
582
|
+
const htmlLines: string[] = []
|
|
583
|
+
while (i < lines.length && !isBlank(lines[i])) {
|
|
584
|
+
htmlLines.push(lines[i])
|
|
585
|
+
i++
|
|
586
|
+
// HTML blocks typically end at blank line; we let blank consume break outer
|
|
587
|
+
if (i < lines.length && isBlank(lines[i])) break
|
|
588
|
+
// Also break if next line is a clear block start (heading, fence, thematic)
|
|
589
|
+
const nxt = i < lines.length ? lines[i] : ''
|
|
590
|
+
if (isATXHeading(nxt) || isFencedCodeStart(nxt) || isThematicBreak(nxt) || isBlockquoteLine(nxt) || isUnorderedListItem(nxt) || isOrderedListItem(nxt)) break
|
|
591
|
+
}
|
|
592
|
+
nodes.push({ type: 'html', value: htmlLines.join('\n') })
|
|
593
|
+
continue
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Lists — must be handled together to group consecutive items
|
|
597
|
+
const isUL = isUnorderedListItem(line)
|
|
598
|
+
const isOL = isOrderedListItem(line)
|
|
599
|
+
if (isUL || isOL) {
|
|
600
|
+
const ordered = !!isOL
|
|
601
|
+
const startNum = isOL ? parseInt(isOL[1], 10) : null
|
|
602
|
+
const items: ListItemNode[] = []
|
|
603
|
+
let spread = false
|
|
604
|
+
// Collect items while next line is list marker of similar type or indented continuation
|
|
605
|
+
while (i < lines.length) {
|
|
606
|
+
const cur = lines[i]
|
|
607
|
+
if (isBlank(cur)) { i++; continue }
|
|
608
|
+
const ulM = isUnorderedListItem(cur)
|
|
609
|
+
const olM = isOrderedListItem(cur)
|
|
610
|
+
const markerMatch = ordered ? olM : ulM
|
|
611
|
+
// If next line is not the right list kind, see if it's continuation of previous item
|
|
612
|
+
if (!markerMatch) {
|
|
613
|
+
// Continuation: indented by ≥2 spaces or belongs to last item's blocks?
|
|
614
|
+
// Check if last item exists and current line is indented (2+ spaces) and not another block start
|
|
615
|
+
if (items.length && /^ {2,}/.test(cur) && !isATXHeading(cur) && !isFencedCodeStart(cur) && !isThematicBreak(cur) && !isBlockquoteLine(cur) && !isHtmlBlockLine(cur)) {
|
|
616
|
+
// Continuation belongs to last item — append to its raw lines before re-parsing
|
|
617
|
+
// We will instead handle continuations inside item parsing loop below.
|
|
511
618
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
.close([{ s: [ZZ] }], { append: true })
|
|
536
|
-
})
|
|
537
|
-
|
|
538
|
-
tn.rule('elem', (rs: RuleSpec) => {
|
|
539
|
-
return rs
|
|
540
|
-
// Replace jsonic's elem bc (child$ / pair handling) with a clean
|
|
541
|
-
// push so record fields and embedded array elements both land as
|
|
542
|
-
// plain values.
|
|
543
|
-
.clearActions('bc')
|
|
544
|
-
.open([
|
|
545
|
-
// An empty element (`,` with nothing before it).
|
|
546
|
-
{
|
|
547
|
-
s: [CA],
|
|
548
|
-
b: 1,
|
|
549
|
-
a: (r: Rule) => {
|
|
550
|
-
r.node.push(options.field.empty)
|
|
551
|
-
r.u.done = true
|
|
552
|
-
},
|
|
553
|
-
},
|
|
554
|
-
])
|
|
555
|
-
// Fallback: parse a field/element value, after jsonic's alts.
|
|
556
|
-
.open([{ p: 'val' }], { append: true })
|
|
557
|
-
.close([
|
|
558
|
-
// An empty element at the end of the line / input.
|
|
559
|
-
{
|
|
560
|
-
s: [CA, [LN, ZZ]],
|
|
561
|
-
b: 1,
|
|
562
|
-
a: (r: Rule) => r.node.push(options.field.empty),
|
|
563
|
-
},
|
|
564
|
-
|
|
565
|
-
// LN ends record
|
|
566
|
-
{ s: [LN], b: 1 },
|
|
567
|
-
])
|
|
568
|
-
.bc((r: Rule) => {
|
|
569
|
-
if (true !== r.u.done && undefined !== r.child.node) {
|
|
570
|
-
r.node.push(r.child.node)
|
|
619
|
+
break
|
|
620
|
+
}
|
|
621
|
+
// Parse one item
|
|
622
|
+
const markerLen = cur.match(/^ {0,3}(?:[*+-]|\d+[.)])[ \t]+/)![0].length
|
|
623
|
+
const firstContent = ordered ? olM![3] : ulM![2]
|
|
624
|
+
const itemLines: string[] = []
|
|
625
|
+
itemLines.push(firstContent)
|
|
626
|
+
i++
|
|
627
|
+
|
|
628
|
+
// Collect following lines belonging to this item: blank lines, indented content, paragraph continuation
|
|
629
|
+
let blankRun = 0
|
|
630
|
+
while (i < lines.length) {
|
|
631
|
+
const nxt = lines[i]
|
|
632
|
+
if (isBlank(nxt)) {
|
|
633
|
+
// Blank inside list item is kept — but peek if next line starts a new item
|
|
634
|
+
const peek = i + 1 < lines.length ? lines[i + 1] : null
|
|
635
|
+
const peekIsList = peek && (isUnorderedListItem(peek) || isOrderedListItem(peek))
|
|
636
|
+
if (peekIsList) break // blank before next item → end of this item
|
|
637
|
+
// If loose, keep blank
|
|
638
|
+
itemLines.push('')
|
|
639
|
+
blankRun++
|
|
640
|
+
i++
|
|
641
|
+
continue
|
|
571
642
|
}
|
|
572
|
-
})
|
|
573
|
-
})
|
|
574
|
-
|
|
575
|
-
tn.rule('val', (rs: RuleSpec) => {
|
|
576
|
-
return rs
|
|
577
|
-
.open([
|
|
578
|
-
// Handle text and space concatenation.
|
|
579
|
-
{ s: [VAL, SP], b: 2, p: 'text' },
|
|
580
|
-
{ s: [SP], b: 1, p: 'text' },
|
|
581
|
-
|
|
582
|
-
// LN ends record
|
|
583
|
-
{ s: [LN], b: 1 },
|
|
584
|
-
])
|
|
585
|
-
})
|
|
586
|
-
}
|
|
587
643
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
return function markdownStringMatcher(lex: Lex): Token | undefined {
|
|
602
|
-
let quoteMap: any = { [mdopts.string.quote]: true }
|
|
603
|
-
|
|
604
|
-
let { pnt, src } = lex
|
|
605
|
-
let { sI, rI, cI } = pnt
|
|
606
|
-
let srclen = src.length
|
|
607
|
-
|
|
608
|
-
if (quoteMap[src[sI]]) {
|
|
609
|
-
const q = src[sI] // Quote character
|
|
610
|
-
const qI = sI
|
|
611
|
-
const qrI = rI
|
|
612
|
-
++sI
|
|
613
|
-
++cI
|
|
614
|
-
|
|
615
|
-
let s: string[] = []
|
|
616
|
-
|
|
617
|
-
for (sI; sI < srclen; sI++) {
|
|
618
|
-
cI++
|
|
619
|
-
let c = src[sI]
|
|
620
|
-
|
|
621
|
-
// Quote char.
|
|
622
|
-
if (q === c) {
|
|
623
|
-
sI++
|
|
624
|
-
cI++
|
|
625
|
-
|
|
626
|
-
if (q === src[sI]) {
|
|
627
|
-
s.push(q)
|
|
628
|
-
} else {
|
|
629
|
-
break // String finished.
|
|
644
|
+
// If next line is a list marker at same base indent, it is the next item
|
|
645
|
+
if ((isUnorderedListItem(nxt) || isOrderedListItem(nxt)) && !/^ {4,}/.test(nxt)) break
|
|
646
|
+
|
|
647
|
+
// If line is indented ≥2 spaces beyond base? Actually CommonMark list content indent = markerLen or 4?
|
|
648
|
+
// Simplify: if line starts with spaces and not a blockquote/thematic/heading/fenced, treat as item continuation.
|
|
649
|
+
if (/^ {2,}/.test(nxt) || /^\t/.test(nxt)) {
|
|
650
|
+
// Strip one level of indentation (up to 4 spaces or 1 tab) but keep as content
|
|
651
|
+
let stripped = nxt
|
|
652
|
+
if (stripped.startsWith('\t')) stripped = stripped.slice(1)
|
|
653
|
+
else {
|
|
654
|
+
// Remove up to markerLen or 4 spaces? Use 2 as common
|
|
655
|
+
const lead = stripped.match(/^ +/)![0].length
|
|
656
|
+
stripped = stripped.slice(Math.min(4, lead))
|
|
630
657
|
}
|
|
658
|
+
// Check if stripped line looks like an inner blockquote/list etc. — keep as is for inner parse
|
|
659
|
+
itemLines.push(stripped)
|
|
660
|
+
i++
|
|
661
|
+
blankRun = 0
|
|
662
|
+
continue
|
|
631
663
|
}
|
|
632
664
|
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
665
|
+
// If line looks like another block start (heading, code, thematic, blockquote, html) at base, break?
|
|
666
|
+
if (isATXHeading(nxt) || isFencedCodeStart(nxt) || isThematicBreak(nxt) || isBlockquoteLine(nxt) || isHtmlBlockLine(nxt)) {
|
|
667
|
+
// Inside list items, these are content, not outer blocks, if indented earlier they'd have been taken.
|
|
668
|
+
// Unindented block inside list: check if we already have content — if so it may terminate list.
|
|
669
|
+
// For tight lists, indented blocks are needed. Simplify: break to let items close, next iteration will handle as outer block.
|
|
670
|
+
break
|
|
671
|
+
}
|
|
636
672
|
|
|
637
|
-
|
|
638
|
-
|
|
673
|
+
// Otherwise treat as loose paragraph continuation? Only if previous line wasn't blank?
|
|
674
|
+
if (blankRun) break
|
|
675
|
+
// Unindented paragraph-like line inside item (tight list paragraph extension)
|
|
676
|
+
// Only if item already has one line and next line is not a list marker: join as continuation
|
|
677
|
+
itemLines.push(nxt)
|
|
678
|
+
i++
|
|
679
|
+
}
|
|
639
680
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
pnt.sI = sI
|
|
655
|
-
pnt.cI = cI
|
|
656
|
-
return lex.bad('unprintable', sI, sI + 1)
|
|
657
|
-
} else {
|
|
658
|
-
s.push(src.substring(bI, sI))
|
|
659
|
-
sI--
|
|
660
|
-
}
|
|
661
|
-
}
|
|
681
|
+
// Determine if item had blank line inside → affects list spread (loose)
|
|
682
|
+
const itemHadBlank = itemLines.includes('')
|
|
683
|
+
if (itemHadBlank) spread = true
|
|
684
|
+
|
|
685
|
+
// Trim trailing blanks
|
|
686
|
+
while (itemLines.length && '' === itemLines[itemLines.length - 1]) itemLines.pop()
|
|
687
|
+
|
|
688
|
+
// Parse item content as blocks (recursive)
|
|
689
|
+
// If itemLines is single line and contains only inline, it will become a paragraph. CommonMark wraps tight items' paragraph directly.
|
|
690
|
+
const strippedItemSrc = itemLines.join('\n')
|
|
691
|
+
// Avoid empty item
|
|
692
|
+
let itemBlocks: Block[] = []
|
|
693
|
+
if (strippedItemSrc.trim()) {
|
|
694
|
+
itemBlocks = parseBlocks(strippedItemSrc.split(/\r?\n/), 0, opts, 0).nodes
|
|
662
695
|
}
|
|
663
696
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
697
|
+
// Tight lists: single paragraph items are collapsed? Our AST keeps paragraph wrapper regardless — callers handle spread.
|
|
698
|
+
// Record spread on item if its trailing had blank
|
|
699
|
+
items.push({ type: 'listItem', spread: itemHadBlank, children: itemBlocks })
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
nodes.push({ type: 'list', ordered, start: startNum, spread, children: items })
|
|
703
|
+
continue
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Paragraph — collect until blank or block start, handling setext underline
|
|
707
|
+
{
|
|
708
|
+
const paraLines: string[] = []
|
|
709
|
+
while (i < lines.length && !isBlank(lines[i])) {
|
|
710
|
+
const nxt = lines[i]
|
|
711
|
+
// Break if next line is a block that cannot be inside a paragraph without blank line.
|
|
712
|
+
// Setext underline (=== or ---) looks like a thematic break but must be kept
|
|
713
|
+
// as the last para line so the post-loop Setext check can turn the paragraph
|
|
714
|
+
// into a heading. Only break on thematic breaks that are NOT Setext underlines.
|
|
715
|
+
const isSetext = null !== isSetextUnderline(nxt)
|
|
716
|
+
const isThematicButNotSetext = isThematicBreak(nxt) && !isSetext
|
|
717
|
+
if (paraLines.length && (
|
|
718
|
+
isATXHeading(nxt) ||
|
|
719
|
+
isFencedCodeStart(nxt) ||
|
|
720
|
+
isThematicButNotSetext ||
|
|
721
|
+
isBlockquoteLine(nxt) ||
|
|
722
|
+
isUnorderedListItem(nxt) ||
|
|
723
|
+
isOrderedListItem(nxt) ||
|
|
724
|
+
isHtmlBlockLine(nxt)
|
|
725
|
+
)) break
|
|
726
|
+
// Also check for indented code (not part of para)
|
|
727
|
+
if (paraLines.length && isIndentedCodeLine(nxt)) break
|
|
728
|
+
paraLines.push(nxt)
|
|
729
|
+
i++
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Check for Setext heading: last line could be underline if para is 1-2 lines of text
|
|
733
|
+
// A setext underline must not be thematic break-duplicate. Pattern: === or ---
|
|
734
|
+
if (paraLines.length) {
|
|
735
|
+
const last = paraLines[paraLines.length - 1]
|
|
736
|
+
const underline = isSetextUnderline(last)
|
|
737
|
+
if (underline && paraLines.length >= 2) {
|
|
738
|
+
const depth: 1 | 2 = '=' === underline[1][0] ? 1 : 2
|
|
739
|
+
const hdText = paraLines.slice(0, -1).join('\n')
|
|
740
|
+
nodes.push({ type: 'heading', depth, children: parseInline(hdText.trim(), opts) })
|
|
741
|
+
continue
|
|
667
742
|
}
|
|
743
|
+
// Single-line setext: need preceding para line + underline on next line? Our peek included underline line already as part of paraLines.
|
|
744
|
+
// If paraLines is 2 lines where second is underline, the above would have depth. If paraLines is 1 line underline alone, it was already handled as thematic break earlier? But `---` alone is thematic break, not setext with no para above.
|
|
745
|
+
// So ok.
|
|
746
|
+
}
|
|
668
747
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
src.substring(pnt.sI, sI),
|
|
673
|
-
pnt,
|
|
674
|
-
)
|
|
675
|
-
|
|
676
|
-
pnt.sI = sI
|
|
677
|
-
pnt.rI = rI
|
|
678
|
-
pnt.cI = cI
|
|
679
|
-
return tkn
|
|
748
|
+
const paraText = paraLines.join('\n').trimEnd()
|
|
749
|
+
if ('' !== paraText) {
|
|
750
|
+
nodes.push({ type: 'paragraph', children: parseInline(paraText, opts) })
|
|
680
751
|
}
|
|
752
|
+
continue
|
|
681
753
|
}
|
|
682
754
|
}
|
|
755
|
+
|
|
756
|
+
return { nodes, next: i }
|
|
683
757
|
}
|
|
684
758
|
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
759
|
+
// ---------------------------------------------------------------------------
|
|
760
|
+
// Plugin wiring — bare engine (no jsonic). The rule is trivial; all
|
|
761
|
+
// work happens in the `bo` that captures the source via `ctx.src()`.
|
|
762
|
+
// This is the same pattern yaml's 2559-line plugin ultimately uses:
|
|
763
|
+
// custom lex matchers + state actions, not declarative alts, for prose.
|
|
764
|
+
|
|
765
|
+
const Markdown: Plugin = (tn: Tabnas, options?: MarkdownOptions) => {
|
|
766
|
+
const opts: Required<MarkdownOptions> = {
|
|
767
|
+
gfm: options?.gfm ?? true,
|
|
768
|
+
breaks: options?.breaks ?? false,
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// No custom token descriptions needed for prose — the block scanner
|
|
772
|
+
// reads ctx.src() directly; railroad will show the single `markdown`
|
|
773
|
+
// rule as the entry point.
|
|
774
|
+
|
|
775
|
+
// Start rule
|
|
776
|
+
tn.options({ rule: { start: 'markdown' } })
|
|
777
|
+
|
|
778
|
+
// Prose parser reads `ctx.src()` directly and does not rely on the
|
|
779
|
+
// jsonic lexer. Disable lexers that would turn Markdown syntax into
|
|
780
|
+
// bad tokens before the block scanner runs:
|
|
781
|
+
// - string: backtick code spans would otherwise be parsed as unterminated strings
|
|
782
|
+
// - comment: `# heading` must not be swallowed as a comment line
|
|
783
|
+
// - number/value: `1. list` must stay as text, not a number/keyword
|
|
784
|
+
tn.options({
|
|
785
|
+
string: { lex: false },
|
|
786
|
+
comment: { lex: false },
|
|
787
|
+
number: { lex: false },
|
|
788
|
+
value: { lex: false },
|
|
789
|
+
lex: { emptyResult: { type: 'document', children: [] } as DocumentNode },
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
// Grammar is intentionally tiny — see `grammarText` above for the
|
|
793
|
+
// railroad-visible spec. Install it first so `rule('markdown')` can
|
|
794
|
+
// clear/override it cleanly.
|
|
795
|
+
// We parse the embedded JSON via a throwaway Tabnas to get a GrammarSpec,
|
|
796
|
+
// then install it with the ref map.
|
|
797
|
+
// To avoid a jsonic dependency, install inline via tn.grammar instead of
|
|
798
|
+
// parsing jsonic text — simpler: just define the rule directly.
|
|
799
|
+
|
|
800
|
+
const refs: Record<string, any> = {
|
|
801
|
+
'@markdown-bo': (r: Rule, ctx: Context) => {
|
|
802
|
+
const src: string = ctx.src()
|
|
803
|
+
// Preserve empty handling: "" → empty document
|
|
804
|
+
if ('' === src || /^[\r\n\t ]*$/.test(src)) {
|
|
805
|
+
r.node = { type: 'document', children: [] } as DocumentNode
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
r.node = parseDocument(src, opts)
|
|
809
|
+
},
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Define the `markdown` rule: `bo` builds the document from
|
|
813
|
+
// `ctx.src()`; the alts then consume the underlying token stream so
|
|
814
|
+
// the engine's trailing-content check (lex must end at #ZZ) passes.
|
|
815
|
+
// `#AA` is the ANY-token wildcard and `r:'markdown'` loops at the
|
|
816
|
+
// same depth, consuming one token per iteration until only #ZZ
|
|
817
|
+
// remains. The empty alt `{}`
|
|
818
|
+
// then falls through to the close phase, which matches #ZZ.
|
|
819
|
+
tn.rule('markdown', (rs) => {
|
|
820
|
+
return rs
|
|
821
|
+
.clear()
|
|
822
|
+
.bo(refs['@markdown-bo'] as any)
|
|
823
|
+
.open([{ s: '#AA', r: 'markdown' }, {}])
|
|
824
|
+
.close([{ s: '#ZZ' }, {}])
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
// Expose helper for external HTML rendering or inline reuse (not part of
|
|
828
|
+
// tabnas contract, but useful for tests/docs).
|
|
829
|
+
;(tn as any).markdown = { parseDocument, parseInline: (t: string) => parseInline(t, opts) }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Defaults for `new Tabnas().use(Markdown, opts?)` merging
|
|
833
|
+
Markdown.defaults = { gfm: true, breaks: false } as MarkdownOptions
|
|
834
|
+
|
|
835
|
+
export {
|
|
836
|
+
Markdown,
|
|
837
|
+
parseDocument,
|
|
838
|
+
parseInline,
|
|
839
|
+
}
|