@tabnas/markdown 0.2.1 → 0.4.2

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