/** * Refer to hexo-generator-searchdb * https://github.com/next-theme/hexo-generator-searchdb/blob/main/dist/search.js * Modified by hexo-theme-butterfly */ class LocalSearch { constructor ({ path = '', unescape = false, top_n_per_article = 1 }) { this.path = path this.unescape = unescape this.top_n_per_article = top_n_per_article this.isfetched = false this.datas = null } getIndexByWord (words, text, caseSensitive = false) { const index = [] const included = new Set() if (!caseSensitive) { text = text.toLowerCase() } words.forEach(word => { if (this.unescape) { const div = document.createElement('div') div.innerText = word word = div.innerHTML } const wordLen = word.length if (wordLen === 0) return let startPosition = 0 let position = -1 if (!caseSensitive) { word = word.toLowerCase() } while ((position = text.indexOf(word, startPosition)) > -1) { index.push({ position, word }) included.add(word) startPosition = position + wordLen } }) // Sort index by position of keyword index.sort((left, right) => { if (left.position !== right.position) { return left.position - right.position } return right.word.length - left.word.length }) return [index, included] } // Merge hits into slices mergeIntoSlice (start, end, index) { let item = index[0] let { position, word } = item const hits = [] const count = new Set() while (position + word.length <= end && index.length !== 0) { count.add(word) hits.push({ position, length: word.length }) const wordEnd = position + word.length // Move to next position of hit index.shift() while (index.length !== 0) { item = index[0] position = item.position word = item.word if (wordEnd > position) { index.shift() } else { break } } } return { hits, start, end, count: count.size } } // Highlight title and content highlightKeyword (val, slice) { let result = '' let index = slice.start for (const { position, length } of slice.hits) { result += val.substring(index, position) index = position + length result += `${val.substr(position, length)}` } result += val.substring(index, slice.end) return result } getResultItems (keywords) { const resultItems = [] this.datas.forEach(({ title, content, url }) => { // The number of different keywords included in the article. const [indexOfTitle, keysOfTitle] = this.getIndexByWord(keywords, title) const [indexOfContent, keysOfContent] = this.getIndexByWord(keywords, content) const includedCount = new Set([...keysOfTitle, ...keysOfContent]).size // Show search results const hitCount = indexOfTitle.length + indexOfContent.length if (hitCount === 0) return const slicesOfTitle = [] if (indexOfTitle.length !== 0) { slicesOfTitle.push(this.mergeIntoSlice(0, title.length, indexOfTitle)) } let slicesOfContent = [] while (indexOfContent.length !== 0) { const item = indexOfContent[0] const { position } = item // Cut out 120 characters. The maxlength of .search-input is 80. const start = Math.max(0, position - 20) const end = Math.min(content.length, position + 100) slicesOfContent.push(this.mergeIntoSlice(start, end, indexOfContent)) } // Sort slices in content by included keywords' count and hits' count slicesOfContent.sort((left, right) => { if (left.count !== right.count) { return right.count - left.count } else if (left.hits.length !== right.hits.length) { return right.hits.length - left.hits.length } return left.start - right.start }) // Select top N slices in content const upperBound = parseInt(this.top_n_per_article, 10) if (upperBound >= 0) { slicesOfContent = slicesOfContent.slice(0, upperBound) } let resultItem = '' url = new URL(url, location.origin) url.searchParams.append('highlight', keywords.join(' ')) if (slicesOfTitle.length !== 0) { resultItem += `
${this.highlightKeyword(title, slicesOfTitle[0])}` } else { resultItem += `
${title}` } slicesOfContent.forEach(slice => { resultItem += `

${this.highlightKeyword(content, slice)}...

` }) resultItem += '
' resultItems.push({ item: resultItem, id: resultItems.length, hitCount, includedCount }) }) return resultItems } fetchData () { const isXml = !this.path.endsWith('json') fetch(this.path) .then(response => response.text()) .then(res => { // Get the contents from search data this.isfetched = true this.datas = isXml ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => ({ title: element.querySelector('title').textContent, content: element.querySelector('content').textContent, url: element.querySelector('url').textContent })) : JSON.parse(res) // Only match articles with non-empty titles this.datas = this.datas.filter(data => data.title).map(data => { data.title = data.title.trim() data.content = data.content ? data.content.trim().replace(/<[^>]+>/g, '') : '' data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/') return data }) // Remove loading animation window.dispatchEvent(new Event('search:loaded')) }) } // Highlight by wrapping node in mark elements with the given class name highlightText (node, slice, className) { const val = node.nodeValue let index = slice.start const children = [] for (const { position, length } of slice.hits) { const text = document.createTextNode(val.substring(index, position)) index = position + length const mark = document.createElement('mark') mark.className = className mark.appendChild(document.createTextNode(val.substr(position, length))) children.push(text, mark) } node.nodeValue = val.substring(index, slice.end) children.forEach(element => { node.parentNode.insertBefore(element, node) }) } // Highlight the search words provided in the url in the text highlightSearchWords (body) { const params = new URL(location.href).searchParams.get('highlight') const keywords = params ? params.split(' ') : [] if (!keywords.length || !body) return const walk = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null) const allNodes = [] while (walk.nextNode()) { if (!walk.currentNode.parentNode.matches('button, select, textarea, .mermaid')) allNodes.push(walk.currentNode) } allNodes.forEach(node => { const [indexOfNode] = this.getIndexByWord(keywords, node.nodeValue) if (!indexOfNode.length) return const slice = this.mergeIntoSlice(0, node.nodeValue.length, indexOfNode) this.highlightText(node, slice, 'search-keyword') }) } } window.addEventListener('load', () => { // Search const { path, top_n_per_article, unescape, languages } = GLOBAL_CONFIG.localSearch const localSearch = new LocalSearch({ path, top_n_per_article, unescape }) const input = document.querySelector('#local-search-input input') const statsItem = document.getElementById('local-search-stats-wrap') const $loadingStatus = document.getElementById('loading-status') const isXml = !path.endsWith('json') const inputEventFunction = () => { if (!localSearch.isfetched) return let searchText = input.value.trim().toLowerCase() isXml && (searchText = searchText.replace(//g, '>')) if (searchText !== '') $loadingStatus.innerHTML = '' const keywords = searchText.split(/[-\s]+/) const container = document.getElementById('local-search-results') let resultItems = [] if (searchText.length > 0) { // Perform local searching resultItems = localSearch.getResultItems(keywords) } if (keywords.length === 1 && keywords[0] === '') { container.textContent = '' statsItem.textContent = '' } else if (resultItems.length === 0) { container.textContent = '' const statsDiv = document.createElement('div') statsDiv.className = 'search-result-stats' statsDiv.textContent = languages.hits_empty.replace(/\$\{query}/, searchText) statsItem.innerHTML = statsDiv.outerHTML } else { resultItems.sort((left, right) => { if (left.includedCount !== right.includedCount) { return right.includedCount - left.includedCount } else if (left.hitCount !== right.hitCount) { return right.hitCount - left.hitCount } return right.id - left.id }) const stats = languages.hits_stats.replace(/\$\{hits}/, resultItems.length) container.innerHTML = `
${resultItems.map(result => result.item).join('')}
` statsItem.innerHTML = `
${stats}
` window.pjax && window.pjax.refresh(container) } $loadingStatus.textContent = '' } let loadFlag = false const $searchMask = document.getElementById('search-mask') const $searchDialog = document.querySelector('#local-search .search-dialog') // fix safari const fixSafariHeight = () => { if (window.innerWidth < 768) { $searchDialog.style.setProperty('--search-height', window.innerHeight + 'px') } } const openSearch = () => { const bodyStyle = document.body.style bodyStyle.width = '100%' bodyStyle.overflow = 'hidden' btf.animateIn($searchMask, 'to_show 0.5s') btf.animateIn($searchDialog, 'titleScale 0.5s') setTimeout(() => { input.focus() }, 300) if (!loadFlag) { !localSearch.isfetched && localSearch.fetchData() input.addEventListener('input', inputEventFunction) loadFlag = true } // shortcut: ESC document.addEventListener('keydown', function f (event) { if (event.code === 'Escape') { closeSearch() document.removeEventListener('keydown', f) } }) fixSafariHeight() window.addEventListener('resize', fixSafariHeight) } const closeSearch = () => { const bodyStyle = document.body.style bodyStyle.width = '' bodyStyle.overflow = '' btf.animateOut($searchDialog, 'search_close .5s') btf.animateOut($searchMask, 'to_hide 0.5s') window.removeEventListener('resize', fixSafariHeight) } const searchClickFn = () => { btf.addEventListenerPjax(document.querySelector('#search-button > .search'), 'click', openSearch) } const searchFnOnce = () => { document.querySelector('#local-search .search-close-button').addEventListener('click', closeSearch) $searchMask.addEventListener('click', closeSearch) if (GLOBAL_CONFIG.localSearch.preload) { localSearch.fetchData() } localSearch.highlightSearchWords(document.getElementById('article-container')) } window.addEventListener('search:loaded', () => { const $loadDataItem = document.getElementById('loading-database') $loadDataItem.nextElementSibling.style.display = 'block' $loadDataItem.remove() }) searchClickFn() searchFnOnce() // pjax window.addEventListener('pjax:complete', () => { !btf.isHidden($searchMask) && closeSearch() localSearch.highlightSearchWords(document.getElementById('article-container')) searchClickFn() }) })