From 9a4883832f5aae537b307afa44eef6b53af6eb70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E6=A2=A6=E6=98=9F=E5=B0=98?= Date: Sun, 31 Jul 2022 11:02:55 +0800 Subject: [PATCH] :wrench: :seedling: Complete the local search feature. --- .../js/third-party/search/algolia-search.js | 0 assets/js/third-party/search/local-search.js | 326 ++++++++++++++++++ exampleSite/config.yaml | 33 +- i18n/en.yaml | 11 +- i18n/zh-cn.yaml | 11 +- layouts/_default/list.searchindexes.xml | 18 + .../partials/_thirdparty/search/algolia.html | 14 + .../partials/_thirdparty/search/local.html | 18 + layouts/partials/header.html | 3 +- layouts/partials/header/search.html | 11 + layouts/partials/init.html | 31 +- layouts/partials/scripts.html | 5 +- static/js/third-party/search/local-search.js | 99 ------ theme.toml | 2 +- 14 files changed, 465 insertions(+), 117 deletions(-) rename {static => assets}/js/third-party/search/algolia-search.js (100%) create mode 100644 assets/js/third-party/search/local-search.js create mode 100644 layouts/_default/list.searchindexes.xml create mode 100644 layouts/partials/_thirdparty/search/algolia.html create mode 100644 layouts/partials/_thirdparty/search/local.html create mode 100644 layouts/partials/header/search.html delete mode 100644 static/js/third-party/search/local-search.js 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}
    +
    + `; + 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}
    -
    - `; - 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."