Hugo and Lunr – Client Side Searching

I use Hugo on a static website that has few updates but still needs a bit of maintenance. With a few thousand pages, it also needs a search. For a long time, I was using a Google Custom Search, but I’m not the biggest Google fan and they insert ads now, so I needed a new solution.

Search is the Worst Part

Search is the worst thing about static sites. Scratch that. Search is the worst part about any site. We all bag on WordPress’ search being terrible, but anyone who’s attempted to install and manage anything like ElasticSearch knows that WordPress’ search is actually pretty good. It’s just limited. And by contrast, the complicated world of search is, well, complicated.

That’s the beauty of many CMS tools like WordPress and Drupal and MediaWiki is that they have a rudimentary and perfectly acceptable search built in. And it’s the headache of static tools like Jekyll and Hugo. They simply don’t have it.

Lunr

If you don’t want to use third-party services, and are interested in self hosting your solution, then you’re going to have to look at a JavaScript solution. Mine was Lunr.js, a fairly straightforward tool that searched a JSON file for the items.

There are pros and cons to this. Having it all in javascript means the load on my server is pretty low. At the same time I have to generate the JSON file somehow every time. In addition, every time someone goes to the search page, they have to download that JSON file, which can get pretty big. Mine’s 3 megs for 2000 or so pages. That’s something I need to keep in mind.

This is, by the way, the entire reason I made that massive JSON file the other day.

To include Lunrjs in your site, download the file and put it in your /static/ folder however you want. I have it at /static/js/lunr.js next to my jquery.min.js file. Now when you build your site, the JS file will be copied into place.

The Code

Since this is for Hugo, it has two steps. The first is the markdown code to make the post and the second is the template code to do the work.

Post: Markdown

The post is called search.md and this is the entirety of it:

---
layout: search
title: Search Results
permalink: /search/
categories: ["Search"]
tags: ["Index"]
noToc: true
---

Yep. That’s it.

Template: HTML+GoLang+JS

I have a template file in layouts/_default/ called search.html and that has all the JS code as well as everything else. This is shamelessly forked from Seb’s example code.

{{ partial "header.html" . }}

	{{ .Content }}

	<h3>Search:</h3>
	<input id="search" type="text" id="searchbox" placeholder="Just start typing...">

	<h3>Results:</h3>
	<ul id="results"></ul>

	<script type="text/javascript" src="/js/lunr.js"></script>
	<script type="text/javascript">
	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;
				console.log("index:", pagesIndex);
				lunrIndex = lunr(function() {
					this.field("title", { boost: 10 });
					this.field("tags", { boost: 5 });
					this.field("categories", { boost: 5 });
					this.field("content");
					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 flie:", err);
			});
	}

	// Nothing crazy here, just hook up a listener on the input field
	function initUI() {
		$results = $("#results");
		$("#search").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).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) {
			return;
		}

		// Only show the ten first results
		results.slice(0, 100).forEach(function(result) {
			var $result = $("<li>");
			$result.append($("<a>", {
				href: result.uri,
				text: "» " + result.title
			}));
			$results.append($result);
		});
	}

	// Let's get started
	initLunr();

	$(document).ready(function() {
		initUI();
	});
	</script>
{{ partial "footer.html" . }}

It’s important to note you will also need to call jQuery but I do that in my header.html file since I have a bit of jQuery I use on every page. If you don’t, then remember to include it up by <script type="text/javascript" src="/js/lunr.js"></script> otherwise nothing will work.

Caveats

If you have a large search file, this will make your search page slow to load.

Also I don’t know how to have a form on one page trigger the search on another, but I’m making baby steps in my javascripting.


Posted

in

by

%d bloggers like this: