Skip to content

Commit 426c897

Browse files
fix: parseincompletemarkdown emphasis character block issue (#94)
* Handle trailing markdown * Fix nested italics * Create old-facts-beg.md
1 parent 4459b14 commit 426c897

File tree

3 files changed

+127
-9
lines changed

3 files changed

+127
-9
lines changed

.changeset/old-facts-beg.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: parseIncompleteMarkdown Emphasis Character Block Issue

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

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -781,10 +781,10 @@ describe("parseIncompleteMarkdown", () => {
781781
describe("edge cases", () => {
782782
it("should handle text ending with formatting characters", () => {
783783
expect(parseIncompleteMarkdown("Text ending with *")).toBe(
784-
"Text ending with **"
784+
"Text ending with *"
785785
);
786786
expect(parseIncompleteMarkdown("Text ending with **")).toBe(
787-
"Text ending with ****"
787+
"Text ending with **"
788788
);
789789
});
790790

@@ -793,16 +793,45 @@ describe("parseIncompleteMarkdown", () => {
793793
expect(parseIncompleteMarkdown("``")).toBe("``");
794794
});
795795

796+
it("should handle standalone emphasis characters (issue #90)", () => {
797+
// Standalone markers should not be auto-closed
798+
expect(parseIncompleteMarkdown("**")).toBe("**");
799+
expect(parseIncompleteMarkdown("__")).toBe("__");
800+
expect(parseIncompleteMarkdown("***")).toBe("***");
801+
expect(parseIncompleteMarkdown("*")).toBe("*");
802+
expect(parseIncompleteMarkdown("_")).toBe("_");
803+
expect(parseIncompleteMarkdown("~~")).toBe("~~");
804+
expect(parseIncompleteMarkdown("`")).toBe("`");
805+
806+
// Multiple standalone markers on the same line
807+
expect(parseIncompleteMarkdown("** __")).toBe("** __");
808+
expect(parseIncompleteMarkdown("\n** __\n")).toBe("\n** __\n");
809+
expect(parseIncompleteMarkdown("* _ ~~ `")).toBe("* _ ~~ `");
810+
811+
// Standalone markers with only whitespace
812+
expect(parseIncompleteMarkdown("** ")).toBe("** ");
813+
expect(parseIncompleteMarkdown(" **")).toBe(" **");
814+
expect(parseIncompleteMarkdown(" ** ")).toBe(" ** ");
815+
816+
// But markers with actual content should still be closed
817+
expect(parseIncompleteMarkdown("**text")).toBe("**text**");
818+
expect(parseIncompleteMarkdown("__text")).toBe("__text__");
819+
expect(parseIncompleteMarkdown("*text")).toBe("*text*");
820+
expect(parseIncompleteMarkdown("_text")).toBe("_text_");
821+
expect(parseIncompleteMarkdown("~~text")).toBe("~~text~~");
822+
expect(parseIncompleteMarkdown("`text")).toBe("`text`");
823+
});
824+
796825
it("should handle very long text", () => {
797826
const longText = `${"a".repeat(10_000)} **bold`;
798827
const expected = `${"a".repeat(10_000)} **bold**`;
799828
expect(parseIncompleteMarkdown(longText)).toBe(expected);
800829
});
801830

802831
it("should handle text with only formatting characters", () => {
803-
expect(parseIncompleteMarkdown("*")).toBe("**");
804-
expect(parseIncompleteMarkdown("**")).toBe("****");
805-
expect(parseIncompleteMarkdown("`")).toBe("``");
832+
expect(parseIncompleteMarkdown("*")).toBe("*");
833+
expect(parseIncompleteMarkdown("**")).toBe("**");
834+
expect(parseIncompleteMarkdown("`")).toBe("`");
806835
});
807836

808837
it("should handle escaped characters", () => {
@@ -811,11 +840,11 @@ describe("parseIncompleteMarkdown", () => {
811840
});
812841

813842
it("should handle markdown at very end of string", () => {
814-
expect(parseIncompleteMarkdown("text**")).toBe("text****");
815-
expect(parseIncompleteMarkdown("text*")).toBe("text**");
816-
expect(parseIncompleteMarkdown("text`")).toBe("text``");
843+
expect(parseIncompleteMarkdown("text**")).toBe("text**");
844+
expect(parseIncompleteMarkdown("text*")).toBe("text*");
845+
expect(parseIncompleteMarkdown("text`")).toBe("text`");
817846
expect(parseIncompleteMarkdown("text$")).toBe("text$"); // Single dollar not completed
818-
expect(parseIncompleteMarkdown("text~~")).toBe("text~~~~");
847+
expect(parseIncompleteMarkdown("text~~")).toBe("text~~");
819848
});
820849

821850
it("should handle whitespace before incomplete markdown", () => {

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ const handleIncompleteBold = (text: string): string => {
4646
const boldMatch = text.match(boldPattern);
4747

4848
if (boldMatch) {
49+
// Don't close if there's no meaningful content after the opening markers
50+
// boldMatch[2] contains the content after **
51+
// Check if content is only whitespace or other emphasis markers
52+
const contentAfterMarker = boldMatch[2];
53+
if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
54+
return text;
55+
}
56+
4957
const asteriskPairs = (text.match(/\*\*/g) || []).length;
5058
if (asteriskPairs % 2 === 1) {
5159
return `${text}**`;
@@ -60,6 +68,14 @@ const handleIncompleteDoubleUnderscoreItalic = (text: string): string => {
6068
const italicMatch = text.match(italicPattern);
6169

6270
if (italicMatch) {
71+
// Don't close if there's no meaningful content after the opening markers
72+
// italicMatch[2] contains the content after __
73+
// Check if content is only whitespace or other emphasis markers
74+
const contentAfterMarker = italicMatch[2];
75+
if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
76+
return text;
77+
}
78+
6379
const underscorePairs = (text.match(/__/g) || []).length;
6480
if (underscorePairs % 2 === 1) {
6581
return `${text}__`;
@@ -119,6 +135,28 @@ const handleIncompleteSingleAsteriskItalic = (text: string): string => {
119135
const singleAsteriskMatch = text.match(singleAsteriskPattern);
120136

121137
if (singleAsteriskMatch) {
138+
// Find the first single asterisk position (not part of **)
139+
let firstSingleAsteriskIndex = -1;
140+
for (let i = 0; i < text.length; i++) {
141+
if (text[i] === '*' && text[i-1] !== '*' && text[i+1] !== '*') {
142+
firstSingleAsteriskIndex = i;
143+
break;
144+
}
145+
}
146+
147+
if (firstSingleAsteriskIndex === -1) {
148+
return text;
149+
}
150+
151+
// Get content after the first single asterisk
152+
const contentAfterFirstAsterisk = text.substring(firstSingleAsteriskIndex + 1);
153+
154+
// Check if there's meaningful content after the asterisk
155+
// Don't close if content is only whitespace or emphasis markers
156+
if (!contentAfterFirstAsterisk || /^[\s_~*`]*$/.test(contentAfterFirstAsterisk)) {
157+
return text;
158+
}
159+
122160
const singleAsterisks = countSingleAsterisks(text);
123161
if (singleAsterisks % 2 === 1) {
124162
return `${text}*`;
@@ -189,6 +227,28 @@ const handleIncompleteSingleUnderscoreItalic = (text: string): string => {
189227
const singleUnderscoreMatch = text.match(singleUnderscorePattern);
190228

191229
if (singleUnderscoreMatch) {
230+
// Find the first single underscore position (not part of __)
231+
let firstSingleUnderscoreIndex = -1;
232+
for (let i = 0; i < text.length; i++) {
233+
if (text[i] === '_' && text[i-1] !== '_' && text[i+1] !== '_' && !isWithinMathBlock(text, i)) {
234+
firstSingleUnderscoreIndex = i;
235+
break;
236+
}
237+
}
238+
239+
if (firstSingleUnderscoreIndex === -1) {
240+
return text;
241+
}
242+
243+
// Get content after the first single underscore
244+
const contentAfterFirstUnderscore = text.substring(firstSingleUnderscoreIndex + 1);
245+
246+
// Check if there's meaningful content after the underscore
247+
// Don't close if content is only whitespace or emphasis markers
248+
if (!contentAfterFirstUnderscore || /^[\s_~*`]*$/.test(contentAfterFirstUnderscore)) {
249+
return text;
250+
}
251+
192252
const singleUnderscores = countSingleUnderscores(text);
193253
if (singleUnderscores % 2 === 1) {
194254
return `${text}_`;
@@ -260,6 +320,14 @@ const handleIncompleteInlineCode = (text: string): string => {
260320
const inlineCodeMatch = text.match(inlineCodePattern);
261321

262322
if (inlineCodeMatch && !insideIncompleteCodeBlock) {
323+
// Don't close if there's no meaningful content after the opening marker
324+
// inlineCodeMatch[2] contains the content after `
325+
// Check if content is only whitespace or other emphasis markers
326+
const contentAfterMarker = inlineCodeMatch[2];
327+
if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
328+
return text;
329+
}
330+
263331
const singleBacktickCount = countSingleBackticks(text);
264332
if (singleBacktickCount % 2 === 1) {
265333
return `${text}\``;
@@ -274,6 +342,14 @@ const handleIncompleteStrikethrough = (text: string): string => {
274342
const strikethroughMatch = text.match(strikethroughPattern);
275343

276344
if (strikethroughMatch) {
345+
// Don't close if there's no meaningful content after the opening markers
346+
// strikethroughMatch[2] contains the content after ~~
347+
// Check if content is only whitespace or other emphasis markers
348+
const contentAfterMarker = strikethroughMatch[2];
349+
if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
350+
return text;
351+
}
352+
277353
const tildePairs = (text.match(/~~/g) || []).length;
278354
if (tildePairs % 2 === 1) {
279355
return `${text}~~`;
@@ -359,6 +435,14 @@ const handleIncompleteBoldItalic = (text: string): string => {
359435
const boldItalicMatch = text.match(boldItalicPattern);
360436

361437
if (boldItalicMatch) {
438+
// Don't close if there's no meaningful content after the opening markers
439+
// boldItalicMatch[2] contains the content after ***
440+
// Check if content is only whitespace or other emphasis markers
441+
const contentAfterMarker = boldItalicMatch[2];
442+
if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
443+
return text;
444+
}
445+
362446
const tripleAsteriskCount = countTripleAsterisks(text);
363447
if (tripleAsteriskCount % 2 === 1) {
364448
return `${text}***`;

0 commit comments

Comments
 (0)