DEV Community

Cover image for Using Hugo Render hooks to make links bend to your will
Leigh Garland
Leigh Garland

Posted on

Using Hugo Render hooks to make links bend to your will

This all began with a post on my socials...

https://mastodon.neilzone.co.uk/@neil/113244632967157645

https://mastodon.neilzone.co.uk/@neil/113244632967157645

The consensus certainly seemed to be that it's not possible to do this solely with CSS (which is a shame), but Neil did mention that they were investigating using Hugo to solve this.

My advice was to use Render Hooks, but I couldn't stop thinking about how I might do this, as it seemed like a neat way to present those long external links...

This is where I started

You can jump straight to the solution if you want. Otherwise, read on.

I spun up a standard Hugo quickstart project, and added some links to my site index page:

Here is a variety of links to test Hugo and/or CSS to show just the domain for external links...

**Internal links**

* [Custom text](/i_love_cats)
* [Custom text, with title](/i_love_cats "I love cats")
* [Custom text, absolute path](http://localhost:1313/i_love_cats)

**External links**

* Top-level domain, auto-generated text https://cats.org
* Deep link, auto-generated text https://www.cats.org.uk/help-and-advice/lost-found-and-feral-cats/lost-found-and-feral-cats
* [Custom text, Top-level domain](https://cats.org)
* [Custom text, Top-level domain, with title ](https://cats.org "I love rescue cats")
* [Custom text, deep link](https://www.cats.org.uk/help-and-advice/lost-found-and-feral-cats/lost-found-and-feral-cats)
* [Custom text, deep link, with title](https://www.cats.org.uk/help-and-advice/lost-found-and-feral-cats/lost-found-and-feral-cats "Lost & found cats")
Enter fullscreen mode Exit fullscreen mode

I wanted to account for all the ways you can present links in markdown, in Hugo. It looks something like this:

Links rendered without adaptation
I think it's those pesky auto-generated deeplinks that Neil wants to make a little prettier.

The rendered link looks like:

<a href="https://www.cats.org.uk/help-and-advice/lost-found-and-feral-cats/lost-found-and-feral-cats">
    https://www.cats.org.uk/help-and-advice/lost-found-and-feral-cats/lost-found-and-feral-cats
    </a>
Enter fullscreen mode Exit fullscreen mode

Which doesn't give you much to hook into with CSS.

This is where Hugo's render hooks come in. For a certain selection of commonly used html elements, Hugo gives you the chance to override the default rendering, and define how you want it to render yourself using the Hugo template system. Hugo provides you with 3 variables for a link.

[Post 1](/posts/post-1 "My first post")
 ------  -------------  ------------- 
  text    destination      title
Enter fullscreen mode Exit fullscreen mode

My first step was to add a data-domain attribute to see if I could use it to replace the content. I created a render-link.html file in

layouts/
    _default/
        _markup/
            render-link.html
Enter fullscreen mode Exit fullscreen mode

This now overrides the default markdown rendering for links. I started with:

{{ $u := urls.Parse .Destination }}

<a href="{{ .Destination | safeURL }}" 
data-domain="{{ $u.Hostname }}">{{ .Text | safeHTML }}
</a>
Enter fullscreen mode Exit fullscreen mode

It uses Hugo's urls.Parse function to take the destination and convert to an object of the various parts of a url. In our case, we just need the Hostname . I added it to the data-domain attribute, and our deep link now looks like

<a href="https://www.cats.org.uk/help-and-advice/lost-found-and-feral-cats/lost-found-and-feral-cats" data-domain="www.cats.org.uk">https://www.cats.org.uk/help-and-advice/lost-found-and-feral-cats/lost-found-and-feral-cats</a>
Enter fullscreen mode Exit fullscreen mode

Then I added a bit to the CSS for links, to show the data-domain attribute as extended content for the link, like this:

a[data-domain]::after {
    content: ' - ' attr(data-domain);
}
Enter fullscreen mode Exit fullscreen mode

But, this was a bit messy, as the data-domain was missing for relative links, and rendered some odd dashes at the end of lines. Also, where the markdown had infered the link test from the url, it looked even messier. I couldn't find a neat way to reliably style out the actual link text leaving only the shorter hostname.

Another screengrab of the output link list,

Given I had the urls.Parse method, I now realised I could do something a little more fancy-schmancy, and build up some additional elements, easily styled by CSS. Something like this:

{{ $u := urls.Parse .Destination }}
<a href="{{ .Destination | safeURL }}"
data-domain="{{ $u.Hostname }}">
    <span class="link-text">{{ .Text | safeHTML }}</span>
    <span class="hostname-text">{{ $u.Hostname }}</span>
</a>
Enter fullscreen mode Exit fullscreen mode

Which didn't really change the output, but now I had some CSS selectors to work with.

I created this monstrosity (you would swap out localhost for your site's domain)

a[href]:not([href*="://localhost"]) .link-text {
    display: none;
}
Enter fullscreen mode Exit fullscreen mode

Thinking this would pretty much do the trick, but... it fails on relative links, because they too don't match the ://localhost domain. Looks like:

Screenshot, internal links don't show their text

Oops 😦

I needed to provide a few more class names for the CSS to be able to hook into.

First up - is this a relative link? For this I need to check if the hostname is empty.

{{ if not (strings.ContainsNonSpace $u.Hostname )}}
Enter fullscreen mode Exit fullscreen mode

Basically this checks if the hostname is empty using the strings.ContainsNonSpace functionality.

So I added a $class variable to store some output, and our template looks like this:

{{ $u := urls.Parse .Destination }}
{{ $class := "" }}

{{ if not (strings.ContainsNonSpace $u.Hostname)}}
    {{ $class = "relative" }}
{{ end }}

<a href="{{ .Destination | safeURL }}" 
class="{{ $class }}"
data-domain="{{ $u.Hostname }}">
    <span class="link-text">{{ .Text | safeHTML }}</span>
    <span class="hostname-text">{{ $u.Hostname }}</span>
</a>
Enter fullscreen mode Exit fullscreen mode

the monster css selector looks like:

a[href]:not([href*="://localhost"]):not(.relative) .link-text {
    display: none;
}
Enter fullscreen mode Exit fullscreen mode

And this renders like this:

Screenshot, looking better, but still some missing text

Which is looking loads better, but has nixed the explicit text on the last four of those links 😒 and is still showing the domain for the absolute link too.

I tried a few variations with the CSS, but this is the best it got.

I needed to make sure I could also explicitly detect an external link AND / OR one with explicit text.

For this, I needed some more template magic.

{{ $u := urls.Parse .Destination }}
{{ $yourDomain := "localhost" }}
{{ $class := "" }}

{{ if not (strings.ContainsNonSpace $u.Hostname)}}
    {{ $class = "relative" }}
{{ else }}
    {{ if not (compare.Eq $u.Hostname $yourDomain) }}
        {{ $class = "external" }}
        {{ if (compare.Eq $u.String .Text) }}
            {{ $class = "external implied"}}
        {{ end }}
    {{ end }}
{{ end }}

<a href="{{ .Destination | safeURL }}" 
class="{{ $class }}" 
data-domain="{{ $u.Hostname }}">
    <span class="link-text">{{ .Text | safeHTML }}</span>
    <span class="hostname-text">{{ $u.Hostname }}</span>
</a>
Enter fullscreen mode Exit fullscreen mode

Now we have a way, by comparing the $u.Hostname to a $yourDomain variable, to see if it explicitly external

{{ if not (compare.Eq $u.Hostname $yourDomain) }}
Enter fullscreen mode Exit fullscreen mode

AND we subsequently check to see if the url is the same as the .Text

{{ if (compare.Eq $u.String .Text) }}
Enter fullscreen mode Exit fullscreen mode

And change the class appropriately. Now we're getting closer! Our rendered deeplink looks like this:

<a href="https://www.cats.org.uk/help-and-advice/lost-found-and-feral-cats/lost-found-and-feral-cats" 
   class="external implied" 
   data-domain="www.cats.org.uk">
    <span class="link-text">https://www.cats.org.uk/help-and-advice/lost-found-and-feral-cats/lost-found-and-feral-cats</span>
    <span class="hostname-text">www.cats.org.uk</span>
</a>
Enter fullscreen mode Exit fullscreen mode

We can update our CSS now, to be a little more readable:

a.relative {
  .hostname-text {
    display: none;
  }
}

a.external:not(.implied) {
  .hostname-text { display:none }
}

a.external.implied {
  .link-text { display: none; }
}
Enter fullscreen mode Exit fullscreen mode

And this renders like this. Pretty much nails it...

Screenshot, all text restored, but is showing the link on the absolute internal link

Bingo!

Grr, then I noticed the absolute link is not accounted for. A simple default on the $class variable, and an additional definition in the CSS, and it's fixed.

{{ $u := urls.Parse .Destination }}
{{ $yourDomain := "localhost" }}
{{ $class := "internal" }}

{{ if not (strings.ContainsNonSpace $u.Hostname)}}
    {{ $class = "relative" }}
{{ else }}
    {{ if not (compare.Eq $u.Hostname $yourDomain) }}
        {{ $class = "external" }}
        {{ if (compare.Eq $u.String .Text) }}
            {{ $class = "external implied"}}
        {{ end }}
    {{ end }}
{{ end }}

<a href="{{ .Destination | safeURL }}" 
class="{{ $class }}" 
data-domain="{{ $u.Hostname }}">
    <span class="link-text">{{ .Text | safeHTML }}</span>
    <span class="hostname-text">{{ $u.Hostname }}</span>
</a>
Enter fullscreen mode Exit fullscreen mode

And the CSS...

a.internal,
a.relative {
  .hostname-text {
    display: none;
  }
}

a.external:not(.implied) {
  .hostname-text { display:none }
}

a.external.implied {
  .link-text { display: none; }
}
Enter fullscreen mode Exit fullscreen mode

...and bingo.

Over-engineering

Yep, that's it. I think this meets the needs of what we wanted in the first place. Namely that it only shows the top-level domain for those implied-text, deeplink urls, and everything else looks as expected...

But there's a little more...

Don't forget, Hugo allows us to pass in a 'title' into our markdown, like this:

[Custom text, deep link, with title](https://www.cats.org.uk/help-and-advice/lost-found-and-feral-cats/lost-found-and-feral-cats "Lost & found cats")
Enter fullscreen mode Exit fullscreen mode

But that title attribute is not currently rendered by our customer render hook. Let's fix that:

{{ $u := urls.Parse .Destination }}
{{ $yourDomain := "localhost" }}
{{ $class := "internal" }}

{{ if not (strings.ContainsNonSpace $u.Hostname)}}
    {{ $class = "relative" }}
{{ else }}
    {{ if not (compare.Eq $u.Hostname $yourDomain) }}
        {{ $class = "external" }}
        {{ if (compare.Eq $u.String .Text) }}
            {{ $class = "external implied"}}
        {{ end }}
    {{ end }}
{{ end }}

<a href="{{ .Destination | safeURL }}" 
{{ with .Title}}title="{{ . }}"{{ end }} 
class="{{ $class }}" 
data-domain="{{ $u.Hostname }}">
    <span class="link-text">{{ .Text | safeHTML }}</span>
    <span class="hostname-text">{{ $u.Hostname }}</span>
</a>
Enter fullscreen mode Exit fullscreen mode

We've simply tested to see if the .Title variable exists, and if it does, add the title attribute. Our link now renders like:

<a href="https://www.cats.org.uk/help-and-advice/lost-found-and-feral-cats/lost-found-and-feral-cats" 
   title="Lost &amp; found cats" 
   class="external" 
   data-domain="www.cats.org.uk">
    <span class="link-text">Custom text, deep link, with title</span>
    <span class="hostname-text">www.cats.org.uk</span>
</a>
Enter fullscreen mode Exit fullscreen mode

Not a big deal, but what's nice is that you can use the existence of the title attribute to give some of those deeplinks a bit more context, without showing the whole URL. We add this to the end of our CSS:

a[title]::after {
  content: '(' attr(title) ')';
}
Enter fullscreen mode Exit fullscreen mode

and we get titles. Which you can style if you want. It also gives you the title on when the mouse hovers over the link.

Screenshot, now showing content from the title attribute

One more thing

Last of all, I want my external links to always open in a new tab, so we can add a target attribute to our link. And that is the solution!

Solution

/layouts/_default/_markup/render-link.html

{{ $u := urls.Parse .Destination }}
{{ $yourDomain := "localhost" }}
{{ $class := "internal" }}
{{ $target := "" }}

{{ if not (strings.ContainsNonSpace $u.Hostname)}}
    {{ $class = "relative" }}
{{ else }}
    {{ if not (compare.Eq $u.Hostname $yourDomain) }}
        {{ $class = "external" }}
        {{ $target = "_blank" }}
        {{ if (compare.Eq $u.String .Text) }}
            {{ $class = "external implied"}}
        {{ end }}
    {{ end }}
{{ end }}

<a href="{{ .Destination | safeURL }}" 
{{ with .Title}}title="{{ . }}"{{ end }} 
target="{{ $target }}"
class="{{ $class }}" 
data-domain="{{ $u.Hostname }}"><span class="link-text">{{ .Text | safeHTML }}</span> <span class="hostname-text">{{ $u.Hostname }}</span></a>
Enter fullscreen mode Exit fullscreen mode

And the final CSS...


a.internal,
a.relative {
  .hostname-text {
    display: none;
  }
}

a.external:not(.implied) {
  .hostname-text { display:none }
}

a.external.implied {
  .link-text { display: none; }
}

a[title]::after {
  content: '(' attr(title) ')';
}
Enter fullscreen mode Exit fullscreen mode

Obviously, this is a little over-engineered, but it gives you a lot of flexibility to display links however you like.

Enjoy!

If you found this useful, or have feedback please drop me a line @toychicken

Finally, if you too love cats, make a donation to the Cats protection league today. πŸˆβ€β¬›

Top comments (0)