Mike0021's picture
Use upstream chat-ui frontend
016ed07 verified
Raw
History Blame Contribute Delete
5.77 kB
/*
* 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;
}