UNPKG

@antora/lunr-extension

Version:

An Antora extension that adds offline, full-text search powered by Lunr to your Antora documentation site.

357 lines (330 loc) 12.3 kB
/* global CustomEvent, globalThis */ 'use strict' import { buildHighlightedText, findTermPosition } from './search-result-highlighting.mjs' 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 highlightSectionTitle (sectionTitle, terms) { if (sectionTitle) { const text = sectionTitle.title ?? sectionTitle.text const positions = getTermPosition(text, terms) return buildHighlightedText(text, positions, snippetLength) } return [] } function highlightKeyword (doc, terms) { const keyword = doc.keyword if (keyword) { const positions = getTermPosition(keyword, terms) return buildHighlightedText(keyword, positions, snippetLength) } return [] } function highlightText (text, terms) { 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: highlightText(doc.title, terms.title || []), sectionTitleNodes: highlightSectionTitle(sectionTitle, terms.title || []), pageContentNodes: highlightText( sectionTitle?.title && sectionTitle.text ? sectionTitle.text : doc.text, terms.text || [] ), pageKeywordNodes: highlightKeyword(doc, terms.keyword || []), } } 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.find(function (item) { return String(item.id) === titleId }) } const metadata = item.matchData.metadata const highlightingResult = highlightHit(metadata, sectionTitle || doc, 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((node) => createHighlightedText(node, documentSectionTitle)) } highlightingResult.pageContentNodes.forEach((node) => createHighlightedText(node, documentHitLink)) // only show keyword when we got a hit on them if (doc.keyword && highlightingResult.pageKeywordNodes.length > 1) { const documentKeywords = document.createElement('div') documentKeywords.classList.add('search-result-keywords') const documentKeywordsFieldLabel = document.createElement('span') documentKeywordsFieldLabel.classList.add('search-result-keywords-field-label') documentKeywordsFieldLabel.innerText = 'keywords: ' const documentKeywordsList = document.createElement('span') documentKeywordsList.classList.add('search-result-keywords-list') highlightingResult.pageKeywordNodes.forEach((node) => createHighlightedText(node, documentKeywordsList)) documentKeywords.appendChild(documentKeywordsFieldLabel) documentKeywords.appendChild(documentKeywordsList) documentHitLink.appendChild(documentKeywords) } 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 } /** * Creates an element from a highlightingResultNode and add it to the targetNode. * @param {Object} highlightingResultNode * @param {String} highlightingResultNode.type - type of the node * @param {String} highlightingResultNode.text * @param {Node} targetNode */ function createHighlightedText (highlightingResultNode, targetNode) { let element if (highlightingResultNode.type === 'text') { element = document.createTextNode(highlightingResultNode.text) } else { element = document.createElement('span') element.classList.add('search-result-highlight') element.innerText = highlightingResultNode.text } targetNode.appendChild(element) } 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?.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) } } export 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)