From eb5ff3e987f0275f6e2ddcedfa57768f2976eae1 Mon Sep 17 00:00:00 2001 From: "qingwei.li" Date: Thu, 9 Feb 2017 00:19:10 +0800 Subject: [PATCH] feat: add search, close #43 --- .eslintrc | 6 +- build/build-css.js | 3 +- build/build.js | 16 +- dev.html | 19 +- package.json | 3 +- src/event.js | 2 +- src/plugins/search.js | 332 +++++++++++++++++++++++++++++++++++ src/render.js | 19 +- src/themes/basic/_layout.css | 9 + src/tpl.js | 2 +- 10 files changed, 395 insertions(+), 16 deletions(-) create mode 100644 src/plugins/search.js diff --git a/.eslintrc b/.eslintrc index a88323e16..527ed9baa 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,7 +1,9 @@ { "extends": ["vue"], + "env": { + "browser": true + }, "globals": { - "XMLHttpRequest": true, - "__docsify__": true + "$docsify": true } } diff --git a/build/build-css.js b/build/build-css.js index 15af1d8e2..40672e05b 100644 --- a/build/build-css.js +++ b/build/build-css.js @@ -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) diff --git a/build/build.js b/build/build.js index f8a69229c..62314e585 100644 --- a/build/build.js +++ b/build/build.js @@ -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()] +}) diff --git a/dev.html b/dev.html index 8b2b27c8c..11faff777 100644 --- a/dev.html +++ b/dev.html @@ -6,7 +6,24 @@ +
- + + + diff --git a/package.json b/package.json index fe0b2795d..a22a0bc5a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/event.js b/src/event.js index 6fb35cf03..f9dca01c5 100644 --- a/src/event.js +++ b/src/event.js @@ -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 = {} diff --git a/src/plugins/search.js b/src/plugins/search.js new file mode 100644 index 000000000..569bdd573 --- /dev/null +++ b/src/plugins/search.js @@ -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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/' + } + + 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(/]*?>[\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)) { + //

+ 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 = `
` + } + + 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 += ` +
+

${post.title}

+

${post.content}

+
+ ` + }) + if (panel.classList.contains('results-panel')) { + panel.classList.add('show') + panel.innerHTML = html || '

No Results!

' + } + } 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, `${keyword}`) + + '...' + + 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() diff --git a/src/render.js b/src/render.js index 3decfaf08..f23358052 100644 --- a/src/render.js +++ b/src/render.js @@ -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), '