@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.
Files changed (49) hide show
  1. package/README.md +520 -0
  2. package/dist/types/ast.d.ts +181 -0
  3. package/dist/types/bake.d.ts +61 -0
  4. package/dist/types/compiler.d.ts +141 -0
  5. package/dist/types/component/index.d.ts +101 -0
  6. package/dist/types/directives.d.ts +126 -0
  7. package/dist/types/entities.d.ts +40 -0
  8. package/dist/types/footnotes.d.ts +83 -0
  9. package/dist/types/frontmatter.d.ts +67 -0
  10. package/dist/types/html.d.ts +72 -0
  11. package/dist/types/index.d.ts +30 -0
  12. package/dist/types/loader.d.ts +84 -0
  13. package/dist/types/mdx.d.ts +45 -0
  14. package/dist/types/parser.d.ts +116 -0
  15. package/dist/types/plugins/highlight.d.ts +64 -0
  16. package/dist/types/plugins/index.d.ts +64 -0
  17. package/dist/types/plugins/mermaid.d.ts +12 -0
  18. package/dist/types/scanner.d.ts +240 -0
  19. package/dist/types/to-html.d.ts +104 -0
  20. package/dist/types/to-md.d.ts +23 -0
  21. package/dist/types/to-vnode.d.ts +161 -0
  22. package/dist/types/utils.d.ts +63 -0
  23. package/docs/LOADER.md +92 -0
  24. package/docs/MD-FORMAT.md +502 -0
  25. package/docs/PLUGINS.md +277 -0
  26. package/package.json +80 -0
  27. package/schemas/jaren-md-ast.schema.json +296 -0
  28. package/src/ast.js +346 -0
  29. package/src/bake.js +104 -0
  30. package/src/compiler.js +167 -0
  31. package/src/component/index.js +191 -0
  32. package/src/directives.js +371 -0
  33. package/src/entities.js +107 -0
  34. package/src/footnotes.js +180 -0
  35. package/src/frontmatter.js +947 -0
  36. package/src/html.js +281 -0
  37. package/src/index.js +76 -0
  38. package/src/loader.js +0 -0
  39. package/src/mdx.js +219 -0
  40. package/src/parser.js +1685 -0
  41. package/src/plugins/highlight.js +325 -0
  42. package/src/plugins/index.js +75 -0
  43. package/src/plugins/mermaid.js +14 -0
  44. package/src/scanner.js +832 -0
  45. package/src/to-html.js +425 -0
  46. package/src/to-md.js +396 -0
  47. package/src/to-vnode.js +766 -0
  48. package/src/utils.js +107 -0
  49. 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 (`&copy;`) 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 `![[[a](u1)](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
+ }