Blog Setup (part 4) : Add Search
[!WARNING]
For my blog, I am no longer using the anatole theme showed in this post and series. I made a Tailwind CSS theme. Read about it in Hugo Theme Development Series
Introduction
Since Hugo generates a static site, the search has to be performed on the client side. Fuse.js is a lightweight library for approximate string matching that runs in the browser. It can take a json file, search for a term and return an array of objects where the matching string was found.
Installation
hugofastsearch is an script that implements Fuse.js in Hugo. Follow the steps in hugofastsearch gist:
- Download and place fuse.js from Fuse.js at theme/anatole/static/js/
- Download and place fastsearch.js from hugofastsearch at theme/anatole/static/js
- Download and place index.json at theme/layouts/_default/index.json
- Link the fuse.js and fastsearch.js using script tag before the end of body section in themes/anatole/layouts/_default/baseof.html
- Place the search bar and search results HTML somewhere in the themes/anatole/layouts/_default/baseof.html
- Add json as output in config.toml in hugo projectAt this point, using
[outputs] home = ["HTML", "RSS", "JSON"]
Ctrl + /
should show pop up the search input box.
Add search button
By default, hugofastsearch uses Ctrl + /
to show the search input box. We can add the search button and input box HTML to themes/anatole/layouts/partials/navabar.html
<li class="nav__list-item">
<div id="fastSearch">
<input id="searchInput" tabindex="0">
<ul id="searchResults">
</ul>
</div>
<li>
<li class="nav__list-item">
<div id="SearchButton">
<a title="Search">
<i class="fas fa-search fa-fw" aria-hidden="true"></i>
</a>
</div>
</li>
Add css for search button, search input box and search results in themes/anatole/assets/custom.css
.nav__list--end>.nav__list-item {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
}
#fastSearch {
visibility: hidden;
max-width: 600px;
white-space: normal;
text-transform: none;
text-align: justify;
min-width: 22vw;
position: relative;
}
#fastSearch input {
width: 100%;
font-size: 1.2em;
font-weight: 400;
border-radius: 3px 3px 0 0;
border: none;
outline: none;
text-align: left;
display: inline-block;
background-color: #ebebeb;
padding: 10px;
box-sizing: border-box;
}
#searchResults {
visibility: inherit;
position: absolute;
display: block;
margin: 0;
padding: 0;
min-width: 22vw;
}
.theme--light #searchResults {
background-color: #fff;
}
.theme--dark #searchResults {
background-color: #152028;
}
#searchResults li {
list-style: none;
background-color: #ebebeb;
margin-top: 2px;
width: 100%;
}
#searchResults a {
text-decoration: none!important;
padding: 10px 15px;
display: block;
}
#searchResults li .title {
font-size: 1.1em;
margin-bottom: 10px;
display: inline-block;
}
.theme--dark #searchResults a {
background-color: #20313c;
}
#searchResults a:hover,
#searchResults a:focus {
outline: 0; background-color: #9f9f9f; color: #fff;
}
.theme--dark #searchResults a:hover,
.theme--dark #searchResults a:focus {
background-color: #324c5d;
}
Modify fastsearch.js to add click events to document and search button.
Move toggle code out of Ctrl + /
keydown event and make a function with it called toggleSearch
and create openSearch
and closeSearch
functions
function toggleSearch() {
// Load json search index if first time invoking search
// Means we don't load json unless searches are going to happen; keep user payload small unless needed
if(firstRun) {
loadSearch(); // loads our json data and builds fuse.js search index
firstRun = false; // let's never do this again
}
// Toggle visibility of search box
if (!searchVisible) {
document.getElementById("fastSearch").style.visibility = "visible"; // show search box
document.getElementById("searchInput").focus(); // put focus in input box so you can just start typing
searchVisible = true; // search visible
}
else {
document.getElementById("fastSearch").style.visibility = "hidden"; // hide search box
document.activeElement.blur(); // remove focus from search box
searchVisible = false; // search not visible
}
}
function openSearch() {
if(!searchVisible) {
toggleSearch();
}
}
function closeSearch() {
if(searchVisible) {
toggleSearch();
}
}
Add event handlers for search button click, search result click and document click
document.getElementById("SearchButton").addEventListener("click", function(e){
e.stopPropagation();
openSearch();
});
document.getElementById("fastSearch").addEventListener("click", function(e){
e.stopPropagation();
});
document.addEventListener("click", function(){
closeSearch();
});
Change search data
Sending and searching full text content is slow especially when there are a lot of posts. Remove full content and add summary and table of contents to index.json
Add series with tag series
:
"series" .Params.series
Remove html tags, newline characters and extra spaces from table of contents and add as toc
:
"toc" (.TableOfContents | replaceRE `<(?:"[^"]*"['"]*|'[^']*'['"]*|[^'">])+>|\\n` "" | replaceRE `\s+` " ")
Format date for showing with search results and add as data
:
"date" (dateFormat "Jan 2, 2006" .Date)
Full index.json
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
{{- $.Scratch.Add "index" (dict
"date" (dateFormat "Jan 2, 2006" .Date)
"title" .Title
"summary" .Summary
"tags" .Params.tags
"categories" .Params.categories
"series" .Params.series
"toc" (.TableOfContents | replaceRE `<(?:"[^"]*"['"]*|'[^']*'['"]*|[^'">])+>|\\n` "" | replaceRE `\s+` " ")
"permalink" .Permalink) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}
Change searchable keys in options passed to fuse.js
In function loadSearch
in fastsearch.js:
keys: [
'title',
'permalink',
'summary',
'categories'
'tags',
'toc'
]
Exact match
hugofastsearch script uses location, distance and threshhold options for fuse.js that enable fuzzy match for the search term. It is approximate match and only searches up to distance * threshold
characters from location. Replace it with complete term match (threshold 0) and no limit on distance (ignoreLocation).
In function loadSearch
in fastsearch.js
var options = {
ignoreLocation: true,
threshold: 0,
}