arrow_back
Back

Hugo client-side search: Lunr, JSON index, and search UI

Andrew Dorokhov Andrew Dorokhov schedule 2 min read
menu_book Table of Contents

Build a client-side search with open_in_new Lunr . Lunr needs a JSON list of documents (title + body). Hugo can emit that list.

JSON index layout

layouts/_default/search.json:

{
  "results": [
    {{- range $index, $page := .Site.RegularPages }}
    {{- if $index -}} , {{- end }}
    {
      "href": {{ .Permalink | jsonify }},
      "title": {{ .Title | jsonify }},
      "body": {{ .Content | plainify | jsonify }}
    }
    {{- end }}
  ]
}

Search content page

---
title: "Search"
draft: false
outputs:
- HTML
- JSON
layout: search
---

After build: /search/index.json.

Search layout

layouts/_default/search.html:

{{ define "main" }}

  <h2>{{ .Title }}</h2>

  <input type="search" id="searchField">
  <button id="searchButton">Search</button>
  <input id="allwords" type="checkbox">
  <label for="allwords">Require all words</label>

  <div id="output">
    <p>Waiting for search input</p>
  </div>
  <script src="//unpkg.com/lunr@2.3.6/lunr.js"></script>
  <script src="//unpkg.com/axios@0.19.0/dist/axios.js"></script>
  <script src="{{ "js/search.js" | relURL }}"></script>

{{ end }}

search.js

'use strict'
window.SearchApp = {
  searchField: document.getElementById("searchField"),
  searchButton: document.getElementById("searchButton"),
  allwords: document.getElementById("allwords"),
  output: document.getElementById("output"),
  searchData: {},
  searchIndex: {}
};

axios
  .get('/search/index.json')
  .then(response => {
    SearchApp.searchData = response.data;
    SearchApp.searchIndex = lunr(function () {
      this.pipeline.remove(lunr.stemmer);
      this.searchPipeline.remove(lunr.stemmer);
      this.ref('href');
      this.field('title');
      this.field('body');
      response.data.results.forEach(e => {
        this.add(e);
      });
    });
  });

SearchApp.searchButton.addEventListener('click', search);

function search() {
  let searchText = SearchApp.searchField.value;

  searchText = searchText
    .split(" ")
    .map(word => { return word + "*" })
    .join(" ");

  if (SearchApp.allwords.checked) {
    searchText = searchText
      .split(" ")
      .map(word => { return "+" + word })
      .join(" ");
  }

  let resultList = SearchApp.searchIndex.search(searchText);

  let list = [];
  resultList.map(entry => {
    SearchApp.searchData.results.filter(d => {
      if (entry.ref == d.href) {
        list.push(d);
      }
    })
  });

  display(list);
}

function display(list) {
  SearchApp.output.innerText = '';
  if (list.length > 0) {
    const ul = document.createElement("ul");
    list.forEach(el => {
      const li = document.createElement("li");
      const a = document.createElement("a");
      a.href = el.href;
      a.text = el.title;
      li.appendChild(a);
      ul.appendChild(li);
    });
    SearchApp.output.appendChild(ul);
  } else {
    SearchApp.output.innerHTML = "Nothing found";
  }
}

Trailing * enables prefix matching; + requires every word when the checkbox is on. Stemming is disabled for more predictable matches.

See also custom JSON output for section-level JSON feeds.

code

Need Help with Development?

Happy to help — reach out via the contacts or go straight to my Upwork profile.

work View Upwork Profile arrow_forward
Next Article

Hugo data sources: local files, remote JSON, and GetPage

arrow_forward