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