|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const linkImagePattern = /(!?\[)([^\]]*?)$/; |
|
|
const boldPattern = /(\*\*)([^*]*?)$/; |
|
|
const italicPattern = /(__)([^_]*?)$/; |
|
|
const boldItalicPattern = /(\*\*\*)([^*]*?)$/; |
|
|
const singleAsteriskPattern = /(\*)([^*]*?)$/; |
|
|
const singleUnderscorePattern = /(_)([^_]*?)$/; |
|
|
const inlineCodePattern = /(`)([^`]*?)$/; |
|
|
const strikethroughPattern = /(~~)([^~]*?)$/; |
|
|
|
|
|
|
|
|
const hasCompleteCodeBlock = (text: string): boolean => { |
|
|
const tripleBackticks = (text.match(/```/g) || []).length; |
|
|
return tripleBackticks > 0 && tripleBackticks % 2 === 0 && text.includes("\n"); |
|
|
}; |
|
|
|
|
|
|
|
|
const getOpenCodeFenceIndex = (text: string): number => { |
|
|
let openFenceIndex = -1; |
|
|
let inFence = false; |
|
|
|
|
|
for (const match of text.matchAll(/```/g)) { |
|
|
const index = match.index ?? -1; |
|
|
if (index === -1) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
if (inFence) { |
|
|
|
|
|
inFence = false; |
|
|
openFenceIndex = -1; |
|
|
} else { |
|
|
|
|
|
inFence = true; |
|
|
openFenceIndex = index; |
|
|
} |
|
|
} |
|
|
|
|
|
return openFenceIndex; |
|
|
}; |
|
|
|
|
|
|
|
|
const handleIncompleteLinksAndImages = (text: string): string => { |
|
|
|
|
|
|
|
|
const incompleteLinkUrlPattern = /(!?)\[([^\]]+)\]\(([^)]+)$/; |
|
|
const incompleteLinkUrlMatch = text.match(incompleteLinkUrlPattern); |
|
|
|
|
|
if (incompleteLinkUrlMatch) { |
|
|
const isImage = incompleteLinkUrlMatch[1] === "!"; |
|
|
const linkText = incompleteLinkUrlMatch[2]; |
|
|
const partialUrl = incompleteLinkUrlMatch[3]; |
|
|
|
|
|
|
|
|
const matchStart = text.lastIndexOf(`${isImage ? "!" : ""}[${linkText}](${partialUrl}`); |
|
|
const beforeLink = text.substring(0, matchStart); |
|
|
|
|
|
if (isImage) { |
|
|
|
|
|
return beforeLink; |
|
|
} |
|
|
|
|
|
|
|
|
return `${beforeLink}[${linkText}](streamdown:incomplete-link)`; |
|
|
} |
|
|
|
|
|
|
|
|
const linkMatch = text.match(linkImagePattern); |
|
|
|
|
|
if (linkMatch) { |
|
|
const isImage = linkMatch[1].startsWith("!"); |
|
|
|
|
|
|
|
|
if (isImage) { |
|
|
const startIndex = text.lastIndexOf(linkMatch[1]); |
|
|
return text.substring(0, startIndex); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return `${text}](streamdown:incomplete-link)`; |
|
|
} |
|
|
|
|
|
return text; |
|
|
}; |
|
|
|
|
|
|
|
|
const handleIncompleteBold = (text: string): string => { |
|
|
|
|
|
if (hasCompleteCodeBlock(text)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
const boldMatch = text.match(boldPattern); |
|
|
|
|
|
if (boldMatch) { |
|
|
|
|
|
|
|
|
|
|
|
const contentAfterMarker = boldMatch[2]; |
|
|
if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const markerIndex = text.lastIndexOf(boldMatch[1]); |
|
|
|
|
|
|
|
|
const openFenceIndex = getOpenCodeFenceIndex(text); |
|
|
if (openFenceIndex !== -1 && markerIndex > openFenceIndex) { |
|
|
return text; |
|
|
} |
|
|
const beforeMarker = text.substring(0, markerIndex); |
|
|
const lastNewlineBeforeMarker = beforeMarker.lastIndexOf("\n"); |
|
|
const lineStart = lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1; |
|
|
const lineBeforeMarker = text.substring(lineStart, markerIndex); |
|
|
|
|
|
|
|
|
if (/^[\s]*[-*+][\s]+$/.test(lineBeforeMarker)) { |
|
|
|
|
|
|
|
|
const hasNewlineInContent = contentAfterMarker.includes("\n"); |
|
|
if (hasNewlineInContent) { |
|
|
|
|
|
return text; |
|
|
} |
|
|
} |
|
|
|
|
|
const asteriskPairs = (text.match(/\*\*/g) || []).length; |
|
|
if (asteriskPairs % 2 === 1) { |
|
|
return `${text}**`; |
|
|
} |
|
|
} |
|
|
|
|
|
return text; |
|
|
}; |
|
|
|
|
|
|
|
|
const handleIncompleteDoubleUnderscoreItalic = (text: string): string => { |
|
|
|
|
|
if (hasCompleteCodeBlock(text)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
const italicMatch = text.match(italicPattern); |
|
|
|
|
|
if (italicMatch) { |
|
|
|
|
|
|
|
|
|
|
|
const contentAfterMarker = italicMatch[2]; |
|
|
if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const markerIndex = text.lastIndexOf(italicMatch[1]); |
|
|
|
|
|
|
|
|
const openFenceIndex = getOpenCodeFenceIndex(text); |
|
|
if (openFenceIndex !== -1 && markerIndex > openFenceIndex) { |
|
|
return text; |
|
|
} |
|
|
const beforeMarker = text.substring(0, markerIndex); |
|
|
const lastNewlineBeforeMarker = beforeMarker.lastIndexOf("\n"); |
|
|
const lineStart = lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1; |
|
|
const lineBeforeMarker = text.substring(lineStart, markerIndex); |
|
|
|
|
|
|
|
|
if (/^[\s]*[-*+][\s]+$/.test(lineBeforeMarker)) { |
|
|
|
|
|
|
|
|
const hasNewlineInContent = contentAfterMarker.includes("\n"); |
|
|
if (hasNewlineInContent) { |
|
|
|
|
|
return text; |
|
|
} |
|
|
} |
|
|
|
|
|
const underscorePairs = (text.match(/__/g) || []).length; |
|
|
if (underscorePairs % 2 === 1) { |
|
|
return `${text}__`; |
|
|
} |
|
|
} |
|
|
|
|
|
return text; |
|
|
}; |
|
|
|
|
|
|
|
|
const countSingleAsterisks = (text: string): number => { |
|
|
return text.split("").reduce((acc, char, index) => { |
|
|
if (char === "*") { |
|
|
const prevChar = text[index - 1]; |
|
|
const nextChar = text[index + 1]; |
|
|
|
|
|
if (prevChar === "\\") { |
|
|
return acc; |
|
|
} |
|
|
|
|
|
|
|
|
let lineStartIndex = index; |
|
|
for (let i = index - 1; i >= 0; i--) { |
|
|
if (text[i] === "\n") { |
|
|
lineStartIndex = i + 1; |
|
|
break; |
|
|
} |
|
|
if (i === 0) { |
|
|
lineStartIndex = 0; |
|
|
break; |
|
|
} |
|
|
} |
|
|
|
|
|
const beforeAsterisk = text.substring(lineStartIndex, index); |
|
|
if (beforeAsterisk.trim() === "" && (nextChar === " " || nextChar === "\t")) { |
|
|
|
|
|
return acc; |
|
|
} |
|
|
if (prevChar !== "*" && nextChar !== "*") { |
|
|
return acc + 1; |
|
|
} |
|
|
} |
|
|
return acc; |
|
|
}, 0); |
|
|
}; |
|
|
|
|
|
|
|
|
const handleIncompleteSingleAsteriskItalic = (text: string): string => { |
|
|
|
|
|
if (hasCompleteCodeBlock(text)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
const singleAsteriskMatch = text.match(singleAsteriskPattern); |
|
|
|
|
|
if (singleAsteriskMatch) { |
|
|
|
|
|
let firstSingleAsteriskIndex = -1; |
|
|
for (let i = 0; i < text.length; i++) { |
|
|
if (text[i] === "*" && text[i - 1] !== "*" && text[i + 1] !== "*") { |
|
|
firstSingleAsteriskIndex = i; |
|
|
break; |
|
|
} |
|
|
} |
|
|
|
|
|
if (firstSingleAsteriskIndex === -1) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
|
|
|
const openFenceIndex = getOpenCodeFenceIndex(text); |
|
|
if (openFenceIndex !== -1 && firstSingleAsteriskIndex > openFenceIndex) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
|
|
|
const contentAfterFirstAsterisk = text.substring(firstSingleAsteriskIndex + 1); |
|
|
|
|
|
|
|
|
|
|
|
if (!contentAfterFirstAsterisk || /^[\s_~*`]*$/.test(contentAfterFirstAsterisk)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
const singleAsterisks = countSingleAsterisks(text); |
|
|
if (singleAsterisks % 2 === 1) { |
|
|
return `${text}*`; |
|
|
} |
|
|
} |
|
|
|
|
|
return text; |
|
|
}; |
|
|
|
|
|
|
|
|
const isWithinMathBlock = (text: string, position: number): boolean => { |
|
|
|
|
|
let inInlineMath = false; |
|
|
let inBlockMath = false; |
|
|
|
|
|
for (let i = 0; i < text.length && i < position; i++) { |
|
|
|
|
|
if (text[i] === "\\" && text[i + 1] === "$") { |
|
|
i++; |
|
|
continue; |
|
|
} |
|
|
|
|
|
if (text[i] === "$") { |
|
|
|
|
|
if (text[i + 1] === "$") { |
|
|
inBlockMath = !inBlockMath; |
|
|
i++; |
|
|
inInlineMath = false; |
|
|
} else if (!inBlockMath) { |
|
|
|
|
|
inInlineMath = !inInlineMath; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return inInlineMath || inBlockMath; |
|
|
}; |
|
|
|
|
|
|
|
|
const countSingleUnderscores = (text: string): number => { |
|
|
return text.split("").reduce((acc, char, index) => { |
|
|
if (char === "_") { |
|
|
const prevChar = text[index - 1]; |
|
|
const nextChar = text[index + 1]; |
|
|
|
|
|
if (prevChar === "\\") { |
|
|
return acc; |
|
|
} |
|
|
|
|
|
if (isWithinMathBlock(text, index)) { |
|
|
return acc; |
|
|
} |
|
|
|
|
|
if ( |
|
|
prevChar && |
|
|
nextChar && |
|
|
/[\p{L}\p{N}_]/u.test(prevChar) && |
|
|
/[\p{L}\p{N}_]/u.test(nextChar) |
|
|
) { |
|
|
return acc; |
|
|
} |
|
|
if (prevChar !== "_" && nextChar !== "_") { |
|
|
return acc + 1; |
|
|
} |
|
|
} |
|
|
return acc; |
|
|
}, 0); |
|
|
}; |
|
|
|
|
|
|
|
|
const handleIncompleteSingleUnderscoreItalic = (text: string): string => { |
|
|
|
|
|
if (hasCompleteCodeBlock(text)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
const singleUnderscoreMatch = text.match(singleUnderscorePattern); |
|
|
|
|
|
if (singleUnderscoreMatch) { |
|
|
|
|
|
let firstSingleUnderscoreIndex = -1; |
|
|
for (let i = 0; i < text.length; i++) { |
|
|
if ( |
|
|
text[i] === "_" && |
|
|
text[i - 1] !== "_" && |
|
|
text[i + 1] !== "_" && |
|
|
text[i - 1] !== "\\" && |
|
|
!isWithinMathBlock(text, i) |
|
|
) { |
|
|
|
|
|
const prevChar = i > 0 ? text[i - 1] : ""; |
|
|
const nextChar = i < text.length - 1 ? text[i + 1] : ""; |
|
|
if ( |
|
|
prevChar && |
|
|
nextChar && |
|
|
/[\p{L}\p{N}_]/u.test(prevChar) && |
|
|
/[\p{L}\p{N}_]/u.test(nextChar) |
|
|
) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
firstSingleUnderscoreIndex = i; |
|
|
break; |
|
|
} |
|
|
} |
|
|
|
|
|
if (firstSingleUnderscoreIndex === -1) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
|
|
|
const openFenceIndex = getOpenCodeFenceIndex(text); |
|
|
if (openFenceIndex !== -1 && firstSingleUnderscoreIndex > openFenceIndex) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
|
|
|
const contentAfterFirstUnderscore = text.substring(firstSingleUnderscoreIndex + 1); |
|
|
|
|
|
|
|
|
|
|
|
if (!contentAfterFirstUnderscore || /^[\s_~*`]*$/.test(contentAfterFirstUnderscore)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
const singleUnderscores = countSingleUnderscores(text); |
|
|
if (singleUnderscores % 2 === 1) { |
|
|
|
|
|
const trailingNewlineMatch = text.match(/\n+$/); |
|
|
if (trailingNewlineMatch) { |
|
|
const textBeforeNewlines = text.slice(0, -trailingNewlineMatch[0].length); |
|
|
return `${textBeforeNewlines}_${trailingNewlineMatch[0]}`; |
|
|
} |
|
|
return `${text}_`; |
|
|
} |
|
|
} |
|
|
|
|
|
return text; |
|
|
}; |
|
|
|
|
|
|
|
|
const isPartOfTripleBacktick = (text: string, i: number): boolean => { |
|
|
const isTripleStart = text.substring(i, i + 3) === "```"; |
|
|
const isTripleMiddle = i > 0 && text.substring(i - 1, i + 2) === "```"; |
|
|
const isTripleEnd = i > 1 && text.substring(i - 2, i + 1) === "```"; |
|
|
|
|
|
return isTripleStart || isTripleMiddle || isTripleEnd; |
|
|
}; |
|
|
|
|
|
|
|
|
const countSingleBackticks = (text: string): number => { |
|
|
let count = 0; |
|
|
for (let i = 0; i < text.length; i++) { |
|
|
if (text[i] === "`" && !isPartOfTripleBacktick(text, i)) { |
|
|
count++; |
|
|
} |
|
|
} |
|
|
return count; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const handleIncompleteInlineCode = (text: string): string => { |
|
|
|
|
|
|
|
|
|
|
|
const inlineTripleBacktickMatch = text.match(/^```[^`\n]*```?$/); |
|
|
if (inlineTripleBacktickMatch && !text.includes("\n")) { |
|
|
|
|
|
if (text.endsWith("``") && !text.endsWith("```")) { |
|
|
return `${text}\``; |
|
|
} |
|
|
|
|
|
return text; |
|
|
} |
|
|
|
|
|
|
|
|
const allTripleBackticks = (text.match(/```/g) || []).length; |
|
|
const insideIncompleteCodeBlock = allTripleBackticks % 2 === 1; |
|
|
|
|
|
|
|
|
if (allTripleBackticks > 0 && allTripleBackticks % 2 === 0 && text.includes("\n")) { |
|
|
|
|
|
return text; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (text.endsWith("```\n") || text.endsWith("```")) { |
|
|
|
|
|
if (allTripleBackticks % 2 === 0) { |
|
|
return text; |
|
|
} |
|
|
} |
|
|
|
|
|
const inlineCodeMatch = text.match(inlineCodePattern); |
|
|
|
|
|
if (inlineCodeMatch && !insideIncompleteCodeBlock) { |
|
|
|
|
|
|
|
|
|
|
|
const contentAfterMarker = inlineCodeMatch[2]; |
|
|
if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
const singleBacktickCount = countSingleBackticks(text); |
|
|
if (singleBacktickCount % 2 === 1) { |
|
|
return `${text}\``; |
|
|
} |
|
|
} |
|
|
|
|
|
return text; |
|
|
}; |
|
|
|
|
|
|
|
|
const handleIncompleteStrikethrough = (text: string): string => { |
|
|
const strikethroughMatch = text.match(strikethroughPattern); |
|
|
|
|
|
if (strikethroughMatch) { |
|
|
|
|
|
|
|
|
|
|
|
const contentAfterMarker = strikethroughMatch[2]; |
|
|
if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
const tildePairs = (text.match(/~~/g) || []).length; |
|
|
if (tildePairs % 2 === 1) { |
|
|
return `${text}~~`; |
|
|
} |
|
|
} |
|
|
|
|
|
return text; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const _countSingleDollarSigns = (text: string): number => { |
|
|
return text.split("").reduce((acc, char, index) => { |
|
|
if (char === "$") { |
|
|
const prevChar = text[index - 1]; |
|
|
const nextChar = text[index + 1]; |
|
|
|
|
|
if (prevChar === "\\") { |
|
|
return acc; |
|
|
} |
|
|
if (prevChar !== "$" && nextChar !== "$") { |
|
|
return acc + 1; |
|
|
} |
|
|
} |
|
|
return acc; |
|
|
}, 0); |
|
|
}; |
|
|
|
|
|
|
|
|
const handleIncompleteBlockKatex = (text: string): string => { |
|
|
|
|
|
const dollarPairs = (text.match(/\$\$/g) || []).length; |
|
|
|
|
|
|
|
|
if (dollarPairs % 2 === 0) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const firstDollarIndex = text.indexOf("$$"); |
|
|
const hasNewlineAfterStart = |
|
|
firstDollarIndex !== -1 && text.indexOf("\n", firstDollarIndex) !== -1; |
|
|
|
|
|
|
|
|
if (hasNewlineAfterStart && !text.endsWith("\n")) { |
|
|
return `${text}\n$$`; |
|
|
} |
|
|
|
|
|
|
|
|
return `${text}$$`; |
|
|
}; |
|
|
|
|
|
|
|
|
const countTripleAsterisks = (text: string): number => { |
|
|
let count = 0; |
|
|
const matches = text.match(/\*+/g) || []; |
|
|
|
|
|
for (const match of matches) { |
|
|
|
|
|
const asteriskCount = match.length; |
|
|
if (asteriskCount >= 3) { |
|
|
|
|
|
count += Math.floor(asteriskCount / 3); |
|
|
} |
|
|
} |
|
|
|
|
|
return count; |
|
|
}; |
|
|
|
|
|
|
|
|
const handleIncompleteBoldItalic = (text: string): string => { |
|
|
|
|
|
if (hasCompleteCodeBlock(text)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (/^\*{4,}$/.test(text)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
const boldItalicMatch = text.match(boldItalicPattern); |
|
|
|
|
|
if (boldItalicMatch) { |
|
|
|
|
|
|
|
|
|
|
|
const contentAfterMarker = boldItalicMatch[2]; |
|
|
if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
|
|
|
const markerIndex = text.lastIndexOf(boldItalicMatch[1]); |
|
|
|
|
|
|
|
|
const openFenceIndex = getOpenCodeFenceIndex(text); |
|
|
if (openFenceIndex !== -1 && markerIndex > openFenceIndex) { |
|
|
return text; |
|
|
} |
|
|
|
|
|
const tripleAsteriskCount = countTripleAsterisks(text); |
|
|
if (tripleAsteriskCount % 2 === 1) { |
|
|
return `${text}***`; |
|
|
} |
|
|
} |
|
|
|
|
|
return text; |
|
|
}; |
|
|
|
|
|
|
|
|
export const parseIncompleteMarkdown = (text: string): string => { |
|
|
if (!text || typeof text !== "string") { |
|
|
return text; |
|
|
} |
|
|
|
|
|
let result = text; |
|
|
|
|
|
|
|
|
const processedResult = handleIncompleteLinksAndImages(result); |
|
|
|
|
|
|
|
|
|
|
|
if (processedResult.endsWith("](streamdown:incomplete-link)")) { |
|
|
return processedResult; |
|
|
} |
|
|
|
|
|
result = processedResult; |
|
|
|
|
|
|
|
|
|
|
|
result = handleIncompleteBoldItalic(result); |
|
|
result = handleIncompleteBold(result); |
|
|
result = handleIncompleteDoubleUnderscoreItalic(result); |
|
|
result = handleIncompleteSingleAsteriskItalic(result); |
|
|
result = handleIncompleteSingleUnderscoreItalic(result); |
|
|
result = handleIncompleteInlineCode(result); |
|
|
result = handleIncompleteStrikethrough(result); |
|
|
|
|
|
|
|
|
result = handleIncompleteBlockKatex(result); |
|
|
|
|
|
|
|
|
return result; |
|
|
}; |
|
|
|