Skip to content

Commit f45ea6d

Browse files
fix: the unterminated long link text is not rendering anything until the full link has completed streaming (#85)
* Update terminator-parser.tsx * Automatically close links with placeholder url * Create yummy-lines-matter.md
1 parent bda3134 commit f45ea6d

File tree

6 files changed

+99
-60
lines changed

6 files changed

+99
-60
lines changed

.changeset/yummy-lines-matter.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"streamdown": patch
3+
---
4+
5+
fix: links invisible while streaming

apps/website/app/components/terminator-parser.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { Section } from './section';
22

3-
const markdown = `# This is a showcase of unterminated Markdown blocks
3+
const markdown = `**This is a very long bold text that keeps going and going without a clear end, so you can see how unterminated bold blocks are handled by the renderer.**
44
5-
**This is a very long bold text that keeps going and going without a clear end, so you can see how unterminated bold blocks are handled by the renderer, especially when the text wraps across multiple lines and continues even further to really test the limits of the parser**
6-
7-
*Here is an equally lengthy italicized sentence that stretches on and on, never quite reaching a conclusion, so you can observe how unterminated italic blocks behave in a streaming Markdown context, particularly when the content is verbose and spans several lines for demonstration purposes*
5+
*Here is an equally lengthy italicized sentence that stretches on and on, never quite reaching a conclusion, so you can observe how unterminated italic blocks behave in a streaming Markdown context, particularly when the content is verbose.*
86
97
\`This is a long inline code block that should be unterminated and continues for quite a while, including some code-like content such as const foo = "bar"; and more, to see how the parser deals with it when the code block is not properly closed\`
108

packages/streamdown/__tests__/components.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,21 @@ describe('Markdown Components', () => {
126126
expect(link?.getAttribute('target')).toBe('_blank');
127127
});
128128

129+
it('should mark incomplete links with data attribute', () => {
130+
const A = components.a!;
131+
const { container } = render(
132+
<A href="streamdown:incomplete-link" node={null as any}>
133+
Incomplete link text
134+
</A>
135+
);
136+
// Should render a normal anchor with data-incomplete attribute
137+
const link = container.querySelector('a[data-streamdown="link"]');
138+
expect(link).toBeTruthy();
139+
expect(link?.getAttribute('data-incomplete')).toBe('true');
140+
expect(link?.getAttribute('href')).toBe('streamdown:incomplete-link');
141+
expect(link?.textContent).toBe('Incomplete link text');
142+
});
143+
129144
it('should render blockquote with correct classes', () => {
130145
const Blockquote = components.blockquote!;
131146
const { container } = render(

packages/streamdown/__tests__/parse-incomplete-markdown.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ describe('parseIncompleteMarkdown', () => {
2020
});
2121

2222
describe('link handling', () => {
23-
it('should remove incomplete links', () => {
23+
it('should preserve incomplete links with special marker', () => {
2424
expect(parseIncompleteMarkdown('Text with [incomplete link')).toBe(
25-
'Text with '
25+
'Text with [incomplete link](streamdown:incomplete-link)'
2626
);
27-
expect(parseIncompleteMarkdown('Text [partial')).toBe('Text ');
27+
expect(parseIncompleteMarkdown('Text [partial')).toBe('Text [partial](streamdown:incomplete-link)');
2828
});
2929

3030
it('should keep complete links unchanged', () => {
@@ -269,9 +269,9 @@ describe('parseIncompleteMarkdown', () => {
269269
expect(parseIncompleteMarkdown(text)).toBe(text);
270270
});
271271

272-
it('should prioritize link/image removal over formatting completion', () => {
272+
it('should prioritize link/image preservation over formatting completion', () => {
273273
expect(parseIncompleteMarkdown('Text with [link and **bold')).toBe(
274-
'Text with '
274+
'Text with [link and **bold](streamdown:incomplete-link)'
275275
);
276276
});
277277

@@ -464,7 +464,7 @@ describe('parseIncompleteMarkdown', () => {
464464
});
465465

466466
it('should handle partial link at chunk boundary', () => {
467-
expect(parseIncompleteMarkdown('Check out [this lin')).toBe('Check out ');
467+
expect(parseIncompleteMarkdown('Check out [this lin')).toBe('Check out [this lin](streamdown:incomplete-link)');
468468
// Links with partial URLs are kept as-is since they might be complete
469469
expect(parseIncompleteMarkdown('Visit [our site](https://exa')).toBe(
470470
'Visit [our site](https://exa'

packages/streamdown/lib/components.tsx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,18 +127,23 @@ export const components: Options['components'] = {
127127
{children}
128128
</span>
129129
),
130-
a: ({ node, children, className, href, ...props }) => (
131-
<a
132-
className={cn('font-medium text-primary underline', className)}
133-
data-streamdown="link"
134-
href={href}
135-
rel="noreferrer"
136-
target="_blank"
137-
{...props}
138-
>
139-
{children}
140-
</a>
141-
),
130+
a: ({ node, children, className, href, ...props }) => {
131+
const isIncomplete = href === 'streamdown:incomplete-link';
132+
133+
return (
134+
<a
135+
className={cn('font-medium text-primary underline', className)}
136+
data-incomplete={isIncomplete}
137+
data-streamdown="link"
138+
href={href}
139+
rel="noreferrer"
140+
target="_blank"
141+
{...props}
142+
>
143+
{children}
144+
</a>
145+
);
146+
},
142147
h1: ({ node, children, className, ...props }) => (
143148
<h1
144149
className={cn('mt-6 mb-2 font-semibold text-3xl', className)}

packages/streamdown/lib/parse-incomplete-markdown.ts

Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,31 @@ const singleAsteriskPattern = /(\*)([^*]*?)$/;
66
const singleUnderscorePattern = /(_)([^_]*?)$/;
77
const inlineCodePattern = /(`)([^`]*?)$/;
88
const strikethroughPattern = /(~~)([^~]*?)$/;
9-
// Removed inlineKatexPattern - no longer processing single dollar signs
10-
const blockKatexPattern = /(\$\$)([^$]*?)$/;
119

1210
// Helper function to check if we have a complete code block
1311
const hasCompleteCodeBlock = (text: string): boolean => {
1412
const tripleBackticks = (text.match(/```/g) || []).length;
15-
return tripleBackticks > 0 && tripleBackticks % 2 === 0 && text.includes('\n');
13+
return (
14+
tripleBackticks > 0 && tripleBackticks % 2 === 0 && text.includes('\n')
15+
);
1616
};
1717

18-
// Handles incomplete links and images by removing them if not closed
18+
// Handles incomplete links and images by preserving them with a special marker
1919
const handleIncompleteLinksAndImages = (text: string): string => {
2020
const linkMatch = text.match(linkImagePattern);
2121

2222
if (linkMatch) {
23-
const startIndex = text.lastIndexOf(linkMatch[1]);
24-
return text.substring(0, startIndex);
23+
const isImage = linkMatch[1].startsWith('!');
24+
25+
// For images, we still remove them as they can't show skeleton
26+
if (isImage) {
27+
const startIndex = text.lastIndexOf(linkMatch[1]);
28+
return text.substring(0, startIndex);
29+
}
30+
31+
// For links, preserve the text and close the link with a
32+
// special placeholder URL that indicates it's incomplete
33+
return `${text}](streamdown:incomplete-link)`;
2534
}
2635

2736
return text;
@@ -33,7 +42,7 @@ const handleIncompleteBold = (text: string): string => {
3342
if (hasCompleteCodeBlock(text)) {
3443
return text;
3544
}
36-
45+
3746
const boldMatch = text.match(boldPattern);
3847

3948
if (boldMatch) {
@@ -85,7 +94,10 @@ const countSingleAsterisks = (text: string): number => {
8594
}
8695
// Check if this asterisk is at the beginning of a line (with optional whitespace)
8796
const beforeAsterisk = text.substring(lineStartIndex, index);
88-
if (beforeAsterisk.trim() === '' && (nextChar === ' ' || nextChar === '\t')) {
97+
if (
98+
beforeAsterisk.trim() === '' &&
99+
(nextChar === ' ' || nextChar === '\t')
100+
) {
89101
// This is likely a list marker, don't count it
90102
return acc;
91103
}
@@ -103,7 +115,7 @@ const handleIncompleteSingleAsteriskItalic = (text: string): string => {
103115
if (hasCompleteCodeBlock(text)) {
104116
return text;
105117
}
106-
118+
107119
const singleAsteriskMatch = text.match(singleAsteriskPattern);
108120

109121
if (singleAsteriskMatch) {
@@ -121,14 +133,14 @@ const isWithinMathBlock = (text: string, position: number): boolean => {
121133
// Count dollar signs before this position
122134
let inInlineMath = false;
123135
let inBlockMath = false;
124-
136+
125137
for (let i = 0; i < text.length && i < position; i++) {
126138
// Skip escaped dollar signs
127139
if (text[i] === '\\' && text[i + 1] === '$') {
128140
i++; // Skip the next character
129141
continue;
130142
}
131-
143+
132144
if (text[i] === '$') {
133145
// Check for block math ($$)
134146
if (text[i + 1] === '$') {
@@ -141,7 +153,7 @@ const isWithinMathBlock = (text: string, position: number): boolean => {
141153
}
142154
}
143155
}
144-
156+
145157
return inInlineMath || inBlockMath;
146158
};
147159

@@ -173,7 +185,7 @@ const handleIncompleteSingleUnderscoreItalic = (text: string): string => {
173185
if (hasCompleteCodeBlock(text)) {
174186
return text;
175187
}
176-
188+
177189
const singleUnderscoreMatch = text.match(singleUnderscorePattern);
178190

179191
if (singleUnderscoreMatch) {
@@ -221,17 +233,21 @@ const handleIncompleteInlineCode = (text: string): string => {
221233
// Already complete inline triple backticks
222234
return text;
223235
}
224-
236+
225237
// Check if we're inside a multi-line code block (complete or incomplete)
226238
const allTripleBackticks = (text.match(/```/g) || []).length;
227239
const insideIncompleteCodeBlock = allTripleBackticks % 2 === 1;
228-
240+
229241
// Don't modify text if we have complete multi-line code blocks (even pairs of ```)
230-
if (allTripleBackticks > 0 && allTripleBackticks % 2 === 0 && text.includes('\n')) {
242+
if (
243+
allTripleBackticks > 0 &&
244+
allTripleBackticks % 2 === 0 &&
245+
text.includes('\n')
246+
) {
231247
// We have complete multi-line code blocks, don't add any backticks
232248
return text;
233249
}
234-
250+
235251
// Special case: if text ends with ```\n (triple backticks followed by newline)
236252
// This is actually a complete code block, not incomplete
237253
if (text.endsWith('```\n') || text.endsWith('```')) {
@@ -289,40 +305,32 @@ const countSingleDollarSigns = (text: string): number => {
289305
const handleIncompleteBlockKatex = (text: string): string => {
290306
// Count all $$ pairs in the text
291307
const dollarPairs = (text.match(/\$\$/g) || []).length;
292-
308+
293309
// If we have an even number of $$, the block is complete
294310
if (dollarPairs % 2 === 0) {
295311
return text;
296312
}
297-
313+
298314
// If we have an odd number, add closing $$
299315
// Check if this looks like a multi-line math block (contains newlines after opening $$)
300316
const firstDollarIndex = text.indexOf('$$');
301-
const hasNewlineAfterStart = firstDollarIndex !== -1 && text.indexOf('\n', firstDollarIndex) !== -1;
302-
317+
const hasNewlineAfterStart =
318+
firstDollarIndex !== -1 && text.indexOf('\n', firstDollarIndex) !== -1;
319+
303320
// For multi-line blocks, add newline before closing $$ if not present
304321
if (hasNewlineAfterStart && !text.endsWith('\n')) {
305322
return `${text}\n$$`;
306323
}
307-
324+
308325
// For inline blocks or when already ending with newline, just add $$
309326
return `${text}$$`;
310327
};
311328

312-
// Completes incomplete inline KaTeX formatting ($)
313-
// Note: Since we've disabled single dollar math delimiters in remarkMath,
314-
// we should not auto-complete single dollar signs as they're likely currency symbols
315-
const handleIncompleteInlineKatex = (text: string): string => {
316-
// Don't process single dollar signs - they're likely currency symbols, not math
317-
// Only process block math ($$) which is handled separately
318-
return text;
319-
};
320-
321329
// Counts triple asterisks that are not part of quadruple or more asterisks
322330
const countTripleAsterisks = (text: string): number => {
323331
let count = 0;
324332
const matches = text.match(/\*+/g) || [];
325-
333+
326334
for (const match of matches) {
327335
// Count how many complete triple asterisks are in this sequence
328336
const asteriskCount = match.length;
@@ -331,7 +339,7 @@ const countTripleAsterisks = (text: string): number => {
331339
count += Math.floor(asteriskCount / 3);
332340
}
333341
}
334-
342+
335343
return count;
336344
};
337345

@@ -341,13 +349,13 @@ const handleIncompleteBoldItalic = (text: string): string => {
341349
if (hasCompleteCodeBlock(text)) {
342350
return text;
343351
}
344-
352+
345353
// Don't process if text is only asterisks and has 4 or more consecutive asterisks
346354
// This prevents cases like **** from being treated as incomplete ***
347355
if (/^\*{4,}$/.test(text)) {
348356
return text;
349357
}
350-
358+
351359
const boldItalicMatch = text.match(boldItalicPattern);
352360

353361
if (boldItalicMatch) {
@@ -368,8 +376,16 @@ export const parseIncompleteMarkdown = (text: string): string => {
368376

369377
let result = text;
370378

371-
// Handle incomplete links and images first (removes content)
372-
result = handleIncompleteLinksAndImages(result);
379+
// Handle incomplete links and images first
380+
const processedResult = handleIncompleteLinksAndImages(result);
381+
382+
// If we added an incomplete link marker, don't process other formatting
383+
// as the content inside the link should be preserved as-is
384+
if (processedResult.endsWith('](streamdown:incomplete-link)')) {
385+
return processedResult;
386+
}
387+
388+
result = processedResult;
373389

374390
// Handle various formatting completions
375391
// Handle triple asterisks first (most specific)
@@ -380,7 +396,7 @@ export const parseIncompleteMarkdown = (text: string): string => {
380396
result = handleIncompleteSingleUnderscoreItalic(result);
381397
result = handleIncompleteInlineCode(result);
382398
result = handleIncompleteStrikethrough(result);
383-
399+
384400
// Handle KaTeX formatting (only block math with $$)
385401
result = handleIncompleteBlockKatex(result);
386402
// Note: We don't handle inline KaTeX with single $ as they're likely currency symbols

0 commit comments

Comments
 (0)