Add Search To A Hugo Site

As with many sites these days, once they get beyond a certain size then search becomes an essential part of them. It can however we one of the trickier parts of a statically built websites because without a back-end to do the search for you then it has to be done client-side in the web browser.

Luckily solutions to this problem already exist and we show off one these solutions below which uses both FuseJS and MarkJS. This code is based on a gist by eddiewebb but doesn’t use jQuery.

HTML - Search Form

We need a html form to search with. This can either be on your search page, any page or all pages as it works be submitting the form to the /search endpoint. We need the id on the input to put the search query back in it.

1
2
3
4
<form action="/search" method="GET">
    <input type="search" name="q" id="search-query" placeholder="Search....">
    <button type="submit">Search</button>
</form>

Page - content/search/_index.md

Create a page for us to use as our search form and search results page. We set the layout to search and then create this layout to give us the ability to easily edit the html.

1
2
3
4
5
6
---
title: "Search"
sitemap:
  priority : 0.1
layout: "search"
---

Layout - layout/_default/search.html

Our search page here contains a div for us to add our results into, as well as a div to store our loading message. In the layout we also define a template which we later use to populate a single search result entry. Finally, we load our JS libraries (from a cdn in this example, but feel free to self-host).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{{ define "main" }}

<main>
    <div id="search-results"></div>
    <div class="search-loading">Loading...</div>

    <script id="search-result-template" type="text/x-js-template">
    <div id="summary-${key}">
        <h3><a href="${link}">${title}</a></h3>
        <p>${snippet}</p>
        <p>
            <small>
                ${ isset tags }Tags: ${tags}<br>${ end }
            </small>
        </p>
    </div>
    </script>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.2.0/fuse.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/mark.min.js"></script>
</main>

{{ end }}

JS

The main chunk of the code comes as JavaScript for this as we need to bring it altogether. We read the url parameters to get the query then pass that to FuseJS and MarkJS the write the results back to our page.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
var summaryInclude=60;
var fuseOptions = {
    shouldSort: true,
    includeMatches: true,
    threshold: 0.0,
    tokenize:true,
    location: 0,
    distance: 100,
    maxPatternLength: 32,
    minMatchCharLength: 1,
    keys: [
        {name:"title",weight:0.8},
        {name:"contents",weight:0.5},
        {name:"tags",weight:0.3},
        {name:"categories",weight:0.3}
    ]
};

// =============================
// Search
// =============================

var inputBox = document.getElementById('search-query');
if (inputBox !== null) {
  var searchQuery = param("s");
  if (searchQuery) {
    inputBox.value = searchQuery || "";
    executeSearch(searchQuery, false);
  } else {
    document.getElementById('search-results').innerHTML = '<p class="search-results-empty">Please enter a word or phrase above, or see <a href="/tags/">all tags</a>.</p>';
  }
}

function executeSearch(searchQuery) {

  show(document.querySelector('.lds-ripple'));

  fetch('/index.json').then( function(response) {
    if (response.status !== 200) {
      console.log('Looks like there was a problem. Status Code: ' + response.status);
      return;
    }
    // Examine the text in the response
    response.json().then( function(data) {
      var pages = data;
      var fuse = new Fuse(pages, fuseOptions);
      var result = fuse.search(searchQuery);
      if (result.length > 0) {
        populateResults(result);
      } else {
        document.getElementById('search-results').innerHTML = '<p class=\"search-results-empty\">No matches found</p>';
      }
      hide(document.querySelector('.lds-ripple'));
    })
    .catch(function(err) {
      console.log('Fetch Error :-S', err);
    });
  });
}

function populateResults(results) {

  var searchQuery = document.getElementById("search-query").value;
  var searchResults = document.getElementById("search-results");

  //pull template from hugo templarte definition
  var templateDefinition = document.getElementById("search-result-template").innerHTML;

  results.forEach( function(value, key) {

    var contents = value.item.contents;
    var snippet = "";
    var snippetHighlights=[];
    var tags = [];

    if(fuseOptions.tokenize) {
      snippetHighlights.push(searchQuery);
    } else {
      value.matches.forEach( function(mvalue, matchKey) {
        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);
    }
      //replace values
      var tags = ""
      if (value.item.tags){
          value.item.tags.forEach(function(element) {
              tags = tags + "<a href='/tags/"+ element +"'>" + "#" + element + "</a> " 
          });
      }
      
    var output = render(templateDefinition, {
      key: key,
      title: value.item.title,
      link: value.item.permalink,
      tags: tags,
      categories: value.item.categories,
      snippet: snippet
    });
    searchResults.innerHTML += output;

    snippetHighlights.forEach( function(snipvalue, snipkey) {
      var instance = new Mark(document.getElementById('summary-'+key));
      instance.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;
}

Site illustrations (of Hugo the rabbit) drawn by Carina Roberts.