From 4f966752043c33bc2104a51404d16418141e301b Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 9 Jun 2026 08:38:53 +0000 Subject: [PATCH 1/2] fix(comark): close inline code inside unclosed link text during auto-close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When auto-closing a partial stream where link text wraps inline code (e.g. `[`foo`), the scanner skipped all markers inside link text and dropped the unclosed backtick, producing `[`foo]` — the bracket closer landed inside the unclosed code span and rendered as literal text. Track backticks opened inside unclosed link text and close the code span before the bracket when odd. --- .../comark/src/internal/parse/auto-close/index.ts | 11 +++++++++-- packages/comark/test/auto-close.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/comark/src/internal/parse/auto-close/index.ts b/packages/comark/src/internal/parse/auto-close/index.ts index b6db5172..5d454243 100644 --- a/packages/comark/src/internal/parse/auto-close/index.ts +++ b/packages/comark/src/internal/parse/auto-close/index.ts @@ -254,6 +254,7 @@ function closeInlineMarkersLinear(line: string): string { let inAttributes = 0 let inLinkText = 0 let inLinkUrl = 0 + let linkTextBacktickCount = 0 for (let i = 0; i < len; i++) { const prevCh = i > 0 ? line[i - 1] : '' const ch = line[i] @@ -295,7 +296,11 @@ 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 === '*') { asteriskCount++ @@ -550,7 +555,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..91cceff2 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', () => { From f56a7146fe4900e9821f2e39b8247e3bbc1f4c0c Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 10 Jun 2026 02:22:37 +0000 Subject: [PATCH 2/2] fix(comark): treat code spans, math, and escapes as literal in auto-close Inline emphasis markers (* _ ~) inside an inline code span or math region are literal text, not delimiters, and a backslash escapes the following marker. The counting-based inline closer previously counted all of these, corrupting marker parity and emitting stray closers: - asterisks inside a closed code span/math threw off bold/italic parity - an open code span/math got a spurious emphasis closer appended - escaped markers (\*) were counted as delimiters Track code/math literal regions during the single O(n) scan and skip escaped characters. When a line ends inside an open code/math region, close only that region and suppress emphasis closers. Genuine nested emphasis of mixed marker types remains a documented limitation. --- .../src/internal/parse/auto-close/index.ts | 59 ++++++++++--- packages/comark/test/auto-close.test.ts | 88 +++++++++++++++++++ 2 files changed, 136 insertions(+), 11 deletions(-) diff --git a/packages/comark/src/internal/parse/auto-close/index.ts b/packages/comark/src/internal/parse/auto-close/index.ts index 5d454243..28e7c02e 100644 --- a/packages/comark/src/internal/parse/auto-close/index.ts +++ b/packages/comark/src/internal/parse/auto-close/index.ts @@ -255,10 +255,33 @@ function closeInlineMarkersLinear(line: string): string { 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 @@ -302,6 +325,23 @@ function closeInlineMarkersLinear(line: string): string { } 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++ // Track ** positions (not part of ***) @@ -332,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 diff --git a/packages/comark/test/auto-close.test.ts b/packages/comark/test/auto-close.test.ts index 91cceff2..75966a50 100644 --- a/packages/comark/test/auto-close.test.ts +++ b/packages/comark/test/auto-close.test.ts @@ -618,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***') + }) +})