PageSourceSearch

https://dyner.netlify.app/searcher.js

js dyner.netlify.app collected 2026-10-03 10:40:30 UTC 18,474 bytes, 483 lines download raw bytes

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