@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/dist/markdown.js CHANGED
@@ -1,26 +1,48 @@
1
1
  "use strict";
2
2
  /* Copyright (c) 2021-2026 Richard Rodger, MIT License */
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.Markdown = void 0;
4
+ exports.renderHTML = exports.MdNode = exports.grammarText = exports.Markdown = void 0;
5
5
  exports.parseDocument = parseDocument;
6
6
  exports.parseInline = parseInline;
7
+ exports.toHtml = toHtml;
8
+ exports.parseTree = parseTree;
9
+ const commonmark_ts_1 = require("./commonmark.js");
10
+ const ast_ts_1 = require("./ast.js");
11
+ const html_ts_1 = require("./html.js");
12
+ const options_ts_1 = require("./options.js");
7
13
  // ---------------------------------------------------------------------------
8
- // Grammar source — single-document entry rule `markdown`. The rule is tiny:
9
- // blocks are parsed in JS inside the `bo` action via `parseDocument()`.
10
- // The block structure therefore shows up as one rule in `debug.model()`
11
- // and as one fanned `markdown → block` diagram from `@tabnas/railroad`.
12
- // Keep the embedded grammar text trivial so `embed-grammar.js` stays happy
13
- // and the source of truth is clearly JS (see dx-report §2.1).
14
+ // Grammar source.
15
+ //
16
+ // The prose grammar is intentionally trivial: block structure is decided by
17
+ // the line-oriented algorithm in `block.ts`, not by declarative alts, so the
18
+ // `markdown` rule exists only to consume the token stream. The file is kept
19
+ // because `ts/embed-grammar.js` embeds it verbatim into both runtimes; see
20
+ // AGENTS.md. It is documentation of the entry rule, not a source of behaviour.
14
21
  // --- BEGIN EMBEDDED markdown-grammar.jsonic ---
15
22
  const grammarText = `
16
- # Markdown prose grammar — CommonMark/GFM subset
17
- # See dx-report.md section 2 and ts/src/markdown.ts header.
18
- # This grammar is intentionally trivial: the prose parser is implemented
19
- # in JS inside the markdown rule bo action (parseDocument). The
20
- # block structure therefore shows as a single markdown -> block fan-out
21
- # in the railroad diagram, which is honest about where the complexity
22
- # lives. The file is kept so ts/embed-grammar.js continues to embed a
23
- # grammar verbatim into both runtimes (see AGENTS.md).
23
+ # Markdown entry rule — CommonMark 0.31.2
24
+ #
25
+ # This grammar is deliberately trivial, and it is NOT where the parser
26
+ # lives. Block structure is decided by the two-phase line algorithm of
27
+ # spec Appendix A, implemented in block.ts / block.go, and inline
28
+ # structure by the delimiter and bracket stacks in inline.ts / inline.go.
29
+ # None of that is expressible as declarative alts, which is why none of
30
+ # it is here.
31
+ #
32
+ # The markdown rule exists only to give the engine an entry point and to
33
+ # consume the token stream so its trailing-content check passes. The bo
34
+ # action reads the whole source via ctx.src() and hands it to the parser.
35
+ # Everything the rule does is in those two lines below.
36
+ #
37
+ # Consequences worth knowing rather than discovering:
38
+ # - The railroad diagram generated from this file is empty: a bare
39
+ # track with no boxes on it. That is accurate, not a rendering fault.
40
+ # - Editing this file changes documentation, not behaviour. The one
41
+ # exception is the open/close alts, which really are the rule.
42
+ #
43
+ # The file is kept because ts/embed-grammar.js embeds it verbatim into
44
+ # both runtimes as grammarText (see AGENTS.md). It may not contain
45
+ # backticks: the Go copy is a raw string literal.
24
46
  {
25
47
  rule: markdown: open: [
26
48
  { s: '#ZZ' }
@@ -30,667 +52,45 @@ const grammarText = `
30
52
  ]
31
53
  }
32
54
  `;
55
+ exports.grammarText = grammarText;
33
56
  // --- END EMBEDDED markdown-grammar.jsonic ---
34
57
  // ---------------------------------------------------------------------------
35
- // Inline helpers
36
- function parseLinkDestination(inside) {
37
- let s = inside.trim();
38
- if ('' === s)
39
- return { url: '', title: null };
40
- // Angle-bracketed URL: <http://example.com>
41
- if (s.startsWith('<')) {
42
- const close = s.indexOf('>', 1);
43
- if (-1 !== close) {
44
- const url = s.slice(1, close);
45
- const rest = s.slice(close + 1).trim();
46
- if ('' === rest)
47
- return { url, title: null };
48
- if ((rest.startsWith('"') && rest.endsWith('"') && 1 < rest.length) ||
49
- (rest.startsWith("'") && rest.endsWith("'") && 1 < rest.length) ||
50
- (rest.startsWith('(') && rest.endsWith(')') && 1 < rest.length)) {
51
- return { url, title: rest.slice(1, -1) };
52
- }
53
- return { url: s, title: null }; // malformed title → treat whole inside as url
54
- }
55
- }
56
- // Unbracketed URL: first token is url, remainder (if quoted) is title
57
- // Split on whitespace not inside quotes.
58
- let url = '';
59
- let title = null;
60
- let i = 0;
61
- while (i < s.length && !/\s/.test(s[i]))
62
- i++;
63
- url = s.slice(0, i);
64
- const rest = s.slice(i).trim();
65
- if ('' !== rest) {
66
- if ((rest.startsWith('"') && rest.endsWith('"') && 1 < rest.length) ||
67
- (rest.startsWith("'") && rest.endsWith("'") && 1 < rest.length) ||
68
- (rest.startsWith('(') && rest.endsWith(')') && 1 < rest.length)) {
69
- title = rest.slice(1, -1);
70
- }
71
- else {
72
- // Malformed — treat whole inside as url
73
- url = s;
74
- title = null;
75
- }
76
- }
77
- return { url, title };
78
- }
79
- function findClosingBracket(text, start) {
80
- let depth = 0;
81
- for (let i = start; i < text.length; i++) {
82
- const c = text[i];
83
- if ('\\' === c && i + 1 < text.length) {
84
- i++;
85
- continue;
86
- }
87
- if ('[' === c)
88
- depth++;
89
- else if (']' === c) {
90
- if (0 === depth)
91
- return i;
92
- depth--;
93
- }
94
- }
95
- return -1;
96
- }
97
- function findClosingParen(text, start) {
98
- let depth = 0;
99
- for (let i = start; i < text.length; i++) {
100
- const c = text[i];
101
- if ('\\' === c && i + 1 < text.length) {
102
- i++;
103
- continue;
104
- }
105
- if ('(' === c)
106
- depth++;
107
- else if (')' === c) {
108
- if (0 === depth)
109
- return i;
110
- depth--;
111
- }
112
- }
113
- return -1;
58
+ // Public parse API (engine-free)
59
+ /** Parse Markdown to the mdast-adjacent JSON AST. */
60
+ function parseDocument(src, opts) {
61
+ const options = (0, options_ts_1.resolveOptions)(opts);
62
+ return (0, ast_ts_1.toAst)((0, commonmark_ts_1.parse)(src, options), options);
114
63
  }
115
- // ---------------------------------------------------------------------------
116
- // Inline scanner — produces Inline[] from a text string.
117
- // `text` may contain '\n' for soft breaks (paragraph lines joined with \n).
64
+ /** Parse a single run of inline Markdown to an `Inline[]`. */
118
65
  function parseInline(text, opts) {
119
- const nodes = [];
120
- let i = 0;
121
- let buf = '';
122
- const flush = () => {
123
- if ('' !== buf) {
124
- nodes.push({ type: 'text', value: buf });
125
- buf = '';
126
- }
127
- };
128
- while (i < text.length) {
129
- const ch = text[i];
130
- // Escapes — \* \_ \~ \` etc.
131
- if ('\\' === ch && i + 1 < text.length && /[\\`*_\[\]{}()#+\-.!|>~]/.test(text[i + 1])) {
132
- buf += text[i + 1];
133
- i += 2;
134
- continue;
135
- }
136
- // Inline code — `code` or ``code with ` inside`` etc.
137
- if ('`' === ch) {
138
- let j = i;
139
- while (j < text.length && '`' === text[j])
140
- j++;
141
- const ticks = j - i;
142
- const fence = '`'.repeat(ticks);
143
- const close = text.indexOf(fence, j);
144
- if (-1 !== close) {
145
- const raw = text.slice(j, close);
146
- // Collapse newlines/spaces: trim at ends, internal newlines → space
147
- const code = raw.replace(/\r?\n/g, ' ').replace(/^\s+|\s+$/g, '');
148
- // Per CommonMark, `` `` → ``? Already handled by fence length.
149
- flush();
150
- nodes.push({ type: 'inlineCode', value: code });
151
- i = close + ticks;
152
- continue;
153
- }
154
- }
155
- // Image — ![alt](url "title")
156
- if (text.startsWith('![', i)) {
157
- const closeAlt = findClosingBracket(text, i + 2);
158
- if (-1 !== closeAlt && '(' === text[closeAlt + 1]) {
159
- const closeParen = findClosingParen(text, closeAlt + 2);
160
- if (-1 !== closeParen) {
161
- const alt = text.slice(i + 2, closeAlt);
162
- const inside = text.slice(closeAlt + 2, closeParen);
163
- const { url, title } = parseLinkDestination(inside);
164
- if ('' !== url) {
165
- flush();
166
- nodes.push({ type: 'image', url, title, alt });
167
- i = closeParen + 1;
168
- continue;
169
- }
170
- }
171
- }
172
- }
173
- // Autolink — <http://…> or <https://…>
174
- if ('<' === ch) {
175
- const close = text.indexOf('>', i);
176
- if (-1 !== close) {
177
- const url = text.slice(i + 1, close);
178
- if (/^https?:\/\/\S+$/.test(url) || /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/.test(url)) {
179
- flush();
180
- const href = url.startsWith('mailto:') ? url : url;
181
- nodes.push({ type: 'link', url: href, title: null, children: [{ type: 'text', value: url }] });
182
- i = close + 1;
183
- continue;
184
- }
185
- // Email autolink <a@b.com> etc. — treat same as http for now (already covered)
186
- if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(url)) {
187
- flush();
188
- nodes.push({ type: 'link', url: 'mailto:' + url, title: null, children: [{ type: 'text', value: url }] });
189
- i = close + 1;
190
- continue;
191
- }
192
- }
193
- }
194
- // Link — [label](url "title")
195
- if ('[' === ch) {
196
- const closeBracket = findClosingBracket(text, i + 1);
197
- if (-1 !== closeBracket) {
198
- const label = text.slice(i + 1, closeBracket);
199
- if ('(' === text[closeBracket + 1]) {
200
- const closeParen = findClosingParen(text, closeBracket + 2);
201
- if (-1 !== closeParen) {
202
- const inside = text.slice(closeBracket + 2, closeParen);
203
- const { url, title } = parseLinkDestination(inside);
204
- if ('' !== url) {
205
- flush();
206
- nodes.push({ type: 'link', url, title, children: parseInline(label, opts) });
207
- i = closeParen + 1;
208
- continue;
209
- }
210
- }
211
- }
212
- }
213
- }
214
- // Strong — **text** or __text__ (must be non-empty)
215
- if (text.startsWith('**', i) || text.startsWith('__', i)) {
216
- const delim = text.slice(i, i + 2);
217
- const close = text.indexOf(delim, i + 2);
218
- if (-1 !== close && close > i + 2) {
219
- const inner = text.slice(i + 2, close);
220
- if ('' !== inner.trim()) {
221
- flush();
222
- nodes.push({ type: 'strong', children: parseInline(inner, opts) });
223
- i = close + 2;
224
- continue;
225
- }
226
- }
227
- }
228
- // Strikethrough GFM — ~~text~~
229
- if (opts.gfm && text.startsWith('~~', i)) {
230
- const close = text.indexOf('~~', i + 2);
231
- if (-1 !== close && close > i + 2) {
232
- const inner = text.slice(i + 2, close);
233
- if ('' !== inner.trim()) {
234
- flush();
235
- nodes.push({ type: 'delete', children: parseInline(inner, opts) });
236
- i = close + 2;
237
- continue;
238
- }
239
- }
240
- }
241
- // Emphasis — *text* or _text_ (single char)
242
- if ('*' === ch || '_' === ch) {
243
- // Avoid handling ** as *: already handled above, but single * inside ** shouldn't reach here alone
244
- // If next char is same delim, we would have taken strong branch; so this is single.
245
- // For '_' inside a word like foo_bar_baz, CommonMark says not emphasis — we simplify to not split inside alphanum_ sequence.
246
- if ('_' === ch) {
247
- const prevIsAlnum = i > 0 && /[\w]/.test(text[i - 1]);
248
- const nextIsAlnum = i + 1 < text.length && /[\w]/.test(text[i + 1]);
249
- if (prevIsAlnum && nextIsAlnum) {
250
- buf += ch;
251
- i++;
252
- continue;
253
- }
254
- // also check close side will have same word-boundary issue, but we check at open and close
255
- }
256
- const delim = ch;
257
- let close = -1;
258
- // Find closing delim not escaped and with word-boundary handling for _
259
- for (let k = i + 1; k < text.length; k++) {
260
- if ('\\' === text[k] && k + 1 < text.length) {
261
- k++;
262
- continue;
263
- }
264
- if (delim === text[k]) {
265
- if ('_' === delim) {
266
- const beforeIsAlnum = k > 0 && /[\w]/.test(text[k - 1]);
267
- const afterIsAlnum = k + 1 < text.length && /[\w]/.test(text[k + 1]);
268
- if (beforeIsAlnum && afterIsAlnum)
269
- continue;
270
- }
271
- // Ensure not part of strong's double
272
- if (k + 1 < text.length && delim === text[k + 1])
273
- continue;
274
- if (k > 0 && delim === text[k - 1] && text.startsWith(delim + delim, k - 1))
275
- continue;
276
- close = k;
277
- break;
278
- }
279
- }
280
- if (-1 !== close && close > i + 1) {
281
- const inner = text.slice(i + 1, close);
282
- if ('' !== inner.trim()) {
283
- flush();
284
- nodes.push({ type: 'emphasis', children: parseInline(inner, opts) });
285
- i = close + 1;
286
- continue;
287
- }
288
- }
289
- }
290
- // Hard break — two spaces + \n or backslash + \n, or plain \n when breaks:true
291
- if ('\n' === ch) {
292
- const prevTwoSpaces = i >= 2 && ' ' === text[i - 1] && ' ' === text[i - 2];
293
- if (prevTwoSpaces) {
294
- // Hard break via trailing spaces: remove the two spaces from buffered text
295
- // They are still in buf (not yet flushed), so trim them there.
296
- if (buf.endsWith(' '))
297
- buf = buf.slice(0, -2);
298
- else if (buf.endsWith(' '))
299
- buf = buf.slice(0, -1);
300
- flush();
301
- nodes.push({ type: 'break' });
302
- }
303
- else if (opts.breaks) {
304
- flush();
305
- nodes.push({ type: 'break' });
306
- }
307
- else {
308
- // Soft break → single space inside the current text run
309
- buf += ' ';
310
- }
311
- i++;
312
- continue;
313
- }
314
- // Fallthrough — plain char
315
- buf += ch;
316
- i++;
317
- }
318
- flush();
319
- // If nothing was produced but non-empty text, return single text node (preserve empty paragraph case ? — caller handles)
320
- return nodes;
321
- }
322
- // ---------------------------------------------------------------------------
323
- // Block helpers
324
- function isBlank(line) {
325
- return /^[\t ]*$/.test(line);
326
- }
327
- function isATXHeading(line) {
328
- // Up to 3 leading spaces, 1-6 #'s, then space, then content, then optional trailing #'s
329
- return line.match(/^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/);
330
- }
331
- function isSetextUnderline(line) {
332
- return line.match(/^ {0,3}(=+|-+)[ \t]*$/);
333
- }
334
- function isThematicBreak(line) {
335
- if (!/^ {0,3}((\*([ \t]*\*)*)|(-([ \t]*-)*)|(_([ \t]*_)*))[ \t]*$/.test(line))
336
- return false;
337
- const stripped = line.replace(/[ \t]/g, '');
338
- if (3 > stripped.length)
339
- return false;
340
- const c = stripped[0];
341
- return [...stripped].every(ch => c === ch);
66
+ const doc = parseDocument(text, opts);
67
+ const first = doc.children[0];
68
+ return first && 'paragraph' === first.type ? first.children : [];
342
69
  }
343
- function isFencedCodeStart(line) {
344
- return line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*.*$/);
70
+ /** Parse Markdown and render it to CommonMark-conformant HTML. */
71
+ function toHtml(src, opts) {
72
+ const options = (0, options_ts_1.resolveOptions)(opts);
73
+ return (0, html_ts_1.renderHTML)((0, commonmark_ts_1.parse)(src, options), options);
345
74
  }
346
- function isIndentedCodeLine(line) {
347
- return /^ {4}|\t/.test(line);
348
- }
349
- function isBlockquoteLine(line) {
350
- return /^ {0,3}>\s?/.test(line);
351
- }
352
- function isUnorderedListItem(line) {
353
- return line.match(/^ {0,3}([*+-])[ \t]+(.*)$/);
354
- }
355
- function isOrderedListItem(line) {
356
- return line.match(/^ {0,3}(\d+)([.)])[ \t]+(.*)$/);
357
- }
358
- function isHtmlBlockLine(line) {
359
- const t = line.trimStart();
360
- return t.startsWith('<') && (/^<(?:!--|!\[CDATA\[|!DOCTYPE|\?[a-z])/i.test(t) ||
361
- /^<\/?[a-zA-Z][\w-]*(?:\s[^>]*)?>/.test(t));
362
- }
363
- // ---------------------------------------------------------------------------
364
- // Block parsing
365
- function parseDocument(src, opts) {
366
- // Normalize newlines, keep trailing newline behaviour: split on \r?\n
367
- const lines = src.split(/\r?\n/);
368
- // CommonMark: final newline not significant for block parsing — but an
369
- // empty trailing line from split should not produce an extra block.
370
- const { nodes } = parseBlocks(lines, 0, opts, 0);
371
- return { type: 'document', children: nodes };
372
- }
373
- function parseBlocks(lines, start, opts, baseIndent) {
374
- const nodes = [];
375
- let i = start;
376
- while (i < lines.length) {
377
- let line = lines[i];
378
- // End if dedented below baseIndent for list handling — caller manages.
379
- // Here we just skip blanks that are not meaningful inside prose.
380
- if (isBlank(line)) {
381
- i++;
382
- continue;
383
- }
384
- // Fenced code — takes precedence over other block types
385
- const fenced = isFencedCodeStart(line);
386
- if (fenced) {
387
- const fence = fenced[1];
388
- const fenceChar = fence[0];
389
- const langAndMeta = line.slice(line.indexOf(fence) + fence.length).trim();
390
- let lang = null;
391
- let meta = null;
392
- if ('' !== langAndMeta) {
393
- const spaceIdx = langAndMeta.search(/\s/);
394
- if (-1 === spaceIdx)
395
- lang = langAndMeta;
396
- else {
397
- lang = langAndMeta.slice(0, spaceIdx);
398
- meta = langAndMeta.slice(spaceIdx + 1).trim() || null;
399
- }
400
- }
401
- const closingRe = new RegExp('^ {0,3}' + fenceChar + '{' + fence.length + ',}[ \\t]*$');
402
- const content = [];
403
- i++;
404
- while (i < lines.length && !closingRe.test(lines[i])) {
405
- content.push(lines[i]);
406
- i++;
407
- }
408
- if (i < lines.length)
409
- i++; // consume closing fence
410
- nodes.push({ type: 'code', lang: lang || null, meta: meta || null, value: content.join('\n') });
411
- continue;
412
- }
413
- // Indented code — line indented by ≥4 spaces and not already handled as list
414
- if (isIndentedCodeLine(line) && !isUnorderedListItem(line) && !isOrderedListItem(line)) {
415
- const content = [];
416
- while (i < lines.length && (isIndentedCodeLine(lines[i]) || isBlank(lines[i]))) {
417
- if (isBlank(lines[i])) {
418
- // Blank line inside indented code is kept as empty line
419
- // but we must peek ahead for next indented line to decide if blank is part of code
420
- // Look ahead to next non-blank
421
- let j = i + 1;
422
- while (j < lines.length && isBlank(lines[j]))
423
- j++;
424
- if (j < lines.length && isIndentedCodeLine(lines[j])) {
425
- content.push('');
426
- i++;
427
- }
428
- else
429
- break;
430
- }
431
- else {
432
- const raw = lines[i];
433
- content.push(raw.startsWith('\t') ? raw.slice(1) : raw.slice(4));
434
- i++;
435
- }
436
- }
437
- nodes.push({ type: 'code', lang: null, meta: null, value: content.join('\n') });
438
- continue;
439
- }
440
- // ATX heading
441
- const atx = isATXHeading(line);
442
- if (atx) {
443
- const depth = atx[1].length;
444
- const content = atx[2].trim();
445
- nodes.push({ type: 'heading', depth, children: parseInline(content, opts) });
446
- i++;
447
- continue;
448
- }
449
- // Thematic break — before paragraph/setext because --- can be both
450
- if (isThematicBreak(line)) {
451
- // But `---` after a paragraph line could be a setext underline for heading level 2.
452
- // Handle setext case one line lookahead before emitting thematic break.
453
- // Setext check is done inside paragraph path below via peek.
454
- // Here we only emit thematic break if not a setext context.
455
- // For simplicity, if previous block was a paragraph's single-line source that could be setext,
456
- // paragraph collector would have already consumed it.
457
- // So safe to emit.
458
- nodes.push({ type: 'thematicBreak' });
459
- i++;
460
- continue;
461
- }
462
- // Blockquote — collect consecutive >-prefixed lines
463
- if (isBlockquoteLine(line)) {
464
- const bqLines = [];
465
- while (i < lines.length && (isBlockquoteLine(lines[i]) || isBlank(lines[i]))) {
466
- // Blank line inside blockquote is kept, but only if next line is also blockquoted or we are continuing quote
467
- if (isBlank(lines[i])) {
468
- // Peek next: if next is blockquote line, this blank is inside quote
469
- const nxt = i + 1 < lines.length ? lines[i + 1] : '';
470
- if (isBlockquoteLine(nxt)) {
471
- bqLines.push('');
472
- i++;
473
- continue;
474
- }
475
- // else blank ends blockquote when followed by non-quote content
476
- // but per CommonMark blank inside quote is allowed even without > marker if we are inside
477
- // we treat blank as part of quote for recursion
478
- bqLines.push('');
479
- i++;
480
- // If next line is blank and then non-quote, break to let outer parse handle
481
- let j = i;
482
- while (j < lines.length && isBlank(lines[j]))
483
- j++;
484
- if (j < lines.length && !isBlockquoteLine(lines[j]) && !isBlank(lines[j]))
485
- break;
486
- continue;
487
- }
488
- const m = lines[i].match(/^ {0,3}>[ \t]?(.*)$/);
489
- bqLines.push(m ? m[1] : '');
490
- i++;
491
- }
492
- // Trim trailing blank
493
- while (bqLines.length && '' === bqLines[bqLines.length - 1])
494
- bqLines.pop();
495
- const inner = parseBlocks(bqLines, 0, opts, 0).nodes;
496
- nodes.push({ type: 'blockquote', children: inner });
497
- continue;
498
- }
499
- // HTML block — raw line starting with < until blank line
500
- if (isHtmlBlockLine(line)) {
501
- const htmlLines = [];
502
- while (i < lines.length && !isBlank(lines[i])) {
503
- htmlLines.push(lines[i]);
504
- i++;
505
- // HTML blocks typically end at blank line; we let blank consume break outer
506
- if (i < lines.length && isBlank(lines[i]))
507
- break;
508
- // Also break if next line is a clear block start (heading, fence, thematic)
509
- const nxt = i < lines.length ? lines[i] : '';
510
- if (isATXHeading(nxt) || isFencedCodeStart(nxt) || isThematicBreak(nxt) || isBlockquoteLine(nxt) || isUnorderedListItem(nxt) || isOrderedListItem(nxt))
511
- break;
512
- }
513
- nodes.push({ type: 'html', value: htmlLines.join('\n') });
514
- continue;
515
- }
516
- // Lists — must be handled together to group consecutive items
517
- const isUL = isUnorderedListItem(line);
518
- const isOL = isOrderedListItem(line);
519
- if (isUL || isOL) {
520
- const ordered = !!isOL;
521
- const startNum = isOL ? parseInt(isOL[1], 10) : null;
522
- const items = [];
523
- let spread = false;
524
- // Collect items while next line is list marker of similar type or indented continuation
525
- while (i < lines.length) {
526
- const cur = lines[i];
527
- if (isBlank(cur)) {
528
- i++;
529
- continue;
530
- }
531
- const ulM = isUnorderedListItem(cur);
532
- const olM = isOrderedListItem(cur);
533
- const markerMatch = ordered ? olM : ulM;
534
- // If next line is not the right list kind, see if it's continuation of previous item
535
- if (!markerMatch) {
536
- // Continuation: indented by ≥2 spaces or belongs to last item's blocks?
537
- // Check if last item exists and current line is indented (2+ spaces) and not another block start
538
- if (items.length && /^ {2,}/.test(cur) && !isATXHeading(cur) && !isFencedCodeStart(cur) && !isThematicBreak(cur) && !isBlockquoteLine(cur) && !isHtmlBlockLine(cur)) {
539
- // Continuation belongs to last item — append to its raw lines before re-parsing
540
- // We will instead handle continuations inside item parsing loop below.
541
- }
542
- break;
543
- }
544
- // Parse one item
545
- const markerLen = cur.match(/^ {0,3}(?:[*+-]|\d+[.)])[ \t]+/)[0].length;
546
- const firstContent = ordered ? olM[3] : ulM[2];
547
- const itemLines = [];
548
- itemLines.push(firstContent);
549
- i++;
550
- // Collect following lines belonging to this item: blank lines, indented content, paragraph continuation
551
- let blankRun = 0;
552
- while (i < lines.length) {
553
- const nxt = lines[i];
554
- if (isBlank(nxt)) {
555
- // Blank inside list item is kept — but peek if next line starts a new item
556
- const peek = i + 1 < lines.length ? lines[i + 1] : null;
557
- const peekIsList = peek && (isUnorderedListItem(peek) || isOrderedListItem(peek));
558
- if (peekIsList)
559
- break; // blank before next item → end of this item
560
- // If loose, keep blank
561
- itemLines.push('');
562
- blankRun++;
563
- i++;
564
- continue;
565
- }
566
- // If next line is a list marker at same base indent, it is the next item
567
- if ((isUnorderedListItem(nxt) || isOrderedListItem(nxt)) && !/^ {4,}/.test(nxt))
568
- break;
569
- // If line is indented ≥2 spaces beyond base? Actually CommonMark list content indent = markerLen or 4?
570
- // Simplify: if line starts with spaces and not a blockquote/thematic/heading/fenced, treat as item continuation.
571
- if (/^ {2,}/.test(nxt) || /^\t/.test(nxt)) {
572
- // Strip one level of indentation (up to 4 spaces or 1 tab) but keep as content
573
- let stripped = nxt;
574
- if (stripped.startsWith('\t'))
575
- stripped = stripped.slice(1);
576
- else {
577
- // Remove up to markerLen or 4 spaces? Use 2 as common
578
- const lead = stripped.match(/^ +/)[0].length;
579
- stripped = stripped.slice(Math.min(4, lead));
580
- }
581
- // Check if stripped line looks like an inner blockquote/list etc. — keep as is for inner parse
582
- itemLines.push(stripped);
583
- i++;
584
- blankRun = 0;
585
- continue;
586
- }
587
- // If line looks like another block start (heading, code, thematic, blockquote, html) at base, break?
588
- if (isATXHeading(nxt) || isFencedCodeStart(nxt) || isThematicBreak(nxt) || isBlockquoteLine(nxt) || isHtmlBlockLine(nxt)) {
589
- // Inside list items, these are content, not outer blocks, if indented earlier they'd have been taken.
590
- // Unindented block inside list: check if we already have content — if so it may terminate list.
591
- // For tight lists, indented blocks are needed. Simplify: break to let items close, next iteration will handle as outer block.
592
- break;
593
- }
594
- // Otherwise treat as loose paragraph continuation? Only if previous line wasn't blank?
595
- if (blankRun)
596
- break;
597
- // Unindented paragraph-like line inside item (tight list paragraph extension)
598
- // Only if item already has one line and next line is not a list marker: join as continuation
599
- itemLines.push(nxt);
600
- i++;
601
- }
602
- // Determine if item had blank line inside → affects list spread (loose)
603
- const itemHadBlank = itemLines.includes('');
604
- if (itemHadBlank)
605
- spread = true;
606
- // Trim trailing blanks
607
- while (itemLines.length && '' === itemLines[itemLines.length - 1])
608
- itemLines.pop();
609
- // Parse item content as blocks (recursive)
610
- // If itemLines is single line and contains only inline, it will become a paragraph. CommonMark wraps tight items' paragraph directly.
611
- const strippedItemSrc = itemLines.join('\n');
612
- // Avoid empty item
613
- let itemBlocks = [];
614
- if (strippedItemSrc.trim()) {
615
- itemBlocks = parseBlocks(strippedItemSrc.split(/\r?\n/), 0, opts, 0).nodes;
616
- }
617
- // Tight lists: single paragraph items are collapsed? Our AST keeps paragraph wrapper regardless — callers handle spread.
618
- // Record spread on item if its trailing had blank
619
- items.push({ type: 'listItem', spread: itemHadBlank, children: itemBlocks });
620
- }
621
- nodes.push({ type: 'list', ordered, start: startNum, spread, children: items });
622
- continue;
623
- }
624
- // Paragraph — collect until blank or block start, handling setext underline
625
- {
626
- const paraLines = [];
627
- while (i < lines.length && !isBlank(lines[i])) {
628
- const nxt = lines[i];
629
- // Break if next line is a block that cannot be inside a paragraph without blank line.
630
- // Setext underline (=== or ---) looks like a thematic break but must be kept
631
- // as the last para line so the post-loop Setext check can turn the paragraph
632
- // into a heading. Only break on thematic breaks that are NOT Setext underlines.
633
- const isSetext = null !== isSetextUnderline(nxt);
634
- const isThematicButNotSetext = isThematicBreak(nxt) && !isSetext;
635
- if (paraLines.length && (isATXHeading(nxt) ||
636
- isFencedCodeStart(nxt) ||
637
- isThematicButNotSetext ||
638
- isBlockquoteLine(nxt) ||
639
- isUnorderedListItem(nxt) ||
640
- isOrderedListItem(nxt) ||
641
- isHtmlBlockLine(nxt)))
642
- break;
643
- // Also check for indented code (not part of para)
644
- if (paraLines.length && isIndentedCodeLine(nxt))
645
- break;
646
- paraLines.push(nxt);
647
- i++;
648
- }
649
- // Check for Setext heading: last line could be underline if para is 1-2 lines of text
650
- // A setext underline must not be thematic break-duplicate. Pattern: === or ---
651
- if (paraLines.length) {
652
- const last = paraLines[paraLines.length - 1];
653
- const underline = isSetextUnderline(last);
654
- if (underline && paraLines.length >= 2) {
655
- const depth = '=' === underline[1][0] ? 1 : 2;
656
- const hdText = paraLines.slice(0, -1).join('\n');
657
- nodes.push({ type: 'heading', depth, children: parseInline(hdText.trim(), opts) });
658
- continue;
659
- }
660
- // Single-line setext: need preceding para line + underline on next line? Our peek included underline line already as part of paraLines.
661
- // 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.
662
- // So ok.
663
- }
664
- const paraText = paraLines.join('\n').trimEnd();
665
- if ('' !== paraText) {
666
- nodes.push({ type: 'paragraph', children: parseInline(paraText, opts) });
667
- }
668
- continue;
669
- }
670
- }
671
- return { nodes, next: i };
75
+ /** Parse Markdown to the native CommonMark node tree. */
76
+ function parseTree(src, opts) {
77
+ return (0, commonmark_ts_1.parse)(src, (0, options_ts_1.resolveOptions)(opts));
672
78
  }
673
79
  // ---------------------------------------------------------------------------
674
- // Plugin wiring — bare engine (no jsonic). The rule is trivial; all
675
- // work happens in the `bo` that captures the source via `ctx.src()`.
676
- // This is the same pattern yaml's 2559-line plugin ultimately uses:
677
- // custom lex matchers + state actions, not declarative alts, for prose.
80
+ // Plugin wiring
81
+ //
82
+ // The `markdown` rule's `bo` action reads the whole source via `ctx.src()` and
83
+ // hands it to the parser; the alts then consume the token stream so the
84
+ // engine's trailing-content check (lex must end at #ZZ) passes. `#AA` is the
85
+ // ANY-token wildcard and `r:'markdown'` loops at the same depth, consuming one
86
+ // token per iteration until only #ZZ remains.
678
87
  const Markdown = (tn, options) => {
679
- const opts = {
680
- gfm: options?.gfm ?? true,
681
- breaks: options?.breaks ?? false,
682
- };
683
- // No custom token descriptions needed for prose — the block scanner
684
- // reads ctx.src() directly; railroad will show the single `markdown`
685
- // rule as the entry point.
686
- // Start rule
88
+ const opts = (0, options_ts_1.resolveOptions)(options);
687
89
  tn.options({ rule: { start: 'markdown' } });
688
- // Prose parser reads `ctx.src()` directly and does not rely on the
689
- // jsonic lexer. Disable lexers that would turn Markdown syntax into
690
- // bad tokens before the block scanner runs:
691
- // - string: backtick code spans would otherwise be parsed as unterminated strings
692
- // - comment: `# heading` must not be swallowed as a comment line
693
- // - number/value: `1. list` must stay as text, not a number/keyword
90
+ // The parser reads `ctx.src()` directly rather than the token stream, so
91
+ // disable the lexers that would corrupt Markdown syntax before it gets
92
+ // there: backtick code spans lex as unterminated strings, `# heading` as a
93
+ // comment, and `1. list` as a number.
694
94
  tn.options({
695
95
  string: { lex: false },
696
96
  comment: { lex: false },
@@ -698,31 +98,12 @@ const Markdown = (tn, options) => {
698
98
  value: { lex: false },
699
99
  lex: { emptyResult: { type: 'document', children: [] } },
700
100
  });
701
- // Grammar is intentionally tiny — see `grammarText` above for the
702
- // railroad-visible spec. Install it first so `rule('markdown')` can
703
- // clear/override it cleanly.
704
- // We parse the embedded JSON via a throwaway Tabnas to get a GrammarSpec,
705
- // then install it with the ref map.
706
- // To avoid a jsonic dependency, install inline via tn.grammar instead of
707
- // parsing jsonic text — simpler: just define the rule directly.
708
101
  const refs = {
709
102
  '@markdown-bo': (r, ctx) => {
710
103
  const src = ctx.src();
711
- // Preserve empty handling: "" → empty document
712
- if ('' === src || /^[\r\n\t ]*$/.test(src)) {
713
- r.node = { type: 'document', children: [] };
714
- return;
715
- }
716
104
  r.node = parseDocument(src, opts);
717
105
  },
718
106
  };
719
- // Define the `markdown` rule: `bo` builds the document from
720
- // `ctx.src()`; the alts then consume the underlying token stream so
721
- // the engine's trailing-content check (lex must end at #ZZ) passes.
722
- // `#AA` is the ANY-token wildcard and `r:'markdown'` loops at the
723
- // same depth, consuming one token per iteration until only #ZZ
724
- // remains. The empty alt `{}`
725
- // then falls through to the close phase, which matches #ZZ.
726
107
  tn.rule('markdown', (rs) => {
727
108
  return rs
728
109
  .clear()
@@ -730,9 +111,21 @@ const Markdown = (tn, options) => {
730
111
  .open([{ s: '#AA', r: 'markdown' }, {}])
731
112
  .close([{ s: '#ZZ' }, {}]);
732
113
  });
733
- tn.markdown = { parseDocument, parseInline: (t) => parseInline(t, opts) };
114
+ tn.markdown = {
115
+ parseDocument: (t) => parseDocument(t, opts),
116
+ parseInline: (t) => parseInline(t, opts),
117
+ toHtml: (t) => toHtml(t, opts),
118
+ parseTree: (t) => parseTree(t, opts),
119
+ };
734
120
  };
735
121
  exports.Markdown = Markdown;
736
- // Defaults for `new Tabnas().use(Markdown, opts?)` merging
737
122
  Markdown.defaults = { gfm: true, breaks: false };
123
+ var node_ts_1 = require("./node.js");
124
+ Object.defineProperty(exports, "MdNode", { enumerable: true, get: function () { return node_ts_1.MdNode; } });
125
+ // Re-exported so the package entry offers the same pair as the Go package,
126
+ // where `RenderHTML` is exported alongside `ToHTML`. Without this, rendering a
127
+ // tree you built or mutated yourself meant a deep import of `commonmark.ts`,
128
+ // and the two runtimes' public surfaces did not match.
129
+ var html_ts_2 = require("./html.js");
130
+ Object.defineProperty(exports, "renderHTML", { enumerable: true, get: function () { return html_ts_2.renderHTML; } });
738
131
  //# sourceMappingURL=markdown.js.map