(function () {
// Search Box Element // ============================================================================= var siteSearchElement = document.getElementsByClassName('search-box')[0] var searchBoxElement = document.getElementById('search-box') var clearButton = document.getElementsByClassName('clear-button')[0] var main = document.getElementsByTagName('main')[0] var searchFilter = document.getElementsByClassName('search-filter')[0] var searchResults = document.getElementsByClassName('search-results')[0] searchBoxElement.oninput = function (event) { if (searchBoxElement.value && searchBoxElement.value.trim().length > 0) { siteSearchElement.classList.add('filled') onSearchChangeDebounced() } else { siteSearchElement.classList.remove('filled') onSearchChange() } } searchBoxElement.onfocus = function () { siteSearchElement.classList.add('focused') if (siteSearchElement.classList.contains('filled')) { searchResults.classList.add('visible') } } searchBoxElement.onblur = function() { siteSearchElement.classList.remove('focused') } document.body.addEventListener('click', function (event) { var target = event.target if (target.id !== 'search-box' && !target.classList.contains('search-btn') && !target.parentNode.classList.contains('search-btn')) { searchResults.classList.remove('visible') } }) clearButton.onclick = function () { searchBoxElement.value = '' searchBoxElement.dispatchEvent(new Event('input', { 'bubbles': true, 'cancelable': true })) } // Assign search endpoint based on env config // =========================================================================== var endpoint = null var env = '{{ jekyll.environment }}' var elasticSearchIndex = '{{site.github.owner_name}}-{{site.github.repository_name}}' if (env === 'production') { endpoint = '{{ site.server_PROD | append: '/' }}' + elasticSearchIndex } else { // Allow overriding of search index in dev env var configElasticSearchIndex = '{{site.elastic_search_index}}' if (configElasticSearchIndex) { elasticSearchIndex = configElasticSearchIndex } endpoint = '{{ site.server_DEV | append: '/' }}' + elasticSearchIndex } var search_endpoint = endpoint + '/search' // Global Variables // ============================================================================= var wordsToHighlight = [] var sectionIndex = {} var minQueryLength = 3 var lunrIndex = null // Begin Lunr Indexing // ============================================================================= function getLunrIndex() { return fetch('{{ "/assets/lunrIndex.json" | relative_url }}') .then(function (res) { return res.json() }) .then(function (json) { lunrIndex = lunr.Index.load(json.index) lunrIndex.pipeline.remove(lunr.stemmer) sectionIndex = json.sectionIndex }) .catch(function (err) { console.error('Fetch failed to read the Lunr index: ' + err) }) } // Load Lunr Index if set // ============================================================================ var searchSetOffline = "{{ site.offline }}" === "true" || false if (searchSetOffline) { getLunrIndex() } // Search // ============================================================================= // Helper function to translate lunr search results // Returns a simple { title, content, link } array var snippetSpace = 40 var maxSnippets = 4 var maxResults = 10 var translateLunrResults = function (allLunrResults) { var lunrResults = allLunrResults.slice(0, maxResults) return lunrResults.map(function (result) { var matchedDocument = sectionIndex[result.ref] var contentSnippets = [] var titleSnippets = [] var snippetsRangesByFields = {} // Loop over matching terms var rangesByFields = {} // Group ranges according to field type(text / title) for (var term in result.matchData.metadata) { // To highlight the main body later wordsToHighlight.push(term) var fields = result.matchData.metadata[term] for (var field in fields) { positions = fields[field].position rangesByFields[field] = rangesByFields[field] ? rangesByFields[field].concat(positions) : positions } } var snippetCount = 0 // Sort according to ascending snippet range for (var field in rangesByFields) { var ranges = rangesByFields[field] .map(function (a) { return [a[0] - snippetSpace, a[0] + a[1] + snippetSpace, a[0], a[0] + a[1]] }) .sort(function (a, b) { return a[0] - b[0] }) // Merge contiguous ranges var startIndex = ranges[0][0] var endIndex = ranges[0][1] var mergedRanges = [] var highlightRanges = [] for (rangeIndex in ranges) { var range = ranges[rangeIndex] snippetCount++ if (range[0] <= endIndex) { endIndex = Math.max(range[1], endIndex) highlightRanges = highlightRanges.concat([range[2], range[3]]) } else { mergedRanges.push([startIndex].concat(highlightRanges).concat([endIndex])) startIndex = range[0] endIndex = range[1] highlightRanges = [range[2], range[3]] } if (snippetCount >= maxSnippets) { mergedRanges.push([startIndex].concat(highlightRanges).concat([endIndex])) snippetsRangesByFields[field] = mergedRanges break } if (+rangeIndex === ranges.length - 1) { if (snippetCount + 1 < maxSnippets) { snippetCount++ } mergedRanges.push([startIndex].concat(highlightRanges).concat([endIndex])) snippetsRangesByFields[field] = mergedRanges if (snippetCount >= maxSnippets) { break } } } } // Extract snippets and add highlights to search results for (var field in snippetsRangesByFields) { positions = snippetsRangesByFields[field] positions.forEach(function (position) { matchedText = matchedDocument[field] var snippet = '' // If start of matched text dont use ellipsis if (position[0] > 0) { snippet += '...' } snippet += matchedText.substring(position[0], position[1]) for (var i = 1; i <= position.length - 2; i++) { if (i % 2 == 1) { snippet += '<mark>' } else { snippet += '</mark>' } snippet += matchedText.substring(position[i], position[i + 1]) } if (field === 'title') { titleSnippets.push(snippet) } else { snippet += '...' contentSnippets.push(snippet) } }) } var joinHighlights = function (str) { if (str) { return str.replace(/<\/mark> <mark>/g, ' ') } } // Build a simple flat object per lunr result return { title: joinHighlights(titleSnippets.length === 0 ? matchedDocument.title: titleSnippets.join(' ')), documentTitle: joinHighlights(matchedDocument.documentTitle), content: joinHighlights(contentSnippets.join(' ')), url: matchedDocument.url } }) } // Displays the search results in HTML // Takes an array of objects with "title" and "content" properties var renderSearchResultsFromLunr = function (searchResults) { var container = document.getElementsByClassName('search-results')[0] container.scrollTop = 0 container.innerHTML = '' if (!searchResults || searchResults.length === 0) { var error = generateErrorHTML() container.append(error) } else { searchResults.forEach(function (result, i) { var element = generateResultHTML(result, i) container.appendChild(element) }) } } var renderSearchResultsFromServer = function (searchResults) { var container = document.getElementsByClassName('search-results')[0] container.scrollTop = 0 container.innerHTML = '' if (typeof searchResults.hits === 'undefined') { var error = document.createElement('p') error.classList.add('not-found') error.innerHTML = searchResults container.appendChild(error) // Check if there are hits and max_score is more than 0 // Max score is checked as well as filter will always return something } else if (searchResults.hits.hits.length === 0 || searchResults.hits['max_score'] === 0) { var error = generateErrorHTML() container.appendChild(error) } else { searchResults.hits.hits.forEach(function (result, i) { if (result._score) { var formatted = formatResult(result, i) var element = generateResultHTML(formatted) container.appendChild(element) } }); highlightBody() } } var generateErrorHTML = function () { var error = document.createElement('p') error.innerHTML = 'Results matching your query were not found.' error.classList.add('not-found') return error } var generateResultHTML = function (result, i) { var element = document.createElement('a') element.className = 'search-link nav-link' var urlParts = result.url.split('/') urlParts = urlParts.filter(function (part) { return part !== '' }) element.href = '/' + urlParts.join('/') var searchResult = document.createElement('div') var searchTitle = document.createElement('p') searchTitle.className = 'search-title' searchTitle.innerHTML = result.documentTitle || {{ site.title | jsonify }} searchResult.appendChild(searchTitle) var searchSubtitle = document.createElement('p') searchSubtitle.className = 'search-subtitle' searchSubtitle.innerHTML = result.title searchResult.appendChild(searchSubtitle) var searchContent = document.createElement('p') searchContent.className = 'search-content' searchContent.innerHTML = result.content searchResult.appendChild(searchContent) element.onmouseup = function() { searchResults.classList.remove('visible') } element.appendChild(searchResult) return element } formatResult = function (result) { var content = null var title = result._source.title var url = result._source.url; var documentTitle = result._source.documentTitle; var regex = /<mark>(.*?)<\/mark>/g var joinHighlights = function (str) { if (str) { return str.replace(/<\/mark> <mark>/g, ' ') } } if (result.highlight) { ['title', 'content'].forEach(function (field) { var curr, match, term; if (result.highlight[field]) { var curr = result.highlight[field].join('...') // trimLeft not supported in IE var curr = curr.replace(/^\s+/, "") var curr = joinHighlights(curr) var match = true while (match) { match = regex.exec(curr) if (match) { var term = match[1].toLowerCase() if ((wordsToHighlight.indexOf(term)) < 0) { wordsToHighlight.push(term) } } } } }) if (result.highlight.content) { content = joinHighlights(result.highlight.content.slice(0, Math.min(3, result.highlight.content.length)).join('...')) } if (result.highlight.title) { title = joinHighlights(result.highlight.title[0]) } } return { url: url, content: content ? '...' + content + '...' : '', title: title, documentTitle: documentTitle } } var debounce = function (func, threshold, execAsap) { var timeout = null; return function () { var args = 1 <= arguments.length ? slice.call(arguments, 0) : [] obj = this var delayed = function () { if (!execAsap) { func.apply(obj, args) } timeout = null } if (timeout) { clearTimeout(timeout) } else if (execAsap) { func.apply(obj, args) } timeout = setTimeout(delayed, threshold || 100) } } var createEsQuery = function (queryStr) { var source = ['title', 'url', 'documentTitle'] var title_automcomplete_q = { 'match_phrase_prefix': { 'title': { 'query': queryStr, 'max_expansions': 20, 'boost': 100, 'slop': 10 } } } var content_automcomplete_q = { 'match_phrase_prefix': { 'content': { 'query': queryStr, 'max_expansions': 20, 'boost': 60, 'slop': 10 } } } var title_keyword_q = { 'match': { 'title': { 'query': queryStr, 'fuzziness': 'AUTO', 'max_expansions': 10, 'boost': 20, 'analyzer': 'stop' } } } var content_keyword_q = { 'match': { 'content': { 'query': queryStr, 'fuzziness': 'AUTO', 'max_expansions': 10, 'analyzer': 'stop' } } } var bool_q = { 'bool': { 'should': [title_automcomplete_q, content_automcomplete_q, title_keyword_q, content_keyword_q], } } // If document filter is present var page = pageIndex[window.location.pathname] if (!searchFilter.classList.contains('hidden') && page && page.documentInfo[0]) { // documentId is the alphanumeric and lowercase version of document title // used as a keyword filter to search within the document var documentId = page.documentInfo[0].replace(/[^\w]/g, '').toLowerCase() var filter_by_document = { 'term': { 'documentId': documentId } } bool_q.bool.filter = filter_by_document } var highlight = {} highlight.require_field_match = false highlight.fields = {} highlight.fields['content'] = { 'fragment_size': 80, 'number_of_fragments': 6, 'pre_tags': ['<mark>'], 'post_tags': ['</mark>'] } highlight.fields['title'] = { 'fragment_size': 80, 'number_of_fragments': 6, 'pre_tags': ['<mark>'], 'post_tags': ['</mark>'] } return { '_source': source, 'query': bool_q, 'highlight': highlight } } // Call the API esSearch = function (query) { var esQuery = createEsQuery(query) fetch(search_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(esQuery) }) .then(checkStatus) .then(parseJSON) .then(function (data) { renderSearchResultsFromServer(data.body) }) .catch(function (err) { console.error(err) renderSearchResultsFromServer('Failed to fetch search results') }) } var lunrSearch = function (query) { // Add wildcard before and after var queryTerm = refineLunrSearchQuery(query) if (lunrIndex !== null) { var lunrResults = lunrIndex.search(queryTerm) var results = translateLunrResults(lunrResults) highlightBody() renderSearchResultsFromLunr(results) } } var refineLunrSearchQuery = function(query) { FUZZY_FACTOR = 4 // range: 1 to INF. Lower is fuzzier *relative to term length*. var addFuzzyOperator = function(term, fuzziness) { return term + '~' + Math.floor(term.length / Math.max(1, fuzziness)).toString() } var stringIsLettersOnly = function(str) { return /^[a-zA-Z]+$/.test(str) } var terms = query.split(' ') terms = terms.map(function(term) { if (stringIsLettersOnly(term)) { return addFuzzyOperator(term, FUZZY_FACTOR) } return term }) return terms.join(' ') } var onSearchChange = function () { var query = searchBoxElement.value.trim() // Clear highlights wordsToHighlight = [] if (query.length < minQueryLength) { searchResults.classList.remove('visible') highlightBody() return } searchResults.classList.add('visible') if (searchSetOffline) { lunrSearch(query) } else { esSearch(query) if (env === 'production' && window.ga) { window.ga('send', 'pageview', '/search?query=' + encodeURIComponent(query)) } } } var onSearchChangeDebounced = debounce(onSearchChange, 500, false) var isBackspaceFirstPress = true var isBackspacePressedOnEmpty = false // Detect that backspace is not part of longpress searchBoxElement.onkeydown = function (e) { searchResults.classList.remove('hidden') if (isBackspaceFirstPress && e.keyCode === 8) { isBackspaceFirstPress = false if (searchBoxElement.value === '') { isBackspacePressedOnEmpty = true } } } clearSearchFilter = function () { searchFilter.classList.add('hidden') } searchFilter.onclick = clearSearchFilter searchBoxElement.onkeyup = function (e) { // flash search results on enter if (e.keyCode === 13) { var container = document.getElementsByClassName('search-results')[0] container.style.opacity = 0 return setTimeout(function () { return container.style.opacity = 1 }, 100) } // Delete filter on backspace when input is empty and not part of longpress if (e.keyCode === 8) { isBackspaceFirstPress = true if (searchBoxElement.value === '' && isBackspacePressedOnEmpty) { clearSearchFilter() isBackspacePressedOnEmpty = false return } } } // Highlighting // ============================================================================ window.highlightBody = function () { // Check if Mark.js script is already imported if (Mark) { var instance = new Mark(main) instance.unmark() if (wordsToHighlight.length > 0) { instance.mark(wordsToHighlight, { exclude: ['h1'], accuracy: { value: 'exactly', limiters: [',', '.', '(', ')', '-', '\'', '[', ']', '?', '/', '\\', ':', '*', '!', '@', '&'] }, separateWordSearch: false }) } } }
})()