Why do this?#

My theme cannot derive image formatting or positioning from standard Markdown syntax like ![Image Alt Text](/path/to/image/). However, I need this capability to place images aligned left, center, or right, with or without text wrap. So, I have to take matters into my own hands and code a solution myself.

Getting started with shortcodes, layouts, and CSS#

I get an overview at cshire and quickly realize that my initial attempt using /layouts/render-image.html isn’t working.

Debugging#

This method lets me determine whether render-image.html is actually being read and applied—independent of any CSS:

<div style="border: 3px dashed red; padding: 10px; margin: 10px; font-weight: bold;">
RENDER-IMAGE.HTML IS BEING READ!
</div>

That works. So, Hugo must be missing something during the Markdown processing stage.

Troubleshooting#

I can see now that Hugo is rendering the layout but isn’t processing my images correctly. Why? Because a trigger, called shortcode, is missing. In Hugo, shortcodes are created in Markdown using {{ <shortcode-name> parameter1="String" parametern="string" }}, processed via the corresponding template file /layouts/shortcodes/<shortcode-name>.html, and finally displayed based on CSS styling.

Shortcode: image.html#

So, I create a shortcode template that allows me to pass a few extra parameters to the image beyond just the source file (src) and alt text (alt): position, caption, attrid, and attrlink. The image position and caption are self-explanatory. I wanted a way to include attribution for images I didn’t create myself. For these, I either access open content governed by terms such as Creative Commons or obtain prior written consent from the copyright holder.

{{- /* layouts/_shortcodes/image.html */ -}}

{{- $src := .Get "src" -}}
{{- $alt := .Get "alt" | default "" -}}
{{- $position := .Get "position" | default "center" -}}
{{- $caption := .Get "caption" -}}
{{- $attrid := .Get "attrid" -}}
{{- $attrlink := .Get "attrlink" -}}

<figure class="media-frame media-frame--{{ $position }}">
  <img src="{{ $src }}" alt="{{ $alt }}">

  {{- if $caption -}}
    <figcaption class="media-caption">
      <span class="caption-text">{{ $caption }}</span>
      {{- if and $attrid $attrlink -}}
        <a
          href="{{ $attrlink }}"
          class="attr-link"
          aria-label="Attribution {{ $attrid }}"
        >
          <sup class="attr-id">[{{ $attrid }}]</sup>
        </a>
      {{- end -}}
    </figcaption>
  {{- end -}}
</figure>

This template reads the parameters provided via the shortcode and wraps the <img> tag in a <figure class="media-frame"> container, which holds the positioning information. If an image caption and/or attribution is provided, it is displayed within a <figcaption class="media-caption"> element.

Using the shortcode#

In Markdown, a fully configured image would look like this:

{{< image src="/path/to/image.jpg"
alt="Example placeholder image for shortcode demonstration purposes"
caption="Shortcode demo image"
attrid="1" attrlink="https://example.com/"
position="right" >}}

Migration#

But how do I convert the ![alt](/path/to/image) {: .align-center} syntax I previously used in Jekyll into the format shown above?

With a script! To do this, I’m creating a Python program that scans through all my articles, looking for images using the aforementioned formatting. When it finds a match, it extracts the src, alt, and position attributes and replaces the entire expression with the equivalent Hugo shortcode.

import os
import re
from pathlib import Path

# spaces/tabs only, no newlines
SPACES = r'[ \t]*'

IMG_WITH_OPTIONAL_ALIGN = re.compile(
    r'!\[([^\]]*?)\]\(([^)]*?)\)'      # alt, src
    + SPACES +                         # optional spaces/tabs before decorator
    r'(?:\{:' + SPACES +               # literal "{:"
    r'[^}\n]*?\.align-(center|left|right)\b[^}\n]*?'  # decorator body (no newlines)
    r'\})?'                           # optional "}"
)

def convert_image_alignments(directory):
    def repl(m):
        alt = m.group(1).replace('"', "'")
        src = m.group(2)
        position = m.group(3) or ''
        # return built Hugo shortcode. Escape Python f-string curly brace { with {{.
        return f'{{{{< image src="{src}" alt="{alt}" position="{position}" >}}}}'

    for root, _, files in os.walk(directory):
        for file in files:
            if not file.endswith('.md'):
                continue

            filepath = Path(root) / file
            content = filepath.read_text(encoding='utf-8')

            new_content = IMG_WITH_OPTIONAL_ALIGN.sub(repl, content)
            if new_content != content:
                filepath.write_text(new_content, encoding='utf-8')
                print(f"Updated: {filepath}")

if __name__ == "__main__":
    project_root = input("Enter the path to your project root: ").strip()
    if os.path.isdir(project_root):
        convert_image_alignments(project_root)
        print("Conversion complete!")
    else:
        print("Invalid directory path.")

I enlisted the help of the large-language model Mistral for the regexes, but otherwise, the script is kept simple.

In Jekyll, images with titles and/or attribution were declared as figure elements. I am writing a very similar script for these as well and running it during the migration process.

CSS#

Let’s move on to image display. I am creating a dedicated CSS file, /assets/css/media.css, designed to standardize the size and formatting of images as well as other embedded media such as audio and video files for both mobile and desktop views. To achieve this, it utilizes the class attributes defined in the template.

Here is an excerpt from the file to illustrate: this section defines the behavior of the media-frame object. Each asset (image, video, or audio) is embedded within a frame that provides the necessary breathing room for the display. The position attribute determines the placement and presentation style.

By default, the content is centered and displayed at its native resolution, provided it does not exceed the maximum text width.

.media-frame img,
.media-frame audio,
.media-frame video {
  display: block;
  width: auto;
  max-width: 100%;
  height: auto;

  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
}

.media-frame--center {
  margin-left: auto;
  margin-right: auto;
}

/* Left/Right align only for Non-mobile use */
@media (min-width: 768px) {
  .media-frame--left {
    float: left;
    max-width: 60%;
    margin-top: 0.35em;
    margin-right: 1rem;
    margin-bottom: 0.75rem;
  }

  .media-frame--right {
    float: right;
    max-width: 60%;
    margin-top: 0.35em;
    margin-left: 1rem;
    margin-bottom: 0.75rem;
  }
}

Images aligned to one side have their width restricted to allow text to flow around them. Additionally, minimum margins relative to other objects are defined to prevent frames from touching when media elements appear in succession. In the mobile view, space is so limited that images are always displayed centered.

To display multiple images in a single location, I create a gallery shortcode along with the corresponding CSS. The gallery displays images in a grid. Hovering the mouse over an image makes it appear brighter than the others. Clicking an image displays a large version of it at the bottom of the gallery, reusing the media shortcode described earlier.

The images to be displayed are defined as a list—specifying src and alt attributes—within the Markdown document’s front matter. Here is an excerpt from the template used to process the shortcode.

<section class="hugo-gallery">
  <div class="hugo-gallery__frame">
    <div class="hugo-gallery__grid" role="list">
      {{- range $i, $img := $items -}}
        {{- $src := $img.src -}}
        {{- $alt := $img.alt | default (printf "Gallery image %d" (add $i 1)) -}}

        {{- $thumbURL := $src -}}
        {{- $p := strings.TrimPrefix $src "/assets/" -}}

        {{- with resources.Get $p -}}
          {{- $thumb := .Fit "400x220" -}}
          {{- $thumbURL = $thumb.RelPermalink -}}
        {{- end -}}

        <a
          class="hugo-gallery__thumb"
          href="#gallery-full-{{ $i }}"
          aria-label="{{ $alt }}">
          <img
            src="{{ $thumbURL }}"
            alt="{{ $alt }}"
            class="hugo-gallery__thumb-img"
            loading="lazy">
        </a>
      {{- end -}}
    </div>

I iterate through all the images defined in the front matter, limit their size to a manageable 400x220px, and arrange them in a grid using the class hugo-gallery__grid.

Tests#

Creating the templates was relatively quick. However, I’m constantly at odds with CSS. Some override from another file keeps getting in the way, or I end up conflicting with the styles in the base CSS. That’s why I need a place to test formatting and layout.

So, I created an image test page for myself. Curious? Check how media is displayed in Hugo