Janik von Rotz


4 min read

Simple Hugo page search with Lunr.js

Hugo is static website engine and acutally used to generated this blog post. Due to its static nature, there is now way to provide serverside capablities for dynamic lookups such as used by a search feature. Everything dynamically has to be done client side. In this post I am going to setup a search page for a Hugo site the most simple way.

Hugo can not only generate HTML documents, but also JSON documents that can be indexed by Lunr.js. Lunr.js is a small, full-text search library for use in the browser. Using a JSON document and the libararz we are going to create a page for site wide full-text search.

In our scenario we assume that the following Hugo content structure exists:

.
├── content
│   ├── page
│   │   ├── about.md
│   │   ├── archive.md
│   │   └── search.md
│   └── post
│       ├── lithium.md
│       └── markdown.md

There are two sections page and post.

We would like to generate a JSON output for the post section. To do so the supported output format has be enabled in the configuration file.

config.toml

[outputs]
    section = ["JSON", "HTML"]

In addition the current theme has to have a .json list template. Either this template is defined as default template or for the post section only, which I prefer.

themes/THEME_NAME/layouts/post/list.json

[
    {{ range $index, $value := where .Site.Pages "Type" "post" }}
    {{ if $index }}, {{ end }}
    {
        "url": "{{ .RelPermalink }}",
        "title": "{{ .Title }}",
        "content": {{ .Content | plainify | jsonify }}
    }
    {{ end }}
]

This template creates JSON object for each blog post. The url acts as an identifier and the fields title and content will be indexed for the full text search.

If the output configuration has been applied, the template created and Hugo server started, a JSON document should be available at http://localhost:1313/post/index.json. This document will be indexed by Lunr.js.

In our next and final step, we are going to create the acutal search page. In order to understand the logic of the page I have dismantled it into 5 blocks.

content/page/search.md

<script src="https://unpkg.com/lunr/lunr.js"></script>
<script type="text/javascript">
<!--...-->

The Lunr.js library is included at the beginning of the page.

//...
// define globale variables
var idx, searchInput, searchResults = null
var documents = []

function renderSearchResults(results){

    if (results.length > 0) {

        // show max 10 results
        if (results.length > 9){
            results = results.slice(0,10)
        }

        // reset search results
        searchResults.innerHTML = ''

        // append results
        results.forEach(result => {
        
            // create result item
            var article = document.createElement('article')
            article.innerHTML = `
            <a href="${result.ref}"><h3 class="title">${documents[result.ref].title}</h3></a>
            <p><a href="${result.ref}">${result.ref}</a></p>
            <br>
            `
            searchResults.appendChild(article)
        })

    // if results are empty
    } else {
        searchResults.innerHTML = '<p>No results found.</p>'
    }
}
//...

The renderSearchResults processes the search results generated by Lunr.js and appends them on a specific DOM element.

//...
function registerSearchHandler() {

    // register on input event
    searchInput.oninput = function(event) {

        // remove search results if the user empties the search input field
        if (searchInput.value == '') {
            
            searchResults.innerHTML = ''
        } else {
            
            // get input value
            var query = event.target.value

            // run fuzzy search
            var results = idx.search(query + '*')

            // render results
            renderSearchResults(results)
        }
    }

    // set focus on search input and remove loading placeholder
    searchInput.focus()
    searchInput.placeholder = ''
}
//...

The registerSearchHandler function attaches the search call on the input event of the search input field.

//...
window.onload = function() {

    // get dom elements
    searchInput = document.getElementById('search-input')
    searchResults = document.getElementById('search-results')

    // request and index documents
    fetch('/post/index.json', {
        method: 'get'
    }).then(
        res => res.json()
    ).then(
        res => {

            // index document
            idx = lunr(function() {
                this.ref('url')
                this.field('title')
                this.field('content')

                res.forEach(function(doc) {
                    this.add(doc)
                    documents[doc.url] = {
                        'title': doc.title,
                        'content': doc.content,
                    }
                }, this)
            })

            // data is loaded, next register handler
            registerSearchHandler()
        }
    ).catch(
        err => {
            searchResults.innerHTML = `<p>${err}</p>`
        }
    )
}
//...

Once the browser has loaded the DOM structure, the post list is retrieved and indexed.

<!--...-->
</script>

<input id="search-input" type="text" placeholder="Loading..." name="search">

<section id="search-results" class="search"></section>

Only the search input field is actually visible.

This page can be mapped to /search. For a full view of the search.md file, check it out in the example site of the Hugo lithium theme.

Hope this article helped adding a search feature to your Hugo site 😊

And as always remember:

Satisfying solutions call for simple actions.

Sources

Of course I had some help creating this search feature for Hugo. Props to these posts:

Foresty - Build a JSON API With Hugo’s Custom Output Formats
Matt Walters - Hugo and Lunr

Categories: JavaScript development
Tags: search , lunrjs , static , javascript
Edit this page
Show statistic for this page