Skip to main content

Build a Search Bar for Your Hugo Blog With a JSON Index and Some Vanilla JS

Published: 2021-03-06 • Last updated: 2021-04-18

My blog has become a personal knowledge base of sorts. As it’s grown, doing the usual Command + F for browser-search doesn’t always cut it. I sometimes need to search for keywords within a blog post, yet only titles are shown on the blog list page. So, that’s my problem, but how to fix it?

Solutions do exist, but they entail connecting to an external search service, or pulling in a large JS framework. I wanted to keep things as close to home as possible and use good ole vanilla JS.

After much consideration, I added a simple search bar. In a nutshell, it uses a JSON index to search against, then re-renders the blog list on each keyup event.

I’m documenting the relevant bits in case it’s useful for someone else. Let’s dive in.

# Prerequisites

  • Hugo version 0.81.0 or higher is required (since newlines are used in the template dictionaries for readability)

# Files

# Config

config.yaml

In your config file, set the output formats for the homepage. Then add search-related params.

# ...

outputs:
  home:
    - HTML
    - RSS
    - JSON

params:
  search: true
  search_minify: false

# Blog List Template

layouts/blog/list.html

This is a fairly normal blog list template, with a few extras:

  • Search bar
  • Enable search checkbox
  • Regex mode checkbox
  • Page count
{{ define "main" }}
  <h1>{{ .Title }}</h1>
  {{ if site.Params.search }}
  <input
    id="search"
    class="form-control"
    type="text"
    aria-label="Case-insensitive search by title, content, or publish date"
    placeholder="Disabled ..."
    disabled>
  <div id="search_form">
    <div class="form-check">
      <input id="enable_search" class="form-check-input" type="checkbox">
      <label class="form-check-label" for="enable_search">
        Enable search
      </label>
    </div>
    <div class="form-check">
      <input id="regex_mode" class="form-check-input" type="checkbox">
      <label class="form-check-label" for="regex_mode">
        Regex mode
      </label>
    </div>
  </div>
  {{ end }}
  <p id="count">
    Count: {{ len .Pages }}
  </p>
  <ul id="list">
    {{ range .Pages.ByPublishDate.Reverse }}
    <li>
      <span>{{ .PublishDate.Format "2006-01-02" }}</span>
      <a href="{{ .RelPermalink }}">{{ .Title }}</a>
    </li>
    {{ end }}
  </ul>
{{ end }}

# JSON Index

layouts/index.json.json

This is part 1 of 2 of the magic. It iterates all blog posts, then creates a list of relevant fields: Title, PublishDateFormatted, and PlainContent. We configure whether to minify the JSON output with the search_minify param.

{{- $blog := slice -}}

{{- range where site.RegularPages.ByPublishDate.Reverse "Section" "==" "blog" -}}
  {{- $item := dict
    "Title" .Title
    "PublishDateFormatted" (.PublishDate.Format "2006-01-02")
    "RelPermalink" .RelPermalink
    "PlainContent" .Plain -}}

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

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

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

# JS

assets/js/search.js

This is part 2 of 2 of the magic. Here’s how it works:

  • If the search bar exists on the page, then:
    • Add the event listeners. Listen for checkbox change events, and search bar keyup events
    • If the enable search checkbox is checked, fetch the JSON index. During the request, disable the search bar and show a loading placeholder. Once the request completes, enable the search bar. Keep two copies of the JSON index. One original, one filtered, so they can be compared
  • On each keyup event:
    • If regex mode is not checked, then uppercase the search query and compare it against the uppercased index fields. If regex mode is checked, then test the regex query against the uppercased index fields. Either way, if there is a match, add it to the filtered list
    • Re-render the count by checking the length of the filtered list
    • Re-render the blog list. This code will look different for your blog because you must represent your UI in JS. Mine is fairly simple because it’s just a ul element
(function () {
  const SEARCH_ID = 'search';
  const ENABLE_SEARCH_ID = 'enable_search';
  const REGEX_MODE_ID = 'regex_mode';
  const COUNT_ID = 'count';
  const LIST_ID = 'list';

  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.blog;
        filteredList = data.blog;
        enableSearchEl();
        logPerformance('fetchJsonIndex', startTime, performance.now());
      })
      .catch(error =>
        console.error(`Failed to fetch JSON index: ${error.message}`)
      );
  };

  const filterList = regexMode => {
    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();
      const publishDate = item.PublishDateFormatted.toUpperCase();
      if (regexMode) {
        return (
          regexQuery.test(title) ||
          regexQuery.test(content) ||
          regexQuery.test(publishDate)
        );
      } else {
        return (
          title.includes(query) ||
          content.includes(query) ||
          publishDate.includes(query)
        );
      }
    });
  };

  const renderCount = () => {
    const count = `Count: ${filteredList.length}`;
    getCountEl().textContent = count;
  };

  const renderList = () => {
    const newList = document.createElement('ul');
    newList.id = LIST_ID;

    filteredList.forEach(item => {
      const li = document.createElement('li');

      const publishDate = document.createElement('span');
      publishDate.textContent = item.PublishDateFormatted;

      const titleLink = document.createElement('a');
      titleLink.href = item.RelPermalink;
      titleLink.textContent = item.Title;

      li.appendChild(publishDate);
      li.appendChild(document.createTextNode(' '));
      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('keyup', handleSearchEvent);
    getRegexModeEl().addEventListener('change', handleSearchEvent);
  };

  const main = () => {
    if (getSearchEl()) {
      addEventListeners();
    }
  };

  main();
})();

# Import the JS

Import the JS on all pages. This is usually done in your layouts/_default/baseof.html template. Sample

{{ if site.Params.search }}
  {{ $searchJs := resources.Get "js/search.js"
    | resources.ExecuteAsTemplate "js/search.js" .
    | fingerprint }}
  <script src="{{ $searchJs.RelPermalink }}"></script>
{{ end }}

# References