fix(editor): skip fenced blocks when scanning for code spans

A backtick inside a fenced block paired with a later prose backtick,
producing a span that covered everything between and hid any <details>
block in that stretch from the tokenizer.
This commit is contained in:
Frederic Barthelemy
2026-09-18 00:33:10 -07:00
committed by Neil
parent 4dbc6ffcae
commit 65ceead55b
2 changed files with 117 additions and 1 deletions
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { findDetailsBlockStart } from './details-markdown-html'
import { markdownCodeSpanRanges, markdownFenceRanges } from './markdown-scan-ranges'
describe('markdownCodeSpanRanges', () => {
it('reports a span whose backtick run closes on an equal run', () => {
expect(markdownCodeSpanRanges('a `<details>` b')).toEqual([[2, 13]])
})
it('keeps a span that contains a blank line', () => {
expect(markdownCodeSpanRanges('text `a\n\nb` tail')).toEqual([[5, 11]])
})
it('reports no span for backticks inside a fenced block', () => {
expect(markdownCodeSpanRanges(['```', 'a `b` c', '```'].join('\n'))).toEqual([])
})
it('does not pair a fence backtick with a later prose backtick', () => {
const content = ['~~~', 'x = `abc', '~~~', '', 'prose with `code` here'].join('\n')
const spans = markdownCodeSpanRanges(content)
expect(spans).toEqual([[29, 35]])
expect(content.slice(29, 35)).toBe('`code`')
})
it('does not run a span through a four-backtick fence closer', () => {
const content = ['```', 'code', '````', '', 'tail', '', '```', 'more', '```'].join('\n')
expect(markdownCodeSpanRanges(content)).toEqual([])
})
})
describe('findDetailsBlockStart with fenced content', () => {
it('finds a details block after a fence whose content holds an unpaired backtick', () => {
const content = [
'~~~',
'x = `abc',
'~~~',
'',
'<details>',
'<summary>S</summary>',
'',
'body',
'',
'</details>',
'',
'prose with `code` here'
].join('\n')
expect(findDetailsBlockStart(content)).toBe(content.indexOf('<details>'))
})
it('finds a details block between a four-backtick closer and a later fence', () => {
const content = [
'```',
'code',
'````',
'',
'<details>',
'<summary>S</summary>',
'',
'x',
'',
'</details>',
'',
'```',
'more',
'```'
].join('\n')
expect(findDetailsBlockStart(content)).toBe(content.indexOf('<details>'))
})
it('skips a details mention inside a code span that spans a blank line', () => {
expect(findDetailsBlockStart('text `a\n\n<details>b` tail')).toBe(-1)
})
})
describe('markdownFenceRanges', () => {
it('covers the opening delimiter, content, and closing delimiter', () => {
const content = ['```', 'x', '```', ''].join('\n')
expect(markdownFenceRanges(content)).toEqual([[0, 10]])
})
it('runs an unterminated fence to the end of the content', () => {
const content = ['```', 'x', 'y'].join('\n')
expect(markdownFenceRanges(content)).toEqual([[0, content.length]])
})
})
@@ -46,14 +46,35 @@ export function isInsideRange(index: number, ranges: MarkdownFenceRanges): boole
return ranges.some(([start, end]) => index >= start && index < end)
}
function rangeEndAt(index: number, ranges: MarkdownFenceRanges): number {
for (const [start, end] of ranges) {
if (index >= start && index < end) {
return end
}
}
return -1
}
// CommonMark code spans: a backtick run only closes on a run of the same
// length, so `` `<details>` `` is one span even though `<details>` alone
// isn't. Mirrors the tick-matching in raw-markdown-html.ts's inline scan.
export function markdownCodeSpanRanges(content: string): MarkdownFenceRanges {
// Fenced blocks are skipped whole — their delimiters and content are not
// inline code, and scanning them pairs a fence backtick with a later prose
// one, swallowing everything between. Blank lines are not span boundaries.
export function markdownCodeSpanRanges(
content: string,
fenceRanges: MarkdownFenceRanges = markdownFenceRanges(content)
): MarkdownFenceRanges {
const ranges: [number, number][] = []
let index = 0
while (index < content.length) {
const fenceEnd = rangeEndAt(index, fenceRanges)
if (fenceEnd !== -1) {
index = fenceEnd
continue
}
if (content[index] !== '`') {
index += 1
continue
@@ -72,6 +93,10 @@ export function markdownCodeSpanRanges(content: string): MarkdownFenceRanges {
if (candidate === -1) {
break
}
if (rangeEndAt(candidate, fenceRanges) !== -1) {
searchFrom = candidate + 1
continue
}
if (
(candidate === 0 || content[candidate - 1] !== '`') &&
content[candidate + tickCount] !== '`'