# Shoka
主题的搜索功能配置
Shoka
主题的搜索功能是基于 Algolia
实现的,由于 Algolia
每个月有搜索次数限制,并且每次搜索时需要访问其服务器进行查询,修改文章后还需要执行 hexo algolia
的指令,操作起来相对繁琐,因此我考虑更换为基于本地文件搜索的形式实现搜索功能。这一部分的实现主要参考了 Next
主题的相关代码。
# 若干主要的代码文件
Shoka
主题与搜索功能相关的代码主要在 shoka/source/js/_app/page.js
, shoka/source/js/_app/pjax.js
, shoka/scripts/generaters/script.js
和 shoka/source/css/_common/components/third-party/search.styl
这四个文件中。其中 page.js
, pjax.js
和 script.js
是功能实现的相关代码, search.styl
则是一些样式设计的代码。
# Algolia
配置的主要代码
Shoka
主题通过在 pjax.js
中调用 algoliasearch
函数来实现搜索功能,我自己的修改则是将其替换为了自己实现的 localsearch
函数,这些函数的实现主要在 page.js
中。 script.js
中有少量和配置选项相关的代码。
基于 Algolia
实现的搜索功能主要利用 search.addWidgets
来添加和搜索功能相关的 Widgets
:
search.addWidgets([ | |
instantsearch.widgets.configure({ | |
hitsPerPage: CONFIG.search.hits.per_page || 10 | |
}), | |
instantsearch.widgets.searchBox({ | |
container : '.search-input-container', | |
placeholder : LOCAL.search.placeholder, | |
searchOnEnterKeyPressOnly: true, // Only search when press enter key | |
// Hide default icons of algolia search | |
showReset : false, | |
showSubmit : false, | |
showLoadingIndicator: false, | |
cssClasses : { | |
input: 'search-input' | |
} | |
}), | |
instantsearch.widgets.stats({ | |
container: '#search-stats', | |
templates: { | |
text: function(data) { | |
var stats = LOCAL.search.stats | |
.replace(/\$\{hits}/, data.nbHits) | |
.replace(/\$\{time}/, data.processingTimeMS); | |
return stats + '<span class="algolia-powered"></span><hr>'; | |
} | |
} | |
}), | |
instantsearch.widgets.hits({ | |
container: '#search-hits', | |
templates: { | |
item: function(data) { | |
console.log(data) | |
var cats = data.categories ? '<span>'+data.categories.join('<i class="ic i-angle-right"></i>')+'</span>' : ''; | |
return '<a href="' + CONFIG.root + data.path +'">' + cats + | |
'<b>' + data._highlightResult.title.value + '</b><br>' + | |
data._snippetResult.contentStrip.value + '<br>( 匹配字词 : ' + | |
data._highlightResult.contentStrip.matchedWords + ' ) | ( 匹配等级 : ' + | |
data._highlightResult.contentStrip.matchLevel + ' )' + '</a>'; | |
}, | |
empty: function(data) { | |
return '<div id="hits-empty">'+ | |
LOCAL.search.empty.replace(/\$\{query}/, data.query) + | |
'</div>'; | |
} | |
}, | |
cssClasses: { | |
item: 'item' | |
} | |
}), | |
instantsearch.widgets.pagination({ | |
container: '#search-pagination', | |
scrollTo : false, | |
showFirst: false, | |
showLast : false, | |
templates: { | |
first : '<i class="ic i-angle-double-left"></i>', | |
last : '<i class="ic i-angle-double-right"></i>', | |
previous: '<i class="ic i-angle-left"></i>', | |
next : '<i class="ic i-angle-right"></i>' | |
}, | |
cssClasses: { | |
root : 'pagination', | |
item : 'pagination-item', | |
link : 'page-number', | |
selectedItem: 'current', | |
disabledItem: 'disabled-item' | |
} | |
}) | |
]); |
这部分代码配置了相关 Widgets
的设置选项,制定了各个对象的展示样式。其中的 HTML
相关代码可以用于之后的本地搜索实现,使得搜索界面的风格和原本的主题保持一致。
# 参考 Hexo 的 Next 主题修改 Algolia
为本地搜索
localsearch
函数的实现主要参考复制了 Next
主题 local-search.js
的一些代码。
这些函数中主要需要关注 inputEventFunction
,它用于处理搜索框的输入,并返回搜索结果对应的 HTML
代码。搜索结果的样式依旧使用原本 Shoka
主题的样式,不做大的修改。
原本的 Shoka
主题只会显示关键词所在的文章标题,这里我在每个文章的标题下方增加显示了每篇文章中前 10 个匹配的关键词所在位置的上下文。
由于 localsearch
的功能是基于 https://github.com/theme-next/hexo-generator-searchdb
插件实现的,这里需要在配置文件中增加对应的选项:
search: | |
enable: true | |
path: search.json # search.xml | |
field: post | |
format: html | |
limit: 10000 | |
content: true | |
unescape: true | |
preload: true | |
trigger: "auto" | |
top_n_per_article: 10 |
上述的选项是我把插件和 Next
主题的配置项混合在一起列出的,具体的含义可以参看插件或者 Next
主题的相关说明文档。
localsearch
函数在 page.js
中:
const localSearch = function(pjax) { | |
// 参考 hexo next 主题的配置方法 | |
// 参考 https://qiuyiwu.github.io/2019/01/25/Hexo-LocalSearch/ 博文 | |
console.log(CONFIG.search); | |
if(CONFIG.search === null) | |
return | |
if(!siteSearch) { | |
siteSearch = BODY.createChild('div', { | |
id: 'search', | |
innerHTML: `<div class="inner"> | |
<div class="header"> | |
<span class="icon"> | |
<i class="ic i-search"> | |
</i> | |
</span> | |
<div class="search-input-container"> | |
<input class="search-input" | |
autocomplete="off" | |
placeholder="${LOCAL.search.placeholder}" | |
spellcheck="false" | |
type="text" | |
id="local-search-input"> | |
</div> | |
<span class="close-btn"> | |
<i class="ic i-times-circle"> | |
</i> | |
</span> | |
</div> | |
<div class="results"> | |
<div class="inner"> | |
<div id="search-stats"> | |
</div> | |
<div id="search-hits"> | |
</div> | |
<div id="search-pagination"> | |
</div> | |
</div> | |
</div> | |
</div>` | |
}); | |
} | |
let isFetched = false; | |
let datas; | |
let isXml = true; | |
// search DB path | |
let searchPath = CONFIG.search.path; | |
console.log(searchPath); | |
if (searchPath.length == 0) { | |
searchPath = 'search.xml'; | |
} else if (searchPath.endsWith('json')) { | |
isXml = false; | |
} | |
const input = $('.search-input'); // document.querySelector('.search-input'); | |
// console.log(input); | |
const resultContent = document.getElementById('search-result'); | |
// console.log(resultContent); | |
const getIndexByWord = (word, text, caseSensitive) => { | |
if (CONFIG.search.unescape) { | |
let div = document.createElement('div'); | |
div.innerText = word; | |
word = div.innerHTML; | |
} | |
let wordLen = word.length; | |
if (wordLen === 0) { | |
return []; | |
} | |
let startPosition = 0; | |
let position = []; | |
let index = []; | |
if (!caseSensitive) { | |
text = text.toLowerCase(); | |
word = word.toLowerCase(); | |
} | |
while ((position = text.indexOf(word, startPosition)) > -1) { | |
index.push({position, word}); | |
startPosition = position + wordLen; | |
} | |
return index; | |
}; | |
// Merge hits into slices | |
const mergeIntoSlice = (start, end, index, searchText) => { | |
let item = index[index.length - 1]; | |
let {position, word} = item; | |
let hits = []; | |
let searchTextCountInSlice = 0; | |
while (position + word.length <= end && index.length !== 0) { | |
if (word === searchText) { | |
searchTextCountInSlice++; | |
} | |
hits.push({ | |
position, | |
length: word.length | |
}); | |
let wordEnd = position + word.length; | |
// Move to next position of hit | |
index.pop(); | |
while (index.length !== 0) { | |
item = index[index.length - 1]; | |
position = item.position; | |
word = item.word; | |
if (wordEnd > position) { | |
index.pop(); | |
} else { | |
break; | |
} | |
} | |
} | |
return { | |
hits, | |
start, | |
end, | |
searchTextCount: searchTextCountInSlice | |
}; | |
} | |
// Highlight title and content | |
const highlightKeyword = (text, slice) => { | |
let result = ''; | |
let prevEnd = slice.start; | |
slice.hits.forEach(hit => { | |
result += text.substring(prevEnd, hit.position); | |
let end = hit.position + hit.length; | |
result += `<mark">${text.substring(hit.position, end)}</mark>`; | |
prevEnd = end; | |
}); | |
result += text.substring(prevEnd, slice.end); | |
return result; | |
}; | |
const inputEventFunction = () => { | |
if (!isFetched) { | |
return; | |
} | |
let searchText = input.value.trim().toLowerCase(); | |
let keywords = searchText.split(/[-\s]+/); | |
if (keywords.length > 1) { | |
keywords.push(searchText); | |
} | |
let resultItems = []; | |
if (searchText.length > 0) { | |
// Perform local searching | |
datas.forEach(({title, content, url}) => { | |
let titleInLowerCase = title.toLowerCase(); | |
let contentInLowerCase = content.toLowerCase(); | |
let indexOfTitle = []; | |
let indexOfContent = []; | |
let searchTextCount = 0; | |
keywords.forEach(keyword => { | |
indexOfTitle = indexOfTitle.concat(getIndexByWord(keyword, titleInLowerCase, false)); | |
indexOfContent = indexOfContent.concat(getIndexByWord(keyword, contentInLowerCase, false)); | |
}); | |
// Show search results | |
if (indexOfTitle.length > 0 || indexOfContent.length > 0) { | |
let hitCount = indexOfTitle.length + indexOfContent.length; | |
// Sort index by position of keyword | |
[indexOfTitle, indexOfContent].forEach(index => { | |
index.sort((itemLeft, itemRight) => { | |
if (itemRight.position !== itemLeft.position) { | |
return itemRight.position - itemLeft.position; | |
} | |
return itemLeft.word.length - item.word.length; | |
}); | |
}); | |
let slicesOfContent = []; | |
while (indexOfContent.length !== 0) { | |
let item = indexOfContent[indexOfContent.length - 1]; | |
let {position, word} = item; | |
// Cut out 100 characters | |
let start = position - 20; | |
let end = position + 80; | |
if (start < 0) { | |
start = 0; | |
} | |
if (end < position + word.length) { | |
end = position + word.length; | |
} | |
if (end > content.length) { | |
end = content.length; | |
} | |
let tmp = mergeIntoSlice(start, end, indexOfContent, searchText); | |
searchTextCount += tmp.searchTextCountInSlice; | |
slicesOfContent.push(tmp); | |
} | |
// Sort slices in content by search text's count and hits' count | |
slicesOfContent.sort((sliceLeft, sliceRight) => { | |
if (sliceLeft.searchTextCount !== sliceRight.searchTextCount) { | |
return sliceRight.searchTextCount - sliceLeft.searchTextCount; | |
} else if (sliceLeft.hits.length !== sliceRight.hits.length) { | |
return sliceRight.hits.length - sliceLeft.hits.length; | |
} | |
return sliceLeft.start - sliceRight.start; | |
}); | |
// Select top N slices in content | |
let upperBound = parseInt(CONFIT.localsearch.top_n_per_article, 10); | |
if (upperBound >= 0) { | |
slicesOfContent = slicesOfContent.slice(0, upperBound); | |
} | |
let resultItem = ''; | |
if (slicesOfTitle.length !== 0) { | |
resultItem += `<li><a href="${url}">${highlightKeyword(title, slicesOfTitle[0])}</a>`; | |
} else { | |
resultItem += `<li><a href="${url}">${title}</a>`; | |
} | |
slicesOfContent.forEach(slice => { | |
resultItem += `<a href="${url}"><p>${highlightKeyword(content, slice)}...</p></a>`; | |
}); | |
resultItem += '</li>'; | |
resultItems.push({ | |
item: resultItem, | |
id : resultItems.length, | |
hitCount, | |
searchTextCount | |
}); | |
} | |
}); | |
} | |
if (keywords.length === 1 && keywords[0] === '') { | |
resultContent.innerHTML = '<div id="no-result"><i></i></div>'; | |
} else if (resultItems.length === 0) { | |
resultContent.innerHTML = '<div id="no-result"><i></i></div>'; | |
} else { | |
resultItems.sort((resultLeft, resultRight) => { | |
if (resultLeft.searchTextCount !== resultRight.searchTextCount) { | |
return resultRight.searchTextCount - resultLeft.searchTextCount; | |
} else if (resultLeft.hitCount !== resultRight.hitCount) { | |
return resultRight.hitCount - resultLeft.hitCount; | |
} | |
return resultRight.id - resultLeft.id; | |
}); | |
resultContent.innerHTML = `<ul>${resultItems.map(result => result.item).join('')}</ul>`; | |
window.pjax && window.pjax.refresh(resultContent); | |
} | |
} | |
const fetchData = () => { | |
fetch(CONFIG.root + searchPath) | |
.then(response => response.text()) | |
.then(res => { | |
// Get the contents from search data | |
isfetched = true; | |
datas = isXml ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => { | |
return { | |
title : element.querySelector('title').textContent, | |
content: element.querySelector('content').textContent, | |
url : element.querySelector('url').textContent | |
}; | |
}) : JSON.parse(res); | |
// Only match articles with not empty titles | |
datas = 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 | |
document.getElementById('no-result').innerHTML = '<i></i>'; | |
inputEventFunction(); | |
}); | |
}; | |
if (CONFIG.search.preload) { | |
fetchData(); | |
} | |
// Handle and trigger popup window | |
document.querySelectorAll('.popup-trigger').forEach(element => { | |
element.addEventListener('click', () => { | |
document.body.style.overflow = 'hidden'; | |
document.querySelector('.search-pop-overlay').classList.add('search-active'); | |
input.focus(); | |
if (!isfetched) fetchData(); | |
}); | |
}); | |
// Handle and trigger popup window | |
$.each('.search', function(element) { | |
element.addEventListener('click', function() { | |
document.body.style.overflow = 'hidden'; | |
transition(siteSearch, 'shrinkIn', function() { | |
$('.search-input').focus(); | |
}) // transition.shrinkIn | |
}); | |
}); | |
// // Monitor main search box | |
const onPopupClose = function() { | |
document.body.style.overflow = ''; | |
transition(siteSearch, 0); // "transition.shrinkOut" | |
}; | |
siteSearch.addEventListener('click', function(event) { | |
if (event.target === siteSearch) { | |
onPopupClose(); | |
} | |
}); | |
$('.close-btn').addEventListener('click', onPopupClose); | |
window.addEventListener('pjax:success', onPopupClose); | |
window.addEventListener('keyup', function(event) { | |
if (event.key === 'Escape') { | |
onPopupClose(); | |
} | |
}); | |
}; |
# 搜索结果样式
搜索结果页面的样式主要由 search.styl
文件决定,我在其中对 #search-item
的内容进行了以下修改:
.item { | |
margin: 2rem 0; | |
a { | |
border-bottom: 0.1rem dashed var(--grey-4); | |
display: block; | |
the-transition(); | |
} | |
span { | |
font-size: 70%; | |
display: block; | |
i { | |
color: var(--grey-4); | |
margin: 0 .3125rem; | |
} | |
} | |
} |
# 分页功能
对本地搜索的展示结果添加了分页功能,主要代码在 pagination
函数中。另,原主题在翻页时不会自动将滚动条回滚到顶部,这里按照我个人的习惯进行了修改,代码为 resultContent.scrollTop = 0;
,在 pagination
执行完毕后设置该属性。