Add Algolia Search To Hugo Static Website

April 15, 2018

Create an account at Algolia. There is a free Community plan which allow 10K Records/100K Operations. Only requirement is to display their logo.

Setup Hugo to generate json for Algolia

Algolia accepts JSON, CSV and TSV format as data source for records. We shall use Hugo’s Custom Output Formats to generate a JSON file for Algolia.

Edit config.toml to add the following.

[outputFormats.Algolia]
baseName = "algolia"
isPlainText = true
mediaType = "application/json"
notAlternative = true

[params.algolia]
vars = ["title", "description", "summary", "date", "lastmod", "permalink"]
params = ["categories", "tags"]

[outputs]
home = ["HTML", "RSS", "Algolia"]

Create the json template/layout file for algolia at layouts/_default/list.algolia.json.

NOTE: The following code is based on tutorial written by Chris Macrae/Forestry

{{/* Generates a valid Algolia search index */}}
{{- $hits := slice -}}
{{- $section := $.Site.GetPage "section" .Section }}
{{- $validVars := $.Param "algolia.vars" | default slice -}}
{{- $validParams := $.Param "algolia.params" | default slice -}}
{{/* Include What Pages? */}}
{{/* range $i, $hit := .Site.AllPages */}}
{{- range $i, $hit := where (where .Site.Pages "Type" "in" (slice "tutorials" "blog")) "IsPage" true -}}
  {{- $dot := . -}}
  {{- if or (and ($hit.IsDescendant $section) (and (not $hit.Draft) (not $hit.Params.private))) $section.IsHome -}}
    {{/* Set the hit's objectID */}}
    {{- .Scratch.SetInMap $hit.File.Path "objectID" $hit.UniqueID -}}
    {{/* Store built-in page variables in iterable object */}}
    {{- .Scratch.SetInMap "temp" "content" $hit.Plain -}}
    {{- .Scratch.SetInMap "temp" "date" $hit.Date.UTC.Unix -}}
    {{- .Scratch.SetInMap "temp" "description" $hit.Description -}}
    {{- .Scratch.SetInMap "temp" "dir" $hit.Dir -}}
    {{- .Scratch.SetInMap "temp" "path" "temp" -}}
    {{- .Scratch.SetInMap "temp" "expirydate" $hit.ExpiryDate.UTC.Unix -}}
    {{- .Scratch.SetInMap "temp" "path" "temp" -}}
    {{- .Scratch.SetInMap "temp" "fuzzywordcount" $hit.FuzzyWordCount -}}
    {{- .Scratch.SetInMap "temp" "keywords" $hit.Keywords -}}
    {{- .Scratch.SetInMap "temp" "kind" $hit.Kind -}}
    {{- .Scratch.SetInMap "temp" "lang" $hit.Lang -}}
    {{- .Scratch.SetInMap "temp" "lastmod" $hit.Lastmod.UTC.Unix -}}
    {{- .Scratch.SetInMap "temp" "permalink" $hit.Permalink -}}
    {{- .Scratch.SetInMap "temp" "publishdate" $hit.PublishDate -}}
    {{- .Scratch.SetInMap "temp" "readingtime" $hit.ReadingTime -}}
    {{- .Scratch.SetInMap "temp" "relpermalink" $hit.RelPermalink -}}
    {{- .Scratch.SetInMap "temp" "summary" $hit.Summary -}}
    {{- .Scratch.SetInMap "temp" "title" $hit.Title -}}
    {{- .Scratch.SetInMap "temp" "type" $hit.Type -}}
    {{- .Scratch.SetInMap "temp" "url" $hit.URL -}}
    {{- .Scratch.SetInMap "temp" "weight" $hit.Weight -}}
    {{- .Scratch.SetInMap "temp" "wordcount" $hit.WordCount -}}
    {{- .Scratch.SetInMap "temp" "section" $hit.Section -}}
    {{/* Include valid page vars */}}
    {{- range $key, $param := (.Scratch.Get "temp") -}}
      {{- if in $validVars $key -}}
        {{- $dot.Scratch.SetInMap $hit.File.Path $key $param -}}
      {{- end -}}
    {{- end -}}
    {{/* Include valid page params */}}
    {{- range $key, $param := $hit.Params -}}
      {{- if in $validParams $key -}}
        {{- $dot.Scratch.SetInMap $hit.File.Path $key $param -}}
      {{- end -}}
    {{- end -}}
    {{- $.Scratch.SetInMap "hits" $hit.File.Path (.Scratch.Get $hit.File.Path) -}}
  {{- end -}}
{{- end -}}
{{- jsonify ($.Scratch.GetSortedMapValues "hits") -}}

NOTE: observe the Include What Pages? section. The original code use .Site.AllPages which include everything including tag pages and others. I wanted content pages only (IsPage) and specific content type, thus I use where (where .Site.Pages "Type" "in" (slice "tutorials" "blog")) "IsPage" true to query for pages.

Run hugo to generate the site and it shall output the Algolia JSON file at public/algolia.json.

Create Algolia App and Index

Goto Algolia Applications and create new application. Put in the name, select community for the free plan and choose your region (where your website’s visitors most likely came from).

After creating the application and being redirected to Dashboard, click on Indices on left tab. Click on new index and give it a name (e.g. default, posts, etc.)

You can use tool like atomic-algolia to upload algolia.json to Algolia by cli. But since I don’t update my website very frequenly, I shall skip this step at the moment and uploadalgolia.json to Algolia using the web interface.

Choose Upload File and upload algolia.json.

NOTE: if you already uploaded your records once, you can access the add records function by clicking on Manage Current Index.

You can perform test search on your records at the Browse tab.

At Indices -> Ranking -> Basic Settings -> Attributes, add title and description to Searchable Attributes and click Save.

NOTE: I only wanted to allow searching by title and description only, while other fields are for display purpose only. The order of the fields/attributes shall affect ranking order, so make sure title is above description.

Embed Algolia Search in Hugo using InstantSearch.js

There are many libraries available to integrate with Algolia, including Vue, React, Android, etc. For integration using plain javascript without any framework, InstantSearch.js would be the best choice.

The following section shall focus on build search and show result using full page like this example, but without the category filter on the left at the moment.

NOTE: if you want a search box at the top of the page which show result in a dropdown as you type, refer to autocomplete.js.

Create a static search page.

hugo new search.md

Edit the page’s configuration to give it a static type and search layout.

---
title: "Search"
date: 2017-07-29T19:57:50+08:00
type: static
layout: search
---

Create a layout template for search at layouts/static/search.html with the following content.

Refer to InstantSearch.js Getting Started for most of the setup

  • Get appId and searchKey from Algolia -> Apps -> API Keys (Application ID and Search-Only API Key)
  • Get indexName from from Algolia -> Apps -> Indices
  • Include CSS styles: instantsearch.min.css and instantsearch-theme-algolia.min.css
  • Include JavaScript: instantsearch.js@2.7.1
  • JavaScript to initialize instantsearch
  • Add html <div id="hits"><!-- Hits widget will appear here --></div> and addWidget hits.
  • Add html <div id="search-box"><!-- SearchBox widget will appear here --></div> and addWidget searchBox
  • Include algolia logo (https://www.algolia.com/static_assets/images/pricing/pricing_new/algolia-powered-by-14773f38.svg) as part of the requirement for community free plan

NOTE: the block/define might differ depending on your baseof.html. main for content, addstyles for css styles and addscripts for javascripts.

{{ define "main" }}
  <div id="search-box"><!-- SearchBox widget will appear here --></div>
  <!-- include algolia logo -->
  <img src="https://www.algolia.com/static_assets/images/pricing/pricing_new/algolia-powered-by-14773f38.svg"></img>

  <div id="hits" class="my-4">
    <!-- Hits widget will appear here -->
  </div>

  <div id="pagination">
    <!-- Pagination widget will appear here -->
  </div>
{{ end }}

{{ define "addstyles" }}
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/instantsearch.js@2.7.1/dist/instantsearch.min.css">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/instantsearch.js@2.7.1/dist/instantsearch-theme-algolia.min.css">
<style>
mark {
  padding: 0;
}
</style>
{{ end }}

{{ define "addscripts" }}
<script src="https://cdn.jsdelivr.net/npm/instantsearch.js@2.7.1"></script>
<script>

// initialize instantsearch
const search = instantsearch({
  appId: '***',
  apiKey: '***',
  indexName: '???',
  urlSync: true
});

search.addWidget(
  instantsearch.widgets.searchBox({
    container: '#search-box',
    placeholder: 'Search'
  })
);

search.addWidget(
  instantsearch.widgets.hits({
    container: '#hits',
    templates: {
      empty: 'No results',
      item: '<div><h3>{{ safeHTML "{{{ _highlightResult.title.value }}}" }}</h3></div>'
    }
  })
);

search.start();
</script>
{{ end }}

Run hugo server and you should be able to access your search page at http://localhost:1313/search, with a search bar, algolia logo and results/hits displayed below it. Search happens as you type.

Did you notice {{ safeHTML "{{{ _highlightResult.title.value }}}" }} with way too many curly brackets? This is due to conflict between go template (used by Hugo) and mustache template (used by InstantSearch.js. To avoid the conflict, it is recommended to put your JavaScript in a .js file.

Edit the "addscripts block to the following.

{{ define "addscripts" }}
<script src="https://cdn.jsdelivr.net/npm/instantsearch.js@2.7.1"></script>
<script src="/js/search.js"></script>
{{ end }}

Create static/js/search.js with the following content.

  • In .js file, we can use {{{ _highlightResult.title.value }}} instead of {{ safeHTML "{{{ _highlightResult.title.value }}}" }} in hugo template file.
  • hits.templates.item is changed to include title, permalink, lastmod, tags and description (I am using Bootstrap 4 CSS classes). The difference between {{{ _highlightResult.title.value }}} and {{ title }} is the former will highlight the text being queried (e.g. when I search “Android”, the word shall be highlighted in the results). I believe we need to include highligtable fields in Algolia -> Apps -> Indices -> Ranking -> Basic Settings -> Attributes -> Searchable Attributes as we did earlier.
  • By default the highlighting of search result with query is done by using em html tag (italic). If you want to change it to yellow highlight, goto Algolia -> Apps -> Indices -> Display -> Display & Pagination, change Highlight prefix tag to <mark> and Highlight postfix tag to </mark>.
  • I added some hits.transformData.item to change date in seconds to date string and format tags array into string.
  • Add html <div id="pagination"><!-- Pagination widget will appear here --></div> and addWidget pagination
const search = instantsearch({
  appId: '***',
  apiKey: '***',
  indexName: '???',
  urlSync: true
});

search.addWidget(
  instantsearch.widgets.hits({
    container: '#hits',
    templates: {
      empty: 'No results',
      // https://caniuse.com/#feat=template-literals
      item: '<div class="my-3"><h3><a href="{{ permalink }}">{{{ _highlightResult.title.value }}}</a></h3><div><span class="text-secondary">{{ lastmod_date }}</span> <span class="text-secondary">∙ {{ tags_text }}</span> {{#_highlightResult.description.value}}∙ {{ _highlightResult.description.value }}{{/_highlightResult.description.value}}</div><small class="text-muted">{{ summary }}</small></div>'
    },
    transformData: {
      item: function(data) {
        data.lastmod_date = new Date(data.lastmod*1000).toISOString().slice(0,10)
        // https://caniuse.com/#search=MAP
        const tags = data.tags.map(function(value) {
          return value.toLowerCase().replace(' ', '-')
        })
        data.tags_text = tags.join(', ')
        return data
      }
    }
  })
);

search.addWidget(
  instantsearch.widgets.searchBox({
    container: '#search-box',
    placeholder: 'Search'
  })
);

search.addWidget(
  instantsearch.widgets.pagination({
    container: '#pagination',
    maxPages: 20,
    // default is to scroll to 'body', here we disable this behavior
    // scrollTo: false
  })
);

search.start();

The search page is complete now.

Algolia Search in Hugo

Now, we need to add a search box at the top of every page which allow searching at /search to be done.

Search Bar

Edit layouts/partials/*.html which is related to search bar or page top navbar (refer to your baseof.html as every Hugo layouts are different).

Add a search form with text input.

<form action="/search/" role="search">
  <input type="text" placeholder="Search" type="text" name="q">
</form>

The following is my search form with Bootstrap 4 CSS.

<form class="form-inline my-2 my-lg-0" action="/search/" role="search" id="lua-search">
  <input class="form-control mr-sm-2" type="text" placeholder="Search" aria-label="Search" type="text" name="q">
</form>

Whenever the user type in a search query and hit ENTER, it shall redirect to /search?q=android with the query string and perform the search.

More

You can add tags filtering (fauceting) to your search result. Refer to Algolia Add Refiniting List (Faceting).

References:

This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.