Joseph Earl

Search your Hugo static site using lunr.js

static-siteshugosearch

In this post I’ll show you how you can add easily add search to your Hugo static site using Hugo’s Scratch feature and the lunr.js JavaScript library on the client.

First we’ll need to create a JSON index of all our documents as part of our Hugo site generation process. Add a new document with hugo new search-index.md and set the type to search-index and the url the index.json in the frontmatter:

---
date: "2017-03-28T00:02:24+01:00"
type: "search-index"
url: "index.json"
---

Then add a new single.html layout to layouts/search-index with the content:

{{- $.Scratch.Add "index" slice -}}
{{- range where .Site.Pages "Type" "not in"  (slice "page" "search-index") -}}
{{- $.Scratch.Add "index" (dict "title" .Title "ref" .Permalink "tags" .Params.tags) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

If you run hugo you should see an index.json file with a list of all your posts in your public output folder.

Next we’ll need to load our index.json into lunr.js — add the lunr.js dependency to your project and then add some new JavaScript:

function searchIndex() {
    return lunr(function() {
        this.field("title",{boost:10}),
        this.field("tags",{boost:5}),
        this.ref("ref")
    });
}

function loadIndexJson(indexJsonLoadedFunction) {
    var x = new XMLHttpRequest;
    x.overrideMimeType("application/json");
    x.open("GET", "/index.json", true);
    x.onreadystatechange = function() {
        if (4 == x.readyState && "200" == x.status) {
            indexJsonLoadedFunction(
                JSON.parse(x.responseText)
            );
        }
    }
    r.send(null)
}

function addToSearchIndex(lunrIndex, indexLoadedFunction) {
    return function(index) {
        var titles = {};
        index.forEach(function(item) {
            lunrIndex.add(item);
            // The lunr results only contain ref and score
            // so we have to keep track of any other values
            // we want to display ourselves
            titles[item.ref] = item.title;
        }
        indexLoadedFunction(lunrIndex, titles);
    }
}

Add an input for the search query and a div for the search results to your HTML:

<p><input id="search-input" type="text" /></p>
<div id="search-results"></div>

Finally when the user types something, we’ll need to search the index and render the results:

function search(renderFactoryFunction) {
  return function (lunrIndex, titles) {
    var renderFunction = renderFactoryFunction(titles);
    return function (query) {
      var results = lunrIndex.search(query);
      renderFunction(results);
    };
  };
}

function renderSearchResults(searchResultsNode) {
  return function (titles) {
    return function (results) {
      // Create a list of results
      var ul = document.createElement("ul");
      results.forEach(function (result) {
        var li = document.createElement("li");
        // Create an item with the title
        li.appendChild(document.createTextNode(titles[result.ref]));
        ul.appendChild(li);
      });
      // Remove any existing content
      while (searchResultsNode.hasChildNodes()) {
        searchResultsNode.removeChild(searchResultsNode.lastChild);
      }
      // Render the list
      searchResultsNode.appendChild(ul);
    };
  };
}

function registerSearchHandler(searchInputNode, searchFactoryFunction) {
  return function (lunrIndex, titles) {
    var searchFunction = searchFactoryFunction(lunrIndex, titles);
    // Register an oninput event handler
    searchInputNode.oninput = function (event) {
      var query = event.target.value;
      searchFunction(query);
    };
  };
}

Putting it all together once your document has loaded:

loadIndexJson(
  addToSearchIndex(
    searchIndex(),
    registerSearchHandler(
      document.getElementById("search-input"),
      search(renderSearchResults(document.getElementById("search-results"))),
    ),
  ),
);

If the search-index document appears on your site, e.g. in the list of posts or elsewhere you’ll want to exclude the search-index type, for instance updating or adding a where condition to page ranges in your Hugo theme templates:

{{ range where .Paginator.Pages "Type" "ne" "search-index" }}

Topics I haven’t covered here for brevity are error-handling and rendering a no-results message, and if you’re using a JavaScript pre-processor and can write ES6 you may want to refactor the above code to use promises instead of callbacks.

One thing to be aware of is that the index.json can be become quite large with a lot of content and the search index will take a lot of time to load and prepare on the client.

To avoid this you can load your index.json into lunr.js as part of your build process and serialize the resulting index using lunrIndex.toJSON to a file. Then load this file on the client using lunr.Index.load.