HugoにFuse.jsを使って全文検索機能を入れてみた

なんとなくやってみた

こちらのGist(eddiewebb/readme.md)を参考にしてやってみた。

仕組みとしては、

  • ビルドの際に、すべての記事のデータを含んだJSONファイルを作成し、/index.jsonというパスでホスティングする。
  • /searchという独自のページを作成して、その画面で検索文字列を入力しsubmitすると、画面の再読み込みが起こり、Javascriptが再実行され検索する
    • Javascriptでは以下の処理を行っている
      • /index.jsonにアクセスし、すべての記事のデータを取得する
      • Fuse.jsにデータを入れて検索
      • 検索して取得した結果から、検索文字列をmark.jsでハイライトさせ、画面に表示させる

上記に従えばだいたいうまくいったけど、検索ごとに再読み込みが発生してしまい、/index.jsonやその他jsやcssを無駄にリソースを食ってしまう

なので再読み込みさせずjsの中だけで検索機能を完結させてみた。

layouts/_default/search.html

// LICENCE: https://gist.github.com/eddiewebb/735feb48f50f0ddd65ae5606a1cb41ae#mit-license
summaryInclude = 60;
var fuseOptions = {
  shouldSort: true,
  includeMatches: true,
  threshold: 0.3,
  tokenize: true,
  keys: [
    { name: "title", weight: 0.8 },
    { name: "contents", weight: 0.5 },
    { name: "tags", weight: 0.3 },
    { name: "categories", weight: 0.3 }
  ]
};

let fuse = null
initialize();

function initialize() {
  $.getJSON("/index.json", function (data) {
    var pages = data;
    fuse = new Fuse(pages, fuseOptions);


    /** 初期化 */
    onSearch(param("s"));

    // hook onSearch function on input string change
    $("#search-query").on("input", debounce(function () {
      const queryString = $("#search-query").val()
      history.replaceState(null, null, "?s="+queryString);
      onSearch(queryString);
    }, 500));
  });
}


// http://davidwalsh.name/javascript-debounce-function
function debounce(func, wait, immediate) {
  var timeout;
  return function() {
      var context = this, args = arguments;
      var later = function() {
          timeout = null;
          if (!immediate) func.apply(context, args);
      };
      var callNow = immediate && !timeout;
      clearTimeout(timeout);
      timeout = setTimeout(later, wait);
      if (callNow) func.apply(context, args);
  };
};

function onSearch(queryString) {
  $('#search-results').empty();
  if (!queryString) {
    $('#search-results').append("<p>検索したい文字を入力してください</p>");
    return
  }
  // reset content in search-results
  var result = fuse.search(queryString);
  if (result.length > 0) {
    populateResults(result, queryString);
  } else {
    $('#search-results').append("<p>合致する記事がありませんでした</p>");
  }
};


function populateResults(result, queryString) {
  $.each(result, function (key, value) {
    var contents = value.item.contents;
    var snippet = "";
    var snippetHighlights = [];
    var tags = [];
    if (fuseOptions.tokenize) {
      snippetHighlights.push(queryString);
    } else {
      $.each(value.matches, function (matchKey, mvalue) {
        console.log(mvalue.indices)
        if (mvalue.key == "tags" || mvalue.key == "categories") {
          snippetHighlights.push(mvalue.value);
        } else if (mvalue.key == "contents") {
          start = mvalue.indices[0][0] - summaryInclude > 0 ? mvalue.indices[0][0] - summaryInclude : 0;
          end = mvalue.indices[0][1] + summaryInclude < contents.length ? mvalue.indices[0][1] + summaryInclude : contents.length;
          snippet += contents.substring(start, end);
          snippetHighlights.push(mvalue.value.substring(mvalue.indices[0][0], mvalue.indices[0][1] - mvalue.indices[0][0] + 1));
        }
      });
    }

    if (snippet.length < 1) {
      snippet += contents.substring(0, summaryInclude * 2);
    }
    //pull template from hugo templarte definition
    var templateDefinition = $('#search-result-template').html();
    //replace values
    var output = render(templateDefinition, { key: key, title: value.item.title, link: value.item.permalink, tags: value.item.tags, categories: value.item.categories, snippet: snippet });
    $('#search-results').append(output);

    $.each(snippetHighlights, function (snipkey, snipvalue) {
      $("#summary-" + key).mark(snipvalue);
    });

  });
}

function param(name) {
  return decodeURIComponent((location.search.split(name + '=')[1] || '').split('&')[0]).replace(/\+/g, ' ');
}

function render(templateString, data) {
  var conditionalMatches, conditionalPattern, copy;
  conditionalPattern = /\$\{\s*isset ([a-zA-Z]*) \s*\}(.*)\$\{\s*end\s*}/g;
  //since loop below depends on re.lastInxdex, we use a copy to capture any manipulations whilst inside the loop
  copy = templateString;
  while ((conditionalMatches = conditionalPattern.exec(templateString)) !== null) {
    if (data[conditionalMatches[1]]) {
      //valid key, remove conditionals, leave contents.
      copy = copy.replace(conditionalMatches[0], conditionalMatches[2]);
    } else {
      //not valid, remove entire section
      copy = copy.replace(conditionalMatches[0], '');
    }
  }
  templateString = copy;
  //now any conditionals removed we can do simple substitution
  var key, find, re;
  for (key in data) {
    find = '\\$\\{\\s*' + key + '\\s*\\}';
    re = new RegExp(find, 'g');
    templateString = templateString.replace(re, data[key]);
  }
  return templateString;
}

layouts/_default/search.html

{{ define "main" }}
<style>
    mark{
        background: orange;
}
</style>
<div role="main" class="container">
    <div class="row">
      <div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
        <section class="resume-section p-3 p-lg-5 d-flex flex-column">
        <div class="my-auto" >
        🔎&nbsp;<input id="search-query" placeholder="Typescript" name="s"/>
        <div id="search-results">
        <h3>Matching pages</h3>
        </div>
        </div>
        </section>
        <!-- this template is sucked in by search.js and appended to the search-results div above. So editing here will adjust style -->
        <script id="search-result-template" type="text/x-js-template">
            
            <article id="summary-${key}" class="post-preview">
                <a href="${link}">
                    <h2 class="post-title">${title}</h2>
                </a>
            
                <p class="post-entry">
                    ${snippet}
                    <a href="${link}" class="post-read-more">[{{ i18n "readMore" }}]</a>
                </p>
            
            </article>
        </script>
</div>
</div>
</div>
{{ end }}

他のファイルは特に変化ない。fuse.jsやjqueryなどのjsは管理しにくかったので別ファイルに移した

/img/2023-10-01.png

完成系

所感

  • 生のjs書くの久々、綺麗にする気があまり起こらなかった。個人のブログやし動くだけでええやろ。
  • jQueryでロジック組むの大変だなぁと改めて思った
tech  hugo 

See also