Skip to content

Commit 6374fbf

Browse files
committed
Fix mermaid asterisk bug
1 parent 6363be1 commit 6374fbf

3 files changed

Lines changed: 228 additions & 14 deletions

File tree

.changeset/young-worms-train.md

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 stray asterisks stemming from mermaid diagrams

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,64 @@ Some notes:
212212
expect(result).not.toMatch(trailingDoubleUnderscorePattern);
213213
});
214214

215+
it("should not add stray * from [*] in mermaid code blocks", () => {
216+
const input = `Here's a state diagram:
217+
218+
\`\`\`mermaid
219+
stateDiagram-v2
220+
[*] --> Idle
221+
Idle --> Loading: fetch()
222+
Loading --> Success: 200 OK
223+
Loading --> Error: 4xx/5xx
224+
Error --> Loading: retry()
225+
Success --> Idle: reset()
226+
\`\`\``;
227+
228+
const result = remend(input);
229+
expect(result).toBe(input);
230+
});
231+
232+
it("should not add stray * from [*] in incomplete mermaid code blocks (streaming)", () => {
233+
const input = `Here's a state diagram:
234+
235+
\`\`\`mermaid
236+
stateDiagram-v2
237+
[*] --> Idle
238+
Idle --> Loading: fetch()`;
239+
240+
const result = remend(input);
241+
expect(result).toBe(input);
242+
});
243+
244+
it("should not add stray * when emphasis exists outside code block with [*] inside", () => {
245+
const input = `*Note:* Here's a state diagram:
246+
247+
\`\`\`mermaid
248+
stateDiagram-v2
249+
[*] --> Idle
250+
\`\`\``;
251+
252+
const result = remend(input);
253+
expect(result).toBe(input);
254+
});
255+
256+
it("should still complete emphasis when * is only outside code blocks", () => {
257+
const input = `\`\`\`mermaid
258+
stateDiagram-v2
259+
[*] --> Idle
260+
\`\`\`
261+
262+
Here is *incomplete italic`;
263+
264+
const result = remend(input);
265+
expect(result).toBe(`\`\`\`mermaid
266+
stateDiagram-v2
267+
[*] --> Idle
268+
\`\`\`
269+
270+
Here is *incomplete italic*`);
271+
});
272+
215273
it("should handle incomplete markdown after code block (#302)", () => {
216274
const text = `\`\`\`css
217275
code here

packages/remend/src/emphasis-handlers.ts

Lines changed: 165 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {
22
boldItalicPattern,
33
boldPattern,
4-
doubleUnderscoreGlobalPattern,
54
fourOrMoreAsterisksPattern,
65
halfCompleteUnderscorePattern,
76
italicPattern,
@@ -108,12 +107,31 @@ const shouldSkipAsterisk = (
108107
};
109108

110109
// OPTIMIZATION: Counts single asterisks without split("").reduce()
111-
// Counts single asterisks that are not part of double asterisks, not escaped, not list markers, and not word-internal
110+
// Counts single asterisks that are not part of double asterisks, not escaped, not list markers, not word-internal,
111+
// and not inside fenced code blocks
112112
export const countSingleAsterisks = (text: string): number => {
113113
let count = 0;
114+
let inCodeBlock = false;
114115
const len = text.length;
115116

116117
for (let index = 0; index < len; index += 1) {
118+
// Track fenced code blocks (```)
119+
if (
120+
text[index] === "`" &&
121+
index + 2 < len &&
122+
text[index + 1] === "`" &&
123+
text[index + 2] === "`"
124+
) {
125+
inCodeBlock = !inCodeBlock;
126+
index += 2;
127+
continue;
128+
}
129+
130+
// Skip content inside fenced code blocks
131+
if (inCodeBlock) {
132+
continue;
133+
}
134+
117135
if (text[index] !== "*") {
118136
continue;
119137
}
@@ -171,12 +189,31 @@ const shouldSkipUnderscore = (
171189
};
172190

173191
// OPTIMIZATION: Counts single underscores without split("").reduce()
174-
// Counts single underscores that are not part of double underscores, not escaped, and not in math blocks
192+
// Counts single underscores that are not part of double underscores, not escaped, not in math blocks,
193+
// and not inside fenced code blocks
175194
export const countSingleUnderscores = (text: string): number => {
176195
let count = 0;
196+
let inCodeBlock = false;
177197
const len = text.length;
178198

179199
for (let index = 0; index < len; index += 1) {
200+
// Track fenced code blocks (```)
201+
if (
202+
text[index] === "`" &&
203+
index + 2 < len &&
204+
text[index + 1] === "`" &&
205+
text[index + 2] === "`"
206+
) {
207+
inCodeBlock = !inCodeBlock;
208+
index += 2;
209+
continue;
210+
}
211+
212+
// Skip content inside fenced code blocks
213+
if (inCodeBlock) {
214+
continue;
215+
}
216+
180217
if (text[index] !== "_") {
181218
continue;
182219
}
@@ -193,13 +230,37 @@ export const countSingleUnderscores = (text: string): number => {
193230
};
194231

195232
// Counts triple asterisks that are not part of quadruple or more asterisks
233+
// and not inside fenced code blocks
196234
// OPTIMIZATION: Count *** without regex to avoid allocation
197235
export const countTripleAsterisks = (text: string): number => {
198236
let count = 0;
199237
let consecutiveAsterisks = 0;
238+
let inCodeBlock = false;
200239

201240
// biome-ignore lint/style/useForOf: "Need index access to check character codes for performance"
202241
for (let i = 0; i < text.length; i += 1) {
242+
// Track fenced code blocks (```)
243+
if (
244+
text[i] === "`" &&
245+
i + 2 < text.length &&
246+
text[i + 1] === "`" &&
247+
text[i + 2] === "`"
248+
) {
249+
// Flush any pending asterisks before toggling
250+
if (consecutiveAsterisks >= 3) {
251+
count += Math.floor(consecutiveAsterisks / 3);
252+
}
253+
consecutiveAsterisks = 0;
254+
inCodeBlock = !inCodeBlock;
255+
i += 2;
256+
continue;
257+
}
258+
259+
// Skip content inside fenced code blocks
260+
if (inCodeBlock) {
261+
continue;
262+
}
263+
203264
if (text[i] === "*") {
204265
consecutiveAsterisks += 1;
205266
} else {
@@ -219,6 +280,60 @@ export const countTripleAsterisks = (text: string): number => {
219280
return count;
220281
};
221282

283+
// Counts ** pairs outside fenced code blocks
284+
const countDoubleAsterisksOutsideCodeBlocks = (text: string): number => {
285+
let count = 0;
286+
let inCodeBlock = false;
287+
288+
for (let i = 0; i < text.length; i += 1) {
289+
if (
290+
text[i] === "`" &&
291+
i + 2 < text.length &&
292+
text[i + 1] === "`" &&
293+
text[i + 2] === "`"
294+
) {
295+
inCodeBlock = !inCodeBlock;
296+
i += 2;
297+
continue;
298+
}
299+
if (inCodeBlock) {
300+
continue;
301+
}
302+
if (text[i] === "*" && i + 1 < text.length && text[i + 1] === "*") {
303+
count += 1;
304+
i += 1;
305+
}
306+
}
307+
return count;
308+
};
309+
310+
// Counts __ pairs outside fenced code blocks
311+
const countDoubleUnderscoresOutsideCodeBlocks = (text: string): number => {
312+
let count = 0;
313+
let inCodeBlock = false;
314+
315+
for (let i = 0; i < text.length; i += 1) {
316+
if (
317+
text[i] === "`" &&
318+
i + 2 < text.length &&
319+
text[i + 1] === "`" &&
320+
text[i + 2] === "`"
321+
) {
322+
inCodeBlock = !inCodeBlock;
323+
i += 2;
324+
continue;
325+
}
326+
if (inCodeBlock) {
327+
continue;
328+
}
329+
if (text[i] === "_" && i + 1 < text.length && text[i + 1] === "_") {
330+
count += 1;
331+
i += 1;
332+
}
333+
}
334+
return count;
335+
};
336+
222337
// Helper to check if bold marker should not be completed
223338
const shouldSkipBoldCompletion = (
224339
text: string,
@@ -268,7 +383,7 @@ export const handleIncompleteBold = (text: string): string => {
268383
return text;
269384
}
270385

271-
const asteriskPairs = (text.match(/\*\*/g) || []).length;
386+
const asteriskPairs = countDoubleAsterisksOutsideCodeBlocks(text);
272387
if (asteriskPairs % 2 === 1) {
273388
// Check for half-complete closing marker: **content* should become **content**
274389
// The trailing * is the first char of the closing ** being streamed
@@ -324,9 +439,8 @@ export const handleIncompleteDoubleUnderscoreItalic = (
324439
if (halfCompleteMatch) {
325440
const markerIndex = text.lastIndexOf(halfCompleteMatch[1]);
326441
if (!isWithinCodeBlock(text, markerIndex)) {
327-
const underscorePairs = (
328-
text.match(doubleUnderscoreGlobalPattern) || []
329-
).length;
442+
const underscorePairs =
443+
countDoubleUnderscoresOutsideCodeBlocks(text);
330444
if (underscorePairs % 2 === 1) {
331445
return `${text}_`;
332446
}
@@ -347,17 +461,36 @@ export const handleIncompleteDoubleUnderscoreItalic = (
347461
return text;
348462
}
349463

350-
const underscorePairs = (text.match(/__/g) || []).length;
464+
const underscorePairs = countDoubleUnderscoresOutsideCodeBlocks(text);
351465
if (underscorePairs % 2 === 1) {
352466
return `${text}__`;
353467
}
354468

355469
return text;
356470
};
357471

358-
// Helper function to find the first single asterisk index
472+
// Helper function to find the first single asterisk index (skips fenced code blocks)
359473
const findFirstSingleAsteriskIndex = (text: string): number => {
474+
let inCodeBlock = false;
475+
360476
for (let i = 0; i < text.length; i += 1) {
477+
// Track fenced code blocks (```)
478+
if (
479+
text[i] === "`" &&
480+
i + 2 < text.length &&
481+
text[i + 1] === "`" &&
482+
text[i + 2] === "`"
483+
) {
484+
inCodeBlock = !inCodeBlock;
485+
i += 2;
486+
continue;
487+
}
488+
489+
// Skip content inside fenced code blocks
490+
if (inCodeBlock) {
491+
continue;
492+
}
493+
361494
if (
362495
text[i] === "*" &&
363496
text[i - 1] !== "*" &&
@@ -434,9 +567,28 @@ export const handleIncompleteSingleAsteriskItalic = (text: string): string => {
434567
return text;
435568
};
436569

437-
// Helper function to find the first single underscore index
570+
// Helper function to find the first single underscore index (skips fenced code blocks)
438571
const findFirstSingleUnderscoreIndex = (text: string): number => {
572+
let inCodeBlock = false;
573+
439574
for (let i = 0; i < text.length; i += 1) {
575+
// Track fenced code blocks (```)
576+
if (
577+
text[i] === "`" &&
578+
i + 2 < text.length &&
579+
text[i + 1] === "`" &&
580+
text[i + 2] === "`"
581+
) {
582+
inCodeBlock = !inCodeBlock;
583+
i += 2;
584+
continue;
585+
}
586+
587+
// Skip content inside fenced code blocks
588+
if (inCodeBlock) {
589+
continue;
590+
}
591+
440592
if (
441593
text[i] === "_" &&
442594
text[i - 1] !== "_" &&
@@ -486,9 +638,8 @@ const handleTrailingAsterisksForUnderscore = (text: string): string | null => {
486638
}
487639

488640
const textWithoutTrailingAsterisks = text.slice(0, -2);
489-
const asteriskPairsAfterRemoval = (
490-
textWithoutTrailingAsterisks.match(/\*\*/g) || []
491-
).length;
641+
const asteriskPairsAfterRemoval =
642+
countDoubleAsterisksOutsideCodeBlocks(textWithoutTrailingAsterisks);
492643

493644
// If removing trailing ** makes the count odd, it was added to close an unclosed **
494645
if (asteriskPairsAfterRemoval % 2 !== 1) {
@@ -562,7 +713,7 @@ export const handleIncompleteSingleUnderscoreItalic = (
562713

563714
// Helper to check if bold-italic markers are already balanced
564715
const areBoldItalicMarkersBalanced = (text: string): boolean => {
565-
const asteriskPairs = (text.match(/\*\*/g) || []).length;
716+
const asteriskPairs = countDoubleAsterisksOutsideCodeBlocks(text);
566717
const singleAsterisks = countSingleAsterisks(text);
567718
return asteriskPairs % 2 === 0 && singleAsterisks % 2 === 0;
568719
};

0 commit comments

Comments
 (0)