Add Search to Your Static Site with Lunr.js (Hugo, Vanilla JS)
Table of Contents
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.
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:
- Update
config.toml
to generate a JSON version of the home page. - Create an output format template for the home page that generates the JSON page data file.
- Write javascript to fetch the JSON page data file and build the Lunr.js search index.
- Create a partial template that allows the user to input and submit a search query.
- Write javascript to retrieve the value entered by the user and generate a list of search results, if any.
- Create a partial template that renders the search results.
- 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"]
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:
|
|
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.
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:
|
|
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 fromindex.json
, andsearchIndex
will contain the Lunr.js search index. - Lines 5-6: The client asynchronously fetches
index.json
, and stores the list of page data in thepagesIndex
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 inpagesIndex
. We specify that three of the four fields available on our page objects (title
,categories
andcontent
) 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 thehref
value, and this value can be used withpagesIndex
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.
- 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:
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).
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:
|
|
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:
|
|
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 theanimationend
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:
|
|
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 returnsresults
. - 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
, thequery
string is modified by callinggetLunrSearchQuery
. In order to understand the purpose of this function, you must understand how thesearchIndex.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:- Call
query.split(" ")
to produce a list of search terms. - If the query only contains a single search term, it is returned unmodified.
- 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. - The modified search term is concatenated to produce a modified query string.
- After all search terms have had a
+
prepended, trailing whitespace is trimmed from the modified query string. - 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.- Call
- 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
andoriginalQuery
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 thehref
field from the JSON page data file and ascore
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 offlatMap
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 isundefined
, we exclude it from the list of search results. - Line 71: For each
hit
, we retrieve the matching page object frompagesIndex
by filtering with the conditionhit.ref
==page.href
. There can only be a single page that matches this condition, so we grab the first element returned by thefilter
method. - Line 72: We update the page object returned from the
filter
method to include thescore
value of the search result.
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>
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:
- 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.
- Whenever a search term appears in the text blurb, it is displayed with bold font weight.
- 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
:
|
|
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
:
|
|
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
andpageContent
.query
is the value entered by the user into the search form text box, andpageContent
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
, providingquery
as the only argument. Add the code below tosearch.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:
- Call
query.split(" ")
to produce a list of search terms. - If the query only contains a single search term, it is returned unmodified.
- 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. - After iterating through all search terms, the final
|
character is removed from the concatenated string. - 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 theRegExp
constructor, along with the global, multi-line and case-insensitive flags to create aRegExp
object that is stored in thesearchQueryRegex
variable.- Call
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 thesearchQueryHits
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 thesentenceBoundaries
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 throughsearchQueryHits
,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 beforelastEndOfSentence
, this means that the last sentence that was added tosearchResultText
contains multiple search terms. In that case, we move on to the nexthitLocation
.Line 106: For each
hitLocation
, we iterate through the list ofsentenceBoundaries
using a traditionalfor
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 currentsentenceBoundary
. When the currentsentenceBoundary
is greater thanhitLocation
, we know that the currentsentenceBoundary
is the end of the sentence containing thehitLocation
.Lines 108-110: Since the current
sentenceBoundary
is the end of the sentence containing thehitLocation
, the previoussentenceBoundary
is the start of the sentence, these two locations are stored in the variablesendOfSentence
andstartOfSentence
, respectively. Also, we updatelastEndOfSentence
to be equal to the value ofendOfSentence
.Lines 111: Using the locations
startOfSentence
andendOfSentence
, we create a substring frompageContent
and trim any leading/trailing whitespace, storing the string asparsedSentence
.Line 112: We append
parsedSentence
and an ellipsis (...
) to the end ofsearchResultText
.Line 113: After parsing the sentence containing the
hitLocation
and updatingsearchResultText
to include this sentence, we do not need to iterate through the list ofsentenceBoundaries
any longer, so webreak
out of thefor
loop.Line 117: After we break out of the inner
for
loop, the first thing we do is call thetokenize
function, providingsearchResultText
as the only argument. The code for this function is given below, add it tosearch.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 theWORD_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 theinput
string, and the length of the word.The
tokenize
function is called with the current value ofsearchResultText
, storing the tokenized list of words in thesearchResultWords
variable.Line 118: Next, we filter
searchResultWords
for all words that are longer than 50 characters and store the result in a variable namedpageBreakers
. 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 thefixPageBreakers
function. Add the code below tosearch.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
andchunkify
functions in great detail since they are fairly self-explanatory. ThelargeWords
argument contains a list of extremely long words and we iterate through this list callingchunkify
for each word.chunkify
accepts two arguments, a word that needs to be broken into chunks (input
) andchunkSize
which is the length of each chunk. The most important thing to note aboutchunkify
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 theinput
string. After doing so for all words inlargeWords
, theinput
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 ofsearchResultText
(in words). If the number of words is greater than or equal to the valueMAX_SUMMARY_LENGTH
, we break out of thefor
loop.Line 124: After constructing
searchResultText
, the first thing we do is callellipsize
. The code for this function is given below, please add it tosearch.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 theinput
string. First, thetokenize
function is called, which produces a list with an object for every word in theinput
string. If the number of words ininput
is less than or equal tomaxLength
, 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 thewords
list produced by thetokenize
function, we know that the last word that is allowed iswords[maxLength]
. Theend
property is the index of the last character of this word within theinput
string. This allows us to create a substring from the start ofinput
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 thereplace
method using thesearchQueryRegex
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 insearchResultText
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 withinsearchResultText
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
|
|
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
:
|
|
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:
|
|
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
|
|
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:- First, results from the previous search request are cleared.
- Second, the list of search results is updated with the results from the current search request.
- Next, the
<div class="primary">
element containing the page content will be hidden (by adding thehide-element
class) and the search results will be shown in their place (by removing thehide-element
class). - 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 theul
element they belong to. Allli
elements are removed from theul
element using awhile
loop.Line 106: The first thing we do in the
updateSearchResults
function is populate aspan
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, thequery
element is in thesearch-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 theinnerHTML
value of this element to the text string returned from theresults.map
method call.Lines 109-110:
hit
is the current search result that themap
method is acting upon in order to construct an HTML representation of a search result. Each search result is represented as ali
element that contains two child elements, the title of the page as a clickable link and a paragraph of text generated by thecreateSearchResultBlurb
function.Line 111: In order to create the page title as a clickable link, first we set the
href
attribute to the value ofhit.href
. Next, by usinghit.title
as theinnerHTML
value of thea
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 ap
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 ali
element. Thejoin
method of this list is called to convert the list into a single string of raw HTML which is set as theinnerHTML
value of the search resultsul
element.Line 117: After generating raw HTML for the search results, we query for all
li
elements that are children of the search resultsul
element and store the result in thetotalSearchResults
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 thetotalSearchResults
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 thegetColorForSearchResult
function.To do so, we iterate through the
li
elements insearchResultListItems
. For eachli
element, the page title can be accessed withli.firstElementChild
. Also, because we created thedata-score
attribute on eachli
element, we can access this value from thedataset
object asli.dataset.score
.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 thehide-element
class.The
showSearchResults
function adds thehide-element
class to the<div class="primary">
element (which contains the regular page content) and removes thehide-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
:
|
|
Lines 252-256: The
handleClearSearchButtonClicked
function is called when the user clicks one of the Clear Search Results buttons. When this occurs, thehideSearchResults
function is called first, which hides the search results and un-hides the regular page content (restoring the page to the normal state). Then, theclearSearchResults
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 theshowSearchResults
function performs. The Clear Search Results buttons are hidden, the<section class="search-results">
element is hidden, and thehide-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 theclear-search-results
class, iterating over the result and assigning thehandleClearSearchButtonClicked
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.