Create Anchored Headings in Hugo Using Shortcodes

A Twitter user, @kaushalmodi, reached out to me with a helpful tip on achieving anchored headings without needing to rely on a shortcode. You can find the solution here. If retaining full markdown syntax in your content is priority, I recommend going with this route.

In Hugo, your content is authored in markdown. This is one of the great features of the static site generator. It makes things very simple for users such as myself who spend most of their time on the platform writing blog posts. Unfortunately, that simplicitly is both a blessing and a curse.

There will inevitably come a point where your content requires a bit more complexity than markdown can handle. This usually leads authors finding themselves writing custom html inside of their .md files. Although valid, it's a forced solution. A better solution would be to use Hugo's shortcodes.

Recently I decided to update a few parts of this site to improve the experience of my visitors. One of the updates was to anchor all of the section headings in my blog posts with a tags, allowing anyone to jump to that section with a simple click or to specifically share that section with someone else. This is a common feature in a lot of blogs.

Before I had this idea, my headings inside .md files would be prefixed with the usual # symbol:

## Look ma, a header!

This outputs the following html:

<h2 id="look-ma-a-header">Look ma, a header!</h2>

Hugo processes markdown content using Blackfriday, a markdown processor that is implemented in Go.

What I want is to have an a tag in the h2 that links to the heading. Something like this:

<h2 id="look-ma-a-header">
  <a href="#look-ma-a-header">#</a>
  Look ma, a header!

My first attempt at solving this problem was to write the above html in the .md file itself. Although this works, it's extremely clunky. Imagine yourself needing to add that snippet to every heading in every blog post. No thanks.

I then began to research whether or not there was a way to abstract this snippet into a single file, and include it in .md files as necessary. I was already doing this with Hugo's template files using partials, so it seemed logical that there would be a way to do this with my content files as well.

After rummaging through Hugo's docs for a few moments, I came across a page describing shortcodes. Here's a definition straight from the docs:

Shortcodes are simple snippets inside your content files calling built-in or custom templates.

Hugo comes with some built-in shortcodes for common use cases such as GitHub gists, syntax highlighting, Twitter posts, and more, but unfortunately there is no shortcode for anchored headers. We can circumvent this by defining a custom shortcode instead.

Defining a custom shortcode

In order to define a custom shortcode, you'll need to create a layouts/shortcodes/ directory. Once that's done, create an html file inside the directory that has a name equal to the shortcode you wish to define. For example, a shortcode named heading will require an html template at layouts/shortcodes/heading.html.

Inside of the template you'll need to add the html the shortcode should output:

<h2 id="look-ma-a-header">
  <a href="#look-ma-a-header">#</a>
  Look ma, a header!

Of course there shouldn't be any hardcoded values inside of the shortcode itself. This is the only caveat. We'll need a way to tell the shortcode:

  • the title of the heading
  • the link of the heading

This can be done in a few ways.

Named parameters

Named parameters can be passed to a shortcode at the time you call the shortcode in your content files. Think of them as arguments to a function. In this situation it would make sense to have two named parameters, title and link.

To call a shortcode with named parameters in your content file, you need to put the name of the shortcode first, followed by your parameters:

{{%/* heading title="Look ma, a header!" link="look-ma-a-header" */%}}

To access the parameters in the shortcode template we'll need to replace the hardcoded text with the {{ .Get "parameter_name" }} syntax:

<h2 id="{{ .Get "link" }}">
  <a href="#{{ .Get "link" }}">#</a>
  {{ .Get "title" }}

Now anytime you call the heading shortcode and pass it the proper parameters, you will receive the html above with the parameter values in place. This a perfectly adequate solution and works exactly as expected, however, I don't like how we have to add both the title parameter and the link parameter. Hugo is powerful enough to generate the value of link for us based on the title of the heading. Let's opt for this route.

Earlier in the post I described how this markdown:

## Look ma, a header!

generates this html:

<h2 id="look-ma-a-header">Look ma, a header!</h2>

Hugo processes markdown content using Blackfriday, a markdown processor that is implemented in Go. One of its features is to add anchors to headings in your .md files. That's why the h2 above receives an id with a sanitized value of the content it holds.

Unfortunately, we opt out of this feature when using shortcodes, so it's up to us to do the work ourselves. We can accomplish this with Hugo's anchorize function, which takes a string as its input and outputs a sanitized version in the same way that Blackfriday does.

Let's update the heading.html template file to sanitize the title parameter:

<h2 id="{{ anchorize (.Get "title") }}">
  <a href="#{{ anchorize (.Get "title") }}">#</a>
  {{ .Get "title" }}

anchorize expects one argument, so (.Get "title") is wrapped in parentheses.

Now there is no need to pass the heading shortcode a link parameter, removing some overhead and making things cleaner. Again, this a perfectly adequate solution and you can stop here if your needs are met. However, there is one more improvement we can make to this template file.

Using the .Inner variable

The .Inner variable receives a value equal to the content placed between an opening shortcode and a closing shortcode. This is different to how shortcodes that rely on named parameters work. Below is an example of how such a shortcode would be called in a content file.

{{%/* heading %}}Look ma, a header!{{% /heading */%}}
Closing shortcodes are optional.

Notice how the concept of an opening and closing shortcode is similar in nature to html elements. The shortcode name must match in both the opening and closing brackets, and the closing bracket should contain a / just like closing html tags.

By calling the heading shortcode using the syntax above, the template file will be able to access the text content "Look ma, a header!" via the .Inner variable instead of a named parameter.

<h2 id="{{ anchorize .Inner }}">
  <a href="#{{ anchorize .Inner }}">#</a>
  {{ .Inner }}

To keep things DRY, lets extract the call to anchorize out of the html and into a variable:

{{ $anchorized := anchorize .Inner }}

<h2 id="{{ $anchorized }}">
  <a href="#{{ $anchorized }}">#</a>
  {{ .Inner }}

Awesome! The template file is much cleaner and it works like a charm. You can add a class to your h2 element and style until your 💜's content. I would even consider naming the heading shortcode something more succinct, like h2. That way the declaration of your shortcode resembles that of a regular h2 element:

{{%/* h2 %}}Look ma, a header!{{% /h2 */%}}

One unfortunate downside to this is that you can't, in my knowledge, declare dynamic html tags based on the heading you desire. For instance, instead of an h2 you may want to render an h3. This was my case.

Currently I just have a separate shortcode for each heading type, so an h2.html and an h3.html. It's not the best, but it gets the job done. If anyone knows how to render html tags using Go templates I'd love to know!

Wrapping up

That's pretty much all there is to it. By now you should have a pretty solid understanding of how shortcodes work in Hugo, and how to create an anchored heading using shortcodes and the anchorize function. If you have any questions, reach out to me on Twitter. Happy coding!

Jake Wiesler

Hey! 👋 I'm Jake

Thanks for reading! I write about software and building on the Web. Learn more about me here.

Subscribe To Original Copy

A weekly email for makers on the Web.

Learn More