HomeThe ClassicsFarai's Codelab

Adding Search to My Hugo Static Site Using lunr.js

Published: Updated:

Update 12 March 2024: I’m not doing this at the moment and I plan to come up with a better way. For now, the search page uses a search engine.


To make it easier to get around my website, I added search functionality to my website. To do this, I used lunr.js a text searching library one of Hugo’s recommended search tools. I could have used one of the given examples, but I decided to make it myself. Here’s how.

To add the search landing page, I added two files.

  • layouts/search/list.html - This file will contain all the things needed for the search page.
  • content/search/_index.md- This is needed so that the search page is actually rendered.

My Hugo website’s project structure now looks like this.

/fgandiya.me
--/archetypes
--/assets
  --/js*
    --/search*
      --logic.js*
      --luna.js*
  --/stylesheets
--/content
  --/blog
  --/search*
    --_index.md*
  --/series
  --/assets
--/favicon
--/layouts
  --/_default
  --/partials
  --/search*
    --list.html*
  --/shortcodes
--/resources
--/static
--config.toml
--.gitlab-ci.yml

2. Made A Simple Page Layout

It’s not much but it’s rather easy to make.

My website featuring the search page. There’s the word Search with the words Powered by lunr.js below it, a search box and two buttons, searh posts and reset search

To test what the displayed search results would look like, I wrote some code to display the search results where wach listing contained a hyperlinked title, the date of publishing, sample text as well as the matching terms highlighted. In production, I’ve only got the hyperlinked title along with the published date.

To highlight random words, here’s the code I used.

<section class="text-sample">
    {{ $escapedSummary := .Summary | htmlUnescape }}
    {{ $uniqWords := split $escapedSummary " " | uniq | shuffle }}
    {{ $randomWord := index $uniqWords 0 }}
    {{ $randomWordReplacement := delimit (slice `<mark class="found-term">` $randomWord `</mark>`) "" }}
    {{ replace $escapedSummary $randomWord $randomWordReplacement | safeHTML }}
</section>

And here’s the resulting search page with the ideal results

A list of search results where each search result had highlighted text.

The template for the search results is marked up like this.

<template id="search-result-template">
    <li class="search-result-item">
        <article>
            <header>
                <a href=""><h2></h2></a>
                <time></time>
            </header>
            <section class="text-sample"></section>
        </article>
    </li>
</template>

3. Imported the lunr.js library

I downloaded the source code for lunr.js [29.5KB] and added it to the project folder at assets/js/search/lunr.js. To add it to the actual site, I fetched it as a resource and minified it.

{{ $luna := resources.Get "js/search/luna.js" | resources.Minify }}
<script src="{{ $luna.Permalink }}"></script>

4. Building the Search Index

Using a similar method to how I made my website’s JSON Feed, I created the search index like this.

let documents = [
    {{ $count := newScratch }}
    {{ $count.Add "count" 0 | safeJS }}
    {{ range .Site.AllPages }}
        {
            "count": {{ $count.Get `count` }},
            "name": "{{ .Title }}",
            "href": "{{ .Permalink }}",
            "date": "{{ .Date.Format "Jan 2, 2006" }}",
            "content": "{{ .Content | plainify }}" 
            {{ $count.Set "count" (add ($count.Get "count") 1) | safeJS }}
        },
    {{- end }}
];

To then initilize the search index, I added the following code.

let idx = lunr(function(){
    this.ref('name')
    this.field('name')
    this.field('content')
    this.field('date')
    this.field('href')
    this.metadataWhitelist = ['position']

    documents.forEach((doc) => {
        this.add(doc);
    }, this)
});

Here I added the fields I wanted to search. The metadataWhitelist adds the position of each search match. It needs to be whitelisted because it comes with a performance penalty (not that I’ve noticed it).

5. Adding The Search Logic

Now for the fun bit. The search logic needs

  1. Initilizing the form controls,
  2. Adding an event handler to monitor input to the search field,
  3. Rendering the search results themselves, and
  4. Clearing the previous search results before new ones are added/

The initilization and event handler looks like this

let searchResultTemplate = document.querySelector("#search-result-template");
let searchResultsList = document.querySelector("#search-results-list");
let searchForm = document.forms[0];
let searchBox = document.querySelector("#query");
let query = "";

searchBox.addEventListener("input", search);

The search function looks like this

function search() {
    clearSearchResultsList();
    let query = searchBox.value;
    console.log(query);
    let searchResults = idx.search(query);
    if(searchResults){
        for(let i = 0; i< searchResults.length; i++) {
            let resultingDocument = documents.filter(obj => {
                return obj.name == searchResults[i].ref;
            })[0];
            let resultTemplateClone = document.importNode(searchResultTemplate.content, true);
            let h2 = resultTemplateClone.querySelector("h2");
            h2.textContent = resultingDocument.name;
            h2.innerHTML = h2.innerHTML.replace(query, `<mark class="found-term">${query}</mark>`);
            let a = resultTemplateClone.querySelector("a");
            a.setAttribute("href", resultingDocument.href);
            let date = resultTemplateClone.querySelector("time");
            date.setAttribute("datetime", resultingDocument.date);
            date.textContent = resultingDocument.date;
            searchResultsList.appendChild(resultTemplateClone);
        }
    }
}

And the function to clear out the search results looks like this.

function clearSearchResultsList(){
    while(searchResultsList.lastChild) {
        searchResultsList.removeChild(searchResultsList.firstChild);
    }
}

Before you publish the site, you need to list it on the site. This is done by adding the file content/search/_index.md with the following contents.

---
title: Search
draft: false
---

Possible Areas of Improvement

While I’m no longer doing development work on the blog until next year, these are some fixes I want to make.

  • Improving Accessibility- I’ve done a bit of work in regards to acessibility but there are 2 things to add. One is a way to only search when the search event has been triggered. The other thing is to add ARIA. While ARIA isn’t recommended unless you know what you’re doing, I might try it out.
  • Highlighting Mathcing Terms- While lunr.js supports match highlighting, I haven’t added it yet.
  • Better search parameters- You can technically hack some search criteria, but I want to add the ability to search by date and topic.
  • No JS Support?- lunr.js depends on JavaScript. I want to create a way to search my site without JavaScript. Node.js microservice maybe?
  • Adding search on every page on my website
  • Prebuilding the search index - As it is now, lunr has to build the search index from the massive json object. Apparantly there’s a way to prebuild the index so I might just do that.
  • Make my own search engine? - Why not?

Conclusion

Wow, writing this blog post was much easier than I though it would be. Hopefully you got something out of my messy excuse of code. I’ve made a Gist of my lunr.js search implementation though I recomment going through Hugo’s search reccomendations.