Client Side Search Engine for Hugo Website Client Side Search Engine for Hugo Website

Search engine for Hugo without any server or additional build steps

Page content

In this tutorial, we’ll learn how to setup client side search engine for Hugo Website without any backend server or additional build steps required.

Overview

When you generate a website using Hugo static site generator. You write your pages generally in markdown (.md files) so all your content lies in the markdown files.

We are going to follow these steps to create our search engine:-

  1. Update config.toml file to instruct Hugo to generate JSON file of entire website content
  2. Create layouts/_default/index.json to provide a template to Hugo for generating a index.json file
  3. Create static/js/search.js to use Fuse.js for searching content out of this JSON file
  4. Create content/search.md to serve a page for /search URL
  5. Create layouts/_default/search.html to provide a template to render your search page results

Follow the steps:-

Update config.toml

Hugo provide out of the box support to generate content in multiple formats including JSON. All we need to do is to tell Hugo that we want to generate index.json file for entire website content.

Add the following snippet in config.toml file to instruct Hugo to generate JSON output along with HTML and RSS default outputs:

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


Create layouts/_default/index.json

Once we instruct Hugo to generate index.json file, Hugo looks for the template to generate file. Add the following index.json template to specified folder:

layouts/_default/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 -}}


Create search.js

Now create a search.js file which uses jquery, fuse.js, and mark.js to parse generated index.json file, and return matching content, with highlighting.

Please note that initial part of the code in search.js provide config:-

  • summaryInclude: Number of pages to show in search result
  • fuseOptions: keys is important parameter where you can increase the weight of title, contents, tags or categories values to give them high priority in matching the content in search results.
static/js/search.js
summaryInclude=60;
var fuseOptions = {
  shouldSort: true,
  includeMatches: true,
  threshold: 0.0,
  tokenize:true,
  location: 0,
  distance: 100,
  maxPatternLength: 32,
  minMatchCharLength: 1,
  keys: [
    {name:"title",weight:0.8},
    {name:"contents",weight:0.5},
    {name:"tags",weight:0.3},
    {name:"categories",weight:0.3}
  ]
};

var searchQuery = param("s");
if(searchQuery){
  $("#search-query").val(searchQuery);
  executeSearch(searchQuery);
}else {
  $('#search-results').append("<p>Please enter a word or phrase above</p>");
}

function executeSearch(searchQuery){
  $.getJSON( "/index.json", function( data ) {
    var pages = data;
    var fuse = new Fuse(pages, fuseOptions);
    var result = fuse.search(searchQuery);
    console.log({"matches":result});
    if(result.length > 0){
      populateResults(result);
    }else{
      $('#search-results').append("<p>No matches found</p>");
    }
  });
}

function populateResults(result){
  $.each(result,function(key,value){
    var contents= value.item.contents;
    var snippet = "";
    var snippetHighlights=[];
    var tags =[];
    if( fuseOptions.tokenize ){
      snippetHighlights.push(searchQuery);
    }else{
      $.each(value.matches,function(matchKey,mvalue){
        if(mvalue.key == "tags" || mvalue.key == "categories" ){
          snippetHighlights.push(mvalue.value);
        }else if(mvalue.key == "contents"){
          start = mvalue.indices[0][0]-summaryInclude>0?mvalue.indices[0][0]-summaryInclude:0;
          end = mvalue.indices[0][1]+summaryInclude<contents.length?mvalue.indices[0][1]+summaryInclude:contents.length;
          snippet += contents.substring(start,end);
          snippetHighlights.push(mvalue.value.substring(mvalue.indices[0][0],mvalue.indices[0][1]-mvalue.indices[0][0]+1));
        }
      });
    }

    if(snippet.length<1){
      snippet += contents.substring(0,summaryInclude*2);
    }
    //pull template from hugo templarte definition
    var templateDefinition = $('#search-result-template').html();
    //replace values
    var output = render(templateDefinition,{key:key,title:value.item.title,link:value.item.permalink,tags:value.item.tags,categories:value.item.categories,snippet:snippet});
    $('#search-results').append(output);

    $.each(snippetHighlights,function(snipkey,snipvalue){
      $("#summary-"+key).mark(snipvalue);
    });

  });
}

function param(name) {
    return decodeURIComponent((location.search.split(name + '=')[1] || '').split('&')[0]).replace(/\+/g, ' ');
}

function render(templateString, data) {
  var conditionalMatches,conditionalPattern,copy;
  conditionalPattern = /\$\{\s*isset ([a-zA-Z]*) \s*\}(.*)\$\{\s*end\s*}/g;
  //since loop below depends on re.lastInxdex, we use a copy to capture any manipulations whilst inside the loop
  copy = templateString;
  while ((conditionalMatches = conditionalPattern.exec(templateString)) !== null) {
    if(data[conditionalMatches[1]]){
      //valid key, remove conditionals, leave contents.
      copy = copy.replace(conditionalMatches[0],conditionalMatches[2]);
    }else{
      //not valid, remove entire section
      copy = copy.replace(conditionalMatches[0],'');
    }
  }
  templateString = copy;
  //now any conditionals removed we can do simple substitution
  var key, find, re;
  for (key in data) {
    find = '\\$\\{\\s*' + key + '\\s*\\}';
    re = new RegExp(find, 'g');
    templateString = templateString.replace(re, data[key]);
  }
  return templateString;
}


Create content/search.md

Create search.md to create a page solely to respond to /search URL. No content from this page is rendered. As we have defined the front-matter layout as “search”, content is rendered on this page is from template layouts/_default/search.html

content/search.md
---
title: "Search Results"
sitemap:
  priority : 0.1
layout: "search"
---

Nothing on this page will be visible. This file exists solely to respond to /search URL.

Setting a very low sitemap priority will tell search engines this is not important content.


Create layouts/_default/search.html

This is the page rendered when viewing /search in your browser. This example uses the template functionality to load content and all required JS files in the “main” block.

layouts/_default/search.html
{{ define "main" }}
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.2.0/fuse.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/jquery.mark.min.js"></script>
<script src="{{ "js/search.js" | absURL }}"></script>
<section class="resume-section p-3 p-lg-5 d-flex flex-column">
  <div class="my-auto" >
    <form action="{{ "search" | absURL }}">
      <input id="search-query" name="s"/>
    </form>
    <div id="search-results">
     <h3>Matching pages</h3>
    </div>
  </div>
</section>
<!-- this template is sucked in by search.js and appended to the search-results div above. So editing here will adjust style -->
<script id="search-result-template" type="text/x-js-template">
    <div id="summary-${key}">
      <h4><a href="${link}">${title}</a></h4>
      <p>${snippet}</p>
      ${ isset tags }<p>Tags: ${tags}</p>${ end }
      ${ isset categories }<p>Categories: ${categories}</p>${ end }
    </div>
</script>
{{ end }}

You may require to put some efforts on layouts/_default/search.html file to match the search result output as per your theme. You can create any template, as long as you include the third-party libs (jquery, fuse, mark.js) before search.js, it will work.

Customization for Hugo Mainroad Theme

I have created this blog website using Hugo static site generator and Hugo Mainroad theme. I have done some changes in above search.html template to match Mainroad theme.

layouts/_default/search.html
{{ define "main" }}
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.2.0/fuse.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/jquery.mark.min.js"></script>
<script src="{{ "js/search.js" | absURL }}"></script>
<main class="main list" role="main">
  {{- with .Title }}
	<header class="main__header">
    <h1 class="main__title">{{ . }}  <span class="list__lead post__lead" id="search-string"></span></h1>
   
	</header>
  {{- end }}
  <div id="search-results">
  </div>
</main>
<!-- this template is sucked in by search.js and appended to the search-results div above. So editing here will adjust style -->
<script id="search-result-template" type="text/x-js-template">
    <article class="list__item post" id="summary-${key}">
      <header class="list__header">
        <h3 class="list__title post__title ">
          <a href="${link}" rel="bookmark">
            ${title} 
          </a>         
        </h3>
        <div class="list__meta meta">
          <div class="meta__item-categories meta__item">
            <svg class="meta__icon icon icon-category" width="16" height="16" viewBox="0 0 16 16"><path d="m7 2l1 2h8v11h-16v-13z"/></svg>
            <span class="meta__text">
              ${ isset categories } ${categories}${ end }
            </span>
          </div>
          <div class="meta__item-categories meta__item">
            <svg class="meta__icon icon icon-tag" width="16" height="16" viewBox="0 0 32 32"><path d="M32 19c0 1-1 2-1 2L21 31s-1 1-2 1-2-1-2-1L2 16c-1-1-1.4-2-1.4-2S0 12.5 0 11V3C0 1.5.8.8.8.8S1.5 0 3 0h8c1.5 0 3 .6 3 .6S15 1 16 2l15 15s1 1 1 2zM7 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/></svg>
            <span class="meta__text">
              ${ isset tags } ${tags}${ end }
            </span>
          </div>
        </div>
      </header>
      <div class="content list__excerpt post__content clearfix">
        ${snippet}
      </div>
    </article>
</script>
{{ end }}

I have also done some changes in search box widget for Hugo Mainroad theme as follows:-

layouts/partials/widgets/search.html
<div class="widget-search widget">
  <form class="widget-search__form" role="search" method="get" action="{{ "search" | absURL }}">
    <label>
      <input class="widget-search__field" type="search" placeholder="{{ T "search_placeholder" }}" value="" name="s" aria-label="{{ T "search_placeholder" }}">
    </label>
    <input class="widget-search__submit" type="submit" value="Search">
  </form>
</div>

Conclusion

We have learned in this tutorial how easily we can create a client side search engine for our Hugo website. You can follow first four steps blindly and require some efforts for 5th step to match search.html template as per your theme.

Once you are done with all the changes, checkout the search result in your dev server as follows:-

http://localhost:1313/search/?s=searchquery

If you want to see how search results look like for my blog website CodingNConcepts, Hit https://codingnconcepts.com/search/?s=java

Reference: gist.github.com/eddiewebb