refactor(plugins): update search plugin

This commit is contained in:
qingwei.li 2017-02-19 10:47:47 +08:00 committed by cinwell.li
commit 079bd00395
22 changed files with 348 additions and 393 deletions

View file

@ -1,5 +1,4 @@
// From https://github.com/egoist/vue-ga/blob/master/src/index.js
function appendScript () {
const script = document.createElement('script')
script.async = true
@ -24,19 +23,13 @@ function collect () {
window.ga('send', 'pageview')
}
const install = function () {
if (install.installed) return
install.installed = true
const install = function (hook) {
if (!window.$docsify.ga) {
console.error('[Docsify] ga is required.')
return
}
window.$docsify.plugins = [].concat(function (hook) {
hook.init(collect)
hook.beforeEach(collect)
}, window.$docsify.plugins)
hook.beforeEach(collect)
}
export default install()
window.$docsify.plugins = [].concat(install, window.$docsify.plugins)

View file

@ -1,349 +0,0 @@
let INDEXS = {}
const CONFIG = {
placeholder: 'Type to search',
paths: 'auto',
maxAge: 86400000 // 1 day
}
const isObj = function (obj) {
return Object.prototype.toString.call(obj) === '[object Object]'
}
const escapeHtml = function (string) {
const entityMap = {
'&': '&',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;'
}
return String(string).replace(/[&<>"'\/]/g, s => entityMap[s])
}
/**
* find all filepath from A tag
*/
const getAllPaths = function () {
const paths = []
;[].slice.call(document.querySelectorAll('a'))
.map(node => {
const href = node.href
if (/#\/[^#]*?$/.test(href)) {
const path = href.replace(/^[^#]+#/, '')
if (paths.indexOf(path) <= 0) paths.push(path)
}
})
return paths
}
/**
* return file path
*/
const genFilePath = function (path, basePath = window.$docsify.basePath) {
let filePath = /\/$/.test(path) ? `${path}README.md` : `${path}.md`
filePath = basePath + filePath
return filePath.replace(/\/+/g, '/')
}
/**
* generate index
*/
const genIndex = function (path, content = '') {
INDEXS[path] = { slug: '', title: '', body: '' }
let slug
content
// remove PRE and TEMPLATE tag
.replace(/<template[^>]*?>[\s\S]+?<\/template>/g, '')
// find all html tag
.replace(/<(\w+)([^>]*?)>([\s\S]+?)<\//g, (match, tag, attr, html) => {
// remove all html tag
const text = html.replace(/<[^>]+>/g, '')
// tag is headline
if (/^h\d$/.test(tag)) {
// <h1 id="xxx"></h1>
const id = attr.match(/id="(\S+)"/)[1]
slug = `#/${path}#${id}`.replace(/\/+/, '/')
INDEXS[slug] = { slug, title: text, body: '' }
} else {
if (!slug) return
// other html tag
if (!INDEXS[slug]) {
INDEXS[slug] = {}
} else {
if (INDEXS[slug].body && INDEXS[slug].body.length) {
INDEXS[slug].body += '\n' + text
} else {
INDEXS[slug].body = text
}
}
}
})
}
/**
* component
*/
class SearchComponent {
constructor () {
if (this.rendered) return
this.style()
const el = document.createElement('div')
const aside = document.querySelector('aside')
el.classList.add('search')
aside.insertBefore(el, aside.children[0])
this.render(el)
this.rendered = true
this.bindEvent()
}
style () {
const code = `
.sidebar {
padding-top: 0;
}
.search {
margin-bottom: 20px;
padding: 6px;
border-bottom: 1px solid #eee;
}
.search .results-panel {
display: none;
}
.search .results-panel.show {
display: block;
}
.search input {
outline: none;
border: none;
width: 100%;
padding: 7px;
line-height: 22px;
font-size: 14px;
}
.search h2 {
font-size: 17px;
margin: 10px 0;
}
.search a {
text-decoration: none;
color: inherit;
}
.search .matching-post {
border-bottom: 1px solid #eee;
}
.search .matching-post:last-child {
border-bottom: 0;
}
.search p {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.search p.empty {
text-align: center;
}
`
const style = document.createElement('style')
style.innerHTML = code
document.head.appendChild(style)
}
render (dom) {
dom.innerHTML = `<input type="search" placeholder="${CONFIG.placeholder}" /><div class="results-panel"></div>`
}
bindEvent () {
const search = document.querySelector('.search')
const input = search.querySelector('.search input')
const panel = search.querySelector('.results-panel')
search.addEventListener('click', e => e.target.tagName !== 'A' && e.stopPropagation())
input.addEventListener('input', e => {
const target = e.target
if (target.value.trim() !== '') {
const matchingPosts = this.search(target.value)
let html = ''
matchingPosts.forEach(function (post, index) {
html += `
<div class="matching-post">
<h2><a href="${post.url}">${post.title}</a></h2>
<p>${post.content}</p>
</div>
`
})
if (panel.classList.contains('results-panel')) {
panel.classList.add('show')
panel.innerHTML = html || '<p class="empty">No Results!</p>'
}
} else {
if (panel.classList.contains('results-panel')) {
panel.classList.remove('show')
panel.innerHTML = ''
}
}
})
}
// From [weex website] https://weex-project.io/js/common.js
search (keywords) {
const matchingResults = []
const data = Object.keys(INDEXS).map(key => INDEXS[key])
keywords = keywords.trim().split(/[\s\-\\\/]+/)
for (let i = 0; i < data.length; i++) {
const post = data[i]
let isMatch = false
let resultStr = ''
const postTitle = post.title && post.title.trim()
const postContent = post.body && post.body.trim()
const postUrl = post.slug || ''
if (postTitle !== '' && postContent !== '') {
keywords.forEach((keyword, i) => {
const regEx = new RegExp(keyword, 'gi')
let indexTitle = -1
let indexContent = -1
indexTitle = postTitle.search(regEx)
indexContent = postContent.search(regEx)
if (indexTitle < 0 && indexContent < 0) {
isMatch = false
} else {
isMatch = true
if (indexContent < 0) indexContent = 0
let start = 0
let end = 0
start = indexContent < 11 ? 0 : indexContent - 10
end = start === 0 ? 70 : indexContent + keyword.length + 60
if (end > postContent.length) end = postContent.length
const matchContent = '...' +
postContent
.substring(start, end)
.replace(regEx, `<em class="search-keyword">${keyword}</em>`) +
'...'
resultStr += matchContent
}
})
if (isMatch) {
const matchingPost = {
title: escapeHtml(postTitle),
content: resultStr,
url: postUrl
}
matchingResults.push(matchingPost)
}
}
}
return matchingResults
}
}
const searchPlugin = function () {
const isAuto = CONFIG.paths === 'auto'
const isExpired = localStorage.getItem('docsify.search.expires') < Date.now()
INDEXS = JSON.parse(localStorage.getItem('docsify.search.index'))
if (isExpired) {
INDEXS = {}
} else if (!isAuto) {
return
}
let count = 0
const paths = isAuto ? getAllPaths() : CONFIG.paths
const len = paths.length
const { load, marked, slugify } = window.Docsify.utils
const alias = window.$docsify.alias
const done = () => {
localStorage.setItem('docsify.search.expires', Date.now() + CONFIG.maxAge)
localStorage.setItem('docsify.search.index', JSON.stringify(INDEXS))
}
paths.forEach(path => {
if (INDEXS[path]) return count++
let route
// replace route
if (alias && alias[path]) {
route = genFilePath(alias[path] || path, '')
} else {
route = genFilePath(path)
}
load(route).then(content => {
genIndex(path, marked(content))
slugify.clear()
count++
if (len === count) done()
})
})
}
const install = function () {
if (install.installed) return
install.installed = true
const userConfig = window.$docsify.search
const isNil = window.Docsify.utils.isNil
if (Array.isArray(userConfig)) {
CONFIG.paths = userConfig
} else if (isObj(userConfig)) {
CONFIG.paths = Array.isArray(userConfig.paths) ? userConfig.paths : 'auto'
CONFIG.maxAge = isNil(userConfig.maxAge) ? CONFIG.maxAge : userConfig.maxAge
CONFIG.placeholder = userConfig.placeholder || CONFIG.placeholder
}
window.$docsify.plugins = [].concat(hook => {
const isAuto = CONFIG.paths === 'auto'
hook.ready(() => {
new SearchComponent()
!isAuto && searchPlugin()
})
isAuto && hook.doneEach(searchPlugin)
}, window.$docsify.plugins)
}
export default install()

View file

@ -0,0 +1,116 @@
import { search } from './search'
let dom
function style () {
const code = `
.sidebar {
padding-top: 0;
}
.search {
margin-bottom: 20px;
padding: 6px;
border-bottom: 1px solid #eee;
}
.search .results-panel {
display: none;
}
.search .results-panel.show {
display: block;
}
.search input {
outline: none;
border: none;
width: 100%;
padding: 7px;
line-height: 22px;
font-size: 14px;
}
.search h2 {
font-size: 17px;
margin: 10px 0;
}
.search a {
text-decoration: none;
color: inherit;
}
.search .matching-post {
border-bottom: 1px solid #eee;
}
.search .matching-post:last-child {
border-bottom: 0;
}
.search p {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.search p.empty {
text-align: center;
}`
const style = dom.create('style', code)
dom.appendTo(dom.head, style)
}
function tpl (opts) {
const html =
`<input type="search" placeholder="${opts.placeholder}" />` +
'<div class="results-panel"></div>' +
'</div>'
const el = dom.create('div', html)
const aside = dom.find('aside')
dom.toggleClass(el, 'search')
dom.before(aside, el)
}
function bindEvents () {
const $search = dom.find('div.search')
const $input = dom.find($search, 'input')
const $panel = dom.find($search, '.results-panel')
// Prevent to Fold sidebar
dom.on($search, 'click',
e => e.target.tagName !== 'A' && e.stopPropagation())
dom.on($input, 'input', e => {
const value = e.target.value.trim()
if (!value) {
$panel.classList.remove('show')
$panel.innerHTML = ''
}
const matchs = search(value)
let html = ''
matchs.forEach(post => {
html += `<div class="matching-post">
<h2><a href="${post.url}">${post.title}</a></h2>
<p>${post.content}</p>
</div>`
})
$panel.classList.add('show')
$panel.innerHTML = html || '<p class="empty">No Results!</p>'
})
}
export default function (opts) {
dom = Docsify.dom
style()
tpl(opts)
bindEvents()
}

View file

@ -0,0 +1,31 @@
import initComponet from './component'
import { init as initSearch } from './search'
const CONFIG = {
placeholder: 'Type to search',
paths: 'auto',
maxAge: 86400000 // 1 day
}
const install = function (hook, vm) {
const util = Docsify.util
const opts = vm.config.search
if (Array.isArray(opts)) {
CONFIG.paths = opts
} else if (typeof opts === 'object') {
CONFIG.paths = Array.isArray(opts.paths) ? opts.paths : 'auto'
CONFIG.maxAge = util.isPrimitive(opts.maxAge) ? opts.maxAge : CONFIG.maxAge
CONFIG.placeholder = opts.placeholder || CONFIG.placeholder
}
const isAuto = CONFIG.paths === 'auto'
hook.ready(_ => {
initComponet(CONFIG)
isAuto && initSearch(CONFIG, vm)
})
!isAuto && hook.doneEach(_ => initSearch(CONFIG, vm))
}
window.$docsify.plugins = [].concat(install, window.$docsify.plugins)

View file

@ -0,0 +1,156 @@
let INDEXS = {}
let helper
function escapeHtml (string) {
const entityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'\'': '&#39;',
'/': '&#x2F;'
}
return String(string).replace(/[&<>"'\/]/g, s => entityMap[s])
}
function getAllPaths () {
const paths = []
helper.dom.findAll('a')
.map(node => {
const href = node.href
const originHref = node.getAttribute('href')
const path = helper.route.parse(href).path
if (paths.indexOf(path) === -1 &&
!helper.route.isAbsolutePath(originHref)) {
paths.push(path)
}
})
return paths
}
function saveData (maxAge) {
localStorage.setItem('docsify.search.expires', Date.now() + maxAge)
localStorage.setItem('docsify.search.index', JSON.stringify(INDEXS))
}
export function genIndex (path, content = '') {
const tokens = window.marked.lexer(content)
const toURL = Docsify.route.toURL
let slug
tokens.forEach(token => {
if (token.type === 'heading' && token.depth === 1) {
slug = toURL(path, { id: token.text })
INDEXS[slug] = { slug, title: token.text, body: '' }
} else {
if (!slug) return
if (!INDEXS[slug]) {
INDEXS[slug] = { slug, title: '', body: '' }
} else {
if (INDEXS[slug].body) {
INDEXS[slug].body += ('\n' + token.text)
} else {
INDEXS[slug].body = token.text
}
}
}
})
}
export function search (keywords) {
const matchingResults = []
const data = Object.keys(INDEXS).map(key => INDEXS[key])
keywords = keywords.trim().split(/[\s\-\\\/]+/)
for (let i = 0; i < data.length; i++) {
const post = data[i]
let isMatch = false
let resultStr = ''
const postTitle = post.title && post.title.trim()
const postContent = post.body && post.body.trim()
const postUrl = post.slug || ''
if (postTitle !== '' && postContent !== '') {
keywords.forEach((keyword, i) => {
const regEx = new RegExp(keyword, 'gi')
let indexTitle = -1
let indexContent = -1
indexTitle = postTitle.search(regEx)
indexContent = postContent.search(regEx)
if (indexTitle < 0 && indexContent < 0) {
isMatch = false
} else {
isMatch = true
if (indexContent < 0) indexContent = 0
let start = 0
let end = 0
start = indexContent < 11 ? 0 : indexContent - 10
end = start === 0 ? 70 : indexContent + keyword.length + 60
if (end > postContent.length) end = postContent.length
const matchContent = '...' +
postContent
.substring(start, end)
.replace(regEx, `<em class="search-keyword">${keyword}</em>`) +
'...'
resultStr += matchContent
}
})
if (isMatch) {
const matchingPost = {
title: escapeHtml(postTitle),
content: resultStr,
url: postUrl
}
matchingResults.push(matchingPost)
}
}
}
return matchingResults
}
export function init (config, vm) {
helper = Docsify
const isAuto = config.paths === 'auto'
const isExpired = localStorage.getItem('docsify.search.expires') < Date.now()
INDEXS = JSON.parse(localStorage.getItem('docsify.search.index'))
if (isExpired) {
INDEXS = {}
} else if (!isAuto) {
return
}
const paths = isAuto ? getAllPaths() : config.paths
const len = paths.length
let count = 0
paths.forEach(path => {
if (INDEXS[path]) return count++
path = vm.$getFile(path)
helper
.get(path)
.then(result => {
genIndex(path, result)
len === ++count && saveData(config.maxAge)
})
})
}