Skip to content

Commit a725579

Browse files
authored
fix(remend): don't complete emphasis/strikethrough markers inside complete inline code spans (#426)
* fix(remend): don't complete bold/italic/strikethrough inside complete inline code spans Bold, italic, and strikethrough handlers were using isWithinCodeBlock() which only detects triple-backtick fenced code blocks. Markers inside single-backtick inline code spans (e.g. `**bold`) were incorrectly completed, leaking outside the code span. Fix: - Add isWithinCompleteInlineCode() to code-block-utils.ts that returns true only when a position is inside a *complete* (both delimiters present) inline code span. This preserves the existing streaming behavior where emphasis inside an *incomplete* inline code span (still being streamed) continues to be closed correctly. - Apply the check in handleIncompleteBold, handleIncompleteBoldItalic, handleIncompleteDoubleUnderscoreItalic, handleIncompleteSingleAsterisk- Italic, and handleIncompleteStrikethrough. Fixes #424 * fix(remend): add isWithinCompleteInlineCode guard to handleIncompleteSingleUnderscoreItalic * chore: add changeset for inline code emphasis fix --------- Co-authored-by: Dmitrii Troitskii <jsleitor@gmail.com>
1 parent 7b40437 commit a725579

File tree

6 files changed

+118
-8
lines changed

6 files changed

+118
-8
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"remend": patch
3+
---
4+
5+
fix: don't complete bold/italic/strikethrough markers inside complete inline code spans

packages/remend/__tests__/inline-code.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,21 @@ describe("inline code formatting (`)", () => {
6363
);
6464
});
6565
});
66+
67+
describe("emphasis markers inside inline code spans should not leak", () => {
68+
it("should not complete bold/italic/strikethrough if they are inside inline code", () => {
69+
expect(remend("`**bold`")).toBe("`**bold`");
70+
expect(remend("`*italic`")).toBe("`*italic`");
71+
expect(remend("`~~strikethrough`")).toBe("`~~strikethrough`");
72+
});
73+
74+
it("should still complete emphasis markers outside inline code", () => {
75+
expect(remend("**bold")).toBe("**bold**");
76+
expect(remend("*italic")).toBe("*italic*");
77+
expect(remend("~~strike")).toBe("~~strike~~");
78+
});
79+
80+
it("should complete emphasis after a closed inline code span", () => {
81+
expect(remend("`code` **bold")).toBe("`code` **bold**");
82+
});
83+
});

packages/remend/__tests__/mixed-formatting.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe("mixed formatting", () => {
5050
it("should handle deeply nested incomplete formatting", () => {
5151
// Formats are closed in the order they're processed
5252
expect(remend("**bold *italic `code ~~strike")).toBe(
53-
"**bold *italic `code ~~strike*`~~"
53+
"**bold *italic `code ~~strike*`"
5454
);
5555
});
5656

packages/remend/src/code-block-utils.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,40 @@ export const countSingleBackticks = (text: string): number => {
4040
}
4141
return count;
4242
};
43+
44+
// Check if a position is inside a COMPLETE inline code span (both opening and closing backtick present).
45+
// Returns false for incomplete inline code spans (streaming) so emphasis markers can still be completed.
46+
export const isWithinCompleteInlineCode = (
47+
text: string,
48+
position: number
49+
): boolean => {
50+
let inInlineCode = false;
51+
let inMultilineCode = false;
52+
let inlineCodeStart = -1;
53+
54+
for (let i = 0; i < text.length; i += 1) {
55+
// Check for triple backticks (multiline code blocks)
56+
if (text.substring(i, i + 3) === "```") {
57+
inMultilineCode = !inMultilineCode;
58+
i += 2;
59+
continue;
60+
}
61+
62+
// Only check for inline code if not in multiline code
63+
if (!inMultilineCode && text[i] === "`") {
64+
if (inInlineCode) {
65+
// Found closing backtick — check if position is inside this complete span
66+
if (inlineCodeStart < position && position < i) {
67+
return true;
68+
}
69+
inInlineCode = false;
70+
inlineCodeStart = -1;
71+
} else {
72+
inInlineCode = true;
73+
inlineCodeStart = i;
74+
}
75+
}
76+
}
77+
78+
return false;
79+
};

packages/remend/src/emphasis-handlers.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
isInsideCodeBlock,
3+
isWithinCompleteInlineCode,
4+
} from "./code-block-utils";
15
import {
26
boldItalicPattern,
37
boldPattern,
@@ -11,7 +15,6 @@ import {
1115
} from "./patterns";
1216
import {
1317
isHorizontalRule,
14-
isWithinCodeBlock,
1518
isWithinHtmlTag,
1619
isWithinLinkOrImageUrl,
1720
isWithinMathBlock,
@@ -340,8 +343,11 @@ export const handleIncompleteBold = (text: string): string => {
340343
const contentAfterMarker = boldMatch[2];
341344
const markerIndex = text.lastIndexOf(boldMatch[1]);
342345

343-
// Check if the bold marker is within a code block
344-
if (isWithinCodeBlock(text, markerIndex)) {
346+
// Check if the bold marker is within a code block (fenced or inline)
347+
if (
348+
isInsideCodeBlock(text, markerIndex) ||
349+
isWithinCompleteInlineCode(text, markerIndex)
350+
) {
345351
return text;
346352
}
347353

@@ -404,7 +410,12 @@ export const handleIncompleteDoubleUnderscoreItalic = (
404410
const halfCompleteMatch = text.match(halfCompleteUnderscorePattern);
405411
if (halfCompleteMatch) {
406412
const markerIndex = text.lastIndexOf(halfCompleteMatch[1]);
407-
if (!isWithinCodeBlock(text, markerIndex)) {
413+
if (
414+
!(
415+
isInsideCodeBlock(text, markerIndex) ||
416+
isWithinCompleteInlineCode(text, markerIndex)
417+
)
418+
) {
408419
const underscorePairs = countDoubleUnderscoresOutsideCodeBlocks(text);
409420
if (underscorePairs % 2 === 1) {
410421
return `${text}_`;
@@ -417,8 +428,11 @@ export const handleIncompleteDoubleUnderscoreItalic = (
417428
const contentAfterMarker = italicMatch[2];
418429
const markerIndex = text.lastIndexOf(italicMatch[1]);
419430

420-
// Check if the italic marker is within a code block
421-
if (isWithinCodeBlock(text, markerIndex)) {
431+
// Check if the italic marker is within a code block (fenced or inline)
432+
if (
433+
isInsideCodeBlock(text, markerIndex) ||
434+
isWithinCompleteInlineCode(text, markerIndex)
435+
) {
422436
return text;
423437
}
424438

@@ -506,6 +520,14 @@ export const handleIncompleteSingleAsteriskItalic = (text: string): string => {
506520
return text;
507521
}
508522

523+
// Don't close if the marker is inside a complete inline code span or fenced code block
524+
if (
525+
isInsideCodeBlock(text, firstSingleAsteriskIndex) ||
526+
isWithinCompleteInlineCode(text, firstSingleAsteriskIndex)
527+
) {
528+
return text;
529+
}
530+
509531
// Get content after the first single asterisk
510532
const contentAfterFirstAsterisk = text.substring(
511533
firstSingleAsteriskIndex + 1
@@ -655,6 +677,10 @@ export const handleIncompleteSingleUnderscoreItalic = (
655677
return text;
656678
}
657679

680+
if (isWithinCompleteInlineCode(text, firstSingleUnderscoreIndex)) {
681+
return text;
682+
}
683+
658684
const singleUnderscores = countSingleUnderscores(text);
659685
if (singleUnderscores % 2 === 1) {
660686
// Check if we need to insert _ before trailing ** for proper nesting
@@ -688,7 +714,10 @@ const shouldSkipBoldItalicCompletion = (
688714
return true;
689715
}
690716

691-
if (isWithinCodeBlock(text, markerIndex)) {
717+
if (
718+
isInsideCodeBlock(text, markerIndex) ||
719+
isWithinCompleteInlineCode(text, markerIndex)
720+
) {
692721
return true;
693722
}
694723

packages/remend/src/strikethrough-handler.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
isInsideCodeBlock,
3+
isWithinCompleteInlineCode,
4+
} from "./code-block-utils";
15
import {
26
doubleTildeGlobalPattern,
37
halfCompleteTildePattern,
@@ -21,6 +25,15 @@ export const handleIncompleteStrikethrough = (text: string): string => {
2125
return text;
2226
}
2327

28+
// Don't close if the marker is inside an inline code span or fenced code block
29+
const markerIndex = text.lastIndexOf(strikethroughMatch[1]);
30+
if (
31+
isInsideCodeBlock(text, markerIndex) ||
32+
isWithinCompleteInlineCode(text, markerIndex)
33+
) {
34+
return text;
35+
}
36+
2437
// doubleTildeGlobalPattern always matches when strikethroughPattern matched
2538
const tildePairs = text.match(doubleTildeGlobalPattern)?.length;
2639
if (tildePairs % 2 === 1) {
@@ -31,6 +44,14 @@ export const handleIncompleteStrikethrough = (text: string): string => {
3144
// The pattern /(~~)([^~]*?)$/ won't match ~~content~ because it ends with ~
3245
const halfCompleteMatch = text.match(halfCompleteTildePattern);
3346
if (halfCompleteMatch) {
47+
// Don't close if the marker is inside an inline code span or fenced code block
48+
const markerIndex = text.lastIndexOf(halfCompleteMatch[0].slice(0, 2));
49+
if (
50+
isInsideCodeBlock(text, markerIndex) ||
51+
isWithinCompleteInlineCode(text, markerIndex)
52+
) {
53+
return text;
54+
}
3455
// doubleTildeGlobalPattern always matches when halfCompleteTildePattern matched
3556
const tildePairs = text.match(doubleTildeGlobalPattern)?.length;
3657
if (tildePairs % 2 === 1) {

0 commit comments

Comments
 (0)