| |
| |
| |
| |
|
|
| 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; |
| }; |
|
|