Building a Hugo theme
How FoundingFuture I was built, and what the Hugo theme directory requires before it accepts a submission.
10 minutes read
2014 words
Contents18
FoundingFuture I is a Hugo theme under the MIT license. Its source is at github.com/FoundingFuture/theme-hugo-ff1 (opens in a new tab) . This page records how it was built and every requirement the Hugo theme directory imposes. The steps apply to any theme.
Measured at version 0.1.0: 25 templates, 6 shortcodes, 11 partials, one stylesheet of 32,370 bytes holding 249 selectors, three scripts totalling 7,074 bytes, and six subset fonts.
Repository layout
A theme is a directory with a fixed set of subdirectories. Hugo reads each by name.
theme-hugo-ff1/
theme.toml metadata for the theme directory
LICENSE the license named in theme.toml
README.md English documentation
hugo.toml defaults a site inherits
images/ screenshot.png and tn.png
layouts/ templates
assets/ files for the asset pipeline
static/ files copied without processing
i18n/ translation keys
exampleSite/ a working site that uses the theme
Two directories feed the browser. assets/ runs its contents through
Hugo’s pipeline. The pipeline minifies, fingerprints, and reports an
integrity hash. static/ copies bytes to the output without processing.
Stylesheets and scripts belong in assets/. Fonts belong in static/. A
fingerprinted font filename changes on every build. That breaks a hardcoded
@font-face rule.
Theme metadata file
theme.toml goes at the repository root. The directory rejects a submission
without it. Eight fields are required, plus one author block.
name = "FoundingFuture I"
license = "MIT"
licenselink = "https://github.com/FoundingFuture/theme-hugo-ff1/blob/main/LICENSE"
description = "A dense publishing theme. Topics of any depth in a colour-coded menu."
homepage = "https://foundingfuture.com/software/foundingfuture-i/"
tags = ["blog", "documentation", "topics", "dense", "responsive"]
features = ["nested sections", "self-hosted fonts", "no third-party requests"]
min_version = "0.146.0"
[author]
name = "Eddie Niese"
homepage = "https://foundingfuture.com/"
min_version takes a full semantic version. Hugo has used three-part
versions since v0.54.0, so 0.146 is invalid and 0.146.0 is correct. Set
it to the highest version any feature in the theme requires. The try
function used in this theme’s embed shortcode requires Hugo v0.141.0.
licenselink must resolve. An early revision of this theme pointed at a
repository path that did not exist and returned 404 for a day.
Directory images
Two images are required, both at a 3:2 aspect ratio.
| File | Minimum size | Use |
|---|---|---|
images/screenshot.png |
1500 by 1000 pixels | the listing page |
images/tn.png |
900 by 600 pixels | the thumbnail grid |
Both show the same view. Render the example site at 1500 by 1000 with a device scale factor of 2. Resize the resulting 3000 by 2000 capture down to each target. A native 900-pixel render triggers the mobile layout. This theme collapses its menu below 60rem.
Open every collapsible element before capturing. A screenshot of a collapsed menu hides the structure the theme exists to show.
Base template
layouts/_default/baseof.html wraps every page. Sections that differ per
page are block definitions. Templates such as single.html and list.html
fill them.
<!doctype html>
<html lang="{{ site.Language.Lang }}">
<head>{{ partial "head.html" . }}</head>
<body>
{{ partial "rail.html" . }}
{{ block "main" . }}{{ end }}
{{ partial "footer.html" . }}
</body>
</html>
Hugo resolves templates by lookup order. A file at layouts/partials/x.html
in the site overrides the theme’s file of the same path. This is the whole
override mechanism. A theme exposes a customisation point by putting the
markup in a partial and naming it in the README.
A placeholder wordmark ships at layouts/partials/wordmark.html. A site
replaces it by writing that path in its own layouts/ directory. No
configuration value is involved.
Menu construction
Hugo’s documentation instructs theme authors not to hardcode section names.
The method Site.MainSections exists for that purpose. It defaults to the
top-level section holding the most pages when a site sets no mainSections
value.
This theme builds its menu from site.Home.Sections, which returns every
top-level section. A recursive partial walks each section’s children.
{{ $sections := site.Home.Sections }}
{{ partial "topictree.html" (dict "page" $ "sections" $sections "depth" 0) }}
An earlier revision read site.GetPage "/topics". Pointed at a site with
content/posts/ and content/docs/, that revision rendered an empty menu,
because no folder carried the expected name.
Two rules govern which folders appear. A folder holding _index.md is a
section and appears. A folder holding index.md is a single page with its
adjacent files as page resources, and does not appear. A section that needs
a URL without a menu entry declares it in front matter.
build:
list: never
render: always
Asset pipeline
Files under assets/ reach the page through resources.Get. The pipeline
returns a resource carrying a fingerprinted permalink and an integrity hash.
{{ $css := resources.Get "css/ff1.css" | minify | fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
The same pipeline gives a site an override point without configuration. The theme requests an optional second stylesheet and emits the link only when the file exists.
{{ with resources.Get "css/custom.css" }}
{{ $extra := . | minify | fingerprint }}
<link rel="stylesheet" href="{{ $extra.RelPermalink }}" integrity="{{ $extra.Data.Integrity }}">
{{ end }}
Scripts load only on pages that need them. The embed script is emitted when
the rendered content contains an embed, the search script only on a page with
layout: search.
Hugo cannot merge an [outputs] block from a theme into a site’s
configuration. A theme therefore cannot add a JSON output format. This
theme’s search page builds its index with resources.FromString and writes
it as a fingerprinted file, which requires no site configuration. The demo
index is 8,774 bytes for 13 pages.
URLs under a subpath
A URL written with a leading slash points at the server root. A site served from a subdirectory returns 404 for every one of them.
relURL does not correct this. Hugo treats a leading slash as already
relative to the root and leaves the string unchanged. Trimming the slash
first produces the base path.
{{ "/tags/" | relURL }} {{/* /tags/ */}}
{{ "tags/" | relURL }} {{/* /template/tags/ */}}
{{ "/tags/" | strings.TrimPrefix "/" | relURL }} {{/* /template/tags/ */}}
This theme routes every configured URL through a partial. The partial trims the slash. A URL with a scheme, or with two leading slashes, passes through untouched.
The fault reached production. The demo at /template/ linked its Tags and
Search rows to the site root. Both returned 404.
Translation keys
Every string the theme prints comes from i18n/en.toml. This theme defines
20 keys.
[pieceCount]
one = "{{ .Count }} piece"
other = "{{ .Count }} pieces"
[subsections]
other = "Subtopics"
Hugo merges a site’s i18n/en.toml over the theme’s, key by key. A site
changes one word without restating the file. Plural forms remove a class of
bug: an earlier revision printed 1 pieces.
Three failures are possible and only one is visible in a browser. A key defined and never called is dead weight. A key called and never defined renders as an empty string with no build error, so a heading disappears. A site key that matches no theme key does nothing and reports nothing.
Example site
exampleSite/ contains a working site. The directory validates a submission
against it, and it is what a downloader copies. It needs its own hugo.toml
naming the theme, and a content/ directory.
This theme’s example site contains 21 markdown files across three top-level sections nested three levels deep. Content depth is the feature under demonstration, so flat sample content would demonstrate nothing.
Build it with --themesDir pointing at the parent of the theme directory.
hugo --source themes/foundingfuture-i/exampleSite \
--themesDir "$PWD/themes" \
--destination public/template \
--baseURL /template/ \
--cleanDestinationDir
--cleanDestinationDir removes stale fingerprinted files. Without it, eight
stylesheets accumulated in a directory where each build writes two.
Font licensing
Six fonts ship in static/fonts/, all under the SIL Open Font License 1.1.
The license text is at static/fonts/OFL.txt.
Read the copyright out of each font file. The name table records the authoritative notice. A download page can be out of date.
from fontTools.ttLib import TTFont
font = TTFont("static/fonts/roboto-condensed-caps.woff2", lazy=True)
for record in font["name"].names:
if record.nameID == 0:
print(str(record))
That check found a reserved font name. Source Sans 3 declares
Reserved Font Name 'Source'. Clause 3 of the OFL prohibits a modified
version from using a reserved name.
The copy in the theme mapped 98 characters. The upstream family covers far more. A subset is a modified version under clause 1. The theme now uses Roboto Condensed, whose license reserves no name.
Menu labels need small-capital glyphs. A browser without them scales the capitals. The strokes scale with them, so the small capital reads lighter than the full capital beside it.
The Roboto Condensed subset keeps the smcp and c2sc features from the
upstream variable font. Google Fonts strips both features. The file is
therefore cut from the upstream source and served from the site. The result
is 495 glyphs, 328 mapped, 25 KB.
Bricolage Grotesque omits small capitals across its whole weight axis. A second face was cut from the variable source. Capitals scale to 0.85 of cap height. A search over the weight axis finds a source stroke at 0.92 of a full capital.
At a 700 target the capital stem measures 146 units. The target stroke is 134.3 units. Weight 770.2 scaled to 0.85 gives 135.2. The face contains 161 characters in 36 KB. The method is described in cutting a small-capitals face .
Verification scripts
Four scripts run before every release. Each reads the theme’s input. None reads Hugo’s output.
CSS reachability
check-css.py is 316 lines of Python over tinycss2. It parses the
stylesheet, collects every class, id, attribute and element a selector names,
then walks layouts/, content/, static/, assets/, i18n/ and
hugo.toml for each name. It follows var() chains, so a custom property or
a font face stays alive while a reachable rule asks for it.
Current result for this theme: 249 selectors, 0 unreachable, 21 of 21 custom properties used, 6 of 6 font faces reached.
The script has two known limits. A selector matched only by markup behind an unset configuration value reads as reachable, because the template contains that markup. An unused feature therefore survives the check. One did: a configurable link list occupied six selectors and eight template lines with no site setting the parameter.
The second limit was fixed. Class names built by JavaScript were invisible
to the parser. The script now reads .js files. It treats every quoted
literal as a possible class or id, and reads createElement("tag") as an
element name.
Asset references
check-assets.py is 211 lines. It reports two conditions. The first is a
file named by a template that does not exist. The second is a file present
that no template names.
Licenses are exempt from the second condition. The OFL requires the license to ship, and nothing links to it.
The script reads the theme’s root directory, which includes README.md. Six
images in this theme’s README pointed at a directory the files had left.
They rendered as broken images on GitHub. No scope read the README until the
root was added.
Key coverage
check-i18n.py is 114 lines. It reports the three failures listed under
translation keys. Current result: 20 defined, 20 called, 20 matched.
Built output
check-site.py is 176 lines. It asserts facts about the built site. Pages
that must exist. Minimum type sizes. No URL under a subdirectory build
pointing outside it.
The last assertion was added after the subpath fault. It reported 93 links on the build that was live at the time.
Release tag
The directory shows a version. Tag the repository with an annotated tag whose message lists what the release contains.
git tag -a v0.1.0 -m "FoundingFuture I 0.1.0"
git push origin v0.1.0
Submission checklist
theme.tomlat the root with all eight required fields and an author block, andmin_versionin three-part form.licenselinkreturns 200.LICENSEat the root, under an OSI-approved license.README.mdin English.images/screenshot.pngat 1500 by 1000 or larger, 3:2.images/tn.pngat 900 by 600 or larger, 3:2.exampleSite/withhugo.tomlandcontent/.- The example site builds with the installed Hugo version.
- A published demo URL.
- A version tag.
Submission is a pull request against github.com/gohugoio/hugoThemes (opens in a new tab) .
Download
The theme is at github.com/FoundingFuture/theme-hugo-ff1 (opens in a new tab) under the MIT license. This site runs it. Built with Hugo 0.165.0.