import type { Root, RootContent } from "mdast";import type { Parser as UnifiedParser, Plugin } from "unified";type Parser = UnifiedParser<Root>;interface ParsedPrefix { source: string; offset: number; line: number; children: RootContent[];}function hasDefinitions(node: Root | RootContent): boolean { return ( node.type === "definition" || node.type === "footnoteDefinition" || ("children" in node && node.children.some((child) => hasDefinitions(child))) );}function shiftPositions(node: Root | RootContent, offset: number, lines: number): void { if (node.position) { for (const point of [node.position.start, node.position.end]) { if (point.offset !== undefined) point.offset += offset; point.line += lines; } } if ("children" in node) { for (const child of node.children) shiftPositions(child, offset, lines); }}/** Keep the full document pipeline while avoiding parsing a completed code-heavy * prefix on every token. A closed top-level fence followed by a blank line is a * parsing boundary. Definitions are document-wide, so they require a full parse. */function createIncrementalMarkdownParser(parse: Parser): Parser { let cached: ParsedPrefix | undefined; return (source, file) => { // A streaming CR can become half of a CRLF. A BOM at the suffix boundary // would be stripped by a new parser although it is inside the full document. if (source.includes("\r") || source.includes("\uFEFF")) return parse(source, file); const prefix = cached && source.startsWith(cached.source) ? cached : undefined; let root: Root; if (prefix) { root = parse(source.slice(prefix.offset), file); if (hasDefinitions(root)) return parse(source, file); shiftPositions(root, prefix.offset, prefix.line - 1); if (root.position) root.position.start = { line: 1, column: 1, offset: 0 }; // Remark transforms mutate their input. The cache owns pristine nodes and // each render receives its own copy, including source positions. root.children.unshift(...structuredClone(prefix.children)); } else { root = parse(source, file); if (hasDefinitions(root)) return root; } for (let index = root.children.length - 1; index >= 0; index--) { const node = root.children[index]; if (node?.type !== "code") continue; const start = node.position?.start.offset; const end = node.position?.end.offset; if (start === undefined || end === undefined) continue; if (prefix && end < prefix.offset) break; const value = source.slice(start, end); const opening = /^ {0,3}(`{3,}|~{3,})[^\n]*\n/.exec(value)?.[1]; if (!opening) continue; const lastLine = value.slice(value.lastIndexOf("\n") + 1); const closing = new RegExp(`^ {0,3}${opening[0]}{${opening.length},}[ \\t]*$`); const separator = /^\n[ \t]*\n/.exec(source.slice(end))?.[0]; if (!closing.test(lastLine) || !separator) continue; const offset = end + separator.length; cached = { source: source.slice(0, offset), offset, line: node.position!.end.line + 2, children: structuredClone(root.children.slice(0, index + 1)), }; break; } return root; };}/** One cache per streaming renderer. Extra syntax plugins must use the normal * parser because their document-wide dependencies are not known here. */export function createIncrementalMarkdownPlugin(): Plugin<[], Root> { let parser: Parser | undefined; return function () { const original = this.parser; if (!original) return; parser ??= createIncrementalMarkdownParser((source, file) => original(source, file) as Root); const parseDocument = parser; // ReactMarkdown creates a processor per render. Its first parse is the // document; transforms can then parse synthetic recovery text on that same // processor. Those parses must not read or replace the document's cache. let documentParsed = false; this.parser = (source, file) => { if (documentParsed) return original(source, file); documentParsed = true; return parseDocument(source, file); }; };}
import type { Root, RootContent } from "mdast";
import type { Parser as UnifiedParser, Plugin } from "unified";
type Parser = UnifiedParser<Root>;
interface ParsedPrefix {
source: string;
offset: number;
line: number;
children: RootContent[];
}
function hasDefinitions(node: Root | RootContent): boolean {
return (
node.type === "definition" ||
node.type === "footnoteDefinition" ||
("children" in node && node.children.some((child) => hasDefinitions(child)))
);
}
function shiftPositions(node: Root | RootContent, offset: number, lines: number): void {
if (node.position) {
for (const point of [node.position.start, node.position.end]) {
if (point.offset !== undefined) point.offset += offset;
point.line += lines;
}
}
if ("children" in node) {
for (const child of node.children) shiftPositions(child, offset, lines);
}
}
/** Keep the full document pipeline while avoiding parsing a completed code-heavy
* prefix on every token. A closed top-level fence followed by a blank line is a
* parsing boundary. Definitions are document-wide, so they require a full parse.
*/
function createIncrementalMarkdownParser(parse: Parser): Parser {
let cached: ParsedPrefix | undefined;
return (source, file) => {
// A streaming CR can become half of a CRLF. A BOM at the suffix boundary
// would be stripped by a new parser although it is inside the full document.
if (source.includes("\r") || source.includes("\uFEFF")) return parse(source, file);
const prefix = cached && source.startsWith(cached.source) ? cached : undefined;
let root: Root;
if (prefix) {
root = parse(source.slice(prefix.offset), file);
if (hasDefinitions(root)) return parse(source, file);
shiftPositions(root, prefix.offset, prefix.line - 1);
if (root.position) root.position.start = { line: 1, column: 1, offset: 0 };
// Remark transforms mutate their input. The cache owns pristine nodes and
// each render receives its own copy, including source positions.
root.children.unshift(...structuredClone(prefix.children));
} else {
root = parse(source, file);
if (hasDefinitions(root)) return root;
}
for (let index = root.children.length - 1; index >= 0; index--) {
const node = root.children[index];
if (node?.type !== "code") continue;
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start === undefined || end === undefined) continue;
if (prefix && end < prefix.offset) break;
const value = source.slice(start, end);
const opening = /^ {0,3}(`{3,}|~{3,})[^\n]*\n/.exec(value)?.[1];
if (!opening) continue;
const lastLine = value.slice(value.lastIndexOf("\n") + 1);
const closing = new RegExp(`^ {0,3}${opening[0]}{${opening.length},}[ \\t]*$`);
const separator = /^\n[ \t]*\n/.exec(source.slice(end))?.[0];
if (!closing.test(lastLine) || !separator) continue;
const offset = end + separator.length;
cached = {
source: source.slice(0, offset),
offset,
line: node.position!.end.line + 2,
children: structuredClone(root.children.slice(0, index + 1)),
};
break;
}
return root;
};
}
/** One cache per streaming renderer. Extra syntax plugins must use the normal
* parser because their document-wide dependencies are not known here.
*/
export function createIncrementalMarkdownPlugin(): Plugin<[], Root> {
let parser: Parser | undefined;
return function () {
const original = this.parser;
if (!original) return;
parser ??= createIncrementalMarkdownParser((source, file) => original(source, file) as Root);
const parseDocument = parser;
// ReactMarkdown creates a processor per render. Its first parse is the
// document; transforms can then parse synthetic recovery text on that same
// processor. Those parses must not read or replace the document's cache.
let documentParsed = false;
this.parser = (source, file) => {
if (documentParsed) return original(source, file);
documentParsed = true;
return parseDocument(source, file);
};
};
}