_/js/search-ui.js (395 lines of code) (raw):

(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.antoraSearch = {})); })(this, (function (exports) { 'use strict'; function buildHighlightedText (text, positions, snippetLength) { const textLength = text.length; const validPositions = positions .filter((position) => position.length > 0 && position.start + position.length <= textLength); if (validPositions.length === 0) { return [ { type: 'text', text: text.slice(0, snippetLength >= textLength ? textLength : snippetLength) + (snippetLength < textLength ? '...' : ''), }, ] } const orderedPositions = validPositions.sort((p1, p2) => p1.start - p2.start); const range = { start: 0, end: textLength, }; const firstPosition = orderedPositions[0]; if (snippetLength && text.length > snippetLength) { const firstPositionStart = firstPosition.start; const firstPositionLength = firstPosition.length; const firstPositionEnd = firstPositionStart + firstPositionLength; range.start = firstPositionStart - snippetLength < 0 ? 0 : firstPositionStart - snippetLength; range.end = firstPositionEnd + snippetLength > textLength ? textLength : firstPositionEnd + snippetLength; } const nodes = []; if (firstPosition.start > 0) { nodes.push({ type: 'text', text: (range.start > 0 ? '...' : '') + text.slice(range.start, firstPosition.start), }); } let lastEndPosition = 0; const positionsWithinRange = orderedPositions .filter((position) => position.start >= range.start && position.start + position.length <= range.end); for (const position of positionsWithinRange) { const start = position.start; const length = position.length; const end = start + length; if (lastEndPosition > 0) { // create text Node from the last end position to the start of the current position nodes.push({ type: 'text', text: text.slice(lastEndPosition, start), }); } nodes.push({ type: 'mark', text: text.slice(start, end), }); lastEndPosition = end; } if (lastEndPosition < range.end) { nodes.push({ type: 'text', text: text.slice(lastEndPosition, range.end) + (range.end < textLength ? '...' : ''), }); } return nodes } /** * Taken and adapted from: https://github.com/olivernn/lunr.js/blob/aa5a878f62a6bba1e8e5b95714899e17e8150b38/lib/tokenizer.js#L24-L67 * @param lunr * @param text * @param term * @return {{start: number, length: number}} */ function findTermPosition (lunr, term, text) { const str = text.toLowerCase(); const len = str.length; for (let sliceEnd = 0, sliceStart = 0; sliceEnd <= len; sliceEnd++) { const char = str.charAt(sliceEnd); const sliceLength = sliceEnd - sliceStart; if ((char.match(lunr.tokenizer.separator) || sliceEnd === len)) { if (sliceLength > 0) { const value = str.slice(sliceStart, sliceEnd); // QUESTION: if we get an exact match without running the pipeline should we stop? if (value.includes(term)) { // returns the first match return { start: sliceStart, length: value.length, } } } sliceStart = sliceEnd + 1; } } // not found! return { start: 0, length: 0, } } /* global CustomEvent, globalThis */ const config = document.getElementById('search-ui-script').dataset; const snippetLength = parseInt(config.snippetLength || 100, 10); const siteRootPath = config.siteRootPath || ''; appendStylesheet(config.stylesheet); const searchInput = document.getElementById('search-input'); const searchResultContainer = document.createElement('div'); searchResultContainer.classList.add('search-result-dropdown-menu'); searchInput.parentNode.appendChild(searchResultContainer); const facetFilterInput = document.querySelector('#search-field input[type=checkbox][data-facet-filter]'); function appendStylesheet (href) { if (!href) return const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; document.head.appendChild(link); } function highlightPageTitle (title, terms) { const positions = getTermPosition(title, terms); return buildHighlightedText(title, positions, snippetLength) } function highlightSectionTitle (sectionTitle, terms) { if (sectionTitle) { const text = sectionTitle.text; const positions = getTermPosition(text, terms); return buildHighlightedText(text, positions, snippetLength) } return [] } function highlightText (doc, terms) { const text = doc.text; const positions = getTermPosition(text, terms); return buildHighlightedText(text, positions, snippetLength) } function getTermPosition (text, terms) { const positions = terms .map((term) => findTermPosition(globalThis.lunr, term, text)) .filter((position) => position.length > 0) .sort((p1, p2) => p1.start - p2.start); if (positions.length === 0) { return [] } return positions } function highlightHit (searchMetadata, sectionTitle, doc) { const terms = {}; for (const term in searchMetadata) { const fields = searchMetadata[term]; for (const field in fields) { terms[field] = [...(terms[field] || []), term]; } } return { pageTitleNodes: highlightPageTitle(doc.title, terms.title || []), sectionTitleNodes: highlightSectionTitle(sectionTitle, terms.title || []), pageContentNodes: highlightText(doc, terms.text || []), } } function createSearchResult (result, store, searchResultDataset) { let currentComponent; result.forEach(function (item) { const ids = item.ref.split('-'); const docId = ids[0]; const doc = store.documents[docId]; let sectionTitle; if (ids.length > 1) { const titleId = ids[1]; sectionTitle = doc.titles.filter(function (item) { return String(item.id) === titleId })[0]; } const metadata = item.matchData.metadata; const highlightingResult = highlightHit(metadata, sectionTitle, doc); const componentVersion = store.componentVersions[`${doc.component}/${doc.version}`]; if (componentVersion !== undefined && currentComponent !== componentVersion) { const searchResultComponentHeader = document.createElement('div'); searchResultComponentHeader.classList.add('search-result-component-header'); const { title, displayVersion } = componentVersion; const componentVersionText = `${title}${doc.version && displayVersion ? ` ${displayVersion}` : ''}`; searchResultComponentHeader.appendChild(document.createTextNode(componentVersionText)); searchResultDataset.appendChild(searchResultComponentHeader); currentComponent = componentVersion; } searchResultDataset.appendChild(createSearchResultItem(doc, sectionTitle, item, highlightingResult)); }); } function createSearchResultItem (doc, sectionTitle, item, highlightingResult) { const documentTitle = document.createElement('div'); documentTitle.classList.add('search-result-document-title'); highlightingResult.pageTitleNodes.forEach(function (node) { let element; if (node.type === 'text') { element = document.createTextNode(node.text); } else { element = document.createElement('span'); element.classList.add('search-result-highlight'); element.innerText = node.text; } documentTitle.appendChild(element); }); const documentHit = document.createElement('div'); documentHit.classList.add('search-result-document-hit'); const documentHitLink = document.createElement('a'); documentHitLink.href = siteRootPath + doc.url + (sectionTitle ? '#' + sectionTitle.hash : ''); documentHit.appendChild(documentHitLink); if (highlightingResult.sectionTitleNodes.length > 0) { const documentSectionTitle = document.createElement('div'); documentSectionTitle.classList.add('search-result-section-title'); documentHitLink.appendChild(documentSectionTitle); highlightingResult.sectionTitleNodes.forEach(function (node) { let element; if (node.type === 'text') { element = document.createTextNode(node.text); } else { element = document.createElement('span'); element.classList.add('search-result-highlight'); element.innerText = node.text; } documentSectionTitle.appendChild(element); }); } highlightingResult.pageContentNodes.forEach(function (node) { let element; if (node.type === 'text') { element = document.createTextNode(node.text); } else { element = document.createElement('span'); element.classList.add('search-result-highlight'); element.innerText = node.text; } documentHitLink.appendChild(element); }); const searchResultItem = document.createElement('div'); searchResultItem.classList.add('search-result-item'); searchResultItem.appendChild(documentTitle); searchResultItem.appendChild(documentHit); searchResultItem.addEventListener('mousedown', function (e) { e.preventDefault(); }); return searchResultItem } function createNoResult (text) { const searchResultItem = document.createElement('div'); searchResultItem.classList.add('search-result-item'); const documentHit = document.createElement('div'); documentHit.classList.add('search-result-document-hit'); const message = document.createElement('strong'); message.innerText = 'No results found for query "' + text + '"'; documentHit.appendChild(message); searchResultItem.appendChild(documentHit); return searchResultItem } function clearSearchResults (reset) { if (reset === true) searchInput.value = ''; searchResultContainer.innerHTML = ''; } function filter (result, documents) { const facetFilter = facetFilterInput && facetFilterInput.checked && facetFilterInput.dataset.facetFilter; if (facetFilter) { const [field, value] = facetFilter.split(':'); return result.filter((item) => { const ids = item.ref.split('-'); const docId = ids[0]; const doc = documents[docId]; return field in doc && doc[field] === value }) } return result } function search (index, documents, queryString) { // execute an exact match search let query; let result = filter( index.query(function (lunrQuery) { const parser = new globalThis.lunr.QueryParser(queryString, lunrQuery); parser.parse(); query = lunrQuery; }), documents ); if (result.length > 0) { return result } // no result, use a begins with search result = filter( index.query(function (lunrQuery) { lunrQuery.clauses = query.clauses.map((clause) => { if (clause.presence !== globalThis.lunr.Query.presence.PROHIBITED) { clause.term = clause.term + '*'; clause.wildcard = globalThis.lunr.Query.wildcard.TRAILING; clause.usePipeline = false; } return clause }); }), documents ); if (result.length > 0) { return result } // no result, use a contains search result = filter( index.query(function (lunrQuery) { lunrQuery.clauses = query.clauses.map((clause) => { if (clause.presence !== globalThis.lunr.Query.presence.PROHIBITED) { clause.term = '*' + clause.term + '*'; clause.wildcard = globalThis.lunr.Query.wildcard.LEADING | globalThis.lunr.Query.wildcard.TRAILING; clause.usePipeline = false; } return clause }); }), documents ); return result } function searchIndex (index, store, text) { clearSearchResults(false); if (text.trim() === '') { return } const result = search(index, store.documents, text); const searchResultDataset = document.createElement('div'); searchResultDataset.classList.add('search-result-dataset'); searchResultContainer.appendChild(searchResultDataset); if (result.length > 0) { createSearchResult(result, store, searchResultDataset); } else { searchResultDataset.appendChild(createNoResult(text)); } } function confineEvent (e) { e.stopPropagation(); } function debounce (func, wait, immediate) { let timeout; return function () { const context = this; const args = arguments; const later = function () { timeout = null; if (!immediate) func.apply(context, args); }; const callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); } } function enableSearchInput (enabled) { if (facetFilterInput) { facetFilterInput.disabled = !enabled; } searchInput.disabled = !enabled; searchInput.title = enabled ? '' : 'Loading index...'; } function isClosed () { return searchResultContainer.childElementCount === 0 } function executeSearch (index) { const debug = 'URLSearchParams' in globalThis && new URLSearchParams(globalThis.location.search).has('lunr-debug'); const query = searchInput.value; try { if (!query) return clearSearchResults() searchIndex(index.index, index.store, query); } catch (err) { if (err instanceof globalThis.lunr.QueryParseError) { if (debug) { console.debug('Invalid search query: ' + query + ' (' + err.message + ')'); } } else { console.error('Something went wrong while searching', err); } } } function toggleFilter (e, index) { searchInput.focus(); if (!isClosed()) { executeSearch(index); } } function initSearch (lunr, data) { const start = performance.now(); const index = { index: lunr.Index.load(data.index), store: data.store }; enableSearchInput(true); searchInput.dispatchEvent( new CustomEvent('loadedindex', { detail: { took: performance.now() - start, }, }) ); searchInput.addEventListener( 'keydown', debounce(function (e) { if (e.key === 'Escape' || e.key === 'Esc') return clearSearchResults(true) executeSearch(index); }, 100) ); searchInput.addEventListener('click', confineEvent); searchResultContainer.addEventListener('click', confineEvent); if (facetFilterInput) { facetFilterInput.parentElement.addEventListener('click', confineEvent); facetFilterInput.addEventListener('change', (e) => toggleFilter(e, index)); } document.documentElement.addEventListener('click', clearSearchResults); } // disable the search input until the index is loaded enableSearchInput(false); exports.initSearch = initSearch; Object.defineProperty(exports, '__esModule', { value: true }); }));