Skip to content

Commit d5088bc

Browse files
committed
fix: use lookbehind in highlightSpans to fix adjacent spans and start-of-line
The old pattern ([^`])`([^`]+?)` required consuming a preceding character as group 1, which caused two bugs: - Issue #2: spans at the start of a line were never matched because there was no preceding character to consume. - Issue #3: adjacent spans like `foo`(`bar`) were broken because the pattern consumed the last char of the first span ('o' from 'foo') and then treated that span's closing backtick as the opening of a new span, capturing '(' as the highlighted content. Replace the consuming group with a negative lookbehind (?<!['`'\\]) so the opening backtick is matched without consuming any preceding character. This fixes both bugs and also subsumes escape handling: a backtick preceded by \ is no longer treated as an opening delimiter. Closes #2 Closes #3
1 parent 3cb9e5b commit d5088bc

2 files changed

Lines changed: 65 additions & 39 deletions

File tree

Lines changed: 54 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,70 @@
11
import { describe, it, expect } from 'vitest';
22

3-
// Test the highlight-spans regex behaviour directly.
4-
// The bug (issue #2): a backtick span at the very start of a line was not
5-
// recognised because the pattern required a non-backtick character before the
6-
// opening backtick. The fix adds `^` as an alternative via the `m` flag.
7-
3+
// Mirror the fixed pattern from slideView.ts highlightBlockSpans.
4+
// Uses a lookbehind so no preceding character is consumed, fixing:
5+
// issue #2 – spans at the start of a line were never matched
6+
// issue #3 – adjacent spans like `foo`(`bar`) incorrectly captured (
87
function applyHighlightPattern(html: string): string {
9-
// Mirrors the fixed pattern in highlightBlockSpans (highlightSpans === true).
10-
const pattern = /(^|[^`])`([^`]+?)`/gm;
11-
return html.replace(pattern, (m, e, c) => {
12-
if (e === '\\') return m.slice(1);
13-
return e + `<span class="remark-code-span-highlighted">${c}</span>`;
8+
const pattern = /(?<![`\\])`([^`]+?)`/g;
9+
return html.replace(pattern, (_m, c) => {
10+
return `<span class="remark-code-span-highlighted">${c}</span>`;
1411
});
1512
}
1613

17-
describe('highlightSpans regex (issue #2)', () => {
18-
it('highlights a span preceded by a non-backtick character', () => {
19-
const result = applyHighlightPattern('call `method`(arg)');
20-
expect(result).toContain('<span class="remark-code-span-highlighted">method</span>');
21-
});
14+
describe('highlightSpans regex', () => {
15+
describe('basic highlighting', () => {
16+
it('highlights a span preceded by a non-backtick character', () => {
17+
const result = applyHighlightPattern('call `method`(arg)');
18+
expect(result).toContain('<span class="remark-code-span-highlighted">method</span>');
19+
expect(result).toContain('(arg)');
20+
});
2221

23-
it('highlights a span at the very start of a string', () => {
24-
const result = applyHighlightPattern('`highlightedMethod`(arg1, arg2)');
25-
expect(result).toContain('<span class="remark-code-span-highlighted">highlightedMethod</span>');
22+
it('does not treat fenced code delimiters (```) as highlight spans', () => {
23+
const result = applyHighlightPattern('```js\ncode\n```');
24+
expect(result).not.toContain('remark-code-span-highlighted');
25+
});
2626
});
2727

28-
it('highlights a span at the start of a new line', () => {
29-
const html = 'normalMethod(a)\n`highlightedMethod`(b)';
30-
const result = applyHighlightPattern(html);
31-
expect(result).toContain('<span class="remark-code-span-highlighted">highlightedMethod</span>');
32-
expect(result).toContain('normalMethod(a)');
33-
});
28+
describe('issue #2 – span at start of line', () => {
29+
it('highlights a span at the very start of a string', () => {
30+
const result = applyHighlightPattern('`highlightedMethod`(arg1, arg2)');
31+
expect(result).toContain('<span class="remark-code-span-highlighted">highlightedMethod</span>');
32+
});
3433

35-
it('does not double-highlight adjacent backtick spans', () => {
36-
const result = applyHighlightPattern('`a` and `b`');
37-
expect(result).toContain('<span class="remark-code-span-highlighted">a</span>');
38-
expect(result).toContain('<span class="remark-code-span-highlighted">b</span>');
34+
it('highlights a span at the start of a new line', () => {
35+
const html = 'normalMethod(a)\n`highlightedMethod`(b)';
36+
const result = applyHighlightPattern(html);
37+
expect(result).toContain('<span class="remark-code-span-highlighted">highlightedMethod</span>');
38+
expect(result).toContain('normalMethod(a)');
39+
});
3940
});
4041

41-
it('does not highlight a backtick span that is escaped', () => {
42-
const result = applyHighlightPattern('\\`notHighlighted`');
43-
expect(result).not.toContain('remark-code-span-highlighted');
42+
describe('issue #3 – trailing parenthesis not captured', () => {
43+
it('does not include ( after closing backtick in the highlighted span', () => {
44+
const result = applyHighlightPattern('case `class Token`(value)');
45+
expect(result).toContain('<span class="remark-code-span-highlighted">class Token</span>');
46+
expect(result).toMatch(/<\/span>\(value\)/);
47+
});
48+
49+
it('correctly highlights both spans in `foo`(`bar`)', () => {
50+
const result = applyHighlightPattern('`foo`(`bar`)');
51+
expect(result).toContain('<span class="remark-code-span-highlighted">foo</span>');
52+
expect(result).toContain('<span class="remark-code-span-highlighted">bar</span>');
53+
// ( must not be inside either span
54+
expect(result).not.toContain('remark-code-span-highlighted">(');
55+
expect(result).not.toContain('(</span>');
56+
});
57+
58+
it('does not capture ( when it follows the closing backtick with spaces', () => {
59+
const result = applyHighlightPattern('`method` (arg)');
60+
expect(result).toMatch(/<\/span> \(arg\)/);
61+
});
4462
});
4563

46-
it('does not treat fenced code delimiters (```) as highlight spans', () => {
47-
const result = applyHighlightPattern('```js\ncode\n```');
48-
expect(result).not.toContain('remark-code-span-highlighted');
64+
describe('escape handling', () => {
65+
it('does not highlight a backtick span preceded by a backslash', () => {
66+
const result = applyHighlightPattern('\\`notHighlighted`');
67+
expect(result).not.toContain('remark-code-span-highlighted');
68+
});
4969
});
5070
});

src/mdeck/views/slideView.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,18 +211,24 @@ function highlightBlockLines(block: HTMLElement, lines: number[]): void {
211211
function highlightBlockSpans(block: HTMLElement, highlightSpans: boolean | RegExp): void {
212212
let pattern: RegExp;
213213
if (highlightSpans === true) {
214-
pattern = /(^|[^`])`([^`]+?)`/gm;
214+
// Use a lookbehind so the opening backtick is not consumed along with its
215+
// preceding character. This fixes two bugs:
216+
// - #2: spans at the start of a line were never matched (no preceding char)
217+
// - #3: adjacent spans like `foo`(`bar`) incorrectly captured ( as content
218+
// because the old ([^`]) group consumed the last char of the first span,
219+
// treating its closing backtick as the opening of a new span.
220+
// The lookbehind also handles escape: \` is not treated as an opening backtick.
221+
pattern = /(?<![\`\\])`([^`]+?)`/g;
215222
} else if (highlightSpans instanceof RegExp) {
216223
if (!highlightSpans.global) throw new Error('highlightSpans RegExp must have /g flag');
217-
pattern = new RegExp('(^|[\\s\\S])' + highlightSpans.source, (highlightSpans.flags || 'g') + 'm');
224+
pattern = new RegExp('(?<![\\`\\\\])' + highlightSpans.source, highlightSpans.flags || 'g');
218225
} else {
219226
throw new Error('Illegal value for highlightSpans');
220227
}
221228
Array.from(block.childNodes).forEach((node) => {
222229
if (node instanceof HTMLElement) {
223-
node.innerHTML = node.innerHTML.replace(pattern, (m, e, c) => {
224-
if (e === '\\') return m.slice(1);
225-
return e + `<span class="remark-code-span-highlighted mdeck-code-span-highlighted">${c}</span>`;
230+
node.innerHTML = node.innerHTML.replace(pattern, (_m, c) => {
231+
return `<span class="remark-code-span-highlighted mdeck-code-span-highlighted">${c}</span>`;
226232
});
227233
}
228234
});

0 commit comments

Comments
 (0)