From e5f96c876276270c84b1cfcafca2d7970a2e3897 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sat, 26 Sep 2020 11:40:33 +0200 Subject: [PATCH 01/14] feat: add search template --- assets/scss/partials/article.scss | 7 + assets/scss/partials/layout/search.scss | 90 ++++++++++ assets/scss/style.scss | 1 + assets/ts/search.tsx | 222 ++++++++++++++++++++++++ layouts/page/search.html | 22 +++ layouts/page/search.json | 20 +++ 6 files changed, 362 insertions(+) create mode 100644 assets/scss/partials/layout/search.scss create mode 100644 assets/ts/search.tsx create mode 100644 layouts/page/search.html create mode 100644 layouts/page/search.json diff --git a/assets/scss/partials/article.scss b/assets/scss/partials/article.scss index 4f2b940..80d7d9d 100644 --- a/assets/scss/partials/article.scss +++ b/assets/scss/partials/article.scss @@ -207,6 +207,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..ad6a8a2 --- /dev/null +++ b/assets/scss/partials/layout/search.scss @@ -0,0 +1,90 @@ +.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; + } + } +} + +.search-result--title { + text-transform: uppercase; + margin-bottom: 10px; + font-size: 1.5rem; + font-weight: 700; + color: var(--body-text-color); +} diff --git a/assets/scss/style.scss b/assets/scss/style.scss index 5e07b9c..dc7000d 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"; a { text-decoration: none; diff --git a/assets/ts/search.tsx b/assets/ts/search.tsx new file mode 100644 index 0000000..6fdd426 --- /dev/null +++ b/assets/ts/search.tsx @@ -0,0 +1,222 @@ +interface pageData { + title: string, + date: string, + permalink: string, + content: string, + image?: string, + preview: string, + matchCount: number +} + +const searchForm = document.querySelector('.search-form') as HTMLFormElement; +const searchInput = searchForm.querySelector('input') as HTMLInputElement; +const searchResultList = document.querySelector('.search-result--list') as HTMLDivElement; +const searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement; + +let data: pageData[]; + +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; +} + +window.createElement = createElement; + +function escapeRegExp(string) { + return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); +} + +async function getData() { + if (!data) { + /// Not fetched yet + const jsonURL = searchForm.dataset.json; + data = await fetch(jsonURL).then(res => res.json()); + } + + return data; +} + +function updateQueryString(keywords: string) { + const pageURL = new URL(window.location.toString()); + + if (keywords === '') { + pageURL.searchParams.delete('keyword') + } + else { + pageURL.searchParams.set('keyword', keywords); + } + + window.history.pushState('', '', pageURL.toString()); +} + +function bindQueryStringChange() { + window.addEventListener('popstate', (e) => { + handleQueryString() + }) +} + +function handleQueryString() { + const pageURL = new URL(window.location.toString()); + const keywords = pageURL.searchParams.get('keyword'); + searchInput.value = keywords; + + if (keywords) { + doSearch(keywords.split(' ')); + } + else { + clear() + } +} + +function bindSearchForm() { + let lastSearch = ''; + searchForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const keywords = searchInput.value; + + updateQueryString(keywords); + + if (keywords === '') { + return clear(); + } + + if (lastSearch === keywords) return; + lastSearch = keywords; + + doSearch(keywords.split(' ')); + }) +} + +function clear() { + searchResultList.innerHTML = ''; + searchResultTitle.innerText = ''; +} + +async function doSearch(keywords: string[]) { + const startTime = performance.now(); + + const results = await searchKeyword(keywords); + clear(); + + for (const item of results) { + searchResultList.append(render(item)); + } + + const endTime = performance.now(); + + searchResultTitle.innerText = `${results.length} pages (${((endTime - startTime) / 1000).toPrecision(1)} seconds)`; +} + +function marker(match, p1, p2, p3, offset, string) { + return '' + match + ''; +} + +async function searchKeyword(keywords: string[]) { + const rawData = await getData(); + let results: pageData[] = []; + + 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) { + const regex = new RegExp(escapeRegExp(keyword), 'gi'); + + const contentMatch = regex.exec(item.content); + regex.lastIndex = 0; /// Reset regex + const titleMatch = regex.exec(item.title); + regex.lastIndex = 0; /// Reset regex + + if (titleMatch) { + result.title = item.title.replace(regex, 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, marker); + } + else { + if (start !== 0) result.preview += `[...] `; + result.preview += `${result.content.slice(start, end).replace(regex, marker)} `; + } + } + } + + if (matched) { + result.preview += '[...]'; + results.push(result); + } + } + + /** Result with more matches appears first */ + return results.sort((a, b) => { + return b.matchCount - a.matchCount; + }); +} + +const render = (item: pageData) => { + return
+ +
+

+ +
+ {item.image && +
+ +
+ } +
+
; +} + +window.addEventListener('load', () => { + handleQueryString(); + bindQueryStringChange(); + bindSearchForm(); +}) \ No newline at end of file diff --git a/layouts/page/search.html b/layouts/page/search.html new file mode 100644 index 0000000..6c98cc5 --- /dev/null +++ b/layouts/page/search.html @@ -0,0 +1,22 @@ +{{ define "body-class" }}template-search{{ 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..4c98536 --- /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" (htmlUnescape .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 From 21f461ce788a2d3e8868af74b64d903d4c6c65cd Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sat, 26 Sep 2020 11:40:52 +0200 Subject: [PATCH 02/14] feat(widget): add search widget --- layouts/partials/widget/search.html | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 layouts/partials/widget/search.html diff --git a/layouts/partials/widget/search.html b/layouts/partials/widget/search.html new file mode 100644 index 0000000..3178872 --- /dev/null +++ b/layouts/partials/widget/search.html @@ -0,0 +1,10 @@ +
+

+ + + + +

+
\ No newline at end of file From 6fa69d7a2bf148233f66ce600fe4ad9bf6fac5db Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sat, 26 Sep 2020 12:05:13 +0200 Subject: [PATCH 03/14] feat: add search icon --- assets/icons/search.svg | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 assets/icons/search.svg 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 @@ + + + + + + + From 08102e2f6907b9960baf7b032fa053e036b6fa38 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sat, 26 Sep 2020 12:06:56 +0200 Subject: [PATCH 04/14] feat(exampleSite): add search page and widget --- exampleSite/config.toml | 8 +++++++- exampleSite/content/page/search.md | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 exampleSite/content/page/search.md diff --git a/exampleSite/config.toml b/exampleSite/config.toml index cc83511..324a1ad 100644 --- a/exampleSite/config.toml +++ b/exampleSite/config.toml @@ -31,7 +31,7 @@ DefaultContentLanguage = "en" # Theme i18n support # Only Disqus is available so far provider = "disqus" [params.widgets] - enabled = ['archives', 'tag-cloud'] + enabled = ['search', 'archives', 'tag-cloud'] [params.widgets.archives] limit = 5 ### Archives page relative URL @@ -75,6 +75,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 From 6e48765d828b0e78018de36932ee1bdb27ed0e70 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sat, 26 Sep 2020 22:50:23 +0200 Subject: [PATCH 05/14] fix(search): HTML escape issue --- assets/ts/search.tsx | 45 +++++++++++++++++++++++++++++++++------- layouts/page/search.json | 2 +- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/assets/ts/search.tsx b/assets/ts/search.tsx index 6fdd426..272e5e2 100644 --- a/assets/ts/search.tsx +++ b/assets/ts/search.tsx @@ -15,6 +15,11 @@ const searchResultTitle = document.querySelector('.search-result--title') as HTM let data: pageData[]; +/** + * createElement + * Edited from: + * @link https://stackoverflow.com/a/42405694 + */ function createElement(tag, attrs, children) { var element = document.createElement(tag); @@ -49,6 +54,26 @@ function escapeRegExp(string) { return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); } +/** + * 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); +} + async function getData() { if (!data) { /// Not fetched yet @@ -118,7 +143,7 @@ function clear() { async function doSearch(keywords: string[]) { const startTime = performance.now(); - const results = await searchKeyword(keywords); + const results = await searchKeywords(keywords); clear(); for (const item of results) { @@ -130,14 +155,15 @@ async function doSearch(keywords: string[]) { searchResultTitle.innerText = `${results.length} pages (${((endTime - startTime) / 1000).toPrecision(1)} seconds)`; } -function marker(match, p1, p2, p3, offset, string) { +function marker(match) { return '' + match + ''; } -async function searchKeyword(keywords: string[]) { +async function searchKeywords(keywords: string[]) { const rawData = await getData(); let results: pageData[] = []; + /// Sort keywords by their length keywords.sort((a, b) => { return b.length - a.length }); @@ -152,15 +178,18 @@ async function searchKeyword(keywords: string[]) { let matched = false; for (const keyword of keywords) { - const regex = new RegExp(escapeRegExp(keyword), 'gi'); + if (keyword === '') continue; - const contentMatch = regex.exec(item.content); + const regex = new RegExp(escapeRegExp(replaceHTMLEnt(keyword)), 'gi'); + + const contentMatch = regex.exec(result.content); regex.lastIndex = 0; /// Reset regex - const titleMatch = regex.exec(item.title); + + const titleMatch = regex.exec(result.title); regex.lastIndex = 0; /// Reset regex if (titleMatch) { - result.title = item.title.replace(regex, marker); + result.title = result.title.replace(regex, marker); } if (titleMatch || contentMatch) { @@ -215,7 +244,7 @@ const render = (item: pageData) => { ; } -window.addEventListener('load', () => { +window.addEventListener('DOMContentLoaded', () => { handleQueryString(); bindQueryStringChange(); bindSearchForm(); diff --git a/layouts/page/search.json b/layouts/page/search.json index 4c98536..ce09a79 100644 --- a/layouts/page/search.json +++ b/layouts/page/search.json @@ -5,7 +5,7 @@ {{- $result := slice -}} {{- range $filtered -}} - {{- $data := dict "title" .Title "date" .Date "permalink" .Permalink "content" (htmlUnescape .Plain) -}} + {{- $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 -}} From c19780280e30855de60767404f7ada0585ddf453 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sat, 26 Sep 2020 23:00:48 +0200 Subject: [PATCH 06/14] feat: add "head" block --- layouts/_default/baseof.html | 5 ++++- layouts/partials/head/head.html | 38 ++++++++++++++++----------------- 2 files changed, 22 insertions(+), 21 deletions(-) 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/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 From 8aeb562bb33aa040ba9318aa1b3aefb58045abad Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2020 15:45:23 +0200 Subject: [PATCH 07/14] feat(search): preload search.json --- layouts/page/search.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/layouts/page/search.html b/layouts/page/search.html index 6c98cc5..618f586 100644 --- a/layouts/page/search.html +++ b/layouts/page/search.html @@ -1,4 +1,9 @@ {{ define "body-class" }}template-search{{ end }} +{{ define "head" }} + {{- with .OutputFormats.Get "json" -}} + + {{- end -}} +{{ end }} {{ define "main" }}

From 84a15e1604c96b07d74ce3ce34ffece88189b619 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2020 15:53:27 +0200 Subject: [PATCH 08/14] feat(search): return results at typing --- assets/ts/search.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/assets/ts/search.tsx b/assets/ts/search.tsx index 272e5e2..46da04f 100644 --- a/assets/ts/search.tsx +++ b/assets/ts/search.tsx @@ -63,7 +63,8 @@ const tagsToReplace = { '&': '&', '<': '<', '>': '>', - '"': '"' + '"': '"', + '…': '…' }; function replaceTag(tag) { @@ -84,7 +85,7 @@ async function getData() { return data; } -function updateQueryString(keywords: string) { +function updateQueryString(keywords: string, replaceState = false) { const pageURL = new URL(window.location.toString()); if (keywords === '') { @@ -94,7 +95,12 @@ function updateQueryString(keywords: string) { pageURL.searchParams.set('keyword', keywords); } - window.history.pushState('', '', pageURL.toString()); + if (replaceState) { + window.history.replaceState('', '', pageURL.toString()); + } + else { + window.history.pushState('', '', pageURL.toString()); + } } function bindQueryStringChange() { @@ -118,11 +124,12 @@ function handleQueryString() { function bindSearchForm() { let lastSearch = ''; - searchForm.addEventListener('submit', async (e) => { + + const eventHandler = (e) => { e.preventDefault(); const keywords = searchInput.value; - updateQueryString(keywords); + updateQueryString(keywords, true); if (keywords === '') { return clear(); @@ -132,7 +139,10 @@ function bindSearchForm() { lastSearch = keywords; doSearch(keywords.split(' ')); - }) + } + + searchInput.addEventListener('input', eventHandler); + searchInput.addEventListener('compositionend', eventHandler); } function clear() { From 26fedaebd272be36dc7a41466a6c5ed7473f21b8 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Mon, 12 Oct 2020 20:31:40 +0200 Subject: [PATCH 09/14] refactor(search): include icon using helper/icon --- layouts/page/search.html | 2 +- layouts/partials/widget/search.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/layouts/page/search.html b/layouts/page/search.html index 618f586..c6f0c0c 100644 --- a/layouts/page/search.html +++ b/layouts/page/search.html @@ -12,7 +12,7 @@

diff --git a/layouts/partials/widget/search.html b/layouts/partials/widget/search.html index 3178872..6f0a2e4 100644 --- a/layouts/partials/widget/search.html +++ b/layouts/partials/widget/search.html @@ -4,7 +4,7 @@

\ No newline at end of file From 2736fec28516a062d4b69a68d566d295f1393b9a Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Fri, 6 Nov 2020 11:12:48 +0100 Subject: [PATCH 10/14] refactor: create createElement.ts --- assets/ts/createElement.ts | 34 ++++++++++++++++++++++++++++++++++ assets/ts/main.ts | 12 +++++++++++- assets/ts/search.tsx | 35 ----------------------------------- 3 files changed, 45 insertions(+), 36 deletions(-) create mode 100644 assets/ts/createElement.ts 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..ae6153f 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,13 @@ 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 index 46da04f..8be4b7d 100644 --- a/assets/ts/search.tsx +++ b/assets/ts/search.tsx @@ -15,41 +15,6 @@ const searchResultTitle = document.querySelector('.search-result--title') as HTM let data: pageData[]; -/** - * 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; -} - -window.createElement = createElement; - function escapeRegExp(string) { return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); } From b97e86a7a763ebac35a3390f144da955626ba4db Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Fri, 6 Nov 2020 11:33:33 +0100 Subject: [PATCH 11/14] style: remove empty line in main.ts --- assets/ts/main.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/ts/main.ts b/assets/ts/main.ts index ae6153f..8875a74 100644 --- a/assets/ts/main.ts +++ b/assets/ts/main.ts @@ -75,7 +75,6 @@ window.addEventListener('load', () => { }, 0); }) - declare global { interface Window { createElement: any; From f5d45458fd55a4fdbff846f132d039518b07d3a3 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Fri, 6 Nov 2020 11:33:51 +0100 Subject: [PATCH 12/14] refactor(search): create Search class --- assets/ts/search.tsx | 362 +++++++++++++++++++++++-------------------- 1 file changed, 193 insertions(+), 169 deletions(-) diff --git a/assets/ts/search.tsx b/assets/ts/search.tsx index 8be4b7d..9539caa 100644 --- a/assets/ts/search.tsx +++ b/assets/ts/search.tsx @@ -8,17 +8,6 @@ interface pageData { matchCount: number } -const searchForm = document.querySelector('.search-form') as HTMLFormElement; -const searchInput = searchForm.querySelector('input') as HTMLInputElement; -const searchResultList = document.querySelector('.search-result--list') as HTMLDivElement; -const searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement; - -let data: pageData[]; - -function escapeRegExp(string) { - return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); -} - /** * Escape HTML tags as HTML entities * Edited from: @@ -40,187 +29,222 @@ function replaceHTMLEnt(str) { return str.replace(/[&<>"]/g, replaceTag); } -async function getData() { - if (!data) { - /// Not fetched yet - const jsonURL = searchForm.dataset.json; - data = await fetch(jsonURL).then(res => res.json()); - } - - return data; +function escapeRegExp(string) { + return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); } -function updateQueryString(keywords: string, replaceState = false) { - const pageURL = new URL(window.location.toString()); +class Search { + private data: pageData[]; + private form: HTMLFormElement; + private input: HTMLInputElement; + private list: HTMLDivElement; + private resultTitle: HTMLHeadElement; - if (keywords === '') { - pageURL.searchParams.delete('keyword') - } - else { - pageURL.searchParams.set('keyword', keywords); + constructor({ form, input, list, resultTitle }) { + this.form = form; + this.input = input; + this.list = list; + this.resultTitle = resultTitle; + + this.handleQueryString(); + this.bindQueryStringChange(); + this.bindSearchForm(); } - if (replaceState) { - window.history.replaceState('', '', pageURL.toString()); + 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; + }); } - else { - window.history.pushState('', '', pageURL.toString()); + + public static marker(match) { + return '' + match + ''; } -} -function bindQueryStringChange() { - window.addEventListener('popstate', (e) => { - handleQueryString() - }) -} + private async doSearch(keywords: string[]) { + const startTime = performance.now(); -function handleQueryString() { - const pageURL = new URL(window.location.toString()); - const keywords = pageURL.searchParams.get('keyword'); - searchInput.value = keywords; + const results = await this.searchKeywords(keywords); + this.clear(); - if (keywords) { - doSearch(keywords.split(' ')); + for (const item of results) { + this.list.append(Search.render(item)); + } + + const endTime = performance.now(); + + this.resultTitle.innerText = `${results.length} pages (${((endTime - startTime) / 1000).toPrecision(1)} seconds)`; } - else { - clear() + + 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; } -} -function bindSearchForm() { - let lastSearch = ''; + private bindSearchForm() { + let lastSearch = ''; - const eventHandler = (e) => { - e.preventDefault(); - const keywords = searchInput.value; + const eventHandler = (e) => { + e.preventDefault(); + const keywords = this.input.value; - updateQueryString(keywords, true); + 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 === '') { - return clear(); + pageURL.searchParams.delete('keyword') + } + else { + pageURL.searchParams.set('keyword', keywords); } - if (lastSearch === keywords) return; - lastSearch = keywords; - - doSearch(keywords.split(' ')); - } - - searchInput.addEventListener('input', eventHandler); - searchInput.addEventListener('compositionend', eventHandler); -} - -function clear() { - searchResultList.innerHTML = ''; - searchResultTitle.innerText = ''; -} - -async function doSearch(keywords: string[]) { - const startTime = performance.now(); - - const results = await searchKeywords(keywords); - clear(); - - for (const item of results) { - searchResultList.append(render(item)); - } - - const endTime = performance.now(); - - searchResultTitle.innerText = `${results.length} pages (${((endTime - startTime) / 1000).toPrecision(1)} seconds)`; -} - -function marker(match) { - return '' + match + ''; -} - -async function searchKeywords(keywords: string[]) { - const rawData = await 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 + if (replaceState) { + window.history.replaceState('', '', pageURL.toString()); } - - 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, 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, marker); - } - else { - if (start !== 0) result.preview += `[...] `; - result.preview += `${result.content.slice(start, end).replace(regex, marker)} `; - } - } - } - - if (matched) { - result.preview += '[...]'; - results.push(result); + else { + window.history.pushState('', '', pageURL.toString()); } } - /** Result with more matches appears first */ - return results.sort((a, b) => { - return b.matchCount - a.matchCount; - }); -} - -const render = (item: pageData) => { - return ; + } } -window.addEventListener('DOMContentLoaded', () => { - handleQueryString(); - bindQueryStringChange(); - bindSearchForm(); -}) \ No newline at end of file +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 + }); + }, 0); +}) + +export default Search; \ No newline at end of file From c018f4967ab5896b1592fcf971287b8e01f61fdb Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Fri, 6 Nov 2020 11:49:30 +0100 Subject: [PATCH 13/14] feat(search): i18n support --- assets/ts/search.tsx | 19 ++++++++++++++++--- i18n/en.toml | 11 ++++++++++- i18n/zh-CN.toml | 11 ++++++++++- layouts/page/search.html | 8 ++++++-- layouts/partials/widget/search.html | 4 ++-- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/assets/ts/search.tsx b/assets/ts/search.tsx index 9539caa..8e4eb6f 100644 --- a/assets/ts/search.tsx +++ b/assets/ts/search.tsx @@ -39,12 +39,14 @@ class Search { private input: HTMLInputElement; private list: HTMLDivElement; private resultTitle: HTMLHeadElement; + private resultTitleTemplate: string; - constructor({ form, input, list, resultTitle }) { + 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(); @@ -136,7 +138,11 @@ class Search { const endTime = performance.now(); - this.resultTitle.innerText = `${results.length} pages (${((endTime - startTime) / 1000).toPrecision(1)} seconds)`; + 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() { @@ -231,6 +237,12 @@ class Search { } } +declare global { + interface Window { + searchResultTitleTemplate: string; + } +} + window.addEventListener('load', () => { setTimeout(function () { const searchForm = document.querySelector('.search-form') as HTMLFormElement, @@ -242,7 +254,8 @@ window.addEventListener('load', () => { form: searchForm, input: searchInput, list: searchResultList, - resultTitle: searchResultTitle + resultTitle: searchResultTitle, + resultTitleTemplate: window.searchResultTitleTemplate }); }, 0); }) 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/page/search.html b/layouts/page/search.html index c6f0c0c..921fa94 100644 --- a/layouts/page/search.html +++ b/layouts/page/search.html @@ -7,8 +7,8 @@ {{ define "main" }}

- - + +

-

+