1import type { Root, RootContent } from "mdast";
2import type { Parser as UnifiedParser, Plugin } from "unified";
3
4type Parser = UnifiedParser<Root>;
5
6interface ParsedPrefix {
7 source: string;
8 offset: number;
9 line: number;
10 children: RootContent[];
11}
12
13function hasDefinitions(node: Root | RootContent): boolean {
14 return (
15 node.type === "definition" ||
16 node.type === "footnoteDefinition" ||
17 ("children" in node && node.children.some((child) => hasDefinitions(child)))
18 );
19}
20
21function shiftPositions(node: Root | RootContent, offset: number, lines: number): void {
22 if (node.position) {
23 for (const point of [node.position.start, node.position.end]) {
24 if (point.offset !== undefined) point.offset += offset;
25 point.line += lines;
26 }
27 }
28 if ("children" in node) {
29 for (const child of node.children) shiftPositions(child, offset, lines);
30 }
31}
32
33/** Keep the full document pipeline while avoiding parsing a completed code-heavy
34 * prefix on every token. A closed top-level fence followed by a blank line is a
35 * parsing boundary. Definitions are document-wide, so they require a full parse.
36 */
37function createIncrementalMarkdownParser(parse: Parser): Parser {
38 let cached: ParsedPrefix | undefined;
39
40 return (source, file) => {
41 // A streaming CR can become half of a CRLF. A BOM at the suffix boundary
42 // would be stripped by a new parser although it is inside the full document.
43 if (source.includes("\r") || source.includes("\uFEFF")) return parse(source, file);
44
45 const prefix = cached && source.startsWith(cached.source) ? cached : undefined;
46 let root: Root;
47 if (prefix) {
48 root = parse(source.slice(prefix.offset), file);
49 if (hasDefinitions(root)) return parse(source, file);
50 shiftPositions(root, prefix.offset, prefix.line - 1);
51 if (root.position) root.position.start = { line: 1, column: 1, offset: 0 };
52 // Remark transforms mutate their input. The cache owns pristine nodes and
53 // each render receives its own copy, including source positions.
54 root.children.unshift(...structuredClone(prefix.children));
55 } else {
56 root = parse(source, file);
57 if (hasDefinitions(root)) return root;
58 }
59
60 for (let index = root.children.length - 1; index >= 0; index--) {
61 const node = root.children[index];
62 if (node?.type !== "code") continue;
63 const start = node.position?.start.offset;
64 const end = node.position?.end.offset;
65 if (start === undefined || end === undefined) continue;
66 if (prefix && end < prefix.offset) break;
67 const value = source.slice(start, end);
68 const opening = /^ {0,3}(`{3,}|~{3,})[^\n]*\n/.exec(value)?.[1];
69 if (!opening) continue;
70 const lastLine = value.slice(value.lastIndexOf("\n") + 1);
71 const closing = new RegExp(`^ {0,3}${opening[0]}{${opening.length},}[ \\t]*$`);
72 const separator = /^\n[ \t]*\n/.exec(source.slice(end))?.[0];
73 if (!closing.test(lastLine) || !separator) continue;
74 const offset = end + separator.length;
75 cached = {
76 source: source.slice(0, offset),
77 offset,
78 line: node.position!.end.line + 2,
79 children: structuredClone(root.children.slice(0, index + 1)),
80 };
81 break;
82 }
83 return root;
84 };
85}
86
87/** One cache per streaming renderer. Extra syntax plugins must use the normal
88 * parser because their document-wide dependencies are not known here.
89 */
90export function createIncrementalMarkdownPlugin(): Plugin<[], Root> {
91 let parser: Parser | undefined;
92 return function () {
93 const original = this.parser;
94 if (!original) return;
95 parser ??= createIncrementalMarkdownParser((source, file) => original(source, file) as Root);
96 const parseDocument = parser;
97 // ReactMarkdown creates a processor per render. Its first parse is the
98 // document; transforms can then parse synthetic recovery text on that same
99 // processor. Those parses must not read or replace the document's cache.
100 let documentParsed = false;
101 this.parser = (source, file) => {
102 if (documentParsed) return original(source, file);
103 documentParsed = true;
104 return parseDocument(source, file);
105 };
106 };
107}