diff --git a/assets/ts/archives.ts b/assets/ts/archives.ts new file mode 100644 index 0000000..ac930bb --- /dev/null +++ b/assets/ts/archives.ts @@ -0,0 +1,165 @@ +interface PageData { + title: string; + content: string; + id: string; +} + +class Search { + private data: PageData[]; + private form: HTMLFormElement; + private input: HTMLInputElement; + private resultTitle: HTMLHeadElement; + private resultTitleTemplate: string; + + constructor({ form, input, resultTitle, resultTitleTemplate }) { + this.form = form; + this.input = input; + this.resultTitle = resultTitle; + this.resultTitleTemplate = resultTitleTemplate; + + this.handleQueryString(); + this.bindQueryStringChange(); + this.bindSearchForm(); + } + + private async getIndex() { + if (!this.data) { + const jsonURL = this.form.dataset.json; + this.data = await fetch(jsonURL).then(res => res.json()); + } + return this.data; + } + + private async searchKeywords(keywords: string[]) { + const index = await this.getIndex(); + /// Return an set of ids that match the keywords + return new Set(index.filter(item => { + return keywords.every(keyword => { + return item.title.includes(keyword) || item.content.includes(keyword); + }); + }).map(item => item.id)); + } + + private async doSearch(keywords: string[]) { + const startTime = performance.now(); + + const results = await this.searchKeywords(keywords); + this.clear(); + + /// Hide all articles except the ones that are in the results + const archiveGroups = document.querySelectorAll('.archives-group') as NodeListOf; + + archiveGroups.forEach(group => { + const articles = Array.from(group.querySelectorAll('article')); + articles.map(article => { + article.style.display = 'none'; + article.style.removeProperty('border-bottom'); + }); + + const matchingArticles = articles.filter(article => results.has(article.id)); + + const hasResults = matchingArticles.length > 0; + if (!hasResults) return group.style.display = 'none'; + + matchingArticles.map(article => article.style.removeProperty('display')); + matchingArticles[matchingArticles.length - 1].style.borderBottom = 'none'; + }); + + const endTime = performance.now(); + this.resultTitle.innerText = this.generateResultTitle(results.size, ((endTime - startTime) / 1000).toPrecision(1)); + this.resultTitle.style.display = 'block'; + } + + private generateResultTitle(resultLen, time) { + return this.resultTitleTemplate.replace("#PAGES_COUNT", resultLen).replace("#TIME_SECONDS", time); + } + + private bindSearchForm() { + let lastSearch = ''; + + const eventHandler = (e) => { + e.preventDefault(); + const keywords = this.input.value.trim(); + + Search.updateQueryString(keywords, true); + + if (keywords === '') { + lastSearch = ''; + 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.resultTitle.style.display = 'none'; + document.querySelectorAll('.archives-group, .archives-group article').forEach(el => el.removeAttribute('style')); + } + + 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()); + } + } +} + +declare global { + interface Window { + searchResultTitleTemplate: string; + } +} + +window.addEventListener('load', () => { + setTimeout(function () { + const searchForm = document.getElementById('search-form') as HTMLFormElement, + searchInput = searchForm.querySelector('input') as HTMLInputElement, + searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement; + + new Search({ + form: searchForm, + input: searchInput, + resultTitle: searchResultTitle, + resultTitleTemplate: window.searchResultTitleTemplate + }); + }, 0); +}) + +export default Search; \ No newline at end of file diff --git a/assets/ts/search.tsx b/assets/ts/search.tsx deleted file mode 100644 index 856b48d..0000000 --- a/assets/ts/search.tsx +++ /dev/null @@ -1,326 +0,0 @@ -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(`${replaceHTMLEnt(str.substring(item.start, end))}`); - 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 === '') { - lastSearch = ''; - 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; \ No newline at end of file diff --git a/exampleSite/content/page/archives/index.md b/exampleSite/content/page/archives/index.md index 2862deb..f164e9e 100644 --- a/exampleSite/content/page/archives/index.md +++ b/exampleSite/content/page/archives/index.md @@ -3,6 +3,9 @@ title: "Archives" date: 2019-05-28 layout: "archives" slug: "archives" +outputs: + - html + - json menu: main: weight: -70 diff --git a/exampleSite/content/page/search/index.md b/exampleSite/content/page/search/index.md deleted file mode 100644 index b2a5943..0000000 --- a/exampleSite/content/page/search/index.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: "Search" -slug: "search" -layout: "search" -outputs: - - html - - json -menu: - main: - weight: -60 - params: - icon: search ---- \ No newline at end of file diff --git a/layouts/_default/archives.html b/layouts/_default/archives.html index 9df633e..71ce866 100644 --- a/layouts/_default/archives.html +++ b/layouts/_default/archives.html @@ -1,9 +1,14 @@ -{{ define "body-class" }}template-archives{{ end }} +{{ define "body-class" }}template-archives template-search{{ end }} +{{ define "head" }} + {{- with .OutputFormats.Get "json" -}} + + {{- end -}} +{{ end }} {{ define "main" }} + {{- $taxonomy := $.Site.GetPage "taxonomyTerm" "categories" -}} + {{- $terms := $taxonomy.Pages -}} + {{ if $terms }}
- {{- $taxonomy := $.Site.GetPage "taxonomyTerm" "categories" -}} - {{- $terms := $taxonomy.Pages -}} - {{ if $terms }}

{{ $taxonomy.Title }}

@@ -12,9 +17,22 @@ {{ end }}
- {{ end }}
+ {{ end }} +
+

+ + +

+ + +
+ +

+ {{ $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections }} {{ range $pages.GroupByDate "2006" }} @@ -30,4 +48,16 @@ {{ end }} {{ partialCached "footer/footer" . }} + + + + {{- $opts := dict "minify" hugo.IsProduction -}} + {{- $archivesScript := resources.Get "ts/archives.ts" | js.Build $opts -}} + {{ end }} + +{{ define "right-sidebar" }} + {{ partial "sidebar/right.html" (dict "Context" . "Scope" "homepage") }} +{{ end }} \ No newline at end of file diff --git a/layouts/_default/archives.json b/layouts/_default/archives.json new file mode 100644 index 0000000..48b4f52 --- /dev/null +++ b/layouts/_default/archives.json @@ -0,0 +1,13 @@ +{{- $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections -}} +{{- $result := slice -}} + +{{- range $pages -}} + {{- $data := dict + "title" .Title + "content" (.Plain) + "id" .File.UniqueID + -}} + {{- $result = $result | append $data -}} +{{- end -}} + +{{ jsonify $result }} \ No newline at end of file diff --git a/layouts/page/search.html b/layouts/page/search.html deleted file mode 100644 index fbfb74d..0000000 --- a/layouts/page/search.html +++ /dev/null @@ -1,33 +0,0 @@ -{{ define "body-class" }}template-search{{ end }} -{{ define "head" }} - {{- with .OutputFormats.Get "json" -}} - - {{- end -}} -{{ end }} -{{ define "main" }} -
-

- - -

- - -
- -
-

-
-
- - - -{{- $opts := dict "minify" hugo.IsProduction "JSXFactory" "createElement" -}} -{{- $searchScript := resources.Get "ts/search.tsx" | js.Build $opts -}} - - -{{ partialCached "footer/footer" . }} -{{ end }} \ No newline at end of file diff --git a/layouts/page/search.json b/layouts/page/search.json deleted file mode 100644 index a0f5184..0000000 --- a/layouts/page/search.json +++ /dev/null @@ -1,23 +0,0 @@ -{{- $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections -}} -{{- $result := slice -}} - -{{- range $pages -}} - {{- $data := dict "title" .Title "date" .Date "permalink" .Permalink "content" (.Plain) -}} - - {{- $image := partialCached "helper/image" (dict "Context" . "Type" "articleList") .RelPermalink "articleList" -}} - {{- if $image.exists -}} - {{- $imagePermalink := "" -}} - {{- if and $image.resource .Page.Site.Params.imageProcessing.cover.enabled -}} - {{- $thumbnail := $image.resource.Fill "120x120" -}} - {{- $imagePermalink = (absURL $thumbnail.Permalink) -}} - {{- else -}} - {{- $imagePermalink = $image.permalink -}} - {{- end -}} - - {{- $data = merge $data (dict "image" (absURL $imagePermalink)) -}} - {{- end -}} - - {{- $result = $result | append $data -}} -{{- end -}} - -{{ jsonify $result }} \ No newline at end of file diff --git a/layouts/partials/article-list/compact.html b/layouts/partials/article-list/compact.html index edd58a0..4b3924c 100644 --- a/layouts/partials/article-list/compact.html +++ b/layouts/partials/article-list/compact.html @@ -1,4 +1,4 @@ -
+

diff --git a/layouts/partials/widget/search.html b/layouts/partials/widget/search.html index 7b0fc73..e7a391d 100644 --- a/layouts/partials/widget/search.html +++ b/layouts/partials/widget/search.html @@ -1,4 +1,4 @@ -{{- $query := first 1 (where .Context.Site.Pages "Layout" "==" "search") -}} +{{- $query := first 1 (where .Context.Site.Pages "Layout" "==" "archives") -}} {{- if $query -}} {{- $searchPage := index $query 0 -}}
@@ -12,5 +12,5 @@

{{- else -}} - {{- warnf "Search page not found. Create a page with layout: search." -}} + {{- warnf "Archives page not found. Create a page with layout: archives." -}} {{- end -}} \ No newline at end of file