diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 0000000..a0b0ddc --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/scss/partials/article.scss b/assets/scss/partials/article.scss index e8b9fcf..1f40673 100644 --- a/assets/scss/partials/article.scss +++ b/assets/scss/partials/article.scss @@ -199,6 +199,13 @@ .article-time { font-size: 1.4rem; } + + .article-preview{ + font-size: 1.4rem; + color: var(--card-text-color-tertiary); + margin-top: 10px; + line-height: 1.5; + } } } diff --git a/assets/scss/partials/layout/search.scss b/assets/scss/partials/layout/search.scss new file mode 100644 index 0000000..b390a7b --- /dev/null +++ b/assets/scss/partials/layout/search.scss @@ -0,0 +1,82 @@ +.search-form { + margin-bottom: var(--section-separation); + position: relative; + --button-size: 80px; + + &.widget { + --button-size: 60px; + + label { + font-size: 1.3rem; + top: 10px; + } + + input { + font-size: 1.5rem; + padding: 30px 20px 15px 20px; + } + } + + p { + position: relative; + margin: 0; + } + + label { + position: absolute; + top: 15px; + left: 20px; + font-size: 1.4rem; + color: var(--card-text-color-tertiary); + } + + input { + padding: 40px 20px 20px; + border-radius: var(--card-border-radius); + background-color: var(--card-background); + box-shadow: var(--shadow-l1); + color: var(--card-text-color-main); + width: 100%; + border: 0; + -webkit-appearance: none; + + transition: box-shadow 0.3s ease; + + font-size: 1.8rem; + + &:focus { + outline: 0; + box-shadow: var(--shadow-l2); + } + } + + button { + position: absolute; + right: 0; + top: 0; + height: 100%; + width: var(--button-size); + cursor: pointer; + background-color: transparent; + border: 0; + + padding: 0 10px; + + &:focus { + outline: 0; + + svg { + stroke-width: 2; + color: var(--accent-color); + } + } + + svg { + color: var(--card-text-color-secondary); + stroke-width: 1.33; + transition: all 0.3s ease; + width: 20px; + height: 20px; + } + } +} \ No newline at end of file diff --git a/assets/scss/style.scss b/assets/scss/style.scss index f16fdfd..3e4b56a 100644 --- a/assets/scss/style.scss +++ b/assets/scss/style.scss @@ -23,6 +23,7 @@ @import "partials/layout/article.scss"; @import "partials/layout/taxonomy.scss"; @import "partials/layout/404.scss"; +@import "partials/layout/search.scss"; @import "custom.scss"; diff --git a/assets/ts/createElement.ts b/assets/ts/createElement.ts new file mode 100644 index 0000000..3a1e85e --- /dev/null +++ b/assets/ts/createElement.ts @@ -0,0 +1,34 @@ +/** + * createElement + * Edited from: + * @link https://stackoverflow.com/a/42405694 + */ +function createElement(tag, attrs, children) { + var element = document.createElement(tag); + + for (let name in attrs) { + if (name && attrs.hasOwnProperty(name)) { + let value = attrs[name]; + + if (name == "dangerouslySetInnerHTML") { + element.innerHTML = value.__html; + } + else if (value === true) { + element.setAttribute(name, name); + } else if (value !== false && value != null) { + element.setAttribute(name, value.toString()); + } + } + } + for (let i = 2; i < arguments.length; i++) { + let child = arguments[i]; + if (child) { + element.appendChild( + child.nodeType == null ? + document.createTextNode(child.toString()) : child); + } + } + return element; +} + +export default createElement; \ No newline at end of file diff --git a/assets/ts/main.ts b/assets/ts/main.ts index b9164cc..8875a74 100644 --- a/assets/ts/main.ts +++ b/assets/ts/main.ts @@ -9,6 +9,7 @@ import { createGallery } from "./gallery" import { getColor } from './color'; import menu from './menu'; +import createElement from './createElement'; let Stack = { init: () => { @@ -74,4 +75,12 @@ window.addEventListener('load', () => { }, 0); }) -window.Stack = Stack; \ No newline at end of file +declare global { + interface Window { + createElement: any; + Stack: any + } +} + +window.Stack = Stack; +window.createElement = createElement; \ No newline at end of file diff --git a/assets/ts/search.tsx b/assets/ts/search.tsx new file mode 100644 index 0000000..8e4eb6f --- /dev/null +++ b/assets/ts/search.tsx @@ -0,0 +1,263 @@ +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) { + result.title = result.title.replace(regex, Search.marker); + } + + 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) { + result.preview = result.preview.replace(regex, Search.marker); + } + else { + if (start !== 0) result.preview += `[...] `; + result.preview += `${result.content.slice(start, end).replace(regex, Search.marker)} `; + } + } + } + + if (matched) { + result.preview += '[...]'; + results.push(result); + } + } + + /** Result with more matches appears first */ + return results.sort((a, b) => { + return b.matchCount - a.matchCount; + }); + } + + public static marker(match) { + return '' + match + ''; + } + + 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; \ No newline at end of file diff --git a/exampleSite/config.toml b/exampleSite/config.toml index d32b20b..3134043 100644 --- a/exampleSite/config.toml +++ b/exampleSite/config.toml @@ -40,7 +40,7 @@ DefaultContentLanguage = "en" # Theme i18n support theme = "preferred-color-scheme" [params.widgets] - enabled = ['archives', 'tag-cloud'] + enabled = ['search', 'archives', 'tag-cloud'] [params.widgets.archives] limit = 5 ### Archives page relative URL @@ -78,6 +78,12 @@ DefaultContentLanguage = "en" # Theme i18n support url = "archives" weight = -70 pre = "archives" + [[menu.main]] + identifier = "search" + name = "Search" + url = "search" + weight = -60 + pre = "search" [related] includeNewer = true diff --git a/exampleSite/content/page/search.md b/exampleSite/content/page/search.md new file mode 100644 index 0000000..0363546 --- /dev/null +++ b/exampleSite/content/page/search.md @@ -0,0 +1,8 @@ +--- +title: "Search" +slug: "search" +layout: "search" +outputs: + - html + - json +--- \ No newline at end of file diff --git a/i18n/en.toml b/i18n/en.toml index f766971..7fb76fe 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -20,4 +20,13 @@ other = "Not Found" [notFoundSubtitle] - other = "This page does not exist." \ No newline at end of file + other = "This page does not exist." + +[searchTitle] + other = "Search" + +[searchPlaceholder] + other = "Type something..." + +[searchResultTitle] + other = "#PAGES_COUNT pages (#TIME_SECONDS seconds)" \ No newline at end of file diff --git a/i18n/zh-CN.toml b/i18n/zh-CN.toml index a3f78cd..e589330 100644 --- a/i18n/zh-CN.toml +++ b/i18n/zh-CN.toml @@ -20,4 +20,13 @@ other = "404 错误" [notFoundSubtitle] - other = "页面不存在" \ No newline at end of file + other = "页面不存在" + +[searchTitle] + other = "搜索" + +[searchPlaceholder] + other = "输入关键词..." + +[searchResultTitle] + other = "#PAGES_COUNT 个结果 (用时 #TIME_SECONDS 秒)" \ No newline at end of file diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index fb6e871..06a31e5 100644 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -1,6 +1,9 @@ - {{- partial "head/head.html" . -}} + + {{- partial "head/head.html" . -}} + {{- block "head" . -}}{{ end }} +
{{ partial "sidebar/left.html" . }} diff --git a/layouts/page/search.html b/layouts/page/search.html new file mode 100644 index 0000000..6078ac1 --- /dev/null +++ b/layouts/page/search.html @@ -0,0 +1,31 @@ +{{ 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 new file mode 100644 index 0000000..ce09a79 --- /dev/null +++ b/layouts/page/search.json @@ -0,0 +1,20 @@ +{{- $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections -}} +{{- $notHidden := where .Site.RegularPages "Params.hidden" "!=" true -}} +{{- $filtered := ($pages | intersect $notHidden) -}} + +{{- $result := slice -}} + +{{- range $filtered -}} + {{- $data := dict "title" .Title "date" .Date "permalink" .Permalink "content" (.Plain) -}} + + {{- $image := partialCached "helper/image" (dict "Context" . "Type" "articleList") .RelPermalink "articleList" -}} + {{- if and $image.exists $image.resource -}} + {{- $thumbnail := $image.resource.Fill "120x120" -}} + {{- $image := dict "image" (absURL $thumbnail.Permalink) -}} + {{- $data = merge $data $image -}} + {{ end }} + + {{- $result = $result | append $data -}} +{{- end -}} + +{{ jsonify $result }} \ No newline at end of file diff --git a/layouts/partials/head/head.html b/layouts/partials/head/head.html index 9df2ea1..40d9749 100644 --- a/layouts/partials/head/head.html +++ b/layouts/partials/head/head.html @@ -1,22 +1,20 @@ - - - - - {{- $description := partialCached "data/description" . .RelPermalink -}} - + + - {{- $title := partialCached "data/title" . .RelPermalink -}} - {{ $title }} - - - - {{- partial "head/style.html" . -}} - {{- partial "head/script.html" . -}} - {{- partial "head/opengraph/include.html" . -}} +{{- $description := partialCached "data/description" . .RelPermalink -}} + - {{- range .AlternativeOutputFormats -}} - - {{- end -}} - - {{- partial "head/custom.html" . -}} - \ No newline at end of file +{{- $title := partialCached "data/title" . .RelPermalink -}} +{{ $title }} + + + +{{- partial "head/style.html" . -}} +{{- partial "head/script.html" . -}} +{{- partial "head/opengraph/include.html" . -}} + +{{- range .AlternativeOutputFormats -}} + +{{- end -}} + +{{- partial "head/custom.html" . -}} \ No newline at end of file diff --git a/layouts/partials/widget/search.html b/layouts/partials/widget/search.html new file mode 100644 index 0000000..fc6d525 --- /dev/null +++ b/layouts/partials/widget/search.html @@ -0,0 +1,10 @@ +
+

+ + + + +

+
\ No newline at end of file