Note: Was used on the old blog version

The Hugo.io project allows easy and fast website generation through static web pages. No external dependencies like databases, server-side scripting languages or running processes are needed to run the website, simple file-hosting like GitHub pages, Amazon S3 or similar is needed.

Searching is however difficult. It needs to be handled on the client-side. Using the lunrjs project and huge inspiration by sebz, who implemented the search also for Hugo, I adapted a search for this site. To work, basically during the generation of the site, also a search index file is created. This file can then be downloaded and searched by the client.

It starts with adding a search page to the site. For me, this is a simple search.md file in content folder. Besides, the frontmatter, no extra data is written.

---
title = "Search"
id = "Search"
type = "search"
showPagination = false
---

Notice, the type parameter as it specifies the layout to load. The search layout is specified in /layouts/search/single.html. The required functionality is listed below, and sure the design might need an improvement eventually… Make sure to download a recent version of lunr.js and put it into the static/js/vendor/ folder or change the path in this code piece accordingly.

    <div id="categories-archives" class="main-content-wrap">
        <h5 class="archive-result text-color-base text-xlarge" data-message-zero="nothing found"
            data-message-one="1 match" data-message-other="{n} matches"></h5>
        <form id="filter-form">
            <input id="search" class="form-control input--xlarge" placeholder="Search" autofocus="autofocus"
                   type="text">
        </form>
        <section class="boxes">
            <ul id="results">
            </ul>
        </section>
    </div>
    <script type="text/javascript" src="https://code.jquery.com/jquery-2.1.3.min.js"></script>
    <script type="text/javascript" src="/js/vendor/lunr.js"></script>
    <script type="text/javascript">
      var lunrIndex,
        $results,
        pagesIndex;

      // Initialize lunrjs using our generated index file
      function initLunr() {
        // First retrieve the index file
        $.getJSON("/js/lunr/PagesIndex.json")
          .done(function (index) {
            pagesIndex = index;
            console.log("index:", pagesIndex);

            // Set up lunrjs by declaring the fields we use
            // Also provide their boost level for the ranking
            lunrIndex = lunr(function () {
              this.field("title", {
                boost: 10
              });
              this.field("tags", {
                boost: 5
              });
              this.field("content");

              // ref is the result item identifier (I chose the page URL)
              this.ref("href");

              // Feed lunr with each file and let lunr actually index them
              const lunr = this;
              pagesIndex.forEach(function (page) {
                lunr.add(page);
              });

              this.build();

              $("#search").trigger('keyup');
            });
          })
          .fail(function (jqxhr, textStatus, error) {
            var err = textStatus + ", " + error;
            console.error("Error getting Hugo index flie:", err);
          });
      }

      // Nothing crazy here, just hook up a listener on the input field
      function initUI() {
        $results = $("#results");
        $("#search").keyup(function () {
          $results.empty();

          // Only trigger a search when 2 chars. at least have been provided
          var query = $(this).val();
          if (query.length < 2) {
            return;
          }

          var results = search(query);

          renderResults(results);
        });
      }

      /**
       * Trigger a search in lunr and transform the result
       *
       * @param  {String} query
       * @return {Array}  results
       */
      function search(query) {
        // Find the item in our index corresponding to the lunr one to have more info
        // Lunr result:
        //  {ref: "/section/page1", score: 0.2725657778206127}
        // Our result:
        //  {title:"Page1", href:"/section/page1", ...}
        return lunrIndex.search(query).map(function (result) {
          return pagesIndex.filter(function (page) {
            return page.href === result.ref;
          })[0];
        });
      }

      /**
       * Display the 10 first results
       *
       * @param  {Array} results to display
       */
      function renderResults(results) {
        if (!results.length) {
          return;
        }

        // Only show the ten first results
        results.slice(0, 10).forEach(function (result) {
          var $result = $("<li>");
          $result.append($("<a>", {
            href: '/' + result.href,
            text: "» " + result.title
          }));
          $results.append($result);
        });
      }

      // Let's get started
      initLunr();

      $(document).ready(function () {
        initUI();
      });
    </script>

And now finally, the interesting part of generating the search index file. As I am using gulp for the task of image conversion and so on, I rewrote it to match it the gulp style as shown below. It requires some nodejs plugins to work, which can be installed via npm install --save bluebird gulp recursive-readdir string toml".

const Promise = require("bluebird");

const fs = Promise.promisifyAll(require("fs"));
const gulp = require('gulp');
const path = require("path");
const recursiveReaddir = require('recursive-readdir');
const s = require("string");
const toml = require("toml");

gulp.task('lunr', function () {
  const indexPages = function (contentFolder) {
    return recursiveReaddir(contentFolder)
      .then(function (files) {
        let pagesIndex = [];
        files.forEach(function (file) {
          console.debug(`Processing ${file}`);
          const abspath = path.normalize(file);
          const filename = path.basename(abspath);
          pagesIndex.push(processFile(abspath, filename));
        });
        return pagesIndex;
      }, function (error) {
        console.error("unable to read blog data", error);
      });
  };

  const processFile = function (abspath, filename) {
    let pageIndex;

    if (s(filename).endsWith(".html")) {
      pageIndex = processHTMLFile(abspath, filename);
    } else if (s(filename).endsWith(".md")) {
      pageIndex = processMDFile(abspath, filename);
    }

    return pageIndex;
  };

  const processHTMLFile = function (abspath, filename) {
    return fs.readFile(abspath, function (err, fileContent) {
      const content = fileContent.toString();
      const pageName = s(filename).chompRight(".html").s;
      const href = s(abspath).chompLeft(CONTENT_PATH_PREFIX).s;
      return {
        title: pageName,
        href: href,
        content: s(content).trim().stripTags().stripPunctuation().s
      };
    });
  };

  const configToHref = function (pageConfig) {
    const dateObj = new Date(pageConfig.date);
    const dateStr = dateObj.toISOString().slice(0, 8).replace(/-/g, '/');
    const title = pageConfig.title.toLowerCase().replace(/ /g, '-');
    return dateStr + title;
  };

  const processMDFile = function (abspath, filename) {
    return fs.readFileAsync(abspath)
      .then(function (fileContent) {
        // First separate the Front Matter from the content and parse it
        const contentSplit = fileContent.toString().split("+++");
        try {
          const frontMatter = toml.parse(contentSplit[1].trim());

          //let href = s(abspath).chompLeft(CONTENT_PATH_PREFIX).chompRight(".md").s;
          // href for index.md files stops at the folder name
          //if (filename === "index.md") {
          //  href = s(abspath).chompLeft(CONTENT_PATH_PREFIX).chompRight(filename).s;
          //}
          const href = configToHref(frontMatter);

          // Build Lunr index for this page
          return {
            title: frontMatter.title,
            tags: frontMatter.tags,
            href: href,
            content: s(contentSplit[2]).trim().stripTags().stripPunctuation().s
          };
        } catch (e) {
          console.error(e.message);
        }
      });
  };

  const CONTENT_PATH_PREFIX = "content\\post\\";
  indexPages(CONTENT_PATH_PREFIX)
    .then(function (data) {
      //resolve promises
      Promise.map(data, Promise.props)
        .then(indexPageData => {
          const indexPagesContent = JSON.stringify(indexPageData, null, 2);
          console.debug('Writing PagesIndex.json');
          fs.writeFile('static/js/lunr/PagesIndex.json', indexPagesContent);
        });
    });
});

That’s it. A simple search for a static-page website using Hugo. To see it in action, just head over to the search.