【Hugo】サイト内検索機能を実装した


Clarityテーマで作り直した新しいサイトに、検索機能を追加しました。

追記
テーマをClarityから自作のものに変更してしまったため、現在は使用していませんが、この記事は残しておきます。

はじめに

右側のサイドバー(スマホだとこの記事の下)に、「search」というバーがあります。 ここに入力してエンターキーを押すと、新しく作成した検索用ページに飛びます。

複数ワードの検索など賢いことはできませんが、検索結果の読み込みが速く、あまり負荷もかからないのではないかと思います。

参考

ここで紹介するのは、これらに少し機能を追加しています。

手順

1.検索用データベースを作成

まずは検索用データベースとして使う、jsの配列ファイルを用意します。

Hugoならではの方法だと思いますが、記事ファイルを利用して作成することにします。

MarkDownを用意

content/indexjs.mdを作成し、以下をコピーしてください。

---
date: 2020-11-22T15:04:16+09:00
type: "js"
url: "index.js"
---

HTMLを用意

次に、layouts/js/single.htmlを作成します。

var data = [{{ range $index, $page := where .Site.Pages "Section" "post"}}
{{ if ne $page.Title "Posts" }}
{{ if ne $index 1 }},{{ end }}
{
url: "{{ $page.Permalink }}",
title: "{{ $page.Title }}",
ctime: "{{ $page.Date.Format "20060102" }}",
content: "{{ .PlainWords }}"
}
{{ end }}
{{ end }}]

構造

先ほどのMarkDownでtypeをjsに指定しているので、layouts/js下のHTMLが読み込まれます。

そしてこのJavaScriptにしか見えないHTMLが読み込まれ、ファイル名がMarkDownで指定されたindex.jsになるわけです。

補足

ちなみに、参考サイトにあるコードに少し手を加えていますが、それは以下のような謎の要素が生成されるのを防ぐためです。

{
url: "https://luke1220.tk/post/",
title: "Posts",
ctime: "20200804",
content: "[]"
}

2.検索ページを作成

検索ページは一応記事ページということになっているので、まずはsearch.mdという名前で記事を作成しました。

---
title: "Search"
date: 2020-11-22T15:07:31+09:00
type: "search"
---

後からこのページのHTMLを変更してしまうので、情報はこれだけでいいです。 記事の内容も要りません。

この後、MarkDownにそのまま検索用のJavaScriptを貼り付けるという手もありますが、 それだとうまく動かないので別の方法をとります。

3.検索ページのHTMLファイルを用意

layouts/search/single.htmlを作成。

テーマによって異なりますが、記事ページのHTMLがthemes/テーマ名/layouts/_default/single.html等あると思いますので その内容をコピーして来てください。

その中の記事が表示される部分を削除して、代わりにこの検索用のHTMLを追加していってください。

データベースを読み込み

絶対URLを書くのは嫌なので、Hugoの機能(GoLang表記)を利用してみました。

<script src='{{ absURL "index.js" }}'></script>

検索用のdivを用意

参考サイトではdl dtタグを使用していましたが、ここではテーマの記事リストっぽいデザインにするために、 ul liタグを使います。

また、エンターキーや矢印キーでページを切り替える機能も要らないので取ってしまいました。

<div id="searchbox">
  <input type="text" id="input" onkeyup="do_find(this.value)" autocomplete="off" autofocus>
  <span id="stat"></span>
</div>
<ul class="posts" id="result"></ul>
<div id="navi"></div>

JavaScriptを用意

かなり長いので、とりあえずコピペしてください。 解説や変更点は次に書きます。

<script>
{
  $_ = String.prototype;

  $_.mReplace = function(pat,flag){
    var temp = this;
    if(!flag){flag=""}
    for(var i in pat){
      var re = new RegExp(i,flag);
      temp = temp.replace(re,pat[i])
    }
    return temp;
  };
}

{
  $_ = Date.prototype;

  $_.format = "yyyy-mm-dd HH:MM:SS";
  $_.formatTime = function(format){
    var yy;
    var o = {
      yyyy : ((yy = this.getYear()) < 2000)? yy+1900 : yy,
      mm   : this.getMonth() + 1,
      dd   : this.getDate(),
      HH   : this.getHours(),
      MM   : this.getMinutes(),
      SS   : this.getSeconds()
    }
    for(var i in o){
      if (o[i] < 10) o[i] = "0" + o[i];
    }
    return (format) ? format.mReplace(o) : this.format.mReplace(o);
  }
}
</script>
<script>
var bodylist = [];
var st = gid("stat");
var re = gid("result");
var nv = gid("navi");
var max = 5;
function gid(id){
  return document.getElementById(id);
}
function ignore_case(){
  var a = arguments;
  return "[" + a[0] + a[0].toUpperCase() + "]"
}
function do_find(v){
  const input = document.getElementById("input")
  window.history.replaceState(0, 0,"./?q="+input.value);

  if(this.lastquery == v){return}
  this.lastquery = v;
  var re = find(v);
  if(re.length){
    pagenavi(re);
    view(re)
  }
}
function find(v){
  var query = v;
  if(!v){return []}
  var aimai;
  if(query){


    aimai = query.replace(/[a-z]/g,ignore_case);
    try{
      reg = new RegExp(aimai,"g");
    }catch(e){
      reg = /(.)/g;
    }
  }else{
    reg = /(.)/g;
  }
  var result = [];
  for(var i=0;i<data.length;i++){

    var s = bodylist[i];
    var res = reg.exec(s);
    if(!res){continue}
    var len = res[0].length;
    var idx = res.index;
    if(idx != -1){
      result.push([i,idx,len]);
    }
  }
  if(result.length){
    st.innerHTML = result.length +" / "+ data.length;
  }
  return result;
}
function snippet(body,idx,len){
  var start = idx - 20;
  return [
    body.substring(start,idx),
    ,"<b>"
    ,body.substr(idx,len)
    ,"</b>"
    ,body.substr(idx+len,50),
    ,"..."
  ].join("");
}
function pagenavi(result){
  var len = result.length;
  var ct = Math.ceil(len/max);
  var buf = [];
  for(var i=0;i<ct;i++){
    buf.push(
      "<span onclick='view(\"\","
      ,i+1
      ,");sw(",i,")'>"
      ,i+1
      ,"</span>"
    );
  }
  nv.innerHTML = buf.join("");
  sw(0);
}
function sw(t){
  var span = nv.getElementsByTagName("span");
  for(var i=0;i<span.length;i++){
    span[i].className = (i==t)?"selected":"";
  }
}
function mv(to){
  var span = nv.getElementsByTagName("span");
  var current;
  if(!span.length){return}
  for(var i=0;i<span.length;i++){
    if(span[i].className == "selected"){
      current = i;break;
    }
  }
  var moveto = current+to;
  if(moveto < 0){return}
  if(moveto > span.length-1){moveto=0}
  sw(moveto);
  view("",moveto+1)
}
function view(result,offset){
  if(!offset){offset = 1}
  if(!result){
    result = this.last.reverse();
  }else{
    this.last = result;
  }
  var r = result.reverse();
  var buf = ["<dl>"];
  var count = 0;
  for(var i=(offset-1)*max;i<r.length;i++){
    count++;
    if(count > max){break}
    var num = r[i][0];
    var idx = r[i][1];
    var len = r[i][2];
    with(data[num]){
      buf.push(
        "<li class='post_item'><div class='excerpt'><div class='excerpt_header'><a href='",url,"'><h3 class='post_link'>"
        ,title||"無題","</a></h3>"
        ,"<div class='post_meta'><svg class='icon'><use xlink:href='#calendar'></use></svg><span class='post_date'>"
        ,ctime
        ,"</span></div></div><div class='excerpt_footer'><p>"
        ,snippet(bodylist[num],idx,len)
        ,"</p></div></li>"
      );
    }
  }
  re.innerHTML = buf.join("");
}
for(var i=0;i<data.length;i++){
  bodylist.push(data[i].title+ " " +data[i].content);
}
var bodyidx = bodylist.join("<>");
</script>

変更点

リスト項目部分
buf.push(
  "<li class='post_item'><div class='excerpt'><div class='excerpt_header'><a href='",url,"'><h3 class='post_link'>"
  ,title||"無題","</a></h3>"
  ,"<div class='post_meta'><svg class='icon'><use xlink:href='#calendar'></use></svg><span class='post_date'>"
  ,ctime
  ,"</span></div></div><div class='excerpt_footer'><p>"
  ,snippet(bodylist[num],idx,len)
  ,"</p></div></li>"
);

これは、記事のリストのHTMLを作成する部分です。

自分の場合は、Clarityテーマの記事リストを再現するためにこのような構造になっていますが、各自編集してください。

url(記事URL)、title(記事名)、ctime(作成日)、snippet(bodylist[num],idx,len)(本文)です。

URLパラメータ
const input = document.getElementById("input")
window.history.replaceState(0, 0,"./?q="+input.value);

ここでは、入力される度にURLパラメータを書き換えています。

4.URLパラメータの対応

<script>
  window.onload = function () {
    const searchParams = new URLSearchParams(decodeURI(location.search));
    const param = searchParams.get('q');
    var input = document.getElementById('input');
    input.value = param;
    do_find(input.value);
  };
</script>

URLパラメータがあると、読み込み時に自動で検索されるスクリプトです。

5.CSSの適用

リスト部分は、テーマのHTMLを使いまわす事によってCSSも適用されるようになりました。あとは、以下の分を書き足せばいいと思います。

あくまで例なので、適宜書き換えてください。

<style>
  body {
    background: var(--custom-bg);
  }
  h1 {
    margin-left: 2%;
  }
  #searchbox {
    margin-left: 2%;
    margin-top: 10px;
    margin-bottom: 10px;
  }
  input {
    background: none;
    border: none;
    border-radius: 0;
    outline: none;
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
    max-width: 230px;
    color:var(--text);
    border-bottom:var(--header-text) 2px solid;
    font-size: 30px;
    margin-right: 5px;
  }
  b{
    color: #0077B8;
    background-color: #D3E0EA;
    font-weight: 600;
    padding: 3px;
    border-radius: 3px;
  }
  #navi {
    font-size: 25px;
    margin-left: 3%;
  }
  #navi span{
    border: 1px solid #0077B8;
    padding: 0 0.5em;
    cursor: pointer;
    border-radius: 3px;
  }
  span.selected{
    color: #0077B8;
    background : #D3E0EA;
  }
  .searchbar{
    display: none;
  }
</style>

ちなみに、非表示にしている.searchbarというのは、後述するサイドバーのボックスのことです。

6.サイドバーに検索ボックスを追加

サイドバーのHTML(Clarityテーマではthemes/テーマ名/layouts/partials/sidebar.html)をテーマ内ファイルから見つけて、 layouts/以下にコピーします。 そして、以下のdivを書き足してください。

<div class="searchbar">
  <h2>Search</h2>
  <input type="text" id="search" class="search"/>
  <script>
    const input = document.getElementById("search");
    input.addEventListener('keypress', (e) => {
      if (e.key === 'Enter') {
        window.location.href = {{ absURL "search/" }} + "?q=" + encodeURI(input.value);
      }
    });
  </script>
</div>

おわりに

以上で完成です。

どちらかというとこの後のCSS調整の方が時間がかかると思いますが、 これでとりあえず動くところまでできたと思います。

Comments

Show Comments