Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Fast, instant client side search for Hugo static site generator

Super fast, keyboard-optimized, client side Hugo search

This is a fork of and builds upon the work of Eddie Webb's search and Matthew Daly's search explorations.

It's built for the Hugo static site generator, but could be adopted to function with any json index compatible with Fuse fuzzy search library.

To see it in action, go to craigmod.com and press CMD-/ and start typing.

Fast Search



Why client side, why fast?

I believe Fast Software is the Best Software and wanted keyboard-based, super fast search for my homepage / online collection of essays. This method was highly inspired by Sublime Text's CMD-P/CMD-shift-P method of opening files / using functions.

The core precepts of this exploration were:

  • Minimal / zero external dependencies (no jQuery)
  • Smallest possible size added to each page
  • json index only delivered when needed (further minimizing overall impact on page speed / user experience)
  • Keyboard friendly, instant navigation (ala Alfred / macOS Spotlight)

As Eddie Webb points out, this method has the additional benefits of:

  • No NPM, grunt, etc
  • No additional build time steps, just hugo as you would normally
  • Easy to swap out choice of client side search tools, anything that can use a json index

Example: craigmod.com

This search is live on my site, craigmod.com on every page. How to use:

  • Press CMD-/ to invoke search
  • start typing
  • use tab / arrow keys to select result
  • press enter to navigate to that page

Setup

  1. Add index.json file to layouts/_default
  2. Add JSON as additional output format in config.toml
  3. Add search.js and fuse.js (downloaded from fusejs.io) to static/js
  4. Add searchbox html to bottom of layouts/_default/baseof.html
  5. Add css styles to your site's main css file or top of baseof.html
  6. hugo
  7. Visit localhost:1313/
  8. Press CMD-/ to invoke search

You can check the json index by visiting localhost:1313/index.json

Files

layouts/_default/baseof.html addition

Add this block to the bottom of your baseof.html file, just before the footer

 <div id="fastSearch">
   <input id="searchInput" tabindex="0">
   <ul id="searchResults">
   </ul>
 </div>
 <script src="/js/fuse.js"></script> <!-- download and copy over fuse.js file from fusejs.io -->
 <script src="/js/fastsearch.js"></script>

static/js/fastsearch.js

This "makes" the search engine based on the index.json file, and wires up all keyboard handling

var fuse; // holds our search engine
var searchVisible = false; 
var firstRun = true; // allow us to delay loading json data unless search activated
var list = document.getElementById('searchResults'); // targets the <ul>
var first = list.firstChild; // first child of search list
var last = list.lastChild; // last child of search list
var maininput = document.getElementById('searchInput'); // input box for search
var resultsAvailable = false; // Did we get any search results?

// ==========================================
// The main keyboard event listener running the show
//
document.addEventListener('keydown', function(event) {

  // CMD-/ to show / hide Search
  if (event.metaKey && event.which === 191) {
      // 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
      }
  }

  // Allow ESC (27) to close search box
  if (event.keyCode == 27) {
    if (searchVisible) {
      document.getElementById("fastSearch").style.visibility = "hidden";
      document.activeElement.blur();
      searchVisible = false;
    }
  }

  // DOWN (40) arrow
  if (event.keyCode == 40) {
    if (searchVisible && resultsAvailable) {
      console.log("down");
      event.preventDefault(); // stop window from scrolling
      if ( document.activeElement == maininput) { first.focus(); } // if the currently focused element is the main input --> focus the first <li>
      else if ( document.activeElement == last ) { last.focus(); } // if we're at the bottom, stay there
      else { document.activeElement.parentElement.nextSibling.firstElementChild.focus(); } // otherwise select the next search result
    }
  }

  // UP (38) arrow
  if (event.keyCode == 38) {
    if (searchVisible && resultsAvailable) {
      event.preventDefault(); // stop window from scrolling
      if ( document.activeElement == maininput) { maininput.focus(); } // If we're in the input box, do nothing
      else if ( document.activeElement == first) { maininput.focus(); } // If we're at the first item, go to input box
      else { document.activeElement.parentElement.previousSibling.firstElementChild.focus(); } // Otherwise, select the search result above the current active one
    }
  }
});


// ==========================================
// execute search as each character is typed
//
document.getElementById("searchInput").onkeyup = function(e) { 
  executeSearch(this.value);
}


// ==========================================
// fetch some json without jquery
//
function fetchJSONFile(path, callback) {
  var httpRequest = new XMLHttpRequest();
  httpRequest.onreadystatechange = function() {
    if (httpRequest.readyState === 4) {
      if (httpRequest.status === 200) {
        var data = JSON.parse(httpRequest.responseText);
          if (callback) callback(data);
      }
    }
  };
  httpRequest.open('GET', path);
  httpRequest.send(); 
}


// ==========================================
// load our search index, only executed once
// on first call of search box (CMD-/)
//
function loadSearch() { 
  fetchJSONFile('/index.json', function(data){

    var options = { // fuse.js options; check fuse.js website for details
      shouldSort: true,
      location: 0,
      distance: 100,
      threshold: 0.4,
      minMatchCharLength: 2,
      keys: [
        'title',
        'permalink',
        'summary'
        ]
    };
    fuse = new Fuse(data, options); // build the index from the json file
  });
}


// ==========================================
// using the index we loaded on CMD-/, run 
// a search query (for "term") every time a letter is typed
// in the search box
//
function executeSearch(term) {
  let results = fuse.search(term); // the actual query being run using fuse.js
  let searchitems = ''; // our results bucket

  if (results.length === 0) { // no results based on what was typed into the input box
    resultsAvailable = false;
    searchitems = '';
  } else { // build our html 
    for (let item in results.slice(0,5)) { // only show first 5 results
      searchitems = searchitems + '<li><a href="' + results[item].permalink + '" tabindex="0">' + '<span class="title">' + results[item].title + '</span><br /> <span class="sc">'+ results[item].section +'</span> — ' + results[item].date + ' — <em>' + results[item].desc + '</em></a></li>';
    }
    resultsAvailable = true;
  }

  document.getElementById("searchResults").innerHTML = searchitems;
  if (results.length > 0) {
    first = list.firstChild.firstElementChild; // first result container — used for checking against keyboard up/down location
    last = list.lastChild.firstElementChild; // last result container — used for checking against keyboard up/down location
  }
}

CSS Styling

#fastSearch { 
  visibility: hidden;
  position: absolute;
  right: 0px;
  top: 0px;
  display: inline-block;
  width: 300px;
}      

#fastSearch input { 
  padding: 4px 10px;
  width: 100%;
  height: 31px;
  font-size: 1.6em;
  color: #aaa;
  font-weight: bold;
  background-color: #000;
  border-radius: 3px 3px 0px 0px;
  border: none;
  outline: none;
  text-align: left;
  display: inline-block;
}

#searchResults li { 
  list-style: none; 
  margin-left: 0em;
  background-color: #333; 
  border-bottom: 1px dotted #000;
}
  #searchResults li .title { font-size: 1.1em; margin-bottom: 10px; display: inline-block;}

#searchResults { visibility: inherit; display: inline-block; width: 320px; }
#searchResults a { text-decoration: none !important; padding: 10px; display: inline-block; }
  #searchResults a:hover, a:focus { outline: 0; background-color: #666; color: #fff; }
  

layouts/_default/index.json

Hugo already builds indexes of all pages, we can cherry-pick which aspects should be searchable. The result is a newly created JSON index at /index.json

{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

Config.toml

Add this snippet to your config file to instruct Hugo to create the index file in JSON format. (RSS and HTML are default outputs, what's important is to add JSON.

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

Alternately if using a custom _index.md for home page, you can just add the output formats to front matter.

outputs:
- html
- rss
- json

See https://gohugo.io/templates/output-formats#output-formats-for-pages

License

The above cobbled together bits are provided as is under the so-called MIT License. Do whatever ya want with it all.

Copyright 2020 Craig Mod

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@tomcritchlow

This comment has been minimized.

Copy link

tomcritchlow commented Mar 9, 2020

This will work as is for Jekyll sites if you generate an index.json file using:

{"items":[{% for post in site.posts %}{ "title": {{post.title | jsonify}}, "permalink": {{ post.url | prepend: site.baseurl | prepend: site.url | jsonify }}, "date": {{ post.date | date: '%B %-d, %Y' | jsonify }}, "summary": {{ post.content | strip_html | strip_newlines | jsonify }} }{% unless forloop.last %},{% endunless %}{% endfor %}]}

This is adapted from here btw: https://gist.github.com/tomtorggler/45bd5478bc8d3542cb7ddb84e2c8eee7

@sarahnator

This comment has been minimized.

Copy link

sarahnator commented May 16, 2020

Hi,
I'm having an issue with the search results -- each field displayed in the results says "undefined". I opened chrome's inspector and the title, link, and description fields are there, but in red. Someone mentioned the red highlighting means a detached DOM tree. I'm confused and wondering if I made a simple mistake somewhere. Does anyone have ideas?

@truckyforme

This comment has been minimized.

Copy link

truckyforme commented May 28, 2020

Hi,
I'm having an issue with the search results -- each field displayed in the results says "undefined". I opened chrome's inspector and the title, link, and description fields are there, but in red. Someone mentioned the red highlighting means a detached DOM tree. I'm confused and wondering if I made a simple mistake somewhere. Does anyone have ideas?

@sarahnator I think you need to reference the results differently:
results[item].permalink should be results[item].item.permalink
and the same for the rest of results

@froilan

This comment has been minimized.

Copy link

froilan commented May 28, 2020

I have encountered the "undefined" in search results. Upon digging in the code, I noticed the part that generates the html does not access the search results array properly, causing it to show as undefined in html.

In the function executeSearch(term)
Just replace this:

searchitems = searchitems + '<li><a href="' + results[item].permalink + '" tabindex="0">' + '<span class="title">' + results[item].title + '</span><br /> <span class="sc">'+ results[item].section +'</span> — ' + results[item].date + ' — <em>' + results[item].desc + '</em></a></li>';

with this:

  searchitems = searchitems + '<li><a href="' + results[item].item.permalink + '" tabindex="0">' + '<span class="title">' + results[item].item.title + '</span><br /> <span class="sc">'+ results[item].item.section +'</span> — ' + results[item].item.date + ' — <em>' + results[item].item.desc + '</em></a></li>';

The permalink, title, etc. are nested under an "item" key, so you'd need to access from that.

@froilan

This comment has been minimized.

Copy link

froilan commented May 28, 2020

For people encountering duplicate results on searches.
If your Hugo page is not a Single Page App, it will repeatedly execute the index.json.
This will cause the pages to be repeatedly added into the "index" search data. This will cause duplicate search results.

@iootaa

This comment has been minimized.

Copy link

iootaa commented Jul 6, 2020

Seems great! Unfortunately I'm not able to make it work, neither on craigmod.com or locally :

Hitting Shift + / displays Firefox internal search engine

Still nothing

Any idea of solution or way to debug?

Is there a way to always display the search form? (I've tried to set var searchVisible = true; , no succes)

@cmod

This comment has been minimized.

Copy link
Owner Author

cmod commented Jul 6, 2020

@iootaa — 
it's Cmd + /
not Shift + /
:)

@iootaa

This comment has been minimized.

Copy link

iootaa commented Jul 6, 2020

Hi @cmod !
Sorry I should have been more explicit:

  • I have a french keyboard layout and the character / is obtained by typing Shift + :
  • The 'Cmd' seem to be specific to Mac, and as I'm using a PC under Linux, the equivalent is the 'Windows' key.

so I tried both combinations:

Win + : Nothing happens
Win + Shift + : Firefox internal research appears
Shift + : Firefox internal research appears

Keyboard layouts are a mess!
I'll dig more into it later, and try another keybinding.
Thanks for the follow-up!

@quangloc1993

This comment has been minimized.

Copy link

quangloc1993 commented Jul 15, 2020

ur Hugo page is not a Single Page App, it will repeatedly execute the index.json.

do you know how to fix this issue?

@thenktor

This comment has been minimized.

Copy link

thenktor commented Jul 22, 2020

Example: craigmod.com
This search is live on my site, craigmod.com on every page. How to use:
Press CMD-/ to invoke search

On a non Mac keyboard (especially with a non english layout) it probably is impossible to test this 😞

@iootaa

This comment has been minimized.

Copy link

iootaa commented Aug 1, 2020

On a non Mac keyboard (especially with a non english layout) it probably is impossible to test this

Same here. Really frustrating :(

@renard

This comment has been minimized.

Copy link

renard commented Aug 4, 2020

Nice one.

Would it be possible to bind it to a search box in a topbar? CMD+/ is not obviously shown on the webpage.

@quangloc1993

This comment has been minimized.

Copy link

quangloc1993 commented Aug 5, 2020

if anyone want to show search input field, just remove line visibility:hidden

#fastSearch {
visibility: hidden;
position: absolute;
right: 0px;
top: 0px;
display: inline-block;
width: 300px;
}

remove this line : visibility:hidden

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.