skip to main content

Using Hugo to Launch a Gemini Capsule

published icon  |  category icon webdesign

As you can read in the “exploring the AlterNet” article, I’ve had my eye on Gemini for a few weeks now. Ever since discovering the new protocol thanks to a couple of weird Mastodon toots, I’ve been thinking about how to set up a “capsule” (they’re not called sites) for myself. I like the appeal of a text-focused, no-whizzbang protocol where the focus is on contents, not aesthetics, especially for blogs such as this one.

A few questions needed to be answered before switching to action modus and letting the static site generator Hugo do our dirty Gemini work for us.

How to host a Gemini capsule?

There are many pieces of Gemini software available to us, but they’re all quite new, as the protocol itself is from 2019. I was keen on trying out a simple Go server, but both go-gemini-server, shavit, and go-gemini required me to build it myself and contained very little documentation. Furthermore, some packages weren’t updated in more than a year… In the end, I decided to go with Agate, a simple Gemini server written in Rust that can serve static files. It has binaries for every platform, was updated six days ago, and its it extensive.

Agate even generates the needed TLS certificates if none are provided. This allowed me to quickly set up a localhost server using the command agate --content docs/gemini --addr --hostname localhost --lang en-US. Fun fact about the port number:

When Gemini is served over TCP/IP, servers should listen on port 1965 (the first manned Gemini mission, Gemini 3, flew in March ‘65).

Running locally before pushing to a server was important to me as I wanted to fiddle with the .gmi files first to see how they look like in my Gemini browser/client, Lagrange. Gotta double-check the ASCII art!

What to publish on Gemini?

This is very personal. There are a few options. People like Drew DevVault and Sylvain Durand mirror their HTTP(S) blog on Gemini, meaning all blog entries are consultable both over the web and over Gemini. Then there are more personal articles, published solely on Gemini to accompany the usually more technical HTTP blogs, such as gemini:// He claims to use it to whine like we did on MySpace yesteryear. I’ve also seen hybrids popping up: articles that are ported, but some exclusive content is also available through Gemini. I like that. My method at least makes this possible.

I wanted to blog in Dutch, my mother language, for a while now, and I’ve tried it a few years back on Brain Baking. It didn’t work out. The entries were misplaced somehow and I wasn’t satisfied, even though I did not expect to actually have readers. I hoped to use a new domain,, for a Dutch Gemini capsule to do some personal whining. That sounded like a good plan.

The plan fell through. Instead, I decided to mirror Brain Baking. Why?

  • I already whine in Dutch in my personal diaries using a fountain pen. I do not want to give that up.
  • I already have a (nice?) blog, and I’d like to expand the Gemini space-i-verse by adding my existing articles to it. I already write in Markdown, so a conversion would be not too difficult.
  • I don’t think I can keep up with posting on yet another blog, since I also occasionally write about retro games on

How to publish on Gemini?

Right. Porting articles turns out to be ridiculously easy with the help of my good old friend, Hugo. Sylvain’s method for declaring Gemini as a custom Hugo output format turned out to work flawlessly. All credits go to him. However, I did make a few significant changes to the link replacement system. First, something important to consider: I do not get rid of special emphasis symbols such as underscores or stars, that are Markdown-specific. I still think they add something when reading plain text and they’re the next best thing to have without any markup at all. So I removed those regex-es.

Gemini pages cannot have inline links, so I had to strip out Markdown-style []() links and place them on a separate paragraph using => link title. A simple find-and-replace, like in Sylvain’s method, is quite ugly if you use inline links extensively like I do. It breaks up the text and the result is a difficult to read Gemlog (that’s a Gemini blog!). In my approach, I collect all links, replace them with a reference number like in academic papers ([1]), and add a section called “References” on the bottom of the article to list them all. This is what it looks like:

My Gemini AlterNet article in Lagrange.

I’m quite pleased with the result, although the code itself is far from pretty, as Gemini is very newline-sensitive, and I had to jam a bunch of Hugo-specific regex functions together. Source code available at GitHub: index.gmi source, single.gmi source (see below). Next to the link change, I also replaced all - and 1. (number) lines, that are enumerators in Markdown, with * ones, which is the only supported enumerator in Gemfiles.

I tried to design the index and single layout files as similar as possible to their html variants, while focusing in simplicity. Related articles are also visible at the end of an article, and the index file simply contains a short bio followed by an overview of all posts, groupbed by year and month, just like in my html /post overview. After defining [outputFormats.GEMINI] in my Hugo config.toml, all that was left is to use rsync to copy over the gemini subfolder to an appropriate location that gets picked up by Agate. Job done!

Well, not entirely. My Markdown files are littered with surprisingly Hugo-specific junk:

  • Shortcodes, such as YouTube, embedded video or audio.
  • Four hashes - h4 - which isn’t supported by the Gemini protocol.
  • <span/> tags in my quotes that help with HTML markup.
  • Links to aliases that are redirects, which don’t work for the Gemini output format.

Also, after trying out a second Gemini client, the terminal-friendly Amfora, I noticed the reference numbers do not align with Amfora’s shortcut keys that allow you to quickly navigate to a link. Reference 1 would match to key 2. Why? Because an image is also converted to a link (=> url), wich is placed in-between text, while the actual references are at the bottom. Hence, pressing number one would let us download the image - except Amfora can’t handle that (yet). I solved this by starting at a specific index, based on the number of times the arrow notation is present in the .gmi file, before processing inline links. These are all things to take into account when writing future posts.

Now, the the most important question, “why publish on Gemini” could be answered with “because it’s easy!”. I’m not yet sure if that answer is very satisfactory, but at least Brain Baking got launched into Space today 🚀! All that is left is to submit it to the GUS Gemini Universal Search engine…

Edit 21 June 2021: After a few months of fiddling with Gemini, I came to the conclusion that it’s simply too early. There’s almost nothing there, and it only increases the complexity of my website codebase. Therefore, I pulled yet another plug. Sorry!

For future reference, the following files have been added to enable Gemini functionality:


{{- $allowedRssSections := (slice "post") -}}
{{- $baseurl := .Site.BaseURL -}}
{{- $pctx := . -}}
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
{{- $pages := slice -}}
{{- if or $.IsHome $.IsSection -}}
{{- $pages = $pctx.RegularPages -}}
{{- else -}}
{{- $pages = $pctx.Pages -}}
{{- end -}}
{{- $limit := .Site.Config.Services.RSS.Limit -}}
{{- if ge $limit 1 -}}
{{- $pages = $pages | first $limit -}}
{{- end -}}
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
<feed xmlns="" xml:lang="en">
  <title>{{ .Site.Title }}</title>
  {{- $perm := replace .Permalink "/gemini" "" 1 -}}
  {{- $alt := .Site.BaseURL | replaceRE `https?://(.+?)` "gemini://$1" -}}
  {{ printf "<link rel=\"self\" type=\"application/atom+xml\" href=\"%s\"/>" $perm | safeHTML }}
  {{ printf "<link rel=\"alternate\" type=\"text/html\" href=\"%s\"/>" $alt | safeHTML }}
  <updated>{{ .Date.Format "2006-01-02T15:04:05-0700" | safeHTML }}</updated>
    <name>{{ }}</name>
    <uri>{{ .Site.BaseURL | replaceRE `https?://(.+?)` "gemini://$1" }}</uri>
  <id>{{ $perm }}</id>
  {{ range $pages }}
  {{ if in $allowedRssSections .Section }}
    <title>{{ .Title }}</title>
    {{- $entryperm := .Permalink | replaceRE `https?://(.+?)` "gemini://$1" -}}
    {{ printf "<link rel=\"alternate\" href=\"%s\"/>" $entryperm | safeHTML }}
    <id>{{ $entryperm }}</id>
    <published>{{ .Date.Format "2006-01-02T15:04:05-0700" | safeHTML }}</published>
    <updated>{{ .Lastmod.Format "2006-01-02T15:04:05-0700" | safeHTML }}</updated>
    <summary>{{ if isset .Params "subtitle" }}{{ .Params.subtitle }}{{ else }}{{ .Summary | html }}{{ end }}</summary>
  {{ end }}
  {{ end }}


# Brain Baking in Space

> Brain Baking: transforming  personal thoughts about thoughts into well-digestible material. The reflective aroma of burnt nervous tissue. Includes a crispy crust of relations between technology, philosophy  and  the world. 

## About The Head Brain Baker

Hey! Yadda yadda

=> Ko-fi Donations
=> mailto:{{ }} E-mail

## Freshly Baked Thoughts: The Gemlog

=> /atom.xml Gemini Atom Feed
{{ range (where (where (where .Site.Pages "Section" "in" (slice "post")) ".Params.type" "ne" "archive") ".Params.concept" "ne" "true").GroupByDate "2006" "desc" }}{{ $year := .Key -}}
{{ range .Pages.GroupByDate "January" }}
### {{ .Key }} {{ $year }}
{{ range .Pages.ByDate.Reverse }}
=> {{ replace .RelPermalink "/gemini" "" 1}} {{ .Date.Format ("02") }} - {{ .Title }}
{{ .Params.Subtitle }}{{ end }}
{{ end }}
{{ end }}

# That's All Folks.

=> Brain Baking on the WWW

And lastly, layouts/_default/single.gmi: (Note the space between {{ < that should be removed)

# {{ .Title }}{{ $scratch := newScratch }}
{{ $content := .RawContent -}}
{{ $content := $content | replaceRE `#### ` "### " -}}
{{ $content := $content | replaceRE `\n- (.+?)` "\n* $1" -}}
{{ $content := $content | replaceRE `\n(\d+). (.+?)` "\n* $2" -}}
{{ $content := $content | replaceRE `\[\^(.+?)\]:?` "" -}}
{{ $content := $content | replaceRE `<br/??>` "\n" -}}
{{ $content := $content | replaceRE `<a .*href="(.+?)".*>(.+?)</a>` "[$2]($1)" -}}
{{ $content := $content | replaceRE `\sgemini://(\S*)` " [gemini://$1](gemini://$1)" -}}
{{ $content := $content | replaceRE `{{ < audio "(.+?)" >}}` "=>$1 Embedded Audio link - $1" -}}
{{ $content := $content | replaceRE `{{ < video "(.+?)" >}}` "=>$1 Embedded Video link - $1" -}}
{{ $content := $content | replaceRE `{{ < youtube (.+?) >}}` "=>$1 YouTube Video link to $1" -}}
{{ $content := $content | replaceRE `{{ < vimeo (.+?) >}}` "=>$1 Vimeo Video link to $1" -}}
{{ $content := $content | replaceRE "([^`])<.*?>([^`])" "$1$2" -}}
{{ $content := $content | replaceRE `\n\n!\[.*\]\((.+?) \"(.+?)\"\)` "\n\n=> $1 Image: $2" -}}
{{ $content := $content | replaceRE `\n\n!\[.*]\((.+?)\)` "\n\n=> $1 Embedded Image: $1" -}}
{{ $links := findRE `\n=> ` $content }}{{ $scratch.Set "ref" (add (len $links) 1) }}
{{ $refs := findRE `\[.+?\]\(.+?\)` $content }}
{{ $scratch.Set "content" $content }}{{ range $refs }}{{ $ref := $scratch.Get "ref" }}{{ $contentInLoop := $scratch.Get "content" }}{{ $url := (printf "%s #%d" . $ref) }}{{ $contentInLoop := replace $contentInLoop . $url -}}{{ $scratch.Set "content" $contentInLoop }}{{ $scratch.Set "ref" (add $ref 1) }}{{ end }}{{ $content := $scratch.Get "content" | replaceRE `\[(.+?)\]\((.+?)\) #(\d+)` "$1 [$3]" -}}
{{ $content | safeHTML }}

Written by Wouter Groeneveld on {{ .Lastmod.Format (.Site.Params.dateFormat | default "2 January 2006") }}.

## References
{{ $scratch.Set "ref" (add (len $links) 1) }}{{ range $refs }}{{ $ref := $scratch.Get "ref" }}{{ $url := (printf "%s #%d" . $ref) }}
=> {{ $url | replaceRE `\[(.+?)\]\((.+?)\) #(\d+)` "$2 [$3] $1 ($2)" -}}
{{ $scratch.Set "ref" (add $ref 1) }}{{ end}}
{{ $related := first 3 (where (where .Site.RegularPages.ByDate.Reverse ".Params.tags" "intersect" .Params.tags) "Permalink" "!=" .Permalink) }}
{{ if $related }}
## Related articles
{{ range $related }}
=> {{ replace .RelPermalink "/gemini" "" 1}} {{ .Title }}: {{ .Params.Subtitle }}{{ end }}{{ end }}

=> / Back to the Index
=>{{ replace (replace .RelPermalink "/gemini" "" 1) "index.gmi" "" }} View this article on the WWW

For more information, feel free to contact me or to plod around in the GitHub repo tree at that point in time.

tags icon gemini hugo accessibility

A bunch of ideas for my website that might never get implemented.Wanna know more about the website? Check out the meta page!Change the font-size to 17px (more?). be careful with different device sizes. Mobile keep 16. Test.Reading sectionCreate backe...

 | by 

I'm Wouter Groeneveld, a Brain Baker, and I love the smell of freshly baked thoughts (and bread) in the morning. I sometimes convince others to bake their brain (and bread) too.

If you found this article amusing and/or helpful, you can support me via PayPal or Ko-Fi. I also like to hear your feedback via Mastodon or e-mail. Thanks!