Repurposing Hugo as a wiki
As part of a project that I’m working on, I decided to see if I could repurpose the excellent Hugo website framework (which also powers this very site) as a single-user wiki of sorts.
Short version: yes!
Requirements
The main features that I wanted were:
-
The ability to, while writing a page, freely link to other topic pages that I know will (or at least plan to make) exist in future, without having to worry about if they exist right now.
-
An automatically-updating visual indication of whether a linked-to page does or does not exist yet.
-
A common “coming soon” page that the missing links will land on (i.e. no actual 404’s for content linked in this manner; normal site 404 functionality is untouched).
-
The simplicity of writing in Markdown that I am so used to already, especially because this is a technical project featuring lots of code syntax highlighting in the content.
Why not <wiki software X>?
The main reason that I didn’t want to use an established wiki package like MediaWiki, the software behind Wikipedia, was that it seemed like overkill. For this project, I don’t need separate authors, I don’t need revisions of pages, and I certainly don’t need a database server.
Hugo exists to create static HTML websites, supports Markdown formatting, is fast, and has a powerful shortcode system which can be extended.
So let’s make it happen. But first, before I get too far into the weeds, let’s take a look at the end result and how simple it is to use while writing.
Using the custom shortcode
I created a custom shortcode called link
. Here’s how you use it.
-
For linking to a page where the page slug is the same as the word you want clickable:
{{% link other %}}
This will produce the link text other linking to
/pages/other/
. -
For linking to a page where you want to make clickable some custom text:
{{% link "my text here" other2 %}}
This will produce the link text my text here linking to
/pages/other2/
.
That’s it. But critically: if in the above examples the pages other
or other2
don’t exist, the link will be styled in a noticeable way (in my case, red instead of blue) and will link to (also, in my case) /pages/missing/
. Whenever I get around to adding said page, all existing links to it will auto-update and become blue in future.
Overview of components
A few things need to happen to bring this configuration together.
-
Enable Goldmark renderer “unsafe” mode. This allows rendering of inline HTML in shortcodes. A reason why you would not want this enabled is if you have untrusted users and/or untrusted content. For me and this project, neither of these apply.
In your
hugo.yaml
:markup: goldmark: renderer: unsafe: true
-
Create and configure the common “missing” page and make it so that unreachable-by-Hugo pages are not fatal errors in the rendering process.
In your
hugo.yaml
:refLinksErrorLevel: WARNING refLinksNotFoundURL: /pages/missing/
-
Create the shortcode. As per above, I used the name
link
, which means I created alayouts/shortcodes/link.html
containing the following:{{- $link := (urls.RelRef . (cond (eq (len .Params) 2) (.Get 1) (.Get 0))) -}} <a href="{{ $link }}"{{ if eq $link (relref . "missing") }} class="missing"{{ end }}>{{ .Get 0 }}</a>
Please forgive the line wrapping. Since a shortcode gets replaced inline in your content, and since I didn’t want to add extra spacing or linebreaks in my content, the shortcode is just two (long-ish) lines.
-
Configure the
missing
class in your theme’s CSS.main#content a.missing { color: #f00; }
How the shortcode works
The shortcode took the most work, as the other bits were just a little configuration tweaking and some strategic web searching in order to puzzle through some minor issues. Here’s how it works.
Line 1: Resolve destination
In the first line, a $link
variable is created which benefits from the flexibility of specifying either one or two arguments to the shortcode — one if the page slug and linked text are the same, and two if they differ.
{{- $link := (urls.RelRef . (cond (eq (len .Params) 2) (.Get 1) (.Get 0))) -}}
I will add that spending a lot of time recently in Lisp and other similar languages which are composed of S-expressions has made Go syntax like (cond (eq (len .Params) 2)...
much more natural to me.
The best part about the use of urls.RelRef
in the link buildup is that the links are filename- and reorganization-independent. If you use a destination page filename of other.md
, a shortcode argument of other
will find it. If you use a filename of other-page.md
but a slug
within its front matter of other
, it will also work. If you change the location of resources but keep either of these the same, it will be found. And finally, if the page cannot be found (likely because it doesn’t exist yet, one of the requirements of tolerance in this system), the “missing” common page will be linked (in my configuration above, /pages/missing/
).
Line 2: Conditionally style link text
Moving on from the $link
setup, the other line of the shortcode sets up a standard HTML anchor link <a>
tag highlighting the shortcode’s first argument as the link text. Most importantly, if the link resolves to the common missing page, this can be detected and the custom “doesn’t exist yet” CSS is applied to the link.
<a href="{{ $link }}"{{ if eq $link (urls.RelRef . "missing") }} class="missing"{{ end }}>{{ .Get 0 }}</a>
Astute readers and/or optimization nerds will notice that this technically relies on duplication of the missing
term — once here and once in the hugo.yaml
part which sets up the missing page for failed urls.RelRef
resolution. While I would have liked to avoid this, no Hugo function exists to get at the same of the refLinksNotFoundURL
variable specified in the site configuration. The closest I got was .Site.Config
, but that only exposes the services
and privacy
keys as a subset of the configuration. Argh, code duplication.
Exciting conclusion
All in all, I’m quite happy with this. While I’ve only really begun the actual buildout of the site for which this system was contrived, it’s already reducing mental overhead and allowing me to see some structure to the site without having to worry about writing it all or even creating placeholder page files for now.
It’s definitely a case of The General Problem, though.
What can I say? I’m most certainly a developer and toolmaker at heart. I prefer to take inspiration here from Abraham Lincoln as quoted in the previous link:
If I had six hours to chop down a tree, I would spend the first four sharpening the axe.
Bonus: Visual Studio Code snippets
Not content with a mere single level of yak shaving, and in keeping with the spirit of the preceding section, I also wanted a quick way to insert these shortcode-based links into my writing. I learned about VS Code snippets which are both easy and powerful, and I was able to create a project-specific .vscode/snippets.code-snippets
file with the following:
{
"Insert wiki page link": {
"scope": "markdown",
"prefix": "link",
"body": ["{{% link ${1:linked text} ${2:slug} %}}"]
}
}
Then, in my project, whenever I type link
, I can Tab
to get a popup suggestion with tab-completion into the two fields for easy fill-out. And if I only want one shortcode argument, I can easily delete the second tabbed-to argument.
Bonus 2: Extra-meta
In the process of writing this post in Hugo and about Hugo, I discovered a puzzler of a problem with including the example shortcode in the usage and snippets examples — the shortcode doesn’t actually exist on this site, but was still being interpreted by Hugo as a shortcode in this post’s content, causing the page render to fail. Bless this kind soul, Chris Liatas, for writing about a technique to work around this using C-style code comments, thereby allowing me to display unparsed Hugo code in a post about Hugo code within a system running Hugo code.