1/** 2 * Refer to hexo-generator-searchdb 3 * https://github.com/next-theme/hexo-generator-searchdb/blob/main/dist/search.js 4 * Modified by hexo-theme-butterfly 5 */ 6 7class LocalSearch { 8 constructor ({ 9 path = '', 10 unescape = false, 11 top_n_per_article = 1 12 }) { 13 this.path = path 14 this.unescape = unescape 15 this.top_n_per_article = top_n_per_article 16 this.isfetched = false 17 this.datas = null 18 } 19 20 getIndexByWord (words, text, caseSensitive = false) { 21 const index = [] 22 const included = new Set() 23 24 if (!caseSensitive) { 25 text = text.toLowerCase() 26 } 27 words.forEach(word => { 28 if (this.unescape) { 29 const div = document.createElement('div') 30 div.innerText = word 31 word = div.innerHTML 32 } 33 const wordLen = word.length 34 if (wordLen === 0) return 35 let startPosition = 0 36 let position = -1 37 if (!caseSensitive) { 38 word = word.toLowerCase() 39 } 40 while ((position = text.indexOf(word, startPosition)) > -1) { 41 index.push({ position, word }) 42 included.add(word) 43 startPosition = position + wordLen 44 } 45 }) 46 // Sort index by position of keyword 47 index.sort((left, right) => { 48 if (left.position !== right.position) { 49 return left.position - right.position 50 } 51 return right.word.length - left.word.length 52 }) 53 return [index, included] 54 } 55 56 // Merge hits into slices 57 mergeIntoSlice (start, end, index) { 58 let item = index[0] 59 let { position, word } = item 60 const hits = [] 61 const count = new Set() 62 while (position + word.length <= end && index.length !== 0) { 63 count.add(word) 64 hits.push({ 65 position, 66 length: word.length 67 }) 68 const wordEnd = position + word.length 69 70 // Move to next position of hit 71 index.shift() 72 while (index.length !== 0) { 73 item = index[0] 74 position = item.position 75 word = item.word 76 if (wordEnd > position) { 77 index.shift() 78 } else { 79 break 80 } 81 } 82 } 83 return { 84 hits, 85 start, 86 end, 87 count: count.size 88 } 89 } 90 91 // Highlight title and content 92 highlightKeyword (val, slice) { 93 let result = '' 94 let index = slice.start 95 for (const { position, length } of slice.hits) { 96 result += val.substring(index, position) 97 index = position + length 98 result += `<mark class="search-keyword">${val.substr(position, length)}</mark>` 99 } 100 result += val.substring(index, slice.end) 101 return result 102 } 103 104 getResultItems (keywords) { 105 const resultItems = [] 106 this.datas.forEach(({ title, content, url }) => { 107 // The number of different keywords included in the article. 108 const [indexOfTitle, keysOfTitle] = this.getIndexByWord(keywords, title) 109 const [indexOfContent, keysOfContent] = this.getIndexByWord(keywords, content) 110 const includedCount = new Set([...keysOfTitle, ...keysOfContent]).size 111 112 // Show search results 113 const hitCount = indexOfTitle.length + indexOfContent.length 114 if (hitCount === 0) return 115 116 const slicesOfTitle = [] 117 if (indexOfTitle.length !== 0) { 118 slicesOfTitle.push(this.mergeIntoSlice(0, title.length, indexOfTitle)) 119 } 120 121 let slicesOfContent = [] 122 while (indexOfContent.length !== 0) { 123 const item = indexOfContent[0] 124 const { position } = item 125 // Cut out 120 characters. The maxlength of .search-input is 80. 126 const start = Math.max(0, position - 20) 127 const end = Math.min(content.length, position + 100) 128 slicesOfContent.push(this.mergeIntoSlice(start, end, indexOfContent)) 129 } 130 131 // Sort slices in content by included keywords' count and hits' count 132 slicesOfContent.sort((left, right) => { 133 if (left.count !== right.count) { 134 return right.count - left.count 135 } else if (left.hits.length !== right.hits.length) { 136 return right.hits.length - left.hits.length 137 } 138 return left.start - right.start 139 }) 140 141 // Select top N slices in content 142 const upperBound = parseInt(this.top_n_per_article, 10) 143 if (upperBound >= 0) { 144 slicesOfContent = slicesOfContent.slice(0, upperBound) 145 } 146 147 let resultItem = '' 148 149 url = new URL(url, location.origin) 150 url.searchParams.append('highlight', keywords.join(' ')) 151 152 if (slicesOfTitle.length !== 0) { 153 resultItem += `<div class="local-search-hit-item"><a href="${url.href}"><span class="search-result-title">${this.highlightKeyword(title, slicesOfTitle[0])}</span>` 154 } else { 155 resultItem += `<div class="local-search-hit-item"><a href="${url.href}"><span class="search-result-title">${title}</span>` 156 } 157 158 slicesOfContent.forEach(slice => { 159 resultItem += `<p class="search-result">${this.highlightKeyword(content, slice)}...</p></a>` 160 }) 161 162 resultItem += '</div>' 163 resultItems.push({ 164 item: resultItem, 165 id: resultItems.length, 166 hitCount, 167 includedCount 168 }) 169 }) 170 return resultItems 171 } 172 173 fetchData () { 174 const isXml = !this.path.endsWith('json') 175 fetch(this.path) 176 .then(response => response.text()) 177 .then(res => { 178 // Get the contents from search data 179 this.isfetched = true 180 this.datas = isXml 181 ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => ({ 182 title: element.querySelector('title').textContent, 183 content: element.querySelector('content').textContent, 184 url: element.querySelector('url').textContent 185 })) 186 : JSON.parse(res) 187 // Only match articles with non-empty titles 188 this.datas = this.datas.filter(data => data.title).map(data => { 189 data.title = data.title.trim() 190 data.content = data.content ? data.content.trim().replace(/<[^>]+>
190/g, '') : '' 191 data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/') 192 return data 193 }) 194 // Remove loading animation 195 window.dispatchEvent(new Event('search:loaded')) 196 }) 197 } 198 199 // Highlight by wrapping node in mark elements with the given class name 200 highlightText (node, slice, className) { 201 const val = node.nodeValue 202 let index = slice.start 203 const children = [] 204 for (const { position, length } of slice.hits) { 205 const text = document.createTextNode(val.substring(index, position)) 206 index = position + length 207 const mark = document.createElement('mark') 208 mark.className = className 209 mark.appendChild(document.createTextNode(val.substr(position, length))) 210 children.push(text, mark) 211 } 212 node.nodeValue = val.substring(index, slice.end) 213 children.forEach(element => { 214 node.parentNode.insertBefore(element, node) 215 }) 216 } 217 218 // Highlight the search words provided in the url in the text 219 highlightSearchWords (body) { 220 const params = new URL(location.href).searchParams.get('highlight') 221 const keywords = params ? params.split(' ') : [] 222 if (!keywords.length || !body) return 223 const walk = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null) 224 const allNodes = [] 225 while (walk.nextNode()) { 226 if (!walk.currentNode.parentNode.matches('button, select, textarea, .mermaid')) allNodes.push(walk.currentNode) 227 } 228 allNodes.forEach(node => { 229 const [indexOfNode] = this.getIndexByWord(keywords, node.nodeValue) 230 if (!indexOfNode.length) return 231 const slice = this.mergeIntoSlice(0, node.nodeValue.length, indexOfNode) 232 this.highlightText(node, slice, 'search-keyword') 233 }) 234 } 235} 236 237window.addEventListener('load', () => { 238// Search 239 const { path, top_n_per_article, unescape, languages } = GLOBAL_CONFIG.localSearch 240 const localSearch = new LocalSearch({ 241 path, 242 top_n_per_article, 243 unescape 244 }) 245 246 const input = document.querySelector('#local-search-input input') 247 const statsItem = document.getElementById('local-search-stats-wrap') 248 const $loadingStatus = document.getElementById('loading-status') 249 const isXml = !path.endsWith('json') 250 251 const inputEventFunction = () => { 252 if (!localSearch.isfetched) return 253 let searchText = input.value.trim().toLowerCase() 254 isXml && (searchText = searchText.replace(/</g, '<').replace(/>/g, '>')) 255 if (searchText !== '') $loadingStatus.innerHTML = '<i class="fas fa-spinner fa-pulse"></i>' 256 const keywords = searchText.split(/[-\s]+/) 257 const container = document.getElementById('local-search-results') 258 let resultItems = [] 259 if (searchText.length > 0) { 260 // Perform local searching 261 resultItems = localSearch.getResultItems(keywords) 262 } 263 if (keywords.length === 1 && keywords[0] === '') { 264 container.textContent = '' 265 statsItem.textContent = '' 266 } else if (resultItems.length === 0) { 267 container.textContent = '' 268 const statsDiv = document.createElement('div') 269 statsDiv.className = 'search-result-stats' 270 statsDiv.textContent = languages.hits_empty.replace(/\$\{query}/, searchText) 271 statsItem.innerHTML = statsDiv.outerHTML 272 } else { 273 resultItems.sort((left, right) => { 274 if (left.includedCount !== right.includedCount) { 275 return right.includedCount - left.includedCount 276 } else if (left.hitCount !== right.hitCount) { 277 return right.hitCount - left.hitCount 278 } 279 return right.id - left.id 280 }) 281 282 const stats = languages.hits_stats.replace(/\$\{hits}/, resultItems.length) 283 284 container.innerHTML = `<div class="search-result-list">${resultItems.map(result => result.item).join('')}</div>` 285 statsItem.innerHTML = `<hr><div class="search-result-stats">${stats}</div>` 286 window.pjax && window.pjax.refresh(container) 287 } 288 289 $loadingStatus.textContent = '' 290 } 291 292 let loadFlag = false 293 const $searchMask = document.getElementById('search-mask') 294 const $searchDialog = document.querySelector('#local-search .search-dialog') 295 296 // fix safari 297 const fixSafariHeight = () => { 298 if (window.innerWidth < 768) { 299 $searchDialog.style.setProperty('--search-height', window.innerHeight + 'px') 300 } 301 } 302 303 const openSearch = () => { 304 const bodyStyle = document.body.style 305 bodyStyle.width = '100%' 306 bodyStyle.overflow = 'hidden' 307 btf.animateIn($searchMask, 'to_show 0.5s') 308 btf.animateIn($searchDialog, 'titleScale 0.5s') 309 setTimeout(() => { input.focus() }, 300) 310 if (!loadFlag) { 311 !localSearch.isfetched && localSearch.fetchData() 312 input.addEventListener('input', inputEventFunction) 313 loadFlag = true 314 } 315 // shortcut: ESC 316 document.addEventListener('keydown', function f (event) { 317 if (event.code === 'Escape') { 318 closeSearch() 319 document.removeEventListener('keydown', f) 320 } 321 }) 322 323 fixSafariHeight() 324 window.addEventListener('resize', fixSafariHeight) 325 } 326 327 const closeSearch = () => { 328 const bodyStyle = document.body.style 329 bodyStyle.width = '' 330 bodyStyle.overflow = '' 331 btf.animateOut($searchDialog, 'search_close .5s')
332 btf.animateOut($searchMask, 'to_hide 0.5s') 333 window.removeEventListener('resize', fixSafariHeight) 334 } 335 336 const searchClickFn = () => { 337 btf.addEventListenerPjax(document.querySelector('#search-button > .search'), 'click', openSearch) 338 } 339 340 const searchFnOnce = () => { 341 document.querySelector('#local-search .search-close-button').addEventListener('click', closeSearch) 342 $searchMask.addEventListener('click', closeSearch) 343 if (GLOBAL_CONFIG.localSearch.preload) { 344 localSearch.fetchData() 345 } 346 localSearch.highlightSearchWords(document.getElementById('article-container')) 347 } 348 349 window.addEventListener('search:loaded', () => { 350 const $loadDataItem = document.getElementById('loading-database') 351 $loadDataItem.nextElementSibling.style.display = 'block' 352 $loadDataItem.remove() 353 }) 354 355 searchClickFn() 356 searchFnOnce() 357 358 // pjax 359 window.addEventListener('pjax:complete', () => { 360 !btf.isHidden($searchMask) && closeSearch() 361 localSearch.highlightSearchWords(document.getElementById('article-container')) 362 searchClickFn() 363 }) 364})
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.