| 1 | import type { Root, RootContent } from "mdast"; |
| 2 | import type { Parser as UnifiedParser, Plugin } from "unified"; |
| 3 | |
| 4 | type Parser = UnifiedParser<Root>; |
| 5 | |
| 6 | interface ParsedPrefix { |
| 7 | source: string; |
| 8 | offset: number; |
| 9 | line: number; |
| 10 | children: RootContent[]; |
| 11 | } |
| 12 | |
| 13 | function 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 | |
| 21 | function 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 | */ |
| 37 | function 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 | */ |
| 90 | export 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 | } |