Featured Image is “Search!” by Jeffrey Beall is licensed with CC BY-ND 2.0. To view a copy of this license, visit https://creativecommons.org/licenses/by-nd/2.0/

In an earlier post about migrating from wordpress to self hosted on Azure, I took you through some steps to host a blog on Hugo. But I don’t know if you realised the blog didn’t have a search function. I couldn’t really search for articles! That I felt was a step back from my wordpress blog where I had the ability to search. Not just me, but you know, if anyone ever wants to search for a particular content in my blog, they really didn’t have a way to do that, unless google let them search.

This lack of search functionality did bother me.

However, I was more annoyed that I hadn’t tailored the light and dark theme of the website based on Solarized colour scheme that I also use for my code editors. Now that was much easier by following override instructions on the Theme’s documentation.

I must admit, these things do sound trivial, however, as someone who has never really interacted with Hugo, I did have a bit of trial error to do. Nor did I have much experience overriding css, in this case sass. But it was not too hard considering, I knew sass enough to read and understand.

So while searching for solutions to solve my next problem, I ran into several github gists that suggested ways of making a client side search for a Hugo blog. So I had to try it out myself.

What was I looking for?

I was looking for a way to add search functionality without having to change the build and deploy process that I setup for my blog. So no additional build steps was one criteria.

The other was, I didn’t want to host a server or a database to do search on my static blog. It didn’t make any sense for a blog like mine, which barely has enough content for you to read in a day!

So if Hugo could generate an index for a website, then maybe I could use that with a client side search engine, that could display the results. But where could that be?

Then I came across an old github gist by eddieweb. This was last updated in 2018, while I’m writing this. It could be a good baseline, to start testing out the concept. I could definitely work on upgrades and tweak the output for my blog. With that in mind, I proceeded. Let us go through some libraries used for this purpose.

Fuse.js

Fuse.js is a lightweight fuzzy-search library with zero dependencies! That’s it! Not need of a backend so long as you have the search index on your client. I felt that would suffice. Perfect project for a weekend. Maybe even a few hours!

All you basically need to get started is include the following script tag on your page:

<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.4.6"></script>

You might have noticed that I chose a specific version. The version you pick is completely upto you. I just pasted the version currently displayed on Fuse.js website.

jQuery

Anyone who has done some web development a decade ago, must have encountered jQuery in one form or another. Extremely useful library with a lot of utility functions to simplify writing functions in javascript.

Mark.js

Mark.js is a cool library to highlight parts of a webpage.

CDN JS

Not a library but a content delivery network. I used cdnjs to include the libraries that I mentioned earlier on the search page.

What theme am I using?

I am using Hugo Clarity on the blog customised to my taste while I am writing this blog. Mentioning this here as some of the styling done on the search page is very specific to this theme.

Changes

content/search.md

Obviously, to allow visitors to your blog to search, you need to give them a search input or a page. Either way you need a page to display the search results. So create a content/search.md file. This can be an empty file with just the bare minimum.

---
title: "Search Results"
sitemap:
  priority : 0.1
layout: "search"
---

nothing here

layouts/_default/search.html

This is the page that renders the search form and the results. This page involves the styling that is necessary for the search page.

{{ define "main" }}

<div class="grid-inverse wrap content">
  <section id="search-form" class="search-section">
    <form action="{{ "search" | absURL }}" class="search-form">
      <label for="search-query" class="input-label">Search: </label>
      <input id="search-query" name="search-query" type="text"/>
    </form>
    <ul id="search-results" class="posts"></ul>
    </ul>
  </section>
  {{- partial "sidebar" . }}
</div>
<!-- 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">
    <li class="post_item">
      <div id="summary-${key}" class="excerpt">
        <div class="excerpt_header">
          <h3 class="post_link">
            <a href="${link}" title="${title}">${title}</a>
          </h3>
        </div>
        <div class="excerpt_footer">
          <div class="matched-content">
            <p>
                ${snippet}
            </p>
            <p>
                ${ isset tags }Tags: ${tags}${ end }
            </p>
            <p>
                ${ isset categories }Categories: ${categories}${ end }
            </p>
          </div>
        </div>
      </div>
    </li>
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fuse.js/6.4.6/fuse.min.js" integrity="sha512-KnvCNMwWBGCfxdOtUpEtYgoM59HHgjHnsVGSxxgz7QH1DYeURk+am9p3J+gsOevfE29DV0V+/Dd52ykTKxN5fA==" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js" integrity="sha512-894YE6QWD5I59HgZOGReFYm4dnWc1Qt5NtvYSaNcOP+u1T9qYdvdihz0PPSiiqn/+/3e7Jo4EaG7TubfWGUrMQ==" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/jquery.mark.min.js" integrity="sha512-mhbv5DqBMgrWL+32MmsDOt/OAvqr/cHimk6B8y/bx/xS88MVkYGPiVv2ixKVrkywF2qHplNRUvFsAHUdxZ3Krg==" crossorigin="anonymous"></script>
<script src="{{ "js/search.js" | absURL }}"></script>
<style>
  .search-section {
    max-width: inherit;
    overflow: inherit;
    word-wrap: break-word;
    display: flex;
    flex-direction: column;
  }
  
  .search-form {
    max-width: inherit;
    overflow: inherit;
    display: flex;
  }
  
  input[type=text] {
    flex-grow: 2;
    padding: 12px 15px;
    display: inline-block;
    border: none;
    border-radius: 5px;
    box-sizing: border-box;
    font-family: 'Open Sans';
    font-size: medium;
  }

  html[data-mode='lit'] input[type=text] {
    background-color: #EEE8D5;
    color: #000;
  }

  html[data-mode='dim'] input[type=text] {
    background-color:#0D3640;
    color: #E0E5E6
  }

  .input-label {
    padding: 12px 20px;
    flex-grow: 1;
    text-align: right;
    font-weight: bolder;
  }
  
  .matched-content {
    width: 100%;
    word-wrap: break-word;
    overflow: auto;
    text-overflow: ellipsis;
  }
  
  mark {
    word-wrap: break-word;
    overflow: hidden;
    background-color: orange;
  }
  
  html[data-mode='lit'] .post_item {
    background-color: #eee8d5;
  }
  
  </style>
{{ end }}

I have included the content here as everything in the file is open for anyone to use and will just work out of the box with hugo clarity theme. You may tweak the colours and other appearance of the elements on the page yourself.

static/js/search.js

This is the core of the search functionality. The script, uses the index, creates a Fuse instance and searches for the query in the search input and parses results and renders the matches on the same page in a way that the word matches are highlighted. I have made several changes to the script as I decided to upgrade the libraries on my search page compared to eddie’s original gist.


summaryInclude = 60;
// https://fusejs.io/api/options.html
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.6 },
    { name: "tags", weight: 0.4 },
    { name: "categories", weight: 0.3 }
  ]
};

var searchQuery = param("search-query");
if (searchQuery) {
  $("#search-query").val(searchQuery);
  executeSearch(searchQuery);
} else {
  $('#search-results').append("");
}

function executeSearch(searchQuery) {
  $.getJSON("/index.json", function (data) {
    var pages = data;
    var fuse = new Fuse(pages, fuseOptions);
    var result = fuse.search(searchQuery);
    if (result.length > 0) {
      populateResults(result);
    } else {
      $('#search-results').append("<li>No matches found</li>");
    }
  });
}

function populateResults(result) {
  var filteredResults = [];
  var addedPermalinks = {};
  // remove multiple instances of the same page from the result set. 
  for (var i=0, len=result.length; i<len; i++){
    // exclude search page from search results if the search query was 'search'
    if (!addedPermalinks.hasOwnProperty(result[i].item.permalink) && (result[i].item.permalink.match(/\/search\//g) === null)){
      filteredResults.push(result[i]);
      addedPermalinks[result[i].item.permalink] = result[i].item.permalink;
    }
  }
  $.each(filteredResults, function (key, value) {
    var contents = value.item.contents;
    var snippet = "";
    var snippetHighlights = [];
    var tags = [];

    if (fuseOptions.tokenize) {
      snippetHighlights.push(searchQuery);
    } else {
      $.each(value.matches, function (matchKey, mvalue) {
        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);
      snippet += "...";
    }
    //pull template from hugo template definition
    var templateDefinition = $('#search-result-template').html();
    //replace values
    var commaSeparatedTags = (value.item.tags) ? value.item.tags.join(', ') : "";
    var commaSeparatedCategories = (value.item.categories) ? value.item.categories.join(', ') : ""; 
    var output = render(templateDefinition, { key: key, title: value.item.title, link: value.item.permalink, tags: commaSeparatedTags, categories: commaSeparatedCategories, snippet: snippet });

    $('#search-results').append(output);
  });
  $(".excerpt").mark(searchQuery);
}

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/index.json

As Eddie mentioned, Hugo apparently builds an index when you build the website. We can configure Hugo to output the index in json format for our consumption and also specifically tweak what parts of it are searchable.

{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

This file is probably the only one unchanged from Eddie’s original post.

config.toml


[outputs]
home = ["HTML", "RSS", "JSON"]

This could be in config.toml or in the frontmatter of your custom _index.md

Result

You can now see the result in action on the Search page

I hope this was useful.