search.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. interface pageData {
  2. title: string,
  3. date: string,
  4. permalink: string,
  5. content: string,
  6. image?: string,
  7. preview: string,
  8. matchCount: number
  9. }
  10. interface match {
  11. start: number,
  12. end: number
  13. }
  14. /**
  15. * Escape HTML tags as HTML entities
  16. * Edited from:
  17. * @link https://stackoverflow.com/a/5499821
  18. */
  19. const tagsToReplace = {
  20. '&': '&',
  21. '<': '&lt;',
  22. '>': '&gt;',
  23. '"': '&quot;',
  24. '…': '&hellip;'
  25. };
  26. function replaceTag(tag) {
  27. return tagsToReplace[tag] || tag;
  28. }
  29. function replaceHTMLEnt(str) {
  30. return str.replace(/[&<>"]/g, replaceTag);
  31. }
  32. function escapeRegExp(string) {
  33. return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
  34. }
  35. class Search {
  36. private data: pageData[];
  37. private form: HTMLFormElement;
  38. private input: HTMLInputElement;
  39. private list: HTMLDivElement;
  40. private resultTitle: HTMLHeadElement;
  41. private resultTitleTemplate: string;
  42. constructor({ form, input, list, resultTitle, resultTitleTemplate }) {
  43. this.form = form;
  44. this.input = input;
  45. this.list = list;
  46. this.resultTitle = resultTitle;
  47. this.resultTitleTemplate = resultTitleTemplate;
  48. this.handleQueryString();
  49. this.bindQueryStringChange();
  50. this.bindSearchForm();
  51. }
  52. /**
  53. * Processes search matches
  54. * @param str original text
  55. * @param matches array of matches
  56. * @param ellipsis whether to add ellipsis to the end of each match
  57. * @param charLimit max length of preview string
  58. * @param offset how many characters before and after the match to include in preview
  59. * @returns preview string
  60. */
  61. private static processMatches(str: string, matches: match[], ellipsis: boolean = true, charLimit = 140, offset = 20): string {
  62. matches.sort((a, b) => {
  63. return a.start - b.start;
  64. });
  65. let i = 0,
  66. lastIndex = 0,
  67. charCount = 0;
  68. const resultArray: string[] = [];
  69. while (i < matches.length) {
  70. const item = matches[i];
  71. /// item.start >= lastIndex (equal only for the first iteration)
  72. /// because of the while loop that comes after, iterating over variable j
  73. if (ellipsis && item.start - offset > lastIndex) {
  74. resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, lastIndex + offset))} [...] `);
  75. resultArray.push(`${replaceHTMLEnt(str.substring(item.start - offset, item.start))}`);
  76. charCount += offset * 2;
  77. }
  78. else {
  79. /// If the match is too close to the end of last match, don't add ellipsis
  80. resultArray.push(replaceHTMLEnt(str.substring(lastIndex, item.start)));
  81. charCount += item.start - lastIndex;
  82. }
  83. let j = i + 1,
  84. end = item.end;
  85. /// Include as many matches as possible
  86. /// [item.start, end] is the range of the match
  87. while (j < matches.length && matches[j].start <= end) {
  88. end = Math.max(matches[j].end, end);
  89. ++j;
  90. }
  91. resultArray.push(`<mark>${replaceHTMLEnt(str.substring(item.start, end))}</mark>`);
  92. charCount += end - item.start;
  93. i = j;
  94. lastIndex = end;
  95. if (ellipsis && charCount > charLimit) break;
  96. }
  97. /// Add the rest of the string
  98. if (lastIndex < str.length) {
  99. let end = str.length;
  100. if (ellipsis) end = Math.min(end, lastIndex + offset);
  101. resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, end))}`);
  102. if (ellipsis && end != str.length) {
  103. resultArray.push(` [...]`);
  104. }
  105. }
  106. return resultArray.join('');
  107. }
  108. private async searchKeywords(keywords: string[]) {
  109. const rawData = await this.getData();
  110. const results: pageData[] = [];
  111. const regex = new RegExp(keywords.filter((v, index, arr) => {
  112. arr[index] = escapeRegExp(v);
  113. return v.trim() !== '';
  114. }).join('|'), 'gi');
  115. for (const item of rawData) {
  116. const titleMatches: match[] = [],
  117. contentMatches: match[] = [];
  118. let result = {
  119. ...item,
  120. preview: '',
  121. matchCount: 0
  122. }
  123. const contentMatchAll = item.content.matchAll(regex);
  124. for (const match of Array.from(contentMatchAll)) {
  125. contentMatches.push({
  126. start: match.index,
  127. end: match.index + match[0].length
  128. });
  129. }
  130. const titleMatchAll = item.title.matchAll(regex);
  131. for (const match of Array.from(titleMatchAll)) {
  132. titleMatches.push({
  133. start: match.index,
  134. end: match.index + match[0].length
  135. });
  136. }
  137. if (titleMatches.length > 0) result.title = Search.processMatches(result.title, titleMatches, false);
  138. if (contentMatches.length > 0) {
  139. result.preview = Search.processMatches(result.content, contentMatches);
  140. }
  141. else {
  142. /// If there are no matches in the content, use the first 140 characters as preview
  143. result.preview = replaceHTMLEnt(result.content.substring(0, 140));
  144. }
  145. result.matchCount = titleMatches.length + contentMatches.length;
  146. if (result.matchCount > 0) results.push(result);
  147. }
  148. /// Result with more matches appears first
  149. return results.sort((a, b) => {
  150. return b.matchCount - a.matchCount;
  151. });
  152. }
  153. private async doSearch(keywords: string[]) {
  154. const startTime = performance.now();
  155. const results = await this.searchKeywords(keywords);
  156. this.clear();
  157. for (const item of results) {
  158. this.list.append(Search.render(item));
  159. }
  160. const endTime = performance.now();
  161. this.resultTitle.innerText = this.generateResultTitle(results.length, ((endTime - startTime) / 1000).toPrecision(1));
  162. }
  163. private generateResultTitle(resultLen, time) {
  164. return this.resultTitleTemplate.replace("#PAGES_COUNT", resultLen).replace("#TIME_SECONDS", time);
  165. }
  166. public async getData() {
  167. if (!this.data) {
  168. /// Not fetched yet
  169. const jsonURL = this.form.dataset.json;
  170. this.data = await fetch(jsonURL).then(res => res.json());
  171. const parser = new DOMParser();
  172. for (const item of this.data) {
  173. item.content = parser.parseFromString(item.content, 'text/html').body.innerText;
  174. }
  175. }
  176. return this.data;
  177. }
  178. private bindSearchForm() {
  179. let lastSearch = '';
  180. const eventHandler = (e) => {
  181. e.preventDefault();
  182. const keywords = this.input.value.trim();
  183. Search.updateQueryString(keywords, true);
  184. if (keywords === '') {
  185. return this.clear();
  186. }
  187. if (lastSearch === keywords) return;
  188. lastSearch = keywords;
  189. this.doSearch(keywords.split(' '));
  190. }
  191. this.input.addEventListener('input', eventHandler);
  192. this.input.addEventListener('compositionend', eventHandler);
  193. }
  194. private clear() {
  195. this.list.innerHTML = '';
  196. this.resultTitle.innerText = '';
  197. }
  198. private bindQueryStringChange() {
  199. window.addEventListener('popstate', (e) => {
  200. this.handleQueryString()
  201. })
  202. }
  203. private handleQueryString() {
  204. const pageURL = new URL(window.location.toString());
  205. const keywords = pageURL.searchParams.get('keyword');
  206. this.input.value = keywords;
  207. if (keywords) {
  208. this.doSearch(keywords.split(' '));
  209. }
  210. else {
  211. this.clear()
  212. }
  213. }
  214. private static updateQueryString(keywords: string, replaceState = false) {
  215. const pageURL = new URL(window.location.toString());
  216. if (keywords === '') {
  217. pageURL.searchParams.delete('keyword')
  218. }
  219. else {
  220. pageURL.searchParams.set('keyword', keywords);
  221. }
  222. if (replaceState) {
  223. window.history.replaceState('', '', pageURL.toString());
  224. }
  225. else {
  226. window.history.pushState('', '', pageURL.toString());
  227. }
  228. }
  229. public static render(item: pageData) {
  230. return <article>
  231. <a href={item.permalink}>
  232. <div class="article-details">
  233. <h2 class="article-title" dangerouslySetInnerHTML={{ __html: item.title }}></h2>
  234. <section class="article-preview" dangerouslySetInnerHTML={{ __html: item.preview }}></section>
  235. </div>
  236. {item.image &&
  237. <div class="article-image">
  238. <img src={item.image} loading="lazy" />
  239. </div>
  240. }
  241. </a>
  242. </article>;
  243. }
  244. }
  245. declare global {
  246. interface Window {
  247. searchResultTitleTemplate: string;
  248. }
  249. }
  250. window.addEventListener('load', () => {
  251. setTimeout(function () {
  252. const searchForm = document.querySelector('.search-form') as HTMLFormElement,
  253. searchInput = searchForm.querySelector('input') as HTMLInputElement,
  254. searchResultList = document.querySelector('.search-result--list') as HTMLDivElement,
  255. searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement;
  256. new Search({
  257. form: searchForm,
  258. input: searchInput,
  259. list: searchResultList,
  260. resultTitle: searchResultTitle,
  261. resultTitleTemplate: window.searchResultTitleTemplate
  262. });
  263. }, 0);
  264. })
  265. export default Search;