Hugo How to set up a search feature

Goal

Hugo Result of the search feature

Initial Situation

My web site is using Hugo based on a collection of posts written in markdown. The content of my site is technical notes. I am keeping some tags to filter down my searches, but I am often in need of searching keywords through my posts. For instance I can remember I used FFASM to test some Biztalk pipeline. I want to be able to use that keyword to find the right posts. I have basic/good know ledge of css, html and go. Javascript is out of my confort zone.

Steps Overview

1 generate an index file in json to be used as source for the searches 2 update the navbar to incluse a search bar 3 get a script to perform the search 4 wire some UI components with the seatch script

Updating the Configuration File

Open the config.toml file and add the follwoing entries :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

...

[params]
  includeBootstrapJs = true
  search = true
  search_minify = true

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

Adding the “JSON” value in the outputs collection will trigger the generation of the index file based on the json template.

Generating the Search Index

In my layouts folder, I have created a new file named “index.json.json”.

The aim is to concatenate the content, the title and the path to build a list of matching entries when the search engine is running.

The script is the following :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{{- $post := slice -}}

{{- range where site.RegularPages.ByPublishDate.Reverse "Section" "==" "posts" -}}
	{{- $item := dict
    "Title" .Title
    "RelPermalink" .RelPermalink
    "PlainContent" .Plain -}}

	{{- $post = $post | append $item -}}
{{- end -}}

{{- $object := dict "post" $post -}}

{{- if (eq site.Params.search_minify true) -}}
  {{- $object | jsonify -}}
{{- else -}}
  {{- $jsonifyOptions := dict "indent" "  " -}}
  {{- $object | jsonify $jsonifyOptions -}}
{{- end -}}

When the site is build, the index file is available at the following url : (http://localhost:1313/index.json)[http://localhost:1313/index.json]

In my partial nav component I have appended a form in my Navigation bar with an input box and a button. I kept the 2 checkbox to enable the search and to enforce regex search but they are hidden. They could be removed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
      <div class="bd-search" id="docsearch" data-bd-docs-version="5.2" style="margin-left: auto;">
        <form class="d-flex" role="search">
          <input id="search" class="form-control me-2" type="text" placeholder="press '/' to search"
            aria-label="Search">
          <button id="searchBtn" class="btn btn-outline-secondary" type="button" data-bs-toggle="modal"
            style="margin-left: 1em;" data-bs-target="#searchModal">Search</button>
          <div class="form-check d-none">
            <input class="form-check-input" type="checkbox" value="" id="enable_search" checked>
            <label class="form-check-label" for="enable_search">Search</label>
          </div>
          <div class="form-check d-none">
            <input class="form-check-input" type="checkbox" value="" id="regex_mode" checked>
            <label class="form-check-label" for="regex_mode">Regex</label>
          </div>
        </form>
      </div>

Important names are the id for the objects : “search”, “searchBtn”, “enable_search” and “regex_mode”. Those will be used later in the search script.

Bootstrap Modal Layout

The result of the search will be shown in a modal window. When the search engine is running, the output will be set inside the “ul” component with the id “listOfUrl”.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- Modal -->
<div class="modal fade" id="searchModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1"
  aria-labelledby="staticBackdropLabel" aria-hidden="true">
  <div class="modal-dialog modal-lg">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title" id="staticBackdropLabel">Search Result</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        <p id="count">
          {{ len .Pages }} results :
        </p>
        <ul id="listOfUrl" class="list-group" />
      </div>
      <div class="modal-footer">
        <button id="hideBtn" type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
      </div>
    </div>
  </div>
</div>

Search Script

The search script is reading the json search index, looking for matching entries. It is inspired from (zwbetz)[https://zwbetz.com/build-a-search-bar-for-your-hugo-blog-with-a-json-index-and-some-vanilla-js/] example. Create a search.js script in the “assets” folder and add the script 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
 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
 88
 89
 90
 91
 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
129
130
131
132
133
134
135
(function () {
    const SEARCH_ID = 'search';
    const ENABLE_SEARCH_ID = 'enable_search';
    const REGEX_MODE_ID = 'regex_mode';
    const COUNT_ID = 'count';
    const LIST_ID = 'listOfUrl';
  
    let list = null;
    let filteredList = null;
  
    const logPerformance = (work, startTime, endTime) => {
      const duration = (endTime - startTime).toFixed(2);
      console.log(`${work} took ${duration} ms`);
    };
  
    const getSearchEl = () => document.getElementById(SEARCH_ID);
    const getEnableSearchEl = () => document.getElementById(ENABLE_SEARCH_ID);
    const getRegexModeEl = () => document.getElementById(REGEX_MODE_ID);
    const getCountEl = () => document.getElementById(COUNT_ID);
    const getListEl = () => document.getElementById(LIST_ID);
  
    const disableSearchEl = placeholder => {
      getSearchEl().disabled = true;
      getSearchEl().placeholder = placeholder;
    };
  
    const enableSearchEl = () => {
      getSearchEl().disabled = false;
      getSearchEl().placeholder =
        'Case-insensitive search by title, content, or publish date';
    };
  
    const disableRegexModeEl = () => {
      getRegexModeEl().disabled = true;
    };
  
    const enableRegexModeEl = () => {
      getRegexModeEl().disabled = false;
    };
  
    const fetchJsonIndex = () => {
      const startTime = performance.now();
      disableSearchEl('Loading ...');
      const url = `${window.location.origin}/index.json`;
      fetch(url)
        .then(response => response.json())
        .then(data => {
          list = data.post;
          filteredList = data.post;
          enableSearchEl();
          logPerformance('fetchJsonIndex', startTime, performance.now());
        })
        .catch(error =>
          console.error(`Failed to fetch JSON index: ${error.message}`)
        );
    };
  
    const filterList = regexMode => {
      console.log("filterList");
      const regexQuery = new RegExp(getSearchEl().value, 'i');
      const query = getSearchEl().value.toUpperCase();
      filteredList = list.filter(item => {
        const title = item.Title.toUpperCase();
        const content = item.PlainContent.toUpperCase();
        if (regexMode) {
          return (
            regexQuery.test(title) ||
            regexQuery.test(content)
          );
        } else {
          return (
            title.includes(query) ||
            content.includes(query) 
          );
        }
      });
    };
  
    const renderCount = () => {
      const count = `Count: ${filteredList.length}`;
      getCountEl().textContent = count;
    };
  
    const renderList = () => {
      const newList = document.createElement('ul');
      newList.id = LIST_ID;
      newList.className = "list-group";
      filteredList.forEach(item => {
        const li = document.createElement('li');
  
        const titleLink = document.createElement('a');
        titleLink.href = item.RelPermalink;
        titleLink.textContent = item.Title;
        li.className ="list-group-item";
        li.appendChild(titleLink);
        newList.appendChild(li);
      });
       const oldList = getListEl();
       oldList.replaceWith(newList);
    };
  
    const handleSearchEvent = () => {
      const startTime = performance.now();
      const regexMode = getRegexModeEl().checked;
      filterList(regexMode);
      renderCount();
      renderList();
      logPerformance('handleSearchEvent', startTime, performance.now());
    };
  
    const handleEnableSearchEvent = () => {
      if (getEnableSearchEl().checked) {
        fetchJsonIndex();
        enableRegexModeEl();
      } else {
        disableSearchEl('Disabled ...');
        disableRegexModeEl();
      }
    };
  
    const addEventListeners = () => {
      getEnableSearchEl().addEventListener('change', handleEnableSearchEvent);
      getSearchEl().addEventListener('keydown', handleSearchEvent );
      getRegexModeEl().addEventListener('change', handleSearchEvent);
      handleEnableSearchEvent();
    };
  
    const main = () => {
      if (getSearchEl()) {
        addEventListeners();
      }
    };
  
    main();
  })();

In my partial footer component, I have added the following scripts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{{ partial "bootstrap-js.html" . }}
{{ if site.Params.search }}
{{ $searchJs := resources.Get "search.js"
  | resources.ExecuteAsTemplate "search.js" .
  | fingerprint 
  }}
<script src="{{ $searchJs.RelPermalink }}"></script>
<script>
  $(document).ready(function(){

    // Show the Modal on load
    $("#searchBtn").click(function(){
      $("#searchModal").modal("show");
    });
      
    // Hide the Modal
    $("#hideBtn").click(function(){
      $("#searchModal").modal("hide");
    });
  });
</script>
{{ end }}

I mentioned the bootstrap-js.html in the script because it is referencing the jquery script that is required to run the search.

References