Automatically generate cover for blog post

DezeStijn5 mins read

Intro

A cover image is a nice way to draw attention to your blog post when sharing a link via social media. However, coming up with and designing a catching cover image can be difficult. It can also distract from the actual content creation.

That’s why I was looking for – and found – a way to automatically generate cover images for blog posts.

These covers contain some basic information for the blog post and displays it in a nice and concise way. Data included in the cover image are the title, author, last update or publish date, tags, and blog post series (if applicable) or category.

Cover image for this blog post
This blog post’s cover image

Generating the cover

To generate the cover I’m using tcardgen, which I forked to add some custom features.

This tool, written in Go, will generate a cover image with the background, font, and colours of your choosing.

Give the following directory structure

assets/
├── img/
|   └── card-template.png
├── font/
|   ├── KintoSans-Bold.ttf
|   ├── KintoSans-Medium.ttf
|   └── KintoSans-Regular.ttf
content/
├── blog/
|   └── blog-post
|   |   └── index.md

you would run the following command to generate a cover image using the default settings.

tcardgen -f assets/font \
         -t assets/img/card-template.png \
         -o content/blog/blog-post/cover.png \
         content/blog/blog-post/index.md

To override the default settings, you can use a config file, like this one:

# tcardgen.config.yaml
template: assets/img/card-template.png
title:
  start:
    px: 123
    py: 165
  fgHexColor: "#8B26AF" # Default: "#000000"
  fontSize: 68
  fontStyle: Bold
  maxWidth: 946
  lineSpacing: 10
category:
  enabled: false        # Default: true
  start:
    px: 126
    py: 110
  fgHexColor: "#8D8D8D"
  fontSize: 42
  fontStyle: Regular
info:
  enabled: true
  start:
    px: 240
    py: 440
  fgHexColor: "#E58003" # Default: "#8D8D8D"
  fontSize: 32
  fontStyle: Italic
  separator: " ∙ "
  timeFormat: "Jan 2 '06"
tags:
  enabled: true
  start:
    px: 1025
    py: 500   # 451
  fgHexColor: "#FFFFFF"
  bgHexColor: "#E58003" # Default: "#7F7776"
  fontSize: 18
  fontStyle: BoldItalic
  boxAlign: Right
  boxSpacing: 6
  boxPadding:
    top: 6
    right: 10
    bottom: 6
    left: 10
series:                 # Added in my custom fork
  enabled: true         # Default: false
  start:
    px: 126
    py: 110
  fgHexColor: "#8D8D8D"
  fontSize: 42
  fontStyle: Regular

In the config file you can change the colours (text and background) and font style of the different elements on the card. You can also move the location of fields or completely disable them.

In my fork I added the option to disable fields, which got upstreamed, and also added the series taxonomy which I used in my blog.

Use the -c parameter to pass a config file:

tcardgen -c tcardgen.config.yaml \
         -f assets/font \
         -o content/blog/blog-post/cover.png \
         content/blog/blog-post/index.md

Hugo config

By default, Hugo’s built-in templates will use the *cover* located with the blog-post as featured image, unless this is overridden in the frontmatter.

If you name the cover image differently (not feature*, *cover* or *thumbnail*) Hugo will look in the frontmatters .Params.images or your site’s site.Params.images configuration for an image. For example, you could name the generated cover image after the name of your blog post and then add the following section to the fronmatter:

---
# ...
params:
  images:
    - blog-post.png
---

Automated cover generation

Now we can manually create a cover for our blogpost by running the tcardgen command and passing the correct parameters. But it would even be better if we could automate this part.

For this, I set up the following GitHub workflow:

# .github/workflows/create-cover.yml
---
name: Create cover
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

on:
  push:
    branches:
      - main
      - staging
  pull_request:
    branches:
      - main
      - staging
  # Allow manual trigger
  workflow_dispatch: {}

jobs:
  changed-files:
    name: Check for new posts
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.changed-files.outputs.added_files }}
      added_files_count: ${{ steps.changed-files.outputs.added_files_count }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Get new posts
        id: changed-files
        uses: ./.github/actions/changes
        with:
          files: |
            ./content/**.md            
          matrix: true
  
  matrix-create:
    name: Create covers
    runs-on: ubuntu-latest
    needs: [changed-files]
    if: needs.changed-files.outputs.added_files_count > 0
    strategy:
      matrix: 
        files: ${{ fromJSON(needs.changed-files.outputs.matrix) }}
      max-parallel: 4
      fail-fast: false
    continue-on-error: true
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version-file: './go.mod'       # this will use go.mod to install the correct package(s)
      
      - name: Create cover
        run: |
          tcardgen -c tcardgen.config.yaml -f assets/fonts -o $(dirname "${{ matrix.files }}")/cover.png ${{ matrix.files }}          
# .github/actions/changes/action.yml
name: Changes
description: Check changed files
inputs:
  files:
    description: Files or folders to monitor
    required: false
    default: null
  files_ignore:
    description: Files or folders to ignore
    required: false
    default: ""
  matrix:
    description: Output changed files in a matrix-ready format
    required: false
    default: false
outputs:
  any_changed:
    description: Returns true when any of the filenames provided using the files or files_ignore inputs have changed. This defaults to true when no patterns are specified. i.e. includes a combination of all added, copied, modified and renamed files
    value: ${{ steps.changed-files.outputs.any_changed }}
  all_changed_files:
    description: Returns all changed files i.e. a combination of all added, copied, modified and renamed files
    value: ${{ steps.changed-files.outputs.all_changed_files }}
  added_files:
    description: Returns only files that are added
    value: ${{ steps.changed-files.outputs.added_files }}
  added_files_count:
    description: Returns number of added files
    value: ${{ steps.changed-files.outputs.added_files_count }}
runs:
  using: "composite"
  steps:
    - name: Get changed filed
      id: changed-files
      uses: tj-actions/changed-files@v45
      with:
        files: ${{ inputs.files }}
        files_ignore: ${{ inputs.files_ignore }}
        matrix: ${{ inputs.matrix }}

    - name: List all changed files
      shell: bash
      env:
        ANY_CHANGED: ${{ steps.changed-files.outputs.any_changed }}
        ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
        COUNT_NEW: ${{ steps.changed-files.outputs.added_files_count }}
      run: |
        echo "Files changed (${ANY_CHANGED} - new: ${COUNT_NEW}):"
        for file in ${ALL_CHANGED_FILES}; do
          echo "- $file"
        done        
# go.mod
module github.com/TheGroundZero/tcardgen

go 1.23

This workflow will check at every push or PR whether a new blog post was added and – if so – will create a cover image for it.

And we’re done 🚀

Now every blog post will always have a cover image attached. And if I want to use something custom, I can still use the post’s frontmatter to pick it instead of the generator cover.