PageSourceSearch

https://www.scala-sbt.org/2.x/docs/en/searcher.js

js scala-sbt.org collected 2026-09-24 06:42:46 UTC 19,996 bytes, 554 lines download raw bytes

1'use strict';
2
3/* global Mark, elasticlunr, path_to_root */
4
5window.search = window.search || {};
6(function search() {
7    // Search functionality
8    //
9    // You can use !hasFocus() to prevent keyhandling in your key
10    // event handlers while the user is typing their search.
11
12    if (!Mark || !elasticlunr) {
13        return;
14    }
15
16    // eslint-disable-next-line max-len
17    // IE 11 Compatibility from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith
18    if (!String.prototype.startsWith) {
19        String.prototype.startsWith = function(search, pos) {
20            return this.substr(!pos || pos < 0 ? 0 : +pos, search.length) === search;
21        };
22    }
23
24    const search_wrap = document.getElementById('search-wrapper'),
25        searchbar_outer = document.getElementById('searchbar-outer'),
26        searchbar = document.getElementById('searchbar'),
27        searchresults = document.getElementById('searchresults'),
28        searchresults_outer = document.getElementById('searchresults-outer'),
29        searchresults_header = document.getElementById('searchresults-header'),
30        searchicon = document.getElementById('search-toggle'),
31        content = document.getElementById('content'),
32
33        // SVG text elements don't render if inside a <mark> tag.
34        mark_exclude = ['text'],
35        marker = new Mark(content),
36        URL_SEARCH_PARAM = 'search',
37        URL_MARK_PARAM = 'highlight';
38
39    let current_searchterm = '',
40        doc_urls = [],
41        search_options = {
42            bool: 'AND',
43            expand: true,
44            fields: {
45                title: {boost: 1},
46                body: {boost: 1},
47                breadcrumbs: {boost: 0},
48            },
49        },
50        searchindex = null,
51        results_options = {
52            teaser_word_count: 30,
53            limit_results: 30,
54        },
55        teaser_count = 0;
56
57    function hasFocus() {
58        return searchbar === document.activeElement;
59    }
60
61    function removeChildren(elem) {
62        while (elem.firstChild) {
63            elem.removeChild(elem.firstChild);
64        }
65    }
66
67    // Helper to parse a url into its building blocks.
68    function parseURL(url) {
69        const a = document.createElement('a');
70        a.href = url;
71        return {
72            source: url,
73            protocol: a.protocol.replace(':', ''),
74            host: a.hostname,
75            port: a.port,
76            params: (function() {
77                const ret = {};
78                const seg = a.search.replace(/^\?/, '').split('&');
79                for (const part of seg) {
80                    if (!part) {
81                        continue;
82                    }
83                    const s = part.split('=');
84                    ret[s[0]] = s[1];
85                }
86                return ret;
87            })(),
88            file: (a.pathname.match(/\/([^/?#]+)$/i) || ['', ''])[1],
89            hash: a.hash.replace('#', ''),
90            path: a.pathname.replace(/^([^/])/, '/$1'),
91        };
92    }
93
94    // Helper to recreate a url string from its building blocks.
95    function renderURL(urlobject) {
96        let url = urlobject.protocol + '://' + urlobject.host;
97        if (urlobject.port !== '') {
98            url += ':' + urlobject.port;
99        }
100        url += urlobject.path;
101        let joiner = '?';
102        for (const prop in urlobject.params) {
103            if (Object.prototype.hasOwnProperty.call(urlobject.params, prop)) {
104                url += joiner + prop + '=' + urlobject.params[prop];
105                joiner = '&';
106            }
107        }
108        if (urlobject.hash !== '') {
109            url += '#' + urlobject.hash;
110        }
111        return url;
112    }
113
114    // Helper to escape html special chars for displaying the teasers
115    const escapeHTML = (function() {
116        const MAP = {
117            '&': '&amp;',
118            '<': '&lt;',
119            '>': '&gt;',
120            '"': '&#34;',
121            '\'': '&#39;',
122        };
123        const repl = function(c) {
124            return MAP[c];
125        };
126        return function(s) {
127            return s.replace(/[&<>'"]/g, repl);
128        };
129    })();
130
131    function formatSearchMetric(count, searchterm) {
132        if (count === 1) {
133            return count + ' search result for \'' + searchterm + '\':';
134        } else if (count === 0) {
135            return 'No search results for \'' + searchterm + '\'.';
136        } else {
137            return count + ' search results for \'' + searchterm + '\':';
138        }
139    }
140
141    function formatSearchResult(result, searchterms) {
142        const teaser = makeTeaser(escapeHTML(result.doc.body), searchterms);
143        teaser_count++;
144
145        // The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor
146        const url = doc_urls[result.ref].split('#');
147        if (url.length === 1) { // no anchor found
148            url.push('');
149        }
150
151        // encodeURIComponent escapes all chars that could allow an XSS except
152        // for '. Due to that we also manually replace ' with its url-encoded
153        // representation (%27).
154        const encoded_search = encodeURIComponent(searchterms.join(' ')).replace(/'/g, '%27');
155
156        return '<a href="' + path_to_root + url[0] + '?' + URL_MARK_PARAM + '=' + encoded_search
157            + '#' + url[1] + '" aria-details="teaser_' + teaser_count + '">'
158            + result.doc.breadcrumbs + '</a>' + '<span class="teaser" id="teaser_' + teaser_count
159            + '" aria-label="Search Result Teaser">' + teaser + '</span>';
160    }
161
162    function makeTeaser(body, searchterms) {
163        // The strategy is as follows:
164        // First, assign a value to each word in the document:
165        //  Words that correspond to search terms (stemmer aware): 40
166        //  Normal words: 2
167        //  First word in a sentence: 8
168        // Then use a sliding window with a constant number of words and count the
169        // sum of the values of the words within the window. Then use the window that got the
170        // maximum sum. If there are multiple maximas, then get the last one.
171        // Enclose the terms in <em>.
172        const stemmed_searchterms = searchterms.map(function(w) {
173            return elasticlunr.stemmer(w.toLowerCase());
174        });
175        const searchterm_weight = 40;
176        const weighted = []; // contains elements of ["word", weight, index_in_document]
177        // split in sentences, then words
178        const sentences = body.toLowerCase().split('. ');
179        let index = 0;
180        let value = 0;
181        let searchterm_found = false;
182        for (const sentenceindex in sentences) {
183            const words = sentences[sentenceindex].split(' ');
184            value = 8;
185            for (const wordindex in words) {
186                const word = words[wordindex];
187                if (word.length > 0) {
188                    for (const searchtermindex in stemmed_searchterms) {
189                        if (elasticlunr.stemmer(word).startsWith(
190                            stemmed_searchterms[searchtermindex])
191                        ) {
192                            value = searchterm_weight;
193                            searchterm_found = true;
194                        }
195                    }
196                    weighted.push([word, value, index]);
197                    value = 2;
198                }
199                index += word.length;
200                index += 1; // ' ' or '.' if last word in sentence
201            }
202            index += 1; // because we split at a two-char boundary '. '
203        }
204
205        if (weighted.length === 0) {
206            return body;
207        }
208
209        const window_weight = [];
210        const window_size = Math.min(weighted.length, results_options.teaser_word_count);
211
212        let cur_sum = 0;
213        for (let wordindex = 0; wordindex < window_size; wordindex++) {
214            cur_sum += weighted[wordindex][1];
215        }
216        window_weight.push(cur_sum);
217        for (let wordindex = 0; wordindex < weighted.length - window_size; wordindex++) {
218            cur_sum -= weighted[wordindex][1];
219            cur_sum += weighted[wordindex + window_size][1];
220            window_weight.push(cur_sum);
221        }
222
223        let max_sum_window_index = 0;
224        if (searchterm_found) {
225            let max_sum = 0;
226            // backwards
227            for (let i = window_weight.length - 1; i >= 0; i--) {
228                if (window_weight[i] > max_sum) {
229                    max_sum = window_weight[i];
230                    max_sum_window_index = i;
231                }
232            }
233        } else {
234            max_sum_window_index = 0;
235        }
236
237        // add <em/> around searchterms
238        const teaser_split = [];
239        index = weighted[max_sum_window_index][2];
240        for (let i = max_sum_window_index; i < max_sum_window_index + window_size; i++) {
241            const word = weighted[i];
242            if (index < word[2]) {
243                // missing text from index to start of `word`
244                teaser_split.push(body.substring(index, word[2]));
245                index = word[2];
246            }
247            if (word[1] === searchterm_weight) {
248                teaser_split.push('<em>');
249            }
250            index = word[2] + word[0].length;
251            teaser_split.push(body.substring(word[2], index));
252            if (word[1] === searchterm_weight) {
253                teaser_split.push('</em>');
254            }
255        }
256
257        return teaser_split.join('');
258    }
259
260    function init(config) {
261        results_options = config.results_options;
262        search_options = config.search_options;
263        doc_urls = config.doc_urls;
264        searchindex = elasticlunr.Index.load(config.index);
265
266        searchbar_outer.classList.remove('searching');
267
268        searchbar.focus();
269
270        const searchterm = searchbar.value.trim();
271        if (searchterm !== '') {
272            searchbar.classList.add('active');
273            doSearch(searchterm);
274        }
275    }
276
277    function initSearchInteractions(config) {
278        // Set up events
279        searchicon.addEventListener('click', () => {
280            searchIconClickHandler();
281        }, false);
282        searchbar.addEventListener('keyup', () => {
283            searchbarKeyUpHandler();
284        }, false);
285        document.addEventListener('keydown', e => {
286            globalKeyHandler(e);
287        }, false);
288        // If the user uses the browser buttons, do the same as if a reload happened
289        window.onpopstate = () => {
290            doSearchOrMarkFromUrl();
291        };
292        // Suppress "submit" events so the page doesn't reload when the user presses Enter
293        document.addEventListener('submit', e => {
294            e.preventDefault();
295        }, false);
296
297        // If reloaded, do the search or mark again, depending on the current url parameters
298        doSearchOrMarkFromUrl();
299
300        // Exported functions
301        config.hasFocus = hasFocus;
302    }
303
304    initSearchInteractions(window.search);
305
306    function unfocusSearchbar() {
307        // hacky, but just focusing a div only works once
308        const tmp = document.createElement('input');
309        tmp.setAttribute('style', 'position: absolute; opacity: 0;');
310        searchicon.appendChild(tmp);
311        tmp.focus();
312        tmp.remove();
313    }
314
315    // On reload or browser history backwards/forwards events, parse the url and do search or mark
316    function doSearchOrMarkFromUrl() {
317        // Check current URL for search request
318        const url = parseURL(window.location.href);
319        if (Object.prototype.hasOwnProperty.call(url.params, URL_SEARCH_PARAM)
320            && url.params[URL_SEARCH_PARAM] !== '') {
321            showSearch(true);
322            searchbar.value = decodeURIComponent(
323                (url.params[URL_SEARCH_PARAM] + '').replace(/\+/g, '%20'));
324            searchbarKeyUpHandler(); // -> doSearch()
325        } else {
326            showSearch(false);
327        }
328
329        if (Object.prototype.hasOwnProperty.call(url.params, URL_MARK_PARAM)) {
330            const words = decodeURIComponent(url.params[URL_MARK_PARAM]).split(' ');
331            marker.mark(words, {
332                exclude: mark_exclude,
333            });
334
335            const markers = document.querySelectorAll('mark');
336            const hide = () => {
337                for (let i = 0; i < markers.length; i++) {
338                    markers[i].classList.add('fade-out');
339                    window.setTimeout(() => {
340                        marker.unmark();
341                    }, 300);
342                }
343            };
344
345            for (let i = 0; i < markers.length; i++) {
346                markers[i].addEventListener('click', hide);
347            }
348        }
349    }
350
351    // Eventhandler for keyevents on `document`
352    function globalKeyHandler(e) {
353        if (e.altKey ||
354            e.ctrlKey ||
355            e.metaKey ||
356            e.shiftKey ||
357            e.target.type === 'textarea' ||
358            e.target.type === 'text' ||
359            !hasFocus() && /^(?:input|select|textarea)$/i.test(e.target.nodeName)
360        ) {
361            return;
362        }
363
364        if (e.key === 'Escape') {
365            e.preventDefault();
366            searchbar.classList.remove('active');
367            setSearchUrlParameters('',
368                searchbar.value.trim() !== '' ? 'push' : 'replace');
369            if (hasFocus()) {
370                unfocusSearchbar();
371            }
372            showSearch(false);
373            marker.unmark();
374        } else if (!hasFocus() && (e.key === 's' || e.key === '/')) {
375            e.preventDefault();
376            showSearch(true);
377            window.scrollTo(0, 0);
378            searchbar.select();
379        } else if (hasFocus() && (e.key === 'ArrowDown'
380                               || e.key === 'Enter')) {
381            e.preventDefault();
382            const first = searchresults.firstElementChild;
383            if (first !== null) {
384                unfocusSearchbar();
385                first.classList.add('focus');
386                if (e.key === 'Enter') {
387                    window.location.assign(first.querySelector('a'));
388                }
389            }
390        } else if (!hasFocus() && (e.key === 'ArrowDown'
391                                || e.key === 'ArrowUp'
392                                || e.key === 'Enter')) {
393            // not `:focus` because browser does annoying scrolling
394            const focused = searchresults.querySelector('li.focus');
395            if (!focused) {
396                return;
397            }
398            e.preventDefault();
399            if (e.key === 'ArrowDown') {
400                const next = focused.nextElementSibling;
401                if (next) {
402                    focused.classList.remove('focus');
403                    next.classList.add('focus');
404                }
405            } else if (e.key === 'ArrowUp') {
406                focused.classList.remove('focus');
407                const prev = focused.previousElementSibling;
408                if (prev) {
409                    prev.classList.add('focus');
410                } else {
411                    searchbar.select();
412                }
413            } else { // Enter
414                window.location.assign(focused.querySelector('a'));
415            }
416        }
417    }
418
419    function loadSearchScript(url, id) {
420        if (document.getElementById(id)) {
421            return;
422        }
423        searchbar_outer.classList.add('searching');
424
425        const script = document.createElement('script');
426        script.src = url;
427        script.id = id;
428        script.onload = () => init(window.search);
429        script.onerror = error => {
430            console.error(`Failed to load \`${url}\`: ${error}`);
431        };
432        document.head.append(script);
433    }
434
435    function showSearch(yes) {
436        if (yes) {
437            loadSearchScript(
438                window.path_to_searchindex_js ||
439                path_to_root + 'searchindex.js',
440                'search-index');
441            search_wrap.classList.remove('hidden');
442            searchicon.setAttribute('aria-expanded', 'true');
443        } else {
444            search_wrap.classList.add('hidden');
445            searchicon.setAttribute('aria-expanded', 'false');
446            const results = searchresults.children;
447            for (let i = 0; i < results.length; i++) {
448                results[i].classList.remove('focus');
449            }
450        }
451    }
452
453    function showResults(yes) {
454        if (yes) {
455            searchresults_outer.classList.remove('hidden');
456        } else {
457            searchresults_outer.classList.add('hidden');
458        }
459    }
460
461    // Eventhandler for search icon
462    function searchIconClickHandler() {
463        if (search_wrap.classList.contains('hidden')) {
464            showSearch(true);
465            window.scrollTo(0, 0);
466            searchbar.select();
467        } else {
468            showSearch(false);
469        }
470    }
471
472    // Eventhandler for keyevents while the searchbar is focused
473    function searchbarKeyUpHandler() {
474        const searchterm = searchbar.value.trim();
475        if (searchterm !== '') {
476            searchbar.classList.add('active');
477            doSearch(searchterm);
478        } else {
479            searchbar.classList.remove('active');
480            showResults(false);
481            removeChildren(searchresults);
482        }
483
484        setSearchUrlParameters(searchterm, 'push_if_new_search_else_replace');
485
486        // Remove marks
487        marker.unmark();
488    }
489
490    // Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and
491    // `#heading-anchor`. `action` can be one of "push", "replace",
492    // "push_if_new_search_else_replace" and replaces or pushes a new browser history item.
493    // "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet.
494    function setSearchUrlParameters(searchterm, action) {
495        const url = parseURL(window.location.href);
496        const first_search = !Object.prototype.hasOwnProperty.call(url.params, URL_SEARCH_PARAM);
497
498        if (searchterm !== '' || action === 'push_if_new_search_else_replace') {
499            url.params[URL_SEARCH_PARAM] = searchterm;
500            delete url.params[URL_MARK_PARAM];
501            url.hash = '';
502        } else {
503            delete url.params[URL_MARK_PARAM];
504            delete url.params[URL_SEARCH_PARAM];
505        }
506        // A new search will also add a new history item, so the user can go back
507        // to the page prior to searching. A updated search term will only replace
508        // the url.
509        if (action === 'push' || action === 'push_if_new_search_else_replace' && first_search ) {
510            history.pushState({}, document.title, renderURL(url));
511        } else if (action === 'replace' ||
512            action === 'push_if_new_search_else_replace' &&
513            !first_search
514        ) {
515            history.replaceState({}, document.title, renderURL(url));
516        }
517    }
518
519    function doSearch(searchterm) {
520        // Don't search the same twice
521        if (current_searchterm === searchterm) {
522            return;
523        }
524        searchbar_outer.classList.add('searching');
525        if (searchindex === null) {
526            return;
527        }
528
529        current_searchterm = searchterm;
530
531        // Do the actual search
532        const results = searchindex.search(searchterm, search_options);
533        const resultcount = Math.min(results.length, results_options.limit_results);
534
535        // Display search metrics
536        searchresults_header.innerText = formatSearchMetric(resultcount, searchterm);
537
538        // Clear and insert results
539        const searchterms = searchterm.split(' ');
540        removeChildren(searchresults);
541        for (let i = 0; i < resultcount ; i++) {
542            const resultElem = document.createElement('li');
543            resultElem.innerHTML = formatSearchResult(results[i], searchterms);
544            searchresults.appendChild(resultElem);
545        }
546
547        // Display results
548        showResults(true);
549        searchbar_outer.classList.remove('searching');
550    }
551
552    // Exported functions
553    search.hasFocus = hasFocus;
554})(window.search);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.