Serving Go Modules with Hugo
∼ 9 minutes / 1800 wordsServing Go modules off a custom domain (such as go.deuill.org
) requires only that you’re able to
serve static HTML for the import paths involved; as simple as this sounds, finding salient
information on the mechanics involved can become quite involved.
This post serves as a soft description into the mechanics of how go get
resolves modules over
HTTP, and walks through setting up Hugo (a static site generator) for serving Go modules off a
custom domain.
Why would anyone want to do this? Aside from indulging one’s vanities, custom Go module paths can potentially be more memorable, and control over these means you can move between code hosts without any user-facing disruption.
Basic Mechanics
At a basic level, go get
resolves module paths to their underlying code by making HTTP calls
against the module path involved; responses are expected to be in HTML format, and must contain a
go-import
meta tag pointing to the code repository for a supported version control system, e.g.:
<meta name="go-import" content="go.example.com/example-module git https://github.com/deuill/example-module.git">
In this case, the go-import
tag specifies a module with name (or, technically, the “import
prefix”, as we’ll find out in later sections), go.example.com/example-module
, which can be
retrieved from https://github.com/deuill/example-module.git
via Git.
Go supports a number of version control systems, including Git, Mercurial,
Subversion, Fossil, and Bazaar; nevertheless, and assuming the meta tag is well-formed, go get
will then continue to pull code for the repository pointed to, for the VCS chosen.
Thus, with this content served over https://go.example.com/example-module
, we can then make our
go get
call and see code pulled from Github:
$ go get -v go.example.com/example-module
go: downloading go.example.com/example-module v0.0.0-20230325162624-6da6d8c20f04
…
The repository pulled must be a valid Go module – that is, a repository containing a valid go.mod
file – for it to resolve correctly; this file must also contain a module
directive that has the
same name as the one being pulled.
…that’s pretty much it!
It is, of course, quite plausible that we can just stuff static HTML files in our web root and be done with it, but where’s the fun in that? Furthermore, Hugo allows us to create complex page hierarchies using simple directives, which is definitely of use to us here.
Hugo Setup
In order to get a workable Go module host set up in Hugo, we need two things: content items, each representing a Go module, and a set of templates to render these as needed.
First, we’ll need to set up a new site with Hugo:
$ hugo new site go.deuill.org
This will set up a fairly comprehensive skeleton for our Go module host, but will need some love
before it’s anywhere near useful. First, add some basic, site-wide configuration – open hugo.toml
and set your baseURL
and title
to whatever values you want, e.g.:
# hugo.toml
baseURL = 'https://go.deuill.org'
languageCode = 'en-us'
title = 'Go Modules on go.deuill.org'
Next, let’s add a basic module skeleton as a piece of content – a Markdown file in the content
directory named example-module.md
. This file doesn’t actually need to contain any content per se:
rather, we’ll be looking to use Hugo front matter, i.e. page metadata, to
describe our modules (though of course the extent to which we take rendering content is entirely up
to us):
# content/example-module.md
---
title: Example Module
description: An example Go module with a custom import path
---
Since Hugo doesn’t create any default templates for rendering content pages, we’ll need to create some basic ones ourselves; at a minimum, we’ll need a page for the content itself, but ideally we’d also want to be able to see all modules available on the host.
Let’s tackle both in turn – for content-specific rendering, we’ll need to add a template file in
layouts/_default/single.html
. The content can be quite minimal for the moment:
<!-- layouts/_default/single.html -->
<!doctype html>
<html lang="{{.Site.LanguageCode}}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{.Title}} - {{.Site.Title}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<main>
<h1>{{.Title}}</h1>
<aside>{{.Description}}</aside>
</main>
</body>
</html>
Similarly, our home-page template is located in layouts/index.html
, and looks like this:
<!-- layouts/index.html -->
<!doctype html>
<html lang="{{.Site.LanguageCode}}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Go Modules - {{.Site.Title}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<main>
<h1>{{.Site.Title}}</h1>
{{range .Site.RegularPages}}
<section>
<h2><a href="{{.Permalink}}">{{.Title}}</a></h2>
<aside>{{.Description}}</aside>
</section>
{{end}}
</main>
</body>
</html>
So far, so good – if you’ve been following along here, you should, by this point, have a fairly
brutalist listing of one solitary Go module, itself not quite ready for consumption by go get
as-of-right-now.
Serving Go Modules
Serving a Go module is, as shown above, a simple matter of rendering a valid go-import
tag for the
same URL pointed to by the import path itself. Rendering a go-import
tag requires three pieces of
data, at a minimum:
- The module path (e.g.
go.example.com/example-module
). - The source control protocol, e.g.
git
orhg
. - The source control repository URL, e.g.
https://github.com/deuill/example-module
.
Hugo supports adding arbitrary key-values in content front matter, which can then be accessed in
templates via the {{.Params}}
mapping. Simply enough, we can extend our content file
example-module.md
with the following values:
# content/example-module.md
---
title: Example Module
description: An example Go module with a custom import path
+module:
+ path: go.example.com/example-module
+repository:
+ type: git
+ url: https://git.deuill.org/deuill/example-module.git
---
Producing a valid go-import
tag is, then, just a matter of referring to these values in the
content-specific layout, layouts/_default/single.html
; we can also render them out on-page to make
things slightly more intuitive for human visitors, e.g.:
<!-- layouts/_default/single.html -->
<!doctype html>
<html lang="{{.Site.LanguageCode}}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{.Title}} - {{.Site.Title}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="go-import" content="{{.Params.module.path}} {{.Params.repository.type}} {{.Params.repository.url}}">
</head>
<body>
<main>
<h1>{{.Title}}</h1>
<aside>{{.Description}}</aside>
+ <dl>
+ <dt>Install</dt>
+ <dd><pre>go install {{.Params.module.path}}@latest</pre></dd>
+ <dt>Documentation</dt>
+ <dd><a href="https://pkg.go.dev/{{.Params.module.path}}">https://pkg.go.dev/{{.Params.module.path}}</a></dd>
+ </dl>
</main>
</body>
</html>
Ship it! This setup is sufficient in serving multiple Go modules, each in their own directories, and with a functional, albeit somewhat retro, human-facing interface.
Sub-modules and Sub-paths
Most modules would be well-served by this setup, but it does assume that the Go module is placed
at the repository root; based on what we know about go get
, the import path needs to resolve to an
HTML file containing a valid go-import
tag, and that import path needs to match the resulting
go.mod
file.
The module path in the go-import
tag can, therefore, be assumed to also be always equal to the
import path; closer reading of the official Go documentation reveals that this
is instead the import prefix, relating to the repository root and not necessarily to any of the Go
modules placed within.
Furthermore, the full path hierarchy must present the same go-import
tag in order to resolve
correctly with go get
. Clearly there’s some headaches to be had.
To better illustrate the issue, let’s assume the following repository structure, containing a number of files in the repository root, as well as a Go module in one of the sub-folders, e.g.:
├── .git
│ └── ...
├── LICENSE
├── README.md
└── thing
├── go.mod
├── go.sum
├── main.go
└── README.md
The full import path for this module would be go.example.com/example-module/thing
, which is also
what the module
directive would be set to in the go.mod
file; however, the import prefix
presented in the go-import
tag needs to be set to go.example.com/example-module
.
Given this conundrum, it is not enough to simply set a different module.path
in the content file
for example-module.md
, or even create a separate example-module/thing.md
content file – we need
to ensure that the full hierarchy resolves to a valid HTML file containing a valid go-import
tag,
that, crucially, always points to the import prefix of go.example.com/example-module
.
Turns out that Hugo has yet a few tricks up its sleeve, and can assist us in setting up this complex content hierarchy using a single content file, the trick being content aliases.
Aliases are commonly intended with redirecting alternative URLs to some canonical URL via
client-side redirects (using the http-equiv="refresh"
meta tag); for our use-case, we’ll need to
slightly extend the underlying templates and render a valid go-import
tag alongside the
http-equiv
tag.
First, let’s add the sub-path corresponding to the Go module as an alias in our existing
example-module.md
content file:
# content/example-module.md
---
title: Example Module
description: An example Go module with a custom import path
module:
path: go.example.com/example-module
repository:
type: git
url: https://git.deuill.org/deuill/example-module.git
+aliases:
+ - /example-module/thing
---
Navigating to go.example.com/example-module/thing
will, then, render a default page containing a
minimal amount of HTML, as well as the aforementioned http-equiv
meta tag. We can extend that for
our own purposes by adding a layout file in layouts/alias.html
, e.g.:
<!-- layouts/alias.html -->
<!DOCTYPE html>
<html lang="en-us">
<head>
<title>{{.Page.Title}}</title>
<link rel="canonical" href="{{.Permalink}}">
<meta name="robots" content="noindex">
<meta charset="utf-8">
<meta http-equiv="refresh" content="5; url={{.Permalink}}">
<meta name="go-import" content="{{.Page.Params.module.path}} {{.Page.Params.repository.type}} {{.Page.Params.repository.url}}">
</head>
<body>
<main>This is a sub-package for the <code>{{.Page.Params.module.path}}</code> module, redirecting in 5 seconds.</main>
</body>
</html>
Crucially, this alias has full access to our custom front matter parameters, making integration of additional sub-modules a simple matter of adding an alias in the base content file.
Since our import paths still expect the fully formed path (and not just the prefix), our
human-readable code examples rendered for specific modules will be incorrect in the face of
sub-packages; solving for this is an exercise left to the reader (hint hint: add a sub
field in
front matter, render it in the go get
example via {{.Params.module.sub}}
).
Making It Pretty
If you’re not a fan of the brutalist aesthetic, and would like to waste your human visitors' precious bandwidth with such frivolities as colors, you could spice things up with a bit of CSS. A little goes a long way:
/* static/main.css */
html {
background: #fefefe;
color: #333;
}
body {
margin: 0;
padding: 0;
}
main {
margin: 0 auto;
max-width: 50rem;
}
a {
color: #000;
border-bottom: 0.2rem solid #c82829;
padding: 0.25rem 0.1rem;
text-decoration: none;
}
a:hover {
color: #c82829;
}
dl dt {
font-size: 1.2rem;
font-weight: bold;
}
dl dd pre,
dl dd a {
display: inline-block;
}
pre {
background: #f0f0f0;
color: #333;
padding: 0.4rem 0.5rem;
overflow: auto;
margin-bottom: 1rem;
white-space: nowrap;
}
Add this to static/main.css
and link to it from within layouts/_default/single.html
and
layouts/index.html
.
Code and Other Links
The setup described here is directly inspired by and used for hosting my own Go modules, under go.deuill.org. The source-code for the site is available here.