Hugo search with lunr



One of the last things bugging me about my blog migration to Hugo is being able to search posts. I use the blog privately to capture my own notes on topics and want to be able to quickly find them. I quickly ruled out using an external search engine and started experimenting with lunr.

This post outlines two approaches to creating the search index within hugo and enabling on your site. The first approach from victoria.devis quick and easy but it adds the search index to every page. The second approach from palant.info stores the search index in a separate json file which means the search index only needs to be downloaded when a search is requested.

1. Loading a search index with every page

Starting point: https://victoria.dev/blog/add-search-to-hugo-static-sites-with-lunr/

Download lunr from source


cd static/js

wget https://raw.githubusercontent.com/olivernn/lunr.js/master/lunr.js

Create a search form partial template @ layouts/partials/search-form.html

<form id="search"
    action='{{ with .GetPage "/search" }}{{.Permalink}}{{end}}' method="get">
    <label hidden for="search-input">Search site</label>
    <input type="text" id="search-input" name="query"
    placeholder="Type here to search">
    <input type="submit" value="search">
</form>

Create a search page @ layouts/search/list.html

{{ define "main" }}
{{ partial "search-form.html" . }}

<ul id="results">
    <li>
        Enter a keyword above to search this site.
    </li>
</ul>
{{ end }}

Create content/search/_index.md so the search page is rendered

---
title: Search
---

Create a template to build the search index layouts/partials/search-index.html

<script>
window.store = {
    // You can specify your blog section only:
    {{ range where .Site.Pages "Section" "blog" }}
    // For all pages in your site, use "range .Site.Pages"
    // You can use any unique identifier here
    "{{ .Permalink }}": {
        // You can customize your searchable fields using any .Page parameters
        "title": "{{ .Title  }}",
        "tags": [{{ range .Params.Tags }}"{{ . }}",{{ end }}],
        "content": {{ .Content | plainify }}, // Strip out HTML tags
        "url": "{{ .Permalink }}"
    },
    {{ end }}
}
</script>
<!-- Include Lunr and code for your search function,
which you'll write in the next section -->
<script src="/js/lunr.js"></script>
<script src="/js/search.js"></script>

Create static/js/search.js to hold the JavaScript that ties it all together


function displayResults (results, store) {
  const searchResults = document.getElementById('results')
  if (results.length) {
    let resultList = ''
    // Iterate and build result list elements
    for (const n in results) {
      const item = store[results[n].ref]
      resultList += '<li><p><a href="' + item.url + '">' + item.title + '</a></p>'
      resultList += '<p>' + item.content.substring(0, 150) + '...</p></li>'
    }
    searchResults.innerHTML = resultList
  } else {
    searchResults.innerHTML = 'No results found.'
  }
}

// Get the query parameter(s)
const params = new URLSearchParams(window.location.search)
const query = params.get('query')

// Perform a search if there is a query

if (query) {
  // Retain the search input in the form when displaying results
  document.getElementById('search-input').setAttribute('value', query)

  const idx = lunr(function () {
    this.ref('id')
    this.field('title', {
      boost: 15
    })
    this.field('tags')
    this.field('content', {
      boost: 10
    })

    for (const key in window.store) {
      this.add({
        id: key,
        title: window.store[key].title,
        tags: window.store[key].category,
        content: window.store[key].content
      })
    }
  })

  // Perform the search
  const results = idx.search(query)
  // Update the list with results
  displayResults(results, window.store)
}

2. Only using the index when a search is triggered

Starting point: https://palant.info/2020/06/04/the-easier-way-to-use-lunr-search-with-hugo/

Download lunr from source


cd static/js

wget https://raw.githubusercontent.com/olivernn/lunr.js/master/lunr.js


```toml
[outputFormats]
  [outputFormats.SearchIndex]
		baseName = 'search'
    mediaType = 'application/json'

[outputs]
  home = ['HTML', 'RSS', 'SearchIndex']

Create the search index @ layouts/index.searchindex.json

[
  {{- range $index, $page := .Site.RegularPages -}}
    {{- if gt $index 0 -}} , {{- end -}}
    {{- $entry := dict "uri" $page.RelPermalink "title" $page.Title -}}
    {{- $entry = merge $entry (dict "content" ($page.Plain | htmlUnescape)) -}}
    {{- $entry = merge $entry (dict "description" $page.Description) -}}
    {{- $entry = merge $entry (dict "categories" $page.Params.categories) -}}
    {{- $entry | jsonify -}}
  {{- end -}}
]

Create a search page @ layouts/search/list.html

{{ define "main" }}

{{ partial "nav.html" . }}
<h1>{{ .Title }}</h1>

{{ partial "search-form.html" . }}

<ul id="results">
    <li>
        Enter a keyword above to search this site.
    </li>
</ul>

<div class="search-results"></div>

{{ end }}

Create content/search/_index.md so the search page is rendered

---
title: Search
---

Create a search form @ layouts/partials/search-form.html

<form id="search" class="search" role="search">
  <input type="search" id="search-input" class="search-input">
</form>

<template id="search-result" hidden>
  <article class="content post">
    <h2 class="post-title"><a class="summary-title-link"></a></h2>
    <summary class="summary"></summary>
    <div class="read-more-container">
      <a class="read-more-link">Read More ยป</a>
    </div>
  </article>
</template>

<script src="/js/lunr.js"></script>
<script src="/js/lunr-search.js"></script>

/lunr-search.js


window.addEventListener("DOMContentLoaded", function(event)
{
  var index = null;
  var lookup = null;
  var queuedTerm = null;

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

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

    var term = input.value.trim();
    if (!term)
      return;

    startSearch(term);
  }, false);

  function startSearch(term)
  {
    // Start icon animation.
    form.setAttribute("data-running", "true");

    if (index)
    {
      // Index already present, search directly.
      search(term);
    }
    else if (queuedTerm)
    {
      // Index is being loaded, replace the term we want to search for.
      queuedTerm = term;
    }
    else
    {
      // Start loading index, perform the search when done.
      queuedTerm = term;
      initIndex();
    }
  }

  function searchDone()
  {
    // Stop icon animation.
    form.removeAttribute("data-running");

    queuedTerm = null;
  }

  function initIndex()
  {
    var request = new XMLHttpRequest();
    request.open("GET", "/search.json");
    request.responseType = "json";
    request.addEventListener("load", function(event)
    {
      lookup = {};
      index = lunr(function()
      {
        // Uncomment the following line and replace de by the right language
        // code to use a lunr language pack.

        // this.use(lunr.de);

        this.ref("uri");

        // If you added more searchable fields to the search index, list them here.
        this.field("title");
        this.field("content");
        this.field("description");
        this.field("categories");

        for (var doc of request.response)
        {
          this.add(doc);
          lookup[doc.uri] = doc;
        }
      });

      // Search index is ready, perform the search now
      search(queuedTerm);
    }, false);
    request.addEventListener("error", searchDone, false);
    request.send(null);
  }

  function search(term)
  {
    var results = index.search(term);

    // The element where search results should be displayed, adjust as needed.
    var target = document.querySelector(".search-results");

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

    var title = document.createElement("h1");
    title.id = "search-results";
    title.className = "list-title";

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

    var template = document.getElementById("search-result");
    for (var result of results)
    {
      var doc = lookup[result.ref];

      // Fill out search result template, adjust as needed.
      var element = template.content.cloneNode(true);
      element.querySelector(".summary-title-link").href =
          element.querySelector(".read-more-link").href = doc.uri;
      element.querySelector(".summary-title-link").textContent = doc.title;
      element.querySelector(".summary").textContent = truncate(doc.content, 70);
      target.appendChild(element);
    }
    // title.scrollIntoView(true);

    searchDone();
  }

  // This matches Hugo's own summary logic:
  // https://github.com/gohugoio/hugo/blob/b5f39d23b8/helpers/content.go#L543
  function truncate(text, minWords)
  {
    var match;
    var result = "";
    var wordCount = 0;
    var regexp = /(\S+)(\s*)/g;
    while (match = regexp.exec(text))
    {
      wordCount++;
      if (wordCount <= minWords)
        result += match[0];
      else
      {
        var char1 = match[1][match[1].length - 1];
        var char2 = match[2][0];
        if (/[.?!"]/.test(char1) || char2 == "\n")
        {
          result += match[1];
          break;
        }
        else
          result += match[0];
      }
    }
    return result;
  }
}, false);

There we have it! Try the search out @ https://www.beyondwatts.com/search/