LRG

Wiggle it, just a little bit

Bend links to your will

Using render hooks to make links more flexible

This all began with a post on my socials…

CSS nerds, I need your help, please. I can style a link to an external site with the full URL path in text after it using a[href]=:not([href^=l’https://neilzone.co.uk’i]):after{content:’(‘attr(href)’)’;} I don’t think that there is a CSS-only (i.e. no Javascript, no extra element/attribute in the html) way of limiting this to just the domain of the external link, rather than the full href link. Am I wrong?

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

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")

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

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>

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

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

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>

It uses Hugo’s urls.Parsegohugo.io 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>

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);
}

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.

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>

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;
}

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:

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 )}}

Basically this checks if the hostname is empty using the strings.ContainsNonSpacegohugo.io 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>

the monster css selector looks like:

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

And this renders like this:

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>

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) }}

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

{{ if (compare.Eq $u.String .Text) }}

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>

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; }
}

And this renders like this. Pretty much nails it…

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>

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; }
}

…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")

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>

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>

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) ')';
}

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.

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>

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) ')';
}

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 @toychickenmastodon.social

Finally, if you too love cats, make a donation to the Cats protection leaguewww.cats.org.uk today. 🐈‍⬛