File size: 5,769 Bytes
fae625a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
/*
 * Copyright 2023 Vercel, Inc.
 * Adapted from: https://github.com/vercel/streamdown/blob/main/packages/streamdown/lib/parse-blocks.tsx
 */

import { Lexer } from "marked";

// Footnote identifiers must be alphanumeric, underscore, or hyphen (e.g., [^1], [^note], [^my-note]).
// A broader pattern like [^\]\s] would also match regex character classes such as [^\s>] inside
// code blocks, collapsing the whole document into a single block.
const footnoteReferencePattern = /\[\^[\w-]{1,200}\](?!:)/;
const footnoteDefinitionPattern = /\[\^[\w-]{1,200}\]:/;
const openingTagPattern = /<(\w+)[\s>]/;

// HTML void elements (self-closing tags) that don't need closing tags
const voidElements = new Set([
	"area",
	"base",
	"br",
	"col",
	"embed",
	"hr",
	"img",
	"input",
	"link",
	"meta",
	"param",
	"source",
	"track",
	"wbr",
]);

// Cache for tag patterns to avoid recreating RegExp objects
const openTagPatternCache = new Map<string, RegExp>();
const closeTagPatternCache = new Map<string, RegExp>();

function getOpenTagPattern(tagName: string): RegExp {
	const normalizedTag = tagName.toLowerCase();
	const cached = openTagPatternCache.get(normalizedTag);
	if (cached) {
		return cached;
	}
	const pattern = new RegExp(`<${normalizedTag}(?=[\\s>/])[^>]*>`, "gi");
	openTagPatternCache.set(normalizedTag, pattern);
	return pattern;
}

function getCloseTagPattern(tagName: string): RegExp {
	const normalizedTag = tagName.toLowerCase();
	const cached = closeTagPatternCache.get(normalizedTag);
	if (cached) {
		return cached;
	}
	const pattern = new RegExp(`</${normalizedTag}(?=[\\s>])[^>]*>`, "gi");
	closeTagPatternCache.set(normalizedTag, pattern);
	return pattern;
}

// Count opening tags that are neither void elements nor self-closing (e.g. <div />)
function countNonSelfClosingOpenTags(block: string, tagName: string): number {
	if (voidElements.has(tagName.toLowerCase())) {
		return 0;
	}
	const matches = block.match(getOpenTagPattern(tagName));
	if (!matches) {
		return 0;
	}
	let count = 0;
	for (const match of matches) {
		if (!match.trimEnd().endsWith("/>")) {
			count += 1;
		}
	}
	return count;
}

function countClosingTags(block: string, tagName: string): number {
	const matches = block.match(getCloseTagPattern(tagName));
	return matches ? matches.length : 0;
}

function countDoubleDollars(str: string): number {
	let count = 0;
	for (let i = 0; i < str.length - 1; i += 1) {
		if (str[i] === "$" && str[i + 1] === "$") {
			count += 1;
			i += 1; // Skip next character
		}
	}
	return count;
}

/**
 * Parses markdown into independent blocks for efficient memoization during streaming.
 * Blocks are split at natural boundaries while keeping related content together
 * (unclosed HTML elements, math blocks split by the lexer, footnotes).
 */
export function parseMarkdownIntoBlocks(markdown: string): string[] {
	// If footnotes are present, return the entire document as a single block.
	// This ensures footnote references and definitions remain in the same tree.
	if (footnoteReferencePattern.test(markdown) || footnoteDefinitionPattern.test(markdown)) {
		return [markdown];
	}

	const tokens = Lexer.lex(markdown, { gfm: true });

	// Post-process to merge consecutive blocks that belong together
	const mergedBlocks: string[] = [];
	const htmlStack: string[] = []; // Track opening HTML tags
	let previousTokenWasCode = false; // Track if previous non-space token was a code block

	for (const token of tokens) {
		const currentBlock = token.raw;
		const mergedBlocksLen = mergedBlocks.length;

		// We're inside an unclosed HTML block: merge with the previous block
		if (htmlStack.length > 0 && mergedBlocksLen > 0) {
			mergedBlocks[mergedBlocksLen - 1] += currentBlock;

			// Track nested opening and closing tags of the same type so that
			// inner closing tags don't prematurely close the outer block
			const trackedTag = htmlStack[htmlStack.length - 1];
			const newOpenTags = countNonSelfClosingOpenTags(currentBlock, trackedTag);
			const newCloseTags = countClosingTags(currentBlock, trackedTag);

			for (let i = 0; i < newOpenTags; i += 1) {
				htmlStack.push(trackedTag);
			}
			for (let i = 0; i < newCloseTags; i += 1) {
				if (htmlStack.length > 0 && htmlStack[htmlStack.length - 1] === trackedTag) {
					htmlStack.pop();
				}
			}
			continue;
		}

		// Check if this is an opening HTML block tag
		if (token.type === "html" && token.block) {
			const openingTagMatch = currentBlock.match(openingTagPattern);
			if (openingTagMatch) {
				const tagName = openingTagMatch[1];
				// Count how many tags remain unclosed within this block
				const openTags = countNonSelfClosingOpenTags(currentBlock, tagName);
				const closeTags = countClosingTags(currentBlock, tagName);
				if (openTags > closeTags) {
					// There is at least one unmatched opening tag, keep track of it
					htmlStack.push(tagName);
				}
			}
		}

		// Math block merging: if the previous block has unclosed math (odd number of $$),
		// merge the current block into it. This handles cases where marked's Lexer splits
		// math blocks (e.g. = on its own line is interpreted as a setext heading underline).
		// Skip if the previous block was a code block — code can legitimately contain $$
		// (e.g. the shell's current process ID).
		if (mergedBlocksLen > 0 && !previousTokenWasCode) {
			const previousBlock = mergedBlocks[mergedBlocksLen - 1];
			if (countDoubleDollars(previousBlock) % 2 === 1) {
				mergedBlocks[mergedBlocksLen - 1] = previousBlock + currentBlock;
				continue;
			}
		}

		mergedBlocks.push(currentBlock);

		// Track if this token was a code block (for next iteration), ignoring space tokens
		if (token.type !== "space") {
			previousTokenWasCode = token.type === "code";
		}
	}

	return mergedBlocks;
}