PageSourceSearch

https://kaleidoyao.github.io/js/tools/localSearch.js

js kaleidoyao.github.io collected 2026-10-03 09:26:54 UTC 10,499 bytes, 327 lines download raw bytes

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

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.