@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/README.md +10 -38
- package/dist/markdown.d.ts +88 -28
- package/dist/markdown.js +692 -551
- package/dist/markdown.js.map +1 -1
- package/package.json +13 -9
- package/src/markdown.ts +754 -629
package/dist/markdown.js
CHANGED
|
@@ -1,597 +1,738 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/* Copyright (c) 2021-
|
|
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.
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
'
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
obj[fields[fI]] =
|
|
243
|
-
undefined === record[fI] ? options.field.empty : record[fI];
|
|
244
|
-
}
|
|
245
|
-
i = fI;
|
|
154
|
+
}
|
|
155
|
+
// Image — 
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
397
|
+
lang = langAndMeta.slice(0, spaceIdx);
|
|
398
|
+
meta = langAndMeta.slice(spaceIdx + 1).trim() || null;
|
|
265
399
|
}
|
|
266
400
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
417
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
//
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|