docsify(三):新增检索高亮功能

相关工程部署可参考docsify(一)https://blog.csdn.net/u598975767/article/details/122576505docsify(二)https://blog.csdn.net/u598975767/article/details/122746132

先说一下改动前docsify自带的关键词检索功能:

1. 支持多个关键词同时检索,各关键词以空格、\等符号分开。此功能bug较多,会导致检索结果混乱。

2. 检索结果链接到落地页后,不支持高亮显示,不直观。

3. 原来的检索是基于markdown文件源码的搜索,导致源代码中的html标签、markdown标签等也会被命中。例如搜索“h1”,在检索的导航面板中会显示很多匹配到的文件,点击该文件导航后,打开的文件内容中并没有"h1"字符。(因为匹配到的只是源码字符串,而此时源码已经被浏览器解析了),再说了看个文档,也没人想去搜索其源代码里的字符串吧~~~

4. 输入特殊字符,如<、>、?、#、/、等等,会导致搜索结果混乱,因为这些特殊字符在代码解析过程中会出现转义、混淆等一系列问题。

再列一下新增的功能,以及问题修复:

1. 禁用纯特殊字符的检索;

2. 检索时忽略html源码、markdown源码等,只对文本检索;

3. 禁用多关键词检索;

4. 检索结果导航到具体页面时,支持高亮显示。

下面上代码喽:

相关文件三个:

search.js  源文件  做了部分调整,已添加中文注释;

hightlight.js 新增文件  重要逻辑已添加中文注释;

index.html  入口文件  仅添加了关键词高亮的样式和对highlight.js文件的引入。

search.js

(function () {
  var INDEXS = {};

  var LOCAL_STORAGE = {
    EXPIRE_KEY: 'docsify.search.expires',
    INDEX_KEY: 'docsify.search.index'
  };

  function resolveExpireKey(namespace) {
    return namespace ? ((LOCAL_STORAGE.EXPIRE_KEY) + "/" + namespace) : LOCAL_STORAGE.EXPIRE_KEY
  }
  function resolveIndexKey(namespace) {
    return namespace ? ((LOCAL_STORAGE.INDEX_KEY) + "/" + namespace) : LOCAL_STORAGE.INDEX_KEY
  }

  function escapeHtml(string) {
    //放开此方法 会导致搜索结果的content中出现html原生标签
    return string
    var entityMap = {
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      '\'': '&#39;',
      '/': '&#x2F;'
    };

    return String(string).replace(/[&<>"'/]/g, function (s) { return entityMap[s]; })
  }

  function getAllPaths(router) {
    var paths = [];

    Docsify.dom.findAll('.sidebar-nav a:not(.section-link):not([data-nosearch])').forEach(function (node) {
      var href = node.href;
      var originHref = node.getAttribute('href');
      var path = router.parse(href).path;

      if (
        path &&
        paths.indexOf(path) === -1 &&
        !Docsify.util.isAbsolutePath(originHref)
      ) {
        paths.push(path);
      }
    });

    return paths
  }

  function saveData(maxAge, expireKey, indexKey) {
    localStorage.setItem(expireKey, Date.now() + maxAge);
    localStorage.setItem(indexKey, JSON.stringify(INDEXS));
  }

  function genIndex(path, content, router, depth) {
    if (content === void 0) content = '';

    var tokens = window.marked.lexer(content);
    var slugify = window.Docsify.slugify;
    var index = {};
    var slug;
    tokens.forEach(function (token) {
      if (token.type === 'heading' && token.depth <= depth) {
        slug = router.toURL(path, { id: slugify(token.text) });
        index[slug] = { slug: slug, title: token.text, body: '' };
      } else {
        if (!slug) {
          return
        }
        if (!index[slug]) {
          index[slug] = { slug: slug, title: '', body: '' };
        } else if (index[slug].body) {
          index[slug].body += '\n' + (token.text || '');
        } else {
          index[slug].body = token.text;
        }
      }
    });
    slugify.clear();
    return index
  }
  /**
   * 此方法 用途是去掉字符串中的所有html原生代码  只保留真实内容  用作搜索匹配
   * @param {*} str 
   * @returns 
   */
  function getText(str) {
    return str ? $("<span>" + str + "</span>").text() : ''

  }
  /**
   * @param {String} query
   * @returns {Array}
   */
  function search(query) {
    var matchingResults = [];
    var data = [];

    Object.keys(INDEXS).forEach(function (key) {
      data = data.concat(Object.keys(INDEXS[key]).map(function (page) { return INDEXS[key][page]; }));
    });

    query = query.trim();
    //禁止检索多个关键字  此功能放开需要重新调整 main内容高亮的逻辑 hightlight.js
    //var keywords = query.split(/[\s\-,\\/]+/);
    var keywords = [query]
    if (keywords.length !== 1) {
      keywords = [].concat(query, keywords);
    }

    var loop = function (i) {
      var post = data[i];
      var isMatch = false;
      var resultStr = '';
      var postTitle = post.title && post.title.trim();
      var postContent = post.body && post.body.trim();
      var postUrl = post.slug || '';
      if (postTitle && postContent) {
        keywords.forEach(function (keyword) {
          var regEx = new RegExp(
            keyword.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'),
            'gi'
          );
          var indexTitle = -1;
          var indexContent = -1;
          //这里调用getText  是为了忽略字符串中html源代码
          if (postTitle) {
            postTitle = getText(postTitle)
            indexTitle = postTitle.search(regEx);
          }
          if (postContent) {
            // 屏蔽markdown中的链接
            postContent = getText(postContent).replace(/(\]\([^\)]*\))/g, ']')
            // indexContent = postContent.search(regEx);
            indexContent = postContent.search(regEx);
          }

          if (indexTitle < 0 && indexContent < 0) {
            isMatch = false;
          } else {
            isMatch = true;
            if (indexContent < 0) {
              indexContent = 0;
            }

            var start = 0;
            var end = 0;

            //显示前10个字符 + keywords + 后60个字符    如果在开始位  keywords +后70个字符
            start = indexContent < 11 ? 0 : indexContent - 10;
            end = start === 0 ? 70 : indexContent + keyword.length + 60;

            if (end > postContent.length) {
              end = postContent.length;
            }

            var matchContent =
              '...' +
              escapeHtml(postContent)
                .substring(start, end)
                .replace(regEx, ("<em class=\"search-keyword\">" + keyword + "</em>")) +
              '...';
            postTitle = postTitle.replace(regEx, ("<em class=\"search-keyword\">" + keyword + "</em>"))

            resultStr += matchContent;
          }
        });

        if (isMatch) {
          var matchingPost = {
            title: escapeHtml(postTitle),
            content: resultStr,
            url: postUrl
          };

          matchingResults.push(matchingPost);
        }
      }
    };

    for (var i = 0; i < data.length; i++) loop(i);

    return matchingResults
  }

  function init$1(config, vm) {
    var isAuto = config.paths === 'auto';

    var expireKey = resolveExpireKey(config.namespace);
    var indexKey = resolveIndexKey(config.namespace);

    var isExpired = localStorage.getItem(expireKey) < Date.now();

    INDEXS = JSON.parse(localStorage.getItem(indexKey)) || {};
    if (isExpired) {
      INDEXS = {};
    } else if (!isAuto) {
      return
    }
    var paths = isAuto ? getAllPaths(vm.router) : config.paths;
    var len = paths.length;
    var count = 0;
    var temp = []
    paths.forEach(function (path) {
      if (INDEXS[path]) {
        return count++
      }
      temp.push(new Promise((resolve, reject) => {
        Docsify.get(vm.router.getFile(path), false, vm.config.requestHeaders).then(function (reslut) {
          resolve({ path, reslut })
        }, reject)
      }))
    });
    //待所有请求均完成以后再执行回调 否则后续INDEXS读取不完整
    Promise.all(temp).then(result => {
      result.forEach(function (res) {
        INDEXS[res.path] = genIndex(res.path, res.reslut, vm.router, config.depth);
        len === ++count && saveData(config.maxAge, expireKey, indexKey);
      })
    }).catch((error) => {
      console.log(error)
    })
  }
  var NO_DATA_TEXT = '';
  var options;

  function style() {
    var code = "\n.sidebar {\n  padding-top: 0;\n}\n\n.search {\n  margin-bottom: 20px;\n  padding: 6px;\n  border-bottom: 1px solid #eee;\n}\n\n.search .input-wrap {\n  display: flex;\n  align-items: center;\n}\n\n.search .results-panel {\n  display: none;\n}\n\n.search .results-panel.show {\n  display: block;\n}\n\n.search input {\n  outline: none;\n  border: none;\n  width: 100%;\n  padding: 0 7px;\n  line-height: 36px;\n  font-size: 14px;\n}\n\n.search input::-webkit-search-decoration,\n.search input::-webkit-search-cancel-button,\n.search input {\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  appearance: none;\n}\n.search .clear-button {\n  width: 36px;\n  text-align: right;\n  display: none;\n}\n\n.search .clear-button.show {\n  display: block;\n}\n\n.search .clear-button svg {\n  transform: scale(.5);\n}\n\n.search h2 {\n  font-size: 17px;\n  margin: 10px 0;\n}\n\n.search a {\n  text-decoration: none;\n  color: inherit;\n}\n\n.search .matching-post {\n  border-bottom: 1px solid #eee;\n}\n\n.search .matching-post:last-child {\n  border-bottom: 0;\n}\n\n.search p {\n  font-size: 14px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n}\n\n.search p.empty {\n  text-align: center;\n}\n\n.app-name.hide, .sidebar-nav.hide {\n  display: none;\n}";

    Docsify.dom.style(code);
  }

  function tpl(defaultValue) {
    if (defaultValue === void 0) defaultValue = '';

    var html =
      "<div class=\"input-wrap\">\n      <input type=\"search\" value=\"" + defaultValue + "\" />\n      <div class=\"clear-button\">\n        <svg width=\"26\" height=\"24\">\n          <circle cx=\"12\" cy=\"12\" r=\"11\" fill=\"#ccc\" />\n          <path stroke=\"white\" stroke-width=\"2\" d=\"M8.25,8.25,15.75,15.75\" />\n          <path stroke=\"white\" stroke-width=\"2\"d=\"M8.25,15.75,15.75,8.25\" />\n        </svg>\n      </div>\n    </div>\n    <div class=\"results-panel\"></div>\n    </div>";
    var el = Docsify.dom.create('div', html);
    var aside = Docsify.dom.find('aside');

    Docsify.dom.toggleClass(el, 'search');
    Docsify.dom.before(aside, el);
  }

  function doSearch(value) {
    var reg = /^[`~!@#$%^&*()_\-+=<>?:"{}|,.\/;'\\[\]·~!@#¥%……&*()——\-+={}|《》?:“”【】、;‘',。、]+$/
    // 禁止检索纯特殊字符
    if (value && reg.test(value)) {
      return false
    }
    var $search = Docsify.dom.find('div.search');
    var $panel = Docsify.dom.find($search, '.results-panel');
    var $clearBtn = Docsify.dom.find($search, '.clear-button');
    var $sidebarNav = Docsify.dom.find('.sidebar-nav');
    var $appName = Docsify.dom.find('.app-name');

    if (!value) {
      $panel.classList.remove('show');
      $clearBtn.classList.remove('show');
      $panel.innerHTML = '';

      if (options.hideOtherSidebarContent) {
        $sidebarNav.classList.remove('hide');
        $appName.classList.remove('hide');
      }
      return
    }
    var matchs = search(value);
    var html = '';
    matchs.forEach(function (post) {
      html += "<div class=\"matching-post\">\n<a href=\"" + post.url + "&s=" + value + "\">\n<h2>" + (post.title) + "</h2>\n<p>" + (post.content) + "</p>\n</a>\n</div>";
    });
    $panel.classList.add('show');
    $clearBtn.classList.add('show');
    $panel.innerHTML = html || ("<p class=\"empty\">" + NO_DATA_TEXT + "</p>");
    if (options.hideOtherSidebarContent) {
      $sidebarNav.classList.add('hide');
      $appName.classList.add('hide');
    }
  }

  function bindEvents() {
    var $search = Docsify.dom.find('div.search');
    var $input = Docsify.dom.find($search, 'input');
    var $inputWrap = Docsify.dom.find($search, '.input-wrap');

    var timeId;
    // Prevent to Fold sidebar
    Docsify.dom.on(
      $search,
      'click',
      function (e) { return e.target.tagName !== 'A' && e.stopPropagation(); }
    );
    Docsify.dom.on($input, 'input', function (e) {
      clearTimeout(timeId);
      timeId = setTimeout(function (_) { return doSearch(e.target.value.trim()); }, 100);
    });
    Docsify.dom.on($inputWrap, 'click', function (e) {
      // Click input outside
      if (e.target.tagName !== 'INPUT') {
        $input.value = '';
        doSearch();
      }
    });
  }

  function updatePlaceholder(text, path) {
    var $input = Docsify.dom.getNode('.search input[type="search"]');

    if (!$input) {
      return
    }
    if (typeof text === 'string') {
      $input.placeholder = text;
    } else {
      var match = Object.keys(text).filter(function (key) { return path.indexOf(key) > -1; })[0];
      $input.placeholder = text[match];
    }
  }

  function updateNoData(text, path) {
    if (typeof text === 'string') {
      NO_DATA_TEXT = text;
    } else {
      var match = Object.keys(text).filter(function (key) { return path.indexOf(key) > -1; })[0];
      NO_DATA_TEXT = text[match];
    }
  }

  function updateOptions(opts) {
    options = opts;
  }


  function init(opts, vm, isAuto) {
    var keywords = vm.router.parse().query.s;

    updateOptions(opts);
    style();
    tpl(keywords);
    bindEvents();
    keywords && setTimeout(function (_) {
      doSearch(keywords);
    }, 500);
  }


  function update(opts, vm) {
    updateOptions(opts);
    updatePlaceholder(opts.placeholder, vm.route.path);
    updateNoData(opts.noData, vm.route.path);
  }

  var CONFIG = {
    placeholder: 'Type to search',
    noData: 'No Results!',
    paths: 'auto',
    depth: 2,
    maxAge: 86400000, // 1 day
    hideOtherSidebarContent: false,
    namespace: undefined
  };

  var install = function (hook, vm) {
    var util = Docsify.util;
    var opts = vm.config.search || CONFIG;

    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;
      CONFIG.noData = opts.noData || CONFIG.noData;
      CONFIG.depth = opts.depth || CONFIG.depth;
      CONFIG.hideOtherSidebarContent = opts.hideOtherSidebarContent || CONFIG.hideOtherSidebarContent;
      CONFIG.namespace = opts.namespace || CONFIG.namespace;
    }

    var isAuto = CONFIG.paths === 'auto';
    hook.mounted(function (_) {
      init(CONFIG, vm, isAuto);
      !isAuto && init$1(CONFIG, vm);
    });
    hook.doneEach(function (_) {
      update(CONFIG, vm);
      isAuto && init$1(CONFIG, vm);
    });
  };

  $docsify.plugins = [].concat(install, $docsify.plugins);

}());

highlight.js

(function () {
  var timer = null
  //页面初始化完成
  $(document).ready(function () {
    //监听 #main 内容发生改变
    $("#main").on('DOMNodeInserted', setHighlight)
  })

  /**
   * 在主内容(#main)中设置高亮
   * 这里timeout做防抖
   */
  function setHighlight() {
    timer && clearTimeout(timer)
    timer = setTimeout(function () {
      var search = getSearch().s
      if (search) {
        //去掉上次匹配关键词的样式
        $("#main .search-keyword").removeClass("search-keyword")
        var newHtml = getHtmlStr($("#main").html(), search)
        //先解绑dom更新监听  待设置高亮完成后再重新注册监听  否则会进入死循环
        newHtml && $("#main").off('DOMNodeInserted').html(newHtml).on('DOMNodeInserted', setHighlight)
      }

    }, 200)
  }
  /**
   * 忽略命中html标签内的属性 
   * 分别对<、>的位置进行判断 区分关键字是否出现在html标签中间,
   * 如果是     跳过 不替换
   * 如果不是   替换高亮展示
   */
  function getHtmlStr(htmlStr, search) {
    //用关键字 区分大小写 分割字符串
    var reg = new RegExp(search, 'i')
    var tempList = htmlStr.split(reg)

    var newHtml = tempList.shift()
    var newHtmlIndex = newHtml === undefined ? 0 : newHtml.length

    tempList.map(temp => {
      var strLastLeft = newHtml.lastIndexOf("<")
      var strLastRight = newHtml.lastIndexOf(">")

      if (strLastRight < strLastLeft) {//最后的标签未闭合
        newHtml = newHtml + search + temp
      } else {
        // 获取原字符串中的关键字  为了区分大小写显示   添加高亮样式
        newHtml = newHtml + "<em class='search-keyword'>" + htmlStr.substr(newHtmlIndex, search.length) + "</em>" + temp
      }
      //计算原关键字在原字符串中的位置
      newHtmlIndex = newHtmlIndex + search.length + temp.length
    })
    return newHtml
  }
  /**
   * 获取搜索关键字
   */
  function getSearch() {
    let hash = location.hash
    let search = hash && hash.indexOf("?") > -1 && hash.substring(hash.indexOf("?") + 1)
    let searchObj = {}
    if (search) {
      search.split("&").map(key_value => {
        let temp = key_value.split("=")
        searchObj[temp[0]] = decodeURI(temp[1])
      })
    }
    return searchObj
  }

}())

index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>文档中心</title>
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
  <meta name="description" content="Description">
  <meta name="viewport"
    content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <!-- <link rel="stylesheet" href="vendor/themes/vue.css"> -->
  <link rel="stylesheet" href="vendor/themes/theme-simple.css">
  <link rel="stylesheet" href="vendor/themes/theme-custom.css">
  <link rel="stylesheet" href="vendor/themes/docsify-copy-code.css">
  <link rel="stylesheet" href="vendor/themes/prism-tomorrow.css">
  <style>
    .sidebar-nav>ul>li>ul>li>ul>li>a:before {
      content: ""
    }

    .sidebar-nav>ul>li>ul>li>ul>li.active>a:before {
      content: ""
    }

    em.search-keyword {
      background: #f7e3a7;
      font-style: normal;
      padding: 2px;
      border-radius: 2px;
    }
  </style>
</head>

<body>
  <div id="app"></div>
  <script src="docsify.config.js"></script>
  <script src="vendor/docsify.js"></script>
  <script src="vendor/plugins/docsify-themeable.js"></script>
  <script src="vendor/plugins/zoom-image.min.js"></script>
  <script src="vendor/plugins/search.js"></script>
  <script src="vendor/plugins/docsify-copy-code.min.js"></script>
  <script src="vendor/plugins/docsify-pagination.min.js"></script>
  <!--页面页签插件-->
  <script src="vendor/plugins/docsify-tabs.js"></script>
  <!-- Alerts样式插件 -->
  <script src="vendor/plugins/docsify-plugin-flexible-alerts.min.js"></script>
  <script src="vendor/jquery.js"></script>
  <script src="vendor/plugins/hightlight.js"></script>
</body>

</html>

不到之处,烦请指正~~

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值