diff --git a/assets/ts/search_nohl.tsx b/assets/ts/search_nohl.tsx new file mode 100644 index 0000000..2922fdd --- /dev/null +++ b/assets/ts/search_nohl.tsx @@ -0,0 +1,252 @@ +interface pageData { + title: string, + date: string, + permalink: string, + content: string, + image?: string, + preview: string, + matchCount: 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(); + } + + private async searchKeywords(keywords: string[]) { + const rawData = await this.getData(); + let results: pageData[] = []; + + /// Sort keywords by their length + keywords.sort((a, b) => { + return b.length - a.length + }); + + for (const item of rawData) { + let result = { + ...item, + preview: '', + matchCount: 0 + } + + let matched = false; + + for (const keyword of keywords) { + if (keyword === '') continue; + + const regex = new RegExp(escapeRegExp(replaceHTMLEnt(keyword)), 'gi'); + + const contentMatch = regex.exec(result.content); + regex.lastIndex = 0; /// Reset regex + + const titleMatch = regex.exec(result.title); + regex.lastIndex = 0; /// Reset regex + + if (titleMatch || contentMatch) { + matched = true; + ++result.matchCount; + + let start = 0, + end = 100; + + if (contentMatch) { + start = contentMatch.index - 20; + end = contentMatch.index + 80 + + if (start < 0) start = 0; + } + + if (result.preview.indexOf(keyword) == -1) { + if (start !== 0) result.preview += `[...] `; + result.preview += `${result.content.slice(start, end)} `; + } + } + } + + if (matched) { + result.preview += '[...]'; + 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()); + } + + return this.data; + } + + private bindSearchForm() { + let lastSearch = ''; + + const eventHandler = (e) => { + e.preventDefault(); + const keywords = this.input.value; + + 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
+ +
+

+ +
+ {item.image && +
+ +
+ } +
+
; + } +} + +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; diff --git a/config.yaml b/config.yaml index 0e5283c..9d8f412 100644 --- a/config.yaml +++ b/config.yaml @@ -119,6 +119,10 @@ params: tagCloud: limit: 10 + # search modes: original, nohighlight, wholeword, highlight + search: + mode: nohighlight + opengraph: twitter: # Your Twitter username diff --git a/layouts/page/search.html b/layouts/page/search.html index 935b384..a2e98c9 100644 --- a/layouts/page/search.html +++ b/layouts/page/search.html @@ -1,6 +1,6 @@ {{ define "body-class" }}template-search{{ end }} {{ define "head" }} - {{- with .OutputFormats.Get "json" -}} + {{- with .OutputFormats.Get "json" -}} {{- end -}} {{ end }} @@ -23,9 +23,21 @@ window.searchResultTitleTemplate = "{{ T `search.resultTitle` }}" +{{- $searchfunc := "" -}} {{- $opts := dict "minify" hugo.IsProduction "JSXFactory" "createElement" -}} -{{- $searchScript := resources.Get "ts/search.tsx" | js.Build $opts -}} +{{- if eq .Site.Params.widgets.search.mode "original" -}} + {{- $searchfunc = "ts/search.tsx" -}} +{{- else if eq .Site.Params.widgets.search.mode "nohighlight" -}} + {{- $searchfunc = "ts/search_nohl.tsx" -}} +{{- else if eq .Site.Params.widgets.search.mode "wholeword" -}} + {{- $searchfunc = "ts/search_ww.tsx" -}} +{{- else if eq .Site.Params.widgets.search.mode "highlight" -}} + {{- $searchfunc = "ts/search_hl.tsx" -}} +{{- else -}} + {{ warnf "Searching function not defined" }} +{{- end -}} +{{- $searchScript := resources.Get $searchfunc | js.Build $opts -}} {{ partialCached "footer/footer" . }} -{{ end }} \ No newline at end of file +{{ end }}