Skip to content

Commit f487d89

Browse files
committed
fix(core): preserve markup around ranged highlights
1 parent 58d068d commit f487d89

10 files changed

Lines changed: 526 additions & 260 deletions

.changeset/lucky-clocks-thank.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@
44

55
fix: stop character-highlight ranges leaking across patterns, so an id-only annotation like `/foo/#a` no longer makes a later pattern inherit the previous pattern's range, and a range-ignored occurrence no longer consumes the whole token it lives in (#169)
66

7-
Note that an occurrence a range excludes is now split into its own span rather than left inside the surrounding token, so the emitted HTML around character ranges changes even where the visible highlighting does not.
7+
Range-excluded occurrences are counted without changing the token tree, so unhighlighted markup remains intact.
8+
9+
Partial highlights inside transformer-generated elements preserve the surrounding semantic element instead of cloning it.
Lines changed: 97 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,70 @@
11
import type { Element } from 'hast';
2+
import { toString as hastToString } from 'hast-util-to-string';
23
import type { CharsHighlighterOptions, CharsElement } from '../types';
34
import { getElementsToHighlight } from './getElementsToHighlight';
4-
import { wrapHighlightedChars } from './wrapHighlightedChars';
5-
import { isElement } from '../utils';
5+
import {
6+
wrapHighlightedChars,
7+
wrapHighlightedCharsInRange,
8+
} from './wrapHighlightedChars';
9+
10+
interface SelectedMatch {
11+
chars: string;
12+
start: number;
13+
end: number;
14+
element?: CharsElement;
15+
}
16+
17+
function isAvailable(occupied: Uint8Array, start: number, end: number) {
18+
for (let index = start; index < end; index++) {
19+
if (occupied[index] === 1) {
20+
return false;
21+
}
22+
}
23+
24+
return true;
25+
}
26+
27+
function findMatches(
28+
source: string,
29+
chars: string,
30+
charsIndex: number,
31+
occupied: Uint8Array,
32+
options: CharsHighlighterOptions,
33+
): Array<SelectedMatch> {
34+
const selectedMatches: Array<SelectedMatch> = [];
35+
const currentRange = options.ranges[charsIndex] || [];
36+
const counterId = `${chars}-${charsIndex}`;
37+
let searchStart = 0;
38+
39+
while (searchStart <= source.length - chars.length) {
40+
const matchStart = source.indexOf(chars, searchStart);
41+
if (matchStart === -1) {
42+
break;
43+
}
44+
45+
const matchEnd = matchStart + chars.length;
46+
if (!isAvailable(occupied, matchStart, matchEnd)) {
47+
searchStart = matchStart + 1;
48+
continue;
49+
}
50+
51+
const occurrence = (options.counterMap.get(counterId) || 0) + 1;
52+
options.counterMap.set(counterId, occurrence);
53+
occupied.fill(1, matchStart, matchEnd);
54+
55+
if (currentRange.length === 0 || currentRange.includes(occurrence)) {
56+
selectedMatches.push({ chars, start: matchStart, end: matchEnd });
57+
}
58+
59+
searchStart = matchEnd;
60+
}
61+
62+
return selectedMatches;
63+
}
664

765
/**
8-
* Loops through the child nodes and finds the nodes that make up the chars.
9-
* If the chars cross node boundaries, those nodes are wrapped with
10-
* <mark data-highlighted-chars-mark>, and that node is passed to
11-
* onVisitHighlightedChars.
12-
*
13-
* If a node partially matches the chars, its content is replaced with the
14-
* matched part, and the left and/or right parts are cloned to sibling nodes.
66+
* Finds each requested string in the line, then applies the selected matches
67+
* without materializing range-excluded occurrences.
1568
*/
1669
export function charsHighlighter(
1770
element: Element,
@@ -22,50 +75,52 @@ export function charsHighlighter(
2275
id: string | undefined,
2376
) => void,
2477
) {
25-
const { ranges = [] } = options;
78+
const source = hastToString(element);
79+
const occupied = new Uint8Array(source.length);
80+
const selectedMatches: Array<SelectedMatch> = [];
2681

27-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: char matching is inherently branchy
28-
charsList.forEach((chars, index) => {
82+
charsList.forEach((chars, charsIndex) => {
2983
if (!chars) {
3084
return;
3185
}
3286

33-
const currentCharsRange = ranges[index] || [];
34-
const id = `${chars}-${index}`;
35-
let startIndex = 0;
36-
37-
while (true) {
38-
const elementsToWrap = getElementsToHighlight(element, chars, startIndex);
39-
40-
if (elementsToWrap.length === 0) break;
87+
selectedMatches.push(
88+
...findMatches(source, chars, charsIndex, occupied, options),
89+
);
90+
});
4191

42-
const matchStartIndex = element.children.indexOf(elementsToWrap[0]);
43-
if (matchStartIndex === -1) break;
92+
const matchesToApply = [...selectedMatches].sort(
93+
(first, second) => second.start - first.start,
94+
);
4495

45-
const occurrence = (options.counterMap.get(id) || 0) + 1;
46-
const ignoreChars =
47-
currentCharsRange.length > 0 && !currentCharsRange.includes(occurrence);
96+
for (const match of matchesToApply) {
97+
const target = getElementsToHighlight(element, match.start, match.end);
98+
if (!target) {
99+
continue;
100+
}
48101

49-
const didWrap = wrapHighlightedChars(
50-
element,
51-
elementsToWrap,
52-
chars,
102+
if (target.type === 'elements') {
103+
match.element = wrapHighlightedChars(
104+
target.parent,
105+
target.elements,
106+
match.chars,
107+
options,
108+
);
109+
} else {
110+
match.element = wrapHighlightedCharsInRange(
111+
target.parent,
112+
target.childIndex,
113+
target.start,
114+
target.end,
115+
match.chars,
53116
options,
54-
ignoreChars,
55-
onVisitHighlightedChars,
56117
);
57-
58-
if (!didWrap) break;
59-
60-
options.counterMap.set(id, occurrence);
61-
startIndex = matchStartIndex;
62118
}
63-
});
119+
}
64120

65-
element.children.forEach((childNode) => {
66-
if (!isElement(childNode)) return;
67-
if (Object.hasOwn(childNode.properties, 'rehype-pretty-code-visited')) {
68-
childNode.properties['rehype-pretty-code-visited'] = undefined;
121+
for (const match of selectedMatches) {
122+
if (match.element) {
123+
onVisitHighlightedChars?.(match.element, options.idsMap.get(match.chars));
69124
}
70-
});
125+
}
71126
}

0 commit comments

Comments
 (0)