Skip to content
Snippets Groups Projects
Select Git revision
  • 4b5d06d4ee4744facfbab5255578e50d044d8da5
  • master default protected
2 results

searcher.js

  • searcher.js 18.11 KiB
    "use strict";
    window.search = window.search || {};
    (function search(search) {
        // Search functionality
        //
        // You can use !hasFocus() to prevent keyhandling in your key
        // event handlers while the user is typing their search.
    
        if (!Mark || !elasticlunr) {
            return;
        }
    
        //IE 11 Compatibility from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith
        if (!String.prototype.startsWith) {
            String.prototype.startsWith = function(search, pos) {
                return this.substr(!pos || pos < 0 ? 0 : +pos, search.length) === search;
            };
        }
    
        var search_wrap = document.getElementById('search-wrapper'),
            searchbar = document.getElementById('searchbar'),
            searchbar_outer = document.getElementById('searchbar-outer'),
            searchresults = document.getElementById('searchresults'),
            searchresults_outer = document.getElementById('searchresults-outer'),
            searchresults_header = document.getElementById('searchresults-header'),
            searchicon = document.getElementById('search-toggle'),
            content = document.getElementById('content'),
    
            searchindex = null,
            doc_urls = [],
            results_options = {
                teaser_word_count: 30,
                limit_results: 30,
            },
            search_options = {
                bool: "AND",
                expand: true,
                fields: {
                    title: {boost: 1},
                    body: {boost: 1},
                    breadcrumbs: {boost: 0}
                }
            },
            mark_exclude = [],
            marker = new Mark(content),
            current_searchterm = "",
            URL_SEARCH_PARAM = 'search',
            URL_MARK_PARAM = 'highlight',
            teaser_count = 0,
    
            SEARCH_HOTKEY_KEYCODE = 83,
            ESCAPE_KEYCODE = 27,
            DOWN_KEYCODE = 40,
            UP_KEYCODE = 38,
            SELECT_KEYCODE = 13;
    
        function hasFocus() {
            return searchbar === document.activeElement;
        }
    
        function removeChildren(elem) {
            while (elem.firstChild) {
                elem.removeChild(elem.firstChild);
            }
        }
    
        // Helper to parse a url into its building blocks.
        function parseURL(url) {
            var a =  document.createElement('a');
            a.href = url;
            return {
                source: url,
                protocol: a.protocol.replace(':',''),
                host: a.hostname,
                port: a.port,
                params: (function(){
                    var ret = {};
                    var seg = a.search.replace(/^\?/,'').split('&');
                    var len = seg.length, i = 0, s;
                    for (;i<len;i++) {
                        if (!seg[i]) { continue; }
                        s = seg[i].split('=');
                        ret[s[0]] = s[1];
                    }
                    return ret;
                })(),
                file: (a.pathname.match(/\/([^/?#]+)$/i) || [,''])[1],
                hash: a.hash.replace('#',''),
                path: a.pathname.replace(/^([^/])/,'/$1')
            };
        }
        
        // Helper to recreate a url string from its building blocks.
        function renderURL(urlobject) {
            var url = urlobject.protocol + "://" + urlobject.host;
            if (urlobject.port != "") {
                url += ":" + urlobject.port;
            }
            url += urlobject.path;
            var joiner = "?";
            for(var prop in urlobject.params) {
                if(urlobject.params.hasOwnProperty(prop)) {
                    url += joiner + prop + "=" + urlobject.params[prop];
                    joiner = "&";
                }
            }
            if (urlobject.hash != "") {
                url += "#" + urlobject.hash;
            }
            return url;
        }
        
        // Helper to escape html special chars for displaying the teasers
        var escapeHTML = (function() {
            var MAP = {
                '&': '&amp;',
                '<': '&lt;',
                '>': '&gt;',
                '"': '&#34;',
                "'": '&#39;'
            };
            var repl = function(c) { return MAP[c]; };
            return function(s) {
                return s.replace(/[&<>'"]/g, repl);
            };
        })();
        
        function formatSearchMetric(count, searchterm) {
            if (count == 1) {
                return count + " search result for '" + searchterm + "':";
            } else if (count == 0) {
                return "No search results for '" + searchterm + "'.";
            } else {
                return count + " search results for '" + searchterm + "':";
            }
        }
        
        function formatSearchResult(result, searchterms) {
            var teaser = makeTeaser(escapeHTML(result.doc.body), searchterms);
            teaser_count++;
    
            // The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor
            var url = doc_urls[result.ref].split("#");
            if (url.length == 1) { // no anchor found
                url.push("");
            }
    
            // encodeURIComponent escapes all chars that could allow an XSS except
            // for '. Due to that we also manually replace ' with its url-encoded
            // representation (%27).
            var searchterms = encodeURIComponent(searchterms.join(" ")).replace(/\'/g, "%27");
    
            return '<a href="' + path_to_root + url[0] + '?' + URL_MARK_PARAM + '=' + searchterms + '#' + url[1]
                + '" aria-details="teaser_' + teaser_count + '">' + result.doc.breadcrumbs + '</a>'
                + '<span class="teaser" id="teaser_' + teaser_count + '" aria-label="Search Result Teaser">' 
                + teaser + '</span>';
        }
        
        function makeTeaser(body, searchterms) {
            // The strategy is as follows:
            // First, assign a value to each word in the document:
            //  Words that correspond to search terms (stemmer aware): 40
            //  Normal words: 2
            //  First word in a sentence: 8
            // Then use a sliding window with a constant number of words and count the
            // sum of the values of the words within the window. Then use the window that got the
            // maximum sum. If there are multiple maximas, then get the last one.
            // Enclose the terms in <em>.
            var stemmed_searchterms = searchterms.map(function(w) {
                return elasticlunr.stemmer(w.toLowerCase());
            });
            var searchterm_weight = 40;
            var weighted = []; // contains elements of ["word", weight, index_in_document]
            // split in sentences, then words
            var sentences = body.toLowerCase().split('. ');
            var index = 0;
            var value = 0;
            var searchterm_found = false;
            for (var sentenceindex in sentences) {
                var words = sentences[sentenceindex].split(' ');
                value = 8;
                for (var wordindex in words) {
                    var word = words[wordindex];
                    if (word.length > 0) {
                        for (var searchtermindex in stemmed_searchterms) {
                            if (elasticlunr.stemmer(word).startsWith(stemmed_searchterms[searchtermindex])) {
                                value = searchterm_weight;
                                searchterm_found = true;
                            }
                        };
                        weighted.push([word, value, index]);
                        value = 2;
                    }
                    index += word.length;
                    index += 1; // ' ' or '.' if last word in sentence
                };
                index += 1; // because we split at a two-char boundary '. '
            };
    
            if (weighted.length == 0) {
                return body;
            }
    
            var window_weight = [];
            var window_size = Math.min(weighted.length, results_options.teaser_word_count);
    
            var cur_sum = 0;
            for (var wordindex = 0; wordindex < window_size; wordindex++) {
                cur_sum += weighted[wordindex][1];
            };
            window_weight.push(cur_sum);
            for (var wordindex = 0; wordindex < weighted.length - window_size; wordindex++) {
                cur_sum -= weighted[wordindex][1];
                cur_sum += weighted[wordindex + window_size][1];
                window_weight.push(cur_sum);
            };
    
            if (searchterm_found) {
                var max_sum = 0;
                var max_sum_window_index = 0;
                // backwards
                for (var i = window_weight.length - 1; i >= 0; i--) {
                    if (window_weight[i] > max_sum) {
                        max_sum = window_weight[i];
                        max_sum_window_index = i;
                    }
                };
            } else {
                max_sum_window_index = 0;
            }
    
            // add <em/> around searchterms
            var teaser_split = [];
            var index = weighted[max_sum_window_index][2];
            for (var i = max_sum_window_index; i < max_sum_window_index+window_size; i++) {
                var word = weighted[i];
                if (index < word[2]) {
                    // missing text from index to start of `word`
                    teaser_split.push(body.substring(index, word[2]));
                    index = word[2];
                }
                if (word[1] == searchterm_weight) {
                    teaser_split.push("<em>")
                }
                index = word[2] + word[0].length;
                teaser_split.push(body.substring(word[2], index));
                if (word[1] == searchterm_weight) {
                    teaser_split.push("</em>")
                }
            };
    
            return teaser_split.join('');
        }
    
        function init(config) {
            results_options = config.results_options;
            search_options = config.search_options;
            searchbar_outer = config.searchbar_outer;
            doc_urls = config.doc_urls;
            searchindex = elasticlunr.Index.load(config.index);
    
            // Set up events
            searchicon.addEventListener('click', function(e) { searchIconClickHandler(); }, false);
            searchbar.addEventListener('keyup', function(e) { searchbarKeyUpHandler(); }, false);
            document.addEventListener('keydown', function(e) { globalKeyHandler(e); }, false);
            // If the user uses the browser buttons, do the same as if a reload happened
            window.onpopstate = function(e) { doSearchOrMarkFromUrl(); };
            // Suppress "submit" events so the page doesn't reload when the user presses Enter
            document.addEventListener('submit', function(e) { e.preventDefault(); }, false);
    
            // If reloaded, do the search or mark again, depending on the current url parameters
            doSearchOrMarkFromUrl();
        }
        
        function unfocusSearchbar() {
            // hacky, but just focusing a div only works once
            var tmp = document.createElement('input');
            tmp.setAttribute('style', 'position: absolute; opacity: 0;');
            searchicon.appendChild(tmp);
            tmp.focus();
            tmp.remove();
        }
        
        // On reload or browser history backwards/forwards events, parse the url and do search or mark
        function doSearchOrMarkFromUrl() {
            // Check current URL for search request
            var url = parseURL(window.location.href);
            if (url.params.hasOwnProperty(URL_SEARCH_PARAM)
                && url.params[URL_SEARCH_PARAM] != "") {
                showSearch(true);
                searchbar.value = decodeURIComponent(
                    (url.params[URL_SEARCH_PARAM]+'').replace(/\+/g, '%20'));
                searchbarKeyUpHandler(); // -> doSearch()
            } else {
                showSearch(false);
            }
    
            if (url.params.hasOwnProperty(URL_MARK_PARAM)) {
                var words = decodeURIComponent(url.params[URL_MARK_PARAM]).split(' ');
                marker.mark(words, {
                    exclude: mark_exclude
                });
    
                var markers = document.querySelectorAll("mark");
                function hide() {
                    for (var i = 0; i < markers.length; i++) {
                        markers[i].classList.add("fade-out");
                        window.setTimeout(function(e) { marker.unmark(); }, 300);
                    }
                }
                for (var i = 0; i < markers.length; i++) {
                    markers[i].addEventListener('click', hide);
                }
            }
        }
        
        // Eventhandler for keyevents on `document`
        function globalKeyHandler(e) {
            if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.target.type === 'textarea' || e.target.type === 'text' || !hasFocus() && /^(?:input|select|textarea)$/i.test(e.target.nodeName)) { return; }
    
            if (e.keyCode === ESCAPE_KEYCODE) {
                e.preventDefault();
                searchbar.classList.remove("active");
                setSearchUrlParameters("",
                    (searchbar.value.trim() !== "") ? "push" : "replace");
                if (hasFocus()) {
                    unfocusSearchbar();
                }
                showSearch(false);
                marker.unmark();
            } else if (!hasFocus() && e.keyCode === SEARCH_HOTKEY_KEYCODE) {
                e.preventDefault();
                showSearch(true);
                window.scrollTo(0, 0);
                searchbar.select();
            } else if (hasFocus() && e.keyCode === DOWN_KEYCODE) {
                e.preventDefault();
                unfocusSearchbar();
                searchresults.firstElementChild.classList.add("focus");
            } else if (!hasFocus() && (e.keyCode === DOWN_KEYCODE
                                    || e.keyCode === UP_KEYCODE
                                    || e.keyCode === SELECT_KEYCODE)) {
                // not `:focus` because browser does annoying scrolling
                var focused = searchresults.querySelector("li.focus");
                if (!focused) return;
                e.preventDefault();
                if (e.keyCode === DOWN_KEYCODE) {
                    var next = focused.nextElementSibling;
                    if (next) {
                        focused.classList.remove("focus");
                        next.classList.add("focus");
                    }
                } else if (e.keyCode === UP_KEYCODE) {
                    focused.classList.remove("focus");
                    var prev = focused.previousElementSibling;
                    if (prev) {
                        prev.classList.add("focus");
                    } else {
                        searchbar.select();
                    }
                } else { // SELECT_KEYCODE
                    window.location.assign(focused.querySelector('a'));
                }
            }
        }
        
        function showSearch(yes) {
            if (yes) {
                search_wrap.classList.remove('hidden');
                searchicon.setAttribute('aria-expanded', 'true');
            } else {
                search_wrap.classList.add('hidden');
                searchicon.setAttribute('aria-expanded', 'false');
                var results = searchresults.children;
                for (var i = 0; i < results.length; i++) {
                    results[i].classList.remove("focus");
                }
            }
        }
    
        function showResults(yes) {
            if (yes) {
                searchresults_outer.classList.remove('hidden');
            } else {
                searchresults_outer.classList.add('hidden');
            }
        }
    
        // Eventhandler for search icon
        function searchIconClickHandler() {
            if (search_wrap.classList.contains('hidden')) {
                showSearch(true);
                window.scrollTo(0, 0);
                searchbar.select();
            } else {
                showSearch(false);
            }
        }
        
        // Eventhandler for keyevents while the searchbar is focused
        function searchbarKeyUpHandler() {
            var searchterm = searchbar.value.trim();
            if (searchterm != "") {
                searchbar.classList.add("active");
                doSearch(searchterm);
            } else {
                searchbar.classList.remove("active");
                showResults(false);
                removeChildren(searchresults);
            }
    
            setSearchUrlParameters(searchterm, "push_if_new_search_else_replace");
    
            // Remove marks
            marker.unmark();
        }
        
        // Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and #heading-anchor .
        // `action` can be one of "push", "replace", "push_if_new_search_else_replace"
        // and replaces or pushes a new browser history item.
        // "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet.
        function setSearchUrlParameters(searchterm, action) {
            var url = parseURL(window.location.href);
            var first_search = ! url.params.hasOwnProperty(URL_SEARCH_PARAM);
            if (searchterm != "" || action == "push_if_new_search_else_replace") {
                url.params[URL_SEARCH_PARAM] = searchterm;
                delete url.params[URL_MARK_PARAM];
                url.hash = "";
            } else {
                delete url.params[URL_MARK_PARAM];
                delete url.params[URL_SEARCH_PARAM];
            }
            // A new search will also add a new history item, so the user can go back
            // to the page prior to searching. A updated search term will only replace
            // the url.
            if (action == "push" || (action == "push_if_new_search_else_replace" && first_search) ) {
                history.pushState({}, document.title, renderURL(url));
            } else if (action == "replace" || (action == "push_if_new_search_else_replace" && !first_search) ) {
                history.replaceState({}, document.title, renderURL(url));
            }
        }
        
        function doSearch(searchterm) {
    
            // Don't search the same twice
            if (current_searchterm == searchterm) { return; }
            else { current_searchterm = searchterm; }
    
            if (searchindex == null) { return; }
    
            // Do the actual search
            var results = searchindex.search(searchterm, search_options);
            var resultcount = Math.min(results.length, results_options.limit_results);
    
            // Display search metrics
            searchresults_header.innerText = formatSearchMetric(resultcount, searchterm);
    
            // Clear and insert results
            var searchterms  = searchterm.split(' ');
            removeChildren(searchresults);
            for(var i = 0; i < resultcount ; i++){
                var resultElem = document.createElement('li');
                resultElem.innerHTML = formatSearchResult(results[i], searchterms);
                searchresults.appendChild(resultElem);
            }
    
            // Display results
            showResults(true);
        }
    
        fetch(path_to_root + 'searchindex.json')
            .then(response => response.json())
            .then(json => init(json))        
            .catch(error => { // Try to load searchindex.js if fetch failed
                var script = document.createElement('script');
                script.src = path_to_root + 'searchindex.js';
                script.onload = () => init(window.search);
                document.head.appendChild(script);
            });
    
        // Exported functions
        search.hasFocus = hasFocus;
    })(window.search);