PageSourceSearch

https://zhangzqs.cn/js/search/local-search.js?v=4.13.0

js zhangzqs.cn collected 2026-09-28 07:05:56 UTC 12,245 bytes, 364 lines download raw bytes

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, '&lt;').replace(/>/g, '&gt;'))
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.