Add Search to Your Static Site with Lunr.js (Hugo, Vanilla JS)

Add Search to Your Static Site with Lunr.js (Hugo, Vanilla JS)

Photo by Brian McMahon on Unsplash

Introduction

If you DuckDuckGo or bing! the phrase “add search to static site”, you will find hundreds of articles explaining how a client-side search product like Lunr.js can be integrated with Hugo, Eleventy or Gatsby. Given this fact, I feel compelled to explain why I created this blog post when it appears that this question has been sufficiently answered.

I decided to document my Hugo/Lunr.js solution because unlike other guides I found while researching this subject, my implementation:

  • Uses Vanilla JS and does not require jQuery for DOM manipulation/JSON fetching functions.
  • Generates the JSON page data file file whenever the site is built by creating a simple Hugo template, NOT by npm scripts you have to manually run or integrate with a CI process.
  • Includes unique features to enhance the search UX:

    • Lunr.js gives each search result a "score" indicating the quality of the search hit. I use this score to dynamically alter the hue of the search result link font color: "warm" colors are applied to higher quality results while "cool" colors are applied to lower quality results, producing a gradient effect on the list of search results. This makes the list easier to visually digest since the relative quality of each result is communicated in a simple, intuitive way.
    • IMO, the text below the search result link containing text from the page should use an algorithm to display text that is directly related to the search term entered by the user. Most implementations I found simply truncate the page content to the first N characters, which is lazy and may not be pertinent to what the user is searching for.
    • Rather than navigating to a new page/route when a search is performed, the main content div is hidden and the search results are "unhidden" (shown) in their place. Prominent Clear Search Results buttons are shown and when clicked, the search results are hidden and replaced by the main content div. Why is this better? It reduces a tiny amount of friction from the search process, making the experience like that of a Single Page App.

The items in the list above make my implementation notably different and (IMO) markedly better than the examples I found when I started looking into this topic. Hopefully this makes my decision to create this post in spite of the fact that this topic has been covered extensively easier to understand.

What is a Client-Side Search Tool?

How is it possible to add a search feature to a static site? Let’s back up and instead ask, how is it possible to add any type of dynamic/interactive behavior to a static site? In general, the answer will fall under one of two categories: serverless functions (e.g., AWS Lambda, Azure Functions, etc.) or client-side javascript.

Lunr.js falls under the latter category, which means that all of the work done to produce search results takes place in the client’s browser. How does the client get the data required for this task? Since we are not running a server where a database is available to provide the data, we must generate a file that acts like a database when the site is built and make it accessible from a route/URL exposed by our site.

The file we generate will be in JSON format, containing the data for all posts that need to be searchable (this file will be referred to as the JSON page data file for the remainder of this guide). When a user performs a search, this JSON file will be fetched and Lunr.js will figure out what posts, if any, match what the user searched for. Most of the code that is unique to my implementation is responsible for the next and final step: rendering the search results as HTML and presenting them to the user.

Determine Your Search Strategy

Before you implement Lunr.js or a similar package that provides client-side search capabilities, it is extremely important to understand the strengths and weaknesses of such a product. Lunr.js is a perfect fit for my website, since the total number of pages that are searchable is very small (24 pages at the time this was written). I am by no means a prolific blogger, so over time this number should increase very slowly.

However, if your site has thousands of pages that need to be searchable, a client-side search solution could be a very poor choice. You must closely monitor the size of the JSON page data file since it will be transferred to the client when the user performs a search.

If you include the entire content of each page in the JSON, the file can become very large, very quickly. This will have an obvious impact on the response time of the search request, making your site appear sluggish.

This can be mitigated by omitting the page content from the page data file and including a description of the page instead. This would address the performance issue but would greatly lessen the power of the search feature.

If you have reached this page and your site has a much greater amount of content than mine, I would recommend a product like Algolia rather than Lunr.js. Algolia is a paid product, but there is a free version and it is much better suited to large sites than Lunr.js.

At the very least, generate the page data file in order to understand the size of the JSON for your site. If the file is more than 2 MB, the search UX will be sluggish for clients with a high-speed connection and unusable for clients with slower data rates.

If the file is between 1-2 MB, you need to make a decision based on the quality of the internet service for an average user of your site. At this file size, high speed users should only experience a minor delay when performing a search, but if your average user only has access to 3G, a search could take a minute to execute!

Ideally, you want the JSON page data file to be less than 500 KB, however a high-speed connection will easily handle 1 MB, but this cannot be necessarily assumed for all users.

Install Lunr.js

Lunr.js can be installed locally using npm:

$ npm install --save-dev lunr

This is the method I use in my project. I bundle the lunr.js script with the other javascript files in use on my website and provide the bundle to the client in a single script tag. Of course, you could also have clients download it directly from the unpkg CDN with the script tag below:

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

How to Integrate Lunr.js with Hugo

If you are still with me, let’s get started! There are quite a few things that we must accomplish in order to fully integrate Lunr.js with a Hugo site:

  1. Update config.toml to generate a JSON version of the home page.
  2. Create an output format template for the home page that generates the JSON page data file.
  3. Write javascript to fetch the JSON page data file and build the Lunr.js search index.
  4. Create a partial template that allows the user to input and submit a search query.
  5. Write javascript to retrieve the value entered by the user and generate a list of search results, if any.
  6. Create a partial template that renders the search results.
  7. Write javascript to render the search results on the page.

Codepen with Final Code

You can inspect the final HTML, CSS and JavaScript with the CodePen below. Please note that the search results will be slightly different from the search form on this page since the CodePen only includes (roughly) the first 500 words of the content from each page:

See the Pen Lunr.js Static Site Search Example by Aaron Luna (@a-luna) on CodePen.

I recommend changing the zoom from 1x to 0.5x if you choose to view the code alongside the Result.

Generate JSON Page Data File

One of Hugo’s most useful (and powerful) features is the ability to generate content in any number of formats. By default, Hugo will render an HTML version of each page, as well as an XML version based on an the default internal RSS template.

Serve index.json from Site Root

There are a few other built-in formats that are not enabled by default, including JSON. In your config.toml file, add "JSON" to the home entry in the [outputs] section.

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

If your config.toml file does not already have a section called [outputs], you can copy and paste the entire code block above. By default, Hugo generates HTML and XML versions of each page, so generating a JSON version of the homepage is the only change that will be made by applying the code above.

This tells Hugo to generate the following files in the site root folder: index.html, index.xml, and index.json. If you save config.toml and attempt to build the site, you will be warned that Hugo is unable to locate a layout template for the JSON output format for the home page:

$ hugo server -D
Building sites … WARN 2020/05/29 14:20:17 found no layout file for "JSON" for kind "home": You should create a template file which matches Hugo Layouts Lookup Rules for this combination.

Since the JSON output template cannot be found, you will receive a 404 response if you attempt to access the URL /index.json. Obviously, our next step is to create this template with the goal of generating the page data file.

Create JSON Output Template

Create a file in the root of the layouts folder named index.json and add the content below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{{- $.Scratch.Add "pagesIndex" slice -}}
{{- range $index, $page := .Site.Pages -}}
  {{- if eq $page.Type "post" -}}
    {{- if gt (len $page.Content) 0 -}}
      {{- $pageData := (dict "title" $page.Title "href" $page.Permalink "categories" (delimit $page.Params.categories " ; ") "content" $page.Plain) -}}
      {{- $.Scratch.Add "pagesIndex" $pageData -}}
    {{- end -}}
  {{- end -}}
{{- end -}}
{{- $.Scratch.Get "pagesIndex" | jsonify -}}

Most of this should be self-explanatory. The only line that I am calling attention to is the if statement in Line 3. For my site, I have pages of types other than post that I need to make searchable, so my template includes these other page types with the code below:

{{- if in (slice "post" "flask_api_tutorial" "projects") $page.Type -}}

Hopefully this helps if you also need to include other page types in your site search, the changes you make will depend on the structure of your content folder.

With the JSON output template in place, rebuild the site. When complete, if you navigate to /index.json you should find a JSON list containing all of the page data for your site:

[
  {
    "categories": "",
    "content": "",
    "href": "",
    "title": ""
  },
  ...
]

You can verify that the JSON page data file for my site exists by clicking here.

I do not use the tags taxonomy on my site, preferring to use categories only. If you only use tags or use both and you want the list of tags to be searchable, the code required is nearly identical to the code shown for categories.

If you do not use categories and want the list of tags for each page to be searchable, replace "categories" (delimit $page.Params.categories " ; ") with "tags" (delimit $page.Params.tags " ; ") in the $pageData dict object.

If you use both tags and categories and want both to be searchable, simply add "tags" (delimit $page.Params.tags " ; ") to the $pageData dict object.

Build Lunr.js Search Index

After creating the JSON output template and updating config.toml to generate a JSON version of the home page, rebuild your site. Verify that this succeeds without the error we encountered earlier and also verify that index.json exists in the root of the public folder.

If the format of index.json is incorrect, make any necessary changes to the template file and rebuild your site. When you are satisfied with the content and format, create a new file named search.js.

There are multiple valid ways to structure your site resources, so place this file with the rest of your javascript files (in my project this location is static/js/search.js). Add the content below and save the file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
let pagesIndex, searchIndex;

async function initSearchIndex() {
  try {
    const response = await fetch("/index.json");
    pagesIndex = await response.json();
    searchIndex = lunr(function () {
      this.field("title");
      this.field("categories");
      this.field("content");
      this.ref("href");
      pagesIndex.forEach((page) => this.add(page));
    });
  } catch (e) {
    console.log(e);
  }
}

initSearchIndex();

What exactly is a Lunr search index? We designed the JSON output template to generate a list of page objects (one object for every page that is searchable) with the following fields: href, title, categories and content. We tell Lunr which fields contain data to be searched, and specify one field as an identifier that is returned in the list of search results.

Lunr takes the list of search fields, the identifier and the list of page objects and builds an index. The process of building a search index is complicated and way over my head, you can read the official docs for all the nerdy details.

The Lunr search index is pretty important, so let’s explain how it is built from the index.json file:

  • Line 1: search.js will have two global variables: pagesIndex will contain the JSON page data read in from index.json, and searchIndex will contain the Lunr.js search index.
  • Lines 5-6: The client asynchronously fetches index.json, and stores the list of page data in the pagesIndex global variable.
  • Line 7: The lunr function is a convenience method that creates a new search index from a list of documents. The search index itself is immutable, all documents to be searched must be provided at this time. It is not possible to add or remove documents after the search index has been created.
  • Lines 8-10: Within the lunr function, the first thing we do is define specify all fields that need to be searchable. The set of possible fields are those that exist on the JSON objects stored in pagesIndex. We specify that three of the four fields available on our page objects (title, categories and content) contain text that should be available to search.
  • Line 11: We specify that the remaining field, href, will be used as the identifier. When a search is performed, the list of results will contain the href value, and this value can be used with pagesIndex to retrieve the page title and content to construct the list of search results and render them as HTML.
  • Line 12: Finally, each page object is added to the Lunr search index. This is the only time that pages can be added to the index, since the Lunr search index object is immutable.

    The order of the tasks performed within the lunr function is actually important. According to the official docs, “all fields should be added before adding documents to the index. Adding fields after a document has been indexed will have no effect on already indexed documents.”

  • Line 19: The initSearchIndex function is called automatically when the page loads. Most likely, index.json will by downloaded only once (assuming the user's browser is configured to cache it), and all subsequent searches will provide results immediately.

A search of all documents is performed by calling searchIndex.search. The search method requires a single parameter, a query string to run against the search index. Our next task is to create a form for users to enter the query string and submit a search request.

Create Search Form HTML Template

The search input form I designed for my site is intended to appear simple and minimalist, revealing additional functionality when a search is attempted. For example, when a search does not return any results or the enter key is pressed and the text box is empty, an error message fades in and out directly overlapping the input element as shown below:

Search Input Form (Error Messages)

My implementation is tied directly to the base theme I started with. All of the items in the sidebar of my site are “widgets” which can be hidden/shown and reordered by adjusting values in config.toml. The base theme does not include a “search” widget, but the theme makes creating custom widgets simple and straightforward.

I have removed class names that are related to the base theme/widget system from the HTML below to make the markup more readable and generic (You can compare it to the actual template in the github repo for this site). For the purposes of this guide, I assume that you will adapt my template to suit your needs or create a new layout:

<div id="site-search">
  <h4><span class="underline--magical">Search</span></h4>
  <div class="search-flex">
    <form id="search-form" name="searchForm" role="form" onsubmit="return false">
      <div class="search-wrapper">
        <div class="search-container">
          <i class="fa fa-search"></i>
          <input autocomplete="off" placeholder="" id="search" class="search-input" aria-label="Search site" type="text" name="q">
        </div>
        <div class="search-error hide-element"><span class="search-error-message"></span></div>
      </div>
    </form>
    <button id="clear-search-results-sidebar" class="button clear-search-results hide-element">Clear Search Results</button>
  </div>
</div>

There are two form elements which are hidden when the page is initially loaded, and this is indicated by the presence of hide-element in their class list. As we will see shortly, these elements will be shown when they are needed by removing this class and other elements will be hidden by adding hide-element to their class list (via javascript).

You may be wondering why the search input form is so highly nested. The fade in/out behavior for error messages shown above is one reason, since this requires absolute/relative positioning of stacked elements.

The purple border effect that occurs when the text box is in focus is achieved by wrapping the input element and magnifying glass icon with the <div class=search-container> element.

Also, while the search form may appear simple when finally rendered, it contains multiple flex-containers. Finally, in an attempt to abide by HTML5 recommendations, the widget includes a form element which further increases the levels of nesting.

The CSS for the search input form is rather lengthy, obviously you can ignore the coloring/font size details and adapt the style to your own needs:

.hide-element {
  display: none;
}

.search-flex {
  display: flex;
  flex-flow: column nowrap;
  margin: 8px 0 3px 0;
}

.search-wrapper {
  position: relative;
  z-index: 0;
  height: 31px;
}

.search-container {
  position: absolute;
  z-index: 1;
  width: 100%;
  display: flex;
  flex-flow: row nowrap;
  align-items: center;
  justify-content: space-evenly;
  line-height: 1.3;
  color: hsl(0, 0%, 77%);
  cursor: pointer;
  background-color: hsl(0, 0%, 16%);
  border: 1px solid hsl(0, 0%, 27%);
  border-radius: 0.5em;
}

.search-container:hover {
  border-color: hsl(0, 0%, 40%);
}

.search-container.focused {
  border-color: hsl(261, 100%, 45%);
  box-shadow: 0 0 1px 2px hsl(261, 100%, 45%);
}

#site-search a {
  color: hsl(0, 0%, 85%);
  text-decoration: none;
}

#site-search h4 {
  position: relative;
  font-family: raleway, sans-serif;
  font-weight: 500;
  line-height: 1.2;
  font-size: 1.45em;
  color: #e2e2e2;
  padding: 0 0 5px 0;
  margin: 0;
}

.underline--magical {
    background-image: linear-gradient( 120deg, hsl(261, 100%, 45%) 0%, hsl(261, 100%, 45%) 100% );
    background-repeat: no-repeat;
    background-size: 100% 0.2em;
    background-position: 0 88%;
    transition: background-size 0.25s ease-in;
}

#site-search.expanded #search-form {
  margin: 0 0 15px 0;
}

#search-form .fa {
  margin: 0 auto;
  font-size: 0.9em;
}

#search {
  flex: 1 0;
  max-width: calc(100% - 35px);
  font-size: 0.8em;
  color: hsl(0, 0%, 77%);
  background-color: transparent;
  font-family: roboto, sans-serif;
  box-sizing: border-box;
  -moz-appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
  border: none;
  border-radius: 4px;
  padding: 6px 0;
  margin: 0 5px 0 0;
}

#search:hover {
  background-color: hsl(0, 0%, 16%);
}

#search:focus {
  border-color: hsl(0, 0%, 16%);
  box-shadow: none;
  color: hsl(0, 0%, 77%);
  outline: none;
}

#clear-search-results-mobile {
  display: none;
}

.clear-search-results {
  font-size: 0.9em;
  color: hsl(0, 0%, 85%);
  background-color: hsl(261, 100%, 45%);
  border-radius: 4px;
  line-height: 16px;
  padding: 7px 10px;
  box-sizing: border-box;
  text-align: center;
  width: 100%;
  min-width: 170px;
  transition: all 0.4s linear;
  box-shadow: 0 3px 3px rgba(0, 0, 0, 0.2), 0 3px 20px rgba(0, 0, 0, 0.3);
  cursor: pointer;
  border: none;
}

.clear-search-results:hover,
.clear-search-results:active,
.clear-search-results:focus,
.clear-search-results:active:hover {
  color: hsl(0, 0%, 95%);
  background-color: hsl(261, 100%, 35%);
}

#search-form .search-error.hide-element {
  display: none;
}

.search-error {
  position: absolute;
  z-index: 2;
  top: 4px;
  right: 7px;
  font-size: 0.75em;
  background-color: hsl(0, 0%, 16%);
  height: 24px;
  display: flex;
  transition: all 0.5s ease-out;
  width: 95%;
}

.search-error-message {
  font-style: italic;
  color: #e6c300;
  text-align: center;
  margin: auto;
  line-height: 1;
  transition: all 0.5s ease-out;
}

.fade {
  -webkit-animation: fade 4s;
  animation: fade 4s;
  -moz-animation: fade 4s;
  -o-animation: fade 4s;
}

@-webkit-keyframes fade {
  0% {
    opacity: 0.2;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0.2;
  }
}

@-moz-keyframes fade {
  0% {
    opacity: 0.2;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

@keyframes fade {
  0% {
    opacity: 0.2;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0.2;
  }
}

@-o-keyframes fade {
  0% {
    opacity: 0.2;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0.2;
  }
}

Open search.js and add the code highlighted below:

 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
let pagesIndex, searchIndex;

async function initSearchIndex() {
  try {
    const response = await fetch("/index.json");
    pagesIndex = await response.json();
    searchIndex = lunr(function () {
      this.field("title");
      this.field("categories");
      this.field("content");
      this.ref("href");
      pagesIndex.forEach((page) => this.add(page));
    });
  } catch (e) {
    console.log(e);
  }
}

function searchBoxFocused() {
  document.querySelector(".search-container").classList.add("focused");
  document
    .getElementById("search")
    .addEventListener("focusout", () => searchBoxFocusOut());
}

function searchBoxFocusOut() {
  document.querySelector(".search-container").classList.remove("focused");
}

initSearchIndex();
document.addEventListener("DOMContentLoaded", function () {
  if (document.getElementById("search-form") != null) {
    const searchInput = document.getElementById("search");
    searchInput.addEventListener("focus", () => searchBoxFocused());
  }
})

The code that was just added does not perform any search functionality, it simply adds and removes the purple border around the search box when it is focused/becomes out of focus.

Handle User Search Request

Now that we have our search input form, we need to retrieve the search query entered by the user. Since I did not create a “Submit” button for the search form, the user will submit their query by pressing the Enter key. I also added a click event handler to the magnifying glass icon, to provide another way to submit a search query:

Open search.js and add the lines highlighted below:

30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
initSearchIndex();
document.addEventListener("DOMContentLoaded", function () {
  if (document.getElementById("search-form") != null) {
    const searchInput = document.getElementById("search");
    searchInput.addEventListener("focus", () => searchBoxFocused());
    searchInput.addEventListener("keydown", (event) => {
      if (event.keyCode == 13) handleSearchQuery(event)
    });
    document
      .querySelector(".search-error")
      .addEventListener("animationend", removeAnimation);
    document
      .querySelector(".fa-search")
      .addEventListener("click", (event) => handleSearchQuery(event));
  }
})

The code above creates three new event handlers:

  • Call handleSearchQuery(event) when the Enter key is pressed and the search input text box is in focus.
  • Call removeAnimation when the animationend event occurs on the <div class="search-error"> element.
  • Call handleSearchQuery(event) when the user clicks on the magnifying glass icon.

We need to create the functions that are called by these event handlers, add the content below to search.js and save the file:

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
function handleSearchQuery(event) {
  event.preventDefault();
  const query = document.getElementById("search").value.trim().toLowerCase();
  if (!query) {
    displayErrorMessage("Please enter a search term");
    return;
  }
  const results = searchSite(query)
  if (!results.length) {
    displayErrorMessage("Your search returned no results")
    return
  }
}

function displayErrorMessage(message) {
  document.querySelector(".search-error-message").innerHTML = message;
  document.querySelector(".search-container").classList.remove("focused");
  document.querySelector(".search-error").classList.remove("hide-element");
  document.querySelector(".search-error").classList.add("fade");
}

function removeAnimation() {
  this.classList.remove("fade");
  this.classList.add("hide-element");
  document.querySelector(".search-container").classList.add("focused");
}

function searchSite(query) {
  const originalQuery = query;
  query = getLunrSearchQuery(query);
  let results = getSearchResults(query);
  return results.length
    ? results
    : query !== originalQuery
    ? getSearchResults(originalQuery)
    : [];
}

function getSearchResults(query) {
  return searchIndex.search(query).flatMap((hit) => {
    if (hit.ref == "undefined") return [];
    let pageMatch = pagesIndex.filter((page) => page.href === hit.ref)[0];
    pageMatch.score = hit.score;
    return [pageMatch];
  });
}

function getLunrSearchQuery(query) {
  const searchTerms = query.split(" ");
  if (searchTerms.length === 1) {
    return query;
  }
  query = "";
  for (const term of searchTerms) {
    query += `+${term} `;
  }
  return query.trim();
}

There is a lot of information contained within the code above, so please bear with me and read the following explanations:

  • Line 31: When the Enter key is pressed and the focus is on a form element, the default behavior is to submit the form, which causes the page to reload. Calling event.preventDefault prevents this and stops the event from propgating further.
  • Line 32: When the query entered by the user is retrieved, it is converted to lowercase and any leading and trailing whitespace is removed.
  • Lines 33-36: If nothing has been entered into the search text box, we display an error message to the user (Please enter a search term).
  • Line 37: Since we have verified that an actual value has been entered, we call searchSite which returns results.
  • Lines 38-41: If the total number of results is zero, we display an error message to the user (Your search returned no results).
  • Lines 44-55: These two functions are responsible for fading in and fading out the error message directly on top of the search text box.
  • Line 58: We store a copy of the search query entered by the user in originalQuery.
  • Lines 59, 77-87: Before generating the list of search results by calling searchIndex.search, the query string is modified by calling getLunrSearchQuery. In order to understand the purpose of this function, you must understand how the searchIndex.search method uses the query string.

    Before conducting the search, the query string is parsed to a search query object. Lunr.js provides a special syntax for query strings, allowing the user to isolate search terms to individual fields, use wildcard characters, perform fuzzy matching, etc.

    The default behavior when a search is performed with a query string that contains more than one word is explained below (from the official docs):

    By default, Lunr combines multiple terms together in a search with a logical OR. That is, a search for “foo bar” will match documents that contain “foo” or contain “bar” or contain both. ... By default each term is optional in a matching document, though a document must have at least one matching term.

    IMO, requiring at least one search term to be found in a document rather than requiring all search terms to be found tends to produce low quality search results. For this reason, the getLunrSearchQuery function modifies the query string in the following manner:

    1. Call query.split(" ") to produce a list of search terms.
    2. If the query only contains a single search term, it is returned unmodified.
    3. Otherwise, each search term is prefixed with the + character, which specifies that the term must be present in the document to be considered a match.
    4. The modified search term is concatenated to produce a modified query string.
    5. After all search terms have had a + prepended, trailing whitespace is trimmed from the modified query string.
    6. The modified query string is returned.

    For example, if the user entered vanilla javascript, the getLunrSearchQuery function would produce +vanilla +javascript. How does modifying the query string in this way change the search behavior? Again, from the Lunr.js docs:

    To indicate that a term must be present in matching documents the term should be prefixed with a plus (+)

    In this example, the effect of the getLunrSearchQuery function changes the search behavior from documents containing vanilla OR javascript to documents containing vanilla AND javascript.

  • Lines 60-65: If the modified search query does not produce any results, we will re-try with the original version of the search query, since this will return results where ANY search term is present. Please note however, that query and originalQuery will be the same value if the user entered a single-word search query. In this case, we do not need to re-try the search and an empty list is returned.
  • Line 69: If we have generated the JSON page data file and built the Lunr.js search index correctly, calling searchIndex.search with a query string will produce a list of search results.

    The search results as returned from searchIndex.search only contain the href field from the JSON page data file and a score value that indicates the quality of the search result. Since we will use the title and page content to construct the list of search results, we need to update the search results to include this information.

    I am using the Array.flatMap method to produce a list of search results containing the data from the JSON page data file and the search result score. I learned about this usage of flatMap from MDN, click here if you are interested in the reason why this produces the list of search results.

  • Line 70: If the ref property of the Lunr.js search result object is undefined, we exclude it from the list of search results.
  • Line 71: For each hit, we retrieve the matching page object from pagesIndex by filtering with the condition hit.ref == page.href. There can only be a single page that matches this condition, so we grab the first element returned by the filter method.
  • Line 72: We update the page object returned from the filter method to include the score value of the search result.
  • I couldn’t find anything in the Lunr.js docs that describe exact score ranges and how those ranges correlate to high-quality, average-quality and low-quality search results. However, based on the results I receive from my site, anything above 3.0 is a high-quality result, 2-1.5 appears average, and less than 1.5 seems to be low-quality. This is just my completely amateurish approximation, so take it with a substantial grain of salt.

On my site, if I place a breakpoint at Line 38, submit the query “search” and debug this code, the list of search results that are returned from the searchSite function can be inspected from the console (obviously, the values for content have been truncated since they contain the entire page content):

» results
 (7) [{…}, {…}, {…}, {…}, {…}, {…}, {…}]
      0:
        categories: "Hugo ; Javascript"
        content: "Introduction If you DuckDuckGo or bing! the phrase"
        href: "http://172.20.10.2:1313/blog/add-search-to-static-site-lunrjs-hugo-vanillajs/"
        score: 3.6189999999999998
        title: "Add Search to Your Static Site with Lunr.js (Hugo, Vanilla JS)"
        __proto__: Object
      1:
        categories: "Virtualization"
        content: "The majority of my work consists of C#/.NET develo"
        href: "http://172.20.10.2:1313/blog/optimize-vm-performance-windows10-visual-studio-vmware-fusion/"
        score: 1.985
        title: "How to Optimize Performance of Windows 10 and Visual Studio in VMWare Fusion"
        __proto__: Object
      2:
        categories: "DevOps"
        content: "If you create Heroku apps, you know that the only"
        href: "http://172.20.10.2:1313/blog/continuously-deploy-heroku-azure-devops/"
        score: 1.577
        title: "How to Continuously Deploy a Heroku App with Azure DevOps"
        __proto__: Object
      3:
        categories: "Hugo ; JavaScript"
        content: "Hugo includes a built-in syntax-highlighter called"
        href: "http://172.20.10.2:1313/blog/add-copy-button-to-code-blocks-hugo-chroma/"
        score: 1.537
        title: "Hugo: Add Copy-to-Clipboard Button to Code Blocks with Vanilla JS"
        __proto__: Object
      4:
        categories: "Linux"
        content: "Why would you want to install NGINX from source co"
        href: "http://172.20.10.2:1313/blog/install-nginx-source-code-shell-script/"
        score: 1.11
        title: "Create a Shell Script to Install NGINX from Source On Ubuntu"
        __proto__: Object
      5:
        categories: "Flask ; Python ; Tutorial-Series"
        content: "Project Structure The chart below shows the folder"
        href: "http://172.20.10.2:1313/series/flask-api-tutorial/part-6/"
        score: 0.808
        title: "How To: Create a Flask API with JWT-Based Authentication (Part 6)"
        __proto__: Object
      6:
        categories: "Flask ; Python ; Tutorial-Series"
        content: "Introduction My goal for this tutorial is to provi"
        href: "http://172.20.10.2:1313/series/flask-api-tutorial/part-1/"
        score: 0.717
        title: "How To: Create a Flask API with JWT-Based Authentication (Part 1)"
        __proto__: Object
      length: 7

As you can see, the list of search results is ordered by score and each list item contains all of the information from the JSON page data file. This is everything we need to construct the list of search results and render them as HTML.

Create Search Results HTML Template

Create a new partial template named search_results.html and add the content below:

<section class="search-results hide-element">
  <div id="search-header">
    <div class="search-query search-query-left">search term: <span id="query"></span></div>
    <div class="search-query search-query-right"><span id="results-count"></span><span id="results-count-text"></span></div>
  </div>
  <button id="clear-search-results-mobile" class="button clear-search-results">Clear Search Results</button>
  <ul></ul>
</section>

The partial HTML template and how it is implemented into the page should not be taken and applied blindly to your web site. The layout and CSS you see here are designed to work in concert with the layout templates I have adapted from the Mainroad base theme and there is very little chance that it will make sense on another site unless significant changes are made.

My site is based on a responsive, two-column layout. The two columns are the <div class="primary"> and <aside class="sidebar"> elements shown in the example below. The highlighted elements are the search input form which we just created (<div id="site-search"/>, which is the first widget in the sidebar), and the other is the search results which we will implement now (i.e., <section class="search-results hide-element"/>).

<div class="wrapper flex">
  <div class="primary">
    ...page content
  </div>
  <section class="search-results hide-element"/>
  <aside class="sidebar">
    <div id="site-search"/>
    ...other widgets
  </aside>
</div>

The important thing to take away from this is that the search results are hidden when the page loads. However, when the user submits a search query, the <div class="primary"> element containing the page content will be hidden (by adding the hide-element class) and the search results will be shown in their place (by removing the hide-element class).

The search results will contain prominent buttons labeled Clear Search Results that will restore the original layout when clicked (i.e., the search results will be hidden by adding the hide-element class and the <div class="primary"> element containing the page content will be shown again by removing the hide-element class).

Again, the CSS below for the search results HTML template is what I am using on this site, you may have to modify it substantially in order to use it. Alternatively, you could create your own layout and style for the search results:

.wrapper {
  display: flex;
  padding: 40px;
}

.primary {
  flex: 1 0 68%;
  margin: 0;
  display: flex;
  flex-flow: column nowrap;
}

main {
  margin: 0 20px 0 0;
}

.content {
  font-size: 1em;
}

.sidebar {
  font-size: 1.1em;
  flex: 0 0 30%;
  margin: 0;
  padding: 0;
}

.sidebar > div {
  background-color: hsl(0, 0%, 13%);
  border-radius: 4px;
  margin: 0 0 20px;
  padding: 10px 15px;
  box-shadow: 0 3px 3px rgba(0, 0, 0, 0.2), 0 3px 20px rgba(0, 0, 0, 0.3);
}

.search-results {
  flex: 1 0 68%;
  margin: 0 0 20px 0;
}

.search-query,
.search-detail {
  color: hsl(0, 0%, 70%);
  line-height: 1.2;
}

#results-count {
  font-size: 0.9em;
  margin: 0 6px 0 0;
}

.search-query {
  flex: 1 1 auto;
  font-size: 1.5em;
}

.search-query-left {
  align-self: flex-end;
}

.search-query-right {
  flex: 0 1 auto;
  text-align: right;
  white-space: nowrap;
  align-self: flex-end;
}

.search-result-page-title {
  font-size: 1.4em;
  line-height: 1.2;
}

.search-detail {
  font-size: 1.4em;
}

.search-results ul {
  list-style: none;
  margin: 0 2em 0 0;
  padding: 0;
}

.search-results a {
  text-decoration: none;
}

.search-results p {
  font-size: 0.95em;
  color: hsl(0, 0%, 70%);
  line-height: 1.45;
  margin: 10px 0 0 0;
  word-wrap: break-word;
}

.search-result-item {
  background-color: hsl(0, 0%, 13%);
  padding: 20px;
  margin: 0 0 20px 0;
  border-radius: 4px;
  box-shadow: 0 3px 3px rgba(0, 0, 0, 0.2), 0 3px 20px rgba(0, 0, 0, 0.3);
}

.search-result-item p strong {
  color: hsl(0, 0%, 95%);
}

#search-header {
  display: flex;
  flex-flow: row nowrap;
  justify-content: space-between;
  margin: 0 2em 10px 0;
}

#query {
  color: hsl(0, 0%, 95%);
  font-weight: 700;
  margin: 0 0 0 3px;
}

@media screen and (max-width: 767px) {
  .wrapper {
    display: block;
  }

  .primary {
    margin: 0;
  }

  .sidebar {
    float: none;
    width: 100%;
    margin: 0 0 40px 0;
    padding: 20px 0;
    border-top: 2px dotted hsl(0, 0%, 50%);
  }

  #search-header {
    font-size: 0.85em;
    margin: 0 0 20px 0;
  }

  #clear-search-results-mobile {
    display: block;
    font-size: 1.3em;
    padding: 10px 15px;
    margin: 0 0 20px 0;
  }

  .search-results ul {
    margin: 0;
  }
}

Render Search Results

Fair warning! This section is going to be lengthy since this is where I have created features and behaviors that are (as far as I have seen) distinct from other Lunr.js/Hugo implementations:

  1. The text blurb below the title of each search result is generated by an algorithm that finds sentences in the page content containing one or more search terms. All such sentences are added to the text blurb until the total word count of the blurb exceeds or is equal to a configurable maximum length. If the search term occurs sparingly, the text blurb will be short. There is no minimum length.
  2. Whenever a search term appears in the text blurb, it is displayed with bold font weight.
  3. The search result score is used to set the color of the search result page title. In the videos below, the first search result (i.e., the search result with the highest score) is displayed with a teal font color, and each subsequent page is displayed in a "cooler" color. The final search result (i.e., the lowest/worst score) is displayed in a mid-dark blue color.

You can see all of these features and compare the desktop and mobile user experience in the videos below.

Desktop Experience

Mobile Experience

Create Search Result Blurb

Let’s start with the code that generates the text blurb beneath each search result. Begin by adding Lines 2-4 to search.js:

1
2
3
4
let pagesIndex, searchIndex
const MAX_SUMMARY_LENGTH = 100
const SENTENCE_BOUNDARY_REGEX = /\b\.\s/gm
const WORD_REGEX = /\b(\w*)[\W|\s|\b]?/gm
  • MAX_SUMMARY_LENGTH: Maximum length (in words) of each text blurb. You can change this value if you find that 100 is too short or too long for your taste.

  • SENTENCE_BOUNDARY_REGEX: Since the blurb is comprised of full sentences containing any search term, we need a way to identify where each sentence begins/ends. This regex will be used to produce a list of all sentences from the page content.

  • WORD_REGEX: This is a simple regex that produces a list of words from the text it is applied to. This will be used to check the number of total words in the blurb as it is being built.

Next, add the createSearchResultBlurb function to search.js:

 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
119
120
121
122
123
124
125
126
127
128
function createSearchResultBlurb(query, pageContent) {
  const searchQueryRegex = new RegExp(createQueryStringRegex(query), "gmi");
  const searchQueryHits = Array.from(
    pageContent.matchAll(searchQueryRegex),
    (m) => m.index
  );
  const sentenceBoundaries = Array.from(
    pageContent.matchAll(SENTENCE_BOUNDARY_REGEX),
    (m) => m.index
  );
  let searchResultText = "";
  let lastEndOfSentence = 0;
  for (const hitLocation of searchQueryHits) {
    if (hitLocation > lastEndOfSentence) {
      for (let i = 0; i < sentenceBoundaries.length; i++) {
        if (sentenceBoundaries[i] > hitLocation) {
          const startOfSentence = i > 0 ? sentenceBoundaries[i - 1] + 1 : 0;
          const endOfSentence = sentenceBoundaries[i];
          lastEndOfSentence = endOfSentence;
          parsedSentence = pageContent.slice(startOfSentence, endOfSentence).trim();
          searchResultText += `${parsedSentence} ... `;
          break;
        }
      }
    }
    const searchResultWords = tokenize(searchResultText);
    const pageBreakers = searchResultWords.filter((word) => word.length > 50);
    if (pageBreakers.length > 0) {
      searchResultText = fixPageBreakers(searchResultText, pageBreakers);
    }
    if (searchResultWords.length >= MAX_SUMMARY_LENGTH) break;
  }
  return ellipsize(searchResultText, MAX_SUMMARY_LENGTH).replace(
    searchQueryRegex,
    "<strong>$&</strong>"
  );
}

There is a lot to breakdown in this function, so bear with me and stay focussed!

  • Line 92: The important thing to note about the function definition is that createSearchResultBlurb requires two parameters: query and pageContent. query is the value entered by the user into the search form text box, and pageContent is the full text of the page that was returned as a match for the user's query.

  • Line 93: The first thing that we do is call createQueryStringRegex, providing query as the only argument. Add the code below to search.js:

    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    
    function createQueryStringRegex(query) {
      const searchTerms = query.split(" ");
      if (searchTerms.length == 1) {
        return query;
      }
      query = "";
      for (const term of searchTerms) {
        query += `${term}|`;
      }
      query = query.slice(0, -1);
      return `(${query})`;
    }

    This function creates a regular expression from the user's search query which is designed to find all occurrences of any term in the search query within any given piece of text. This regular expression is created by performing the following steps:

    1. Call query.split(" ") to produce a list of search terms.
    2. If the query only contains a single search term, it is returned unmodified.
    3. Otherwise, the function iterates through the search terms and appends the | character to each word, creating a new string by concatenating the modified search terms.
    4. After iterating through all search terms, the final | character is removed from the concatenated string.
    5. The concatenated string is surrounded by parentheses to create a capturing group and this string is used as the return value.

    For example, if the user entered vanilla javascript, the createQueryStringRegex function would produce (vanilla|javascript). This is a very simple regular expression that finds either the word vanilla OR the word javascript.

    The string value returned from createQueryStringRegex is provided to the RegExp constructor, along with the global, multi-line and case-insensitive flags to create a RegExp object that is stored in the searchQueryRegex variable.

  • Lines 94-97: pageContent.matchAll(searchQueryRegex) is called to produce an array containing the location of all occurrences of any search term within the page content. The list of search term locations is stored in the searchQueryHits variable.

  • Lines 98-101: pageContent.matchAll(SENTENCE_BOUNDARY_REGEX) is called to produce an array containing the location of all period (.) characters that are followed by a whitespace character within the page content. The list of sentence boundary locations is stored in the sentenceBoundaries variable.

  • Line 102: The variable searchResultText is declared as an empty string. Sentences from the page content containing search terms will be parsed and appended to this string, which will be used as the return value when all relevant sentences have been added or the maximum length for the search result text blurb has been reached.

  • Line 103: The variable lastEndOfSentence will be used to keep track of the location of the last sentence that was found containing a search term.

  • Line 104: We use a for..of loop to iterate through searchQueryHits, hitLocation is the current search term location.

  • Line 105: For each hitLocation, the first thing we do is make sure that the location of the search term occurs after the location of the last sentence boundary. If the location of the search term occurs before lastEndOfSentence, this means that the last sentence that was added to searchResultText contains multiple search terms. In that case, we move on to the next hitLocation.

  • Line 106: For each hitLocation, we iterate through the list of sentenceBoundaries using a traditional for loop. We use this type of loop because we need access to the index of the current item.

  • Line 107: We compare the hitLocation to the current sentenceBoundary. When the current sentenceBoundary is greater than hitLocation, we know that the current sentenceBoundary is the end of the sentence containing the hitLocation.

  • Lines 108-110: Since the current sentenceBoundary is the end of the sentence containing the hitLocation, the previous sentenceBoundary is the start of the sentence, these two locations are stored in the variables endOfSentence and startOfSentence, respectively. Also, we update lastEndOfSentence to be equal to the value of endOfSentence.

  • Lines 111: Using the locations startOfSentence and endOfSentence, we create a substring from pageContent and trim any leading/trailing whitespace, storing the string as parsedSentence.

  • Line 112: We append parsedSentence and an ellipsis (...) to the end of searchResultText.

  • Line 113: After parsing the sentence containing the hitLocation and updating searchResultText to include this sentence, we do not need to iterate through the list of sentenceBoundaries any longer, so we break out of the for loop.

  • Line 117: After we break out of the inner for loop, the first thing we do is call the tokenize function, providing searchResultText as the only argument. The code for this function is given below, add it to search.js:

    143
    144
    145
    146
    147
    148
    149
    150
    151
    
    function tokenize(input) {
      const wordMatches = Array.from(input.matchAll(WORD_REGEX), (m) => m);
      return wordMatches.map((m) => ({
        word: m[0],
        start: m.index,
        end: m.index + m[0].length,
        length: m[0].length,
      }));
    }

    This function takes an input string and uses the WORD_REGEX regular expression to capture the individual words from the string. The return value is an array of objects containing (for each word) the word itself, the start/end indices where the word is located within the input string, and the length of the word.

    The tokenize function is called with the current value of searchResultText, storing the tokenized list of words in the searchResultWords variable.

  • Line 118: Next, we filter searchResultWords for all words that are longer than 50 characters and store the result in a variable named pageBreakers. The name should be a clue as to why we are filtering for these words, since extremely long words will fail to cause a line-break leading to layout issues with the page.

  • Lines 119-121: If searchResultText contains any words longer than 50 characters, they need to be broken into smaller pieces. This task is performed by calling the fixPageBreakers function. Add the code below to search.js:

    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    
    function fixPageBreakers(input, largeWords) {
      largeWords.forEach((word) => {
        const chunked = chunkify(word.word, 20);
        input = input.replace(word.word, chunked);
      });
      return input;
    }
    
    function chunkify(input, chunkSize) {
      let output = "";
      let totalChunks = (input.length / chunkSize) | 0;
      let lastChunkIsUneven = input.length % chunkSize > 0;
      if (lastChunkIsUneven) {
        totalChunks += 1;
      }
      for (let i = 0; i < totalChunks; i++) {
        let start = i * chunkSize;
        let end = start + chunkSize;
        if (lastChunkIsUneven && i === totalChunks - 1) {
          end = input.length;
        }
        output += input.slice(start, end) + " ";
      }
      return output;
    }

    I won't explain the fixPageBreakers and chunkify functions in great detail since they are fairly self-explanatory. The largeWords argument contains a list of extremely long words and we iterate through this list calling chunkify for each word.

    chunkify accepts two arguments, a word that needs to be broken into chunks (input) and chunkSize which is the length of each chunk. The most important thing to note about chunkify is that the return value is a single string, not a list of "chunks". This string is constructed by concatenating the chunks into a single string, separated by space characters.

    The string returned from chunkify is used to replace the original really long word in the input string. After doing so for all words in largeWords, the input string (which no longer has any really long words) is returned.

  • Line 122: At this point we are still within the main for loop. Before beginning the next iteration, we check the length of searchResultText (in words). If the number of words is greater than or equal to the value MAX_SUMMARY_LENGTH, we break out of the for loop.

  • Line 124: After constructing searchResultText, the first thing we do is call ellipsize. The code for this function is given below, please add it to search.js:

    179
    180
    181
    182
    183
    184
    185
    
    function ellipsize(input, maxLength) {
      const words = tokenize(input);
      if (words.length <= maxLength) {
        return input;
      }
      return input.slice(0, words[maxLength].end) + "...";
    }

    This function is very simple. It requires two parameters: an input string and a number (maxLength) which is the maximum number of words allowed in the input string. First, the tokenize function is called, which produces a list with an object for every word in the input string. If the number of words in input is less than or equal to maxLength, the original string is returned, unmodified.

    However, if the number of words is greater than maxLength, the string is truncated in an intelligent way. Using the words list produced by the tokenize function, we know that the last word that is allowed is words[maxLength]. The end property is the index of the last character of this word within the input string. This allows us to create a substring from the start of input to the location of the end of the last allowed word. The return value is the truncated string with an ellipsis appended (...).

  • Line 124-127: The last thing we do to searchResultText is call the replace method using the searchQueryRegex created earlier in Line 85 as the first argument (this regular expression is designed to capture any search term entered by the user). Any matches in searchResultText for this regular expression will be replaced by the second argument, '<strong>$&</strong>'. The special syntax $& represents the substring that matched the regular expression (i.e., the search term).

    Summarizing the above, calling the replace method finds all occurrences of any search term entered by the user within searchResultText and wraps the search term in a <strong> element. This will make all search terms appear in bold font weight when rendered as HTML.

If you are still here and you are still awake, that is seriously impressive! The createSearchResultBlurb function takes care of items #1 and #2 in the list of features provided earlier, hopefully that makes the insane level of detail provided understandable.

Polyfill String.matchAll

Before moving on to the next implementation detail, add the code below to search.js:

204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
if (!String.prototype.matchAll) {
  String.prototype.matchAll = function (regex) {
    "use strict";
    function ensureFlag(flags, flag) {
      return flags.includes(flag) ? flags : flags + flag;
    }
    function* matchAll(str, regex) {
      const localCopy = new RegExp(regex, ensureFlag(regex.flags, "g"));
      let match;
      while ((match = localCopy.exec(str))) {
        match.index = localCopy.lastIndex - match[0].length;
        yield match;
      }
    }
    return matchAll(this, regex);
  };
}

This is a polyfill for the String.matchAll method, which is available in most browsers but notably absent from mobile Safari 12, which is still used by a lot of devices. Adding this polyfill will make this method available to all browsers.

Set Font Color Based on Search Result Score

The only item that has not been implemented is probably the most impactful feature, at least visually. This feature uses the search result score to set the color of the search result page title, producing a gradient effect of warm to cool color to communicate which results are determined to be higher- and lower-quality.

Add the code below to search.js:

187
188
189
190
191
192
193
194
195
196
197
198
function getColorForSearchResult(score) {
  const highQualityHue = 171;
  const lowQualityHue = 212;
  return adjustHue(highQualityHue, lowQualityHue, score);
}

function adjustHue(hue1, hue2, score) {
  if (score > 3) return `hsl(${hue1}, 100%, 50%)`;
  const hueAdjust = (parseFloat(score) / 3) * (hue1 - hue2);
  const newHue = hue2 + Math.floor(hueAdjust);
  return `hsl(${newHue}, 100%, 50%)`;
}

The getColorForSearchResult function requires a single argument, score, which is the Lunr.js score for a single search result. If you remember way back when we created the searchSite function, this is a numeric value that describes the quality of the search result.

Also within getColorForSearchResult, we define two values: warmColorHue and coolColorHue. These values are used for the hue component of a HSL color value. If you are unfamiliar with HSL colors, I wholeheartedly recommend using them instead of RBG colors since the individual components are much easier to understand and manipulate through code. A quick definition of these components is given below:

  • Hue: Think of a color wheel. Around 0° and 360° are reds. 120° is where greens are and 240° are blues. Use anything in between 0-360. Values above and below will be modulus 360.
  • Saturation: 0% is completely desaturated (grayscale). 100% is fully saturated (full color).
  • Lightness: 0% is completely dark (black). 100% is completely light (white). 50% is average lightness.

Given the definitions above, we can easily modify a color simply by adjusting the hue component. The adjustHue function leaves both saturation and lightness components untouched at 100% and 50%, respectively, in order to produce fully saturated colors with average lightness.

From my observations of the search results produced by the Lunr.js implementation on this site, the vast majority of search result scores are in the range 0-3. For this reason, if a search result score is greater than 3, we do not perform any calculations to adjust the hue and simply return a HSL color using the warmColorHue value.

However, if the score is less than 3, we will adjust the hue and produce an HSL color that gradually becomes “cooler”. As the score approaches zero, the color becomes closer to the coolColorHue value.

Tieing it All Together

You may be wondering, where is the code that calls the createSearchResultBlurb and getColorForSearchResult functions? IMO, it is much easier to understand how these functions are designed when they are presented on their own. Now, we will close the loop and create the code that uses these functions.

First, add the highlighted line below to the handleSearchQuery function:

33
34
35
36
37
38
39
40
41
42
43
44
45
46
function handleSearchQuery(event) {
  event.preventDefault()
  const query = document.getElementById("search").value.trim().toLowerCase()
  if (!query) {
    displayErrorMessage("Please enter a search term")
    return
  }
  const results = searchSite(query)
  if (!results.length) {
    displayErrorMessage("Your search returned no results")
    return
  }
  renderSearchResults(query, results)
}

The handleSearchQuery function is called when the user submits a search query. If the query returned any results, the renderSearchResults function is called using the query string entered by the user and the list of search results.

Obviously, we need to implement the renderSearchResults function. Next, add the following functions to search.js (In other words, add the code that is highlighted below):

  • renderSearchResults
  • clearSearchResults
  • updateSearchResults
  • showSearchResults
  • scrollToTop
 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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
function renderSearchResults(query, results) {
  clearSearchResults();
  updateSearchResults(query, results);
  showSearchResults();
  scrollToTop();
}

function clearSearchResults() {
  const results = document.querySelector(".search-results ul");
  while (results.firstChild) results.removeChild(results.firstChild);
}

function updateSearchResults(query, results) {
  document.getElementById("query").innerHTML = query;
  document.querySelector(".search-results ul").innerHTML = results
    .map(
      (hit) => `
    <li class="search-result-item" data-score="${hit.score.toFixed(2)}">
      <a href="${hit.href}" class="search-result-page-title">${hit.title}</a>
      <p>${createSearchResultBlurb(query, hit.content)}</p>
    </li>
    `
    )
    .join("");
  const searchResultListItems = document.querySelectorAll(".search-results ul li");
  document.getElementById("results-count").innerHTML = searchResultListItems.length;
  document.getElementById("results-count-text").innerHTML = searchResultListItems.length > 1 ? "results" : "result";
  searchResultListItems.forEach(
    (li) => (li.firstElementChild.style.color = getColorForSearchResult(li.dataset.score))
  );
}

function createSearchResultBlurb(query, pageContent) {
  const searchQueryRegex = new RegExp(createQueryStringRegex(query), "gmi");
  const searchQueryHits = Array.from(
    pageContent.matchAll(searchQueryRegex),
    (m) => m.index
  );
  const sentenceBoundaries = Array.from(
    pageContent.matchAll(SENTENCE_BOUNDARY_REGEX),
    (m) => m.index
  );
  let searchResultText = "";
  let lastEndOfSentence = 0;
  for (const hitLocation of searchQueryHits) {
    if (hitLocation > lastEndOfSentence) {
      for (let i = 0; i < sentenceBoundaries.length; i++) {
        if (sentenceBoundaries[i] > hitLocation) {
          const startOfSentence = i > 0 ? sentenceBoundaries[i - 1] + 1 : 0;
          const endOfSentence = sentenceBoundaries[i];
          lastEndOfSentence = endOfSentence;
          parsedSentence = pageContent.slice(startOfSentence, endOfSentence).trim();
          searchResultText += `${parsedSentence} ... `;
          break;
        }
      }
    }
    const searchResultWords = tokenize(searchResultText);
    const pageBreakers = searchResultWords.filter((word) => word.length > 50);
    if (pageBreakers.length > 0) {
      searchResultText = fixPageBreakers(searchResultText, pageBreakers);
    }
    if (searchResultWords.length >= MAX_SUMMARY_LENGTH) break;
  }
  return ellipsize(searchResultText, MAX_SUMMARY_LENGTH).replace(
    searchQueryRegex,
    "<strong>$&</strong>"
  );
}

function createQueryStringRegex(query) {
  const searchTerms = query.split(" ");
  if (searchTerms.length == 1) {
    return query;
  }
  query = "";
  for (const term of searchTerms) {
    query += `${term}|`;
  }
  query = query.slice(0, -1);
  return `(${query})`;
}

function tokenize(input) {
  const wordMatches = Array.from(input.matchAll(WORD_REGEX), (m) => m);
  return wordMatches.map((m) => ({
    word: m[0],
    start: m.index,
    end: m.index + m[0].length,
    length: m[0].length,
  }));
}

function fixPageBreakers(input, largeWords) {
  largeWords.forEach((word) => {
    const chunked = chunkify(word.word, 20);
    input = input.replace(word.word, chunked);
  });
  return input;
}

function chunkify(input, chunkSize) {
  let output = "";
  let totalChunks = (input.length / chunkSize) | 0;
  let lastChunkIsUneven = input.length % chunkSize > 0;
  if (lastChunkIsUneven) {
    totalChunks += 1;
  }
  for (let i = 0; i < totalChunks; i++) {
    let start = i * chunkSize;
    let end = start + chunkSize;
    if (lastChunkIsUneven && i === totalChunks - 1) {
      end = input.length;
    }
    output += input.slice(start, end) + " ";
  }
  return output;
}

function ellipsize(input, maxLength) {
  const words = tokenize(input);
  if (words.length <= maxLength) {
    return input;
  }
  return input.slice(0, words[maxLength].end) + "...";
}

function showSearchResults() {
  document.querySelector(".primary").classList.add("hide-element");
  document.querySelector(".search-results").classList.remove("hide-element");
  document.getElementById("site-search").classList.add("expanded");
  document.getElementById("clear-search-results-sidebar").classList.remove("hide-element");
}

function scrollToTop() {
  const toTopInterval = setInterval(function () {
    const supportedScrollTop = document.body.scrollTop > 0 ? document.body : document.documentElement;
    if (supportedScrollTop.scrollTop > 0) {
      supportedScrollTop.scrollTop = supportedScrollTop.scrollTop - 50;
    }
    if (supportedScrollTop.scrollTop < 1) {
      clearInterval(toTopInterval);
    }
  }, 10);
}

function getColorForSearchResult(score) {
  const warmColorHue = 171;
  const coolColorHue = 212;
  return adjustHue(warmColorHue, coolColorHue, score);
}

function adjustHue(hue1, hue2, score) {
  if (score > 3) return `hsl(${hue1}, 100%, 50%)`;
  const hueAdjust = (parseFloat(score) / 3) * (hue1 - hue2);
  const newHue = hue2 + Math.floor(hueAdjust);
  return `hsl(${newHue}, 100%, 50%)`;
}

I included the code for the createSearchResultBlurb and getColorForSearchResult functions (and all functions they rely on), in case there was any confusion about the layout/structure I am using for search.js.

  • Lines 94-97: The algorithm of the renderSearchResults function can be quickly understood just by reading the names of the functions that are called. The steps performed in order to render the results are given below:

    1. First, results from the previous search request are cleared.
    2. Second, the list of search results is updated with the results from the current search request.
    3. Next, the <div class="primary"> element containing the page content will be hidden (by adding the hide-element class) and the search results will be shown in their place (by removing the hide-element class).
    4. Finally, the page is automatically scrolled to the top of the page (this is done for mobile devices since the search form is placed at the bottom of the page).
  • Lines 100-103: Since the search results are rendered as li elements, we clear the list by querying for the ul element they belong to. All li elements are removed from the ul element using a while loop.

  • Line 106: The first thing we do in the updateSearchResults function is populate a span element above the list of search results to display the search query entered by the user (click here to see the search results HTML template, the query element is in the search-header div element).

  • Lines 107-108: We populate the list by first querying for the ul element within the search results HTML template. Then, we set the innerHTML value of this element to the text string returned from the results.map method call.

  • Lines 109-110: hit is the current search result that the map method is acting upon in order to construct an HTML representation of a search result. Each search result is represented as a li element that contains two child elements, the title of the page as a clickable link and a paragraph of text generated by the createSearchResultBlurb function.

    PLEASE NOTE that we are defining a attribute on each li element named data-score and setting the value equal to hit.score, which is the Lunr.js score for the quality of the search result.

  • Line 111: In order to create the page title as a clickable link, first we set the href attribute to the value of hit.href. Next, by using hit.title as the innerHTML value of the a element, the title of the page will be used as the link text.

  • Line 112: The second child element is created simply by wrapping the text returned from the createSearchResultBlurb function inside a p element.

  • Line 116: After the map method has iterated through all search results, it returns a list of strings where each list item is the raw HTML for a li element. The join method of this list is called to convert the list into a single string of raw HTML which is set as the innerHTML value of the search results ul element.

  • Line 117: After generating raw HTML for the search results, we query for all li elements that are children of the search results ul element and store the result in the totalSearchResults variable.

  • Lines 118-119: In the search results HTML template, we created placeholder span elements to display the number of results found above the list of search results. To populate these with the correct values, we find these elements by ID and set the value for each using the totalSearchResults variable.

  • Lines 120-122: The last thing we do in the updateSearchResults function is the most important — we finally change the color of the search result page title using the getColorForSearchResult function.

    To do so, we iterate through the li elements in searchResultListItems. For each li element, the page title can be accessed with li.firstElementChild. Also, because we created the data-score attribute on each li element, we can access this value from the dataset object as li.dataset.score.

    If you are unfamiliar with data attributes, they are easy to use and extremely useful. Click here for an MDN article explaining what they are and how to use them.

  • Lines 221-224: After constructing the list of search results in HTML, we need to actually display them since the entire <section class="search-results"> element is hidden when the page initially loads due to the hide-element class.

    The showSearchResults function adds the hide-element class to the <div class="primary"> element (which contains the regular page content) and removes the hide-element class from <section class="search-results">, displaying them in place of the regular page content.

    The Clear Search Results buttons below the search form text box and above the list of search results are also displayed, which allows the user to restore the page to normal.

  • Lines 227-237: Finally, after the regular page content has been hidden and the list of search results is displayed, the page automatically scrolls to the top. This is done solely for mobile users, since the search form is displayed at the bottom of the page if the screen width is less than 767px.

    Without the scrollToTop function, when a mobile user submits a search query, they would have to manually scroll to the top in order to see the first (i.e., the highest-quality) search result. You can see the scroll-to-top behavior in the video demonstrating the mobile search UX from the beginning of this section.

Clear Search Results

Finally, we need a way to clear the search results and display the page content when the user clicks the Clear Search Results button. To do so, add the lines highlighted below to search.js:

252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
function handleClearSearchButtonClicked() {
  hideSearchResults();
  clearSearchResults();
  document.getElementById("search").value = "";
}

function hideSearchResults() {
  document.getElementById("clear-search-results-sidebar").classList.add("hide-element");
  document.getElementById("site-search").classList.remove("expanded");
  document.querySelector(".search-results").classList.add("hide-element");
  document.querySelector(".primary").classList.remove("hide-element");
}

initSearchIndex()
document.addEventListener("DOMContentLoaded", function () {
  if (document.getElementById("search-form") != null) {
    const searchInput = document.getElementById("search")
    searchInput.addEventListener("focus", () => searchBoxFocused())
    searchInput.addEventListener("keydown", (event) => {
      if (event.keyCode == 13) handleSearchQuery(event)
    })
    document
      .querySelector(".search-error")
      .addEventListener("animationend", removeAnimation)
    document
      .querySelector(".fa-search")
      .addEventListener("click", (event) => handleSearchQuery(event))
  }
  document
    .querySelectorAll(".clear-search-results")
    .forEach((button) =>
      button.addEventListener("click", () => handleClearSearchButtonClicked())
    )
})
  • Lines 252-256: The handleClearSearchButtonClicked function is called when the user clicks one of the Clear Search Results buttons. When this occurs, the hideSearchResults function is called first, which hides the search results and un-hides the regular page content (restoring the page to the normal state). Then, the clearSearchResults function is called (this was already defined and explained), and finally any value in the search input text box is cleared.

  • Lines 258-263: The hideSearchResults function performs the exact opposite set of steps that the showSearchResults function performs. The Clear Search Results buttons are hidden, the <section class="search-results"> element is hidden, and the hide-element class is removed from the <div class="primary"> element (which contains the regular page content).

  • Lines 280-284: Since there are two Clear Search Results buttons, we can add an event listener for the click event to each by querying for all elements with the clear-search-results class, iterating over the result and assigning the handleClearSearchButtonClicked function as the event handler.

Conclusion

I hope this post was helpful to you, if you have any questions please leave a comment. Remember, you can view the full, finished CSS and JS using the CodePen I embedded earlier.