diff --git a/static/js/third-party/search/algolia-search.js b/assets/js/third-party/search/algolia-search.js
similarity index 100%
rename from static/js/third-party/search/algolia-search.js
rename to assets/js/third-party/search/algolia-search.js
diff --git a/assets/js/third-party/search/local-search.js b/assets/js/third-party/search/local-search.js
new file mode 100644
index 0000000..c2c238f
--- /dev/null
+++ b/assets/js/third-party/search/local-search.js
@@ -0,0 +1,326 @@
+/* global CONFIG, pjax, LocalSearch */
+class LocalSearch {
+ constructor({
+ path = '',
+ unescape = false,
+ top_n_per_article = 1
+ }) {
+ this.path = path;
+ this.unescape = unescape;
+ this.top_n_per_article = top_n_per_article;
+ this.isfetched = false;
+ this.datas = null;
+ }
+
+ getIndexByWord(words, text, caseSensitive = false) {
+ const index = [];
+ const included = new Set();
+
+ if (!caseSensitive) {
+ text = text.toLowerCase();
+ }
+ words.forEach(word => {
+ if (this.unescape) {
+ const div = document.createElement('div');
+ div.innerText = word;
+ word = div.innerHTML;
+ }
+ const wordLen = word.length;
+ if (wordLen === 0) return;
+ let startPosition = 0;
+ let position = -1;
+ if (!caseSensitive) {
+ word = word.toLowerCase();
+ }
+ while ((position = text.indexOf(word, startPosition)) > -1) {
+ index.push({ position, word });
+ included.add(word);
+ startPosition = position + wordLen;
+ }
+ });
+ // Sort index by position of keyword
+ index.sort((left, right) => {
+ if (left.position !== right.position) {
+ return left.position - right.position;
+ }
+ return right.word.length - left.word.length;
+ });
+ return [index, included];
+ }
+
+ // Merge hits into slices
+ mergeIntoSlice(start, end, index) {
+ let item = index[0];
+ let { position, word } = item;
+ const hits = [];
+ const count = new Set();
+ while (position + word.length <= end && index.length !== 0) {
+ count.add(word);
+ hits.push({
+ position,
+ length: word.length
+ });
+ const wordEnd = position + word.length;
+
+ // Move to next position of hit
+ index.shift();
+ while (index.length !== 0) {
+ item = index[0];
+ position = item.position;
+ word = item.word;
+ if (wordEnd > position) {
+ index.shift();
+ } else {
+ break;
+ }
+ }
+ }
+ return {
+ hits,
+ start,
+ end,
+ count: count.size
+ };
+ }
+
+ // Highlight title and content
+ highlightKeyword(val, slice) {
+ let result = '';
+ let index = slice.start;
+ for (const { position, length } of slice.hits) {
+ result += val.substring(index, position);
+ index = position + length;
+ result += `${val.substr(position, length)}`;
+ }
+ result += val.substring(index, slice.end);
+ return result;
+ }
+
+ getResultItems(keywords) {
+ const resultItems = [];
+ this.datas.forEach(({ title, content, url }) => {
+ // The number of different keywords included in the article.
+ const [indexOfTitle, keysOfTitle] = this.getIndexByWord(keywords, title);
+ const [indexOfContent, keysOfContent] = this.getIndexByWord(keywords, content);
+ const includedCount = new Set([...keysOfTitle, ...keysOfContent]).size;
+
+ // Show search results
+ const hitCount = indexOfTitle.length + indexOfContent.length;
+ if (hitCount === 0) return;
+
+ const slicesOfTitle = [];
+ if (indexOfTitle.length !== 0) {
+ slicesOfTitle.push(this.mergeIntoSlice(0, title.length, indexOfTitle));
+ }
+
+ let slicesOfContent = [];
+ while (indexOfContent.length !== 0) {
+ const item = indexOfContent[0];
+ const { position } = item;
+ // Cut out 100 characters. The maxlength of .search-input is 80.
+ const start = Math.max(0, position - 20);
+ const end = Math.min(content.length, position + 80);
+ slicesOfContent.push(this.mergeIntoSlice(start, end, indexOfContent));
+ }
+
+ // Sort slices in content by included keywords' count and hits' count
+ slicesOfContent.sort((left, right) => {
+ if (left.count !== right.count) {
+ return right.count - left.count;
+ } else if (left.hits.length !== right.hits.length) {
+ return right.hits.length - left.hits.length;
+ }
+ return left.start - right.start;
+ });
+
+ // Select top N slices in content
+ const upperBound = parseInt(this.top_n_per_article, 10);
+ if (upperBound >= 0) {
+ slicesOfContent = slicesOfContent.slice(0, upperBound);
+ }
+
+ let resultItem = '';
+
+ url = new URL(url, location.origin);
+ url.searchParams.append('highlight', keywords.join(' '));
+
+ if (slicesOfTitle.length !== 0) {
+ resultItem += `
${this.highlightKeyword(title, slicesOfTitle[0])}`;
+ } else {
+ resultItem += `${title}`;
+ }
+
+ slicesOfContent.forEach(slice => {
+ resultItem += `${this.highlightKeyword(content, slice)}...
`;
+ });
+
+ resultItem += '';
+ resultItems.push({
+ item: resultItem,
+ id : resultItems.length,
+ hitCount,
+ includedCount
+ });
+ });
+ return resultItems;
+ }
+
+ fetchData() {
+ const isXml = !this.path.endsWith('json');
+ fetch(this.path)
+ .then(response => response.text())
+ .then(res => {
+ // Get the contents from search data
+ this.isfetched = true;
+ this.datas = isXml ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => ({
+ title : element.querySelector('title').textContent,
+ content: element.querySelector('content').textContent,
+ url : element.querySelector('url').textContent
+ })) : JSON.parse(res);
+ // Only match articles with non-empty titles
+ this.datas = this.datas.filter(data => data.title).map(data => {
+ data.title = data.title.trim();
+ data.content = data.content ? data.content.trim().replace(/<[^>]+>/g, '') : '';
+ data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/');
+ return data;
+ });
+ // Remove loading animation
+ window.dispatchEvent(new Event('search:loaded'));
+ });
+ }
+
+ // Highlight by wrapping node in mark elements with the given class name
+ highlightText(node, slice, className) {
+ const val = node.nodeValue;
+ let index = slice.start;
+ const children = [];
+ for (const { position, length } of slice.hits) {
+ const text = document.createTextNode(val.substring(index, position));
+ index = position + length;
+ const mark = document.createElement('mark');
+ mark.className = className;
+ mark.appendChild(document.createTextNode(val.substr(position, length)));
+ children.push(text, mark);
+ }
+ node.nodeValue = val.substring(index, slice.end);
+ children.forEach(element => {
+ node.parentNode.insertBefore(element, node);
+ });
+ }
+
+ // Highlight the search words provided in the url in the text
+ highlightSearchWords(body) {
+ const params = new URL(location.href).searchParams.get('highlight');
+ const keywords = params ? params.split(' ') : [];
+ if (!keywords.length || !body) return;
+ const walk = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null);
+ const allNodes = [];
+ while (walk.nextNode()) {
+ if (!walk.currentNode.parentNode.matches('button, select, textarea')) allNodes.push(walk.currentNode);
+ }
+ allNodes.forEach(node => {
+ const [indexOfNode] = this.getIndexByWord(keywords, node.nodeValue);
+ if (!indexOfNode.length) return;
+ const slice = this.mergeIntoSlice(0, node.nodeValue.length, indexOfNode);
+ this.highlightText(node, slice, 'search-keyword');
+ });
+ }
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ if (! NexT.CONFIG.localSearch.path) {
+ // Search DB path
+ console.warn('`search indexes file` is not configurate!');
+ return;
+ }
+ const localSearch = new LocalSearch({
+ path : NexT.CONFIG.localSearch.path,
+ top_n_per_article: NexT.CONFIG.localSearch.topnperarticle,
+ unescape : NexT.CONFIG.localSearch.unescape
+ });
+
+ const input = document.querySelector('.search-input');
+
+ const inputEventFunction = () => {
+ if (!localSearch.isfetched) return;
+ const searchText = input.value.trim().toLowerCase();
+ const keywords = searchText.split(/[-\s]+/);
+ const container = document.querySelector('.search-result-container');
+ let resultItems = [];
+ if (searchText.length > 0) {
+ // Perform local searching
+ resultItems = localSearch.getResultItems(keywords);
+ }
+ if (keywords.length === 1 && keywords[0] === '') {
+ container.classList.add('no-result');
+ container.innerHTML = '
';
+ } else if (resultItems.length === 0) {
+ container.classList.add('no-result');
+ container.innerHTML = '
';
+ } else {
+ resultItems.sort((left, right) => {
+ if (left.includedCount !== right.includedCount) {
+ return right.includedCount - left.includedCount;
+ } else if (left.hitCount !== right.hitCount) {
+ return right.hitCount - left.hitCount;
+ }
+ return right.id - left.id;
+ });
+ const stats = NexT.CONFIG.i18n.hits.replace('${hits}', resultItems.length);
+
+ container.classList.remove('no-result');
+ container.innerHTML = `${stats}
+
+ ${resultItems.map(result => result.item).join('')}
`;
+ if (typeof pjax === 'object') pjax.refresh(container);
+ }
+ };
+
+ localSearch.highlightSearchWords(document.querySelector('.post-body'));
+ if (NexT.CONFIG.localSearch.preload) {
+ localSearch.fetchData();
+ }
+
+ if (NexT.CONFIG.localSearch.trigger === 'auto') {
+ input.addEventListener('input', inputEventFunction);
+ } else {
+ document.querySelector('.search-icon').addEventListener('click', inputEventFunction);
+ input.addEventListener('keypress', event => {
+ if (event.key === 'Enter') {
+ inputEventFunction();
+ }
+ });
+ }
+ window.addEventListener('search:loaded', inputEventFunction);
+
+ // Handle and trigger popup window
+ document.querySelectorAll('.popup-trigger').forEach(element => {
+ element.addEventListener('click', () => {
+ document.body.classList.add('search-active');
+ // Wait for search-popup animation to complete
+ setTimeout(() => input.focus(), 500);
+ if (!localSearch.isfetched) localSearch.fetchData();
+ });
+ });
+
+ // Monitor main search box
+ const onPopupClose = () => {
+ document.body.classList.remove('search-active');
+ };
+
+ document.querySelector('.search-pop-overlay').addEventListener('click', event => {
+ if (event.target === document.querySelector('.search-pop-overlay')) {
+ onPopupClose();
+ }
+ });
+ document.querySelector('.popup-btn-close').addEventListener('click', onPopupClose);
+ document.addEventListener('pjax:success', () => {
+ localSearch.highlightSearchWords(document.querySelector('.post-body'));
+ onPopupClose();
+ });
+ window.addEventListener('keyup', event => {
+ if (event.key === 'Escape') {
+ onPopupClose();
+ }
+ });
+});
diff --git a/exampleSite/config.yaml b/exampleSite/config.yaml
index 0536ba9..4a3f898 100644
--- a/exampleSite/config.yaml
+++ b/exampleSite/config.yaml
@@ -55,6 +55,27 @@ markup:
endLevel: 3
ordered: false
+# 站点文章导航文件
+# Site map of all articles
+sitemap:
+ filename: "sitemap.xml"
+ changefreq: "weekly"
+ priority: 0.5
+
+outputFormats:
+ RSS:
+ baseName: "rss"
+ # 自定义生成本地搜索文件
+ # Custom file of indexes for local search
+ SearchIndexes:
+ mediaType: "application/xml"
+ baseName: "searchindexes"
+ isPlainText: true
+ notAlternative: true
+
+outputs:
+ home: ["HTML", "RSS", "SearchIndexes"]
+
#--------------------------------------
# 菜单配置说明
# identifier : 唯一标识不可重复
@@ -889,16 +910,26 @@ params:
hits:
perPage: 10
+ # 本地搜索
# Local Search
localSearch:
+ # 是否开启搜索功能
+ # Enable search function
enable: true
+ # 搜索索引文件路径
+ # Indexes file path for search
+ path: searchindexes.xml
+ # 是立即搜索当输入关键字时,可选值: auto | manual
# If auto, trigger search by changing input.
# If manual, trigger search by pressing enter key or search button.
trigger: auto
+ # 显示头部的搜索记录,-1 表示显示所有搜索结果
# Show top n results per article, show all results by setting to -1
- top_n_per_article: 1
+ topNPerArticle: -1
+ # 将 html 字符串转换为可读字符串
# Unescape html strings to the readable one.
unescape: false
+ # 页面加载时是否要重新载入索引文件
# Preload the search data when the page loads.
preload: false
diff --git a/i18n/en.yaml b/i18n/en.yaml
index a7ca413..65f0158 100644
--- a/i18n/en.yaml
+++ b/i18n/en.yaml
@@ -140,4 +140,13 @@ PageViewsLabel:
other: Total Page Views
FooterPowerby:
- other: "Power by %s"
\ No newline at end of file
+ other: "Power by %s"
+
+SearchPh:
+ other: Searching...
+SearchEmpty:
+ other: "We didn't find any results for the search: ${query}"
+SearchHits:
+ hits: "${hits} results found"
+SearchHitsTime:
+ other: "${hits} results found in ${time} ms"
\ No newline at end of file
diff --git a/i18n/zh-cn.yaml b/i18n/zh-cn.yaml
index 1aa9600..a0b30be 100644
--- a/i18n/zh-cn.yaml
+++ b/i18n/zh-cn.yaml
@@ -141,4 +141,13 @@ PageViewsLabel:
other: 总访问量
FooterPowerby:
- other: 由 %s 强力驱动
\ No newline at end of file
+ other: 由 %s 强力驱动
+
+SearchPh:
+ other: 搜索...
+SearchEmpty:
+ other: "没有找到任何搜索结果:${query}"
+SearchHits:
+ hits: "找到 ${hits} 个搜索结果"
+SearchHitsTime:
+ other: "找到 ${hits} 个搜索结果(用时 ${time} 毫秒)"
\ No newline at end of file
diff --git a/layouts/_default/list.searchindexes.xml b/layouts/_default/list.searchindexes.xml
new file mode 100644
index 0000000..ea75b7b
--- /dev/null
+++ b/layouts/_default/list.searchindexes.xml
@@ -0,0 +1,18 @@
+{{ printf "" | safeHTML }}
+
+ {{range where .Site.RegularPages "Kind" "page"}}
+
+ {{ .Title }}
+ {{ .Permalink }}
+
+ {{- range .Params.categories }}{{ . }}{{- end }}
+
+
+ {{- range .Params.tags }}
+ {{ . }}
+ {{- end }}
+
+
+
+ {{ end }}
+
\ No newline at end of file
diff --git a/layouts/partials/_thirdparty/search/algolia.html b/layouts/partials/_thirdparty/search/algolia.html
new file mode 100644
index 0000000..81e67c0
--- /dev/null
+++ b/layouts/partials/_thirdparty/search/algolia.html
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/layouts/partials/_thirdparty/search/local.html b/layouts/partials/_thirdparty/search/local.html
new file mode 100644
index 0000000..d67006a
--- /dev/null
+++ b/layouts/partials/_thirdparty/search/local.html
@@ -0,0 +1,18 @@
+
+
\ No newline at end of file
diff --git a/layouts/partials/header.html b/layouts/partials/header.html
index 01f3aaa..956a531 100644
--- a/layouts/partials/header.html
+++ b/layouts/partials/header.html
@@ -1,2 +1,3 @@
{{ partial "header/brand.html" . }}
-{{ partial "header/menus.html" . }}
\ No newline at end of file
+{{ partial "header/menus.html" . }}
+{{ partial "header/search.html" . }}
\ No newline at end of file
diff --git a/layouts/partials/header/search.html b/layouts/partials/header/search.html
new file mode 100644
index 0000000..00353b8
--- /dev/null
+++ b/layouts/partials/header/search.html
@@ -0,0 +1,11 @@
+{{- if or .Site.Params.algoliaSearch.enable .Site.Params.localSearch.enable }}
+
+
+
+{{- end }}
\ No newline at end of file
diff --git a/layouts/partials/init.html b/layouts/partials/init.html
index 3fd640a..34aef75 100644
--- a/layouts/partials/init.html
+++ b/layouts/partials/init.html
@@ -23,18 +23,25 @@
{{ $globalVars.Set "router" $router }}
{{ $config := dict
- "hostname" .Site.BaseURL
- "root" "/"
- "lang" .Site.LanguageCode
- "vendor" (dict "plugins" $vendor "router" $router)
- "darkmode" .Site.Params.darkmode
- "version" .Site.Data.config.version
- "scheme" .Site.Params.scheme
- "sidebar" .Site.Params.sidebar
- "copybtn" .Site.Params.codeblock.copyBtn
- "bookmark" .Site.Params.bookmark
- "lazyload" .Site.Params.lazyload
- "motion" .Site.Params.motion
+ "hostname" .Site.BaseURL
+ "root" "/"
+ "lang" .Site.LanguageCode
+ "vendor" (dict "plugins" $vendor "router" $router)
+ "darkmode" .Site.Params.darkmode
+ "version" .Site.Data.config.version
+ "scheme" .Site.Params.scheme
+ "sidebar" .Site.Params.sidebar
+ "copybtn" .Site.Params.codeblock.copyBtn
+ "bookmark" .Site.Params.bookmark
+ "lazyload" .Site.Params.lazyload
+ "motion" .Site.Params.motion
+ "localSearch" .Site.Params.localSearch
+ "i18n" (dict
+ "placeholder" (T "SearchPh")
+ "empty" (T "SearchEmpty")
+ "hits_time" (T "SearchHitsTime")
+ "hits" (T "SearchHits")
+ )
}}
{{ with .Site.Params.waline }}
diff --git a/layouts/partials/scripts.html b/layouts/partials/scripts.html
index 0b4a23c..f7ac6dd 100644
--- a/layouts/partials/scripts.html
+++ b/layouts/partials/scripts.html
@@ -68,4 +68,7 @@
{{ if hugo.IsProduction }}
{{- $nextjs = $nextjs | minify | fingerprint }}
{{ end }}
-
\ No newline at end of file
+
+{{- $search := resources.Get "js/third-party/search/local-search.js" }}
+
+
diff --git a/static/js/third-party/search/local-search.js b/static/js/third-party/search/local-search.js
deleted file mode 100644
index 92a264d..0000000
--- a/static/js/third-party/search/local-search.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/* global CONFIG, pjax, LocalSearch */
-
-document.addEventListener('DOMContentLoaded', () => {
- if (!CONFIG.path) {
- // Search DB path
- console.warn('`hexo-generator-searchdb` plugin is not installed!');
- return;
- }
- const localSearch = new LocalSearch({
- path : CONFIG.path,
- top_n_per_article: CONFIG.localsearch.top_n_per_article,
- unescape : CONFIG.localsearch.unescape
- });
-
- const input = document.querySelector('.search-input');
-
- const inputEventFunction = () => {
- if (!localSearch.isfetched) return;
- const searchText = input.value.trim().toLowerCase();
- const keywords = searchText.split(/[-\s]+/);
- const container = document.querySelector('.search-result-container');
- let resultItems = [];
- if (searchText.length > 0) {
- // Perform local searching
- resultItems = localSearch.getResultItems(keywords);
- }
- if (keywords.length === 1 && keywords[0] === '') {
- container.classList.add('no-result');
- container.innerHTML = '
';
- } else if (resultItems.length === 0) {
- container.classList.add('no-result');
- container.innerHTML = '
';
- } else {
- resultItems.sort((left, right) => {
- if (left.includedCount !== right.includedCount) {
- return right.includedCount - left.includedCount;
- } else if (left.hitCount !== right.hitCount) {
- return right.hitCount - left.hitCount;
- }
- return right.id - left.id;
- });
- const stats = CONFIG.i18n.hits.replace('${hits}', resultItems.length);
-
- container.classList.remove('no-result');
- container.innerHTML = `${stats}
-
- ${resultItems.map(result => result.item).join('')}
`;
- if (typeof pjax === 'object') pjax.refresh(container);
- }
- };
-
- localSearch.highlightSearchWords(document.querySelector('.post-body'));
- if (CONFIG.localsearch.preload) {
- localSearch.fetchData();
- }
-
- if (CONFIG.localsearch.trigger === 'auto') {
- input.addEventListener('input', inputEventFunction);
- } else {
- document.querySelector('.search-icon').addEventListener('click', inputEventFunction);
- input.addEventListener('keypress', event => {
- if (event.key === 'Enter') {
- inputEventFunction();
- }
- });
- }
- window.addEventListener('search:loaded', inputEventFunction);
-
- // Handle and trigger popup window
- document.querySelectorAll('.popup-trigger').forEach(element => {
- element.addEventListener('click', () => {
- document.body.classList.add('search-active');
- // Wait for search-popup animation to complete
- setTimeout(() => input.focus(), 500);
- if (!localSearch.isfetched) localSearch.fetchData();
- });
- });
-
- // Monitor main search box
- const onPopupClose = () => {
- document.body.classList.remove('search-active');
- };
-
- document.querySelector('.search-pop-overlay').addEventListener('click', event => {
- if (event.target === document.querySelector('.search-pop-overlay')) {
- onPopupClose();
- }
- });
- document.querySelector('.popup-btn-close').addEventListener('click', onPopupClose);
- document.addEventListener('pjax:success', () => {
- localSearch.highlightSearchWords(document.querySelector('.post-body'));
- onPopupClose();
- });
- window.addEventListener('keyup', event => {
- if (event.key === 'Escape') {
- onPopupClose();
- }
- });
-});
diff --git a/theme.toml b/theme.toml
index 3d6831f..eae8001 100644
--- a/theme.toml
+++ b/theme.toml
@@ -1,4 +1,4 @@
-name = "Hugo Next"
+name = "Hugo NexT"
license = "MIT"
licenselink = "https://github.com/hugo-next/hugo-theme-next/blob/main/LICENSE"
description = "Easily & powerful theme for Hugo engine."