Bill Baer /bɛːr/

Skip to main content

Banner

 
Bill Baer /bɛːr/
Bill Baer is a Senior Product Manager for Microsoft 365 at Microsoft in Redmond, Washington.

Getting started with Lunr.js and Hugo

Getting started with Lunr.js and Hugo

Getting started with Lunr.js and Hugo

  Lunrjs Hugo Go

UPDATED 4/22/2022

A few months ago I posted on using Algolia Search with Hugo. As mentioned in that post, I currently use Lunr to serve the purposes of search on this site and am finally getting around to posting how to pull together the two.

Lunr, unlike other search services, has no external dependencies and works either within the browser or on the server with node.js - at its core it’s a small, full-text search library for use in the browser. Lunr describes itself as “A bit like Solr, but much smaller and not as bright.”

The beauty of Lunr, particularly with static site generators such as Hugo is that with all the data already in the client, it makes sense to be able to search that data on the client too. As a result, you have a local search index that’s quicker, there’s no network overhead, and remains available and usable even without a network connection.

While, depending on the scope of the content indexed, there’s a bit of overhead loading an index, but the performance of loading results from a pre-served index mitigates some of the initial overhead.

Now on to getting started…

Grab lunr.js

The first thing we’ll need to do is grab and include lunr.js. There are a couple of options here:

Install Lunr with npm:

npm install --save-dev lunr

Use Lunr as a single file for use in browsers using script tags. It can be included from the unpkg CDN like this:

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

Download Lunr and vendorize in /assets/js. This is the approach I personally take. You can grab Lunr.js from its repo at https://github.com/olivernn/lunr.js/blob/master/lunr.js.

Set up the output

Before we get started generating the index, we need to instruct Hugo as to the expected output formats we’d like to see.

To configure our output, open config.toml (or otherwise config.yaml or .json depending upon your preferences) and paste the following (this example is in .toml):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[outputs]
home = ["HTML","RSS","Lunr"]

[outputFormats.Lunr]
baseName = "index"
isPlainText = true
mediaType = "application/json"
notAlternative = true

[params.lunr]
vars = ["title", "summary", "date", "publishdate", "expirydate", "permalink"]
params = ["categories", "tags"]

Modify output params as needed to suit your individual needs.

Build the index

To get started we’ll need to create an index in a format that Lunr can use. For this purpose you’ll need to create a JSON representation of your content.

If you read my post on Algolia, we need to generate an index in a format (key value pairs) that can be read by Lunr.

Create a new file list.lunr.json in /layouts/_default and paste the code below.

1
2
3
4
5
{{- $index := slice -}}
{{- range $page := $.Site.RegularPages -}}
    {{- $index = $index | append (dict "title" $page.Title "href" $page.Permalink "content" ($page.Content | plainify | jsonify) "summary" ($page.Summary | plainify | safeHTML) "tags" (delimit $page.Params.tags " ") ) -}}
{{- end -}}
{{- $index | jsonify -}}

NOTE You can update this sample as needed to include the fields you would like included in the output. I elected to use Hugo’s built-in summarization, Summary, over more common examples to mitigate the need to manually truncate the output later.

Out of the box, Hugo automatically takes the first 70 words of your content as its summary and stores it into the .Summary page variable for use in templates. You can customize the summary length by setting summaryLength in your site configuration (config.toml). As a best practice you should customize how HTML tags in the summary are loaded using functions like plainify or safeHTML.

To build the index run hugo or your build command. This will create a slice (array) of all passed arguments as index.json in your build directory, e.g. /public, /build, etc. by adding values to the same variable or key while iterating over your sites’ regular pages and assembling a JSON file with the title, URL, summary, and tags of each.

To review what we’ve done here, we generated an index in a format that Lunr can use and included lunr.js in our page. The next step is to create a search form and script to render our results.

Create a search form

The first thing you need is a search form itself, I’ll defer to your requirements as to how you’d like to implement your form, but at minimum you need an input field with a defined Id we’ll call in our code.

A simple form could be as follows:

1
2
3
<form id="search" role="search">
  <input type="search" id="search-input">
</form>

The important thing is to ensure the element IDs in the form are shared in the glue code later.

Create a results page / template

Now we’ll somewhere to store the results.

In my scenario here I display the search results on the page where the search was initiated using a content template (<template>), replacing the original page content.

The <template> element is a way to hold HTML that is not to be rendered immediately when a page is loaded but can be instantiated subsequently during runtime using JavaScript.

You can think of a template as a content fragment that is stored for future use in a document. Though the parser does process the contents of the <template> element while loading the page, it does so only to ensure that those contents are valid; the element’s contents are not rendered, however.

You’ll also want to create a container for the match counter and place it prior to the content template. This will hold the match counter text from the script below, e.g. “Found n results for “keyword””.

1
<div class="results"></div>
1
2
3
4
5
6
7
8
9
<template id="template" hidden>
  <article>
    <h1 class="title">
      <a class="href"></a>
    </h1>
    <div class="summary"></div>
    <div class="tags"></div>
  </article>
</template>

Wiring it all together

Now that we have our form and results page, we’ll need to glue together Lunr, our index, and our template.

Create a new file, search.js in assets/js and paste the code below.

This script can be used as-is with the example form and template above.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
window.addEventListener("DOMContentLoaded", () => {

    "use strict";
    let index, parse, query;

    const form = document.getElementById("search");
    const input = document.getElementById("search-input");

    form.addEventListener(
        "submit",
        function (event) {
            event.preventDefault();

            const keywords = input.value.trim();
            if (!keywords) return;

            query = keywords;
            initSearchIndex();
        },
        false,
    );

    function handleEvent(e) {
        console.log(e.type);
    }

    async function initSearchIndex() {
        const request = new XMLHttpRequest();
        request.open("GET", "/index.json");
        request.responseType = "json";
        request.addEventListener (
            "load",
            function () {
                parse = {};
                index = lunr(function () {

                    const documents = request.response;

                    this.ref("href");
                    this.field("title", {
                        boost: 20,
                        usePipeline: true,
                        wildcard: lunr.Query.wildcard.TRAILING,
                        presence: lunr.Query.presence.REQUIRED,
                    });
                    this.field("content", {
                        boost: 15,
                    });
                    this.field("summary", {
                        boost: 10,
                    });
                    this.field("tags", {
                        boost: 5,
                    });

                    documents.forEach(function(doc) {
                      this.add(doc);
                      parse[doc.href] = {
                        title: doc.title,
                        content: doc.content,
                        summary: doc.summary,
                        tags: doc.tags,
                      };
                    }, this);
                });
                search(query);
            },
            false,
        );
        request.addEventListener("error", handleEvent);
        request.send(null);
    }

    function search(keywords) {
        const results = index.search(keywords);

        if ("content" in document.createElement("template")) {

          const target = document.querySelector(".is-search-result");

          while (target.firstChild) target.removeChild(target.firstChild);

          const title = document.createElement("h3");
          title.id = "search-results";
          title.className = "subtitle is-size-3";

          if (results.length == 0)
              title.textContent = `No results found for "${keywords}"`;
          else if (results.length == 1)
              title.textContent = `Found one result for "${keywords}"`;
          else
              title.textContent = `Found ${results.length} results for "${keywords}"`;

          target.appendChild(title);
          document.title = title.textContent;

          const template = document.getElementById("is-search-template");

          results.forEach(function(result) {
            const doc = parse[result.ref];
            const element = template.content.cloneNode(true);

            element.querySelector(".is-read-more")
                .href = doc.href;
            element.querySelector(".is-read-more")
                .textContent = doc.title;
            element.querySelector(".summary")
                .textContent = doc.summary;
            element.querySelector(".tags")
                .textContent = doc.tags;
            target.appendChild(element);

          }, this);
        } else {}
    }
  },
  false,
);

Now just include search.js in your site.

That’s about it - now you should be able to build your site as normal and start searching.

This has been a rather brief walkthrough and if you’re interested in seeing how I’m using Lunr and Hugo here, just scroll to the bottom of the page and click the code icon to view the source of this site.

To learn more about Lunr visit https://lunrjs.com/guides/getting_started.html.

| | Permalink to this article
Fingerprint for this articlef520bd978f3567cefd3349d9623a2343
 
 

Comments

 
 
Skip to footer

Social Links