A Swiss Geek previously in Singapore, now in Portugal

From Wordpress to Hugo

As mentioned in Going Static, I migrated my Wordpress blogs to a static site build with Hugo. Most articles on the subject convinced me that it would be easy. The truth is, it isn’t that straight forward.


With help of a Wordpress plugin, exporting your posts and content is easy. The plugin adds the necessary front matter in Yaml format. But you still need to go through all your posts to update manually all image references. Depending on the amount of posts and images you have, this can be a tremendous task.


Finding a theme is never an easy task. Specially if you want a theme as close as possible to your previous Wordpress theme. If you are not picky on themes, this step is an easy task.

Depending on the theme chosen, you will need to adapt each post’s front matter to add theme related informations, like cover image, author, …


I wanted my blogs to me AMP validated. I didn’t find a theme doing what I wanted. Using the concepts implemented in gohugo-amp, I wrote a theme for each of my blogs:

  • Using Hugo’s theme Casper as a base, I modified it to create Campser. casper + AMP = cAMPster. This theme is currently used on Far from Home.
  • Using this blog’s wordpress theme Origin as a base, I created Origin for Hugo from scratch. This theme is used on this blog.

    Hugo Theme Campser

    Hugo Theme Origin

Front Matter

I modified all post’s front matter from YAML to TOML, this was probably useless overkill work. But as said before, each post’s front matter need to be edited to match the needs of theme.


In Hugo, stylesheets are simply static files. I wanted to managed my styles with Sass, using Gulp to generate the final css. Stylesheets are the concerns of the theme, if you just use an existing theme, you don’t have to bother with this issue.


This is probably the most time consuming part. Wordpress automatically creates several sizes of every image uploaded. There is no automation in Hugo to handle images.


To create different resolution for each image, I used Gulp and several plugins:

  • gulp-responsive: to create different variations for each image
  • gulp-changed: to treat only images that haven’t been treated yet
  • gulp-filter: to assign different tasks for different image files
  • gulp-imagemin: to load various images compression algorithms
  • imagemin-jpeg-recompress: compression for jpeg
  • imagemin-pngquant-gfw: compression for png

Sources images are stored in src/images and stored in static/images once processed. Hugo will use the content in static/ to generate the site and not use the source images in src/images.

You need to define a list of output sizes, jpeg files are transformed in webp in addition to jpeg.


const gulp = require('gulp')
const $ = require('gulp-load-plugins')()

// image lossy compression plugins
const compressJpg = require('imagemin-jpeg-recompress')
const pngquant = require('imagemin-pngquant-gfw')

const contentSrc = 'src/images'
const contentDst = 'static/images'
const pngFilter = $.filter(['**/*.png'], {restore: true})

function buildOutputs (sizes, resolutions) {
  var outputs = []
  for (let i = 0; i < sizes.length; i += 1) {
    let size = sizes[i]
    for (let j = 0; j < resolutions.length; j += 1) {
      let res = resolutions[j]
      let resext = '-' + res + 'x'
      if (res === 1) { resext = '' }
      let output = {
        width: size * res,
        rename: {
          suffix: '-' + size + 'px' + resext
      let webp = JSON.parse(JSON.stringify(output))
      webp.rename.extname = '.webp'
  let squares = [150, 300]
  for (let i = 0; i < squares.length; i += 1) {
    let size = squares[i]
    let output = {
      width: size,
      height: size,
      crop: 'entropy',
      rename: {
        suffix: '-square-' + size + 'px'
    let webp = JSON.parse(JSON.stringify(output))
    webp.rename.extname = '.webp'
    progressive: true,
    compressionLevel: 6,
    withMetadata: false
    rename: {
      extname: '.webp'
  return outputs

gulp.task('img-content', function () {
  return gulp.src(contentSrc + '/**/*.{jpg,png}')
      '**/*': buildOutputs([150, 360, 720, 1280, 1920, 3840], [1])
    }, {
      progressive: true,
      compressionLevel: 6,
      withMetadata: false,
      withoutenlargement: true,
      skipOnEnlargement: true,
      errorOnEnlargement: false,
      errorOnUnusedConfig: false
        loops: 4,
        min: 50,
        max: 95,
        quality: 'high'
    .pipe(pngquant({ quality: '65-80', speed: 4 })())

gulp.task('images', ['img-content'])
gulp.task('img-content:clean', function () {
  return gulp.src(contentDst, {read: false})
gulp.task('images:clean', ['img-content:clean'])

Markdown doesn’t provide a solution to define automatically an srcset. Either you create every single one of them manually, or you can use a Shortcode and Partials to automate the insertion of images.

{{ $image := .Params.src }}
{{ $type_arr := split $image "." }}
{{ $srcbase := index $type_arr 0 }}
{{ $srcext := index $type_arr 1 }}
{{ $.Scratch.Set "srcbase" $srcbase }}
{{ $.Scratch.Set "srcext" $srcext }}
{{ with (imageConfig (printf "static%s" $image)) }}
    {{ $.Scratch.Set "srcwidth" .Width }}
    {{ $.Scratch.Set "srcheight" .Height }}

{{ $.Scratch.Set "srcset" "" }}
{{ range ( slice 150 360 720 1280 1920 3840) ", "}}
    {{ if gt ( $.Scratch.Get "srcwidth" ) . }}
        {{ $.Scratch.Set "srcset" ( printf "%s%s-%dpx.%s %dw, " ($.Scratch.Get "srcset") ($.Scratch.Get "srcbase") . ($.Scratch.Get "srcext") .) }}
    {{ end }}
{{ $.Scratch.Set "srcset" ( printf "%s%s.%s %dw" ($.Scratch.Get "srcset") $srcbase $srcext ($.Scratch.Get "srcwidth")) }}
<figure class="w450">
        {{ with .Params.alt }}alt="{{ range (split . " ") }}{{ . }} {{ end }}"{{ end }}
        {{ with .Params.attribution }}attribution="{{ range (split . " ") }}{{ . }} {{ end }}"{{ end }}
        srcset="{{ range (split ($.Scratch.Get "srcset") " ") }}{{ replace . $srcext "webp" }} {{ end }}"
        width="{{ $.Scratch.Get "srcwidth" }}"
        height="{{ $.Scratch.Get "srcheight" }}"
        {{ with .Params.lightbox }}
        {{ end }}
        sizes="(min-width: 500px) 450px, 100vw"
        <div fallback>
                {{ with .Params.alt }}alt="{{ range (split . " ") }}{{ . }} {{ end }}"{{ end }}
                {{ with .Params.attribution }}attribution="{{ range (split . " ") }}{{ . }} {{ end }}"{{ end }}
                srcset="{{ range (split ($.Scratch.Get "srcset") " ") }}{{ . }} {{ end }}"
                width="{{ $.Scratch.Get "srcwidth" }}"
                height="{{ $.Scratch.Get "srcheight" }}"
                {{ with .Params.lightbox }}
                {{ end }}
                sizes="(min-width: 500px) 450px, 100vw"
    {{ if or (isset .Params "title") (isset .Params "caption") (isset .Params "attr") }}
    {{ if isset .Params "title" }}
        <h4>{{ .Params.title }}</h4>
    {{ end }}
    {{ if or (isset .Params "caption") (isset .Params "attr")}}
        {{ .Params.caption }}
        {{ if isset .Params "attrlink" }}<a href="{{.Params.attrlink}}">{{ end }}
        {{ .Params.attr }}
        {{ if isset .Params "attrlink" }}</a>{{ end }}
    {{ end }}
    {{ end }}

Inserting a multi source image becomes very easy:

{{&lt;amp-figure src="/images/2014/09/VPN.png" caption="Standard Site-to-Site VPN setup"&gt;}}


Hugo doesn’t handle “Last modified” or “published date” automatically. You need to update the front matter manually. Nothing that a little sed based script can manage:

. bin/lastmod content/post/from-wordpress-to-hugo.md




if [[ $1 ]]; then
    if [[ ! -f $file ]]; then
        echo "Error: No such file: "$file
        exit 1

now=`date +%FT%T%:z`
sed -i '/^lastmod =.*$/d' $file
sed -i '/^date =.*$/a lastmod = "'$now'"' $file
echo "lastmod updated to "$now" on "`basename $file`

if [[ $update_draft_date ]]; then
    draft=`grep -i "^draft\s*=\s*true$" $file`
    if [[ $draft ]]; then
        sed -i 's/^date =.*$/date = "'$now'"/' $file
        echo "date updated to "$now" on "`basename $file`


Being static, there is no possibility to host comment. Most Hugo theme come with an easy Disqus integration. Exporting your existing comment from Wordpress to Disqus is again made easy with a Wordpress plugin.


I chose S3 to host my blog and Cloudfront. To avoid purging the cache after each deploy, I set a long TTL on images and a shorter one on HTML content. With this, new content is available as soon as published, but pages referencing the new post will be updated worst case in a few hours only.

I chose to deploy manually new content and note use any CI.

Build and deploy

Using npm to orchestrate build and deploy, deploying the site becomes:

npm run deploy

The complete package.json is available on github.

HTTP redirects

Hugo handles HTTP redirects (like /tags/1.html redirected to /tags/) with html meta-tags. Nothing wrong with this practice, except that the http response is 200 OK instead of 301 Moved Permanently preferred by SEO engines. S3 allows to create http redirects by using a specific meta-data. After building the site, we find all the files with http-equiv="refresh", delete them and create an empty file in S3 with the correct meta-tag.

. bin/alias/build
. bin/alias/push


rm -f bin/alias/push
touch bin/alias/empty
rgrep -Po '(?<=http-equiv=\"refresh\" content=\"0; url=https://example.com).*(?=\".*)' public/* | while read line; do
    srcfile=`echo $line | cut -d':' -f1`
    dstfile=`echo $line | cut -d':' -f2`
    rm -f $srcfile
    cleansrcfile=`echo $srcfile | sed -e 's/^public\///'`
    echo "aws --region us-east-1 --profile default s3 cp --website-redirect $dstfile --cache-control 'max-age=43200' --storage-class REDUCED_REDUNDANCY --acl public-read bin/alias/empty s3://example.com/$cleansrcfile" >> bin/alias/push
find public/ -type d -empty -delete


If you have a one-man Wordpress site, migrating to Hugo is a perfect solution. Specially if you add all the automation tasks. You not only win in security but you also win in delivery speed.

Getting new content is fast and easy too:

hugo new post/new-post-title.md
edit content/post/new-post-title.md
. bin/lastmod content/post/new-post-title.md
hugo undraft content/post/new-post-title.md
npm run deploy