@jarenjs/md 0.34.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/README.md +520 -0
- package/dist/types/ast.d.ts +181 -0
- package/dist/types/bake.d.ts +61 -0
- package/dist/types/compiler.d.ts +141 -0
- package/dist/types/component/index.d.ts +101 -0
- package/dist/types/directives.d.ts +126 -0
- package/dist/types/entities.d.ts +40 -0
- package/dist/types/footnotes.d.ts +83 -0
- package/dist/types/frontmatter.d.ts +67 -0
- package/dist/types/html.d.ts +72 -0
- package/dist/types/index.d.ts +30 -0
- package/dist/types/loader.d.ts +84 -0
- package/dist/types/mdx.d.ts +45 -0
- package/dist/types/parser.d.ts +116 -0
- package/dist/types/plugins/highlight.d.ts +64 -0
- package/dist/types/plugins/index.d.ts +64 -0
- package/dist/types/plugins/mermaid.d.ts +12 -0
- package/dist/types/scanner.d.ts +240 -0
- package/dist/types/to-html.d.ts +104 -0
- package/dist/types/to-md.d.ts +23 -0
- package/dist/types/to-vnode.d.ts +161 -0
- package/dist/types/utils.d.ts +63 -0
- package/docs/LOADER.md +92 -0
- package/docs/MD-FORMAT.md +502 -0
- package/docs/PLUGINS.md +277 -0
- package/package.json +80 -0
- package/schemas/jaren-md-ast.schema.json +296 -0
- package/src/ast.js +346 -0
- package/src/bake.js +104 -0
- package/src/compiler.js +167 -0
- package/src/component/index.js +191 -0
- package/src/directives.js +371 -0
- package/src/entities.js +107 -0
- package/src/footnotes.js +180 -0
- package/src/frontmatter.js +947 -0
- package/src/html.js +281 -0
- package/src/index.js +76 -0
- package/src/loader.js +0 -0
- package/src/mdx.js +219 -0
- package/src/parser.js +1685 -0
- package/src/plugins/highlight.js +325 -0
- package/src/plugins/index.js +75 -0
- package/src/plugins/mermaid.js +14 -0
- package/src/scanner.js +832 -0
- package/src/to-html.js +425 -0
- package/src/to-md.js +396 -0
- package/src/to-vnode.js +766 -0
- package/src/utils.js +107 -0
- package/styles/md.css +238 -0
package/src/parser.js
ADDED
|
@@ -0,0 +1,1685 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The Markdown parser: block structure + inline parsing → AST.
|
|
4
|
+
*
|
|
5
|
+
* One pass over the source builds the block tree (a container stack of
|
|
6
|
+
* blockquotes/lists/items plus one open leaf), buffering each leaf's
|
|
7
|
+
* raw text; inline parsing runs once per leaf when it closes. Plugin
|
|
8
|
+
* extension points — fence claims, block rules, inline rules — are
|
|
9
|
+
* prebuilt tables consulted by indexed lookup in the hot loop
|
|
10
|
+
* (docs/PLUGINS.md); with no plugins the tables are shared empty maps.
|
|
11
|
+
*
|
|
12
|
+
* The same machinery runs batch (`parseMarkdown`) and incrementally
|
|
13
|
+
* (`createIncrementalParser`): blocks land in the output only when
|
|
14
|
+
* closed, so the incremental parser can hand out completed top-level
|
|
15
|
+
* blocks while later chunks are still arriving (docs/LOADER.md §4).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { countIndent, expandTabs, hashContent, fnv1a, FNV1A_OFFSET_BASIS } from './utils.js';
|
|
19
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
20
|
+
import {
|
|
21
|
+
scanThematicBreak,
|
|
22
|
+
scanAtxHeading,
|
|
23
|
+
scanFenceOpen,
|
|
24
|
+
scanFenceClose,
|
|
25
|
+
splitFenceInfo,
|
|
26
|
+
scanBlockquote,
|
|
27
|
+
scanListMarker,
|
|
28
|
+
scanSetextUnderline,
|
|
29
|
+
scanTableDelimiter,
|
|
30
|
+
splitTableRow,
|
|
31
|
+
scanHtmlBlockStart,
|
|
32
|
+
scanHtmlBlockEnd,
|
|
33
|
+
scanLinkDefinition,
|
|
34
|
+
scanLinkDestination,
|
|
35
|
+
scanLinkTitle,
|
|
36
|
+
scanFootnoteDefinition,
|
|
37
|
+
scanFootnoteReference,
|
|
38
|
+
scanAutolinkLiterals,
|
|
39
|
+
normalizeLabel,
|
|
40
|
+
isSpaceCode,
|
|
41
|
+
ASCII_PUNCT,
|
|
42
|
+
isUnicodeWhitespace,
|
|
43
|
+
isUnicodePunctuation,
|
|
44
|
+
codePointBefore,
|
|
45
|
+
} from './scanner.js';
|
|
46
|
+
import { scanEntity } from './entities.js';
|
|
47
|
+
import {
|
|
48
|
+
MD_VERSION,
|
|
49
|
+
thematicBreak, blockquote, list, listItem,
|
|
50
|
+
code, htmlBlock, tableRow, tableCell,
|
|
51
|
+
text, emphasis, strong, strikethrough, link, image, inlineCode,
|
|
52
|
+
hardBreak, softBreak, textOf,
|
|
53
|
+
autolink, footnoteDefinition, footnoteReference,
|
|
54
|
+
} from './ast.js';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {import('./ast.js').MdNode} MdNode
|
|
58
|
+
* @typedef {import('./ast.js').MdDocument} MdDocument
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {object} MdParseOptions
|
|
62
|
+
* @property {any[]} [plugins] compiled-in plugins (docs/PLUGINS.md)
|
|
63
|
+
* @property {boolean} [gfm] GFM tables/strikethrough/task lists (default true)
|
|
64
|
+
* @property {boolean} [frontmatter] detect frontmatter (default true)
|
|
65
|
+
* @property {(text: string) => any} [toml] injectable TOML frontmatter parser
|
|
66
|
+
* @property {string|null} [sourceUrl] recorded in `meta.sourceUrl`
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
// ------------------------------------------------------------------
|
|
70
|
+
// Plugin tables (built once per plugin set, memoized by array identity)
|
|
71
|
+
// ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/** @type {Map<any, any>} */
|
|
74
|
+
const EMPTY_MAP = new Map();
|
|
75
|
+
const NO_TABLES = Object.freeze({
|
|
76
|
+
fences: EMPTY_MAP,
|
|
77
|
+
blocks: EMPTY_MAP,
|
|
78
|
+
inlines: EMPTY_MAP,
|
|
79
|
+
renders: EMPTY_MAP,
|
|
80
|
+
htmls: EMPTY_MAP,
|
|
81
|
+
hydrates: EMPTY_MAP,
|
|
82
|
+
plugins: Object.freeze([]),
|
|
83
|
+
vnodeMemo: new WeakMap(),
|
|
84
|
+
hydrateMemo: new WeakMap(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/** @type {WeakMap<any[], any>} */
|
|
88
|
+
const tableMemo = new WeakMap();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Merge a plugin array into the four dispatch tables (first plugin
|
|
92
|
+
* wins on every collision). Memoized by array identity so module-level
|
|
93
|
+
* plugin arrays compile exactly once.
|
|
94
|
+
* @param {any[] | undefined} plugins
|
|
95
|
+
*/
|
|
96
|
+
export function buildPluginTables(plugins) {
|
|
97
|
+
if (plugins === undefined || plugins.length === 0) return NO_TABLES;
|
|
98
|
+
let tables = tableMemo.get(plugins);
|
|
99
|
+
if (tables !== undefined) return tables;
|
|
100
|
+
/** @type {Map<string, any>} */
|
|
101
|
+
const fences = new Map();
|
|
102
|
+
/** @type {Map<number, any[]>} */
|
|
103
|
+
const blocks = new Map();
|
|
104
|
+
/** @type {Map<number, any[]>} */
|
|
105
|
+
const inlines = new Map();
|
|
106
|
+
/** @type {Map<string, any>} */
|
|
107
|
+
const renders = new Map();
|
|
108
|
+
/** @type {Map<string, any>} */
|
|
109
|
+
const htmls = new Map();
|
|
110
|
+
/** @type {Map<string, any>} */
|
|
111
|
+
const hydrates = new Map();
|
|
112
|
+
for (const plugin of plugins) {
|
|
113
|
+
if (Array.isArray(plugin.fences)) {
|
|
114
|
+
for (const word of plugin.fences) {
|
|
115
|
+
if (!fences.has(word)) fences.set(word, plugin);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(plugin.blocks)) {
|
|
119
|
+
for (const rule of plugin.blocks) {
|
|
120
|
+
for (const ch of rule.chars) {
|
|
121
|
+
const cc = ch.charCodeAt(0);
|
|
122
|
+
const bucket = blocks.get(cc);
|
|
123
|
+
if (bucket === undefined) blocks.set(cc, [rule]);
|
|
124
|
+
else bucket.push(rule);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(plugin.inlines)) {
|
|
129
|
+
for (const rule of plugin.inlines) {
|
|
130
|
+
const cc = rule.char.charCodeAt(0);
|
|
131
|
+
const bucket = inlines.get(cc);
|
|
132
|
+
if (bucket === undefined) inlines.set(cc, [rule]);
|
|
133
|
+
else bucket.push(rule);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (typeof plugin.render === 'function') {
|
|
137
|
+
const type = plugin.node ?? plugin.name;
|
|
138
|
+
if (!renders.has(type)) renders.set(type, plugin);
|
|
139
|
+
if (typeof plugin.hydrate === 'function') hydrates.set(type, plugin);
|
|
140
|
+
}
|
|
141
|
+
// the string emitter's table: a plugin may serve one emitter, the
|
|
142
|
+
// other, or both, so this is registered independently of `render`
|
|
143
|
+
if (typeof plugin.toHtml === 'function') {
|
|
144
|
+
const type = plugin.node ?? plugin.name;
|
|
145
|
+
if (!htmls.has(type)) htmls.set(type, plugin);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
tables = {
|
|
149
|
+
fences, blocks, inlines, renders, htmls, hydrates, plugins,
|
|
150
|
+
vnodeMemo: new WeakMap(),
|
|
151
|
+
hydrateMemo: new WeakMap(),
|
|
152
|
+
};
|
|
153
|
+
tableMemo.set(plugins, tables);
|
|
154
|
+
return tables;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ------------------------------------------------------------------
|
|
158
|
+
// Block parser
|
|
159
|
+
// ------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The content indent of a footnote definition's continuation lines.
|
|
163
|
+
* Fixed at four columns, unlike a list item's, whose marker decides it:
|
|
164
|
+
* `[^label]:` has no width a reader can count, so the reference
|
|
165
|
+
* implementation picked a constant and every document written for
|
|
166
|
+
* GitHub is indented to it.
|
|
167
|
+
*/
|
|
168
|
+
const FOOTNOTE_INDENT = 4;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The block parser state. Not exported — reach it through
|
|
172
|
+
* `parseMarkdown` or `createIncrementalParser`.
|
|
173
|
+
*/
|
|
174
|
+
class BlockParser {
|
|
175
|
+
/**
|
|
176
|
+
* @param {MdParseOptions} options
|
|
177
|
+
* @param {any} tables
|
|
178
|
+
*/
|
|
179
|
+
constructor(options, tables) {
|
|
180
|
+
this.options = options;
|
|
181
|
+
this.tables = tables;
|
|
182
|
+
this.gfm = options.gfm !== false;
|
|
183
|
+
/** Completed top-level blocks (raw; inline text not yet parsed). */
|
|
184
|
+
/** @type {MdNode[]} */
|
|
185
|
+
this.blocks = [];
|
|
186
|
+
/** Open containers: blockquote / list / listItem entries. */
|
|
187
|
+
/** @type {any[]} */
|
|
188
|
+
this.stack = [];
|
|
189
|
+
/** The open leaf, or null. */
|
|
190
|
+
/** @type {any} */
|
|
191
|
+
this.leaf = null;
|
|
192
|
+
/** Link reference definitions seen so far. */
|
|
193
|
+
/** @type {Map<string, { url: string, title: string|null }>} */
|
|
194
|
+
this.defs = new Map();
|
|
195
|
+
/**
|
|
196
|
+
* Footnote definitions seen so far, by normalized identifier (first
|
|
197
|
+
* definition wins, as for link references). The inline phase
|
|
198
|
+
* consults it: `[^x]` with nothing to point at stays literal text,
|
|
199
|
+
* which is what GitHub does and what keeps a bracketed `^` in prose
|
|
200
|
+
* from becoming a dangling superscript.
|
|
201
|
+
* @type {Map<string, MdNode>}
|
|
202
|
+
*/
|
|
203
|
+
this.footnotes = new Map();
|
|
204
|
+
/**
|
|
205
|
+
* Blocks whose last line was blank. List tightness is decided from
|
|
206
|
+
* this at list close (§Lists, "a list is loose if any of its
|
|
207
|
+
* constituent list items are separated by blank lines, or if any of
|
|
208
|
+
* its constituent list items directly contain two block-level
|
|
209
|
+
* elements with a blank line between them") — a rule about a list's
|
|
210
|
+
* OWN items, which is why it cannot be a flag on the open list: a
|
|
211
|
+
* blank line inside a sublist or a blockquote belongs to that
|
|
212
|
+
* container, not to the list around it.
|
|
213
|
+
* @type {WeakSet<any>}
|
|
214
|
+
*/
|
|
215
|
+
this.blankEnd = new WeakSet();
|
|
216
|
+
/** Plugin rule context. */
|
|
217
|
+
this.ctx = { frontmatter: /** @type {any} */ (null), options };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Process one detabbed line.
|
|
222
|
+
* @param {string} line
|
|
223
|
+
*/
|
|
224
|
+
line(line) {
|
|
225
|
+
// 1. Match the open containers' prefixes.
|
|
226
|
+
let offset = 0;
|
|
227
|
+
let matched = 0;
|
|
228
|
+
const stack = this.stack;
|
|
229
|
+
while (matched < stack.length) {
|
|
230
|
+
const entry = stack[matched];
|
|
231
|
+
if (entry.type === 'blockquote') {
|
|
232
|
+
let i = offset;
|
|
233
|
+
let spaces = 0;
|
|
234
|
+
while (spaces < 3 && line.charCodeAt(i) === 0x20) { i++; spaces++; }
|
|
235
|
+
const content = scanBlockquote(line, i);
|
|
236
|
+
if (content === -1) break;
|
|
237
|
+
offset = content;
|
|
238
|
+
}
|
|
239
|
+
else if (entry.type === 'listItem' || entry.type === 'footnoteDefinition') {
|
|
240
|
+
// A footnote definition continues exactly as a list item does —
|
|
241
|
+
// indented content, lazy continuation, one leading blank at most
|
|
242
|
+
// — so the two share this branch and differ only in where their
|
|
243
|
+
// content indent comes from (a marker's width, or a fixed 4).
|
|
244
|
+
//
|
|
245
|
+
// Blankness is a property of what is LEFT of the line, not of the
|
|
246
|
+
// whole line: inside `>>`, the item sees a blank line even though
|
|
247
|
+
// the line is not.
|
|
248
|
+
if (isBlankFrom(line, offset)) {
|
|
249
|
+
// An item may begin with at most one blank line, so a blank
|
|
250
|
+
// line does not continue an item that is still empty — it ends
|
|
251
|
+
// it (and the list with it).
|
|
252
|
+
if (this.isItemEmpty(entry, matched)) break;
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
let i = offset;
|
|
256
|
+
let spaces = 0;
|
|
257
|
+
while (spaces < entry.contentIndent && line.charCodeAt(i) === 0x20) { i++; spaces++; }
|
|
258
|
+
if (spaces < entry.contentIndent) break;
|
|
259
|
+
offset = i;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// 'list' entries consume nothing.
|
|
263
|
+
matched++;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const blank = isBlankFrom(line, offset);
|
|
267
|
+
|
|
268
|
+
if (matched < stack.length) {
|
|
269
|
+
const rest = line.slice(offset);
|
|
270
|
+
// Lazy continuation: an open paragraph swallows a line that would
|
|
271
|
+
// not start a block of its own. The paragraph-INTERRUPTION rules
|
|
272
|
+
// (no empty list item, no ordered list starting elsewhere than 1)
|
|
273
|
+
// deliberately do not apply here: they govern a paragraph that is
|
|
274
|
+
// the innermost matched container, and this paragraph is not —
|
|
275
|
+
// its own containers just failed to match. `1. a\n2. b\n3) c`
|
|
276
|
+
// starts a second list for exactly that reason.
|
|
277
|
+
if (this.leaf !== null && this.leaf.kind === 'paragraph'
|
|
278
|
+
&& !blank && !this.startsBlock(rest)) {
|
|
279
|
+
this.leaf.lines.push(stripIndent(rest));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.closeTo(matched);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const rest = offset === 0 ? line : line.slice(offset);
|
|
286
|
+
|
|
287
|
+
// 2. Raw leaves consume the line before any block-start scan.
|
|
288
|
+
const leaf = this.leaf;
|
|
289
|
+
if (leaf !== null) {
|
|
290
|
+
if (leaf.kind === 'fence') {
|
|
291
|
+
if (!blank && scanFenceClose(rest, leaf.marker, leaf.length)) {
|
|
292
|
+
this.closeLeaf();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const strip = Math.min(leaf.indent, countIndent(rest));
|
|
296
|
+
leaf.lines.push(strip === 0 ? rest : rest.slice(strip));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (leaf.kind === 'html') {
|
|
300
|
+
if (leaf.htmlKind >= 6) {
|
|
301
|
+
if (blank) {
|
|
302
|
+
this.closeLeaf();
|
|
303
|
+
this.sawBlank();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
leaf.lines.push(rest);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
leaf.lines.push(rest);
|
|
310
|
+
if (scanHtmlBlockEnd(leaf.htmlKind, rest)) this.closeLeaf();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (leaf.kind === 'plugin') {
|
|
314
|
+
const verdict = leaf.rule.continue(leaf.node, rest, this.ctx);
|
|
315
|
+
if (verdict === true) return;
|
|
316
|
+
this.closeLeaf();
|
|
317
|
+
if (verdict === 'end') return; // the closing line is consumed
|
|
318
|
+
if (blank) { this.sawBlank(); return; }
|
|
319
|
+
// The rejecting line is reprocessed as a fresh block start.
|
|
320
|
+
this.open(rest);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (leaf.kind === 'table') {
|
|
324
|
+
if (blank || this.interruptsParagraph(rest)) {
|
|
325
|
+
this.closeLeaf();
|
|
326
|
+
if (blank) { this.sawBlank(); return; }
|
|
327
|
+
this.open(rest);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
leaf.rows.push(rest);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 3. Try to open new blocks (containers loop within the line).
|
|
336
|
+
this.open(rest);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Would this text start a construct that interrupts a paragraph?
|
|
341
|
+
* (Also the lazy-continuation test and the GFM table row breaker.)
|
|
342
|
+
* @param {string} rest
|
|
343
|
+
* @returns {boolean}
|
|
344
|
+
*/
|
|
345
|
+
interruptsParagraph(rest) {
|
|
346
|
+
const indent = countIndent(rest);
|
|
347
|
+
if (indent >= 4) return false;
|
|
348
|
+
const c = rest.charCodeAt(indent);
|
|
349
|
+
if (c === 0x3E /* > */) return true;
|
|
350
|
+
if (c === 0x23 /* # */) return scanAtxHeading(rest, indent) !== null;
|
|
351
|
+
if (scanThematicBreak(rest, indent)) return true;
|
|
352
|
+
if (scanFenceOpen(rest, indent) !== null) return true;
|
|
353
|
+
if (c === 0x3C /* < */) {
|
|
354
|
+
const kind = scanHtmlBlockStart(rest, indent, true);
|
|
355
|
+
return kind !== 0 && kind !== 7;
|
|
356
|
+
}
|
|
357
|
+
if (c === 0x5B /* [ */ && this.gfm && scanFootnoteDefinition(rest, indent) !== null) {
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
const marker = scanListMarker(rest, indent);
|
|
361
|
+
if (marker !== null) {
|
|
362
|
+
// Only non-empty items — and ordered lists starting at 1 —
|
|
363
|
+
// interrupt a paragraph.
|
|
364
|
+
if (marker.contentOffset >= rest.length) return false;
|
|
365
|
+
return !marker.ordered || marker.start === 1;
|
|
366
|
+
}
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Would this text open ANY block, with no paragraph in the way? The
|
|
372
|
+
* lazy-continuation test: a line that starts a block is not paragraph
|
|
373
|
+
* text, whatever the paragraph would have preferred.
|
|
374
|
+
*
|
|
375
|
+
* Indented code and setext underlines are absent on purpose — both
|
|
376
|
+
* need the paragraph to be the innermost matched container, which is
|
|
377
|
+
* exactly the case this method is not asked about.
|
|
378
|
+
* @param {string} rest
|
|
379
|
+
* @returns {boolean}
|
|
380
|
+
*/
|
|
381
|
+
startsBlock(rest) {
|
|
382
|
+
const indent = countIndent(rest);
|
|
383
|
+
if (indent >= 4 || indent >= rest.length) return false;
|
|
384
|
+
const c = rest.charCodeAt(indent);
|
|
385
|
+
if (c === 0x3E /* > */) return true;
|
|
386
|
+
if (c === 0x23 /* # */ && scanAtxHeading(rest, indent) !== null) return true;
|
|
387
|
+
if (scanThematicBreak(rest, indent)) return true;
|
|
388
|
+
if (scanFenceOpen(rest, indent) !== null) return true;
|
|
389
|
+
if (c === 0x3C /* < */ && scanHtmlBlockStart(rest, indent, false) !== 0) return true;
|
|
390
|
+
if (c === 0x5B /* [ */ && this.gfm && scanFootnoteDefinition(rest, indent) !== null) return true;
|
|
391
|
+
if (scanListMarker(rest, indent) !== null) return true;
|
|
392
|
+
const rules = this.tables.blocks.get(c);
|
|
393
|
+
if (rules !== undefined) {
|
|
394
|
+
const trimmed = rest.slice(indent);
|
|
395
|
+
for (let i = 0; i < rules.length; i++) {
|
|
396
|
+
if (rules[i].start(trimmed, this.ctx) != null) return true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Is the list item at stack depth `index` still empty — nothing
|
|
404
|
+
* closed into it, no open leaf and no deeper container? Only the
|
|
405
|
+
* innermost entry can own the open leaf, which is what makes this a
|
|
406
|
+
* cheap check rather than a walk.
|
|
407
|
+
* @param {any} entry @param {number} index
|
|
408
|
+
* @returns {boolean}
|
|
409
|
+
*/
|
|
410
|
+
isItemEmpty(entry, index) {
|
|
411
|
+
return entry.node.children.length === 0
|
|
412
|
+
&& index === this.stack.length - 1
|
|
413
|
+
&& this.leaf === null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Open new blocks in `rest` (recursing through fresh containers).
|
|
418
|
+
* @param {string} rest
|
|
419
|
+
*/
|
|
420
|
+
open(rest) {
|
|
421
|
+
for (;;) {
|
|
422
|
+
const indent = countIndent(rest);
|
|
423
|
+
|
|
424
|
+
if (indent >= rest.length) {
|
|
425
|
+
// Blank: paragraphs, tables and type-6/7 html close; indented
|
|
426
|
+
// code buffers the blank; fences got it earlier.
|
|
427
|
+
const leaf = this.leaf;
|
|
428
|
+
if (leaf !== null && leaf.kind === 'indented') leaf.lines.push('');
|
|
429
|
+
else this.closeLeaf();
|
|
430
|
+
this.sawBlank();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Indented code (only when no paragraph is open to continue).
|
|
435
|
+
if (indent >= 4) {
|
|
436
|
+
const leaf = this.leaf;
|
|
437
|
+
if (leaf !== null && leaf.kind === 'paragraph') {
|
|
438
|
+
leaf.lines.push(rest.slice(indent));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (leaf !== null && leaf.kind === 'indented') {
|
|
442
|
+
leaf.lines.push(rest.slice(4));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
this.closeList();
|
|
446
|
+
this.leaf = { kind: 'indented', lines: [rest.slice(4)] };
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const c = rest.charCodeAt(indent);
|
|
451
|
+
let para = this.leaf !== null && this.leaf.kind === 'paragraph'
|
|
452
|
+
? this.leaf : null;
|
|
453
|
+
|
|
454
|
+
// Setext underline turns the open paragraph into a heading.
|
|
455
|
+
if (para !== null && (c === 0x3D /* = */ || c === 0x2D /* - */)) {
|
|
456
|
+
const depth = scanSetextUnderline(rest, indent);
|
|
457
|
+
if (depth !== 0) {
|
|
458
|
+
const lines = para.lines;
|
|
459
|
+
this.leaf = null;
|
|
460
|
+
// Link reference definitions are shed BEFORE the underline is
|
|
461
|
+
// applied: they are not heading text. A paragraph that was
|
|
462
|
+
// nothing but definitions leaves no content to underline, so
|
|
463
|
+
// the `=` line falls through and starts a paragraph of its own.
|
|
464
|
+
const raw = this.extractDefinitions(lines.join('\n')).trim();
|
|
465
|
+
if (raw !== '') {
|
|
466
|
+
this.add({ type: 'heading', depth, children: [], raw });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
// nothing left to underline: this line is ordinary content, and
|
|
470
|
+
// the paragraph it would have continued no longer exists
|
|
471
|
+
para = null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// GFM table: the open one-pipe paragraph line + a delimiter row.
|
|
476
|
+
if (this.gfm && para !== null && para.lines.length > 0) {
|
|
477
|
+
const align = scanTableDelimiter(rest);
|
|
478
|
+
if (align !== null) {
|
|
479
|
+
const header = para.lines[para.lines.length - 1];
|
|
480
|
+
const cells = splitTableRow(header);
|
|
481
|
+
if (cells !== null && cells.length === align.length) {
|
|
482
|
+
para.lines.pop();
|
|
483
|
+
this.closeLeaf();
|
|
484
|
+
this.leaf = { kind: 'table', align, rows: [header] };
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const fence = scanFenceOpen(rest, indent);
|
|
491
|
+
if (fence !== null) {
|
|
492
|
+
this.closeLeaf();
|
|
493
|
+
this.closeList();
|
|
494
|
+
this.leaf = {
|
|
495
|
+
kind: 'fence',
|
|
496
|
+
marker: fence.marker,
|
|
497
|
+
length: fence.length,
|
|
498
|
+
indent,
|
|
499
|
+
info: fence.info,
|
|
500
|
+
lines: [],
|
|
501
|
+
};
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (c === 0x23 /* # */) {
|
|
506
|
+
const atx = scanAtxHeading(rest, indent);
|
|
507
|
+
if (atx !== null) {
|
|
508
|
+
this.closeLeaf();
|
|
509
|
+
this.closeList();
|
|
510
|
+
this.add({ type: 'heading', depth: atx.depth, children: [], raw: atx.text });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (scanThematicBreak(rest, indent)) {
|
|
516
|
+
this.closeLeaf();
|
|
517
|
+
this.closeList();
|
|
518
|
+
this.add(thematicBreak());
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (c === 0x3E /* > */) {
|
|
523
|
+
this.closeLeaf();
|
|
524
|
+
this.closeList();
|
|
525
|
+
const node = blockquote([]);
|
|
526
|
+
this.stack.push({ type: 'blockquote', node });
|
|
527
|
+
rest = rest.slice(scanBlockquote(rest, indent));
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const marker = scanListMarker(rest, indent);
|
|
532
|
+
if (marker !== null
|
|
533
|
+
&& !(para !== null && !this.interruptsParagraph(rest))) {
|
|
534
|
+
this.closeLeaf();
|
|
535
|
+
const top = this.stack[this.stack.length - 1];
|
|
536
|
+
let entry = top !== undefined && top.type === 'list' ? top : null;
|
|
537
|
+
if (entry !== null
|
|
538
|
+
&& (entry.bullet !== marker.bullet || entry.delimiter !== marker.delimiter)) {
|
|
539
|
+
this.closeList();
|
|
540
|
+
entry = null;
|
|
541
|
+
}
|
|
542
|
+
if (entry === null) {
|
|
543
|
+
const node = list(marker.ordered, marker.ordered ? marker.start : null, true, []);
|
|
544
|
+
entry = {
|
|
545
|
+
type: 'list', node,
|
|
546
|
+
bullet: marker.bullet, delimiter: marker.delimiter,
|
|
547
|
+
};
|
|
548
|
+
this.stack.push(entry);
|
|
549
|
+
}
|
|
550
|
+
const item = listItem(null, []);
|
|
551
|
+
this.stack.push({ type: 'listItem', node: item, contentIndent: marker.contentOffset });
|
|
552
|
+
rest = rest.slice(Math.min(marker.contentOffset, rest.length));
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (c === 0x3C /* < */) {
|
|
557
|
+
const kind = scanHtmlBlockStart(rest, indent, para !== null);
|
|
558
|
+
if (kind !== 0) {
|
|
559
|
+
this.closeLeaf();
|
|
560
|
+
this.closeList();
|
|
561
|
+
this.leaf = { kind: 'html', htmlKind: kind, lines: [rest] };
|
|
562
|
+
if (scanHtmlBlockEnd(kind, rest)) this.closeLeaf();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// GFM footnote definition. A block, not a paragraph-leading
|
|
568
|
+
// definition like `[foo]:` — so it interrupts, and it is tried
|
|
569
|
+
// BEFORE the paragraph closes, which is what keeps `[^1]: x` from
|
|
570
|
+
// reaching `extractDefinitions` and becoming a link reference
|
|
571
|
+
// named `^1`.
|
|
572
|
+
if (this.gfm && c === 0x5B /* [ */) {
|
|
573
|
+
const note = scanFootnoteDefinition(rest, indent);
|
|
574
|
+
if (note !== null) {
|
|
575
|
+
this.closeLeaf();
|
|
576
|
+
this.closeList();
|
|
577
|
+
const node = footnoteDefinition(normalizeLabel(note.label), note.label, []);
|
|
578
|
+
if (!this.footnotes.has(node.identifier)) this.footnotes.set(node.identifier, node);
|
|
579
|
+
this.stack.push({
|
|
580
|
+
type: 'footnoteDefinition', node, contentIndent: FOOTNOTE_INDENT,
|
|
581
|
+
});
|
|
582
|
+
rest = rest.slice(Math.min(note.contentOffset, rest.length));
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Plugin block rules, dispatched on the first non-space char.
|
|
588
|
+
const rules = this.tables.blocks.get(c);
|
|
589
|
+
if (rules !== undefined) {
|
|
590
|
+
const trimmed = rest.slice(indent);
|
|
591
|
+
for (let i = 0; i < rules.length; i++) {
|
|
592
|
+
const node = rules[i].start(trimmed, this.ctx);
|
|
593
|
+
if (node !== null && node !== undefined) {
|
|
594
|
+
this.closeLeaf();
|
|
595
|
+
this.closeList();
|
|
596
|
+
this.leaf = { kind: 'plugin', rule: rules[i], node };
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Paragraph text.
|
|
603
|
+
if (para !== null) {
|
|
604
|
+
para.lines.push(rest.slice(indent));
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
this.closeLeaf();
|
|
608
|
+
this.closeList();
|
|
609
|
+
this.leaf = { kind: 'paragraph', lines: [rest.slice(indent)] };
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* A blank line was consumed: remember which block it ended, so the
|
|
617
|
+
* enclosing list can decide its own tightness when it closes.
|
|
618
|
+
*
|
|
619
|
+
* The blank belongs to the innermost open block — the last child of
|
|
620
|
+
* the innermost container, the leaf having just been closed into it.
|
|
621
|
+
* Two exclusions carry the spec's meaning: a blockquote absorbs the
|
|
622
|
+
* blank (`* a\n > b\n >\n* c` is a TIGHT list), and an item that has
|
|
623
|
+
* nothing in it yet is not "ending with a blank line" — its own
|
|
624
|
+
* opening blank must not make its list loose.
|
|
625
|
+
*/
|
|
626
|
+
sawBlank() {
|
|
627
|
+
const top = this.stack[this.stack.length - 1];
|
|
628
|
+
if (top === undefined || top.type === 'blockquote') return;
|
|
629
|
+
const children = top.node.children;
|
|
630
|
+
if (children.length > 0) this.blankEnd.add(children[children.length - 1]);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Does this block end with a blank line? A list or item answers for
|
|
635
|
+
* its last child, which is how a blank at the end of a sublist
|
|
636
|
+
* reaches the item that contains it.
|
|
637
|
+
* @param {MdNode} node
|
|
638
|
+
* @returns {boolean}
|
|
639
|
+
*/
|
|
640
|
+
endsWithBlankLine(node) {
|
|
641
|
+
if (this.blankEnd.has(node)) return true;
|
|
642
|
+
if (node.type !== 'list' && node.type !== 'listItem') return false;
|
|
643
|
+
const children = node.children;
|
|
644
|
+
return children.length > 0 && this.endsWithBlankLine(children[children.length - 1]);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Decide a finished list's tightness (§Lists): loose if a non-final
|
|
649
|
+
* item ends with a blank line, or if an item directly contains two
|
|
650
|
+
* block-level children with a blank line between them.
|
|
651
|
+
* @param {MdNode} node
|
|
652
|
+
*/
|
|
653
|
+
finalizeList(node) {
|
|
654
|
+
const items = node.children;
|
|
655
|
+
for (let i = 0; i < items.length; i++) {
|
|
656
|
+
if (i < items.length - 1 && this.endsWithBlankLine(items[i])) {
|
|
657
|
+
node.tight = false;
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const blocks = items[i].children;
|
|
661
|
+
for (let k = 0; k < blocks.length; k++) {
|
|
662
|
+
if ((i < items.length - 1 || k < blocks.length - 1)
|
|
663
|
+
&& this.endsWithBlankLine(blocks[k])) {
|
|
664
|
+
node.tight = false;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
node.tight = true;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Close a directly enclosing list when non-list content arrives. */
|
|
673
|
+
closeList() {
|
|
674
|
+
const top = this.stack[this.stack.length - 1];
|
|
675
|
+
if (top !== undefined && top.type === 'list') {
|
|
676
|
+
this.closeTo(this.stack.length - 1);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Close the open leaf, appending its finished node.
|
|
682
|
+
*/
|
|
683
|
+
closeLeaf() {
|
|
684
|
+
const leaf = this.leaf;
|
|
685
|
+
if (leaf === null) return;
|
|
686
|
+
this.leaf = null;
|
|
687
|
+
switch (leaf.kind) {
|
|
688
|
+
case 'paragraph': {
|
|
689
|
+
let raw = leaf.lines.join('\n');
|
|
690
|
+
raw = this.extractDefinitions(raw);
|
|
691
|
+
raw = trimEnd(raw);
|
|
692
|
+
if (raw !== '') this.add({ type: 'paragraph', children: [], raw });
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
case 'fence': {
|
|
696
|
+
const { lang, meta } = splitFenceInfo(leaf.info);
|
|
697
|
+
const value = leaf.lines.length === 0 ? '' : leaf.lines.join('\n') + '\n';
|
|
698
|
+
const plugin = lang !== null ? this.tables.fences.get(lang) : undefined;
|
|
699
|
+
if (plugin !== undefined) {
|
|
700
|
+
this.add({ type: plugin.node, value, meta });
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
this.add(code(lang, meta, value));
|
|
704
|
+
}
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
case 'indented': {
|
|
708
|
+
const lines = leaf.lines;
|
|
709
|
+
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
710
|
+
this.add(code(null, null, lines.join('\n') + '\n'));
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
case 'html':
|
|
714
|
+
this.add(htmlBlock(leaf.lines.join('\n')));
|
|
715
|
+
break;
|
|
716
|
+
case 'table':
|
|
717
|
+
this.add({ type: 'table', align: leaf.align, children: [], raw: leaf.rows });
|
|
718
|
+
break;
|
|
719
|
+
case 'plugin':
|
|
720
|
+
if (leaf.rule.close !== undefined) leaf.rule.close(leaf.node, this.ctx);
|
|
721
|
+
this.add(leaf.node);
|
|
722
|
+
break;
|
|
723
|
+
default:
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Strip leading link reference definitions from a closed paragraph's
|
|
730
|
+
* raw text into the definition map.
|
|
731
|
+
* @param {string} raw
|
|
732
|
+
* @returns {string}
|
|
733
|
+
*/
|
|
734
|
+
extractDefinitions(raw) {
|
|
735
|
+
let pos = 0;
|
|
736
|
+
while (pos < raw.length && raw.charCodeAt(pos) === 0x5B /* [ */) {
|
|
737
|
+
const def = scanLinkDefinition(raw, pos);
|
|
738
|
+
if (def === null) break;
|
|
739
|
+
if (!this.defs.has(def.label)) {
|
|
740
|
+
this.defs.set(def.label, { url: def.url, title: def.title });
|
|
741
|
+
}
|
|
742
|
+
pos = Math.min(def.end, raw.length);
|
|
743
|
+
while (pos < raw.length && raw.charCodeAt(pos) === 0x0A) pos++;
|
|
744
|
+
}
|
|
745
|
+
return pos === 0 ? raw : raw.slice(pos);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Close containers down to stack depth `depth` (leaf first).
|
|
750
|
+
* @param {number} depth
|
|
751
|
+
*/
|
|
752
|
+
closeTo(depth) {
|
|
753
|
+
this.closeLeaf();
|
|
754
|
+
while (this.stack.length > depth) {
|
|
755
|
+
const entry = /** @type {any} */ (this.stack.pop());
|
|
756
|
+
if (entry.type === 'list') this.finalizeList(entry.node);
|
|
757
|
+
this.add(entry.node);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Append a finished node to the innermost open container (or the
|
|
763
|
+
* document).
|
|
764
|
+
* @param {MdNode} node
|
|
765
|
+
*/
|
|
766
|
+
add(node) {
|
|
767
|
+
const stack = this.stack;
|
|
768
|
+
const top = stack[stack.length - 1];
|
|
769
|
+
if (top === undefined) {
|
|
770
|
+
this.blocks.push(node);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
top.node.children.push(node);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Close everything (end of input). */
|
|
777
|
+
finish() {
|
|
778
|
+
this.closeTo(0);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Strip leading spaces without a regex (hot path).
|
|
784
|
+
* @param {string} text
|
|
785
|
+
* @returns {string}
|
|
786
|
+
*/
|
|
787
|
+
function stripIndent(text) {
|
|
788
|
+
const indent = countIndent(text);
|
|
789
|
+
return indent === 0 ? text : text.slice(indent);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Is the rest of the line, from `from`, blank? Blankness is relative to
|
|
794
|
+
* what a container has already consumed.
|
|
795
|
+
* @param {string} line @param {number} from
|
|
796
|
+
* @returns {boolean}
|
|
797
|
+
*/
|
|
798
|
+
function isBlankFrom(line, from) {
|
|
799
|
+
for (let i = from; i < line.length; i++) {
|
|
800
|
+
const c = line.charCodeAt(i);
|
|
801
|
+
if (c !== 0x20 && c !== 0x09) return false;
|
|
802
|
+
}
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// ------------------------------------------------------------------
|
|
807
|
+
// Inline parser
|
|
808
|
+
// ------------------------------------------------------------------
|
|
809
|
+
|
|
810
|
+
// eslint-disable-next-line no-control-regex -- the spec excludes all control characters
|
|
811
|
+
const RE_AUTOLINK_URI = /^<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^<>\x00-\x20]*)>/;
|
|
812
|
+
const RE_AUTOLINK_EMAIL = /^<([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)>/;
|
|
813
|
+
const RE_INLINE_HTML = /^<(?:[a-zA-Z][a-zA-Z0-9-]*(?:\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)*\s*\/?>|\/[a-zA-Z][a-zA-Z0-9-]*\s*>|!--->|!-->|!--[\s\S]*?-->|\?[^>]*\?>|![A-Za-z][^>]*>|!\[CDATA\[[\s\S]*?\]\]>)/;
|
|
814
|
+
|
|
815
|
+
/** Characters the inline scanner dispatches on; the rest fast-skip. */
|
|
816
|
+
const INLINE_SPECIAL = new Uint8Array(128);
|
|
817
|
+
for (const ch of '\\`*_~[!]<&\n') INLINE_SPECIAL[ch.charCodeAt(0)] = 1;
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* The inline parsing context threaded through one document.
|
|
822
|
+
* @typedef {{ defs: Map<string, {url: string, title: string|null}>,
|
|
823
|
+
* footnotes?: Map<string, MdNode>,
|
|
824
|
+
* inlines: Map<number, any[]>, gfm: boolean, ctx: any,
|
|
825
|
+
* unresolved?: boolean, deferred?: {node: any, raw: string}[] }} InlineCtx
|
|
826
|
+
*/
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Parse inline Markdown text into inline AST nodes.
|
|
830
|
+
* @param {string} src
|
|
831
|
+
* @param {InlineCtx} ictx
|
|
832
|
+
* @returns {MdNode[]}
|
|
833
|
+
*/
|
|
834
|
+
export function parseInlines(src, ictx) {
|
|
835
|
+
/** @type {MdNode[]} */
|
|
836
|
+
const nodes = [];
|
|
837
|
+
/** Emphasis delimiter entries: { node, marker, canOpen, canClose }. */
|
|
838
|
+
/** @type {any[]} */
|
|
839
|
+
const delims = [];
|
|
840
|
+
/** Bracket entries: { index, delimIndex, image, active }. */
|
|
841
|
+
/** @type {any[]} */
|
|
842
|
+
const brackets = [];
|
|
843
|
+
let pos = 0;
|
|
844
|
+
let textStart = 0;
|
|
845
|
+
const hasPluginInlines = ictx.inlines.size > 0;
|
|
846
|
+
|
|
847
|
+
/** @param {number} end */
|
|
848
|
+
const flush = (end) => {
|
|
849
|
+
if (end > textStart) nodes.push(text(src.slice(textStart, end)));
|
|
850
|
+
if (end > textStart) textStart = end;
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
while (pos < src.length) {
|
|
854
|
+
const c = src.charCodeAt(pos);
|
|
855
|
+
|
|
856
|
+
// Plain text fast path: skip to the next dispatchable character.
|
|
857
|
+
if (c < 128 && INLINE_SPECIAL[c] === 0 && (!hasPluginInlines || !ictx.inlines.has(c))) {
|
|
858
|
+
pos++;
|
|
859
|
+
while (pos < src.length) {
|
|
860
|
+
const d = src.charCodeAt(pos);
|
|
861
|
+
if (d < 128 && INLINE_SPECIAL[d] === 1) break;
|
|
862
|
+
if (hasPluginInlines && ictx.inlines.has(d)) break;
|
|
863
|
+
pos++;
|
|
864
|
+
}
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (c >= 128 && !(hasPluginInlines && ictx.inlines.has(c))) {
|
|
868
|
+
pos++;
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Plugin inline rules first — their char, their call.
|
|
873
|
+
const rules = hasPluginInlines ? ictx.inlines.get(c) : undefined;
|
|
874
|
+
if (rules !== undefined) {
|
|
875
|
+
let claimed = false;
|
|
876
|
+
for (let i = 0; i < rules.length; i++) {
|
|
877
|
+
const hit = rules[i].scan(src, pos, ictx.ctx);
|
|
878
|
+
if (hit !== null && hit !== undefined) {
|
|
879
|
+
flush(pos);
|
|
880
|
+
nodes.push(hit.node);
|
|
881
|
+
pos = hit.end;
|
|
882
|
+
textStart = pos;
|
|
883
|
+
claimed = true;
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (claimed) continue;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
switch (c) {
|
|
891
|
+
case 0x5C /* \ */: {
|
|
892
|
+
const next = src.charCodeAt(pos + 1);
|
|
893
|
+
if (next === 0x0A) {
|
|
894
|
+
flush(pos);
|
|
895
|
+
nodes.push(hardBreak());
|
|
896
|
+
pos += 2;
|
|
897
|
+
textStart = pos;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (next < 128 && ASCII_PUNCT[next] === 1) {
|
|
901
|
+
flush(pos);
|
|
902
|
+
nodes.push(text(src[pos + 1]));
|
|
903
|
+
pos += 2;
|
|
904
|
+
textStart = pos;
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
pos++;
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
case 0x60 /* ` */: {
|
|
911
|
+
let run = pos;
|
|
912
|
+
while (run < src.length && src.charCodeAt(run) === 0x60) run++;
|
|
913
|
+
const n = run - pos;
|
|
914
|
+
let close = run;
|
|
915
|
+
let closeEnd = -1;
|
|
916
|
+
while (close < src.length) {
|
|
917
|
+
if (src.charCodeAt(close) === 0x60) {
|
|
918
|
+
let e = close;
|
|
919
|
+
while (e < src.length && src.charCodeAt(e) === 0x60) e++;
|
|
920
|
+
if (e - close === n) { closeEnd = e; break; }
|
|
921
|
+
close = e;
|
|
922
|
+
}
|
|
923
|
+
else close++;
|
|
924
|
+
}
|
|
925
|
+
if (closeEnd === -1) { pos = run; continue; }
|
|
926
|
+
flush(pos);
|
|
927
|
+
let content = src.slice(run, close).replace(/\n/g, ' ');
|
|
928
|
+
if (content.length > 2
|
|
929
|
+
&& content.charCodeAt(0) === 0x20
|
|
930
|
+
&& content.charCodeAt(content.length - 1) === 0x20
|
|
931
|
+
&& content.trim() !== '') {
|
|
932
|
+
content = content.slice(1, -1);
|
|
933
|
+
}
|
|
934
|
+
nodes.push(inlineCode(content));
|
|
935
|
+
pos = closeEnd;
|
|
936
|
+
textStart = pos;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
case 0x2A /* * */:
|
|
940
|
+
case 0x5F /* _ */:
|
|
941
|
+
case 0x7E /* ~ */: {
|
|
942
|
+
if (c === 0x7E && !ictx.gfm) { pos++; continue; }
|
|
943
|
+
let run = pos;
|
|
944
|
+
while (run < src.length && src.charCodeAt(run) === c) run++;
|
|
945
|
+
const count = run - pos;
|
|
946
|
+
if (c === 0x7E && count !== 2) { pos = run; continue; }
|
|
947
|
+
const before = pos === 0 ? 0x0A : codePointBefore(src, pos);
|
|
948
|
+
const after = run >= src.length ? 0x0A : /** @type {number} */ (src.codePointAt(run));
|
|
949
|
+
const wsBefore = isUnicodeWhitespace(before);
|
|
950
|
+
const wsAfter = isUnicodeWhitespace(after);
|
|
951
|
+
const punctBefore = isUnicodePunctuation(before);
|
|
952
|
+
const punctAfter = isUnicodePunctuation(after);
|
|
953
|
+
const leftFlank = !wsAfter && (!punctAfter || wsBefore || punctBefore);
|
|
954
|
+
const rightFlank = !wsBefore && (!punctBefore || wsAfter || punctAfter);
|
|
955
|
+
let canOpen = leftFlank;
|
|
956
|
+
let canClose = rightFlank;
|
|
957
|
+
if (c === 0x5F /* _ */) {
|
|
958
|
+
canOpen = leftFlank && (!rightFlank || punctBefore);
|
|
959
|
+
canClose = rightFlank && (!leftFlank || punctAfter);
|
|
960
|
+
}
|
|
961
|
+
flush(pos);
|
|
962
|
+
const node = text(src.slice(pos, run));
|
|
963
|
+
nodes.push(node);
|
|
964
|
+
if (canOpen || canClose) {
|
|
965
|
+
delims.push({ node, marker: c, canOpen, canClose });
|
|
966
|
+
}
|
|
967
|
+
pos = run;
|
|
968
|
+
textStart = pos;
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
case 0x5B /* [ */: {
|
|
972
|
+
// GFM footnote reference. A defined `[^x]` is a citation; an
|
|
973
|
+
// undefined one is not a bracket to close either, so it falls
|
|
974
|
+
// through and stays literal text.
|
|
975
|
+
if (ictx.gfm && src.charCodeAt(pos + 1) === 0x5E /* ^ */) {
|
|
976
|
+
const ref = scanFootnoteReference(src, pos);
|
|
977
|
+
if (ref !== null) {
|
|
978
|
+
const identifier = normalizeLabel(ref.label);
|
|
979
|
+
if (ictx.footnotes !== undefined && ictx.footnotes.has(identifier)) {
|
|
980
|
+
flush(pos);
|
|
981
|
+
nodes.push(footnoteReference(identifier, ref.label));
|
|
982
|
+
pos = ref.end;
|
|
983
|
+
textStart = pos;
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
// a definition may still arrive further down a stream
|
|
987
|
+
ictx.unresolved = true;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
flush(pos);
|
|
991
|
+
const node = text('[');
|
|
992
|
+
nodes.push(node);
|
|
993
|
+
brackets.push({ index: nodes.length - 1, delims: delims.length, image: false, active: true, start: pos + 1 });
|
|
994
|
+
pos++;
|
|
995
|
+
textStart = pos;
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
case 0x21 /* ! */: {
|
|
999
|
+
if (src.charCodeAt(pos + 1) === 0x5B) {
|
|
1000
|
+
flush(pos);
|
|
1001
|
+
nodes.push(text('!['));
|
|
1002
|
+
brackets.push({ index: nodes.length - 1, delims: delims.length, image: true, active: true, start: pos + 2 });
|
|
1003
|
+
pos += 2;
|
|
1004
|
+
textStart = pos;
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
pos++;
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
case 0x5D /* ] */: {
|
|
1011
|
+
const closed = closeBracket(src, pos, nodes, delims, brackets, ictx, flush);
|
|
1012
|
+
if (closed !== -1) { pos = closed; textStart = pos; continue; }
|
|
1013
|
+
pos++;
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
case 0x3C /* < */: {
|
|
1017
|
+
const rest = pos === 0 ? src : src.slice(pos);
|
|
1018
|
+
let m = RE_AUTOLINK_URI.exec(rest);
|
|
1019
|
+
if (m !== null) {
|
|
1020
|
+
flush(pos);
|
|
1021
|
+
nodes.push(link(m[1], null, [text(m[1])]));
|
|
1022
|
+
pos += m[0].length;
|
|
1023
|
+
textStart = pos;
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
m = RE_AUTOLINK_EMAIL.exec(rest);
|
|
1027
|
+
if (m !== null) {
|
|
1028
|
+
flush(pos);
|
|
1029
|
+
nodes.push(link('mailto:' + m[1], null, [text(m[1])]));
|
|
1030
|
+
pos += m[0].length;
|
|
1031
|
+
textStart = pos;
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
m = RE_INLINE_HTML.exec(rest);
|
|
1035
|
+
if (m !== null) {
|
|
1036
|
+
flush(pos);
|
|
1037
|
+
nodes.push({ type: 'html', value: m[0] });
|
|
1038
|
+
pos += m[0].length;
|
|
1039
|
+
textStart = pos;
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
pos++;
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
case 0x26 /* & */: {
|
|
1046
|
+
const entity = scanEntity(src, pos);
|
|
1047
|
+
if (entity !== null) {
|
|
1048
|
+
flush(pos);
|
|
1049
|
+
nodes.push(text(entity.value));
|
|
1050
|
+
pos = entity.end;
|
|
1051
|
+
textStart = pos;
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
pos++;
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
case 0x0A /* \n */: {
|
|
1058
|
+
let end = pos;
|
|
1059
|
+
while (end > textStart && src.charCodeAt(end - 1) === 0x20) end--;
|
|
1060
|
+
const hard = pos - end >= 2;
|
|
1061
|
+
flush(end);
|
|
1062
|
+
nodes.push(hard ? hardBreak() : softBreak());
|
|
1063
|
+
pos++;
|
|
1064
|
+
while (pos < src.length && src.charCodeAt(pos) === 0x20) pos++;
|
|
1065
|
+
textStart = pos;
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
default:
|
|
1069
|
+
pos++;
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
flush(src.length);
|
|
1074
|
+
resolveEmphasis(nodes, delims, 0);
|
|
1075
|
+
const out = mergeText(nodes);
|
|
1076
|
+
return ictx.gfm && mayAutolink(src) ? linkifyLiterals(out) : out;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Could this leaf hold a literal autolink at all?
|
|
1081
|
+
*
|
|
1082
|
+
* Every match contains `@`, `www.` or `://`, and a character reference
|
|
1083
|
+
* could spell any of them, so a leaf with none of the four cannot
|
|
1084
|
+
* produce one — and skipping the pass skips the WALK, which is where its
|
|
1085
|
+
* cost turned out to be rather than in the scan. Four vectorized
|
|
1086
|
+
* substring searches per leaf replace a recursive visit of every inline
|
|
1087
|
+
* node in it.
|
|
1088
|
+
* @param {string} src
|
|
1089
|
+
* @returns {boolean}
|
|
1090
|
+
*/
|
|
1091
|
+
function mayAutolink(src) {
|
|
1092
|
+
return src.indexOf('@') !== -1 || src.indexOf('www.') !== -1
|
|
1093
|
+
|| src.indexOf('://') !== -1 || src.indexOf('&') !== -1;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Turn bare URLs and email addresses in an inline run into links (GFM
|
|
1098
|
+
* §Autolinks).
|
|
1099
|
+
*
|
|
1100
|
+
* This runs AFTER the inline phase rather than inside its dispatch
|
|
1101
|
+
* switch, which is the reference implementation's shape and the right
|
|
1102
|
+
* one for three reasons: the trigger characters (`w`, `h`, `f`, `@`) are
|
|
1103
|
+
* ordinary letters, and putting them in the hot table would break the
|
|
1104
|
+
* plain-text fast path on roughly every tenth character of English
|
|
1105
|
+
* prose; an email address begins to the LEFT of its trigger, which a
|
|
1106
|
+
* forward scanner cannot see; and the entity rule (`©`) is only
|
|
1107
|
+
* meaningful once references have been resolved, because a real entity
|
|
1108
|
+
* is no longer spelled `&…;` by the time we look.
|
|
1109
|
+
*
|
|
1110
|
+
* Link subtrees are skipped — links do not nest — and only `text` nodes
|
|
1111
|
+
* are examined, so code spans and raw HTML are untouched by
|
|
1112
|
+
* construction.
|
|
1113
|
+
* @param {MdNode[]} nodes
|
|
1114
|
+
* @returns {MdNode[]}
|
|
1115
|
+
*/
|
|
1116
|
+
function linkifyLiterals(nodes) {
|
|
1117
|
+
/** @type {MdNode[] | null} */
|
|
1118
|
+
let out = null;
|
|
1119
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1120
|
+
const node = nodes[i];
|
|
1121
|
+
if (node.type === 'text') {
|
|
1122
|
+
const hits = scanAutolinkLiterals(node.value);
|
|
1123
|
+
if (hits === null) {
|
|
1124
|
+
if (out !== null) out.push(node);
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
if (out === null) out = nodes.slice(0, i);
|
|
1128
|
+
const value = node.value;
|
|
1129
|
+
let at = 0;
|
|
1130
|
+
for (let k = 0; k < hits.length; k++) {
|
|
1131
|
+
const hit = hits[k];
|
|
1132
|
+
if (hit.start > at) out.push(text(value.slice(at, hit.start)));
|
|
1133
|
+
out.push(autolink(hit.url, value.slice(hit.start, hit.end)));
|
|
1134
|
+
at = hit.end;
|
|
1135
|
+
}
|
|
1136
|
+
if (at < value.length) out.push(text(value.slice(at)));
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
if (node.type !== 'link' && Array.isArray(node.children)) {
|
|
1140
|
+
node.children = linkifyLiterals(node.children);
|
|
1141
|
+
}
|
|
1142
|
+
if (out !== null) out.push(node);
|
|
1143
|
+
}
|
|
1144
|
+
return out === null ? nodes : out;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Try to close the most recent active bracket at `]` (position `pos`).
|
|
1149
|
+
* Returns the position after the whole link/image, or -1.
|
|
1150
|
+
* @param {string} src
|
|
1151
|
+
* @param {number} pos
|
|
1152
|
+
* @param {MdNode[]} nodes
|
|
1153
|
+
* @param {any[]} delims
|
|
1154
|
+
* @param {any[]} brackets
|
|
1155
|
+
* @param {InlineCtx} ictx
|
|
1156
|
+
* @param {(end: number) => void} flush
|
|
1157
|
+
* @returns {number}
|
|
1158
|
+
*/
|
|
1159
|
+
function closeBracket(src, pos, nodes, delims, brackets, ictx, flush) {
|
|
1160
|
+
// Only the MOST RECENT opener may close here. An inactive one (a link
|
|
1161
|
+
// opener deactivated because links do not nest) is discarded and the
|
|
1162
|
+
// `]` stays literal — walking outward to an older active opener would
|
|
1163
|
+
// let `](u2)](u3)` close the image on the wrong bracket.
|
|
1164
|
+
const opener = brackets[brackets.length - 1];
|
|
1165
|
+
if (opener === undefined) return -1;
|
|
1166
|
+
if (!opener.active) {
|
|
1167
|
+
brackets.pop();
|
|
1168
|
+
return -1;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
let url = null;
|
|
1172
|
+
let title = null;
|
|
1173
|
+
let end = -1;
|
|
1174
|
+
|
|
1175
|
+
if (src.charCodeAt(pos + 1) === 0x28 /* ( */) {
|
|
1176
|
+
// Inline form:](dest "title")
|
|
1177
|
+
let i = pos + 2;
|
|
1178
|
+
while (i < src.length && (isSpaceCode(src.charCodeAt(i)) || src.charCodeAt(i) === 0x0A)) i++;
|
|
1179
|
+
const dest = src.charCodeAt(i) === 0x29
|
|
1180
|
+
? { url: '', end: i }
|
|
1181
|
+
: scanLinkDestination(src, i);
|
|
1182
|
+
if (dest !== null) {
|
|
1183
|
+
i = dest.end;
|
|
1184
|
+
while (i < src.length && (isSpaceCode(src.charCodeAt(i)) || src.charCodeAt(i) === 0x0A)) i++;
|
|
1185
|
+
const t = scanLinkTitle(src, i);
|
|
1186
|
+
if (t !== null) {
|
|
1187
|
+
i = t.end;
|
|
1188
|
+
while (i < src.length && (isSpaceCode(src.charCodeAt(i)) || src.charCodeAt(i) === 0x0A)) i++;
|
|
1189
|
+
}
|
|
1190
|
+
if (src.charCodeAt(i) === 0x29 /* ) */) {
|
|
1191
|
+
url = dest.url;
|
|
1192
|
+
title = t !== null ? t.title : null;
|
|
1193
|
+
end = i + 1;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (end === -1) {
|
|
1199
|
+
// Reference forms: ][label], ][] and shortcut ].
|
|
1200
|
+
let label = null;
|
|
1201
|
+
if (src.charCodeAt(pos + 1) === 0x5B /* [ */) {
|
|
1202
|
+
const close = src.indexOf(']', pos + 2);
|
|
1203
|
+
if (close !== -1 && close - pos - 2 <= 999) {
|
|
1204
|
+
const explicit = src.slice(pos + 2, close);
|
|
1205
|
+
label = explicit === '' ? null : explicit;
|
|
1206
|
+
if (label !== null || close === pos + 2) {
|
|
1207
|
+
end = close + 1;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
if (label === null) {
|
|
1212
|
+
// Collapsed / shortcut: the label is the bracketed text, taken
|
|
1213
|
+
// from the SOURCE — a definition matches on what the author
|
|
1214
|
+
// wrote, so `[foo\!]` and `[foo!]` are different labels even
|
|
1215
|
+
// though they render the same.
|
|
1216
|
+
flush(pos);
|
|
1217
|
+
label = src.slice(opener.start, pos);
|
|
1218
|
+
if (end === -1) end = pos + 1;
|
|
1219
|
+
}
|
|
1220
|
+
const def = ictx.defs.get(normalizeLabel(label));
|
|
1221
|
+
if (def === undefined) {
|
|
1222
|
+
// A definition may still arrive: the incremental parser notes the
|
|
1223
|
+
// miss so it can re-resolve this block once the stream ends.
|
|
1224
|
+
ictx.unresolved = true;
|
|
1225
|
+
brackets.pop();
|
|
1226
|
+
return -1;
|
|
1227
|
+
}
|
|
1228
|
+
url = def.url;
|
|
1229
|
+
title = def.title;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
flush(pos);
|
|
1233
|
+
const children = nodes.splice(opener.index + 1);
|
|
1234
|
+
nodes.pop(); // the `[` / `![` marker text node
|
|
1235
|
+
// Delimiters inside the label resolve within the children scope.
|
|
1236
|
+
const innerDelims = delims.splice(opener.delims);
|
|
1237
|
+
resolveEmphasis(children, innerDelims, 0);
|
|
1238
|
+
const node = opener.image
|
|
1239
|
+
? image(/** @type {string} */ (url), title, textOf(children))
|
|
1240
|
+
: link(/** @type {string} */ (url), title, mergeText(children));
|
|
1241
|
+
nodes.push(node);
|
|
1242
|
+
brackets.pop();
|
|
1243
|
+
if (!opener.image) {
|
|
1244
|
+
// Links do not nest: deactivate earlier link openers.
|
|
1245
|
+
for (let i = 0; i < brackets.length; i++) {
|
|
1246
|
+
if (!brackets[i].image) brackets[i].active = false;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return end;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Resolve emphasis/strong/strikethrough delimiters over a node list
|
|
1254
|
+
* (the classic delimiter-stack pairing, `*`/`_` with the rule of
|
|
1255
|
+
* three, `~~` for strikethrough).
|
|
1256
|
+
* @param {MdNode[]} nodes
|
|
1257
|
+
* @param {any[]} delims
|
|
1258
|
+
* @param {number} floor
|
|
1259
|
+
*/
|
|
1260
|
+
function resolveEmphasis(nodes, delims, floor) {
|
|
1261
|
+
let ci = floor;
|
|
1262
|
+
while (ci < delims.length) {
|
|
1263
|
+
const closer = delims[ci];
|
|
1264
|
+
if (!closer.canClose || closer.node.value.length === 0) {
|
|
1265
|
+
ci++;
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
let oi = ci - 1;
|
|
1269
|
+
while (oi >= floor) {
|
|
1270
|
+
const opener = delims[oi];
|
|
1271
|
+
if (opener.canOpen && opener.marker === closer.marker
|
|
1272
|
+
&& opener.node.value.length > 0) {
|
|
1273
|
+
// Rule of three (only for * and _).
|
|
1274
|
+
if (closer.marker !== 0x7E
|
|
1275
|
+
&& (opener.canClose || closer.canOpen)
|
|
1276
|
+
&& (opener.node.value.length + closer.node.value.length) % 3 === 0
|
|
1277
|
+
&& (opener.node.value.length % 3 !== 0 || closer.node.value.length % 3 !== 0)) {
|
|
1278
|
+
oi--;
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
break;
|
|
1282
|
+
}
|
|
1283
|
+
oi--;
|
|
1284
|
+
}
|
|
1285
|
+
if (oi < floor) {
|
|
1286
|
+
ci++;
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
const opener = delims[oi];
|
|
1290
|
+
const use = closer.marker === 0x7E
|
|
1291
|
+
? 2
|
|
1292
|
+
: opener.node.value.length >= 2 && closer.node.value.length >= 2 ? 2 : 1;
|
|
1293
|
+
const openIdx = nodes.indexOf(opener.node);
|
|
1294
|
+
const closeIdx = nodes.indexOf(closer.node);
|
|
1295
|
+
if (openIdx === -1 || closeIdx === -1 || closeIdx <= openIdx) {
|
|
1296
|
+
ci++;
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const children = mergeText(nodes.slice(openIdx + 1, closeIdx));
|
|
1300
|
+
const wrapper = closer.marker === 0x7E
|
|
1301
|
+
? strikethrough(children)
|
|
1302
|
+
: use === 2 ? strong(children) : emphasis(children);
|
|
1303
|
+
opener.node.value = opener.node.value.slice(0, -use);
|
|
1304
|
+
closer.node.value = closer.node.value.slice(use);
|
|
1305
|
+
if (opener.node.value.length === 0) {
|
|
1306
|
+
nodes.splice(openIdx, closeIdx - openIdx + 1, wrapper);
|
|
1307
|
+
}
|
|
1308
|
+
else {
|
|
1309
|
+
nodes.splice(openIdx + 1, closeIdx - openIdx, wrapper);
|
|
1310
|
+
}
|
|
1311
|
+
if (closer.node.value.length > 0) {
|
|
1312
|
+
nodes.splice(nodes.indexOf(wrapper) + 1, 0, closer.node);
|
|
1313
|
+
}
|
|
1314
|
+
// Delimiters strictly between the pair can never match outward;
|
|
1315
|
+
// spent delimiters leave the list. The closer (when it still has
|
|
1316
|
+
// characters) is retried from its new position.
|
|
1317
|
+
delims.splice(oi + 1, ci - oi - 1);
|
|
1318
|
+
if (closer.node.value.length === 0) {
|
|
1319
|
+
const at = delims.indexOf(closer);
|
|
1320
|
+
if (at !== -1) delims.splice(at, 1);
|
|
1321
|
+
}
|
|
1322
|
+
if (opener.node.value.length === 0) {
|
|
1323
|
+
delims.splice(oi, 1);
|
|
1324
|
+
}
|
|
1325
|
+
const next = delims.indexOf(closer);
|
|
1326
|
+
ci = next === -1 ? oi : next;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Merge adjacent text nodes and drop empties (post-emphasis cleanup).
|
|
1332
|
+
* @param {MdNode[]} nodes
|
|
1333
|
+
* @returns {MdNode[]}
|
|
1334
|
+
*/
|
|
1335
|
+
function mergeText(nodes) {
|
|
1336
|
+
/** @type {MdNode[]} */
|
|
1337
|
+
const out = [];
|
|
1338
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1339
|
+
const node = nodes[i];
|
|
1340
|
+
if (node.type === 'text') {
|
|
1341
|
+
if (node.value === '') continue;
|
|
1342
|
+
const prev = out[out.length - 1];
|
|
1343
|
+
if (prev !== undefined && prev.type === 'text') {
|
|
1344
|
+
prev.value += node.value;
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
out.push(node);
|
|
1349
|
+
}
|
|
1350
|
+
return out;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// ------------------------------------------------------------------
|
|
1354
|
+
// Finishing: raw block text → inline children
|
|
1355
|
+
// ------------------------------------------------------------------
|
|
1356
|
+
|
|
1357
|
+
const RE_TASK = /^\[([ xX])\] +/;
|
|
1358
|
+
const RE_ESCAPED_PIPE = /\\\|/g;
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Resolve the buffered raw text of finished blocks into inline
|
|
1362
|
+
* children (paragraphs, headings, table cells, task-list markers).
|
|
1363
|
+
* Runs once per block, after which the `raw` buffers are gone.
|
|
1364
|
+
* @param {MdNode[]} blocks
|
|
1365
|
+
* @param {InlineCtx} ictx
|
|
1366
|
+
*/
|
|
1367
|
+
export function finishBlocks(blocks, ictx) {
|
|
1368
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
1369
|
+
const node = blocks[i];
|
|
1370
|
+
switch (node.type) {
|
|
1371
|
+
case 'paragraph':
|
|
1372
|
+
case 'heading':
|
|
1373
|
+
if (node.raw !== undefined) {
|
|
1374
|
+
const raw = node.raw;
|
|
1375
|
+
ictx.unresolved = false;
|
|
1376
|
+
node.children = parseInlines(raw, ictx);
|
|
1377
|
+
delete node.raw;
|
|
1378
|
+
if (ictx.unresolved === true && ictx.deferred !== undefined)
|
|
1379
|
+
ictx.deferred.push({ node, raw });
|
|
1380
|
+
}
|
|
1381
|
+
break;
|
|
1382
|
+
case 'table':
|
|
1383
|
+
if (node.raw !== undefined) {
|
|
1384
|
+
finishTable(node, ictx);
|
|
1385
|
+
}
|
|
1386
|
+
break;
|
|
1387
|
+
case 'blockquote':
|
|
1388
|
+
finishBlocks(node.children, ictx);
|
|
1389
|
+
break;
|
|
1390
|
+
case 'list': {
|
|
1391
|
+
const items = node.children;
|
|
1392
|
+
for (let k = 0; k < items.length; k++) {
|
|
1393
|
+
finishListItem(items[k], ictx);
|
|
1394
|
+
}
|
|
1395
|
+
break;
|
|
1396
|
+
}
|
|
1397
|
+
default:
|
|
1398
|
+
if (Array.isArray(node.children)) finishBlocks(node.children, ictx);
|
|
1399
|
+
break;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* @param {MdNode} item
|
|
1406
|
+
* @param {InlineCtx} ictx
|
|
1407
|
+
*/
|
|
1408
|
+
function finishListItem(item, ictx) {
|
|
1409
|
+
const first = item.children[0];
|
|
1410
|
+
if (ictx.gfm && first !== undefined && first.type === 'paragraph'
|
|
1411
|
+
&& first.raw !== undefined) {
|
|
1412
|
+
const m = RE_TASK.exec(first.raw);
|
|
1413
|
+
if (m !== null) {
|
|
1414
|
+
item.checked = m[1] !== ' ';
|
|
1415
|
+
first.raw = first.raw.slice(m[0].length);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
finishBlocks(item.children, ictx);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* @param {MdNode} node
|
|
1423
|
+
* @param {InlineCtx} ictx
|
|
1424
|
+
*/
|
|
1425
|
+
function finishTable(node, ictx) {
|
|
1426
|
+
const rawRows = node.raw;
|
|
1427
|
+
delete node.raw;
|
|
1428
|
+
const width = node.align.length;
|
|
1429
|
+
/** @type {MdNode[]} */
|
|
1430
|
+
const rows = [];
|
|
1431
|
+
for (let r = 0; r < rawRows.length; r++) {
|
|
1432
|
+
const cells = splitTableRow(rawRows[r]) ?? [rawRows[r].trim()];
|
|
1433
|
+
/** @type {MdNode[]} */
|
|
1434
|
+
const rowCells = [];
|
|
1435
|
+
for (let ci = 0; ci < width; ci++) {
|
|
1436
|
+
// `\|` puts a pipe in a cell "including inside other inline spans"
|
|
1437
|
+
// (GFM §Tables), so the escape has to be spent before the inline
|
|
1438
|
+
// phase — a code span would otherwise keep the backslash it was
|
|
1439
|
+
// never meant to show. Splitting already spent the ones between
|
|
1440
|
+
// spans; these are the ones it stepped over.
|
|
1441
|
+
const raw = ci < cells.length ? cells[ci].trim().replace(RE_ESCAPED_PIPE, '|') : '';
|
|
1442
|
+
rowCells.push(tableCell(raw === '' ? [] : parseInlines(raw, ictx)));
|
|
1443
|
+
}
|
|
1444
|
+
rows.push(tableRow(rowCells));
|
|
1445
|
+
}
|
|
1446
|
+
node.children = rows;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// ------------------------------------------------------------------
|
|
1450
|
+
// Entry points
|
|
1451
|
+
// ------------------------------------------------------------------
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Parse Markdown source into an MdDocument (frontmatter + AST).
|
|
1455
|
+
*
|
|
1456
|
+
* @example
|
|
1457
|
+
* parseMarkdown('# Hi').ast
|
|
1458
|
+
* // [{ type: 'heading', depth: 1, children: [{ type: 'text', value: 'Hi' }] }]
|
|
1459
|
+
*
|
|
1460
|
+
* @param {string} source
|
|
1461
|
+
* @param {MdParseOptions} [options]
|
|
1462
|
+
* @returns {MdDocument}
|
|
1463
|
+
*/
|
|
1464
|
+
export function parseMarkdown(source, options = {}) {
|
|
1465
|
+
const tables = buildPluginTables(options.plugins);
|
|
1466
|
+
const fm = options.frontmatter !== false
|
|
1467
|
+
? parseFrontmatter(source, options.toml !== undefined ? { toml: options.toml } : undefined)
|
|
1468
|
+
: { data: null, body: source, lang: null };
|
|
1469
|
+
const parser = new BlockParser(options, tables);
|
|
1470
|
+
parser.ctx.frontmatter = fm.data;
|
|
1471
|
+
feedLines(parser, fm.body, true);
|
|
1472
|
+
parser.finish();
|
|
1473
|
+
const ictx = {
|
|
1474
|
+
defs: parser.defs,
|
|
1475
|
+
footnotes: parser.footnotes,
|
|
1476
|
+
inlines: tables.inlines,
|
|
1477
|
+
gfm: parser.gfm,
|
|
1478
|
+
ctx: parser.ctx,
|
|
1479
|
+
};
|
|
1480
|
+
finishBlocks(parser.blocks, ictx);
|
|
1481
|
+
return {
|
|
1482
|
+
$md: MD_VERSION,
|
|
1483
|
+
frontmatter: fm.data,
|
|
1484
|
+
ast: parser.blocks,
|
|
1485
|
+
meta: {
|
|
1486
|
+
sourceUrl: options.sourceUrl ?? null,
|
|
1487
|
+
hash: hashContent(source),
|
|
1488
|
+
frontmatterLang: fm.lang,
|
|
1489
|
+
},
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/**
|
|
1494
|
+
* Feed a text segment to the block parser line by line. When `final`,
|
|
1495
|
+
* the trailing partial line (no newline) is processed too; otherwise
|
|
1496
|
+
* it is returned to buffer.
|
|
1497
|
+
* @param {BlockParser} parser
|
|
1498
|
+
* @param {string} textChunk
|
|
1499
|
+
* @param {boolean} final
|
|
1500
|
+
* @returns {string} the unprocessed tail
|
|
1501
|
+
*/
|
|
1502
|
+
function feedLines(parser, textChunk, final) {
|
|
1503
|
+
let pos = 0;
|
|
1504
|
+
for (;;) {
|
|
1505
|
+
const nl = textChunk.indexOf('\n', pos);
|
|
1506
|
+
if (nl === -1) break;
|
|
1507
|
+
let line = textChunk.slice(pos, nl);
|
|
1508
|
+
if (line.endsWith('\r')) line = line.slice(0, -1);
|
|
1509
|
+
parser.line(expandTabs(line));
|
|
1510
|
+
pos = nl + 1;
|
|
1511
|
+
}
|
|
1512
|
+
const tail = pos === 0 ? textChunk : textChunk.slice(pos);
|
|
1513
|
+
if (final) {
|
|
1514
|
+
if (tail !== '') parser.line(expandTabs(tail.endsWith('\r') ? tail.slice(0, -1) : tail));
|
|
1515
|
+
return '';
|
|
1516
|
+
}
|
|
1517
|
+
return tail;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Drop trailing whitespace. A hand-rolled scan rather than
|
|
1522
|
+
* `replace(/\s+$/, '')`: that pattern re-scans the string from every
|
|
1523
|
+
* position it can start at, and closing a paragraph is one of the
|
|
1524
|
+
* hottest points in the parse.
|
|
1525
|
+
* @param {string} text
|
|
1526
|
+
* @returns {string}
|
|
1527
|
+
*/
|
|
1528
|
+
function trimEnd(text) {
|
|
1529
|
+
let end = text.length;
|
|
1530
|
+
while (end > 0) {
|
|
1531
|
+
const code = text.charCodeAt(end - 1);
|
|
1532
|
+
if (code !== 0x20 && code !== 0x09 && code !== 0x0A && code !== 0x0D
|
|
1533
|
+
&& code !== 0x0B && code !== 0x0C) break;
|
|
1534
|
+
end--;
|
|
1535
|
+
}
|
|
1536
|
+
return end === text.length ? text : text.slice(0, end);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* The incremental parsing core behind `streamMarkdown` (docs/LOADER.md
|
|
1541
|
+
* §4): feed chunks, collect completed top-level blocks per feed, and
|
|
1542
|
+
* flush the tail with `end()`.
|
|
1543
|
+
*
|
|
1544
|
+
* Frontmatter resolves as soon as its closing fence arrives; reference
|
|
1545
|
+
* definitions apply to blocks completed after them (the documented
|
|
1546
|
+
* streaming limitation).
|
|
1547
|
+
*
|
|
1548
|
+
* @param {MdParseOptions} [options]
|
|
1549
|
+
* @returns {{
|
|
1550
|
+
* feed: (chunk: string) => MdNode[],
|
|
1551
|
+
* end: () => MdDocument,
|
|
1552
|
+
* frontmatter: any,
|
|
1553
|
+
* }}
|
|
1554
|
+
*/
|
|
1555
|
+
export function createIncrementalParser(options = {}) {
|
|
1556
|
+
const tables = buildPluginTables(options.plugins);
|
|
1557
|
+
const parser = new BlockParser(options, tables);
|
|
1558
|
+
const detect = options.frontmatter !== false;
|
|
1559
|
+
/**
|
|
1560
|
+
* Blocks emitted with a reference link whose definition had not
|
|
1561
|
+
* arrived YET, kept with the source text they came from. A streamed
|
|
1562
|
+
* document may define `[ref]` after the paragraph that uses it, and a
|
|
1563
|
+
* parser that hands blocks out as they close has already emitted that
|
|
1564
|
+
* paragraph — so those blocks are re-resolved at `end()`, when the
|
|
1565
|
+
* whole document is known, and land exactly where batch parsing puts
|
|
1566
|
+
* them.
|
|
1567
|
+
* @type {{node: any, raw: string}[]}
|
|
1568
|
+
*/
|
|
1569
|
+
const deferred = [];
|
|
1570
|
+
const ictx = {
|
|
1571
|
+
defs: parser.defs,
|
|
1572
|
+
footnotes: parser.footnotes,
|
|
1573
|
+
inlines: tables.inlines,
|
|
1574
|
+
gfm: parser.gfm,
|
|
1575
|
+
ctx: parser.ctx,
|
|
1576
|
+
unresolved: false,
|
|
1577
|
+
deferred,
|
|
1578
|
+
};
|
|
1579
|
+
let buffer = '';
|
|
1580
|
+
let hash = FNV1A_OFFSET_BASIS;
|
|
1581
|
+
/** null = undecided, false = none, otherwise resolved */
|
|
1582
|
+
/** @type {any} */
|
|
1583
|
+
let fmState = detect ? null : false;
|
|
1584
|
+
/** @type {any} */
|
|
1585
|
+
let frontmatter = null;
|
|
1586
|
+
/** @type {'yaml'|'json'|'toml'|null} */
|
|
1587
|
+
let frontmatterLang = null;
|
|
1588
|
+
let emitted = 0;
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* Try to resolve frontmatter from the buffered head. Returns true
|
|
1592
|
+
* once decided (either way).
|
|
1593
|
+
* @param {boolean} final
|
|
1594
|
+
*/
|
|
1595
|
+
const resolveFrontmatter = (final) => {
|
|
1596
|
+
if (fmState !== null) return true;
|
|
1597
|
+
if (buffer.length === 0) return final;
|
|
1598
|
+
const c0 = buffer.charCodeAt(0);
|
|
1599
|
+
if (c0 !== 0x2D && c0 !== 0x2B && c0 !== 0x7B) {
|
|
1600
|
+
fmState = false;
|
|
1601
|
+
return true;
|
|
1602
|
+
}
|
|
1603
|
+
const fm = parseFrontmatter(buffer, options.toml !== undefined ? { toml: options.toml } : undefined);
|
|
1604
|
+
if (fm.lang !== null) {
|
|
1605
|
+
frontmatter = fm.data;
|
|
1606
|
+
frontmatterLang = fm.lang;
|
|
1607
|
+
parser.ctx.frontmatter = fm.data;
|
|
1608
|
+
api.frontmatter = fm.data;
|
|
1609
|
+
buffer = fm.body;
|
|
1610
|
+
fmState = true;
|
|
1611
|
+
return true;
|
|
1612
|
+
}
|
|
1613
|
+
// No closing fence in the buffer yet: wait for more input unless
|
|
1614
|
+
// the stream ended or the head is clearly not frontmatter anymore
|
|
1615
|
+
// (backstop against buffering a whole fence-less document).
|
|
1616
|
+
if (!final && buffer.length <= 65536) return false;
|
|
1617
|
+
fmState = false;
|
|
1618
|
+
return true;
|
|
1619
|
+
};
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Re-parse the blocks that referenced a definition they had not seen.
|
|
1623
|
+
* The nodes are patched IN PLACE: a consumer of `feed()` already holds
|
|
1624
|
+
* them, and handing back a copy would leave that consumer with the
|
|
1625
|
+
* unresolved version forever. Blocks whose reference is still unknown
|
|
1626
|
+
* at this point simply keep their literal text, which is what the
|
|
1627
|
+
* batch parser produces for them too.
|
|
1628
|
+
*/
|
|
1629
|
+
const resolveDeferred = () => {
|
|
1630
|
+
if (deferred.length === 0) return;
|
|
1631
|
+
const pending = deferred.splice(0);
|
|
1632
|
+
for (const { node, raw } of pending) {
|
|
1633
|
+
node.children = parseInlines(raw, ictx);
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
|
|
1637
|
+
/** @returns {MdNode[]} */
|
|
1638
|
+
const drain = () => {
|
|
1639
|
+
const fresh = parser.blocks.slice(emitted);
|
|
1640
|
+
if (fresh.length > 0) {
|
|
1641
|
+
finishBlocks(fresh, ictx);
|
|
1642
|
+
emitted = parser.blocks.length;
|
|
1643
|
+
}
|
|
1644
|
+
return fresh;
|
|
1645
|
+
};
|
|
1646
|
+
|
|
1647
|
+
const api = {
|
|
1648
|
+
frontmatter: /** @type {any} */ (null),
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* @param {string} chunk
|
|
1652
|
+
* @returns {MdNode[]}
|
|
1653
|
+
*/
|
|
1654
|
+
feed(chunk) {
|
|
1655
|
+
// fold the chunk into the running hash: seeding fnv1a with the
|
|
1656
|
+
// accumulator makes the streamed hash equal the whole-source one
|
|
1657
|
+
hash = fnv1a(chunk, hash);
|
|
1658
|
+
buffer += chunk;
|
|
1659
|
+
if (!resolveFrontmatter(false)) return [];
|
|
1660
|
+
buffer = feedLines(parser, buffer, false);
|
|
1661
|
+
return drain();
|
|
1662
|
+
},
|
|
1663
|
+
|
|
1664
|
+
/** @returns {MdDocument} */
|
|
1665
|
+
end() {
|
|
1666
|
+
resolveFrontmatter(true);
|
|
1667
|
+
feedLines(parser, buffer, true);
|
|
1668
|
+
buffer = '';
|
|
1669
|
+
parser.finish();
|
|
1670
|
+
drain();
|
|
1671
|
+
resolveDeferred();
|
|
1672
|
+
return {
|
|
1673
|
+
$md: MD_VERSION,
|
|
1674
|
+
frontmatter,
|
|
1675
|
+
ast: parser.blocks,
|
|
1676
|
+
meta: {
|
|
1677
|
+
sourceUrl: options.sourceUrl ?? null,
|
|
1678
|
+
hash: hash.toString(36),
|
|
1679
|
+
frontmatterLang,
|
|
1680
|
+
},
|
|
1681
|
+
};
|
|
1682
|
+
},
|
|
1683
|
+
};
|
|
1684
|
+
return api;
|
|
1685
|
+
}
|