@tabnas/markdown 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/markdown.js CHANGED
@@ -1,597 +1,738 @@
1
1
  "use strict";
2
- /* Copyright (c) 2021-2025 Richard Rodger, MIT License */
2
+ /* Copyright (c) 2021-2026 Richard Rodger, MIT License */
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.Markdown = void 0;
5
- exports.buildMarkdownStringMatcher = buildMarkdownStringMatcher;
6
- // The engine is the tabnas parser; jsonic supplies the relaxed-JSON
7
- // grammar that the embedded grammar text is authored in.
8
- const parser_1 = require("@tabnas/parser");
9
- const jsonic_1 = require("@tabnas/jsonic");
5
+ exports.parseDocument = parseDocument;
6
+ exports.parseInline = parseInline;
7
+ // ---------------------------------------------------------------------------
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).
10
14
  // --- BEGIN EMBEDDED markdown-grammar.jsonic ---
11
15
  const grammarText = `
12
- # Markdown Grammar Definition
13
- # Parsed by a standard Jsonic instance and passed to jsonic.grammar()
14
- # Function references (@ prefixed) are resolved against the refs map
15
- #
16
- # Token naming:
17
- # #LN - line ending (removed from per-instance IGNORE set)
18
- # #SP - whitespace (removed from per-instance IGNORE set in strict mode)
19
- # #CA - comma / field separator
20
- # #ZZ - end of input
21
- # #VAL - token set: text, string, number, value literals
22
- #
23
- # Rules markdown, newline, record, text are fully defined here.
24
- # Rules list, elem, val are modified in code (strict mode defines from scratch;
25
- # non-strict prepends to existing defaults to preserve JSON parsing).
26
-
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).
27
24
  {
28
25
  rule: markdown: open: [
29
26
  { s: '#ZZ' }
30
- { s: '#LN' p: newline c: '@not-record-empty' }
31
- { p: record }
32
27
  ]
33
-
34
- rule: newline: open: [
35
- { s: '#LN #LN' r: newline }
36
- { s: '#LN' r: newline }
28
+ rule: markdown: close: [
37
29
  { s: '#ZZ' }
38
- { r: record }
39
- ]
40
- rule: newline: close: [
41
- { s: '#LN #LN' r: newline }
42
- { s: '#LN' r: newline }
43
- { s: '#ZZ' }
44
- { r: record }
45
- ]
46
-
47
- rule: record: open: [
48
- { p: list }
49
- ]
50
- rule: record: close: [
51
- { s: '#ZZ' }
52
- { s: '#LN #ZZ' b: 1 }
53
- { s: '#LN' r: '@record-close-next' }
54
- ]
55
-
56
- rule: text: open: [
57
- { s: ['#VAL' '#SP'] b: 1 r: text n: { text: 1 } g: 'markdown,space,follows' a: '@text-follows' }
58
- { s: ['#SP' '#VAL'] r: text n: { text: 1 } g: 'markdown,space,leads' a: '@text-leads' }
59
- { s: ['#SP' '#CA #LN #ZZ'] b: 1 n: { text: 1 } g: 'markdown,end' a: '@text-end' }
60
- { s: '#SP' n: { text: 1 } g: 'markdown,space' a: '@text-space' p: '@text-space-push' }
61
- {}
62
30
  ]
63
31
  }
64
32
  `;
65
33
  // --- END EMBEDDED markdown-grammar.jsonic ---
66
- // Plugin implementation.
67
- const Markdown = (tn, options) => {
68
- // Human descriptions for the tokens, surfaced in railroad diagram legends
69
- // (read off the live config by @tabnas/railroad). Newline and whitespace are
70
- // structurally significant (record separator / field text); the JSON
71
- // brace/bracket tokens only appear inside embedded jsonic field values.
72
- tn.options({
73
- config: {
74
- modify: {
75
- 'markdown-tokendesc': (cfg) => {
76
- cfg.tokenDesc = Object.assign(cfg.tokenDesc || {}, {
77
- '#LN': 'newline (ends a record / row)',
78
- '#SP': 'whitespace (significant: field text)',
79
- '#OB': 'open brace { (embedded JSON map)',
80
- '#CB': 'close brace } (embedded JSON map)',
81
- '#OS': 'open bracket [ (embedded JSON list)',
82
- '#CS': 'close bracket ] (embedded JSON list)',
83
- '#CL': 'colon : (key:value in embedded JSON)',
84
- 'KEY': 'embedded JSON map key',
85
- 'VAL': 'field value: text, number, string, or keyword',
86
- });
87
- },
88
- },
89
- },
90
- });
91
- // Normalize boolean options.
92
- const strict = !!options.strict;
93
- const objres = !!options.object;
94
- const header = !!options.header;
95
- // These may be changed below by superior options.
96
- let trim = !!options.trim;
97
- let comment = !!options.comment;
98
- let opt_number = !!options.number;
99
- let opt_value = !!options.value;
100
- let record_empty = !!options.record?.empty;
101
- const stream = options.stream;
102
- // In strict mode, Jsonic field content is not parsed.
103
- if (strict) {
104
- if (false !== options.string.markdown) {
105
- tn.options({
106
- lex: {
107
- match: {
108
- stringmarkdown: { order: 1e5, make: buildMarkdownStringMatcher(options) },
109
- },
110
- },
111
- });
34
+ // ---------------------------------------------------------------------------
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
112
54
  }
113
- tn.options({
114
- rule: { exclude: 'jsonic,imp' },
115
- });
116
55
  }
117
- // Fields may contain Jsonic content.
118
- else {
119
- if (true === options.string.markdown) {
120
- tn.options({
121
- lex: {
122
- match: {
123
- stringmarkdown: { order: 1e5, make: buildMarkdownStringMatcher(options) },
124
- },
125
- },
126
- });
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;
127
75
  }
128
- trim = null === options.trim ? true : trim;
129
- comment = null === options.comment ? true : comment;
130
- opt_number = null === options.number ? true : opt_number;
131
- opt_value = null === options.value ? true : opt_value;
132
- tn.options({
133
- rule: { exclude: 'imp' },
134
- });
135
- }
136
- // Stream rows as they are parsed, do not store in result.
137
- if (stream) {
138
- let parser = tn.internal().parser;
139
- let origStart = parser.start.bind(parser);
140
- parser.start = ((...args) => {
141
- try {
142
- return origStart(...args);
143
- }
144
- catch (e) {
145
- stream('error', e);
146
- }
147
- });
148
76
  }
149
- let token = {};
150
- if (strict) {
151
- // Disable JSON structure tokens
152
- token = {
153
- '#OB': null,
154
- '#CB': null,
155
- '#OS': null,
156
- '#CS': null,
157
- '#CL': null,
158
- };
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
+ }
159
94
  }
160
- // Custom "comma"
161
- if (options.field.separation) {
162
- token['#CA'] = options.field.separation;
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
+ }
163
112
  }
164
- // Jsonic option overrides.
165
- let jsonicOptions = {
166
- rule: {
167
- start: 'markdown',
168
- },
169
- fixed: {
170
- token,
171
- },
172
- tokenSet: {
173
- IGNORE: [
174
- strict ? null : undefined, // Handle #SP space
175
- null, // Handle #LN newlines
176
- undefined, // Still ignore #CM comments
177
- ],
178
- },
179
- number: {
180
- lex: opt_number,
181
- },
182
- value: {
183
- lex: opt_value,
184
- },
185
- comment: {
186
- lex: comment,
187
- },
188
- lex: {
189
- emptyResult: [],
190
- },
191
- line: {
192
- single: record_empty,
193
- chars: null == options.record.separators
194
- ? undefined
195
- : options.record.separators,
196
- rowChars: null == options.record.separators
197
- ? undefined
198
- : options.record.separators,
199
- },
200
- error: {
201
- markdown_extra_field: 'unexpected extra field value: $fsrc',
202
- markdown_missing_field: 'missing field',
203
- },
204
- hint: {
205
- markdown_extra_field: `Row $row has too many fields (the first of which is: $fsrc). Only $len
206
- fields per row are expected.`,
207
- markdown_missing_field: `Row $row has too few fields. $len fields per row are expected.`,
208
- },
113
+ return -1;
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // Inline scanner — produces Inline[] from a text string.
117
+ // `text` may contain '\n' for soft breaks (paragraph lines joined with \n).
118
+ 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
+ }
209
127
  };
210
- tn.options(jsonicOptions);
211
- // Named function references for declarative grammar definition.
212
- const refs = {
213
- // === State actions (auto-wired by @rulename-{bo,ao,bc,ac} convention) ===
214
- '@markdown-bo': (r, ctx) => {
215
- ctx.u.recordI = 0;
216
- stream && stream('start');
217
- r.node = [];
218
- },
219
- '@markdown-ac': (_r) => {
220
- stream && stream('end');
221
- },
222
- '@record-bc': (r, ctx) => {
223
- let fields = ctx.u.fields || options.field.names;
224
- if (0 === ctx.u.recordI && header) {
225
- ctx.u.fields = undefined === r.child.node ? [] : r.child.node;
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;
226
153
  }
227
- else {
228
- let record = r.child.node || [];
229
- if (objres) {
230
- let obj = {};
231
- let i = 0;
232
- if (fields) {
233
- if (options.field.exact) {
234
- if (record.length !== fields.length) {
235
- return ctx.t0.bad(record.length > fields.length
236
- ? 'markdown_extra_field'
237
- : 'markdown_missing_field');
238
- }
239
- }
240
- let fI = 0;
241
- for (; fI < fields.length; fI++) {
242
- obj[fields[fI]] =
243
- undefined === record[fI] ? options.field.empty : record[fI];
244
- }
245
- i = fI;
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;
246
169
  }
247
- for (; i < record.length; i++) {
248
- let field_name = options.field.nonameprefix + i;
249
- obj[field_name] =
250
- undefined === record[i] ? options.field.empty : record[i];
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
+ }
251
210
  }
252
- record = obj;
253
211
  }
254
- else {
255
- for (let i = 0; i < record.length; i++) {
256
- record[i] =
257
- undefined === record[i] ? options.field.empty : record[i];
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;
258
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;
259
278
  }
260
- if (stream) {
261
- stream('record', record);
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;
262
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);
342
+ }
343
+ function isFencedCodeStart(line) {
344
+ return line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*.*$/);
345
+ }
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;
263
396
  else {
264
- r.node.push(record);
397
+ lang = langAndMeta.slice(0, spaceIdx);
398
+ meta = langAndMeta.slice(spaceIdx + 1).trim() || null;
265
399
  }
266
400
  }
267
- ctx.u.recordI++;
268
- },
269
- '@text-bc': (r) => {
270
- r.parent.node = undefined === r.child.node ? r.node : r.child.node;
271
- },
272
- // === Alt actions ===
273
- '@elem-open-empty': (r) => {
274
- r.node.push(options.field.empty);
275
- r.u.done = true;
276
- },
277
- '@elem-close-trailing': (r) => {
278
- r.node.push(options.field.empty);
279
- },
280
- '@text-follows': (r) => {
281
- let v = 1 === r.n.text ? r : r.prev;
282
- r.node = v.node = (1 === r.n.text ? '' : r.prev.node) + r.o0.val;
283
- },
284
- '@text-leads': (r) => {
285
- let v = 1 === r.n.text ? r : r.prev;
286
- r.node = v.node =
287
- (1 === r.n.text ? '' : r.prev.node) +
288
- (2 <= r.n.text || !trim ? r.o0.src : '') +
289
- r.o1.src;
290
- },
291
- '@text-end': (r) => {
292
- let v = 1 === r.n.text ? r : r.prev;
293
- r.node = v.node =
294
- (1 === r.n.text ? '' : r.prev.node) + (!trim ? r.o0.src : '');
295
- },
296
- '@text-space': (r) => {
297
- if (strict) {
298
- let v = 1 === r.n.text ? r : r.prev;
299
- r.node = v.node =
300
- (1 === r.n.text ? '' : r.prev.node) + (!trim ? r.o0.src : '');
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++;
301
407
  }
302
- },
303
- // === Condition refs ===
304
- '@not-record-empty': () => !record_empty,
305
- // === FuncRef for dynamic rule names ===
306
- '@record-close-next': () => record_empty ? 'record' : 'newline',
307
- '@text-space-push': () => strict ? '' : 'val',
308
- };
309
- // Usually [#TX, #ST, #NR, #VL]
310
- let VAL = tn.tokenSet.VAL;
311
- let { LN, CA, SP, ZZ } = tn.token;
312
- // Parse embedded grammar definition with a jsonic-grammar Tabnas instance.
313
- const grammarDef = new parser_1.Tabnas().use(jsonic_1.jsonic).parse(grammarText);
314
- grammarDef.ref = refs;
315
- tn.grammar(grammarDef);
316
- // Rules list, elem, val are modified in code rather than the grammar file,
317
- // because in non-strict mode the default jsonic alternatives must be
318
- // preserved to support embedded JSON values like [1,2] and {x:1}.
319
- //
320
- // In STRICT mode the rules are rebuilt from scratch (clear()): the new
321
- // node-building contract makes the jsonic defaults accumulate via child$
322
- // and the info marker, which flattens records and produces `[object
323
- // Object]` keys. Each rule then owns its node — allocate in `bo`,
324
- // coalesce in `bc`. This mirrors the Go port (markdown.go list/elem/val),
325
- // which already passes against the new core.
326
- //
327
- // In NON-STRICT mode the jsonic open/close alternatives are kept (so the
328
- // embedded JSON structural tokens `[`/`]`/`{`/`}` still parse). The
329
- // markdown record alternates are prepended (record/field handling wins
330
- // for top-level rows) with an unconditional `elem`/`val` fallback
331
- // appended after the jsonic alts (so jsonic's `#OS`/`#CS` etc. are tried
332
- // first for embedded containers). The corrupting jsonic lifecycle
333
- // bo/bc actions are replaced with clean, record-aware versions.
334
- if (strict) {
335
- tn.rule('list', (rs) => {
336
- return rs
337
- .clear()
338
- // Allocate the record's field array; the parent (record) reads it
339
- // back from r.child.node in @record-bc.
340
- .bo((r) => {
341
- r.node = [];
342
- })
343
- .open([
344
- // If not ignoring empty fields, don't consume LN used to close empty record.
345
- { s: [LN], b: 1 },
346
- // Parse each field as an element.
347
- { p: 'elem' },
348
- ])
349
- .close([
350
- // LN ends record
351
- { s: [LN], b: 1 },
352
- { s: [ZZ] },
353
- ]);
354
- });
355
- tn.rule('elem', (rs) => {
356
- return rs
357
- .clear()
358
- .open([
359
- // An empty element (`,` with nothing before it).
360
- {
361
- s: [CA],
362
- b: 1,
363
- a: (r) => {
364
- r.node.push(options.field.empty);
365
- r.u.done = true;
366
- },
367
- },
368
- // Otherwise parse a field value.
369
- { p: 'val' },
370
- ])
371
- .close([
372
- // An empty element at the end of the line / input.
373
- {
374
- s: [CA, [LN, ZZ]],
375
- b: 1,
376
- a: (r) => r.node.push(options.field.empty),
377
- },
378
- // Next element.
379
- { s: [CA], r: 'elem' },
380
- // LN ends record
381
- { s: [LN], b: 1 },
382
- // ZZ ends input
383
- { s: [ZZ] },
384
- ])
385
- // Push the parsed field value, unless an open alt already handled it
386
- // (done flag) or there was no value.
387
- .bc((r) => {
388
- if (true !== r.u.done && undefined !== r.child.node) {
389
- r.node.push(r.child.node);
390
- }
391
- });
392
- });
393
- tn.rule('val', (rs) => {
394
- return rs
395
- .clear()
396
- // A value rule owns its node; start undefined so bc can tell whether
397
- // a child / token supplied a value (implicit-null otherwise).
398
- .bo((r) => {
399
- r.node = undefined;
400
- })
401
- .open([
402
- // Handle text and space concatenation.
403
- { s: [VAL, SP], b: 2, p: 'text' },
404
- { s: [SP], b: 1, p: 'text' },
405
- // A plain value token.
406
- { s: [VAL] },
407
- // LN ends record
408
- { s: [LN], b: 1 },
409
- ])
410
- // Coalesce: child value > matched token > implicit null (undefined).
411
- .bc((r, ctx) => {
412
- if (undefined === r.node) {
413
- if (undefined !== r.child.node) {
414
- r.node = r.child.node;
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++;
415
427
  }
416
- else if (0 !== r.os) {
417
- r.node = r.o0.resolveVal(r, ctx);
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;
418
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;
419
487
  }
420
- });
421
- });
422
- }
423
- else {
424
- // Non-strict: keep the jsonic/json alternatives (embedded containers),
425
- // layer the markdown record alternates around them.
426
- tn.rule('list', (rs) => {
427
- return rs
428
- // Record context allocates its own clean field array. For an
429
- // embedded `[...]`, json's @array$ open-alt re-allocates, so this is
430
- // harmless there. Replace jsonic's list bo (dmap/implist + info
431
- // marker) which would otherwise corrupt the record node.
432
- .clearActions('bo', 'bc')
433
- .bo((r) => {
434
- r.node = [];
435
- })
436
- // Don't consume the LN that closes an (empty) record.
437
- .open([{ s: [LN], b: 1 }])
438
- // Fallback: a record field. Appended AFTER the jsonic alts so the
439
- // embedded `#OS`/`#OS #CS` alts are matched first.
440
- .open([{ p: 'elem' }], { append: true })
441
- // LN / ZZ end a record.
442
- .close([{ s: [LN], b: 1 }])
443
- .close([{ s: [ZZ] }], { append: true });
444
- });
445
- tn.rule('elem', (rs) => {
446
- return rs
447
- // Replace jsonic's elem bc (child$ / pair handling) with a clean
448
- // push so record fields and embedded array elements both land as
449
- // plain values.
450
- .clearActions('bc')
451
- .open([
452
- // An empty element (`,` with nothing before it).
453
- {
454
- s: [CA],
455
- b: 1,
456
- a: (r) => {
457
- r.node.push(options.field.empty);
458
- r.u.done = true;
459
- },
460
- },
461
- ])
462
- // Fallback: parse a field/element value, after jsonic's alts.
463
- .open([{ p: 'val' }], { append: true })
464
- .close([
465
- // An empty element at the end of the line / input.
466
- {
467
- s: [CA, [LN, ZZ]],
468
- b: 1,
469
- a: (r) => r.node.push(options.field.empty),
470
- },
471
- // LN ends record
472
- { s: [LN], b: 1 },
473
- ])
474
- .bc((r) => {
475
- if (true !== r.u.done && undefined !== r.child.node) {
476
- r.node.push(r.child.node);
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;
477
530
  }
478
- });
479
- });
480
- tn.rule('val', (rs) => {
481
- return rs
482
- .open([
483
- // Handle text and space concatenation.
484
- { s: [VAL, SP], b: 2, p: 'text' },
485
- { s: [SP], b: 1, p: 'text' },
486
- // LN ends record
487
- { s: [LN], b: 1 },
488
- ]);
489
- });
490
- }
491
- // Close is called on final rule - set parent val node
492
- tn.rule('text', (rs) => {
493
- rs.bc((r) => {
494
- r.parent.node = undefined === r.child.node ? r.node : r.child.node;
495
- });
496
- });
497
- };
498
- exports.Markdown = Markdown;
499
- // Custom Markdown String matcher.
500
- // Handles "a""b" -> "a"b" quoting wierdness.
501
- // This is a reduced copy of the standard Jsonic string matcher.
502
- function buildMarkdownStringMatcher(mdopts) {
503
- return function makeMarkdownStringMatcher(cfg, _opts) {
504
- return function markdownStringMatcher(lex) {
505
- let quoteMap = { [mdopts.string.quote]: true };
506
- let { pnt, src } = lex;
507
- let { sI, rI, cI } = pnt;
508
- let srclen = src.length;
509
- if (quoteMap[src[sI]]) {
510
- const q = src[sI]; // Quote character
511
- const qI = sI;
512
- const qrI = rI;
513
- ++sI;
514
- ++cI;
515
- let s = [];
516
- for (sI; sI < srclen; sI++) {
517
- cI++;
518
- let c = src[sI];
519
- // Quote char.
520
- if (q === c) {
521
- sI++;
522
- cI++;
523
- if (q === src[sI]) {
524
- s.push(q);
525
- }
526
- else {
527
- break; // String finished.
528
- }
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.
529
541
  }
530
- // Body part of string.
531
- else {
532
- let bI = sI;
533
- let qc = q.charCodeAt(0);
534
- let cc = src.charCodeAt(sI);
535
- while (sI < srclen && 32 <= cc && qc !== cc) {
536
- cc = src.charCodeAt(++sI);
537
- cI++;
538
- }
539
- cI--;
540
- if (cfg.line.chars[src[sI]]) {
541
- if (cfg.line.rowChars[src[sI]]) {
542
- pnt.rI = ++rI;
543
- }
544
- cI = 1;
545
- s.push(src.substring(bI, sI + 1));
546
- }
547
- else if (cc < 32) {
548
- pnt.sI = sI;
549
- pnt.cI = cI;
550
- return lex.bad('unprintable', sI, sI + 1);
551
- }
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);
552
576
  else {
553
- s.push(src.substring(bI, sI));
554
- sI--;
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));
555
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;
556
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++;
557
601
  }
558
- if (src[sI - 1] !== q || pnt.sI === sI - 1) {
559
- pnt.rI = qrI;
560
- return lex.bad('unterminated_string', qI, sI);
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;
561
616
  }
562
- const tkn = lex.token('#ST', s.join(''), src.substring(pnt.sI, sI), pnt);
563
- pnt.sI = sI;
564
- pnt.rI = rI;
565
- pnt.cI = cI;
566
- return tkn;
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 });
567
620
  }
568
- };
569
- };
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 };
570
672
  }
571
- // Default option values.
572
- Markdown.defaults = {
573
- trim: null,
574
- comment: null,
575
- number: null,
576
- value: null,
577
- header: true,
578
- object: true,
579
- stream: null,
580
- strict: true,
581
- field: {
582
- separation: null,
583
- nonameprefix: 'field~',
584
- empty: '',
585
- names: undefined,
586
- exact: false,
587
- },
588
- record: {
589
- separators: null,
590
- empty: false,
591
- },
592
- string: {
593
- quote: '"',
594
- markdown: null,
595
- },
673
+ // ---------------------------------------------------------------------------
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.
678
+ 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
687
+ 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
694
+ tn.options({
695
+ string: { lex: false },
696
+ comment: { lex: false },
697
+ number: { lex: false },
698
+ value: { lex: false },
699
+ lex: { emptyResult: { type: 'document', children: [] } },
700
+ });
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
+ const refs = {
709
+ '@markdown-bo': (r, ctx) => {
710
+ 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
+ r.node = parseDocument(src, opts);
717
+ },
718
+ };
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
+ tn.rule('markdown', (rs) => {
727
+ return rs
728
+ .clear()
729
+ .bo(refs['@markdown-bo'])
730
+ .open([{ s: '#AA', r: 'markdown' }, {}])
731
+ .close([{ s: '#ZZ' }, {}]);
732
+ });
733
+ tn.markdown = { parseDocument, parseInline: (t) => parseInline(t, opts) };
596
734
  };
735
+ exports.Markdown = Markdown;
736
+ // Defaults for `new Tabnas().use(Markdown, opts?)` merging
737
+ Markdown.defaults = { gfm: true, breaks: false };
597
738
  //# sourceMappingURL=markdown.js.map