feat: add search, close #43

This commit is contained in:
qingwei.li 2017-02-09 00:19:10 +08:00 committed by cinwell.li
parent 12e2479014
commit eb5ff3e987
10 changed files with 395 additions and 16 deletions

View File

@ -1,7 +1,9 @@
{
"extends": ["vue"],
"env": {
"browser": true
},
"globals": {
"XMLHttpRequest": true,
"__docsify__": true
"$docsify": true
}
}

View File

@ -2,6 +2,7 @@ var fs = require('fs')
var cssnano = require('cssnano').process
var resolve = require('path').resolve
var postcss = require('postcss')
var isProd = process.argv[process.argv.length - 1] !== '--dev'
var processor = postcss([require('postcss-salad')({
features: {
@ -34,7 +35,7 @@ list.forEach(function (file) {
.then(function (result) {
save(file, result.css)
console.log('salad - ' + file)
cssnano(loadLib(file))
isProd && cssnano(loadLib(file))
.then(function (result) {
saveMin(file, result.css)
console.log('cssnao - ' + file)

View File

@ -3,6 +3,7 @@ var buble = require('rollup-plugin-buble')
var commonjs = require('rollup-plugin-commonjs')
var nodeResolve = require('rollup-plugin-node-resolve')
var uglify = require('rollup-plugin-uglify')
var isProd = process.argv[process.argv.length - 1] !== '--dev'
var build = function (opts) {
rollup
@ -16,7 +17,7 @@ var build = function (opts) {
console.log(dest)
bundle.write({
format: 'iife',
moduleName: opts.moduleName || 'Docsify',
moduleName: opts.moduleName || 'D',
dest: dest
})
})
@ -30,8 +31,19 @@ build({
output: 'docsify.js',
plugins: [commonjs(), nodeResolve()]
})
build({
isProd && build({
entry: 'index.js',
output: 'docsify.min.js',
plugins: [commonjs(), nodeResolve(), uglify()]
})
build({
entry: 'plugins/search.js',
output: 'plugins/search.js',
moduleName: 'D.Search'
})
isProd && build({
entry: 'plugins/search.js',
output: 'plugins/search.min.js',
moduleName: 'D.Search',
plugins: [uglify()]
})

View File

@ -6,7 +6,24 @@
<link rel="stylesheet" href="/themes/vue.css">
</head>
<body>
<nav>
<a href="#/">En</a>
<a href="#/zh-cn">中文</a>
</nav>
<div id="app"></div>
</body>
<script src="/lib/docsify.js" data-repo="qingwei-li/docsify" data-name="docsify" data-auto2top></script>
<script>
window.$docsify = {
search: {
maxAge: 0
}
}
</script>
<script
src="/lib/docsify.js"
data-repo="qingwei-li/docsify"
data-name="docsify"
data-base-path="docs/"
data-auto2top></script>
<script src="/lib/plugins/search.js"></script>
</html>

View File

@ -10,7 +10,8 @@
],
"scripts": {
"build": "rm -rf lib themes && node build/build.js && mkdir lib/themes && mkdir themes && node build/build-css.js",
"dev": "node app.js & nodemon -w src -e js,css --exec 'npm run build'",
"dev:build": "rm -rf lib themes && mkdir themes && node build/build.js --dev && node build/build-css.js --dev",
"dev": "node app.js & nodemon -w src -e js,css --exec 'npm run dev:build'",
"test": "eslint src test"
},
"repository": {

View File

@ -9,7 +9,7 @@ export function scrollActiveSidebar () {
let hoveredOverSidebar = false
const anchors = document.querySelectorAll('.anchor')
const sidebar = document.querySelector('.sidebar>div')
const sidebar = document.querySelector('.sidebar')
const sidebarHeight = sidebar.clientHeight
const nav = {}

332
src/plugins/search.js Normal file
View File

@ -0,0 +1,332 @@
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 = {
'&': '&amp;',
'<': '&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) {
const 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] = {}
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 {
// 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 input = document.querySelector('.search input')
const panel = document.querySelector('.results-panel')
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 matchingNum = 0
let resultStr = ''
const postTitle = post.title && post.title.trim()
const postContent = post.body && post.body.trim()
const postUrl = post.slug || ''
const postType = post.pagetitle
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
matchingNum++
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,
type: postType,
matchingNum: matchingNum
}
matchingResults.push(matchingPost)
}
}
}
return matchingResults
}
}
// TODO 如果不存在就重新加载
const searchPlugin = function () {
if (localStorage.getItem('docsify.search.expires') > Date.now()) {
INDEXS = JSON.parse(localStorage.getItem('docsify.search.index'))
return
}
const paths = CONFIG.paths === 'auto' ? getAllPaths() : CONFIG.paths
const len = paths.length
const { load, marked, slugify } = window.Docsify.utils
let count = 0
const done = () => {
localStorage.setItem('docsify.search.expires', Date.now() + CONFIG.maxAge)
localStorage.setItem('docsify.search.index', JSON.stringify(INDEXS))
}
paths.forEach(path => {
load(genFilePath(path)).then(content => {
genIndex(path, marked(content))
slugify.clear()
count++
if (len === count) done()
})
})
}
const install = function () {
if (!window.Docsify || !window.Docsify.installed) {
console.error('[Docsify] Please load docsify.js first.')
return
}
window.$docsify.plugins = [].concat(window.$docsify.plugins, searchPlugin)
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
}
new SearchComponent()
}
export default install()

View File

@ -68,7 +68,11 @@ export function init () {
markdown = text => emojify(md(text))
window.Docsify.utils.marked = markdown
window.Docsify.utils.marked = text => {
const result = markdown(text)
toc = []
return result
}
}
/**
@ -76,17 +80,19 @@ export function init () {
*/
export function renderApp (dom, replace) {
const nav = document.querySelector('nav') || document.createElement('nav')
const body = document.body
const head = document.head
if (!$docsify.repo) nav.classList.add('no-badge')
dom[replace ? 'outerHTML' : 'innerHTML'] = tpl.corner($docsify.repo) +
($docsify.coverpage ? tpl.cover() : '') +
tpl.main()
document.body.insertBefore(nav, document.body.children[0])
body.insertBefore(nav, body.children[0])
// theme color
if ($docsify.themeColor) {
document.head.innerHTML += tpl.theme($docsify.themeColor)
head.innerHTML += tpl.theme($docsify.themeColor)
polyfill.cssVars()
}
@ -102,7 +108,7 @@ export function renderApp (dom, replace) {
if ($docsify.coverpage) {
!isMobile() && window.addEventListener('scroll', event.sticky)
} else {
document.body.classList.add('sticky')
body.classList.add('sticky')
}
}
@ -156,9 +162,8 @@ export function renderSidebar (content) {
html = tpl.tree(genTree(toc, $docsify.maxLevel), '<ul>')
}
console.log(html)
renderTo('.sidebar>div', html)
const target = event.activeLink('.sidebar>div', true)
renderTo('.sidebar-nav', html)
const target = event.activeLink('.sidebar-nav', true)
if (target) renderSubSidebar(target)
toc = []

View File

@ -24,6 +24,15 @@
z-index: 999999;
}
.search a:hover {
color: var(--theme-color, $color-primary);
}
.search .search-keyword {
color: var(--theme-color, $color-primary);
font-style: normal;
}
html, body {
height: 100%;
}

View File

@ -23,7 +23,7 @@ export function corner (data) {
* Render main content
*/
export function main () {
const aside = `${toggle()}<aside class="sidebar"><div></div></aside>`
const aside = `${toggle()}<aside class="sidebar"><div class="sidebar-nav"></div></aside>`
return (isMobile() ? `${aside}<main>` : `<main>${aside}`) +
`<section class="content">