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