Table of contents
Hugo is a fascinating piece of software. Unlike other static site generators which either give you a template engine and bare minimum functionality you need to get along (like, say, Zola) or straight up let you execute arbitrary JavaScript code (like, say, 11ty), Hugo is different — it gives you a template engine and an absurdly wide range of features on top of it, basically extending it into a real dynamic programming language with list processing, string manipulation, and text formatting. This is why I love it — it gives you a lot of freedom without the responsibility of not falling down the npm rabbit hole.
A major reason why this is possible is Hugo's template engine of choice — Go templates. They let you simply hook up a bunch of structures and functions to it and make Go's runtime reflection magic handle the rest. In no uncertain terms, they turn Go, a compiled language with a rich package ecosystem, into a scripting language — all you need to do is expose the things you want to the user. As a basic concept, this is groundbreaking, and Hugo takes full advantage of it, giving you nice stuff like image processing capabilities, filesystem traversal capabilities, the ability to read image Exif metadata.
Unfortunately, Go templates are also full of design flaws and missed opportunities that hold it back from its true potential, which I slowly discovered while using Hugo for my websites over the course of the past few years. Here they are, roughly in the order of egregiousness:
Bad design
Section titled 'Bad design'0 == 0.0 is false
Section titled '0 == 0.0 is false'{{/* Throws no error, prints false. */}}
{{ eq 0 0.0 }}
Now, I know how bad this looks at first glance — it truly feels like Go's authors are trolling you. Especially if your first glance at this issue is after hours of debugging why your if statement fails to catch if a variable is equal to 0, like what happened to me. But hold on with me for a moment, there's actually a technical reason for it. Sort of.
Go is a strongly typed programming language, meaning that if you try to perform an arithmetic operation on 2 different types or pass one type where another one is expected, it'll scream at you instead of coercing your value to the right type. When it comes to numbers, it's even stronger than other strongly typed languages like Python, and it won't coerce an integer to a float. Take the following code, for instance:
a := 0
b := 0.0
fmt.Println(a == b)
If you tried to compile it, Go would notice that the variable a
is
an integer and the variable b
is a float, and rightfully tell you
that you can't do it:
invalid operation: a == b (mismatched types int and float64)
Go templates inherit this property of being strongly typed. Unlike Go,
however, which is statically typed, Go templates are dynamically
typed, meaning that the same variable can be reassigned to a value of
a different type, which makes sense for a tiny template language. As
such, instead of reporting an error, like a strong static language
would, or coercing the values, like a weak language would, it silently
returns a false
because the types don't match.
Except it's not quite that simple. Go does, in fact, coerce the values, but only if both operands are literals, not variable identifiers:
// Prints true
fmt.Println(0 == 0.0)
Go templates, on the other hand, don't perform coercion even in this case and always treat the operands like Go would treat variable identifiers, leading to complete chaos where Go templates' behavior doesn't match Go's behavior.
Variable assignment is a footgun
Section titled 'Variable assignment is a footgun'This is another case where Go templates do what Go does but in an inconvenient way. As far as variables go in Go (pun intended), it's about as follows:
- Variables can be declared with
var identifier = value
. There's also syntactic sugar for this —identifier := value
— and since it saves you 3 whole key presses, everyone is using it instead. - You can assign a new value to the variable with
identifier = value
. - Variables are block scoped, meaning that if you enter a block like the body of an if statement, you can declare a variable with the same identifier as another variable without affecting the latter in any way because your variable will only ever stay within that block.
On its own, :=
used for variable declaration and =
used for
variable assignment sounds like a bad idea — it'd be really easy to
mix up the two of them and end declaring a new scoped variable instead
of assigning to an existing one, and you wouldn't use the var identifier = value
syntax out of the need to conform to the agreed
upon code style.
But fear not! Go has us covered here. If you do mess up, you'll most likely create a variable that's declared and then never used, which Go will scream at you about and not let you compile your project:
declared and not used: identifier
What do Go templates do in this case? Nothing:
{{ $meal := .PotatoChips }}
{{ if $isBeforeMidnight }}
{{/* Throws no error. */}}
{{ $meal := .BuyFood "healthy" }}
{{ end }}
{{/* Always .PotatoChips */}}
{{ $meal }}
And you can't even use the var identifier = value
syntax here
because it doesn't exist in Go templates.
There's nil but no nil literal
Section titled 'There's nil but no nil literal'This one is self-explanatory: In Go templates, nil
exists as a
keyword but doesn't exist as a literal. You can compare values to
nil
but can't assign a variable to it without resorting to hacks:
{{ $dict := dict }}
{{ $nil := index $dict "non-existent key" }}
{{/* Prints <nil>. */}}
{{ print $nil }}
{{/* Prints <nil>. */}}
{{ if eq $nil nil }}
{{ print $nil }}
{{ end }}
{{/* Throws an error. */}}
{{ $notNil := nil }}
To be completely fair, the practical impact of this is negligible — it's at most a nuisance that you can't set a variable to an undefined value in the few cases where you need it. But from a language design standpoint, this is extremely cursed. An unrepresentable primitive is not a thing you see often in other languages.
There's no ternary operator
Section titled 'There's no ternary operator'Let's say you want to have a variable that has 1 of 2 possible values, picked depending on whether a certain condition is satisfied. In Hugo, you can do it like this:
{{ $meal := cond $isBeforeMidnight
(.BuyFood "healthy")
.Fridge.Contents.Extract
}}
There are 2 problems with this: First, you can only do it in Hugo.
cond
is not a thing in Go templates by default. Second, there's a
bug in this code. Since Hugo couldn't add a new keyword to the
language, they had to make cond
a function. And since functions
don't do short circuit evaluation like, say, an and
keyword, cond
will evaluate both possible values regardless of the condition,
leading to hard to diagnose side effects.
There's only one way to work around this — use an if statement:
{{/* No nil, baby! */}}
{{ $meal := false }}
{{ if $isBeforeMidnight }}
{{ $meal = .BuyFood "healthy" }}
{{ else }}
{{ $meal = .Fridge.Contents.Extract }}
{{ end }}
The difference in the number of braces speaks for itself.
Missed opportunities
Section titled 'Missed opportunities'Pipes are inflexible
Section titled 'Pipes are inflexible'Go templates have a nifty feature that lets you chain function calls without having to wrap everything in a million parentheses or making a variable for every intermediate value:
{{ $meal := $potatoes | boil | mash | stickIn $stew }}
Is syntactic sugar for:
{{ $meal := stickIn $stew (mash (boil $potatoes)) }}
You may already see a problem with this: It always passes the value as
the last argument to the function. Let's say that it turns out the
function stickIn
accepts the arguments in the opposite order. Now
you have to change the code above to:
{{ $meal := stickIn ($potatoes | mash | boil) $stew }}
And if you then want to call a method on the resulting value, it gets even worse:
{{ $meal := (stickIn ($potatoes | mash | boil) $stew).Serve }}
Compare this to the ES proposal for pipes in JavaScript that uses placeholders for argument placement instead:
const meal = potatoes
|> mash(%)
|> boil(%)
|> stickIn(%, stew)
|> %.serve();
Much better. This is how pipes should be done, if you ask me.
Templates can't return a value
Section titled 'Templates can't return a value'Go templates let you define and call inline templates in your code, and I can't overstate how awesome this is. For example, if your template host provides you with some rudimentary reflection capabilities, which Hugo does, you can use it to print lists with recursion:
{{ define "print-slice" }}
[
{{ range . }}
{{ if reflect.IsSlice . }}
{{ template "print-slice" . }}
{{ else }}
{{ . }}
{{ end }}
{{ end }}
]
{{ end }}
{{ template "print-slice" (slice 9 8 7 (slice 7 6 5) 4 32 true) }}
But unfortunately, printing is the only way you can output values in vanilla Go templates. Hugo does have a workaround for this — inline partials — but it's not without its flaws. Let's say you want to make a function that calculates the factorial of a number:
{{ define "partials/factorial" }}
{{ return (math.Product (seq 1 .)) }}
{{ end }}
{{ partial "factorial" 5 }}
This works correctly but only for positive values because seq
will
start iterating backwards for negative values. If you try to fix it
the naive way:
{{ if gt . 1 }}
{{ return (math.Product (seq 1 .)) }}
{{ else }}
{{ return 1 }}
{{ end }}
You'll get an inscrutable error like this:
execute of template failed at <return>: wrong number of args for return: want 0 got 1
Because it doesn't work for the same reason cond
can't short circuit
evaluate its arguments — return
is just a function. Since it's not a
keyword, it can't preemptively stop the execution of a function. All
it can do is set a value somewhere and tell Hugo to later put it in
the right place. So instead, you have to do this:
{{ $result := 1 }}
{{ if gt . 0 }}
{{ $result = math.Product (seq 1 .) }}
{{ end }}
{{ return $result }}
Go templates were this close to having user definable functions, but they didn't add a return keyword. What a shame.
Conclusion
Section titled 'Conclusion'If you're writing a new template engine, it'd be really cool if you added the things Go templates do right without adding the things they do wrong. Just sayin'.