@tabnas/markdown 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -10
- package/dist/ast.d.ts +113 -0
- package/dist/ast.js +273 -0
- package/dist/ast.js.map +1 -0
- package/dist/block.d.ts +10 -0
- package/dist/block.js +1411 -0
- package/dist/block.js.map +1 -0
- package/dist/common.d.ts +49 -0
- package/dist/common.js +155 -0
- package/dist/common.js.map +1 -0
- package/dist/commonmark.d.ts +14 -0
- package/dist/commonmark.js +31 -0
- package/dist/commonmark.js.map +1 -0
- package/dist/entities.d.ts +1 -0
- package/dist/entities.js +14 -0
- package/dist/entities.js.map +1 -0
- package/dist/html.d.ts +16 -0
- package/dist/html.js +393 -0
- package/dist/html.js.map +1 -0
- package/dist/inline.d.ts +19 -0
- package/dist/inline.js +1331 -0
- package/dist/inline.js.map +1 -0
- package/dist/markdown.d.ts +21 -84
- package/dist/markdown.js +80 -687
- package/dist/markdown.js.map +1 -1
- package/dist/node.d.ts +112 -0
- package/dist/node.js +203 -0
- package/dist/node.js.map +1 -0
- package/dist/options.d.ts +26 -0
- package/dist/options.js +16 -0
- package/dist/options.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -1
- package/src/ast.ts +406 -0
- package/src/block.ts +1579 -0
- package/src/common.ts +166 -0
- package/src/commonmark.ts +34 -0
- package/src/entities.ts +14 -0
- package/src/html.ts +446 -0
- package/src/inline.ts +1495 -0
- package/src/markdown.ts +132 -770
- package/src/node.ts +293 -0
- package/src/options.ts +44 -0
- package/src/tsconfig.json +2 -0
package/src/markdown.ts
CHANGED
|
@@ -1,103 +1,78 @@
|
|
|
1
1
|
/* Copyright (c) 2021-2026 Richard Rodger, MIT License */
|
|
2
2
|
|
|
3
|
-
// @tabnas/markdown — CommonMark
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
3
|
+
// @tabnas/markdown — a CommonMark 0.31.2 parser, built as a tabnas plugin on
|
|
4
|
+
// the bare engine.
|
|
5
|
+
//
|
|
6
|
+
// This file is only the plugin wiring and the public surface. The parser
|
|
7
|
+
// itself is engine-free and lives in `commonmark.ts` and the modules it pulls
|
|
8
|
+
// in (`block.ts`, `inline.ts`, `html.ts`, `common.ts`, `node.ts`), so it can
|
|
9
|
+
// be exercised — and the conformance suite run — without `@tabnas/parser`
|
|
10
|
+
// installed. Nothing under `commonmark.ts` may import the engine.
|
|
11
|
+
//
|
|
12
|
+
// The parse result is a native CommonMark node tree; `ast.ts` projects it to
|
|
13
|
+
// the mdast-adjacent JSON AST this package has always returned, and `html.ts`
|
|
14
|
+
// renders it for `toHtml`.
|
|
15
|
+
|
|
16
|
+
// `Tabnas` is the only runtime binding needed here; the rest are types, so
|
|
17
|
+
// they are imported as such. Without `import type`, Node's type stripping
|
|
18
|
+
// keeps them in the emitted import and demands runtime exports that do not
|
|
19
|
+
// exist.
|
|
20
|
+
import type { Tabnas, Plugin, Rule, Context } from '@tabnas/parser'
|
|
21
|
+
|
|
22
|
+
import { parse } from './commonmark.ts'
|
|
23
|
+
import { toAst } from './ast.ts'
|
|
24
|
+
import { renderHTML } from './html.ts'
|
|
25
|
+
import { resolveOptions } from './options.ts'
|
|
26
|
+
import type { ParserOptions } from './options.ts'
|
|
27
|
+
import { MdNode } from './node.ts'
|
|
15
28
|
|
|
16
29
|
// ---------------------------------------------------------------------------
|
|
17
30
|
// Public options
|
|
18
31
|
|
|
19
32
|
export type MarkdownOptions = {
|
|
20
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Enable the GFM extensions — tables, strikethrough, task list items,
|
|
35
|
+
* autolink literals and the disallowed-raw-HTML filter. Default true.
|
|
36
|
+
*/
|
|
21
37
|
gfm?: boolean
|
|
22
38
|
/** When true, a single soft break becomes a hard break. Default false. */
|
|
23
39
|
breaks?: boolean
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
// ---------------------------------------------------------------------------
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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).
|
|
43
|
+
// Grammar source.
|
|
44
|
+
//
|
|
45
|
+
// The prose grammar is intentionally trivial: block structure is decided by
|
|
46
|
+
// the line-oriented algorithm in `block.ts`, not by declarative alts, so the
|
|
47
|
+
// `markdown` rule exists only to consume the token stream. The file is kept
|
|
48
|
+
// because `ts/embed-grammar.js` embeds it verbatim into both runtimes; see
|
|
49
|
+
// AGENTS.md. It is documentation of the entry rule, not a source of behaviour.
|
|
90
50
|
|
|
91
51
|
// --- BEGIN EMBEDDED markdown-grammar.jsonic ---
|
|
92
52
|
const grammarText = `
|
|
93
|
-
# Markdown
|
|
94
|
-
#
|
|
95
|
-
# This grammar is
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
53
|
+
# Markdown entry rule — CommonMark 0.31.2
|
|
54
|
+
#
|
|
55
|
+
# This grammar is deliberately trivial, and it is NOT where the parser
|
|
56
|
+
# lives. Block structure is decided by the two-phase line algorithm of
|
|
57
|
+
# spec Appendix A, implemented in block.ts / block.go, and inline
|
|
58
|
+
# structure by the delimiter and bracket stacks in inline.ts / inline.go.
|
|
59
|
+
# None of that is expressible as declarative alts, which is why none of
|
|
60
|
+
# it is here.
|
|
61
|
+
#
|
|
62
|
+
# The markdown rule exists only to give the engine an entry point and to
|
|
63
|
+
# consume the token stream so its trailing-content check passes. The bo
|
|
64
|
+
# action reads the whole source via ctx.src() and hands it to the parser.
|
|
65
|
+
# Everything the rule does is in those two lines below.
|
|
66
|
+
#
|
|
67
|
+
# Consequences worth knowing rather than discovering:
|
|
68
|
+
# - The railroad diagram generated from this file is empty: a bare
|
|
69
|
+
# track with no boxes on it. That is accurate, not a rendering fault.
|
|
70
|
+
# - Editing this file changes documentation, not behaviour. The one
|
|
71
|
+
# exception is the open/close alts, which really are the rule.
|
|
72
|
+
#
|
|
73
|
+
# The file is kept because ts/embed-grammar.js embeds it verbatim into
|
|
74
|
+
# both runtimes as grammarText (see AGENTS.md). It may not contain
|
|
75
|
+
# backticks: the Go copy is a raw string literal.
|
|
101
76
|
{
|
|
102
77
|
rule: markdown: open: [
|
|
103
78
|
{ s: '#ZZ' }
|
|
@@ -110,712 +85,65 @@ const grammarText = `
|
|
|
110
85
|
// --- END EMBEDDED markdown-grammar.jsonic ---
|
|
111
86
|
|
|
112
87
|
// ---------------------------------------------------------------------------
|
|
113
|
-
//
|
|
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
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
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
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return { url, title }
|
|
159
|
-
}
|
|
160
|
-
|
|
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--
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return -1
|
|
173
|
-
}
|
|
174
|
-
|
|
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
|
-
}
|
|
185
|
-
}
|
|
186
|
-
return -1
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// ---------------------------------------------------------------------------
|
|
190
|
-
// Inline scanner — produces Inline[] from a text string.
|
|
191
|
-
// `text` may contain '\n' for soft breaks (paragraph lines joined with \n).
|
|
192
|
-
|
|
193
|
-
function parseInline(text: string, opts: Required<MarkdownOptions>): Inline[] {
|
|
194
|
-
const nodes: Inline[] = []
|
|
195
|
-
let i = 0
|
|
196
|
-
let buf = ''
|
|
197
|
-
|
|
198
|
-
const flush = () => {
|
|
199
|
-
if ('' !== buf) { nodes.push({ type: 'text', value: buf }); buf = '' }
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
while (i < text.length) {
|
|
203
|
-
const ch = text[i]
|
|
204
|
-
|
|
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
|
-
}
|
|
211
|
-
|
|
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
|
-
}
|
|
230
|
-
|
|
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
|
-
}
|
|
249
|
-
|
|
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
|
-
}
|
|
271
|
-
|
|
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
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
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
|
-
}
|
|
307
|
-
|
|
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
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
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
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
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
|
-
}
|
|
382
|
-
|
|
383
|
-
// Fallthrough — plain char
|
|
384
|
-
buf += ch
|
|
385
|
-
i++
|
|
386
|
-
}
|
|
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
|
-
}
|
|
392
|
-
|
|
393
|
-
// ---------------------------------------------------------------------------
|
|
394
|
-
// Block helpers
|
|
395
|
-
|
|
396
|
-
function isBlank(line: string): boolean {
|
|
397
|
-
return /^[\t ]*$/.test(line)
|
|
398
|
-
}
|
|
88
|
+
// Public parse API (engine-free)
|
|
399
89
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
90
|
+
/** Parse Markdown to the mdast-adjacent JSON AST. */
|
|
91
|
+
export function parseDocument(src: string, opts?: MarkdownOptions | ParserOptions) {
|
|
92
|
+
const options = resolveOptions(opts)
|
|
93
|
+
return toAst(parse(src, options), options)
|
|
403
94
|
}
|
|
404
95
|
|
|
405
|
-
|
|
406
|
-
|
|
96
|
+
/** Parse a single run of inline Markdown to an `Inline[]`. */
|
|
97
|
+
export function parseInline(text: string, opts?: MarkdownOptions | ParserOptions) {
|
|
98
|
+
const doc = parseDocument(text, opts)
|
|
99
|
+
const first = doc.children[0]
|
|
100
|
+
return first && 'paragraph' === first.type ? first.children : []
|
|
407
101
|
}
|
|
408
102
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
const c = stripped[0]
|
|
414
|
-
return [...stripped].every(ch => c === ch)
|
|
103
|
+
/** Parse Markdown and render it to CommonMark-conformant HTML. */
|
|
104
|
+
export function toHtml(src: string, opts?: MarkdownOptions | ParserOptions): string {
|
|
105
|
+
const options = resolveOptions(opts)
|
|
106
|
+
return renderHTML(parse(src, options), options)
|
|
415
107
|
}
|
|
416
108
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
}
|
|
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
|
-
)
|
|
109
|
+
/** Parse Markdown to the native CommonMark node tree. */
|
|
110
|
+
export function parseTree(src: string, opts?: MarkdownOptions | ParserOptions): MdNode {
|
|
111
|
+
return parse(src, resolveOptions(opts))
|
|
443
112
|
}
|
|
444
113
|
|
|
445
114
|
// ---------------------------------------------------------------------------
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const { nodes } = parseBlocks(lines, 0, opts, 0)
|
|
454
|
-
return { type: 'document', children: nodes }
|
|
455
|
-
}
|
|
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
|
-
}
|
|
475
|
-
|
|
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
|
-
}
|
|
500
|
-
|
|
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
|
-
}
|
|
524
|
-
|
|
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
|
-
}
|
|
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
|
-
}
|
|
548
|
-
|
|
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.
|
|
618
|
-
}
|
|
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
|
|
642
|
-
}
|
|
643
|
-
|
|
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))
|
|
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
|
|
663
|
-
}
|
|
664
|
-
|
|
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
|
-
}
|
|
672
|
-
|
|
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
|
-
}
|
|
680
|
-
|
|
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
|
|
695
|
-
}
|
|
696
|
-
|
|
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
|
|
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
|
-
}
|
|
747
|
-
|
|
748
|
-
const paraText = paraLines.join('\n').trimEnd()
|
|
749
|
-
if ('' !== paraText) {
|
|
750
|
-
nodes.push({ type: 'paragraph', children: parseInline(paraText, opts) })
|
|
751
|
-
}
|
|
752
|
-
continue
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
return { nodes, next: i }
|
|
757
|
-
}
|
|
758
|
-
|
|
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.
|
|
115
|
+
// Plugin wiring
|
|
116
|
+
//
|
|
117
|
+
// The `markdown` rule's `bo` action reads the whole source via `ctx.src()` and
|
|
118
|
+
// hands it to the parser; the alts then consume the token stream so the
|
|
119
|
+
// engine's trailing-content check (lex must end at #ZZ) passes. `#AA` is the
|
|
120
|
+
// ANY-token wildcard and `r:'markdown'` loops at the same depth, consuming one
|
|
121
|
+
// token per iteration until only #ZZ remains.
|
|
764
122
|
|
|
765
123
|
const Markdown: Plugin = (tn: Tabnas, options?: MarkdownOptions) => {
|
|
766
|
-
const opts
|
|
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.
|
|
124
|
+
const opts = resolveOptions(options)
|
|
774
125
|
|
|
775
|
-
// Start rule
|
|
776
126
|
tn.options({ rule: { start: 'markdown' } })
|
|
777
127
|
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
// - comment: `# heading` must not be swallowed as a comment line
|
|
783
|
-
// - number/value: `1. list` must stay as text, not a number/keyword
|
|
128
|
+
// The parser reads `ctx.src()` directly rather than the token stream, so
|
|
129
|
+
// disable the lexers that would corrupt Markdown syntax before it gets
|
|
130
|
+
// there: backtick code spans lex as unterminated strings, `# heading` as a
|
|
131
|
+
// comment, and `1. list` as a number.
|
|
784
132
|
tn.options({
|
|
785
133
|
string: { lex: false },
|
|
786
134
|
comment: { lex: false },
|
|
787
135
|
number: { lex: false },
|
|
788
136
|
value: { lex: false },
|
|
789
|
-
lex: { emptyResult: { type: 'document', children: [] }
|
|
137
|
+
lex: { emptyResult: { type: 'document', children: [] } },
|
|
790
138
|
})
|
|
791
139
|
|
|
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
140
|
const refs: Record<string, any> = {
|
|
801
141
|
'@markdown-bo': (r: Rule, ctx: Context) => {
|
|
802
142
|
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
143
|
r.node = parseDocument(src, opts)
|
|
809
144
|
},
|
|
810
145
|
}
|
|
811
146
|
|
|
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
147
|
tn.rule('markdown', (rs) => {
|
|
820
148
|
return rs
|
|
821
149
|
.clear()
|
|
@@ -824,16 +152,50 @@ const Markdown: Plugin = (tn: Tabnas, options?: MarkdownOptions) => {
|
|
|
824
152
|
.close([{ s: '#ZZ' }, {}])
|
|
825
153
|
})
|
|
826
154
|
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
;(tn as any).markdown = {
|
|
155
|
+
// Not part of the tabnas contract, but useful for tests, docs and callers
|
|
156
|
+
// that want HTML or the native tree without a second import.
|
|
157
|
+
;(tn as any).markdown = {
|
|
158
|
+
parseDocument: (t: string) => parseDocument(t, opts),
|
|
159
|
+
parseInline: (t: string) => parseInline(t, opts),
|
|
160
|
+
toHtml: (t: string) => toHtml(t, opts),
|
|
161
|
+
parseTree: (t: string) => parseTree(t, opts),
|
|
162
|
+
}
|
|
830
163
|
}
|
|
831
164
|
|
|
832
|
-
// Defaults for `new Tabnas().use(Markdown, opts?)` merging
|
|
833
165
|
Markdown.defaults = { gfm: true, breaks: false } as MarkdownOptions
|
|
834
166
|
|
|
835
|
-
export {
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
167
|
+
export { Markdown, grammarText }
|
|
168
|
+
|
|
169
|
+
export type {
|
|
170
|
+
DocumentNode,
|
|
171
|
+
Block,
|
|
172
|
+
HeadingNode,
|
|
173
|
+
ParagraphNode,
|
|
174
|
+
BlockquoteNode,
|
|
175
|
+
ListNode,
|
|
176
|
+
ListItemNode,
|
|
177
|
+
CodeNode,
|
|
178
|
+
HtmlNode,
|
|
179
|
+
ThematicBreakNode,
|
|
180
|
+
TableNode,
|
|
181
|
+
TableRowNode,
|
|
182
|
+
TableCellNode,
|
|
183
|
+
Inline,
|
|
184
|
+
TextNode,
|
|
185
|
+
EmphasisNode,
|
|
186
|
+
StrongNode,
|
|
187
|
+
InlineCodeNode,
|
|
188
|
+
LinkNode,
|
|
189
|
+
ImageNode,
|
|
190
|
+
BreakNode,
|
|
191
|
+
DeleteNode,
|
|
192
|
+
} from './ast.ts'
|
|
193
|
+
|
|
194
|
+
export { MdNode } from './node.ts'
|
|
195
|
+
export type { ParserOptions } from './options.ts'
|
|
196
|
+
|
|
197
|
+
// Re-exported so the package entry offers the same pair as the Go package,
|
|
198
|
+
// where `RenderHTML` is exported alongside `ToHTML`. Without this, rendering a
|
|
199
|
+
// tree you built or mutated yourself meant a deep import of `commonmark.ts`,
|
|
200
|
+
// and the two runtimes' public surfaces did not match.
|
|
201
|
+
export { renderHTML } from './html.ts'
|