From e5f96c876276270c84b1cfcafca2d7970a2e3897 Mon Sep 17 00:00:00 2001
From: Jimmy Cai <jimmehcai@gmail.com>
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 '<mark>' + match + '</mark>';
+}
+
+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 <article>
+        <a href={item.permalink}>
+            <div class="article-details">
+                <h2 class="article-title" dangerouslySetInnerHTML={{ __html: item.title }}></h2>
+                <secion class="article-preview" dangerouslySetInnerHTML={{ __html: item.preview }}></secion>
+            </div>
+            {item.image &&
+                <div class="article-image">
+                    <img src={item.image} loading="lazy" />
+                </div>
+            }
+        </a>
+    </article>;
+}
+
+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" }}
+<form action="{{ .Permalink }}" class="search-form"{{ with .OutputFormats.Get "json" -}} data-json="{{ .Permalink }}"{{- end }}>
+    <p>
+        <label>Keyword</label>
+        <input name="keyword" placeholder="Type something..." />
+    </p>
+
+    <button title="Search">
+        {{ (resources.Get "icons/search.svg").Content | safeHTML }}
+    </button>
+</form>
+
+<h3 class="search-result--title"></h3>
+<div class="search-result--list article-list--compact"></div>
+
+{{- $opts := dict "minify" hugo.IsProduction "JSXFactory" "createElement" -}}
+{{- $searchScript := resources.Get "ts/search.tsx" | js.Build $opts -}}
+<script type="text/javascript" src="{{ $searchScript.RelPermalink }}" defer></script>
+
+{{ 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 <jimmehcai@gmail.com>
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 @@
+<form action="/search" class="search-form widget" {{ with .OutputFormats.Get "json" -}}data-json="{{ .Permalink }}" {{- end }}>
+    <p>
+        <label>Search</label>
+        <input name="keyword" required placeholder="Type something..." />
+    
+        <button title="Search">
+            {{ (resources.Get "icons/search.svg").Content | safeHTML }}
+        </button>
+    </p>
+</form>
\ No newline at end of file

From 6fa69d7a2bf148233f66ce600fe4ad9bf6fac5db Mon Sep 17 00:00:00 2001
From: Jimmy Cai <jimmehcai@gmail.com>
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 @@
+<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
+  <path stroke="none" d="M0 0h24v24H0z"/>
+  <circle cx="10" cy="10" r="7" />
+  <line x1="21" y1="21" x2="15" y2="15" />
+</svg>
+
+

From 08102e2f6907b9960baf7b032fa053e036b6fa38 Mon Sep 17 00:00:00 2001
From: Jimmy Cai <jimmehcai@gmail.com>
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 <jimmehcai@gmail.com>
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 = {
+    '&': '&amp;',
+    '<': '&lt;',
+    '>': '&gt;',
+    '"': '&quot;'
+};
+
+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 '<mark>' + match + '</mark>';
 }
 
-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) => {
     </article>;
 }
 
-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 <jimmehcai@gmail.com>
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 @@
 <!DOCTYPE html>
 <html lang="{{ .Site.LanguageCode }}">
-    {{- partial "head/head.html" . -}}
+    <head>
+        {{- partial "head/head.html" . -}}
+        {{- block "head" . -}}{{ end }}
+    </head>
     <body class="{{ block `body-class` . }}{{ end }}">
         <div class="container flex on-phone--column align-items--flex-start {{ if .Site.Params.widgets.enabled }}extended{{ else }}compact{{ end }} {{ block `container-class` . }}{{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 @@
-<head>
-    <meta charset='utf-8'>
-    <meta name='viewport' content='width=device-width, initial-scale=1'>
-    
-    {{- $description := partialCached "data/description" . .RelPermalink -}}
-    <meta name='description' content='{{ $description }}'>
+<meta charset='utf-8'>
+<meta name='viewport' content='width=device-width, initial-scale=1'>
 
-    {{- $title := partialCached "data/title" . .RelPermalink -}}
-    <title>{{ $title }}</title>
-    
-    <link rel='canonical' href='{{ .Permalink }}'>
-    
-    {{- partial "head/style.html" . -}}
-    {{- partial "head/script.html" . -}}
-    {{- partial "head/opengraph/include.html" . -}}
+{{- $description := partialCached "data/description" . .RelPermalink -}}
+<meta name='description' content='{{ $description }}'>
 
-    {{- range .AlternativeOutputFormats -}}
-        <link rel="{{ .Rel }}" type="{{ .MediaType.Type }}" href="{{ .Permalink | safeURL }}">
-    {{- end -}}
-    
-    {{- partial "head/custom.html" . -}}
-</head>
\ No newline at end of file
+{{- $title := partialCached "data/title" . .RelPermalink -}}
+<title>{{ $title }}</title>
+
+<link rel='canonical' href='{{ .Permalink }}'>
+
+{{- partial "head/style.html" . -}}
+{{- partial "head/script.html" . -}}
+{{- partial "head/opengraph/include.html" . -}}
+
+{{- range .AlternativeOutputFormats -}}
+    <link rel="{{ .Rel }}" type="{{ .MediaType.Type }}" href="{{ .Permalink | safeURL }}">
+{{- end -}}
+
+{{- partial "head/custom.html" . -}}
\ No newline at end of file

From 8aeb562bb33aa040ba9318aa1b3aefb58045abad Mon Sep 17 00:00:00 2001
From: Jimmy Cai <jimmehcai@gmail.com>
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" -}} 
+        <link rel="preload" href="{{ .Permalink }}" as="fetch" crossorigin="anonymous">
+    {{- end -}}
+{{ end }}
 {{ define "main" }}
 <form action="{{ .Permalink }}" class="search-form"{{ with .OutputFormats.Get "json" -}} data-json="{{ .Permalink }}"{{- end }}>
     <p>

From 84a15e1604c96b07d74ce3ce34ffece88189b619 Mon Sep 17 00:00:00 2001
From: Jimmy Cai <jimmehcai@gmail.com>
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 = {
     '&': '&amp;',
     '<': '&lt;',
     '>': '&gt;',
-    '"': '&quot;'
+    '"': '&quot;',
+    '…': '&hellip;'
 };
 
 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 <jimmehcai@gmail.com>
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 @@
     </p>
 
     <button title="Search">
-        {{ (resources.Get "icons/search.svg").Content | safeHTML }}
+        {{ partial "helper/icon" "search" }}
     </button>
 </form>
 
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 @@
         <input name="keyword" required placeholder="Type something..." />
     
         <button title="Search">
-            {{ (resources.Get "icons/search.svg").Content | safeHTML }}
+            {{ partial "helper/icon" "search" }}
         </button>
     </p>
 </form>
\ No newline at end of file

From 2736fec28516a062d4b69a68d566d295f1393b9a Mon Sep 17 00:00:00 2001
From: Jimmy Cai <jimmehcai@gmail.com>
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 <jimmehcai@gmail.com>
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 <jimmehcai@gmail.com>
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 '<mark>' + match + '</mark>';
     }
-}
 
-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 '<mark>' + match + '</mark>';
-}
-
-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 <article>
-        <a href={item.permalink}>
-            <div class="article-details">
-                <h2 class="article-title" dangerouslySetInnerHTML={{ __html: item.title }}></h2>
-                <secion class="article-preview" dangerouslySetInnerHTML={{ __html: item.preview }}></secion>
-            </div>
-            {item.image &&
-                <div class="article-image">
-                    <img src={item.image} loading="lazy" />
+    public static render(item: pageData) {
+        return <article>
+            <a href={item.permalink}>
+                <div class="article-details">
+                    <h2 class="article-title" dangerouslySetInnerHTML={{ __html: item.title }}></h2>
+                    <secion class="article-preview" dangerouslySetInnerHTML={{ __html: item.preview }}></secion>
                 </div>
-            }
-        </a>
-    </article>;
+                {item.image &&
+                    <div class="article-image">
+                        <img src={item.image} loading="lazy" />
+                    </div>
+                }
+            </a>
+        </article>;
+    }
 }
 
-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 <jimmehcai@gmail.com>
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" }}
 <form action="{{ .Permalink }}" class="search-form"{{ with .OutputFormats.Get "json" -}} data-json="{{ .Permalink }}"{{- end }}>
     <p>
-        <label>Keyword</label>
-        <input name="keyword" placeholder="Type something..." />
+        <label>{{ T "searchTitle" }}</label>
+        <input name="keyword" placeholder="{{ T `searchPlaceholder` }}" />
     </p>
 
     <button title="Search">
@@ -19,6 +19,10 @@
 <h3 class="search-result--title"></h3>
 <div class="search-result--list article-list--compact"></div>
 
+<script>
+    window.searchResultTitleTemplate = "{{ T `searchResultTitle` }}"
+</script>
+
 {{- $opts := dict "minify" hugo.IsProduction "JSXFactory" "createElement" -}}
 {{- $searchScript := resources.Get "ts/search.tsx" | js.Build $opts -}}
 <script type="text/javascript" src="{{ $searchScript.RelPermalink }}" defer></script>
diff --git a/layouts/partials/widget/search.html b/layouts/partials/widget/search.html
index 6f0a2e4..fc6d525 100644
--- a/layouts/partials/widget/search.html
+++ b/layouts/partials/widget/search.html
@@ -1,7 +1,7 @@
 <form action="/search" class="search-form widget" {{ with .OutputFormats.Get "json" -}}data-json="{{ .Permalink }}" {{- end }}>
     <p>
-        <label>Search</label>
-        <input name="keyword" required placeholder="Type something..." />
+        <label>{{ T "searchTitle" }}</label>
+        <input name="keyword" required placeholder="{{ T `searchPlaceholder` }}" />
     
         <button title="Search">
             {{ partial "helper/icon" "search" }}

From efa7ff14b7772c8726b0a4978a110238934447b7 Mon Sep 17 00:00:00 2001
From: Jimmy Cai <jimmehcai@gmail.com>
Date: Fri, 6 Nov 2020 12:13:12 +0100
Subject: [PATCH 14/14] refactor(search): remove .search-result--title style

---
 assets/scss/partials/layout/search.scss | 10 +---------
 layouts/page/search.html                |  2 +-
 2 files changed, 2 insertions(+), 10 deletions(-)

diff --git a/assets/scss/partials/layout/search.scss b/assets/scss/partials/layout/search.scss
index ad6a8a2..b390a7b 100644
--- a/assets/scss/partials/layout/search.scss
+++ b/assets/scss/partials/layout/search.scss
@@ -79,12 +79,4 @@
             height: 20px;
         }
     }
-}
-
-.search-result--title {
-    text-transform: uppercase;
-    margin-bottom: 10px;
-    font-size: 1.5rem;
-    font-weight: 700;
-    color: var(--body-text-color);
-}
+}
\ No newline at end of file
diff --git a/layouts/page/search.html b/layouts/page/search.html
index 921fa94..6078ac1 100644
--- a/layouts/page/search.html
+++ b/layouts/page/search.html
@@ -16,7 +16,7 @@
     </button>
 </form>
 
-<h3 class="search-result--title"></h3>
+<h3 class="search-result--title section-title"></h3>
 <div class="search-result--list article-list--compact"></div>
 
 <script>