@@ -2,19 +2,12 @@ import type { Element } from 'hast';
22import type { CharsHighlighterOptions , CharsElement } from '../types' ;
33import { getElementsToHighlight } from './getElementsToHighlight' ;
44import { wrapHighlightedChars } from './wrapHighlightedChars' ;
5- import { toString as hastToString } from 'hast-util-to-string' ;
65import { 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