Skip to content

Commit 58d068d

Browse files
committed
fix(core): preserve chars highlights across token trees
1 parent 417da03 commit 58d068d

6 files changed

Lines changed: 675 additions & 445 deletions

File tree

packages/core/src/chars/charsHighlighter.ts

Lines changed: 26 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,12 @@ import type { Element } from 'hast';
22
import type { CharsHighlighterOptions, CharsElement } from '../types';
33
import { getElementsToHighlight } from './getElementsToHighlight';
44
import { wrapHighlightedChars } from './wrapHighlightedChars';
5-
import { toString as hastToString } from 'hast-util-to-string';
65
import { isElement } from '../utils';
76

8-
/**
9-
* Stands in for a node excluded from the remaining text, so the fragments
10-
* around it stay separated. NUL cannot occur in the highlighted source.
11-
*/
12-
const BOUNDARY = '\0';
13-
147
/**
158
* Loops through the child nodes and finds the nodes that make up the chars.
169
* If the chars cross node boundaries, those nodes are wrapped with
17-
* <span data-highlighted-chars-mark>, and that node is passed to
10+
* <mark data-highlighted-chars-mark>, and that node is passed to
1811
* onVisitHighlightedChars.
1912
*
2013
* If a node partially matches the chars, its content is replaced with the
@@ -30,83 +23,42 @@ export function charsHighlighter(
3023
) => void,
3124
) {
3225
const { ranges = [] } = options;
33-
const textContent = hastToString(element);
3426

3527
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: char matching is inherently branchy
3628
charsList.forEach((chars, index) => {
37-
if (chars && textContent?.includes(chars)) {
38-
let textContent = hastToString(element);
39-
let startIndex = 0;
40-
41-
while (textContent.includes(chars)) {
42-
// Snapshot the remaining text so we can bail out if an iteration
43-
// fails to make progress. The recomputed `textContent` excludes
44-
// already-highlighted nodes, so a productive iteration always makes
45-
// it strictly shorter; if it does not shrink, no occurrence was
46-
// consumed and continuing would loop forever.
47-
const previousTextContent = textContent;
48-
const currentCharsRange = ranges[index] || [];
49-
const id = `${chars}-${index}`;
29+
if (!chars) {
30+
return;
31+
}
5032

51-
const elementsToWrap = getElementsToHighlight(
52-
element,
53-
chars,
54-
startIndex,
55-
);
33+
const currentCharsRange = ranges[index] || [];
34+
const id = `${chars}-${index}`;
35+
let startIndex = 0;
5636

57-
// maybe throw / notify due to failure here
58-
if (elementsToWrap.length === 0) break;
37+
while (true) {
38+
const elementsToWrap = getElementsToHighlight(element, chars, startIndex);
5939

60-
// Count the occurrence only once it has resolved to real elements.
61-
// Counting before the match is confirmed lets a candidate that never
62-
// materializes consume a number, so a later genuine occurrence is
63-
// tested against the wrong index and a range like `/lo/2` misses it.
64-
const occurrence = (options.counterMap.get(id) || 0) + 1;
65-
options.counterMap.set(id, occurrence);
40+
if (elementsToWrap.length === 0) break;
6641

67-
const ignoreChars =
68-
currentCharsRange.length > 0 &&
69-
!currentCharsRange.includes(occurrence);
42+
const matchStartIndex = element.children.indexOf(elementsToWrap[0]);
43+
if (matchStartIndex === -1) break;
7044

71-
wrapHighlightedChars(
72-
element,
73-
elementsToWrap,
74-
options,
75-
ignoreChars,
76-
onVisitHighlightedChars,
77-
);
45+
const occurrence = (options.counterMap.get(id) || 0) + 1;
46+
const ignoreChars =
47+
currentCharsRange.length > 0 && !currentCharsRange.includes(occurrence);
7848

79-
// re-start from the 'last' node (the chars or part of them may exist
80-
// multiple times in the same node)
81-
// account for possible extra nodes added from split with - 2
82-
startIndex = Math.max(
83-
elementsToWrap[elementsToWrap.length - 1].index - 2,
84-
0,
85-
);
49+
const didWrap = wrapHighlightedChars(
50+
element,
51+
elementsToWrap,
52+
chars,
53+
options,
54+
ignoreChars,
55+
onVisitHighlightedChars,
56+
);
8657

87-
textContent = element.children
88-
.map((childNode) => {
89-
const props = isElement(childNode) ? childNode.properties : {};
90-
if (
91-
props &&
92-
!Object.hasOwn(props, 'rehype-pretty-code-visited') &&
93-
!Object.hasOwn(props, 'data-highlighted-chars-mark')
94-
) {
95-
return hastToString(childNode);
96-
}
97-
// An excluded node is a gap in the text, not an absence. Dropping
98-
// it would join its neighbours: once `balloon` is split around an
99-
// ignored `lo`, `bal` + `on;` reads as `balon;`, which contains an
100-
// occurrence of `lo` the code never had. Keep a boundary so
101-
// fragments on either side cannot spell a synthetic match.
102-
return BOUNDARY;
103-
})
104-
.join('');
58+
if (!didWrap) break;
10559

106-
// Safety guard: if the remaining text is unchanged, this iteration
107-
// consumed nothing, so stop instead of spinning forever.
108-
if (textContent === previousTextContent) break;
109-
}
60+
options.counterMap.set(id, occurrence);
61+
startIndex = matchStartIndex;
11062
}
11163
});
11264

0 commit comments

Comments
 (0)