Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 57 additions & 13 deletions packages/comark/src/internal/parse/auto-close/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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++
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions packages/comark/test/auto-close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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***')
})
})
Loading