Building a blog with hugo

Building a blog with hugo
Page content

There are many tools to build your own blog. If you prefer lightweight websites, you should take a look at hugo . With configuration files, some Markdown and templates you create a completely static, blazing fast html site, which is easy to maintain.

Introduction

Hugo is a very fast static site generator and generates websites by using a toml configuration, themes and templates. It utilises a rich feature set like Markdown, go templates, data files, image processing, translations and even a site search to create a completely static HTML page, which can be nearly hosted anywhere.

You should have a decent amount of experience in the following areas

  • Command line tools (hugo has no GUI)
  • Markdown
  • HTML, CSS and JavaScript
  • git (optional, but recommended)

If not, you can still try to follow the steps below and improve your skills ;-)

Why not simply use WordPress

Although WordPress is the de facto standard, it does not fit my personal requirements. I needed blog without a database and administration interface.

A static site is fast, accessible, easy to back up / maintain and provides “security by design” not having a backend with authentication. If that does not convince you, you could check google pagespeed insights , which should show pretty good results…

Setup hugo

The first step is to setup hugo (e.g. with brew install hugo on MacOS, sudo apt install hugo on Ubuntu or other setup instructions ). Since it is a go project, there is only a single binary without dependencies. You can download and install it for any OS . If you’re not sure about how to do it, see the official homepage for further instructions or support.

If hugo version shows you a version output > 0.80, you are ready to go.

About hugo themes

To start your blog, you have to create a new site, choose a theme and add content. That’s it. BUT: Themes are very different. They are responsible for basically everything on your site - not only layout and colors but even features like tags, categories or search. A template may also restrict your customization options or mobile appearance.

So you have to choose wisely depending on the features you’d like to have… I was pretty happy with the Mainroad theme by vimux :

  • Clean and responsive design
  • Menu management
  • Sidebar widgets
  • Translations (I did not require that, but it’s nice to have, isn’t it?)
  • many more…

If you choose another one, some things described here might need adjustments or just don’t work.

Let’s (hu)go!

Now it is time to create your project with hugo new <site-name>. I strongly recommend using git, to keep track of changes and rollback stuff, that did not work.

Project initialization

SITE_NAME="pilabor"

# create site and change into directory
hugo new site "$SITE_NAME"
cd "$SITE_NAME"

# initalize git repository
git init
git add .
git commit -m "initial commit"

# add mainroad theme via submodule
cd themes 
git submodule add https://github.com/vimux/mainroad
cd ..

Main config

The main config for hugo is /config.toml in the root directory of the project. Open this file in your favorite editor and change it like this:

# /config.toml
baseurl = "/"
title = "Mainroad"
languageCode = "en-us"
paginate = "10" # Number of posts per page
theme = "mainroad"
disqusShortname = "" # Enable Disqus comments by entering your Disqus shortname
googleAnalytics = "" # Enable Google Analytics by entering your tracking id

[Author] # Used in authorbox
  name = "John Doe"
  bio = "John Doe's true identity is unknown. Maybe he is a successful blogger or writer. Nobody knows it."
  avatar = "img/avatar.png"

[Params]
  subtitle = "" # Deprecated in favor of .Site.Params.logo.subtitle
  description = "John Doe's Personal blog about everything" # Site description. Used in meta description
  copyright = "John Doe" # Footer copyright holder, otherwise will use site title
  opengraph = true # Enable OpenGraph if true
  schema = true # Enable Schema
  twitter_cards = true # Enable Twitter Cards if true
  readmore = false # Show "Read more" button in list if true
  authorbox = true # Show authorbox at bottom of pages if true
  toc = true # Enable Table of Contents
  pager = true # Show pager navigation (prev/next links) at the bottom of pages if true
  post_meta = ["author", "date", "categories", "translations"] # Order of post meta information
  mainSections = ["post", "blog", "news"] # Specify section pages to show on home page and the "Recent articles" widget
  dateformat = "2006-01-02" # Change the format of dates
  mathjax = true # Enable MathJax
  mathjaxPath = "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.6/MathJax.js" # Specify MathJax path
  mathjaxConfig = "TeX-AMS-MML_HTMLorMML" # Specify MathJax config
  googleFontsLink = "https://fonts.googleapis.com/css?family=Open+Sans:400,400i,700" # Load Google Fonts
  highlightColor = "" # Deprecated in favor of .Site.Params.style.vars.highlightColor
  customCSS = ["css/custom.css"] # Include custom CSS files
  customJS = ["js/custom.js"] # Include custom JS files

[Params.style.vars]
  highlightColor = "#e22d30" # Override highlight color

  # Override font-family sets. Secondary font-family set responsible for pre, code, kbd, and samp tags font
  # Take care of different quotes OR escaping symbols in these params if necessary
  fontFamilyPrimary = "'Open Sans', Helvetica, Arial, sans-serif"
  fontFamilySecondary = "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace"

[Params.logo]
  image = "img/placeholder.png" # Logo image. Path relative to "static"
  title = "Mainroad" # Logo title, otherwise will use site title
  subtitle = "Just another site" # Logo subtitle

[Params.sidebar]
  home = "right" # Configure layout for home page
  list = "left"  # Configure layout for list pages
  single = false # Configure layout for single pages
  # Enable widgets in given order
  widgets = ["search", "recent", "categories", "taglist", "social", "languages"]
  # alternatively "ddg-search" can be used, to search via DuckDuckGo
  # widgets = ["ddg-search", "recent", "categories", "taglist", "social", "languages"]

[Params.widgets]
  recent_num = 5 # Set the number of articles in the "Recent articles" widget
  categories_counter = false # Enable counter for each category in "Categories" widget
  tags_counter = false # Enable counter for each tag in "Tags" widget

[Params.widgets.social]
  # Enable parts of social widget
  #facebook = "username"
  #twitter = "username"
  #instagram = "username"
  #linkedin = "username"
  #telegram = "username"
  #github = "username"
  #gitlab = "username"
  #bitbucket = "username"
  #email = "example@example.com"

You may notice that I removed / commented out some lines of the default config. That’s because with the official Mainroad instructions I had an error. Now it is time to take a look at your blog by running

hugo server

and browsing to http://localhost:1313

You should see the Mainroad default blog without content, placeholders and dummy text.

Before you add files to the git repository, you may want to ignore some files - in my case it is the .idea/ directory containing project files. Create a /.gitignore and commit your changes:

touch .gitignore
# optional: Add files to ignore

git add .
git commit -m "added base config"

Before creating blog content, you should take care about your main menu. I’d like to have a link to Home, About and Imprint, while Home as the landing page will display a list of blog articles, About will tell you something about the author and Imprint is a legal requirement in some countries.

Home

To create a simple link in the main menu, that does not point to specific content, you just need to add it in the config.toml:

# /config.toml
[menu]
  [[menu.main]]
    identifier = "home"
    name = "Home"
    url = "/"
    weight = -110 # place it on first position

About

To have a page with custom content like about, which is not a blog post and will not expire, just create the file /content/about.md:

---
# /content/about.md
title: "About"          
description: "About"  
authorbox: false
menu: main
weight: -90
---

Placeholder for the about content

The content contains the following elements:

  • Metadata and configuration section (enclosed by ---), which is for defining metadata and overriding settings
    • title - Title / list headline of the article
    • description - Meta description of the article
    • authorbox - Show (true) or hide (false) a box with information abbout the author
    • menu - Add page to the menu with this name
    • weight - Position in the menu - the lower the sooner it appears
  • Content section (everything after ---) - the page content itself

Imprint

Proceed exactly as for About, but name the file /content/imprint.md and change the metadata for title, description and weight.

Testing and saving your changes

After a refresh, you should have the desired 3 items in the main menu. Let’s commit that to the repository:

git add .
git commit -m 'added main menu items'

Organizing blog content files

Before you start customizing the layout too much (which may be frustrating and time-consuming), I recommend putting your first effort in creating some real content.

Something you should really think about is how to structure your directories and files for blog posts, news, etc. By default, the /config.toml contains

# /config.toml
mainSections = ["post", "blog", "news"]

which means, that MarkDown files can be saved to /content/post/, /content/blog/ and /content/news/.

However, the important thing is, that hugo will create all URLs depending on where you put your articles and how you entitle them. So reorganizing too much of your content later may be not the best idea terms of SEO (search engines really hate 404 errors).

Basically you have 2 options:

  • Create named .md files (e.g. /content/blog/building-a-blog-with-hugo.md)
  • Put an index.md into named directories (e.g. /content/blog/building-a-blog-with-hugo/index.md)

I, personally prefer the latter because you can add images or article series. If you plan to have a lot of content, it also might be a good idea to create some subdirectories - in case of a blog the year and month may be a good idea:

  • /content/blog/2021/05/building-a-blog-with-hugo/index.md

The according URL would be http://yoursite.com/blog/2021/05/building-a-blog-with-hugo/.

Creating content - your first blog article

Before going in too much detail here, I will give you an example, how I usually write articles. Create a new file /blog/2021/05/lorem-ipsum/index.md with the following Markdown . Instead of using the Lorem ipsum placeholders, you may also directly use your first real content:

---
# /blog/2021/05/lorem-ipsum/index.md
title: "Lorem ipsum"
description: "Lorem ipsum description"
# thumbnail: "img/teaser/lorem.jpg"
date: 2021-05-24
# expirydate: 2021-05-23
categories:
- "Development"
tags:
- "Lorem"
- "Ipsum"
---

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. 
<!--more-->

## Introduction
Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.

Similar to the main menu pages About and Imprint, the content contains the following elements:

  • Metadata and configuration section (enclosed by ---), which is for defining an article’s metadata, visibility and overriding global settings
    • title - Title / list headline of the article
    • description - Meta description of the article
    • thumbnail - Thumbnail for listings and article image
    • date - Publish date (if its in the future, the article won’t be visible)
    • expirydate - Stop publishing date (I use this instead of draft: true or publishdate: <future-date> to manage my unpublished draft articles)
    • categories - The articles categories (I recommend a maximum of 2)
    • tags - Obviously the articles tags (I recommend between 2 and 6)
  • Teaser section (Text before <!---more-->) - this text is used as article teaser in listings
  • Content section (everything after <!---more-->) - the article content itself

Refreshing http://localhost:1313 now will list one article without a thumbnail. Don’t worry, we’ll get to thumbnails and images later.

Time to commit, if you are using git:

git add .
git commit -m "added first blog post"

Customize config.toml for the first release

Shipping early and often is very important, even if it is only a blog with one post. So if you have used the dummy images, pages and articles, it’s time to fill it with real content now…

Ok, lets assume you now have real content, it is time to adjust the /config.toml for the first release of your website. Fill in all the blanks and missing information - here are the most important ones:

Note: If you are trying to use a logo, that is not the size or format of the /static/img/placeholder.png and refuses to show up correctly, just skip it. You will learn more about images later.

# /config.toml
title = ""

[Author] # Used in authorbox
name = ""
bio = ""

[Params]
description = "" # Site description. Used in meta description
copyright = "" # Footer copyright holder, otherwise will use site title

[Params.logo]
image = "img/placeholder.png" # Logo image. Path relative to "static"
title = "" # Logo title, otherwise will use site title
subtitle = "" # Logo subtitle

[Params.widgets.social]
github = ""

After you filled in these placeholders and changed images, it is time to try your first release. Just run:

hugo -D --minify

If everything worked, you have completely static, minified (but yes, there is room for improvement) HTML files located in /public. All you have to do now is to upload these to your hosting provider.

Usually, you would not add these pages to your repository, since it is generated content. To prevent that, add public/ to your .gitignore and commit:

echo public/ >> .gitignore
git add .
git commit -m 'page is ready for release'

Images

A blog needs images. Unfortunately, this topic is more complex than it should be. I did not come across a silver bullet for image handling in hugo (maybe there is one, I did not find…). To understand, what I’m talking about, here are some things, that you should consider:

  • Which size should the images have (maybe different sizes for different pages or devices)?
  • Which format should be used (maybe different formats like jpg, svg, etc. or even avif)?
  • Can images be auto-resized to save space and bandwidth?
  • Should global images be placed in a global directory, while article specific images go to the article directory?
  • Can we add media-query-based image rendering to improve display quality on HiDPi devices?

While all that IS possible, it’s not easy to do out of the box. hugo is indeed able to process, convert and resize images, but it might fail for some types (like svg or avif). In the end I came up with a pretty complex, non-generic shell script, and an even more complex hugo custom partial to render my all images properly.

My recommendation for getting started is to keep it simple. Store your original images as high quality as possible and create all images with its original name in a smaller size (e.g. 512px) to:

  • /static/img/global/... for re-usable page images (icons, placeholders, etc.)
  • /static/img/teaser/... for re-usable teaser images in listings
  • /static/img/content/blog/2021/05/building-a-blog-with-hugo/... for article specific images

While it is possible to put the images besides the articles, I would not recommend, to do so. The /static/img/ directory will be automatically copied to publish/img/ on deployment, so all images can be referenced by relative paths and later on, relative paths are really helpful:

---
thumbnail: "img/teaser/lorem.jpg"
---
![An image](img/content/blog/2021/05/building-a-blog-with-hugo/post-specific.jpg)

The goal with this simple technique is, that you can change the whole image rendering process in one single template file. For now, it will lead to suboptimal unified image rendering (SEO tools will complain), but it will work, is easy to accomplish and ready for further optimizations.

Since our blog post does not have a thumbnail yet, lets fix that now:

  • Open /blog/2021/05/lorem-ipsum/index.md
  • Change # thumbnail: "img/teaser/lorem.jpg" to thumbnail: "img/teaser/lorem.jpg"
  • Store any image you like to /static/img/teaser/lorem.jpg

The startpage now should show a thumbnail image for your first article. It may look strange or does not fit 100%, but as already said, we’ll fix that later. Let’s commit our changes:

git add .
git commit -m "added base image rendering with one size"

Appropriate image rendering with theme overrides, partials and render hooks

To use appropriate image sizes for the right purpose, we first need to understand, what theme overrides, partials and render hooks are, since hugo uses go templates to generate content. These templates are stored as “programmable” .html files.

Theme overrides

Everytime an article summary in a list is rendered, hugo uses the template /themes/<selected-theme>/layouts/_default/summary.html - in our case its mainroad. To modify the generated HTML, you can override this template by copying /themes/mainroad/layouts/_default/summary.html to /layouts/_default/summary.html and apply some changes:

{{/* /layouts/_default/summary.html */}}

{{/* ... the part above has been skipped for convenience, but don't delete it ...  */}}

  <figure class="list__thumbnail">
      <a href="{{ .Permalink }}">
          <img src="{{ .Params.thumbnail | relURL }}" alt="{{ .Title }}" />
      </a>
  </figure>

{{/* ... the part below has been skipped for convenience, but don't delete it ...  */}}

Add style="border:3px solid red;" to verify, that it’s working:

{{/* /layouts/_default/summary.html */}}

{{/* ... the part above has been skipped for convenience, but don't delete it ...  */}}

  <figure class="list__thumbnail">
      <a href="{{ .Permalink }}">
          <img src="{{ .Params.thumbnail | relURL }}" alt="{{ .Title }}" style="border:3px solid red;" />
      </a>
  </figure>

{{/* ... the part below has been skipped for convenience, but don't delete it ...  */}}

We now could replace the image tag with our own HTML, but there is a problem: What about the images in other templates? Since I don’t like to copy and paste the same code everywhere, we are going to use a so called partial.

Partials

Partials are re-usable template snippets, that are stored in /layout/partials/<partial-name>.html. We would like to create a partial for showing correctly sized images, that later may also be responsive, so it will be named rimg (responsive img). Create a file /layout/partials/rimg.html with the summary part for the image:

{{/* /layout/partials/rimg.html */}}
<img src="{{ .Params.thumbnail | relURL }}" alt="{{ .Title }}" style="border:3px solid red;" />

And replace /layouts/_default/summary.html to use the partial:

{{/* /layouts/_default/summary.html */}}

{{/* ... the part above has been skipped for convenience, but don't delete it ...  */}}

<figure class="list__thumbnail">
  <a href="{{ .Permalink }}">
    {{ partial "rimg.html" . }}
  </a>
</figure>

{{/* ... the part below has been skipped for convenience, but don't delete it ...  */}}

The . means, that it is transferring the current context as parameter to the partial. In the partial, we are using the context-variables .Params.thumbnail (image path) and .Title (title of the image). Although it is common to have partials using the current context, there is a problem. It is pretty unlikely, that in our article rendering the image path is also available via .Params.thumbnail. To make the partial even more re-usable, I found it pretty handy using a dict (key-value-pairs), to map parameter names. Change the partial, and the template like this:

{{/* /layout/partials/rimg.html */}}
<img src="{{ .img | relURL }}" alt="{{ .alt }}" title="{{ .alt }}" />
{{/* /layouts/_default/summary.html */}}

{{/* ... the part above has been skipped for convenience, but don't delete it ...  */}}

  <figure class="list__thumbnail">
    <a href="{{ .Permalink }}">
      {{ partial "rimg.html" (dict "img" .Params.thumbnail "alt" .Title "title" .Title "preferSize" 235) }}
    </a>
  </figure>
  
{{/* ... the part below has been skipped for convenience, but don't delete it ...  */}}  

We now would like to use the preferSize parameter, to select an appropriate image for the current use case:

  • In a list, use a thumbnail (size 235px)
  • In an article, use the full size image (size >= 512px)
  • If the image is svg, always use the full size image

First you have to create the required images:

  • /static/img/teaser/lorem_235.jpg (= 235px)
  • /static/img/teaser/lorem.jpg (>= 512px)

Then change /layout/partials/rimg.html to

{{/* /layout/partials/rimg.html */}}

{{/* partial parameters */}}
{{ $image := .img }}
{{ $alt := .alt }}
{{ $title := .title | default .alt }}
{{ $preferSize := .preferSize | default 0 }}

{{/* split image file name */}}
{{ $ext := path.Ext $image }}
{{ $imageNoExt := strings.TrimRight $ext $image }}

{{/* build preferSizeImage file name (e.g. lorem_235.jpg) - printf is used for string/number concatenation */}}
{{ $preferSizeImage := printf "%s_%d%s" $imageNoExt $preferSize $ext }}
{{ $preferSizeImageFullPath := printf "static/%s" $preferSizeImage }}

{{/* fallback to original, if preferSizeImage not exists */}}
{{ $finalImage := $preferSizeImage }}
{{ if not (fileExists $preferSizeImageFullPath) }}
    {{ $finalImage = $image }}
{{ end }}

<img src="{{ $finalImage | relURL }}" alt="{{ .alt }}" title="{{ .alt }}" />

The listing now will prefer images with _235.jpg suffix, and the article view will always use the full size .jpg image without suffix. If the preferred image is not available, it will fallback to the default.

While thumbnails are working now, those images referenced within the markdown (e.g. ![An image](img/content/blog/2021/05/building-a-blog-with-hugo/post-specific.jpg)) still will not be handled correctly. A render hook is required.

Render hooks for inline images

Render hooks have fixed names and are only available for specific markdown elements (e.g. links or images). Everytime a *.md file contains such an element, it is rendered using the hook template. In our case, we would like to render images, so create a file /layouts/_default/_markup/render-image.html with the following content:

{{/* /layouts/_default/_markup/render-image.html */}}

{{ partial "rimg.html" (dict "img" .Destination "alt" .Text "title" .Title "preferSize" 672) }}

Since we already created a partial rimg with dict mapping, it can be easily re-used here. The preferSize parameter has to be added as fixed value, since it is not provided by hugo’s image render hook.

Although images are sized correctly, they are still lacking width and height attributes for raster images, which is not optimal. You can fix that using resources.

Switching from filenames (/static/) to resources (/assets/)

To get most out of your images, hugo provides a way to transform them into resources containing metadata like width, height, etc. Unfortunately, this is only provided for files in the /assets/ path and comes with some caveats. The documentation clearly states:

Only the files whose .Permalink or .RelPermalink are used will be published to the public directory. Note: assets directory is not created by default.

Files, that are not referenced as resource, will not be in the production release. So we have to do the following:

  • Create directory /assets/
  • Move /static/img/ to /assets/img/
  • Restart hugo server to recognize the new directory /assets/
  • Change /layout/partials/rimg.html to use resources instead of static files

The changes in /layout/partials/rimg.html look like this:

{{/* /layout/partials/rimg.html */}}

{{/* partial parameters */}}
{{ $image := .img }}
{{ $alt := .alt }}
{{ $title := .title | default .alt }}
{{ $preferSize := .preferSize | default 0 }}

{{/* split image file name */}}
{{ $ext := path.Ext $image }}
{{ $imageNoExt := strings.TrimRight $ext $image }}

{{/* try preferSizeImage (e.g. lorem_235.jpg) */}}
{{ $preferSizeImage := printf "%s_%d%s" $imageNoExt $preferSize $ext }}

{{/* fallback to original, if preferSizeImage not exists */}}
{{ $finalImage := resources.Get $preferSizeImage }}
{{ if not $finalImage }}
    {{ $finalImage = resources.Get $image }}
{{ end }}

{{/* only one size for svg - width and height are not determined by hugo */}}
{{ if eq $ext ".svg" }}
    <img src="{{ $finalImage.RelPermalink }}" alt="{{ .alt }}" title="{{ .alt }}" />
{{ else }}
    <img src="{{ $finalImage.RelPermalink }}" width="{{ $finalImage.Width }}"  height="{{ $finalImage.Height }}" alt="{{ .alt }}" title="{{ .alt }}" />
{{ end }}

Since we now have a single partial to render all our images, we can do our further improvements (e.g. responsiveness) later in one file - but that’s it for now, there are still other things to do, and we should save that to git:

git add .
git commit -m 'added more sophisticated image rendering'

Custom CSS and JavaScript

After embedding some images you might want to improve your design by adding some custom CSS or JavaScript. This is already provided by the mainroad theme:

  • Create /static/js/custom.js for custom JavaScript
  • Create /static/css/custom.css for custom CSS

As an example you might want to make sure that your svg images will render correctly by adding a min width in CSS for mobile and desktop layout:

/* /static/css/custom.css */
.logo__img, .list__thumbnail, .list__thumbnail img, .post__thumbnail {
    min-width: 80px;
    height: auto;
}

@media screen and (min-width: 620px) {
  .list__thumbnail, .list__thumbnail img, .post__thumbnail {
    min-width: 235px;
  }
}

Let your creativity run free changing colors and backgrounds or even sizes - but don’t forget to commit your changes:

git add .
git commit -m 'added custom styles and javascript'

Now that we know how to use render hooks and custom CSS, we can improve the user experience by highlighting external links and opening them in a new tab. This step is not mandatory, but in my opinion it helps users immensely.

Create a render hook file for links /layouts/_default/_markup/render-link.html:

<a href="{{ .Destination | safeURL }}"{{ with .Title}} title="{{ . }}"{{ end }}{{ if strings.HasPrefix .Destination "http" }} target="_blank" rel="noopener"{{ end }}>{{ .Text | safeHTML }}</a>

It will tell hugo to render links starting with http with the attributes target="_blank" rel="noopener". After this, you can highlight external links visually by adding some custom CSS in /static/css/custom.css:

/* /static/css/custom.css */

a[target="_blank"]::after {
  content: ' ↗️ ';
  font-size:0.5em;
  vertical-align:top;
}

This will show a little arrow beneath external links. If you like it, commit your changes:

git add .
git commit -m 'improved external link handling'

A site search is very helpful, especially if the amount of content grows. With hugo and some JavaScript it’s pretty easy to provide a fast and reliable search for your site.

Rendering endpoint (/content/search.md)

Start by creating the rendering endpoint with the following content:

---
# /content/search.md
title: "Search Results"
sitemap:
priority : 0.1
layout: "search"
authorbox: false
pager: false
---

custom template rendered, no content required

This will tell hugo to create an endpoint /search/ as page for showing search results. The content does not matter, it will be replaced by a custom template.

Adjust config (/config.toml)

By adding the following to your /config.toml

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

hugo is told to not only build static HTML pages, but also RSS and a JSON index. The latter is required for implementing a JavaScript search…

You can also get rid of the search widget, which is no longer needed, as soon as the following changes are all applied:

# /config.toml
# ...
  # widgets = ["search", "recent", "categories", "taglist", "social", "languages"]
  widgets = ["recent", "categories", "taglist", "social", "languages"]
# ...

Index format (/layouts/_default/index.json)

To format the json index correctly, create a file /layouts/_default/index.json:

{{/* /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 -}}

The name index.json is a bit misleading, since it does contain not a single line of valid json, but the contents are generated by a template.

JavaScript search engine (/static/js/search.js)

Next create the JavaScript file /static/js/search.js, that performs the search and renders the result:

Note: This script is only loaded on the /search/ page, so don’t put it into /static/js/custom.js

// /static/js/search.js
var searchQuery;
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}
    ]
};

document.addEventListener("DOMContentLoaded", init);

function init() {
    searchQuery = param("s")
    if (searchQuery) {
        gId("search-query").innerHTML = searchQuery;
        gId('search-input').value = searchQuery;
        executeSearch(searchQuery);
    } else {
        gId('search-results').innerHTML += "<p>Please enter a word or phrase</p><form class=\"menu__item__search\" role=\"search\" method=\"get\" action=\"/search\"><label for=\"search-input-content\"><input id=\"search-input-content\" type=\"search\" autofocus=\"\" placeholder=\"SEARCH…\" name=\"s\" aria-label=\"SEARCH…\"></label></form>";
    }
}

function gId(id) {
    return document.getElementById(id);
}

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

function executeSearch() {
    var url = "/index.json";
    var xhr = new XMLHttpRequest;
    xhr.onreadystatechange = function () {
        if (xhr.readyState === 4 && xhr.status === 200) {
            renderResults(JSON.parse(xhr.responseText));
        }
    }
    xhr.open("GET", url)
    xhr.send();
}

function renderResults(pages) {
    var fuse = new Fuse(pages, fuseOptions);
    var result = fuse.search(searchQuery);

    if (result.length > 0) {
        populateResults(result);
    } else {
        gId('search-results').innerHTML = ("<p>No matches found</p>");
    }
}

function populateResults(result) {
    var templateDefinition = gId('search-result-template').innerHTML;
    for(var key in result){
        var value = result[key];

        var snippet = value.item.contents;
        var snippetParts = snippet.split('\n');
        if(snippetParts.length > 1){
            snippet = snippetParts[0];
        }
        if(snippet.length > 200){
            snippet = snippet.substr(0, 200) + "...";
        }

        gId('search-results').innerHTML += render(templateDefinition, {
            key: key,
            title: value.item.title,
            link: value.item.permalink,
            tags: value.item.tags,
            categories: value.item.categories,
            snippet: snippet
        });
    }
}

function render(templateString, data) {
    var conditionalMatches, conditionalPattern, copy;
    conditionalPattern = /\${\s*isset ([a-zA-Z]*) \s*}(.*)\${\s*end\s*}/g;
    
    copy = templateString;
    while ((conditionalMatches = conditionalPattern.exec(templateString)) !== null) {
        if (data[conditionalMatches[1]]) {
            copy = copy.replace(conditionalMatches[0], conditionalMatches[2]);
        } else {
            copy = copy.replace(conditionalMatches[0], '');
        }
    }
    templateString = copy;
    
    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;
}

Override the search rendering (/layouts/_default/search.html)

After creating the search.md, index.json and the JavaScript code, it’s time to glue everything together. This is done by overriding the search rendering file /layouts/_default/search.html to include fuse.js (a lightweight fuzzy-search library) and our search.js running the fuzzy search and display the results:

Note: This template has been adjusted to fit the Mainroad theme, other themes might differ.

{{/* /layouts/_default/search.html */}}
{{ define "main" }}
<script src="//cdnjs.cloudflare.com/ajax/libs/fuse.js/3.2.0/fuse.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">{{ . }} for <span class="main__search-query" id="search-query"></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 search-result-content">
            ${snippet}
        </div>
    </article>
</script>
{{ end }}

Adding a search input field to the menu (/layouts/partials/menu.html)

Last step is to add a search to the menu bar via override. You could also adjust the search widget, but in my opinion a search input field should go to the upper right. Copy the file /themes/mainroad/layouts/partials/menu.html to /layouts/partials/menu.html and adjust the menu (only add the following, don’t delete the other content):

{/* `/layouts/partials/menu.html`  */}
{/* ... this part has been skipped, but don't delete it ...  */}
        {{- end }}
        {{- end }}
		<li class="menu__item menu__item--search">
			<form class="menu__item__search" role="search" method="get" action="{{ "search" | absURL }}">
				<label for="search-input">
					<input id="search-input" type="search" placeholder="{{ T "search_placeholder" }}" value="" name="s" aria-label="{{ T "search_placeholder" }}">
				</label>
			</form>
		</li>
{/* ... this part has been skipped, but don't delete it ...  */}

Positioning the search input field

Now you should already be able to use the search by typing words and hitting enter.

Note: If you get duplicate results, restart hugo server, there seem to be caching issues sometimes

Maybe you would like to improve the positioning of the search input field and results with some CSS in the /static/css/custom.css:

/* /static/css/custom.css */
.search-result-content {
    font-size: 1.0rem;
}

.menu__list {
  display: flex;
}

.menu__item--search, .menu__item--search:hover {
  background: transparent;
  flex: 1;
  display: grid;
  place-items: center right;
  padding-right: 5px;
}

.menu__item--search input {
  border-radius: 5px;
}

.main__search-query {
  font-style: italic;
  color: #e22d30;
}

After all these changes you should have a working search bar, that uses vanilla JavaScript and HTML. If everything is working, don’t forget to commit these changes:

git add .
git commit -m 'added search'

404 error page

Since your site is completely static HTML, it is possible, that a page, that does not exist, shows an ugly 404 error from your server. To prevent that, Mainroad provides a 404.html. The only thing, that needs to be done is to ensure, that a missing link is redirected. I’ll provide an example for NGINX and Apache, but you should easily find examples for other webservers:

NGINX

  error_page 404 /404.html;

Apache (/static/.htaccess)

# /static/.htaccess
  ErrorDocument 404 /404.html

Deployment

Now that your hugo project is ready for takeoff, all you need to do is another release:

hugo -D --minify

Then upload the contents of /public/ to your web hoster, or your github pages repository - since you are using nothing but static HTML, this will also work like a charm.

Conclusion

Static pages are awesome - and so is hugo. Having many advantages over bloated, database driven, insecure blogging platforms, there are some caveats, but with a little effort you can create a really nice little website in a really short time. The best thing is, that maintenance and backups are a walk in the park.

I hope you enjoyed my personal, opinionated getting started with hugo and learned something new. There is still more to do, though, but that will be covered in the future. To give you an idea, here are some improvements I already did to this site:

  • Asset management (minifying JavaScript and CSS)
  • Responsive images and favicons (<picture> and <source>)
  • Data templates (providing dynamic content via json files, e.g. for affiliate marketing)

That’s it for now… stay tuned.

Andreas Fuhrich avatar
About Andreas Fuhrich
I’m a professional software developer and tech enthusiast from Germany. On this website I share my personal notes and side project details. If you like it, you could support me on github - if not, feel free to file an issue :-)