44 *
55 * Sphinx JavaScript utilities for the full-text search.
66 *
7- * :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS.
7+ * :copyright: Copyright 2007-2024 by the Sphinx team, see AUTHORS.
88 * :license: BSD, see LICENSE for details.
99 *
1010 */
@@ -57,12 +57,12 @@ const _removeChildren = (element) => {
5757const _escapeRegExp = ( string ) =>
5858 string . replace ( / [ . * + \- ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ; // $& means the whole matched string
5959
60- const _displayItem = ( item , searchTerms ) => {
60+ const _displayItem = ( item , searchTerms , highlightTerms ) => {
6161 const docBuilder = DOCUMENTATION_OPTIONS . BUILDER ;
62- const docUrlRoot = DOCUMENTATION_OPTIONS . URL_ROOT ;
6362 const docFileSuffix = DOCUMENTATION_OPTIONS . FILE_SUFFIX ;
6463 const docLinkSuffix = DOCUMENTATION_OPTIONS . LINK_SUFFIX ;
6564 const showSearchSummary = DOCUMENTATION_OPTIONS . SHOW_SEARCH_SUMMARY ;
65+ const contentRoot = document . documentElement . dataset . content_root ;
6666
6767 const [ docName , title , anchor , descr , score , _filename ] = item ;
6868
@@ -75,28 +75,35 @@ const _displayItem = (item, searchTerms) => {
7575 if ( dirname . match ( / \/ i n d e x \/ $ / ) )
7676 dirname = dirname . substring ( 0 , dirname . length - 6 ) ;
7777 else if ( dirname === "index/" ) dirname = "" ;
78- requestUrl = docUrlRoot + dirname ;
78+ requestUrl = contentRoot + dirname ;
7979 linkUrl = requestUrl ;
8080 } else {
8181 // normal html builders
82- requestUrl = docUrlRoot + docName + docFileSuffix ;
82+ requestUrl = contentRoot + docName + docFileSuffix ;
8383 linkUrl = docName + docLinkSuffix ;
8484 }
8585 let linkEl = listItem . appendChild ( document . createElement ( "a" ) ) ;
8686 linkEl . href = linkUrl + anchor ;
8787 linkEl . dataset . score = score ;
8888 linkEl . innerHTML = title ;
89- if ( descr )
89+ if ( descr ) {
9090 listItem . appendChild ( document . createElement ( "span" ) ) . innerHTML =
9191 " (" + descr + ")" ;
92+ // highlight search terms in the description
93+ if ( SPHINX_HIGHLIGHT_ENABLED ) // set in sphinx_highlight.js
94+ highlightTerms . forEach ( ( term ) => _highlightText ( listItem , term , "highlighted" ) ) ;
95+ }
9296 else if ( showSearchSummary )
9397 fetch ( requestUrl )
9498 . then ( ( responseData ) => responseData . text ( ) )
9599 . then ( ( data ) => {
96100 if ( data )
97101 listItem . appendChild (
98- Search . makeSearchSummary ( data , searchTerms )
102+ Search . makeSearchSummary ( data , searchTerms , anchor )
99103 ) ;
104+ // highlight search terms in the summary
105+ if ( SPHINX_HIGHLIGHT_ENABLED ) // set in sphinx_highlight.js
106+ highlightTerms . forEach ( ( term ) => _highlightText ( listItem , term , "highlighted" ) ) ;
100107 } ) ;
101108 Search . output . appendChild ( listItem ) ;
102109} ;
@@ -109,26 +116,43 @@ const _finishSearch = (resultCount) => {
109116 ) ;
110117 else
111118 Search . status . innerText = _ (
112- ` Search finished, found ${ resultCount } page(s) matching the search query.`
113- ) ;
119+ " Search finished, found ${resultCount} page(s) matching the search query."
120+ ) . replace ( '${resultCount}' , resultCount ) ;
114121} ;
115122const _displayNextItem = (
116123 results ,
117124 resultCount ,
118- searchTerms
125+ searchTerms ,
126+ highlightTerms ,
119127) => {
120128 // results left, load the summary and display it
121129 // this is intended to be dynamic (don't sub resultsCount)
122130 if ( results . length ) {
123- _displayItem ( results . pop ( ) , searchTerms ) ;
131+ _displayItem ( results . pop ( ) , searchTerms , highlightTerms ) ;
124132 setTimeout (
125- ( ) => _displayNextItem ( results , resultCount , searchTerms ) ,
133+ ( ) => _displayNextItem ( results , resultCount , searchTerms , highlightTerms ) ,
126134 5
127135 ) ;
128136 }
129137 // search finished, update title and status message
130138 else _finishSearch ( resultCount ) ;
131139} ;
140+ // Helper function used by query() to order search results.
141+ // Each input is an array of [docname, title, anchor, descr, score, filename].
142+ // Order the results by score (in opposite order of appearance, since the
143+ // `_displayNextItem` function uses pop() to retrieve items) and then alphabetically.
144+ const _orderResultsByScoreThenName = ( a , b ) => {
145+ const leftScore = a [ 4 ] ;
146+ const rightScore = b [ 4 ] ;
147+ if ( leftScore === rightScore ) {
148+ // same score: sort alphabetically
149+ const leftTitle = a [ 1 ] . toLowerCase ( ) ;
150+ const rightTitle = b [ 1 ] . toLowerCase ( ) ;
151+ if ( leftTitle === rightTitle ) return 0 ;
152+ return leftTitle > rightTitle ? - 1 : 1 ; // inverted is intentional
153+ }
154+ return leftScore > rightScore ? 1 : - 1 ;
155+ } ;
132156
133157/**
134158 * Default splitQuery function. Can be overridden in ``sphinx.search`` with a
@@ -152,13 +176,26 @@ const Search = {
152176 _queued_query : null ,
153177 _pulse_status : - 1 ,
154178
155- htmlToText : ( htmlString ) => {
179+ htmlToText : ( htmlString , anchor ) => {
156180 const htmlElement = new DOMParser ( ) . parseFromString ( htmlString , 'text/html' ) ;
157- htmlElement . querySelectorAll ( ".headerlink" ) . forEach ( ( el ) => { el . remove ( ) } ) ;
181+ for ( const removalQuery of [ ".headerlinks" , "script" , "style" ] ) {
182+ htmlElement . querySelectorAll ( removalQuery ) . forEach ( ( el ) => { el . remove ( ) } ) ;
183+ }
184+ if ( anchor ) {
185+ const anchorContent = htmlElement . querySelector ( `[role="main"] ${ anchor } ` ) ;
186+ if ( anchorContent ) return anchorContent . textContent ;
187+
188+ console . warn (
189+ `Anchored content block not found. Sphinx search tries to obtain it via DOM query '[role=main] ${ anchor } '. Check your theme or template.`
190+ ) ;
191+ }
192+
193+ // if anchor not specified or not found, fall back to main content
158194 const docContent = htmlElement . querySelector ( '[role="main"]' ) ;
159- if ( docContent !== undefined ) return docContent . textContent ;
195+ if ( docContent ) return docContent . textContent ;
196+
160197 console . warn (
161- "Content block not found. Sphinx search tries to obtain it via '[role=main]'. Could you check your theme or template."
198+ "Content block not found. Sphinx search tries to obtain it via DOM query '[role=main]'. Check your theme or template."
162199 ) ;
163200 return "" ;
164201 } ,
@@ -231,16 +268,7 @@ const Search = {
231268 else Search . deferQuery ( query ) ;
232269 } ,
233270
234- /**
235- * execute search (requires search index to be loaded)
236- */
237- query : ( query ) => {
238- const filenames = Search . _index . filenames ;
239- const docNames = Search . _index . docnames ;
240- const titles = Search . _index . titles ;
241- const allTitles = Search . _index . alltitles ;
242- const indexEntries = Search . _index . indexentries ;
243-
271+ _parseQuery : ( query ) => {
244272 // stem the search terms and add them to the correct list
245273 const stemmer = new Stemmer ( ) ;
246274 const searchTerms = new Set ( ) ;
@@ -276,16 +304,32 @@ const Search = {
276304 // console.info("required: ", [...searchTerms]);
277305 // console.info("excluded: ", [...excludedTerms]);
278306
279- // array of [docname, title, anchor, descr, score, filename]
280- let results = [ ] ;
307+ return [ query , searchTerms , excludedTerms , highlightTerms , objectTerms ] ;
308+ } ,
309+
310+ /**
311+ * execute search (requires search index to be loaded)
312+ */
313+ _performSearch : ( query , searchTerms , excludedTerms , highlightTerms , objectTerms ) => {
314+ const filenames = Search . _index . filenames ;
315+ const docNames = Search . _index . docnames ;
316+ const titles = Search . _index . titles ;
317+ const allTitles = Search . _index . alltitles ;
318+ const indexEntries = Search . _index . indexentries ;
319+
320+ // Collect multiple result groups to be sorted separately and then ordered.
321+ // Each is an array of [docname, title, anchor, descr, score, filename].
322+ const normalResults = [ ] ;
323+ const nonMainIndexResults = [ ] ;
324+
281325 _removeChildren ( document . getElementById ( "search-progress" ) ) ;
282326
283- const queryLower = query . toLowerCase ( ) ;
327+ const queryLower = query . toLowerCase ( ) . trim ( ) ;
284328 for ( const [ title , foundTitles ] of Object . entries ( allTitles ) ) {
285- if ( title . toLowerCase ( ) . includes ( queryLower ) && ( queryLower . length >= title . length / 2 ) ) {
329+ if ( title . toLowerCase ( ) . trim ( ) . includes ( queryLower ) && ( queryLower . length >= title . length / 2 ) ) {
286330 for ( const [ file , id ] of foundTitles ) {
287331 let score = Math . round ( 100 * queryLower . length / title . length )
288- results . push ( [
332+ normalResults . push ( [
289333 docNames [ file ] ,
290334 titles [ file ] !== title ? `${ titles [ file ] } > ${ title } ` : title ,
291335 id !== null ? "#" + id : "" ,
@@ -300,46 +344,47 @@ const Search = {
300344 // search for explicit entries in index directives
301345 for ( const [ entry , foundEntries ] of Object . entries ( indexEntries ) ) {
302346 if ( entry . includes ( queryLower ) && ( queryLower . length >= entry . length / 2 ) ) {
303- for ( const [ file , id ] of foundEntries ) {
304- let score = Math . round ( 100 * queryLower . length / entry . length )
305- results . push ( [
347+ for ( const [ file , id , isMain ] of foundEntries ) {
348+ const score = Math . round ( 100 * queryLower . length / entry . length ) ;
349+ const result = [
306350 docNames [ file ] ,
307351 titles [ file ] ,
308352 id ? "#" + id : "" ,
309353 null ,
310354 score ,
311355 filenames [ file ] ,
312- ] ) ;
356+ ] ;
357+ if ( isMain ) {
358+ normalResults . push ( result ) ;
359+ } else {
360+ nonMainIndexResults . push ( result ) ;
361+ }
313362 }
314363 }
315364 }
316365
317366 // lookup as object
318367 objectTerms . forEach ( ( term ) =>
319- results . push ( ...Search . performObjectSearch ( term , objectTerms ) )
368+ normalResults . push ( ...Search . performObjectSearch ( term , objectTerms ) )
320369 ) ;
321370
322371 // lookup as search terms in fulltext
323- results . push ( ...Search . performTermsSearch ( searchTerms , excludedTerms ) ) ;
372+ normalResults . push ( ...Search . performTermsSearch ( searchTerms , excludedTerms ) ) ;
324373
325374 // let the scorer override scores with a custom scoring function
326- if ( Scorer . score ) results . forEach ( ( item ) => ( item [ 4 ] = Scorer . score ( item ) ) ) ;
327-
328- // now sort the results by score (in opposite order of appearance, since the
329- // display function below uses pop() to retrieve items) and then
330- // alphabetically
331- results . sort ( ( a , b ) => {
332- const leftScore = a [ 4 ] ;
333- const rightScore = b [ 4 ] ;
334- if ( leftScore === rightScore ) {
335- // same score: sort alphabetically
336- const leftTitle = a [ 1 ] . toLowerCase ( ) ;
337- const rightTitle = b [ 1 ] . toLowerCase ( ) ;
338- if ( leftTitle === rightTitle ) return 0 ;
339- return leftTitle > rightTitle ? - 1 : 1 ; // inverted is intentional
340- }
341- return leftScore > rightScore ? 1 : - 1 ;
342- } ) ;
375+ if ( Scorer . score ) {
376+ normalResults . forEach ( ( item ) => ( item [ 4 ] = Scorer . score ( item ) ) ) ;
377+ nonMainIndexResults . forEach ( ( item ) => ( item [ 4 ] = Scorer . score ( item ) ) ) ;
378+ }
379+
380+ // Sort each group of results by score and then alphabetically by name.
381+ normalResults . sort ( _orderResultsByScoreThenName ) ;
382+ nonMainIndexResults . sort ( _orderResultsByScoreThenName ) ;
383+
384+ // Combine the result groups in (reverse) order.
385+ // Non-main index entries are typically arbitrary cross-references,
386+ // so display them after other results.
387+ let results = [ ...nonMainIndexResults , ...normalResults ] ;
343388
344389 // remove duplicate search results
345390 // note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept
@@ -353,14 +398,19 @@ const Search = {
353398 return acc ;
354399 } , [ ] ) ;
355400
356- results = results . reverse ( ) ;
401+ return results . reverse ( ) ;
402+ } ,
403+
404+ query : ( query ) => {
405+ const [ searchQuery , searchTerms , excludedTerms , highlightTerms , objectTerms ] = Search . _parseQuery ( query ) ;
406+ const results = Search . _performSearch ( searchQuery , searchTerms , excludedTerms , highlightTerms , objectTerms ) ;
357407
358408 // for debugging
359409 //Search.lastresults = results.slice(); // a copy
360410 // console.info("search results:", Search.lastresults);
361411
362412 // print the results
363- _displayNextItem ( results , results . length , searchTerms ) ;
413+ _displayNextItem ( results , results . length , searchTerms , highlightTerms ) ;
364414 } ,
365415
366416 /**
@@ -458,14 +508,18 @@ const Search = {
458508 // add support for partial matches
459509 if ( word . length > 2 ) {
460510 const escapedWord = _escapeRegExp ( word ) ;
461- Object . keys ( terms ) . forEach ( ( term ) => {
462- if ( term . match ( escapedWord ) && ! terms [ word ] )
463- arr . push ( { files : terms [ term ] , score : Scorer . partialTerm } ) ;
464- } ) ;
465- Object . keys ( titleTerms ) . forEach ( ( term ) => {
466- if ( term . match ( escapedWord ) && ! titleTerms [ word ] )
467- arr . push ( { files : titleTerms [ word ] , score : Scorer . partialTitle } ) ;
468- } ) ;
511+ if ( ! terms . hasOwnProperty ( word ) ) {
512+ Object . keys ( terms ) . forEach ( ( term ) => {
513+ if ( term . match ( escapedWord ) )
514+ arr . push ( { files : terms [ term ] , score : Scorer . partialTerm } ) ;
515+ } ) ;
516+ }
517+ if ( ! titleTerms . hasOwnProperty ( word ) ) {
518+ Object . keys ( titleTerms ) . forEach ( ( term ) => {
519+ if ( term . match ( escapedWord ) )
520+ arr . push ( { files : titleTerms [ term ] , score : Scorer . partialTitle } ) ;
521+ } ) ;
522+ }
469523 }
470524
471525 // no match but word was a required one
@@ -488,9 +542,8 @@ const Search = {
488542
489543 // create the mapping
490544 files . forEach ( ( file ) => {
491- if ( fileMap . has ( file ) && fileMap . get ( file ) . indexOf ( word ) === - 1 )
492- fileMap . get ( file ) . push ( word ) ;
493- else fileMap . set ( file , [ word ] ) ;
545+ if ( ! fileMap . has ( file ) ) fileMap . set ( file , [ word ] ) ;
546+ else if ( fileMap . get ( file ) . indexOf ( word ) === - 1 ) fileMap . get ( file ) . push ( word ) ;
494547 } ) ;
495548 } ) ;
496549
@@ -541,8 +594,8 @@ const Search = {
541594 * search summary for a given text. keywords is a list
542595 * of stemmed words.
543596 */
544- makeSearchSummary : ( htmlText , keywords ) => {
545- const text = Search . htmlToText ( htmlText ) ;
597+ makeSearchSummary : ( htmlText , keywords , anchor ) => {
598+ const text = Search . htmlToText ( htmlText , anchor ) ;
546599 if ( text === "" ) return null ;
547600
548601 const textLower = text . toLowerCase ( ) ;
0 commit comments