summary refs log tree commit diff
path: root/v1.50/searcher.js
diff options
context:
space:
mode:
authorreivilibre <reivilibre@users.noreply.github.com>2022-01-05 12:40:23 +0000
committerreivilibre <reivilibre@users.noreply.github.com>2022-01-05 12:40:23 +0000
commit405951e9c57c59b63bcdcefdc356dbd2bc50174a (patch)
treea6adfeb1516eaa5169da8cc709f1475b8d8b04da /v1.50/searcher.js
parentdeploy: eedb4527f1524fc3b83cd4838774b04d6f1e3911 (diff)
downloadsynapse-405951e9c57c59b63bcdcefdc356dbd2bc50174a.tar.xz
deploy: 9be5aacc2d901f553ec972d0d62738bd42c87037
Diffstat (limited to 'v1.50/searcher.js')
-rw-r--r--v1.50/searcher.js483
1 files changed, 483 insertions, 0 deletions
diff --git a/v1.50/searcher.js b/v1.50/searcher.js
new file mode 100644
index 0000000000..d2b0aeed38
--- /dev/null
+++ b/v1.50/searcher.js
@@ -0,0 +1,483 @@
+"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') { 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);