Skip to content
Source
Sign inSign up

markdown-incremental.ts

98 lines3.6 KB

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[];
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 );
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 }
32​
33// Cache completed fences followed by blank lines; document-wide definitions require a full parse.
34function createIncrementalMarkdownParser(parse: Parser): Parser {
35 let cached: ParsedPrefix | undefined;
36​
37 return (source, file) => {
38 // Streaming CR may become CRLF; parsing a suffix separately would strip an internal BOM.
39 if (source.includes("\r") || source.includes("\uFEFF")) return parse(source, file);
40​
41 const prefix = cached && source.startsWith(cached.source) ? cached : undefined;
42 let root: Root;
43 if (prefix) {
44 root = parse(source.slice(prefix.offset), file);
45 if (hasDefinitions(root)) return parse(source, file);
46 shiftPositions(root, prefix.offset, prefix.line - 1);
47 if (root.position) root.position.start = { line: 1, column: 1, offset: 0 };
48 // Remark mutates nodes; give each render a clone of the pristine cache.
49 root.children.unshift(...structuredClone(prefix.children));
50 } else {
51 root = parse(source, file);
52 if (hasDefinitions(root)) return root;
53 }
54​
55 for (let index = root.children.length - 1; index >= 0; index--) {
56 const node = root.children[index];
57 if (node?.type !== "code") continue;
58 const start = node.position?.start.offset;
59 const end = node.position?.end.offset;
60 if (start === undefined || end === undefined) continue;
61 if (prefix && end < prefix.offset) break;
62 const value = source.slice(start, end);
63 const opening = /^ {0,3}(`{3,}|~{3,})[^\n]*\n/.exec(value)?.[1];
64 if (!opening) continue;
65 const lastLine = value.slice(value.lastIndexOf("\n") + 1);
66 const closing = new RegExp(`^ {0,3}${opening[0]}{${opening.length},}[ \\t]*$`);
67 const separator = /^\n[ \t]*\n/.exec(source.slice(end))?.[0];
68 if (!closing.test(lastLine) || !separator) continue;
69 const offset = end + separator.length;
70 cached = {
71 source: source.slice(0, offset),
72 offset,
73 line: node.position!.end.line + 2,
74 children: structuredClone(root.children.slice(0, index + 1)),
75 };
76 break;
77 }
78 return root;
79 };
81​
82/** Use one cache per streaming renderer; extra syntax plugins require the normal parser. */
83export function createIncrementalMarkdownPlugin(): Plugin<[], Root> {
84 let parser: Parser | undefined;
85 return function () {
86 const original = this.parser;
87 if (!original) return;
88 parser ??= createIncrementalMarkdownParser((source, file) => original(source, file) as Root);
89 const parseDocument = parser;
90 // ReactMarkdown parses recovery text second; only the first parse may access the document cache.
91 let documentParsed = false;
92 this.parser = (source, file) => {
93 if (documentParsed) return original(source, file);
94 documentParsed = true;
95 return parseDocument(source, file);
96 };
97 };