/* global KEEP */ KEEP.initLocalSearch = () => { // Search DB path let searchPath = KEEP.hexo_config.path if (!searchPath) { // Search DB path console.warn('`hexo-generator-searchdb` plugin is not installed!') return } // Popup Window let isfetched = false let datas let isXml = true if (searchPath.length === 0) { searchPath = 'search.xml' } else if (searchPath.endsWith('json')) { isXml = false } const searchInputDom = document.querySelector('.search-input') const resultContent = document.getElementById('search-result') const getIndexByWord = (word, text, caseSensitive) => { let wordLen = word.length if (wordLen === 0) return [] let startPosition = 0 let position = [] let index = [] if (!caseSensitive) { text = text.toLowerCase() word = word.toLowerCase() } while ((position = text.indexOf(word, startPosition)) > -1) { index.push({ position, word }) startPosition = position + wordLen } return index } // Merge hits into slices const mergeIntoSlice = (start, end, index, searchText) => { let item = index[index.length - 1] let { position, word } = item let hits = [] let searchTextCountInSlice = 0 while (position + word.length <= end && index.length !== 0) { if (word === searchText) { searchTextCountInSlice++ } hits.push({ position, length: word.length }) let wordEnd = position + word.length // Move to next position of hit index.pop() while (index.length !== 0) { item = index[index.length - 1] position = item.position word = item.word if (wordEnd > position) { index.pop() } else { break } } } return { hits, start, end, searchTextCount: searchTextCountInSlice } } // Highlight title and content const highlightKeyword = (text, slice) => { let result = '' let prevEnd = slice.start slice.hits.forEach((hit) => { result += text.substring(prevEnd, hit.position) let end = hit.position + hit.length result += `${text.substring(hit.position, end)}` prevEnd = end }) result += text.substring(prevEnd, slice.end) return result } const inputEventFunction = () => { if (!isfetched) return let searchText = searchInputDom.value.trim().toLowerCase() let keywords = searchText.split(/[-\s]+/) if (keywords.length > 1) { keywords.push(searchText) } let resultItems = [] if (searchText.length > 0) { // Perform local searching datas.forEach(({ title, content, url }) => { let titleInLowerCase = title.toLowerCase() let contentInLowerCase = content.toLowerCase() let indexOfTitle = [] let indexOfContent = [] let searchTextCount = 0 keywords.forEach((keyword) => { indexOfTitle = indexOfTitle.concat(getIndexByWord(keyword, titleInLowerCase, false)) indexOfContent = indexOfContent.concat(getIndexByWord(keyword, contentInLowerCase, false)) }) // Show search results if (indexOfTitle.length > 0 || indexOfContent.length > 0) { let hitCount = indexOfTitle.length + indexOfContent.length // Sort index by position of keyword ;[indexOfTitle, indexOfContent].forEach((index) => { index.sort((itemLeft, itemRight) => { if (itemRight.position !== itemLeft.position) { return itemRight.position - itemLeft.position } return itemLeft.word.length - itemRight.word.length }) }) let slicesOfTitle = [] if (indexOfTitle.length !== 0) { let tmp = mergeIntoSlice(0, title.length, indexOfTitle, searchText) searchTextCount += tmp.searchTextCountInSlice slicesOfTitle.push(tmp) } let slicesOfContent = [] while (indexOfContent.length !== 0) { let item = indexOfContent[indexOfContent.length - 1] let { position, word } = item // Cut out 100 characters let start = position - 20 let end = position + 80 if (start < 0) { start = 0 } if (end < position + word.length) { end = position + word.length } if (end > content.length) { end = content.length } let tmp = mergeIntoSlice(start, end, indexOfContent, searchText) searchTextCount += tmp.searchTextCountInSlice slicesOfContent.push(tmp) } // Sort slices in content by search text's count and hits' count slicesOfContent.sort((sliceLeft, sliceRight) => { if (sliceLeft.searchTextCount !== sliceRight.searchTextCount) { return sliceRight.searchTextCount - sliceLeft.searchTextCount } else if (sliceLeft.hits.length !== sliceRight.hits.length) { return sliceRight.hits.length - sliceLeft.hits.length } return sliceLeft.start - sliceRight.start }) // Select top N slices in content let upperBound = parseInt( KEEP.theme_config.local_search.top_n_per_article ? KEEP.theme_config.local_search.top_n_per_article : 1, 10 ) if (upperBound >= 0) { slicesOfContent = slicesOfContent.slice(0, upperBound) } let resultItem = '' if (slicesOfTitle.length !== 0) { resultItem += `
  • ${highlightKeyword( title, slicesOfTitle[0] )}` } else { resultItem += `
  • ${title}` } slicesOfContent.forEach((slice) => { resultItem += `

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

    ` }) resultItem += '
  • ' resultItems.push({ item: resultItem, id: resultItems.length, hitCount, searchTextCount }) } }) } if (keywords.length === 1 && keywords[0] === '') { resultContent.innerHTML = '
    ' } else if (resultItems.length === 0) { resultContent.innerHTML = '
    ' } else { resultItems.sort((resultLeft, resultRight) => { if (resultLeft.searchTextCount !== resultRight.searchTextCount) { return resultRight.searchTextCount - resultLeft.searchTextCount } else if (resultLeft.hitCount !== resultRight.hitCount) { return resultRight.hitCount - resultLeft.hitCount } return resultRight.id - resultLeft.id }) let searchResultList = '' resultContent.innerHTML = searchResultList window.pjax && window.pjax.refresh(resultContent) } } const fetchData = () => { fetch(KEEP.hexo_config.root + searchPath) .then((response) => response.text()) .then((res) => { // Get the contents from search data isfetched = true datas = isXml ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map( (element) => { return { title: element.querySelector('title').textContent, content: element.querySelector('content').textContent, url: element.querySelector('url').textContent } } ) : JSON.parse(res) // Only match articles with not empty titles datas = 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 const noResultDom = document.querySelector('#no-result') noResultDom && (noResultDom.innerHTML = '') }) } if (KEEP.theme_config.local_search.preload) { fetchData() } if (searchInputDom) { searchInputDom.addEventListener('input', inputEventFunction) } // Handle and trigger popup window document.querySelectorAll('.search-popup-trigger').forEach((element) => { element.addEventListener('click', () => { document.body.style.overflow = 'hidden' document.querySelector('.search-pop-overlay').classList.add('active') setTimeout(() => searchInputDom.focus(), 500) if (!isfetched) fetchData() }) }) // Monitor main search box const onPopupClose = () => { document.body.style.overflow = '' document.querySelector('.search-pop-overlay').classList.remove('active') } document.querySelector('.search-pop-overlay').addEventListener('click', (event) => { if (event.target === document.querySelector('.search-pop-overlay')) { onPopupClose() } }) document.querySelector('.search-input-field-pre').addEventListener('click', () => { searchInputDom.value = '' searchInputDom.focus() inputEventFunction() }) document.querySelector('.close-popup-btn').addEventListener('click', onPopupClose) window.addEventListener('pjax:success', onPopupClose) window.addEventListener('keyup', (event) => { if (event.key === 'Escape') { onPopupClose() } }) }