365 lines
12 KiB
JavaScript
365 lines
12 KiB
JavaScript
|
/**
|
||
|
* 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 += `<mark class="search-keyword">${val.substr(position, length)}</mark>`
|
||
|
}
|
||
|
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 += `<div class="local-search-hit-item"><a href="${url.href}"><span class="search-result-title">${this.highlightKeyword(title, slicesOfTitle[0])}</span>`
|
||
|
} else {
|
||
|
resultItem += `<div class="local-search-hit-item"><a href="${url.href}"><span class="search-result-title">${title}</span>`
|
||
|
}
|
||
|
|
||
|
slicesOfContent.forEach(slice => {
|
||
|
resultItem += `<p class="search-result">${this.highlightKeyword(content, slice)}...</p></a>`
|
||
|
})
|
||
|
|
||
|
resultItem += '</div>'
|
||
|
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, '<').replace(/>/g, '>'))
|
||
|
if (searchText !== '') $loadingStatus.innerHTML = '<i class="fas fa-spinner fa-pulse"></i>'
|
||
|
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 = `<div class="search-result-list">${resultItems.map(result => result.item).join('')}</div>`
|
||
|
statsItem.innerHTML = `<hr><div class="search-result-stats">${stats}</div>`
|
||
|
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()
|
||
|
})
|
||
|
})
|