· All Posts · All in Go

Hugo blog from scratch - blogging with simple documents

Since the Eternal September, the internet has changed forever. The 1990 were dominated with a wave of creativity of early adopters, the 2000 marked the peak of internet culture and since the 2010s we’re declining towards a sterile network, dominated by a handful of apps. Entertainment is consumed in form of FAANG content and communication has deteriorated to 140 280 character exchanges on censored and heavily policed platforms. Blogs have become rare and are often only found in form of medium posts.

Windows 95 dialing progress - the old internet

I like the smol internet. Even in the modern web, articles are fundamentally plain text. The Gopher protocol is currently undergoing a renaissance and has spawned some rethinking and the gemini protocol. There is a small but thriving community of likeminded people who seek simplicity and clarity in a complex world, writing web logs and serving them over gopher and gemini. The best static site generator is still hugo, but there are only few documents starting with it from scratch, rather than existing templates.

Setting up Hugo

We’re setting up Hugo from scratch in these notes, without external themes and just go through the basics. Keeping the layouts simple allows for a later addition of providing the same content as a Gemini gemlog without making the two versions visually too different.

Compile Hugo from Source

At the time of writing, the homebrew version of Hugo does us no good, neither does the upstream version since it cannot compile Math formulas at compile time. KaTeX is a math typesetting library that produces the same output regardless of browser or environment, allowing to pre-render expressions and serve them as plain HTML. Perfect for Hugo. The corresponding pull request of adding KaTeX was rejected due to the CGO requirement (understandably so). You can download my hugo patch n0x1m/hugo-katex-patch. I try to keep it reasonably up to date and only changes ~10 LOC.

git clone
cd hugo
git fetch -a
# v0.92.2 here or latest tagged version, it may work too
git checkout v0.92.2
git apply 0001-PATCH-goldmark-add-katex-extension-support.patch
# instal hugo
go mod tidy
go build
which hugo
mv hugo /usr/local/bin/hugo

Enable Katex in the hugo config toml:

  defaultMarkdownHandler = "goldmark"
    katex = true

If you don’t care about math, just go ahead and install Hugo with homebrew.


Create the site skeleton

hugo new site mylog
cd mylog

the file tree looks like this

ll .
├── archetypes
│   └──
├── config.toml
├── content
├── data
├── layouts
├── static
└── themes

The hugo serve command will still crash on visit with

hugo serve
WARN 2021/06/14 15:58:23 found no layout file for "HTML" for kind "home": You should create a template file which matches Hugo Layouts Lookup Rules for this combination.

add a basic html layout

mkdir -p layouts/_default
mkdir -p layouts/partials
mkdir -p content/post
touch layouts/404.html
touch layouts/index.html
touch layouts/_default/baseof.html
touch layouts/_default/list.html
touch layouts/_default/single.html
touch layouts/partials/head.html
touch layouts/partials/style.html
touch archetypes/
touch content/
touch content/posts/

The core html component is layouts/_default/baseof.html, a basic version looks like this:

<!doctype html>
<html lang="en">

    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>{{ .Title }}</title>

    <div class="container">
        <main id="main">
            {{ block "main" . }}{{ end }}


create `layouts/_default/single.html

<!doctype html>
<html lang="en">
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>{{ .Title }}</title>
    <div class="container">
      <main id="main">
        <h1>{{ .Title }}</h1>
        {{ .Content }}

the archetype ./archetypes/

title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: false

configure our link format in config.toml:

posts = "/:year/:month:day/:filename"

now we can import/add content under content/posts/

Running hugo will render the markdown files by their title in your URL bar. This is the minimum required for a simple page. Styling and layouting can be done of course to greater extend. But I don’t need to list this here as there are plenty or resouces out there.

This is just the base setup conceptually, from here onwards it’s just styles and content. I’ll let you play withthat on your own time.

We don’t want search engines to classify us as a link farm, so let’s make sure we render hugo pages with the nofollow tags (on the html version).

  defaultMarkdownHandler = "blackfriday"
    nofollowLinks = true
    hrefTargetBlank = true

That’s all for now. If anything is unclear and you send me questions I might add to this post.

The above was a simple way when using the blackfriday Markdown renderer which deprecates soon with the next Hugo versions. Version hugo v0.87.0 starts warning about this deprecation.

Render hooks allow us several ways to extend the default markdown behaviour, e.g. resizing images, or adding rel=nofollow tags.

mkdir -p layouts/_default/_markup/
touch layouts/_default/_markup/render-link.html

And edit said file layouts/_default/_markup/render-link.html:

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

That’s it.

Auto Heading Anchor IDs

Allow to automatically jump to headings and subheadings with anchors:

  defaultMarkdownHandler = "goldmark"
    autoHeadingID = true
    autoHeadingIDType = 'github'

Semantic Web

JSON-LD is a way to add semantic mark up your output with objects. JSON-LD is a lightweight Linked Data format. It is easy for humans to read and write and is supposedly an ideal data format for programming environments and web services. It is an important concept in SEO. We start by adding a Hugo partial:

touch layouts/partials/site_schema.html

We’re creating the BlogPosting type and its attributes. There is also an article by Google in Advanced SEO.

{{ if .IsHome -}}
<script type="application/ld+json">
    "@context": "",
    "@type": "WebSite",
    "name": "{{ .Site.Title }}",
    "url": "{{ .Site.BaseURL }}",
    "description": "{{ .Site.Params.description }}",
    "thumbnailUrl": "{{ .Site.Params.logo | absURL }}",
    "license": "{{ .Site.Copyright }}"
{{ else if .IsPage }}
{{ $author :=  or ( ( }}
{{ $org_name :=  .Site.Title }}
{{ $image_path := .Params.thumbnail | default site.Params.image -}}
<script type="application/ld+json">
    "@context": "",
    "@type": "BlogPosting",
    "articleSection": "{{ .Section }}",
    "name": "{{ .Title | safeJS }}",
    "headline": "{{ .Title | safeJS }}",
    "description": "{{ if .Description }}{{ .Description | safeJS }}{{ else }}{{if .IsPage}}{{ .Summary  }}{{ end }}{{ end }}",
    "inLanguage": {{ .Site.LanguageCode | default "en-us" }},
    "mainEntityOfPage": {
        "@type": "WebPage",
        "@id": "{{ .Permalink }}"
    "author" : {
        "@type": "Person",
        "name": "{{ $author }}"
    "creator" : {
        "@type": "Person",
        "name": "{{ $author }}"
    "accountablePerson" : {
        "@type": "Person",
        "name": "{{ $author }}"
    "copyrightHolder" : "{{ $org_name }}",
    "copyrightYear" : "{{ .Date.Format "2006" }}",
    "dateCreated": "{{ .Date.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
    "datePublished": "{{ .PublishDate.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
    "dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
        "name": {{ $org_name }},
        "url": {{ .Site.BaseURL }},
        "logo": {
            "@type": "ImageObject",
            "url": "{{ .Site.Params.logo | absURL }}",
    "image": {{ $image_path | absURL }},
    "url" : "{{ .Permalink }}",
    "wordCount" : "{{ .WordCount }}",
    "keywords" : [ {{ range $index, $keyword := .Params.categories }}{{ if $index }}, {{ end }}"{{ $keyword }}" {{ end }}]
{{ end }}

Generating Gemini gemtext from Hugo content

I’ve a follow up post on how to setup Gemini alongside Hugo with minimum hassle. It uses the same links, content and file structure just on the gemini:// protocol.

PageSpeed Insights

Over at Google’s PageSpeed Insights we score 100/100 with our Hugo static site.

PageSpeed Insights

Blog Comments

The hugo docs point to a number of different comment options. Disqus carries however bloat and advertisements that we don’t want to promote. I don’t know yet if comments are even used and this is one place where I admittedly would like something off the shelf that gets rid off spam and that I don’t need to self host. After looking into it (no more than 5 minutes), the easiest option seems to me to be I’ve set up an empty repository on GitHub n0x1m/ and embedded a conditially loaded blob based on a button click and if there is Javascript in the browser. Putting comments behind a button click also prevents the section to be abused for 3rd party links, stealing domain authority.

<center id='have-js' style="display: none;">
<br />
<button id='load-comments-btn' onclick="loadComments()"><b>Load comments</b><br /> <small>(requires Javascript via GitHub Utterances)</small></button>
<div id='comments'></div>
<br />

<script type="text/javascript">
    // unhide button if we have javascript
    var haveJs = document.getElementById("have-js");
    haveJs.setAttribute('style', 'display: block;');

    // only load this sh*t on click
    function loadComments() {
        console.log('loading utterances...')
        var btn = document.getElementById("load-comments-btn");
        btn.setAttribute('style', 'display: none;')
        var anchor = document.getElementById("comments");
        var s = document.createElement('script');
        s.type = 'text/javascript';
        s.src = '';
        s.setAttribute('repo', 'n0x1m/');
        s.setAttribute('issue-term', 'pathname');
        s.setAttribute('label', 'utterances');
        s.setAttribute('theme', 'preferred-color-scheme');
        s.setAttribute('crossorigin', 'anonymous');

This means Javascript and the 3rd party isn’t imposed on any user by default but requires Javascript to be enabled and a deliberate button click. Let’s see where this goes. Happy commenting!

Published on Monday, Jun 14, 2021. Last modified on Monday, Sep 11, 2023.
Go back

If you’d like to support me, follow me on Twitter or buy me a coffee. Use Bitcoin
BTC address: bc1q6zjzekdjhp44aws36hdavzc5hhf9p9xnx9j7cv