7.3 Syntax Highlighting with Chroma: Fenced Code Blocks
Right, let’s talk about making your code blocks look less like a sad notepad.exe session and more like you opened it in a proper IDE. Hugo, in its infinite wisdom, doesn’t do this itself. It outsources the job to a library called Chroma. This is a good thing. Chroma is excellent. It’s written in Go, it’s fast, and it supports a staggering number of languages. Your job is to tell Chroma exactly what you want.
The basic incantation is simple. You use a fenced code block with the language tag. Hugo sees this, hands the code inside to Chroma, and Chroma returns a beautifully highlighted HTML snippet, all wrapped in <div> and <pre> tags with a cascade of <span> elements applying CSS classes for coloring.
```go
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
But of course, the devil is in the details, and the first detail is: how do you tell Hugo to actually *use* Chroma? You have to configure it. This isn't enabled by default in some themes, which is a design choice I find utterly baffling. It's 2024. Syntax highlighting is not a "nice to have."
### Configuring Chroma in hugo.toml
You control Chroma's behavior in your `hugo.toml` (or `config.toml`) file. Here's a robust starting point that enables it and chooses a style. I'm partial to `monokai` because I'm not a monster, but choose your own adventure.
```toml
[markup]
[markup.highlight]
codeFences = true
guessSyntax = false
hl_Lines = ''
lineNos = true
lineNumbersInTable = true
noClasses = false
style = 'monokai'
tabWidth = 4
Let’s break this down because some of these options are crucial:
codeFences = true: Enables highlighting for```code blocks. Leave this on. Seriously.guessSyntax = false: This is a trap. When set totrue, if you forget a language tag, Chroma will try to guess the language. It’s bad at it. It’s really bad at it. It will confidently identify your.zshrcsnippet as Pascal. Turn this off. It forces you to be explicit, which is a good thing.lineNos = true&lineNumbersInTable = true: This gives you line numbers, but wraps the whole thing in a table structure which prevents the annoying copy-paste problem of grabbing the line numbers along with your code. Always use the table option.noClasses = false: This is vital. If you set this totrue, Chroma will output inlinestyle="color: #ff8c00"attributes instead of CSS classes. This is horrible for theming and customization. You want classes. Trust me.style = 'monokai': This selects the built-in color theme. You can see all available styles withhugo gen chromastyles --style=monokai(change the style name to see others).
The Critical CSS Step
Here’s the part everyone misses and then gets frustrated when their code blocks are white-on-white: the Chroma configuration only generates the HTML with classes. It does not provide the CSS. You are responsible for that.
You need to get the CSS for your chosen style and include it in your site’s stylesheet. The easiest way is to generate it and paste it:
hugo gen chromastyles --style=monokai > assets/css/syntax.css
Then, include that generated syntax.css file in your site’s <head> via your base template. The generated CSS is a series of rules for classes like .chroma .hl, .chroma .kd, etc. Without this file, you have unstyled, invisible code. Don’t panic when this happens. Just add the CSS.
Highlighting Specific Lines and Other Party Tricks
Sometimes you need to draw attention to a particular line, like the one where the magic happens or, more likely, the one where the terrible error is thrown. You can do this with Chroma by adding a highlight indicator in the code fence.
```go {hl_lines=[2,5-7]}
func problematicFunction() {
userId := getUserId() // This will be hilariously nil
data, err := fetchData(userId)
if err != nil {
// Let's highlight this whole cascade of failure
log.Fatalf("Everything is on fire: %v", err)
return
}
process(data)
}
You can even add line numbers starting from a specific number, which is weirdly useful for referencing code from another file:
```python {linenos=true, linenostart=42}
# ... continued from line 41 of another script
def answer():
return 42 # The line number matches the answer. Coincidence?
The configuration in your `hugo.toml` sets the defaults, but these options in the fence itself (`hl_lines`, `linenos`, `linenostart`) override them on a per-block basis. Use this power wisely.
The most common pitfall, after forgetting the CSS, is forgetting the language tag. Without it, and with `guessSyntax = false`, you get a plain code block. With `guessSyntax = true`, you get a colorful abomination. Be explicit. Always tag your code. It’s the least you can do for the person trying to read it.