diff --git a/packages/comark/src/internal/parse/auto-close/index.ts b/packages/comark/src/internal/parse/auto-close/index.ts index b6db5172..28e7c02e 100644 --- a/packages/comark/src/internal/parse/auto-close/index.ts +++ b/packages/comark/src/internal/parse/auto-close/index.ts @@ -254,10 +254,34 @@ function closeInlineMarkersLinear(line: string): string { let inAttributes = 0 let inLinkText = 0 let inLinkUrl = 0 + let linkTextBacktickCount = 0 + // Markers inside an inline code span (`...`) or math ($...$) are literal text + let inCode = false + let inMath = false for (let i = 0; i < len; i++) { const prevCh = i > 0 ? line[i - 1] : '' const ch = line[i] + if (ch === '\\') { + i++ // Backslash escapes the next char, so neither is a delimiter + continue + } + + if (inCode) { + if (ch === '`') { + backtickCount++ + inCode = false + } + continue + } + if (inMath) { + if (ch === '$' && !(i + 1 < len && line[i + 1] === '$')) { + dollarCount++ + inMath = false + } + continue + } + if (ch === '{' && prevCh !== ' ') { inAttributes++ continue @@ -295,7 +319,28 @@ function closeInlineMarkersLinear(line: string): string { continue } - if (inLinkText > 0 || inLinkUrl > 0) continue + if (inLinkText > 0) { + if (ch === '`') linkTextBacktickCount++ + continue + } + if (inLinkUrl > 0) continue + + if (ch === '`') { + backtickCount++ + inCode = true + continue + } + if (ch === '$') { + if (i + 1 < len && line[i + 1] === '$') { + dollarPairCount++ // `$$` is block math, counted as a pair + dollarCount += 2 + i++ + } else { + dollarCount++ + inMath = true + } + continue + } if (ch === '*') { asteriskCount++ @@ -327,20 +372,17 @@ function closeInlineMarkersLinear(line: string): string { } else { singleTildeCount++ } - } else if (ch === '`') { - backtickCount++ - } else if (ch === '$' && prevCh !== '\\') { - // Count $$ pairs for block/display math - if (i + 1 < len && line[i + 1] === '$') { - dollarPairCount++ - dollarCount += 2 // Count both dollars in the pair - i++ // Skip next $ since we counted the pair - } else { - dollarCount++ // Single $ for inline math - } } } + // Open code/math region: close only it; everything after the opener is literal + if (inCode) { + return line + '`' + } + if (inMath) { + return line.trim() === '$$' ? line : line + '$' + } + // Check for complete ** pairs in O(1) - pairs are matched left to right const hasCompleteBoldPair = doubleAsteriskPositions.length >= 2 @@ -550,7 +592,9 @@ function closeInlineMarkersLinear(line: string): string { // Check [ ] (brackets) if (!closingSuffix && bracketBalance > 0) { - closingSuffix = ']' + // An odd backtick opened inside the unclosed link text is an unclosed + // inline code span; close it before the bracket so `]` stays outside it. + closingSuffix = linkTextBacktickCount % 2 === 1 ? '`]' : ']' } // Check ( ) (parens) diff --git a/packages/comark/test/auto-close.test.ts b/packages/comark/test/auto-close.test.ts index c7a3f73d..75966a50 100644 --- a/packages/comark/test/auto-close.test.ts +++ b/packages/comark/test/auto-close.test.ts @@ -560,6 +560,14 @@ describe('link', () => { const expected = 'https://errors.pydantic.dev/2.13/v/value_error' expect(autoCloseMarkdown(input)).toBe(expected) }) + + it('should close inline code inside unclosed link text', () => { + // Backtick must close before the bracket, else `]` lands inside the + // unclosed code span and renders as literal "`foo]" not a code link. + const input = '[`foo' + const expected = '[`foo`]' + expect(autoCloseMarkdown(input)).toBe(expected) + }) }) describe('attributes scope', () => { it('should ignore $ in inline attributes', () => { @@ -610,3 +618,91 @@ describe('attributes scope', () => { expect(autoCloseMarkdown(input)).toBe(expected) }) }) + +describe('inline code spans as literal regions', () => { + it('should not count asterisks inside a closed code span', () => { + const input = 'text `a*b` and **bold' + const expected = 'text `a*b` and **bold**' + expect(autoCloseMarkdown(input)).toBe(expected) + }) + + it('should not count underscores inside a closed code span', () => { + const input = 'see `a_b_c` then __bold' + const expected = 'see `a_b_c` then __bold__' + expect(autoCloseMarkdown(input)).toBe(expected) + }) + + it('should close an open code span without leaking an asterisk', () => { + const input = '`x * y' + const expected = '`x * y`' + expect(autoCloseMarkdown(input)).toBe(expected) + }) + + it('should close only the code span when bold wraps an open code span', () => { + const input = '**bold `code' + const expected = '**bold `code`' + expect(autoCloseMarkdown(input)).toBe(expected) + }) + + it('should treat an underscore inside an open code span as literal', () => { + const input = '`snake_case' + const expected = '`snake_case`' + expect(autoCloseMarkdown(input)).toBe(expected) + }) +}) + +describe('inline math as literal regions', () => { + it('should not count asterisks inside closed inline math', () => { + const input = '$a * b$ and *italic' + const expected = '$a * b$ and *italic*' + expect(autoCloseMarkdown(input)).toBe(expected) + }) + + it('should not count an underscore inside an open math region', () => { + const input = 'value $x_0' + const expected = 'value $x_0$' + expect(autoCloseMarkdown(input)).toBe(expected) + }) + + it('should close inline math without leaking an asterisk', () => { + const input = '$a * b' + const expected = '$a * b$' + expect(autoCloseMarkdown(input)).toBe(expected) + }) +}) + +describe('escaped markers', () => { + it('should not count an escaped asterisk as a delimiter', () => { + const input = 'a \\* b *it' + const expected = 'a \\* b *it*' + expect(autoCloseMarkdown(input)).toBe(expected) + }) + + it('should not count an escaped underscore as a delimiter', () => { + const input = 'a \\_ b _it' + const expected = 'a \\_ b _it_' + expect(autoCloseMarkdown(input)).toBe(expected) + }) + + it('should not count an escaped backtick as a code span', () => { + const input = 'literal \\` then `code' + const expected = 'literal \\` then `code`' + expect(autoCloseMarkdown(input)).toBe(expected) + }) + + it('should leave a fully escaped marker untouched', () => { + const input = 'just a \\* star' + expect(autoCloseMarkdown(input)).toBe(input) + }) +}) + +describe('nested emphasis (known limitations)', () => { + // Mixed-marker nesting needs CommonMark flanking resolution, out of scope here + it.todo('should close nested bold + underscore-italic (**_text -> **_text_**)', () => { + expect(autoCloseMarkdown('**_text')).toBe('**_text_**') + }) + + it.todo('should close nested italic + bold (*a **b -> *a **b***)', () => { + expect(autoCloseMarkdown('*a **b')).toBe('*a **b***') + }) +})