diff --git a/_config.yml b/_config.yml index f6955d7..a593141 100644 --- a/_config.yml +++ b/_config.yml @@ -71,4 +71,25 @@ busuanzi_count: # custom pv span for one page only page_pv: false page_pv_header: - page_pv_footer: \ No newline at end of file + 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 \ No newline at end of file diff --git a/languages/en.yml b/languages/en.yml index a8e05d0..2da9b17 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -17,5 +17,5 @@ tag: Tag home: Home archive: Archives about: About -site_uv: "UV: " -site_pv: "PV: " \ No newline at end of file +site_uv: UV +site_pv: PV \ No newline at end of file diff --git a/languages/zh-CN.yml b/languages/zh-CN.yml index e30d960..1117350 100644 --- a/languages/zh-CN.yml +++ b/languages/zh-CN.yml @@ -17,5 +17,5 @@ tag: 标签 home: 首页 archive: 归档 about: 关于 -site_uv: 访问人数: -site_pv: 总访问量: \ No newline at end of file +site_uv: 访问人数 +site_pv: 总访问量 \ No newline at end of file diff --git a/layout/common/head.ejs b/layout/common/head.ejs index 1e708bf..a6684cb 100644 --- a/layout/common/head.ejs +++ b/layout/common/head.ejs @@ -28,4 +28,6 @@ <%- favicon_tag(theme.favicon) %> <% } %> <%- css('css/style') %> + <%- export_config() %> + diff --git a/layout/common/local-search.ejs b/layout/common/local-search.ejs new file mode 100644 index 0000000..f4da160 --- /dev/null +++ b/layout/common/local-search.ejs @@ -0,0 +1,27 @@ +
+ +
\ No newline at end of file diff --git a/layout/common/nav.ejs b/layout/common/nav.ejs index b886f8b..b2f7b9a 100644 --- a/layout/common/nav.ejs +++ b/layout/common/nav.ejs @@ -20,4 +20,8 @@ <% } %> + <% if (theme.local_search.enable) { %> + + <%- next_js('local-search.js') %> + <% } %> \ No newline at end of file diff --git a/layout/layout.ejs b/layout/layout.ejs index 4c7ce5b..9c8f427 100644 --- a/layout/layout.ejs +++ b/layout/layout.ejs @@ -3,6 +3,12 @@ <%- partial('common/head') %> <%- body %> +
+ <%- partial('common/local-search') %> +
<%- partial('common/scripts') %> +<% if (theme.local_search.enable) { %> + <%- next_js('local-search.js') %> +<% } %> \ No newline at end of file diff --git a/layout/right-side.ejs b/layout/right-side.ejs index 6e31375..cd71b8d 100644 --- a/layout/right-side.ejs +++ b/layout/right-side.ejs @@ -1,7 +1,7 @@
diff --git a/scripts/helpers/engine.js b/scripts/helpers/engine.js new file mode 100644 index 0000000..a961625 --- /dev/null +++ b/scripts/helpers/engine.js @@ -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, '', { + 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 ? ` + ` : ''; + const right = next ? ` + ` : ''; + return ` +
+
${left}
+
${right}
+
`; +}); diff --git a/scripts/helpers/export-config.js b/scripts/helpers/export-config.js new file mode 100644 index 0000000..8c06341 --- /dev/null +++ b/scripts/helpers/export-config.js @@ -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 ``; +}); diff --git a/scripts/helper.js b/scripts/helpers/helper.js similarity index 100% rename from scripts/helper.js rename to scripts/helpers/helper.js diff --git a/source/css/layout/common/local-search.styl b/source/css/layout/common/local-search.styl new file mode 100644 index 0000000..5cdbb4f --- /dev/null +++ b/source/css/layout/common/local-search.styl @@ -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; + } +} \ No newline at end of file diff --git a/source/css/layout/common/nav.styl b/source/css/layout/common/nav.styl index 1238083..3a75fc5 100644 --- a/source/css/layout/common/nav.styl +++ b/source/css/layout/common/nav.styl @@ -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 diff --git a/source/css/layout/right-side.styl b/source/css/layout/right-side.styl index 5194ef2..9936c32 100644 --- a/source/css/layout/right-side.styl +++ b/source/css/layout/right-side.styl @@ -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%; + } + + + } + } \ No newline at end of file diff --git a/source/css/style.styl b/source/css/style.styl index febb7be..5fbf495 100644 --- a/source/css/style.styl +++ b/source/css/style.styl @@ -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" \ No newline at end of file diff --git a/source/js/local-search.js b/source/js/local-search.js new file mode 100644 index 0000000..86755c9 --- /dev/null +++ b/source/js/local-search.js @@ -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 += `${text.substring(hit.position, end)}`; + 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 += `
  • ${highlightKeyword(title, slicesOfTitle[0])}`; + } else { + resultItem += `
  • ${title}`; + } + + slicesOfContent.forEach(slice => { + resultItem += `

    ${highlightKeyword(content, slice)}...

    `; + }); + + resultItem += '
  • '; + resultItems.push({ + item: resultItem, + id : resultItems.length, + hitCount, + searchTextCount + }); + } + }); + } + if (keywords.length === 1 && keywords[0] === '') { + resultContent.innerHTML = '
    '; + } else if (resultItems.length === 0) { + resultContent.innerHTML = '
    '; + } 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 = ''; + 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 = ''; + }); + }; + + 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(); + } + }); +}); diff --git a/source/js/main.js b/source/js/main.js index 0132e81..97cf980 100644 --- a/source/js/main.js +++ b/source/js/main.js @@ -1 +1,3 @@ -console.log('hexo-theme-ils v0.1.0'); \ No newline at end of file +window.addEventListener('DOMContentLoaded', () => { + console.log(`${CONFIG.themeName} v${CONFIG.themeVersion}`); +}); \ No newline at end of file