# Shoka 主题的搜索功能配置

Shoka 主题的搜索功能是基于 Algolia 实现的,由于 Algolia 每个月有搜索次数限制,并且每次搜索时需要访问其服务器进行查询,修改文章后还需要执行 hexo algolia 的指令,操作起来相对繁琐,因此我考虑更换为基于本地文件搜索的形式实现搜索功能。这一部分的实现主要参考了 Next 主题的相关代码。

# 若干主要的代码文件

Shoka 主题与搜索功能相关的代码主要在 shoka/source/js/_app/page.jsshoka/source/js/_app/pjax.jsshoka/scripts/generaters/script.jsshoka/source/css/_common/components/third-party/search.styl 这四个文件中。其中 page.jspjax.jsscript.js 是功能实现的相关代码, search.styl 则是一些样式设计的代码。

# Algolia 配置的主要代码

Shoka 主题通过在 pjax.js 中调用 algoliasearch 函数来实现搜索功能,我自己的修改则是将其替换为了自己实现的 localsearch 函数,这些函数的实现主要在 page.js 中。 script.js 中有少量和配置选项相关的代码。

基于 Algolia 实现的搜索功能主要利用 search.addWidgets 来添加和搜索功能相关的 Widgets

search.addWidgets([
    instantsearch.widgets.configure({
      hitsPerPage: CONFIG.search.hits.per_page || 10
    }),
    instantsearch.widgets.searchBox({
      container           : '.search-input-container',
      placeholder         : LOCAL.search.placeholder,
      searchOnEnterKeyPressOnly: true,  // Only search when press enter key
      // Hide default icons of algolia search
      showReset           : false,
      showSubmit          : false,
      showLoadingIndicator: false,
      cssClasses          : {
        input: 'search-input'
      }
    }),
    instantsearch.widgets.stats({
      container: '#search-stats',
      templates: {
        text: function(data) {
          var stats = LOCAL.search.stats
            .replace(/\$\{hits}/, data.nbHits)
            .replace(/\$\{time}/, data.processingTimeMS);
          return stats + '<span class="algolia-powered"></span><hr>';
        }
      }
    }),
    instantsearch.widgets.hits({
      container: '#search-hits',
      templates: {
        item: function(data) {
          console.log(data)
          var cats = data.categories ? '<span>'+data.categories.join('<i class="ic i-angle-right"></i>')+'</span>' : '';
          return '<a href="' + CONFIG.root + data.path +'">' + cats + 
          '<b>' + data._highlightResult.title.value + '</b><br>' + 
          data._snippetResult.contentStrip.value + '<br>( 匹配字词 : ' + 
          data._highlightResult.contentStrip.matchedWords + ' ) | ( 匹配等级 : ' + 
          data._highlightResult.contentStrip.matchLevel + ' )' + '</a>';
        },
        empty: function(data) {
          return '<div id="hits-empty">'+
              LOCAL.search.empty.replace(/\$\{query}/, data.query) +
            '</div>';
        }
      },
      cssClasses: {
        item: 'item'
      }
    }),
    instantsearch.widgets.pagination({
      container: '#search-pagination',
      scrollTo : false,
      showFirst: false,
      showLast : false,
      templates: {
        first   : '<i class="ic i-angle-double-left"></i>',
        last    : '<i class="ic i-angle-double-right"></i>',
        previous: '<i class="ic i-angle-left"></i>',
        next    : '<i class="ic i-angle-right"></i>'
      },
      cssClasses: {
        root        : 'pagination',
        item        : 'pagination-item',
        link        : 'page-number',
        selectedItem: 'current',
        disabledItem: 'disabled-item'
      }
    })
  ]);

这部分代码配置了相关 Widgets 的设置选项,制定了各个对象的展示样式。其中的 HTML 相关代码可以用于之后的本地搜索实现,使得搜索界面的风格和原本的主题保持一致。

# 参考 Hexo 的 Next 主题修改 Algolia 为本地搜索

localsearch 函数的实现主要参考复制了 Next 主题 local-search.js 的一些代码。

这些函数中主要需要关注 inputEventFunction ,它用于处理搜索框的输入,并返回搜索结果对应的 HTML 代码。搜索结果的样式依旧使用原本 Shoka 主题的样式,不做大的修改。

原本的 Shoka 主题只会显示关键词所在的文章标题,这里我在每个文章的标题下方增加显示了每篇文章中前 10 个匹配的关键词所在位置的上下文。

由于 localsearch 的功能是基于 https://github.com/theme-next/hexo-generator-searchdb 插件实现的,这里需要在配置文件中增加对应的选项:

search:
  enable: true
  path: search.json # search.xml
  field: post
  format: html
  limit: 10000
  content: true
  unescape: true
  preload:  true
  trigger: "auto"
  top_n_per_article: 10

上述的选项是我把插件和 Next 主题的配置项混合在一起列出的,具体的含义可以参看插件或者 Next 主题的相关说明文档。

localsearch 函数在 page.js 中:

const localSearch = function(pjax) {
  // 参考 hexo next 主题的配置方法
  // 参考 https://qiuyiwu.github.io/2019/01/25/Hexo-LocalSearch/ 博文
  console.log(CONFIG.search);
  if(CONFIG.search === null)
    return
  if(!siteSearch) {
    siteSearch = BODY.createChild('div', {
      id: 'search',
      innerHTML: `<div class="inner">
                    <div class="header">
                      <span class="icon">
                        <i class="ic i-search">
                        </i>
                      </span>
                      <div class="search-input-container">
                      <input  class="search-input"
                              autocomplete="off"
                              placeholder="${LOCAL.search.placeholder}" 
                              spellcheck="false"
                              type="text" 
                              id="local-search-input">
                      </div>
                        <span class="close-btn">
                          <i class="ic i-times-circle">
                          </i>
                        </span>
                      </div>
                      <div class="results">
                        <div class="inner">
                        <div id="search-stats">
                        </div>
                        <div id="search-hits">
                        </div>
                        <div id="search-pagination">
                        </div>
                      </div>
                    </div>
                  </div>`
    });
  }
  
  let isFetched = false;
  let datas;
  let isXml = true;
  // search DB path
  let searchPath = CONFIG.search.path;
  console.log(searchPath);
  if (searchPath.length == 0) {
    searchPath = 'search.xml';
  } else if (searchPath.endsWith('json')) {
    isXml = false;
  }
  const input = $('.search-input'); // document.querySelector('.search-input');
  // console.log(input);
  const resultContent = document.getElementById('search-result');
  // console.log(resultContent);
  const getIndexByWord = (word, text, caseSensitive) => {
    if (CONFIG.search.unescape) {
      let div = document.createElement('div');
      div.innerText = word;
      word = div.innerHTML;
    }
    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 += `<mark">${text.substring(hit.position, end)}</mark>`;
      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 - item.word.length;
            });
          });
          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(CONFIT.localsearch.top_n_per_article, 10);
          if (upperBound >= 0) {
            slicesOfContent = slicesOfContent.slice(0, upperBound);
          }
          let resultItem = '';
          if (slicesOfTitle.length !== 0) {
            resultItem += `<li><a href="${url}">${highlightKeyword(title, slicesOfTitle[0])}</a>`;
          } else {
            resultItem += `<li><a href="${url}">${title}</a>`;
          }
          slicesOfContent.forEach(slice => {
            resultItem += `<a href="${url}"><p>${highlightKeyword(content, slice)}...</p></a>`;
          });
          resultItem += '</li>';
          resultItems.push({
            item: resultItem,
            id  : resultItems.length,
            hitCount,
            searchTextCount
          });
        }
      });
    }
    if (keywords.length === 1 && keywords[0] === '') {
      resultContent.innerHTML = '<div id="no-result"><i></i></div>';
    } else if (resultItems.length === 0) {
      resultContent.innerHTML = '<div id="no-result"><i></i></div>';
    } 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;
      });
      resultContent.innerHTML = `<ul>${resultItems.map(result => result.item).join('')}</ul>`;
      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, '') : '';
          data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/');
          return data;
        });
        // Remove loading animation
        document.getElementById('no-result').innerHTML = '<i></i>';
        inputEventFunction();
      });
  };
  if (CONFIG.search.preload) {
    fetchData();
  }
  // Handle and trigger popup window
  document.querySelectorAll('.popup-trigger').forEach(element => {
    element.addEventListener('click', () => {
      document.body.style.overflow = 'hidden';
      document.querySelector('.search-pop-overlay').classList.add('search-active');
      input.focus();
      if (!isfetched) fetchData();
    });
  });
  // Handle and trigger popup window
  $.each('.search', function(element) {
    element.addEventListener('click', function() {
      document.body.style.overflow = 'hidden';
      transition(siteSearch, 'shrinkIn', function() {
          $('.search-input').focus();
        }) // transition.shrinkIn
    });
  });
  // // Monitor main search box
  const onPopupClose = function() {
    document.body.style.overflow = '';
    transition(siteSearch, 0); // "transition.shrinkOut"
  };
  siteSearch.addEventListener('click', function(event) {
    if (event.target === siteSearch) {
      onPopupClose();
    }
  });
  
  $('.close-btn').addEventListener('click', onPopupClose);
  window.addEventListener('pjax:success', onPopupClose);
  window.addEventListener('keyup', function(event) {
    if (event.key === 'Escape') {
      onPopupClose();
    }
  });
};

# 搜索结果样式

搜索结果页面的样式主要由 search.styl 文件决定,我在其中对 #search-item 的内容进行了以下修改:

.item {
    margin: 2rem 0;
    a {
      border-bottom: 0.1rem dashed var(--grey-4);
      display: block;
      the-transition();
    }
    span {
      font-size: 70%;
      display: block;
      i {
        color: var(--grey-4);
        margin: 0 .3125rem;
      }
    }
  }

# 分页功能

对本地搜索的展示结果添加了分页功能,主要代码在 pagination 函数中。另,原主题在翻页时不会自动将滚动条回滚到顶部,这里按照我个人的习惯进行了修改,代码为 resultContent.scrollTop = 0; ,在 pagination 执行完毕后设置该属性。

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Linn 微信支付

微信支付

Linn 支付宝

支付宝