Skip to content

Commit c810aab

Browse files
authored
fix(comark): harden auto-close for code spans, math, escapes, and link text (#233)
1 parent ab25206 commit c810aab

2 files changed

Lines changed: 153 additions & 13 deletions

File tree

packages/comark/src/internal/parse/auto-close/index.ts

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,34 @@ function closeInlineMarkersLinear(line: string): string {
254254
let inAttributes = 0
255255
let inLinkText = 0
256256
let inLinkUrl = 0
257+
let linkTextBacktickCount = 0
258+
// Markers inside an inline code span (`...`) or math ($...$) are literal text
259+
let inCode = false
260+
let inMath = false
257261
for (let i = 0; i < len; i++) {
258262
const prevCh = i > 0 ? line[i - 1] : ''
259263
const ch = line[i]
260264

265+
if (ch === '\\') {
266+
i++ // Backslash escapes the next char, so neither is a delimiter
267+
continue
268+
}
269+
270+
if (inCode) {
271+
if (ch === '`') {
272+
backtickCount++
273+
inCode = false
274+
}
275+
continue
276+
}
277+
if (inMath) {
278+
if (ch === '$' && !(i + 1 < len && line[i + 1] === '$')) {
279+
dollarCount++
280+
inMath = false
281+
}
282+
continue
283+
}
284+
261285
if (ch === '{' && prevCh !== ' ') {
262286
inAttributes++
263287
continue
@@ -295,7 +319,28 @@ function closeInlineMarkersLinear(line: string): string {
295319
continue
296320
}
297321

298-
if (inLinkText > 0 || inLinkUrl > 0) continue
322+
if (inLinkText > 0) {
323+
if (ch === '`') linkTextBacktickCount++
324+
continue
325+
}
326+
if (inLinkUrl > 0) continue
327+
328+
if (ch === '`') {
329+
backtickCount++
330+
inCode = true
331+
continue
332+
}
333+
if (ch === '$') {
334+
if (i + 1 < len && line[i + 1] === '$') {
335+
dollarPairCount++ // `$$` is block math, counted as a pair
336+
dollarCount += 2
337+
i++
338+
} else {
339+
dollarCount++
340+
inMath = true
341+
}
342+
continue
343+
}
299344

300345
if (ch === '*') {
301346
asteriskCount++
@@ -327,20 +372,17 @@ function closeInlineMarkersLinear(line: string): string {
327372
} else {
328373
singleTildeCount++
329374
}
330-
} else if (ch === '`') {
331-
backtickCount++
332-
} else if (ch === '$' && prevCh !== '\\') {
333-
// Count $$ pairs for block/display math
334-
if (i + 1 < len && line[i + 1] === '$') {
335-
dollarPairCount++
336-
dollarCount += 2 // Count both dollars in the pair
337-
i++ // Skip next $ since we counted the pair
338-
} else {
339-
dollarCount++ // Single $ for inline math
340-
}
341375
}
342376
}
343377

378+
// Open code/math region: close only it; everything after the opener is literal
379+
if (inCode) {
380+
return line + '`'
381+
}
382+
if (inMath) {
383+
return line.trim() === '$$' ? line : line + '$'
384+
}
385+
344386
// Check for complete ** pairs in O(1) - pairs are matched left to right
345387
const hasCompleteBoldPair = doubleAsteriskPositions.length >= 2
346388

@@ -550,7 +592,9 @@ function closeInlineMarkersLinear(line: string): string {
550592

551593
// Check [ ] (brackets)
552594
if (!closingSuffix && bracketBalance > 0) {
553-
closingSuffix = ']'
595+
// An odd backtick opened inside the unclosed link text is an unclosed
596+
// inline code span; close it before the bracket so `]` stays outside it.
597+
closingSuffix = linkTextBacktickCount % 2 === 1 ? '`]' : ']'
554598
}
555599

556600
// Check ( ) (parens)

packages/comark/test/auto-close.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,14 @@ describe('link', () => {
560560
const expected = 'https://errors.pydantic.dev/2.13/v/value_error'
561561
expect(autoCloseMarkdown(input)).toBe(expected)
562562
})
563+
564+
it('should close inline code inside unclosed link text', () => {
565+
// Backtick must close before the bracket, else `]` lands inside the
566+
// unclosed code span and renders as literal "`foo]" not a code link.
567+
const input = '[`foo'
568+
const expected = '[`foo`]'
569+
expect(autoCloseMarkdown(input)).toBe(expected)
570+
})
563571
})
564572
describe('attributes scope', () => {
565573
it('should ignore $ in inline attributes', () => {
@@ -610,3 +618,91 @@ describe('attributes scope', () => {
610618
expect(autoCloseMarkdown(input)).toBe(expected)
611619
})
612620
})
621+
622+
describe('inline code spans as literal regions', () => {
623+
it('should not count asterisks inside a closed code span', () => {
624+
const input = 'text `a*b` and **bold'
625+
const expected = 'text `a*b` and **bold**'
626+
expect(autoCloseMarkdown(input)).toBe(expected)
627+
})
628+
629+
it('should not count underscores inside a closed code span', () => {
630+
const input = 'see `a_b_c` then __bold'
631+
const expected = 'see `a_b_c` then __bold__'
632+
expect(autoCloseMarkdown(input)).toBe(expected)
633+
})
634+
635+
it('should close an open code span without leaking an asterisk', () => {
636+
const input = '`x * y'
637+
const expected = '`x * y`'
638+
expect(autoCloseMarkdown(input)).toBe(expected)
639+
})
640+
641+
it('should close only the code span when bold wraps an open code span', () => {
642+
const input = '**bold `code'
643+
const expected = '**bold `code`'
644+
expect(autoCloseMarkdown(input)).toBe(expected)
645+
})
646+
647+
it('should treat an underscore inside an open code span as literal', () => {
648+
const input = '`snake_case'
649+
const expected = '`snake_case`'
650+
expect(autoCloseMarkdown(input)).toBe(expected)
651+
})
652+
})
653+
654+
describe('inline math as literal regions', () => {
655+
it('should not count asterisks inside closed inline math', () => {
656+
const input = '$a * b$ and *italic'
657+
const expected = '$a * b$ and *italic*'
658+
expect(autoCloseMarkdown(input)).toBe(expected)
659+
})
660+
661+
it('should not count an underscore inside an open math region', () => {
662+
const input = 'value $x_0'
663+
const expected = 'value $x_0$'
664+
expect(autoCloseMarkdown(input)).toBe(expected)
665+
})
666+
667+
it('should close inline math without leaking an asterisk', () => {
668+
const input = '$a * b'
669+
const expected = '$a * b$'
670+
expect(autoCloseMarkdown(input)).toBe(expected)
671+
})
672+
})
673+
674+
describe('escaped markers', () => {
675+
it('should not count an escaped asterisk as a delimiter', () => {
676+
const input = 'a \\* b *it'
677+
const expected = 'a \\* b *it*'
678+
expect(autoCloseMarkdown(input)).toBe(expected)
679+
})
680+
681+
it('should not count an escaped underscore as a delimiter', () => {
682+
const input = 'a \\_ b _it'
683+
const expected = 'a \\_ b _it_'
684+
expect(autoCloseMarkdown(input)).toBe(expected)
685+
})
686+
687+
it('should not count an escaped backtick as a code span', () => {
688+
const input = 'literal \\` then `code'
689+
const expected = 'literal \\` then `code`'
690+
expect(autoCloseMarkdown(input)).toBe(expected)
691+
})
692+
693+
it('should leave a fully escaped marker untouched', () => {
694+
const input = 'just a \\* star'
695+
expect(autoCloseMarkdown(input)).toBe(input)
696+
})
697+
})
698+
699+
describe('nested emphasis (known limitations)', () => {
700+
// Mixed-marker nesting needs CommonMark flanking resolution, out of scope here
701+
it.todo('should close nested bold + underscore-italic (**_text -> **_text_**)', () => {
702+
expect(autoCloseMarkdown('**_text')).toBe('**_text_**')
703+
})
704+
705+
it.todo('should close nested italic + bold (*a **b -> *a **b***)', () => {
706+
expect(autoCloseMarkdown('*a **b')).toBe('*a **b***')
707+
})
708+
})

0 commit comments

Comments
 (0)