When I was working on a static site, generated with hugo, the amount of pages started to get really out of hand. I was looking for pages, but wasn’t entirely sure where to look for them. This was the point that it crossed my mind that searching the site would be extremely convenient.

So my first thought was, let’s install a search package. However this was not as available as I initially thought. Hugo has some docs on search functionality, however none of them give a full implementation example. This post will.

The problem

Static sites are generated into all available paths, and then those files are served. The server won’t be running a nice database that you can query for some content. This means that the database that will be used for searching has to be generated as well. In this example that database will be a generated json file that will be served over the path /search.

Getting started

Here I will describe the functionality that is available on this site. It will use lunrjs as a client side search engine, that is lightweight and super easy to get started with search library.

The source is of the implementation described in this post is available here.

The solution is not exactly rocket science, but it took me some time to get it all working and integrated, so hopefully this example will save you some time.

What do we need

To get this up and running we are going to need 4 things

  1. An endpoint that can serve the json database
  2. A template that will generate the json file, so we can query it
  3. A client side script that retrieves the database file, and allows searching in the file (I will use lunr for that
  4. A search page (in this case a partial), so it is actually possible to jot down search words

The endpoint

If you have a default layout for your hugo site, like I do, you will need to make the /search endpoint available. There are multiple ways to do that, but I chose to make a directory search inside the content directory, containing an index.md file. You can see it here. The index.md file will contain dummy content. You can use this to add some documentation for the team if you’d prefer. But really, what’s in there doesn’t matter, because the actual file won’t be served.

What is important is the type of the file. I used data, but it doesn’t matter at this point. We will only have to make sure that we use it when we are creating the json template. Make sure to put it in the front matter.

Mine looks like this

---
type: data
---

Make sure, to exclude the data type from the pages that you want people to see in the list.

In my layouts/_default/list.html template that drills down to

{{ range $index, $element := where .Paginator.Pages ".Type" "==" "post" }}
    ...
{{ end }}

because I only want to list pages of type post. But you can change this to

{{ range $index, $element := where .Paginator.Pages ".Type" "!=" "data" }}
    ...
{{ end }}

You see, nothing fancy so far. Now that the /search endpoint is available, it’s time to proceed, however hugo will error out on this, because there is no template for this type. So let’s do that next.

Creating the data template

Because I chose the type to be data and to use a directory search with a file index.md, I created a directory data in /content/layouts. Inside this directory I put the template single.html. This is the file that hugo expects for a file called index.md. If you prefer _index.md make sure to call this file baseof.html. If you don’t want to put the search file inside a directory, but want to add search.md to the root of the content dir, then call this file list.html.

Once this is done, you might have to restart your local hugo server, if you are running it locally like me. When that is done, there will be an empty page when you browse http://localhost:8888/search (or whatever port it runs locally).

But we want this url to show a nice json representation of all the pages, because that we can load into [lunr][lunr-js].

Filling the template

You can view how to fill the template here

The example is based on this gist from goblindegook.

{{ $.Scratch.Add "index" slice }}

{{ $searchablePages := where .Site.Pages "Params.type" "==" "post" }}

{{ range $index, $page := $searchablePages }}
  {{ .Scratch.Set "pageData" "" }}
  {{ .Scratch.Set "pageContent" "" }}
  {{ .Scratch.Set "pageURL" "" }}
  {{ .Scratch.Set "pageTag" "" }}


  {{ if gt (len $page.Content) 0 }}
    {{ .Scratch.Set "pageContent" $page.Plain }}
    {{ .Scratch.Set "pageURL" $page.Permalink }}
    {{ if (isset $page.Params "tags") }}
    {{ .Scratch.Set "pageTag" (delimit $page.Params.tags " ; ") }}
    {{ end }}

    {{ .Scratch.Set "pageData" (dict "id" $index "title" $page.Title "url" (.Scratch.Get "pageURL") "content" (.Scratch.Get "pageContent") "tag" (.Scratch.Get "pageTag")) }}

    {{ $.Scratch.Add "index" (.Scratch.Get "pageData") }}
  {{ end }}
{{ end }}

{{ $.Scratch.Get "index" | jsonify }}

You can edit the fields to whatever you like. For convenience I set the id field to the incrementor of the list.

Now when you visit the /search endpoint, it will return json with the following layout

[
    {
        "id": "The id generated by hugo",
        "title": "The page title",
        "url":  "Link to the page, mostly so we can link it from the search results",
        "content": "A plain text string of the content",
        "tag": "semicolon seperated string of the tags, because that makes them searchable"
    },
    ...
]

Client side searching

Now we are ready to use this in the client. I am loading the json file in a promise, so it won’t annoy the user when the file gets huge. I will use axios for this.

UPDATE: axios has been replcaed by browser’s native fetch.

Make sure that if you do so, and you really can’t drop all the IE users, that you will need to polyfill Promise as well.

I don’t care about IE users, so I didn’t. Also I was too lazy to setup a webpack config. So you will notice that the javascript syntax is not ES5+, and I will load the libraries from a cdn (unpkg in this case, but there are plenty)

So add the following scripts to your base template if you are also lazy

Axios

The axios part is deprecated for my site, but if you chose to use axios, you will need to add this as well.

<script src="https://unpkg.com/axios/dist/axios.min.js"></script>

Lunr

<script src="https://unpkg.com/lunr/lunr.js"></script>

Polyfill for Promise

On polyfill.io you can click the bundle you want, and it will generate a script tag for you. If you do this for Promise only you will get something like this

<script
    crossorigin="anonymous"
    src="https://polyfill.io/v3/polyfill.min.js?flags=gated%2Calways&features=Promise"
></script>

If you need more, it is pretty straight-forward. Make sure that you put the polyfill as the first element after <body>.

I put them all in a partial, that I load in my head (except the polyfill, because, like I said, I don’t care).

So all set there, time to create a partial for the client side search. You can find the partial here, but it looks like this.

<div class="show-search">
    <a class="toggle-search" title="search across all content">
        <svg xmlns="http://www.w3.org/2000/svg" width="612.056" height="612.057" viewbox="0 0 613 613">
            <path
                d="M595.2 513.908L493.775 412.482c26.707-41.727 42.685-91.041 42.685-144.263C536.459 120.085 416.375 0 268.24 0 120.106 0 .021 120.085.021 268.219c0 148.134 120.085 268.22 268.219 268.22 53.222 0 102.537-15.979 144.225-42.686l101.426 101.463c22.454 22.453 58.854 22.453 81.271 0 22.492-22.491 22.492-58.855.038-81.308zm-326.96-54.103c-105.793 0-191.585-85.793-191.585-191.585 0-105.793 85.792-191.585 191.585-191.585s191.585 85.792 191.585 191.585c.001 105.792-85.791 191.585-191.585 191.585z" />
        </svg>
    </a>
</div>
<aside role="search">
    <div class="close toggle-search">
        <svg height="512" width="512" viewbox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
            <path d="M443.6 387.1L312.4 255.4l131.5-130c5.4-5.4 5.4-14.2 0-19.6l-37.4-37.6c-2.6-2.6-6.1-4-9.8-4-3.7 0-7.2 1.5-9.8 4L256 197.8 124.9 68.3c-2.6-2.6-6.1-4-9.8-4-3.7 0-7.2 1.5-9.8 4L68 105.9c-5.4 5.4-5.4 14.2 0 19.6l131.5 130L68.4 387.1c-2.6 2.6-4.1 6.1-4.1 9.8 0 3.7 1.4 7.2 4.1 9.8l37.4 37.6c2.7 2.7 6.2 4.1 9.8 4.1 3.5 0 7.1-1.3 9.8-4.1L256 313.1l130.7 131.1c2.7 2.7 6.2 4.1 9.8 4.1 3.5 0 7.1-1.3 9.8-4.1l37.4-37.6c2.6-2.6 4.1-6.1 4.1-9.8-.1-3.6-1.6-7.1-4.2-9.7z"/>
        </svg>
    </div>
    <div class="search-wrapper">
        <form class="search" method="get">
            <input type="search" placeholder="search..." disabled="disabled" />
            <div class="search-content">
                <a href="https://lunrjs.com/guides/searching.html">Read more on how to search</a> on the <a href="https://lunrjs.com">lunrjs</a> page
            </div>
        </form>
    </div>
    <ul class="search-results">
        <li>
        </li>
    </ul>
</aside>
{{ $script := resources.Get "js/search.js" | resources.Minify | resources.Fingerprint }}
<script src="{{ $script.RelPermalink }}"></script>

Note that the javascript file is already included here. I will come back to that in the next paragraph

It will need some styling. Take a peek here.

Additionally, if you put the styling in a scss file, make sure to load it. The template I am using, allows injection of css via an _extra.scss file in the assets/css directory. So I just created a _search.scss file in the assets directory, and included it in the _extra.scss like so

...
@import 'search';
...

The real magic

Now the final step is to make the search work. For that, just create a javascript file in the assets directory. For me that is assets/js/search.js.

It looks like this:

document.addEventListener("DOMContentLoaded", () => {
    let searchResults = [];
    const searchWrapper = document.querySelector("aside[role=search]");
    const searchResultElement = searchWrapper.querySelector(".search-results");
    const searchInput = searchWrapper.querySelector("input");

    const toggleSearch = (searchWrapper, searchInput)  =>{
        if (searchWrapper.classList.contains("active")) {
            searchWrapper.classList.add("visible");
            setTimeout(() => {
                searchWrapper.classList.remove("visible");
            }, 300);
            searchWrapper.classList.remove("active");
        } else {
            searchWrapper.classList.add("active");
            searchInput.focus();
        }
    }

    document.querySelectorAll(".toggle-search").forEach(el => {
        el.addEventListener("click", e => {
            toggleSearch(searchWrapper, searchInput);
        });
    });

    window.addEventListener("keydown", e => {
        // dismiss search on  ESC
        if (e.key == "Escape" && searchWrapper.classList.contains("active")) {
            e.preventDefault();
            toggleSearch(searchWrapper, searchInput);
        }

        // open search on CTRL+SHIFT+F
        if (e.ctrlKey && e.shiftKey && e.key == "F" && !searchWrapper.classList.contains("active")) {
            e.preventDefault();
            toggleSearch(searchWrapper, searchInput);
        }
    });

    const tags = (tags, searchString) => {
        let tagHTML = (tags.split(" ; ") || [])
            .filter(i => {
                return i && i.length > 0;
            })
            .map(i => {
                return "<span class='tag'>" + mark(i, searchString) + "</span>";
            })
        return tagHTML.join("");
    }

    const mark = (content, search) => {
        if (search) {
            let pattern = /^[a-zA-Z0-9]*:/i;
            search.split(" ").forEach(s => {
                if (pattern.test(s)) {
                    s = s.replace(pattern, "");
                }

                if (s && s.startsWith("+")) {
                    s = s.substring(1);
                }

                if (s && s.indexOf("~") > 0
                    && s.length > s.indexOf("~")
                    && parseInt(s.substring(s.indexOf("~") + 1)) == s.substring(s.indexOf("~") + 1)
                ) {
                    s = s.substring(0, s.indexOf("~"));
                }

                if (!s || s.startsWith("-")) {
                    return;
                }
                let re = new RegExp(s, "i");
                content = content.replace(re, m => {
                    return "<mark>"+m+"</mark>";
                });
            });
        }

        return content;
    }

    fetch("/search")
        .then(response => response.json())
        .then(result => {
            const searchContent = result;
            const searchIndex = lunr(builder => {
                builder.ref("id")
                builder.field("content");
                builder.field("tag");
                builder.field("title");
                builder.field("url");
                builder.field("type");

                Array.from(result).forEach(doc => {
                    builder.add(doc)
                }, builder)
            })
            searchInput.removeAttribute("disabled");
            searchInput.addEventListener("keyup", e => {
                let searchString = e.target.value;
                if (searchString && searchString.length > 2) {
                    try {
                        searchResults = searchIndex.search(searchString);
                    } catch (err) {
                        if (err instanceof lunr.QueryParseError) {
                            return;
                        }
                    }
                } else {
                    searchResults = [];
                }

                if (searchResults.length > 0) {
                    searchResultElement.innerHTML = searchResults.map(match => {
                        let item = searchContent.find(el => {
                            return el.id == parseInt(match.ref);
                        });
                        return "<li>" +
                            "<h4 title='field: title'><a href='" + item.url + "'>" + mark(item.title, searchString) + "</a></h4>" +
                            "<p class='type'>" + item.type + "</p>" +
                            "<p class='summary' title='field: content'>" +
                            mark((item.content.length > 200 ? (item.content.substring(0, 200) + "...") : item.content), searchString) +
                            "</p>" +
                            "<p class='tags' title='field: tag'>" + tags(item.tag, searchString) + "</p>" +
                            "<a href='" + item.url + "' title='field: url'>" + mark(item.url, searchString) + "</a>" +
                            "</li>";
                    }).join("");
                } else {
                    searchResultElement.innerHTML = "<li><p class='no-result'>No results found</p></li>";
                }
            });
        })
        .catch(err => {
            console.error(err);
        });
});

There are some functions in there to make a nice transition for open/closing the search. But the important part is

fetch("/search")
    .then(response => response.json())
    .then(result => {
        const searchContent = result;
        const searchIndex = lunr(builder => {
            builder.ref("id")
            builder.field("content");
            builder.field("tag");
            builder.field("title");
            builder.field("url");
            builder.field("type");

            Array.from(result).forEach(doc => {
                builder.add(doc)
            }, builder)
        })
        searchInput.removeAttribute("disabled");
        searchInput.addEventListener("keyup", e => {
            let searchString = e.target.value;
            if (searchString && searchString.length > 2) {
                try {
                    searchResults = searchIndex.search(searchString);
                } catch (err) {
                    if (err instanceof lunr.QueryParseError) {
                        return;
                    }
                }
            } else {
                searchResults = [];
            }

            if (searchResults.length > 0) {
                searchResultElement.innerHTML = searchResults.map(match => {
                    let item = searchContent.find(el => {
                        return el.id == parseInt(match.ref);
                    });
                    return "<li>" +
                        "<h4 title='field: title'><a href='" + item.url + "'>" + mark(item.title, searchString) + "</a></h4>" +
                        "<p class='type'>" + item.type + "</p>" +
                        "<p class='summary' title='field: content'>" +
                        mark((item.content.length > 200 ? (item.content.substring(0, 200) + "...") : item.content), searchString) +
                        "</p>" +
                        "<p class='tags' title='field: tag'>" + tags(item.tag, searchString) + "</p>" +
                        "<a href='" + item.url + "' title='field: url'>" + mark(item.url, searchString) + "</a>" +
                        "</li>";
                }).join("");
            } else {
                searchResultElement.innerHTML = "<li><p class='no-result'>No results found</p></li>";
            }
        });
    })
    .catch(err => {
        console.error(err);
    });

This loads the json in a Promise from our search url. Then when that is successful it will load the data into lunr.

const searchContent = result.data;
const searchIndex = lunr(function () {
   this.ref("id")
   this.field("content");
   this.field("tag");
   this.field("title");
   this.field("url");

   Array.from(result.data).forEach(function (doc) {
      this.add(doc)
   }, this)
})

So now lunr indexes all the fields we wanted. Here the index from before is used as a reference

this.ref("id")

and what other fields to index

this.field("content");
this.field("tag");
this.field("title");
this.field("url");

And then finally, load the results into a template, that can be injected into the ul.search-results element

if (searchResults.length > 0) {
    searchResultElement.innerHTML = searchResults.map(function (match) {
            let item = searchContent.find(function(e) {
                    return e.id == parseInt(match.ref);
                    });
            return "<li>" +
            "<h4 title='field: title'><a href='" + item.url + "'>" + mark(item.title, searchString) + "</a></h4>" +
            "<p class='summary' title='field: content'>" +
            mark((item.content.length > 200 ? (item.content.substring(0, 200) + "...") : item.content), searchString) +
            "</p>" +
            "<p class='tags' title='field: tag'>" + tags(item.tag, searchString) + "</p>" +
            "<a href='" + item.url + "' title='field: url'>" + mark(item.url, searchString) + "</a>" +
            "</li>";
            }).join("");
} else {
    searchResultElement.innerHTML = "<li><p class='no-result'>No results found</p></li>";
}

And all wrapped nicely into the keypup event on the search input.

BOOM, all set . Happy copy-pasting

If you want to use Fusejs, there’s a nice post here