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.