Crosslinks by Title in Hugo--But Better!

Once upon a time, I solved Crosslinks by Title in Hugo. Back then, I added a shortcode so that I could link to any post by title like this:

{{< crosslink "Title goes here" >}}

It worked pretty well, but … it never really felt ‘Markdown’y. Which I suppose was the point.

But more recently, I came across Markdown render hooks.

What’s that you say? I can write code that will take the parameters to any Markdown link (or image/heading/codeblock) and generate the HTML with a custom template?

Interesting!

The basic implementation

In a nutshell–and per the docs–all you have to do is make a file [layouts/_default/_markup/render-link.html]. It will be provided the .Page being rendered, the .Destination the link is to, the .Title (if specified) and the .Text / .PlainText to the page.

So if you have:

[An example link](https://example.com)

.Text / .PlainText will be An example link and .Destination will be https://example.com

If we just want to re-implement the basic functionality:

<!-- render-link.html -->
<a href="{{ .Destination }}">{{ .Text }}</a>

Easy enough.

Now one step further, what if we want all links (by default) to open in a new tab?

<!-- render-link.html -->
<a href="{{ .Destination }}"{{ if strings.HasPrefix .Destination "http" }} target="_blank" rel="noopener"{{ end }}>{{ .Text }}</a>

I think you might be guessing what’s next…

What’s neat about that is the .Text can be pretty flexible. What if I make links like this:

[[Title goes here]]()

In this case, .Text is [Title goes here] (note the leading and trailing square brackets) and .Destination is empty. And… it looks an awful lot like a wikistyle crosslink! (The () at the end is suboptimal but at the moment unavoidable, so it goes)

So we have:

<!-- render-link.html -->
{{-
if (and (strings.HasPrefix .PlainText "[")
        (strings.HasSuffix .PlainText "]")
        (eq .Destination ""))
-}}
    {{- /*
        Crosslinks:
            [[Title]]() 
    */ -}}

    {{- $scratch := newScratch -}}

    {{- $fixedText := substr .PlainText 1 -1 }}
    {{- $fixedText = (replace $fixedText "&lsquo;" "'") -}}
    {{- $fixedText = (replace $fixedText "&rsquo;" "'") -}}

    {{- $parts := split $fixedText "|"  -}}
    {{- $title := $fixedText -}}
    {{- $text := $fixedText -}}

    {{- $scratch.Set "crosslink-url" false -}}

    {{- range $page := .Page.Site.AllPages -}}
        {{- if (and (not ($scratch.Get "crosslink-url")) (eq $page.Title $title)) -}}
            {{- $scratch.Set "crosslink-url" $page.Permalink -}}
        {{- end -}}
    {{- end -}}

    {{- if $scratch.Get "crosslink-url" -}}
        <a href="{{ $scratch.Get "crosslink-url" }}">{{ $text }}</a>
    {{- else -}}
        {{ errorf "Markdown crosslink error in %s, for title: %s, title: %s" .Page.File.Path $title $text }}
    {{- end -}}

{{- else -}}
    <a href="{{ .Destination }}"{{ if strings.HasPrefix .Destination "http" }} target="_blank" rel="noopener"{{ end }}>{{ .Text }}</a>
{{- end -}}

It’s pretty similar to the original crosslink code, although I did drop support for partial tags. You need an exact match (and it still doesn’t deal with multiple matches, if there are two pages with the same title, you’ll get the ‘first’ one, whatever that means).

Two gotchas I did have to solve though:

  • render-link.html doesn’t have it’s own scratch space and can’t access the one from the page it’s on; but they’ve added newScratch since I last looked which solves that issue
  • both .Text and .PlainText have HTML escaping and fancy quotes handled; so ' becomes becomes &rsquo;, while the original page title (that I’m looking up by) still has '. Ergo $fixedText.

Allowing custom text

Next problem, what if I don’t want the page title to actually appear to the user. In modified wiki-style links, I want |:

<!-- render-link.html -->
[[Page title goes here|but text goes here]]()

If the $title and $text variables both being assigned to $fixedText looked funny… well, it’s because I’m working backwards a bit. Instead of that, I have a nice split and index (with a guard to make sure there’s only one |):

<!-- render-link.html -->

...

{{- if gt (strings.Count .PlainText "|") 1 -}}
{{ errorf "Markdown crosslink error in %s, multiple | in %s" .Page.File.Path .PlainText }}
{{- end -}}

...

{{- $parts := split $fixedText "|"  -}}
{{- $title := index $parts 0 -}}
{{- $text := index $parts (sub (len $parts) 1) -}}

...

The rest just works the same way. And because I’m indexing at 0 and length - 1 (instead of 0 and 1), it ‘just works’ no matter if there is a | or not.

The last thing that I wanted to support (since I was working in this code anyways) is the ability to make other kinds of parameterized links in the same style. Specifically:

<!-- render-link.html -->
[[wiki:The Example|apparently a 1637 comedy written by James Shirley]]()

To replace:

{{< wikipedia title="The Example" text="apparently a 1637 comedy written by James Shirley" >}}

And it’s actually just a few more lines:

<!-- render-link.html -->

...

{{- if strings.HasPrefix $title "wiki:" -}}
    {{- $title = substr $title 5 -}}
    {{- if strings.HasPrefix $text "wiki:" -}}{{- $text = substr $text 5 -}}{{- end -}}
    {{- $scratch.Set "crosslink-url" (printf "https://en.wikipedia.org/wiki/%s" $title) -}}
{{- end -}}

...

Having the remove wiki: from both $title and $text is a side effect of defaulting $text to $title if not provided, but other than that, pretty straight forward. And I’m already checking if crosslink-url is set, so nothing else needs to change.

And… that’s it. You can see the full source here: render-link.html.

I’ll probably move over a few more shortcodes (doc links in particular), but other than that… good to go. It’s just easier to write the double square brackets (IMO) and in theory, more portable. We shall see.

Onward!