123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325 |
- interface pageData {
- title: string,
- date: string,
- permalink: string,
- content: string,
- image?: string,
- preview: string,
- matchCount: number
- }
- interface match {
- start: number,
- end: number
- }
- /**
- * Escape HTML tags as HTML entities
- * Edited from:
- * @link https://stackoverflow.com/a/5499821
- */
- const tagsToReplace = {
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- '…': '…'
- };
- function replaceTag(tag) {
- return tagsToReplace[tag] || tag;
- }
- function replaceHTMLEnt(str) {
- return str.replace(/[&<>"]/g, replaceTag);
- }
- function escapeRegExp(string) {
- return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
- }
- class Search {
- private data: pageData[];
- private form: HTMLFormElement;
- private input: HTMLInputElement;
- private list: HTMLDivElement;
- private resultTitle: HTMLHeadElement;
- private resultTitleTemplate: string;
- constructor({ form, input, list, resultTitle, resultTitleTemplate }) {
- this.form = form;
- this.input = input;
- this.list = list;
- this.resultTitle = resultTitle;
- this.resultTitleTemplate = resultTitleTemplate;
- this.handleQueryString();
- this.bindQueryStringChange();
- this.bindSearchForm();
- }
- /**
- * Processes search matches
- * @param str original text
- * @param matches array of matches
- * @param ellipsis whether to add ellipsis to the end of each match
- * @param charLimit max length of preview string
- * @param offset how many characters before and after the match to include in preview
- * @returns preview string
- */
- private static processMatches(str: string, matches: match[], ellipsis: boolean = true, charLimit = 140, offset = 20): string {
- matches.sort((a, b) => {
- return a.start - b.start;
- });
- let i = 0,
- lastIndex = 0,
- charCount = 0;
- const resultArray: string[] = [];
- while (i < matches.length) {
- const item = matches[i];
- /// item.start >= lastIndex (equal only for the first iteration)
- /// because of the while loop that comes after, iterating over variable j
- if (ellipsis && item.start - offset > lastIndex) {
- resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, lastIndex + offset))} [...] `);
- resultArray.push(`${replaceHTMLEnt(str.substring(item.start - offset, item.start))}`);
- charCount += offset * 2;
- }
- else {
- /// If the match is too close to the end of last match, don't add ellipsis
- resultArray.push(replaceHTMLEnt(str.substring(lastIndex, item.start)));
- charCount += item.start - lastIndex;
- }
- let j = i + 1,
- end = item.end;
- /// Include as many matches as possible
- /// [item.start, end] is the range of the match
- while (j < matches.length && matches[j].start <= end) {
- end = Math.max(matches[j].end, end);
- ++j;
- }
- resultArray.push(`<mark>${replaceHTMLEnt(str.substring(item.start, end))}</mark>`);
- charCount += end - item.start;
- i = j;
- lastIndex = end;
- if (ellipsis && charCount > charLimit) break;
- }
- /// Add the rest of the string
- if (lastIndex < str.length) {
- let end = str.length;
- if (ellipsis) end = Math.min(end, lastIndex + offset);
- resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, end))}`);
- if (ellipsis && end != str.length) {
- resultArray.push(` [...]`);
- }
- }
- return resultArray.join('');
- }
- private async searchKeywords(keywords: string[]) {
- const rawData = await this.getData();
- const results: pageData[] = [];
- const regex = new RegExp(keywords.filter((v, index, arr) => {
- arr[index] = escapeRegExp(v);
- return v.trim() !== '';
- }).join('|'), 'gi');
- for (const item of rawData) {
- const titleMatches: match[] = [],
- contentMatches: match[] = [];
- let result = {
- ...item,
- preview: '',
- matchCount: 0
- }
- const contentMatchAll = item.content.matchAll(regex);
- for (const match of Array.from(contentMatchAll)) {
- contentMatches.push({
- start: match.index,
- end: match.index + match[0].length
- });
- }
- const titleMatchAll = item.title.matchAll(regex);
- for (const match of Array.from(titleMatchAll)) {
- titleMatches.push({
- start: match.index,
- end: match.index + match[0].length
- });
- }
- if (titleMatches.length > 0) result.title = Search.processMatches(result.title, titleMatches, false);
- if (contentMatches.length > 0) {
- result.preview = Search.processMatches(result.content, contentMatches);
- }
- else {
- /// If there are no matches in the content, use the first 140 characters as preview
- result.preview = replaceHTMLEnt(result.content.substring(0, 140));
- }
- result.matchCount = titleMatches.length + contentMatches.length;
- if (result.matchCount > 0) results.push(result);
- }
- /// Result with more matches appears first
- return results.sort((a, b) => {
- return b.matchCount - a.matchCount;
- });
- }
- private async doSearch(keywords: string[]) {
- const startTime = performance.now();
- const results = await this.searchKeywords(keywords);
- this.clear();
- for (const item of results) {
- this.list.append(Search.render(item));
- }
- const endTime = performance.now();
- this.resultTitle.innerText = this.generateResultTitle(results.length, ((endTime - startTime) / 1000).toPrecision(1));
- }
- private generateResultTitle(resultLen, time) {
- return this.resultTitleTemplate.replace("#PAGES_COUNT", resultLen).replace("#TIME_SECONDS", time);
- }
- public async getData() {
- if (!this.data) {
- /// Not fetched yet
- const jsonURL = this.form.dataset.json;
- this.data = await fetch(jsonURL).then(res => res.json());
- const parser = new DOMParser();
- for (const item of this.data) {
- item.content = parser.parseFromString(item.content, 'text/html').body.innerText;
- }
- }
- return this.data;
- }
- private bindSearchForm() {
- let lastSearch = '';
- const eventHandler = (e) => {
- e.preventDefault();
- const keywords = this.input.value.trim();
- Search.updateQueryString(keywords, true);
- if (keywords === '') {
- return this.clear();
- }
- if (lastSearch === keywords) return;
- lastSearch = keywords;
- this.doSearch(keywords.split(' '));
- }
- this.input.addEventListener('input', eventHandler);
- this.input.addEventListener('compositionend', eventHandler);
- }
- private clear() {
- this.list.innerHTML = '';
- this.resultTitle.innerText = '';
- }
- private bindQueryStringChange() {
- window.addEventListener('popstate', (e) => {
- this.handleQueryString()
- })
- }
- private handleQueryString() {
- const pageURL = new URL(window.location.toString());
- const keywords = pageURL.searchParams.get('keyword');
- this.input.value = keywords;
- if (keywords) {
- this.doSearch(keywords.split(' '));
- }
- else {
- this.clear()
- }
- }
- private static updateQueryString(keywords: string, replaceState = false) {
- const pageURL = new URL(window.location.toString());
- if (keywords === '') {
- pageURL.searchParams.delete('keyword')
- }
- else {
- pageURL.searchParams.set('keyword', keywords);
- }
- if (replaceState) {
- window.history.replaceState('', '', pageURL.toString());
- }
- else {
- window.history.pushState('', '', pageURL.toString());
- }
- }
- public static render(item: pageData) {
- return <article>
- <a href={item.permalink}>
- <div class="article-details">
- <h2 class="article-title" dangerouslySetInnerHTML={{ __html: item.title }}></h2>
- <section class="article-preview" dangerouslySetInnerHTML={{ __html: item.preview }}></section>
- </div>
- {item.image &&
- <div class="article-image">
- <img src={item.image} loading="lazy" />
- </div>
- }
- </a>
- </article>;
- }
- }
- declare global {
- interface Window {
- searchResultTitleTemplate: string;
- }
- }
- window.addEventListener('load', () => {
- setTimeout(function () {
- const searchForm = document.querySelector('.search-form') as HTMLFormElement,
- searchInput = searchForm.querySelector('input') as HTMLInputElement,
- searchResultList = document.querySelector('.search-result--list') as HTMLDivElement,
- searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement;
- new Search({
- form: searchForm,
- input: searchInput,
- list: searchResultList,
- resultTitle: searchResultTitle,
- resultTitleTemplate: window.searchResultTitleTemplate
- });
- }, 0);
- })
- export default Search;
|