Link and image render hooks

Use Hugo's render hooks to accurately resolve Markdown link and image destinations. This approach solves one of the most common problems encountered by site and content authors.

Overview

Hugo converts Markdown to HTML using the Goldmark Markdown renderer. Goldmark is fast, accurate, and well-maintained. It adheres to the CommonMark specification, and consistently follows a well-defined set of rules.

A Markdown link has three components: link text, a link destination, and optionally a link title.

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

A Markdown image also has three components: an image description, the image destination, and optionally an image title.

![white kitten](/images/kitten.jpg "A kitten!")
  ------------  ------------------  ---------
  description      destination        title

Goldmark renders these to:

<a href="/posts/post-1" title="My first post">Post 1</a>
<img src="/images/kitten.jpg" alt="white kitten" title="A kitten!">

Goldmark renders link and image destinations the same way, every time, regardless of context.

The problem

External link and image destinations are absolute, with no ambiguity:

https://example.org/posts/post-1/
https://example.org/images/kitten.jpg

Internal link and image destinations are not absolute:

/posts/post-1
posts/post-1
post-1

/images/kitten.jpg
images/kitten.jpg
kitten.jpg

These are all relative to something. And if that something changes, links and images in your Markdown may break, resulting in 404 errors.

What can change?

Server subdirectory

Look at these examples again:

[Post 1](/posts/post-1)
![white kitten](/images/kitten.jpg)

These destinations, beginning with a slash, are relative to the root of the web server. With a baseURL of https://example.org/ the destinations accurately resolve to:

https://example.org/posts/post-1
https://example.org/images/kitten.jpg

If you change the baseURL in your site configuration to https://example.org/docs/ the destinations will still resolve to:

https://example.org/posts/post-1
https://example.org/images/kitten.jpg

Both of these are now broken. You could fix this by changing the destinations to include the subdirectory:

[Post 1](/docs/posts/post-1 "My first post")
![white kitten](/docs/images/kitten.jpg "A kitten!")

But if you change the baseURL in the future, you will have to edit every link and image reference in your Markdown.

For links, you could also use the relref shortcode:

[Post 1]({{< relref "/posts/post-1" >}} "My first post")

But the syntax is ugly, the Markdown is not portable to other environments, and the relref shortcode will not resolve image destinations.

Alternate context

Let’s say the destinations of your Markdown links and images are relative to the current page:

[Post 1](post-1 "My first post")
![white kitten](kitten.jpg "A kitten!")

Goldmark renders this to:

<a href="post-1" title="My first post">Post 1</a>
<img alt="white kitten" src="kitten.jpg" title="A kitten!">

And everything works as it should. But what if you wish to display page content when rendering a list page?

{{ range site.RegularPages }}
  <h2>{{ .Title }}</h2>
  <div>{{ .Content }}</div>
{{ end }}

When viewing the list page, the image and link destinations will be broken. The destinations are relative to the content page, not the list page. You will encounter the same problem when displaying summaries if they contain links and images with destinations relative to the content page.

Configuration

You can also break link and image destinations by:

  • Setting permalinks in your site configuration
  • Enabling disablePathToLower in your site configuration
  • Enabling uglyURLs in your site configuration
  • Setting the slug in front matter
  • Setting the url in front matter

Each of these settings may alter the target destination for both links and images, resulting in broken links and images.

The solution

Use link and image render hooks to resolve destinations.

Do not try to solve the problem by enabling canonifyURLs or relativeURLs in your site configuration. Neither of these options does what you might think or hope.

Do not try to solve the problem by placing a base element in your template. This may fix some problems while creating others.

When rendering Markdown to HTML, render hooks override the conversion. Each render hook is a template, with one template for each element type: blockquote, code block, heading, image, or link.

layouts/
└── _default/
    └── _markup/
        ├── render-blockquote.html
        ├── render-codeblock.html
        ├── render-heading.html
        ├── render-image.html
        └── render-link.html
layouts/_default/_markup/render-link.html
{{- /* Last modified: 2024-08-09T14:24:24-07:00 */}}

{{- /*
Copyright 2023 Veriphor, LLC

Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}

{{- /*
This render hook resolves internal destinations by looking for a matching:

  1. Content page
  2. Page resource (a file in the current page bundle)
  3. Section resource (a file in the current section)
  4. Global resource (a file in the assets directory)

It skips the section resource lookup if the current page is a leaf bundle.

External destinations are not modified.

You must place global resources in the assets directory. If you have placed
your resources in the static directory, and you are unable or unwilling to move
them, you must mount the static directory to the assets directory by including
both of these entries in your site configuration:

  [[module.mounts]]
  source = 'assets'
  target = 'assets'

  [[module.mounts]]
  source = 'static'
  target = 'assets'

By default, if this render hook is unable to resolve a destination, including a
fragment if present, it passes the destination through without modification. To
emit a warning or error, set the error level in your site configuration:

  [params.render_hooks.link]
  errorLevel = 'warning' # ignore (default), warning, or error (fails the build)

When you set the error level to warning, and you are in a development
environment, you can visually highlight broken internal links:

  [params.render_hooks.link]
  errorLevel = 'warning' # ignore (default), warning, or error (fails the build)
  highlightBroken = true # true or false (default)

This will add a "broken" class to anchor elements with invalid src attributes.
Add a rule to your CSS targeting the broken links:

  a.broken {
    background: #ff0;
    border: 2px solid #f00;
    padding: 0.1em 0.2em;
  }

This render hook may be unable to resolve destinations created with the ref and
relref shortcodes. Unless you set the error level to ignore you should not use
either of these shortcodes in conjunction with this render hook.

@context {string} Destination The link destination.
@context {page} Page A reference to the page containing the link.
@context {string} PlainText The link description as plain text.
@context {string} Text The link description.
@context {string} Title The link title.

@returns {template.html}
*/}}

{{- /* Initialize. */}}
{{- $renderHookName := "link" }}

{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.125.6" }}
{{- if lt hugo.Version $minHugoVersion }}
  {{- errorf "The %q render hook requires Hugo v%s or later." $renderHookName $minHugoVersion }}
{{- end }}

{{- /* Error level when unable to resolve destination: ignore, warning, or error. */}}
{{- $errorLevel := or site.Params.render_hooks.link.errorLevel "ignore" | lower }}

{{- /* If true, adds "broken" class to broken links. Applicable in development environment when errorLevel is warning. */}}
{{- $highlightBrokenLinks := or site.Params.render_hooks.link.highlightBroken false }}

{{- /* Validate error level. */}}
{{- if not (in (slice "ignore" "warning" "error") $errorLevel) }}
  {{- errorf "The %q render hook is misconfigured. The errorLevel %q is invalid. Please check your site configuration." $renderHookName $errorLevel }}
{{- end }}

{{- /* Determine content path for warning and error messages. */}}
{{- $contentPath := "" }}
{{- with .Page.File }}
  {{- $contentPath = .Path }}
{{- else }}
  {{- $contentPath = .Path }}
{{- end }}

{{- /* Parse destination. */}}
{{- $u := urls.Parse .Destination }}

{{- /* Set common message. */}}
{{- $msg := printf "The %q render hook was unable to resolve the destination %q in %s" $renderHookName $u.String $contentPath }}

{{- /* Set attributes for anchor element. */}}
{{- $attrs := dict "href" $u.String }}
{{- if $u.IsAbs }}
  {{- /* Destination is a remote resource. */}}
  {{- $attrs = merge $attrs (dict "rel" "external") }}
{{- else }}
  {{- with $u.Path }}
    {{- with $p := or ($.PageInner.GetPage .) ($.PageInner.GetPage (strings.TrimRight "/" .)) }}
      {{- /* Destination is a page. */}}
      {{- $href := .RelPermalink }}
      {{- with $u.RawQuery }}
        {{- $href = printf "%s?%s" $href . }}
      {{- end }}
      {{- with $u.Fragment }}
        {{- $ctx := dict
          "contentPath" $contentPath
          "errorLevel" $errorLevel
          "page" $p
          "parsedURL" $u
          "renderHookName" $renderHookName
        }}
        {{- partial "inline/h-rh-l/validate-fragment.html" $ctx }}
        {{- $href = printf "%s#%s" $href . }}
      {{- end }}
      {{- $attrs = dict "href" $href }}
    {{- else }}
      {{- with $.PageInner.Resources.Get $u.Path }}
        {{- /* Destination is a page resource; drop query and fragment. */}}
        {{- $attrs = dict "href" .RelPermalink }}
      {{- else }}
        {{- with (and (ne $.Page.BundleType "leaf") ($.Page.CurrentSection.Resources.Get $u.Path)) }}
          {{- /* Destination is a section resource, and current page is not a leaf bundle. */}}
          {{- $attrs = dict "href" .RelPermalink }}
        {{- else }}
          {{- with resources.Get $u.Path }}
            {{- /* Destination is a global resource; drop query and fragment. */}}
            {{- $attrs = dict "href" .RelPermalink }}
          {{- else }}
            {{- if eq $errorLevel "warning" }}
              {{- warnf $msg }}
              {{- if and $highlightBrokenLinks hugo.IsDevelopment }}
                {{- $attrs = merge $attrs (dict "class" "broken") }}
              {{- end }}
            {{- else if eq $errorLevel "error" }}
              {{- errorf $msg }}
            {{- end }}
          {{- end }}
        {{- end }}
      {{- end }}
    {{- end }}
  {{- else }}
    {{- with $u.Fragment }}
      {{- /* Destination is on the same page; prepend relative permalink. */}}
      {{- $ctx := dict
        "contentPath" $contentPath
        "errorLevel" $errorLevel
        "page" $.Page
        "parsedURL" $u
        "renderHookName" $renderHookName
      }}
      {{- partial "inline/h-rh-l/validate-fragment.html" $ctx }}
      {{- $attrs = dict "href" (printf "%s#%s" $.Page.RelPermalink .) }}
    {{- else }}
      {{- if eq $errorLevel "warning" }}
        {{- warnf $msg }}
        {{- if and $highlightBrokenLinks hugo.IsDevelopment }}
          {{- $attrs = merge $attrs (dict "class" "broken") }}
        {{- end }}
      {{- else if eq $errorLevel "error" }}
        {{- errorf $msg }}
      {{- end }}
    {{- end }}
  {{- end }}
{{- end }}
{{- $attrs = merge $attrs (dict "title" (.Title | transform.HTMLEscape)) }}

{{- /* Render anchor element. */ -}}
<a
  {{- range $k, $v := $attrs }}
    {{- if $v }}
      {{- printf " %s=%q" $k $v | safeHTMLAttr }}
    {{- end }}
  {{- end -}}
>{{ .Text | safeHTML }}</a>

{{- define "partials/inline/h-rh-l/validate-fragment.html" }}
  {{- /*
  Validates the fragment portion of a link destination.

  @context {string} contentPath The page containing the link.
  @context {string} errorLevel The error level when unable to resolve destination; ignore (default), warning, or error.
  @context {page} page The page corresponding to the link destination
  @context {struct} parsedURL The link destination parsed by urls.Parse.
  @context {string} renderHookName The name of the render hook.
  */}}

  {{- /* Initialize. */}}
  {{- $contentPath := .contentPath }}
  {{- $errorLevel := .errorLevel }}
  {{- $p := .page }}
  {{- $u := .parsedURL }}
  {{- $renderHookName := .renderHookName }}

  {{- /* Validate. */}}
  {{- with $u.Fragment }}
    {{- if $p.Fragments.Identifiers.Contains . }}
      {{- if gt ($p.Fragments.Identifiers.Count .) 1 }}
        {{- $msg := printf "The %q render hook detected duplicate heading IDs %q in %s" $renderHookName . $contentPath }}
        {{- if eq $errorLevel "warning" }}
          {{- warnf $msg }}
        {{- else if eq $errorLevel "error" }}
          {{- errorf $msg }}
        {{- end }}
      {{- end }}
    {{- else }}
      {{- /* Determine target path for warning and error message. */}}
      {{- $targetPath := "" }}
      {{- with $p.File }}
        {{- $targetPath = .Path }}
      {{- else }}
        {{- $targetPath = .Path }}
      {{- end }}
      {{- /* Set common message. */}}
      {{- $msg := printf "The %q render hook was unable to find heading ID %q in %s. See %s" $renderHookName . $targetPath $contentPath }}
      {{- if eq $targetPath $contentPath }}
        {{- $msg = printf "The %q render hook was unable to find heading ID %q in %s" $renderHookName . $targetPath }}
      {{- end }}
      {{- /* Throw warning or error. */}}
      {{- if eq $errorLevel "warning" }}
        {{- warnf $msg }}
      {{- else if eq $errorLevel "error" }}
        {{- errorf $msg }}
      {{- end }}
    {{- end }}
  {{- end }}

{{- end -}}

This render hook resolves internal destinations by looking for a matching:

  1. Content page
  2. Page resource (a file in the current page bundle)
  3. Section resource (a file in the current section)
  4. Global resource (a file in the assets directory)

It skips the section resource lookup if the current page is a leaf bundle.

External destinations are not modified.

You must place global resources in the assets directory. If you have placed your resources in the static directory, and you are unable or unwilling to move them, you must mount the static directory to the assets directory by including both of these entries in your site configuration:

[[module.mounts]]
source = 'assets'
target = 'assets'

[[module.mounts]]
source = 'static'
target = 'assets'

By default, if this render hook is unable to resolve a destination, including a fragment1 if present, it passes the destination through without modification. To emit a warning or error, set the error level in your site configuration:

[params.render_hooks.link]
errorLevel = 'warning' # ignore (default), warning, or error (fails the build)

When you set the error level to warning, and you are in a development environment, you can visually highlight broken internal links:

[params.render_hooks.link]
errorLevel = 'warning' # ignore (default), warning, or error (fails the build)
highlightBroken = true # true or false (default)

This will add a “broken” class to anchor elements with invalid src attributes. Add a rule to your CSS targeting the broken links:

a.broken {
  background: #ff0;
  border: 2px solid #f00;
  padding: 0.1em 0.2em;
}

This render hook may be unable to resolve destinations created with the ref and relref shortcodes. Unless you set the error level to ignore you should not use either of these shortcodes in conjunction with this render hook.

Image render hook

layouts/_default/_markup/render-image.html
{{- /* Last modified: 2024-08-09T14:24:24-07:00 */}}

{{- /*
Copyright 2023 Veriphor, LLC

Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}

{{- /*
This render hook resolves internal destinations by looking for a matching:

  1. Page resource (an image in the current page bundle)
  2. Section resource (an image in the current section)
  3. Global resource (an image in the assets directory)

It skips the section resource lookup if the current page is a leaf bundle, and
captures external destinations as resources for local hosting.

You must place global resources in the assets directory. If you have placed
your resources in the static directory, and you are unable or unwilling to move
them, you must mount the static directory to the assets directory by including
both of these entries in your site configuration:

  [[module.mounts]]
  source = 'assets'
  target = 'assets'

  [[module.mounts]]
  source = 'static'
  target = 'assets'

By default, if this render hook is unable to resolve a destination, it passes
the destination through without modification. To emit a warning or error, set
the error level in your site configuration:

  [params.render_hooks.image]
  errorLevel = 'warning' # ignore (default), warning, or error (fails the build)

Image render hooks are also used to:

  - Resize, crop, rotate, filter, and convert images
  - Build responsive images using srcset and sizes attributes
  - Wrap images inside of a picture element
  - Transform standalone images into figure elements

To perform any of these operations, you can “hook” into this render hook with a
partial template, after the render hook has captured the resource.

@context {map} Attributes The Markdown attributes, available if (a) markup.goldmark.parser.attribute.block is true, and (b) markup.goldmark.parser.wrapStandAloneImageWithinParagraph is false in site configuration.
@context {string} Destination The image destination.
@context {bool} IsBlock Returns true if a standalone image is not wrapped within a paragraph element.
@context {int} Ordinal The zero-based ordinal of the image on the page.
@context {page} Page A reference to the page containing the image.
@context {string} PlainText The image description as plain text.
@context {string} Text The image description.
@context {string} Title The image title.

@returns {template.html}
*/}}

{{- /* Initialize. */}}
{{- $renderHookName := "image" }}

{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.125.6" }}
{{- if lt hugo.Version $minHugoVersion }}
  {{- errorf "The %q render hook requires Hugo v%s or later." $renderHookName $minHugoVersion }}
{{- end }}

{{- /* Error level when unable to resolve destination: ignore, warning, or error. */}}
{{- $errorLevel := or site.Params.render_hooks.image.errorLevel "ignore" | lower }}

{{- /* Validate error level. */}}
{{- if not (in (slice "ignore" "warning" "error") $errorLevel) }}
  {{- errorf "The %q render hook is misconfigured. The errorLevel %q is invalid. Please check your site configuration." $renderHookName $errorLevel }}
{{- end }}

{{- /* Determine content path for warning and error messages. */}}
{{- $contentPath := "" }}
{{- with .Page.File }}
  {{- $contentPath = .Path }}
{{- else }}
  {{- $contentPath = .Path }}
{{- end }}

{{- /* Parse destination. */}}
{{- $u := urls.Parse .Destination }}

{{- /* Set common message. */}}
{{- $msg := printf "The %q render hook was unable to resolve the destination %q in %s" $renderHookName $u.String $contentPath }}

{{- /* Get image resource. */}}
{{- $r := "" }}
{{- if $u.IsAbs }}
  {{- with resources.GetRemote $u.String }}
    {{- with .Err }}
      {{- if eq $errorLevel "warning" }}
        {{- warnf "%s. See %s" . $contentPath }}
      {{- else if eq $errorLevel "error" }}
        {{- errorf "%s. See %s" . $contentPath }}
      {{- end }}
    {{- else }}
      {{- /* Destination is a remote resource. */}}
      {{- $r = . }}
    {{- end }}
  {{- else }}
    {{- if eq $errorLevel "warning" }}
      {{- warnf $msg }}
    {{- else if eq $errorLevel "error" }}
      {{- errorf $msg }}
    {{- end }}
  {{- end }}
{{- else }}
  {{- with .PageInner.Resources.Get (strings.TrimPrefix "./" $u.Path) }}
    {{- /* Destination is a page resource. */}}
    {{- $r = . }}
  {{- else }}
    {{- with (and (ne .Page.BundleType "leaf") (.Page.CurrentSection.Resources.Get (strings.TrimPrefix "./" $u.Path))) }}
      {{- /* Destination is a section resource, and current page is not a leaf bundle. */}}
      {{- $r = . }}
    {{- else }}
      {{- with resources.Get $u.Path }}
        {{- /* Destination is a global resource. */}}
        {{- $r = . }}
      {{- else }}
        {{- if eq $errorLevel "warning" }}
          {{- warnf $msg }}
        {{- else if eq $errorLevel "error" }}
          {{- errorf $msg }}
        {{- end }}
      {{- end }}
    {{- end }}
  {{- end }}
{{- end }}

{{- /* Determine id attribute. */}}
{{- $id := printf "h-rh-i-%d" .Ordinal }}
{{- with .Attributes.id }}
  {{- $id = . }}
{{- end }}

{{- /* Initialize attributes. */}}
{{- $attrs := merge .Attributes (dict "id" $id "alt" .Text "title" (.Title | transform.HTMLEscape) "src" $u.String) }}

{{- /* Merge attributes from resource. */}}
{{- with $r }}
  {{- $attrs = merge $attrs (dict "src" .RelPermalink) }}
  {{- if not (eq .MediaType.SubType "svg") }}
    {{- $attrs = merge $attrs (dict "height" (string .Height) "width" (string .Width)) }}
  {{- end }}
{{- end }}

{{- /* Render image element. */ -}}
<img
  {{- range $k, $v := $attrs }}
    {{- if or $v (eq $k "alt") }}
      {{- printf " %s=%q" $k $v | safeHTMLAttr }}
    {{- end }}
  {{- end -}}
>
{{- /**/ -}}

This render hook resolves internal destinations by looking for a matching:

  1. Page resource (an image in the current page bundle)
  2. Section resource (an image in the current section)
  3. Global resource (an image in the assets directory)

It skips the section resource lookup if the current page is a leaf bundle, and captures external destinations as resources for local hosting.

You must place global resources in the assets directory. If you have placed your resources in the static directory, and you are unable or unwilling to move them, you must mount the static directory to the assets directory by including both of these entries in your site configuration:

[[module.mounts]]
source = 'assets'
target = 'assets'

[[module.mounts]]
source = 'static'
target = 'assets'

By default, if this render hook is unable to resolve a destination, it passes the destination through without modification. To emit a warning or error, set the error level in your site configuration:

[params.render_hooks.image]
errorLevel = 'warning' # ignore (default), warning, or error (fails the build)

Image render hooks are also used to:

  • Resize, crop, rotate, filter, and convert images
  • Build responsive images using srcset and sizes attributes
  • Wrap images inside of a picture element
  • Transform stand-alone images into figure elements

To perform any of these operations, you can “hook” into this render hook with a partial template, after the render hook has captured the resource.


  1. Hugo introduced .Page.Fragments in v0.111.0↩︎

Last modified: