@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/src/markdown.ts CHANGED
@@ -1,103 +1,78 @@
1
1
  /* Copyright (c) 2021-2026 Richard Rodger, MIT License */
2
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).
6
-
7
- import {
8
- Tabnas,
9
- Plugin,
10
- Rule,
11
- Context,
12
- Config,
13
- TabnasOptions,
14
- } from '@tabnas/parser'
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
- /** Enable GFM extensions (strikethrough `~~`, task lists, tables — tables deferred, see dx-report). */
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
- // 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).
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 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).
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
- // 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
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 — ![alt](url "title")
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
- 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]*$/)
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
- function isSetextUnderline(line: string): RegExpMatchArray | null {
406
- return line.match(/^ {0,3}(=+|-+)[ \t]*$/)
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
- 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)
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
- function isFencedCodeStart(line: string): RegExpMatchArray | null {
418
- return line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*.*$/)
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
- // Block parsing
447
-
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
- }
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: 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.
124
+ const opts = resolveOptions(options)
774
125
 
775
- // Start rule
776
126
  tn.options({ rule: { start: 'markdown' } })
777
127
 
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
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: [] } as DocumentNode },
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
- // 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) }
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
- Markdown,
837
- parseDocument,
838
- parseInline,
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'