@tabnas/markdown 0.4.1 → 0.5.0

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,195 @@
1
- /* Copyright (c) 2021-2025 Richard Rodger, MIT License */
2
-
3
- // The engine is the tabnas parser; jsonic supplies the relaxed-JSON
4
- // grammar that the embedded grammar text is authored in.
5
- import {
6
- Tabnas,
7
- Rule,
8
- RuleSpec,
9
- Plugin,
10
- Context,
11
- Config,
12
- TabnasOptions,
13
- Lex,
14
- Token,
15
- } 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
- }
1
+ /* Copyright (c) 2021-2026 Richard Rodger, MIT License */
2
+
3
+ // @tabnas/markdown — a CommonMark 0.31.2 parser, built as a tabnas plugin on
4
+ // the bare engine.
5
+ //
6
+ // This file is only the plugin wiring and the public surface. The parser
7
+ // itself is engine-free and lives in `commonmark.ts` and the modules it pulls
8
+ // in (`block.ts`, `inline.ts`, `html.ts`, `common.ts`, `node.ts`), so it can
9
+ // be exercised — and the conformance suite run — without `@tabnas/parser`
10
+ // installed. Nothing under `commonmark.ts` may import the engine.
11
+ //
12
+ // The parse result is a native CommonMark node tree; `ast.ts` projects it to
13
+ // the mdast-adjacent JSON AST this package has always returned, and `html.ts`
14
+ // renders it for `toHtml`.
15
+
16
+ // `Tabnas` is the only runtime binding needed here; the rest are types, so
17
+ // they are imported as such. Without `import type`, Node's type stripping
18
+ // keeps them in the emitted import and demands runtime exports that do not
19
+ // exist.
20
+ import type { Tabnas, Plugin, Rule, Context } from '@tabnas/parser'
21
+
22
+ import { parse } from './commonmark.ts'
23
+ import { toAst } from './ast.ts'
24
+ import { renderHTML } from './html.ts'
25
+ import { resolveOptions } from './options.ts'
26
+ import type { ParserOptions } from './options.ts'
27
+ import { MdNode } from './node.ts'
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Public options
31
+
32
+ export type MarkdownOptions = {
33
+ /** Enable GFM extensions (strikethrough). Default true. */
34
+ gfm?: boolean
35
+ /** When true, a single soft break becomes a hard break. Default false. */
36
+ breaks?: boolean
43
37
  }
44
38
 
39
+ // ---------------------------------------------------------------------------
40
+ // Grammar source.
41
+ //
42
+ // The prose grammar is intentionally trivial: block structure is decided by
43
+ // the line-oriented algorithm in `block.ts`, not by declarative alts, so the
44
+ // `markdown` rule exists only to consume the token stream. The file is kept
45
+ // because `ts/embed-grammar.js` embeds it verbatim into both runtimes; see
46
+ // AGENTS.md. It is documentation of the entry rule, not a source of behaviour.
47
+
45
48
  // --- BEGIN EMBEDDED markdown-grammar.jsonic ---
46
49
  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
+ # Markdown entry rule — CommonMark 0.31.2
50
51
  #
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
52
+ # This grammar is deliberately trivial, and it is NOT where the parser
53
+ # lives. Block structure is decided by the two-phase line algorithm of
54
+ # spec Appendix A, implemented in block.ts / block.go, and inline
55
+ # structure by the delimiter and bracket stacks in inline.ts / inline.go.
56
+ # None of that is expressible as declarative alts, which is why none of
57
+ # it is here.
57
58
  #
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
-
59
+ # The markdown rule exists only to give the engine an entry point and to
60
+ # consume the token stream so its trailing-content check passes. The bo
61
+ # action reads the whole source via ctx.src() and hands it to the parser.
62
+ # Everything the rule does is in those two lines below.
63
+ #
64
+ # Consequences worth knowing rather than discovering:
65
+ # - The railroad diagram generated from this file is empty: a bare
66
+ # track with no boxes on it. That is accurate, not a rendering fault.
67
+ # - Editing this file changes documentation, not behaviour. The one
68
+ # exception is the open/close alts, which really are the rule.
69
+ #
70
+ # The file is kept because ts/embed-grammar.js embeds it verbatim into
71
+ # both runtimes as grammarText (see AGENTS.md). It may not contain
72
+ # backticks: the Go copy is a raw string literal.
62
73
  {
63
74
  rule: markdown: open: [
64
75
  { 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
- ]
75
- rule: newline: close: [
76
- { s: '#LN #LN' r: newline }
77
- { s: '#LN' r: newline }
78
- { s: '#ZZ' }
79
- { r: record }
80
- ]
81
-
82
- rule: record: open: [
83
- { p: list }
84
76
  ]
85
- rule: record: close: [
77
+ rule: markdown: close: [
86
78
  { 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
79
  ]
98
80
  }
99
81
  `
100
82
  // --- END EMBEDDED markdown-grammar.jsonic ---
101
83
 
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
84
+ // ---------------------------------------------------------------------------
85
+ // Public parse API (engine-free)
141
86
 
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
- })
152
- }
153
- tn.options({
154
- rule: { exclude: 'jsonic,imp' },
155
- })
156
- }
157
-
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
- })
168
- }
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
- }
87
+ /** Parse Markdown to the mdast-adjacent JSON AST. */
88
+ export function parseDocument(src: string, opts?: MarkdownOptions | ParserOptions) {
89
+ const options = resolveOptions(opts)
90
+ return toAst(parse(src, options), options)
91
+ }
190
92
 
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,
200
- }
201
- }
93
+ /** Parse a single run of inline Markdown to an `Inline[]`. */
94
+ export function parseInline(text: string, opts?: MarkdownOptions | ParserOptions) {
95
+ const doc = parseDocument(text, opts)
96
+ const first = doc.children[0]
97
+ return first && 'paragraph' === first.type ? first.children : []
98
+ }
202
99
 
203
- // Custom "comma"
204
- if (options.field.separation) {
205
- token['#CA'] = options.field.separation
206
- }
100
+ /** Parse Markdown and render it to CommonMark-conformant HTML. */
101
+ export function toHtml(src: string, opts?: MarkdownOptions | ParserOptions): string {
102
+ const options = resolveOptions(opts)
103
+ return renderHTML(parse(src, options), options)
104
+ }
207
105
 
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
- },
255
- }
106
+ /** Parse Markdown to the native CommonMark node tree. */
107
+ export function parseTree(src: string, opts?: MarkdownOptions | ParserOptions): MdNode {
108
+ return parse(src, resolveOptions(opts))
109
+ }
256
110
 
257
- tn.options(jsonicOptions)
111
+ // ---------------------------------------------------------------------------
112
+ // Plugin wiring
113
+ //
114
+ // The `markdown` rule's `bo` action reads the whole source via `ctx.src()` and
115
+ // hands it to the parser; the alts then consume the token stream so the
116
+ // engine's trailing-content check (lex must end at #ZZ) passes. `#AA` is the
117
+ // ANY-token wildcard and `r:'markdown'` loops at the same depth, consuming one
118
+ // token per iteration until only #ZZ remains.
258
119
 
120
+ const Markdown: Plugin = (tn: Tabnas, options?: MarkdownOptions) => {
121
+ const opts = resolveOptions(options)
259
122
 
260
- // Named function references for declarative grammar definition.
261
- const refs: Record<string, Function> = {
123
+ tn.options({ rule: { start: 'markdown' } })
262
124
 
263
- // === State actions (auto-wired by @rulename-{bo,ao,bc,ac} convention) ===
125
+ // The parser reads `ctx.src()` directly rather than the token stream, so
126
+ // disable the lexers that would corrupt Markdown syntax before it gets
127
+ // there: backtick code spans lex as unterminated strings, `# heading` as a
128
+ // comment, and `1. list` as a number.
129
+ tn.options({
130
+ string: { lex: false },
131
+ comment: { lex: false },
132
+ number: { lex: false },
133
+ value: { lex: false },
134
+ lex: { emptyResult: { type: 'document', children: [] } },
135
+ })
264
136
 
137
+ const refs: Record<string, any> = {
265
138
  '@markdown-bo': (r: Rule, ctx: Context) => {
266
- ctx.u.recordI = 0
267
- stream && stream('start')
268
- r.node = []
269
- },
270
-
271
- '@markdown-ac': (_r: Rule) => {
272
- stream && stream('end')
273
- },
274
-
275
- '@record-bc': (r: Rule, ctx: Context) => {
276
- let fields: string[] = ctx.u.fields || options.field.names
277
-
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
- }
297
-
298
- let fI = 0
299
- for (; fI < fields.length; fI++) {
300
- obj[fields[fI]] =
301
- undefined === record[fI] ? options.field.empty : record[fI]
302
- }
303
- i = fI
304
- }
305
-
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
- }
311
-
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
- }
318
- }
319
-
320
- if (stream) {
321
- stream('record', record)
322
- } else {
323
- r.node.push(record)
324
- }
325
- }
326
-
327
- ctx.u.recordI++
328
- },
329
-
330
- '@text-bc': (r: Rule) => {
331
- r.parent.node = undefined === r.child.node ? r.node : r.child.node
139
+ const src: string = ctx.src()
140
+ r.node = parseDocument(src, opts)
332
141
  },
333
-
334
-
335
- // === Alt actions ===
336
-
337
- '@elem-open-empty': (r: Rule) => {
338
- r.node.push(options.field.empty)
339
- r.u.done = true
340
- },
341
-
342
- '@elem-close-trailing': (r: Rule) => {
343
- r.node.push(options.field.empty)
344
- },
345
-
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
- },
350
-
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
- },
358
-
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
- },
364
-
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
- },
372
-
373
-
374
- // === Condition refs ===
375
-
376
- '@not-record-empty': () => !record_empty,
377
-
378
-
379
- // === FuncRef for dynamic rule names ===
380
-
381
- '@record-close-next': () => record_empty ? 'record' : 'newline',
382
-
383
- '@text-space-push': () => strict ? '' : 'val',
384
142
  }
385
143
 
386
-
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
- }
511
- }
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)
571
- }
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
-
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
- })
144
+ tn.rule('markdown', (rs) => {
145
+ return rs
146
+ .clear()
147
+ .bo(refs['@markdown-bo'] as any)
148
+ .open([{ s: '#AA', r: 'markdown' }, {}])
149
+ .close([{ s: '#ZZ' }, {}])
593
150
  })
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
151
 
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.
630
- }
631
- }
632
-
633
- // Body part of string.
634
- else {
635
- let bI = sI
636
-
637
- let qc = q.charCodeAt(0)
638
- let cc = src.charCodeAt(sI)
639
-
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
- }
662
- }
663
-
664
- if (src[sI - 1] !== q || pnt.sI === sI - 1) {
665
- pnt.rI = qrI
666
- return lex.bad('unterminated_string', qI, sI)
667
- }
668
-
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
680
- }
681
- }
152
+ // Not part of the tabnas contract, but useful for tests, docs and callers
153
+ // that want HTML or the native tree without a second import.
154
+ ;(tn as any).markdown = {
155
+ parseDocument: (t: string) => parseDocument(t, opts),
156
+ parseInline: (t: string) => parseInline(t, opts),
157
+ toHtml: (t: string) => toHtml(t, opts),
158
+ parseTree: (t: string) => parseTree(t, opts),
682
159
  }
683
160
  }
684
161
 
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 }
162
+ Markdown.defaults = { gfm: true, breaks: false } as MarkdownOptions
163
+
164
+ export { Markdown, grammarText }
165
+
166
+ export type {
167
+ DocumentNode,
168
+ Block,
169
+ HeadingNode,
170
+ ParagraphNode,
171
+ BlockquoteNode,
172
+ ListNode,
173
+ ListItemNode,
174
+ CodeNode,
175
+ HtmlNode,
176
+ ThematicBreakNode,
177
+ Inline,
178
+ TextNode,
179
+ EmphasisNode,
180
+ StrongNode,
181
+ InlineCodeNode,
182
+ LinkNode,
183
+ ImageNode,
184
+ BreakNode,
185
+ DeleteNode,
186
+ } from './ast.ts'
187
+
188
+ export { MdNode } from './node.ts'
189
+ export type { ParserOptions } from './options.ts'
190
+
191
+ // Re-exported so the package entry offers the same pair as the Go package,
192
+ // where `RenderHTML` is exported alongside `ToHTML`. Without this, rendering a
193
+ // tree you built or mutated yourself meant a deep import of `commonmark.ts`,
194
+ // and the two runtimes' public surfaces did not match.
195
+ export { renderHTML } from './html.ts'