PageSourceSearch

https://silence-tang.github.io/js/local-search.js

js silence-tang.github.io collected 2026-10-03 09:57:18 UTC 9,718 bytes, 278 lines download raw bytes

1/* global CONFIG */
2
3document.addEventListener('DOMContentLoaded', () => {
4  // Popup Window
5  let isfetched = false;
6  let datas;
7  let isXml = true;
8  // Search DB path
9  let searchPath = CONFIG.path;
10  if (searchPath.length === 0) {
11    searchPath = 'search.xml';
12  } else if (searchPath.endsWith('json')) {
13    isXml = false;
14  }
15  const input = document.querySelector('.search-input');
16  const resultContent = document.getElementById('search-result');
17
18  const getIndexByWord = (word, text, caseSensitive) => {
19    if (CONFIG.localsearch.unescape) {
20      let div = document.createElement('div');
21      div.innerText = word;
22      word = div.innerHTML;
23    }
24    let wordLen = word.length;
25    if (wordLen === 0) return [];
26    let startPosition = 0;
27    let position = [];
28    let index = [];
29    if (!caseSensitive) {
30      text = text.toLowerCase();
31      word = word.toLowerCase();
32    }
33    while ((position = text.indexOf(word, startPosition)) > -1) {
34      index.push({ position, word });
35      startPosition = position + wordLen;
36    }
37    return index;
38  };
39
40  // Merge hits into slices
41  const mergeIntoSlice = (start, end, index, searchText) => {
42    let item = index[index.length - 1];
43    let { position, word } = item;
44    let hits = [];
45    let searchTextCountInSlice = 0;
46    while (position + word.length <= end && index.length !== 0) {
47      if (word === searchText) {
48        searchTextCountInSlice++;
49      }
50      hits.push({
51        position,
52        length: word.length
53      });
54      let wordEnd = position + word.length;
55
56      // Move to next position of hit
57      index.pop();
58      while (index.length !== 0) {
59        item = index[index.length - 1];
60        position = item.position;
61        word = item.word;
62        if (wordEnd > position) {
63          index.pop();
64        } else {
65          break;
66        }
67      }
68    }
69    return {
70      hits,
71      start,
72      end,
73      searchTextCount: searchTextCountInSlice
74    };
75  };
76
77  // Highlight title and content
78  const highlightKeyword = (text, slice) => {
79    let result = '';
80    let prevEnd = slice.start;
81    slice.hits.forEach(hit => {
82      result += text.substring(prevEnd, hit.position);
83      let end = hit.position + hit.length;
84      result += `<b class="search-keyword">${text.substring(hit.position, end)}</b>`;
85      prevEnd = end;
86    });
87    result += text.substring(prevEnd, slice.end);
88    return result;
89  };
90
91  const inputEventFunction = () => {
92    if (!isfetched) return;
93    let searchText = input.value.trim().toLowerCase();
94    let keywords = searchText.split(/[-\s]+/);
95    if (keywords.length > 1) {
96      keywords.push(searchText);
97    }
98    let resultItems = [];
99    if (searchText.length > 0) {
100      // Perform local searching
101      datas.forEach(({ title, content, url }) => {
102        let titleInLowerCase = title.toLowerCase();
103        let contentInLowerCase = content.toLowerCase();
104        let indexOfTitle = [];
105        let indexOfContent = [];
106        let searchTextCount = 0;
107        keywords.forEach(keyword => {
108          indexOfTitle = indexOfTitle.concat(getIndexByWord(keyword, titleInLowerCase, false));
109          indexOfContent = indexOfContent.concat(getIndexByWord(keyword, contentInLowerCase, false));
110        });
111
112        // Show search results
113        if (indexOfTitle.length > 0 || indexOfContent.length > 0) {
114          let hitCount = indexOfTitle.length + indexOfContent.length;
115          // Sort index by position of keyword
116          [indexOfTitle, indexOfContent].forEach(index => {
117            index.sort((itemLeft, itemRight) => {
118              if (itemRight.position !== itemLeft.position) {
119                return itemRight.position - itemLeft.position;
120              }
121              return itemLeft.word.length - itemRight.word.length;
122            });
123          });
124
125          let slicesOfTitle = [];
126          if (indexOfTitle.length !== 0) {
127            let tmp = mergeIntoSlice(0, title.length, indexOfTitle, searchText);
128            searchTextCount += tmp.searchTextCountInSlice;
129            slicesOfTitle.push(tmp);
130          }
131
132          let slicesOfContent = [];
133          while (indexOfContent.length !== 0) {
134            let item = indexOfContent[indexOfContent.length - 1];
135            let { position, word } = item;
136            // Cut out 100 characters
137            let start = position - 20;
138            let end = position + 80;
139            if (start < 0) {
140              start = 0;
141            }
142            if (end < position + word.length) {
143              end = position + word.length;
144            }
145            if (end > content.length) {
146              end = content.length;
147            }
148            let tmp = mergeIntoSlice(start, end, indexOfContent, searchText);
149            searchTextCount += tmp.searchTextCountInSlice;
150            slicesOfContent.push(tmp);
151          }
152
153          // Sort slices in content by search text's count and hits' count
154          slicesOfContent.sort((sliceLeft, sliceRight) => {
155            if (sliceLeft.searchTextCount !== sliceRight.searchTextCount) {
156              return sliceRight.searchTextCount - sliceLeft.searchTextCount;
157            } else if (sliceLeft.hits.length !== sliceRight.hits.length) {
158              return sliceRight.hits.length - sliceLeft.hits.length;
159            }
160            return sliceLeft.start - sliceRight.start;
161          });
162
163          // Select top N slices in content
164          let upperBound = parseInt(CONFIG.localsearch.top_n_per_article, 10);
165          if (upperBound >= 0) {
166            slicesOfContent = slicesOfContent.slice(0, upperBound);
167          }
168
169          let resultItem = '';
170
171          if (slicesOfTitle.length !== 0) {
172            resultItem += `<li><a href="${url}" class="search-result-title">${highlightKeyword(title, slicesOfTitle[0])}</a>`;
173          } else {
174            resultItem += `<li><a href="${url}" class="search-result-title">${title}</a>`;
175          }
176
177          slicesOfContent.forEach(slice => {
178            resultItem += `<a href="${url}"><p class="search-result">${highlightKeyword(content, slice)}...</p></a>`;
179          });
180
181          resultItem += '</li>';
182          resultItems.push({
183            item: resultItem,
184            id  : resultItems.length,
185            hitCount,
186            searchTextCount
187          });
188        }
189      });
190    }
191    if (keywords.length === 1 && keywords[0] === '') {
192      resultContent.innerHTML = '<div id="no-result"><i class="fa fa-search fa-5x"></i></div>';
193    } else if (resultItems.length === 0) {
194      resultContent.innerHTML = '<div id="no-result"><i class="far fa-frown fa-5x"></i></div>';
195    } else {
196      resultItems.sort((resultLeft, resultRight) => {
197        if (resultLeft.searchTextCount !== resultRight.searchTextCount) {
198          return resultRight.searchTextCount - resultLeft.searchTextCount;
199        } else if (resultLeft.hitCount !== resultRight.hitCount) {
200          return resultRight.hitCount - resultLeft.hitCount;
201        }
202        return resultRight.id - resultLeft.id;
203      });
204      resultContent.innerHTML = `<ul class="search-result-list">${resultItems.map(result => result.item).join('')}</ul>`;
205      window.pjax && window.pjax.refresh(resultContent);
206    }
207  };
208
209  const fetchData = () => {
210    fetch(CONFIG.root + searchPath)
211      .then(response => response.text())
212      .then(res => {
213        // Get the contents from search data
214        isfetched = true;
215        datas = isXml ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => {
216          return {
217            title  : element.querySelector('title').textContent,
218            content: element.querySelector('content').textContent,
219            url    : element.querySelector('url').textContent
220          };
221        }) : JSON.parse(res);
222        // Only match articles with not empty titles
223        datas = datas.filter(data => data.title).map(data => {
224          data.title = data.title.trim();
225          data.content = data.content ? data.content.trim().replace(/<[^>]+>/g, '') : '';
226          data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/');
227          return data;
228        });
229        // Remove loading animation
230        document.getElementById('no-result').innerHTML = '<i class="fa fa-search fa-5x"></i>';
231        inputEventFunction();
232      });
233  };
234
235  if (CONFIG.localsearch.preload) {
236    fetchData();
237  }
238
239  if (CONFIG.localsearch.trigger === 'auto') {
240    input.addEventListener('input', inputEventFunction);
241  } else {
242    document.querySelector('.search-icon').addEventListener('click', inputEventFunction);
243    input.addEventListener('keypress', event => {
244      if (event.key === 'Enter') {
245        inputEventFunction();
246      }
247    });
248  }
249
250  // Handle and trigger popup window
251  document.querySelectorAll('.popup-trigger').forEach(element => {
252    element.addEventListener('click', () => {
253      document.body.style.overflow = 'hidden';
254      document.querySelector('.search-pop-overlay').classList.add('search-active');
255      input.focus();
256      if (!isfetched) fetchData();
257    });
258  });
259
260  // Monitor main search box
261  const onPopupClose = () => {
262    document.body.style.overflow = '';
263    document.querySelector('.search-pop-overlay').classList.remove('search-active');
264  };
265
266  document.querySelector('.search-pop-overlay').addEventListener('click', event => {
267    if (event.target === document.querySelector('.search-pop-overlay')) {
268      onPopupClose();
269    }
270  });
271  document.querySelector('.popup-btn-close').addEventListener('click', onPopupClose);
272  window.addEventListener('pjax:success', onPopupClose);
273  window.addEventListener('keyup', event => {
274    if (event.key === 'Escape') {
275      onPopupClose();
276    }
277  });
278});

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.