Building a blog with hugo
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.
Recommended skills
You should have a decent amount of experience in the following areas
- Command line tools (
hugo
has no GUI) Markdown
HTML
,CSS
andJavaScript
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"
Main menu
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 settingstitle
- Title / list headline of the articledescription
- Meta description of the articleauthorbox
- Show (true
) or hide (false
) a box with information abbout the authormenu
- Add page to the menu with this nameweight
- 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 settingstitle
- Title / list headline of the articledescription
- Meta description of the articlethumbnail
- Thumbnail for listings and article imagedate
- Publish date (if its in the future, the article won’t be visible)expirydate
- Stop publishing date (I use this instead ofdraft: true
orpublishdate: <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 evenavif
)? - 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"
tothumbnail: "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 customJavaScript
- Create
/static/css/custom.css
for customCSS
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'
Improving external links
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'
Site search
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
andCSS
) - 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.