feat: 增加搜索功能和自定义 hexo 辅助函数
This commit is contained in:
parent
1c51f444fa
commit
6ccb5d3cc6
23
_config.yml
23
_config.yml
|
@ -71,4 +71,25 @@ busuanzi_count:
|
|||
# custom pv span for one page only
|
||||
page_pv: false
|
||||
page_pv_header:
|
||||
page_pv_footer:
|
||||
page_pv_footer:
|
||||
|
||||
|
||||
# Local Search
|
||||
# Dependencies: https://github.com/theme-next/hexo-generator-searchdb
|
||||
local_search:
|
||||
enable: true
|
||||
# If auto, trigger search by changing input.
|
||||
# If manual, trigger search by pressing enter key or search button.
|
||||
trigger: auto
|
||||
# Show top n results per article, show all results by setting to -1
|
||||
top_n_per_article: 1
|
||||
# Unescape html strings to the readable one.
|
||||
unescape: false
|
||||
# Preload the search data when the page loads.
|
||||
preload: false
|
||||
|
||||
|
||||
# Assets
|
||||
css: css
|
||||
js: js
|
||||
images: images
|
|
@ -17,5 +17,5 @@ tag: Tag
|
|||
home: Home
|
||||
archive: Archives
|
||||
about: About
|
||||
site_uv: "UV: "
|
||||
site_pv: "PV: "
|
||||
site_uv: UV
|
||||
site_pv: PV
|
|
@ -17,5 +17,5 @@ tag: 标签
|
|||
home: 首页
|
||||
archive: 归档
|
||||
about: 关于
|
||||
site_uv: 访问人数:
|
||||
site_pv: 总访问量:
|
||||
site_uv: 访问人数
|
||||
site_pv: 总访问量
|
|
@ -28,4 +28,6 @@
|
|||
<%- favicon_tag(theme.favicon) %>
|
||||
<% } %>
|
||||
<%- css('css/style') %>
|
||||
<%- export_config() %>
|
||||
</head>
|
||||
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
<div class="search-pop-overlay">
|
||||
<div class="popup search-popup">
|
||||
<div class="search-header">
|
||||
<span class="search-icon">
|
||||
<i class="fa fa-search"></i>
|
||||
</span>
|
||||
<div class="search-input-container">
|
||||
<input autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
placeholder="<%= __('search') %>"
|
||||
spellcheck="false"
|
||||
type="search"
|
||||
class="search-input"
|
||||
>
|
||||
</div>
|
||||
<span class="popup-btn-close">
|
||||
<i class="fa fa-times-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div id="search-result">
|
||||
<div id="no-result">
|
||||
<i class="fa fa-spinner fa-pulse fa-5x fa-fw"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -20,4 +20,8 @@
|
|||
</li>
|
||||
<% } %>
|
||||
</ul>
|
||||
<% if (theme.local_search.enable) { %>
|
||||
<button class="search-btn btn popup-trigger"><i class="fa fa-search"></i> <%= __('search') %></button>
|
||||
<%- next_js('local-search.js') %>
|
||||
<% } %>
|
||||
</nav>
|
|
@ -3,6 +3,12 @@
|
|||
<%- partial('common/head') %>
|
||||
<body>
|
||||
<%- body %>
|
||||
<div class="search-popup-container">
|
||||
<%- partial('common/local-search') %>
|
||||
</div>
|
||||
<%- partial('common/scripts') %>
|
||||
<% if (theme.local_search.enable) { %>
|
||||
<%- next_js('local-search.js') %>
|
||||
<% } %>
|
||||
</body>
|
||||
</html>
|
|
@ -1,7 +1,7 @@
|
|||
<div class="right-side-container">
|
||||
|
||||
<div class="nav-container animated">
|
||||
<%- partial('common/nav', { cache: true }) %>
|
||||
<%- partial('common/nav') %>
|
||||
</div>
|
||||
|
||||
<div class="article-post-container animated">
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
/* global hexo */
|
||||
|
||||
'use strict';
|
||||
|
||||
hexo.extend.helper.register('next_inject', function(point) {
|
||||
return hexo.theme.config.injects[point]
|
||||
.map(item => this.partial(item.layout, item.locals, item.options))
|
||||
.join('');
|
||||
});
|
||||
|
||||
hexo.extend.helper.register('next_js', function(...urls) {
|
||||
const { js } = hexo.theme.config;
|
||||
return urls.map(url => this.js(`${js}/${url}`)).join('');
|
||||
});
|
||||
|
||||
hexo.extend.helper.register('next_vendors', function(url) {
|
||||
if (url.startsWith('//')) return url;
|
||||
const internal = hexo.theme.config.vendors._internal;
|
||||
return this.url_for(`${internal}/${url}`);
|
||||
});
|
||||
|
||||
hexo.extend.helper.register('post_edit', function(src) {
|
||||
const theme = hexo.theme.config;
|
||||
if (!theme.post_edit.enable) return '';
|
||||
return this.next_url(theme.post_edit.url + src, '<i class="fa fa-pencil"></i>', {
|
||||
class: 'post-edit-link',
|
||||
title: this.__('post.edit')
|
||||
});
|
||||
});
|
||||
|
||||
hexo.extend.helper.register('post_nav', function(post) {
|
||||
const theme = hexo.theme.config;
|
||||
if (theme.post_navigation === false || (!post.prev && !post.next)) return '';
|
||||
const prev = theme.post_navigation === 'right' ? post.prev : post.next;
|
||||
const next = theme.post_navigation === 'right' ? post.next : post.prev;
|
||||
const left = prev ? `
|
||||
<a href="${this.url_for(prev.path)}" rel="prev" title="${prev.title}">
|
||||
<i class="fa fa-chevron-left"></i> ${prev.title}
|
||||
</a>` : '';
|
||||
const right = next ? `
|
||||
<a href="${this.url_for(next.path)}" rel="next" title="${next.title}">
|
||||
${next.title} <i class="fa fa-chevron-right"></i>
|
||||
</a>` : '';
|
||||
return `
|
||||
<div class="post-nav">
|
||||
<div class="post-nav-item">${left}</div>
|
||||
<div class="post-nav-item">${right}</div>
|
||||
</div>`;
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
/* global hexo */
|
||||
|
||||
'use strict';
|
||||
|
||||
const url = require('url');
|
||||
|
||||
/**
|
||||
* Export theme config to js
|
||||
*/
|
||||
hexo.extend.helper.register('export_config', function() {
|
||||
let { config, theme } = this;
|
||||
let exportConfig = {
|
||||
hostname : url.parse(config.url).hostname || config.url,
|
||||
root : config.root,
|
||||
localsearch: theme.local_search,
|
||||
themeName: theme.theme_name,
|
||||
themeVersion: theme.theme_version
|
||||
};
|
||||
if (config.search) {
|
||||
exportConfig.path = config.search.path;
|
||||
}
|
||||
return `<script id="hexo-configurations">
|
||||
let CONFIG = ${JSON.stringify(exportConfig)};
|
||||
</script>`;
|
||||
});
|
|
@ -0,0 +1,104 @@
|
|||
@require '../variables.styl'
|
||||
|
||||
$icon-size = 18px;
|
||||
|
||||
$keyword-red = #ff2a2a;
|
||||
|
||||
.search-pop-overlay {
|
||||
background: rgba(0, 0, 0, .3);
|
||||
display: none;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
|
||||
.search-popup {
|
||||
background: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
height: 80%;
|
||||
left: calc(50% - 350px);
|
||||
position: fixed;
|
||||
top: 10%;
|
||||
width: 700px;
|
||||
z-index: 1001;
|
||||
|
||||
.search-icon, .popup-btn-close {
|
||||
color: $default-font-color;
|
||||
font-size: $icon-size;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.popup-btn-close {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .fa {
|
||||
color: #222;
|
||||
}
|
||||
}
|
||||
|
||||
.search-header {
|
||||
background: #eee;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
input.search-input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.search-popup {
|
||||
.search-input-container {
|
||||
flex-grow: 1;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
ul.search-result-list {
|
||||
margin: 5px;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
p.search-result {
|
||||
border-bottom: 1px dashed #ccc;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
a.search-result-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.search-keyword {
|
||||
border-bottom: 1px dashed $keyword-red;
|
||||
color: $keyword-red;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#search-result {
|
||||
display: flex;
|
||||
height: calc(100% - 55px);
|
||||
overflow: auto;
|
||||
padding: 5px 25px;
|
||||
}
|
||||
|
||||
#no-result {
|
||||
color: #ccc;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
|
@ -39,6 +39,13 @@ $nav-font-size = 12px;
|
|||
}
|
||||
|
||||
|
||||
.search-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 20px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@media screen and (max-width: $media-max-width) {
|
||||
.fold-left-side-btn {
|
||||
display: none
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
.nav-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
width: 70%;
|
||||
height: $nav-height;
|
||||
z-index: 999;
|
||||
}
|
||||
|
@ -18,4 +18,14 @@
|
|||
background: $background-color;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $media-max-width) {
|
||||
|
||||
.nav-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -14,6 +14,7 @@
|
|||
@import "layout/tag-post.styl"
|
||||
@import "layout/common/site-info.styl"
|
||||
@import "layout/common/valine.styl"
|
||||
@import "layout/common/local-search.styl"
|
||||
@import "highlight"
|
||||
@import "layout/variables.styl"
|
||||
@import "markdown.styl"
|
||||
@import "markdown.styl"
|
|
@ -0,0 +1,297 @@
|
|||
/* global CONFIG */
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
// Popup Window
|
||||
let isfetched = false;
|
||||
let datas;
|
||||
let isXml = true;
|
||||
// Search DB path
|
||||
let searchPath = CONFIG.path;
|
||||
if (searchPath.length === 0) {
|
||||
searchPath = 'search.xml';
|
||||
} else if (searchPath.endsWith('json')) {
|
||||
isXml = false;
|
||||
}
|
||||
const input = document.querySelector('.search-input');
|
||||
const resultContent = document.getElementById('search-result');
|
||||
|
||||
// Ref: https://github.com/ForbesLindesay/unescape-html
|
||||
const unescapeHtml = html => {
|
||||
return String(html)
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, '\'')
|
||||
.replace(/:/g, ':')
|
||||
// Replace all the other &#x; chars
|
||||
.replace(/&#(\d+);/g, (m, p) => {
|
||||
return String.fromCharCode(p);
|
||||
})
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
};
|
||||
|
||||
const getIndexByWord = (word, text, caseSensitive) => {
|
||||
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 += `<b class="search-keyword">${text.substring(hit.position, end)}</b>`;
|
||||
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 - itemRight.word.length;
|
||||
});
|
||||
});
|
||||
|
||||
let slicesOfTitle = [];
|
||||
if (indexOfTitle.length !== 0) {
|
||||
let tmp = mergeIntoSlice(0, title.length, indexOfTitle, searchText);
|
||||
searchTextCount += tmp.searchTextCountInSlice;
|
||||
slicesOfTitle.push(tmp);
|
||||
}
|
||||
|
||||
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(CONFIG.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}" class="search-result-title">${highlightKeyword(title, slicesOfTitle[0])}</a>`;
|
||||
} else {
|
||||
resultItem += `<li><a href="${url}" class="search-result-title">${title}</a>`;
|
||||
}
|
||||
|
||||
slicesOfContent.forEach(slice => {
|
||||
resultItem += `<a href="${url}"><p class="search-result">${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 class="fa fa-search fa-5x"></i></div>';
|
||||
} else if (resultItems.length === 0) {
|
||||
resultContent.innerHTML = '<div id="no-result"><i class="fa fa-frown-o fa-5x"></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;
|
||||
});
|
||||
let searchResultList = '<ul class="search-result-list">';
|
||||
resultItems.forEach(result => {
|
||||
searchResultList += result.item;
|
||||
});
|
||||
searchResultList += '</ul>';
|
||||
resultContent.innerHTML = searchResultList;
|
||||
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, '') : '';
|
||||
if (CONFIG.localsearch.unescape) {
|
||||
data.content = unescapeHtml(data.content);
|
||||
}
|
||||
data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/');
|
||||
return data;
|
||||
});
|
||||
// Remove loading animation
|
||||
document.getElementById('no-result').innerHTML = '<i class="fa fa-search fa-5x"></i>';
|
||||
});
|
||||
};
|
||||
|
||||
if (CONFIG.localsearch.preload) {
|
||||
fetchData();
|
||||
}
|
||||
|
||||
if (CONFIG.localsearch.trigger === 'auto') {
|
||||
if (input) {
|
||||
input.addEventListener('input', inputEventFunction);
|
||||
}
|
||||
|
||||
} else {
|
||||
document.querySelector('.search-icon').addEventListener('click', inputEventFunction);
|
||||
input.addEventListener('keypress', event => {
|
||||
if (event.key === 'Enter') {
|
||||
inputEventFunction();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle and trigger popup window
|
||||
document.querySelectorAll('.popup-trigger').forEach(element => {
|
||||
element.addEventListener('click', () => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.querySelector('.search-pop-overlay').style.display = 'block';
|
||||
input.focus();
|
||||
if (!isfetched) fetchData();
|
||||
});
|
||||
});
|
||||
|
||||
// Monitor main search box
|
||||
const onPopupClose = () => {
|
||||
document.body.style.overflow = '';
|
||||
document.querySelector('.search-pop-overlay').style.display = '';
|
||||
};
|
||||
|
||||
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);
|
||||
window.addEventListener('pjax:success', onPopupClose);
|
||||
window.addEventListener('keyup', event => {
|
||||
if (event.key === 'Escape') {
|
||||
onPopupClose();
|
||||
}
|
||||
});
|
||||
});
|
|
@ -1 +1,3 @@
|
|||
console.log('hexo-theme-ils v0.1.0');
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
console.log(`${CONFIG.themeName} v${CONFIG.themeVersion}`);
|
||||
});
|
Loading…
Reference in New Issue