Search, comments and an image viewer with Hugo

Last update: September 16, 2018

In a previous article I wrote about why I chose Hugo, a static site generator, as the framework on which to build Verummeum.

Now I would like to go more in depth on the more advanced features I was able to integrate into Verummeum using Hugo. I hope this guide will be interesting for anyone who would like to know about the possibilities of static site generators in general, and of Hugo specifically. I myself underestimated the capabilities of static site generators until recently when I started doing some research, and I am happy to share what I have learned in this article.

One of the features I wanted to include in Verummeum was a search page, where a reader could easily search through all articles within Verummeum. Because a static website does not have a database it can query, my first thought was that I would have to rely on an external service like Google or Algolia to provide a search for Verummeum. Luckily the Hugo documentation described a few alternatives using Lunr.js. Lunr.js is a small Javascript based text search library that can be used to search through JSON documents, to find matching documents based on a search query.

The lunr.js integrations that are described in the Hugo documentation make use of an external build tool like Gulp or Grunt to build the actual JSON file that can be used by lunr.js, but I wanted to avoid having to use any external build tools to build Verummeum. On the Hugo Forum I found instructions on how you can generate the JSON file by only making use of Hugo’s built-in features.

How to implement a Lunr.js Search page in Hugo

Let’s take a look at the code that is necessary to implement a search in Hugo using Lunr.js, without the use of any external build tools.

The first step is to configure Hugo to output your content in the JSON format, since the JSON output format is supported by Hugo, this only requires enabling the JSON output in the config.toml configuration of the Hugo project.

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

Source on github.

The second step is to create an index.json file based on the JSON generated by Hugo. Start by creating an empty index.json file in your layouts folder. Then add the following code to generate the actual JSON.

{{- $.Scratch.Add "index" slice -}}
{{- range where .Site.Pages "Type" "in"  (slice "blog") -}}
{{- $.Scratch.Add "index" (dict "uri" .Permalink "title" .Title "tags" .Params.tags "date" .Params.date "summary" .Summary "banner" .Params.banner) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

Source on github.

We make use of a scratch variable and the dict function to build the content of our file while we iterate over our blog posts using the range function. Then we use the jsonify function to format our content as JSON. You can see the resulting index.json file here.

As you can see inside the dict function, I only include a summary of each blog post. Including the whole content of each blog post would increase the size of the index.json file dramatically, which in turn would slow down the actual search.

Now that we have a JSON file to search through, it is time to include the Lunr.js library in our project. Either download to source or use a CDN link, and add the Lunr.js Javascript library to your HTML.

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

The final step is to implement the search using Lunr.js. Refer to this guide if you would like to read more information on how to use Lunr.js.

This is the Lunr.js based search in Verummeum:

var lunrIndex, $results, pagesIndex;

function getQueryVariable(variable) {
    var query = window.location.search.substring(1);
    var vars = query.split('&');

    for (var i = 0; i < vars.length; i++) {
        var pair = vars[i].split('=');

        if (pair[0] === variable) {
            return decodeURIComponent(pair[1].replace(/\+/g, '%20'));
        }
    }
}

var searchTerm = getQueryVariable('query');

// Initialize lunrjs using our generated index file
function initLunr() {
    // First retrieve the index file
    $.getJSON("/index.json")
        .done(function (index) {
            pagesIndex = index;
            lunrIndex = lunr(function () {
                this.field("title", { boost: 10 });
                this.field("tags", { boost: 5 });
                this.field("summary");
                this.ref("uri");

                pagesIndex.forEach(function (page) {
                    this.add(page)
                }, this)
            });
        })
        .fail(function (jqxhr, textStatus, error) {
            var err = textStatus + ", " + error;
            console.error("Error getting Hugo index file:", err);
        });
}

// Nothing crazy here, just hook up a listener on the input field
function initUI() {
    $results = $("#blog-listing-medium");
    $("#searchinput").keyup(function () {
        $results.empty();

        // Only trigger a search when 2 chars. at least have been provided
        var query = $(this).val();
        if (query.length < 2) {
            return;
        }

        var results = search(query);

        renderResults(results);
    });
}

/**
 * Trigger a search in lunr and transform the result
 *
 * @param  {String} query
 * @return {Array}  results
 */
function search(query) {
    return lunrIndex.search(query, {
        wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING
    }).map(function (result) {
        return pagesIndex.filter(function (page) {
            return page.uri === result.ref;
        })[0];
    });
}

/**
 * Display the 10 first results
 *
 * @param  {Array} results to display
 */
function renderResults(results) {
    if (!results.length) {
        $results.append("<p>No results found</p>");
        return;
    }


    results.sort(function (a, b) {
        return new Date(b.date) - new Date(a.date);
    });

    // Only show the ten first results
    results.slice(0, 100).forEach(function (result) {


        var $resultstring = "<section class='post'>" +
            "<div  class='row'>" +
            "<div class='col-md-4'> " +
            "<div class='image'>" +
            "<a href='" + result.uri + "'>" +
            "<img src='" + result.banner + "' class='img-responsive' alt=''>" +
            "</a>" +
            "</div>" +
            "</div>" +
            "<div class='col-md-8'>" +
            "<h2><a href='" + result.uri + "'>" + result.title + "</a></h2>" +
            "<div class='clearfix'>" +
            "<p class='author-category'>";
        for (i = 0; i < result.tags.length; i++) {
            $resultstring += "<a href='/tags/" + result.tags[i] + "'>" + result.tags[i] + "</a>";
            if ((i + 1) < result.tags.length) {
                $resultstring += ", ";
            }
        }

        $resultstring += "</p>" +
            "<p class='date-comments'>" +
            "<a href='" + result.uri + "'><i class='fa fa-calendar-o'></i> " + moment(result.date).format('LL') + "</a>" +
            "</p>" +
            "</div>" +
            "<div class='intro'>" + result.summary + "</div>" +
            "<p class='read-more'><a href='" + result.uri + "' class='btn btn-template-main'>Continue reading</a>" +
            "</p>" +
            "</div>" +
            "</div>" +
            "</section>";

        var $result = ($resultstring);
        $results.append($result);
    });
}

initLunr();

$(document).ready(function () {
    initUI();
});

Source on github.

The initLunr() function will retrieve our index.json file on page load. The initUI() will start to listen to the search field, so that when a user starts typing a search, we automatically start searching for matching articles. Finally, when articles are found, the renderResults() fuction will render the articles so that they become visible on the searh page in the results section. I have included this Javascript in a separate file lunr-search.js which also needs to be added to the HTML with a script tag. Now we have a fully functional search page!

Comments

I wanted readers of Verummeum to be able to leave comments with feedback and to further discuss the articles. Of course this is also harder to implement on a static website since you are not able to store the comments on a server without running a back-end that would allow you to store and retrieve comments. Again the awesome Hugo documentation describes a few alternatives to implement comments on your static Hugo website.

Hugo projects have Disqus integrated out of the box. If you would like to make use of the Disqus comment service, all you have to do is enable it in the config.toml configuration file. I had encountered Disqus before on other websites and used it to post comments, so I did consider using Disqus as the comment system on Verummeum. The reason I decided not to use Disqus was that I had read about issues with the service, like advertisements appearing on websites in their Disqus comments section and comments wrongfully being flagged as spam. I wanted to use a comment system without advertisements or tracking, so that my reader’s privacy would be respected, and their comments would not be censored or wrongfully marked as spam.

Luckily I found a link to Utterances in the Hugo documentation. Utterances is a free and open source comments widget built on GitHub issues. Since Utterances is open source, I can guarantee that my readers are not being tracked, or that advertisements will never unknowingly appear on Verummeum. All comments are stored in github issues in the Verummeum github repository. When a comment is posted on one of our articles, Utterances will check if an issue already exists for that article, and will either create a new issue or add the comment to the existing issue for that article. This means our comments are not locked into a service like Disqus, but are freely and openly available.

How to enable comments using Utterances

To enable comments with Utterances on your websites, two steps need to be taken. Start by browsing to the Utterances website and follow the instructions to generate a script tag that will need to be added to the HTML of your website, in the location you would like the comments to be shown.

<div id="comments">
    <script src="https://utteranc.es/client.js"
        repo="PhilipVis/philipvis.github.io"
        issue-term="pathname"
        crossorigin="anonymous"
        async>
    </script>
</div>

Source on github.

Now install the Utterances Github widget in the same Github repository you added a link to in the script tag, so that Utterances will be able to create issues in your Github repository. This repository must be public so that your readers can view the issues and comments.

Congratulations! Now you have a working Utterances comment section on your website! Feel free to leave a comment on this article if you would like to try out posting comments using Utterances.

Image viewer

Hugo provides shortcodes to easily add content like images and videos to markdown files, or to do other things where simple markdown falls short. Essentially, a shortcode allows the use of custom HTML inside markdown files. I found the figure shortcode which can be used to include images in your markdown to be a bit lacking. I wanted to be able to show thumbnails inside my articles on which the reader can click to see the full sized version of the image.

That is why I chose to integrate Fancybox into Verummeum. Fancybox is a Javascript image viewer library that allows you to easily show thumbnails that will open the full image on click. The Fancybox image viewer also allows the reader to scroll through all images on a page. If you would like to see this image viewer in action, you can visit one of our reviews which typically contain a lot of images.

How to use the Fancybox image viewer

To start using the Fancybox image viewer, you will first need to include the Fancybox library in your project. Either download the source from the Fancybox website or use a CDN link, and add the Fancybox library to your HTML. Note that both a CSS and Javascript file will need to be added.

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script>

Because we would like to make use of the Fancybox library for images inside the content of our articles, we want to be able to use it in a shortcode, similar to the figure shortcode Hugo provides by default. Luckily Hugo allows you to create [custom shortcodes](https://gohugo.io/templates/shortcode-templates/, so we can easily create a custom shortcode to make use of the Fancybox library.

Create a new HTML file in the layouts/shortcodes folder. The name of this file should be the name you want to use the shortcode with. I chose to name my shortcode file image.html. This is the content of the image.html shortcode file:

<a class="thumbnail-image" data-fancybox="gallery" href="{{.Get "big" }}"><img src="{{.Get "small" }}" alt="{{.Get "alt" }}"></a>

Source on github.

The ‘big’, ‘small’ and ‘alt’ variables are custom values we can fill in when using the shortcode. The ‘small’ variable will contain the url to the thumbnail image, the ‘big’ variable will contain the url to the full-sized image and the ‘alt’ variable can be used to add a value to the ‘alt’ tag of the thumbnail image.

This is an example of how the custom image shortcode can be used in a markdown file:

{{< image small="/img/blog/2018/voyager/voyager_live_desktop_thumbnail.jpg" big="/img/blog/2018/voyager/voyager_live_desktop.jpg" alt="Voyager Live desktop" >}}

You should now have a fully functioning image viewer, usable from inside your markdown files.

Conclusion

Since I start investigating the possibilities of what one can build using Hugo and other static site generators, I have learned that a static website is not as limiting as I expected. In this article I have discussed three of the features that helped me decide a static website was the right choice for Verummeum. I hope I have sparked your interest in Hugo and static site generators!

Share