RSS Amplifier

BurgeonLab: Full-text · Nov 25, 2025

How to Automate OG Images with D2 and Bash Script

0
Sign in to vote or save

Naty S · BurgeonLab

Read how I moved from third‑party free tools (like OG Image Maker) to D2, a text‑to‑diagram language, for local, customizable OG image generation. Combining D2 with a Bash script helped automate the process and produce ready‑to‑use OG images. The workflow uses CLI image tools (pngquant, cwebp, ImageMagick) and I talk about the benefits of text‑based templates, offline generation, and automation.

Previous OG Image Workflow

After learning about Open Graph images and setting it up correctly on Hugo earlier this year; I’ve manually designed a “default” OG image with GIMP.

Burgeonlab's main OG image

Main OG image for burgeonlab.com

This is only used for the home page or pages without a specific OG:image. For all other content pages, I’ve been using free online OG image makers and generators—my favourite being OG Image Maker by Eddy, like so:

Screenshot of a grid of OG images used in previous posts on burgeonlab.com.

As you can see, it is not that consistent; other than font family and logo

Limitations of Generating Open Graph Images Using Cloud Tools

But, to be honest, it doesn’t really cut it for me:

  1. No saved styles—hard to keep consistent
  2. Not enough customization options
  3. Third-party reliance
  4. Not a long-term solution

What I wanted was a way to generate it locally, using some sort of text-based workflow. I knew about scripting languages like Mermaid (text-to-diagram); but it was only recently when I discovered a modern (2022) diagram scripting language called D2 (Declarative Diagramming). Importantly, it supports generating diagrams as code, with rich text and even Markdown support.1

D2 is open source, Go-based, and supports an offline workflow which was important to me. It is handy they have an official plugin for VSCode (good for initial setup, with live preview) as that’s the IDE I use with my Hugo blog. I think this is a neat scripting language that offers much more than what I’m using it for (try out the D2 Playground demo).

Tip

I realized a bit too late, but Hugo actually has pre-built embedded Open Graph templates. Here are a few blogs by others about it if you want something simpler:

V1: Generate OG Images with Scripts

To use D2 reliably for generating dynamic OG images, I wrote a Bash script2 to integrate it into my Hugo workflow.

Note

I have not investigated adding screenshots to D2-generated OG images yet. I will keep my OG images text-based for now.

Overview

The process is semi-automated; I just have a one‑step prompt:

  1. Run the script in Terminal.
  2. Prompt asks me for the title text (with multi-line support).
  3. Script generates the image from text input, crops it to the right OG image dimensions at 1200 x 630px (D2 doesn’t support custom output dimensions), and compresses the PNG for the web.

While the generation of the image is automated (bar the manual input of the title text), there are still some steps required after:

  1. Move the image into the right page bundle
  2. Manually git add add and git commit the new image

Prerequisites

  • Install D2, see d2/docs/INSTALL.md. I used brew install d2 on macOS with Homebrew.
  • Install a plugin or extension for easy live previews during initial setup (you don’t need it afterwards).
  • Experiment with snippet examples in the playground and read the docs to learn the basic syntax
  • I use image processing CLI tools in the Bash script; so install them as well if you want to combine image resizing and compression.

D2 ‘Base’ Script

D2 files end in .d2. They’re like the “recipe” file for generating diagrams.

  • Choose a theme-id from D2’s theme catalogue
  • Fill options for background.
  • Add images, use an empty quote "" to skip the label. It supports local images or URLs. Tweak the width, height, near attributes for images (it’s not very flexible compared to graphic editors).
  • More font styles are available.
  • The size options are a bit finicky… they’re relative I think? For example, if I make the font size >30, it will make the images smaller, and vice versa.
  • Do some trial and error with --pad and --scale= in the compile command (next step) to get the right balance.
 1vars: {
 2    d2-config: {
 3        theme-id: 105 # 1=neutralGray
 4    }
 5}
 6style: {
 7  fill: "linear-gradient( #220B01, #7a2104)"
 8  fill-pattern: lines
 9}
10button: "" {
11  icon: "https://burgeonlab.com/images/burgeonlab_pill_button.png"
12  shape: image
13  width: 200
14  height: 20
15  style.opacity: 1
16  near: bottom-center
17}
18logo: "" {
19  icon: "~/burgeonlab/static/images/logo.png"
20  shape: image
21  height: 70
22  style.opacity: 1
23  near: top-center
24}
25postTitle: "Default Title" {
26  shape: text
27  style: {
28    font-color: "#f0f0f0"
29    font-size: 30
30    bold: false
31  }
32}

D2 Compile Command

  • To export a .d2 script, choose an output format; I will use PNG for OG images.
  • My compile command adds padding, scale, and custom fonts (D2 only supports .ttf) to match my site’s style.
  • If you aren’t using extra flags/settings, the basic command is: d2 your-d2-file.d2 output-file-name.output-format
  • Use the D2 CLI manual to learn about the relevant flags.
  • Note: This does not generate a PNG in the right OG image dimensions though—that’s done via the Bash script in the next step.
1d2 --font-bold="/path-to-ttf-font/KodeMono-Bold.ttf" \ 
2--font-semibold="/path-to-ttf-font/KodeMono-SemiBold.ttf" \ 
3--font-regular="/path-to-ttf-font/KodeMono-Medium.ttf" \
4--pad 80 --scale=2 \
5ogimg_gen.d2 output_ogimg.png

Example base OG image generated with D2 diagram scripting language

Here’s what we have so far

Automate Image Processing with Bash

D2 supports \n new line in text objects, so instead of:

1postTitle: "Default Title" {

I could enter a multi-line text block like:

1postTitle: "Create Dynamic Open Graph\nImages with Diagram as Code\n(D2 Lang + Bash script)" {

But problems arose when I combined the image processing workflow with D2 in Bash. The prompt’s multi-line input for the title would not escape properly to work with the D2 script. After some code debugging with guidance from Qwen 2.5 Coder 3B via Ollama, I managed to fix it!

Tip

After creating and saving the Bash script, remember to run chmod +x /path-to-your-script/d2-script.sh for permission to run!

Bash Script Code (V1): d2ogimg_prompt.sh

After replacing the postTitle with the title we want for the OG image, the script becomes more straight forward:

  1. Replace D2 postTitle from prompt input.
  2. Compile D2 with new postTitle.
  3. Resize image to 1200x630px with magick.
  4. Compress PNG with `pngquant.
  5. Calculate before and after size.
  6. Remove temporary files used in script.
  7. Display log info.

You can use my script below and change only what is mentioned in the commented section headings:

  1#!/usr/bin/env bash
  2set -euo pipefail
  3
  4# -------- SCRIPT CONFIG (change D2_FILE, OUTPUT_PNG) --------
  5D2_FILE="/path-to-d2/og_img_gen.d2"
  6OUTPUT_PNG="/path-to-output/og_img_0xx.png"
  7TARGET_W=1200
  8TARGET_H=630
  9TEMP_D2="temp-filled.d2"
 10TEMP_PNG="temp-d2-output.png"
 11
 12# -------- VALIDATION --------
 13if [[ ! -f "$D2_FILE" ]]; then
 14  echo "Error: D2 file not found: $D2_FILE"
 15  exit 1
 16fi
 17
 18# -------- PROMPT FOR OG IMAGE TEXT --------
 19echo "-------------------------------------------"
 20echo "Enter title, press ENTER once for new line"
 21echo "Finish by pressing ENTER on an empty line"
 22echo "-------------------------------------------"
 23
 24USER_TITLE=""
 25while IFS= read -r -e line; do
 26  [[ -z "$line" ]] && break
 27  USER_TITLE+="${line}"$'\n'
 28done
 29
 30USER_TITLE="${USER_TITLE%$'\n'}"
 31
 32s1="${USER_TITLE//\\/\\\\}"
 33nl=$'\n'
 34s2="${s1//$nl/\\n}"
 35s3="${s2//\"/\\\"}"
 36
 37FINAL_TITLE="\"$s3\""
 38
 39line_no=$(awk '/^[[:space:]]*postTitle[[:space:]]*:[[:space:]]*".*"/ { print NR; exit }' "$D2_FILE")
 40
 41if [ -z "$line_no" ]; then
 42  echo "ERROR: could not find postTitle: line in $D2_FILE" >&2
 43  exit 1
 44fi
 45
 46if sed -n "${line_no}p" "$D2_FILE" | grep -q '{[[:space:]]*$'; then
 47  endbrace=' {'
 48else
 49  endbrace=''
 50fi
 51
 52if [ "$line_no" -eq 1 ]; then
 53  : > "$TEMP_D2"
 54else
 55  head -n $((line_no - 1)) "$D2_FILE" > "$TEMP_D2"
 56fi
 57
 58printf 'postTitle: %s%s\n' "$FINAL_TITLE" "$endbrace" >> "$TEMP_D2"
 59
 60tail -n +"$((line_no + 1))" "$D2_FILE" >> "$TEMP_D2"
 61
 62echo "Inserted title in $TEMP_D2, line $line_no"
 63sed -n "${line_no},${line_no}p" "$TEMP_D2"
 64
 65# -------- RENDER D2 (change font paths)--------
 66echo "Rendering diagram with <$D2_FILE>"
 67d2 "$TEMP_D2" "$TEMP_PNG" \
 68  --font-bold="/path-to-font/FontName-Bold.ttf" \ 
 69  --font-semibold="/path-to-font/FontName-SemiBold.ttf" \
 70  --font-regular="/path-to-font/FontName-Regular.ttf" \
 71  --font-italic="/path-to-font/FontName-Italic.ttf" \
 72  --pad 80 --scale=2
 73
 74# -------- CROP TO OG IMAGE SIZE --------
 75echo "Resizing..."
 76magick "$TEMP_PNG" \
 77  -background none \
 78  -gravity center \
 79  -resize "${TARGET_W}x${TARGET_H}"^ \
 80  -extent "${TARGET_W}x${TARGET_H}" \
 81  "$OUTPUT_PNG"
 82
 83echo "Dimensions: $(identify -format '%wx%h' "$OUTPUT_PNG")px"
 84
 85size_before=$(wc -c < "$OUTPUT_PNG") # can be removed 
 86
 87# -------- COMPRESS PNG --------
 88echo "Compressing..."
 89pngquant --speed 1 --quality 80-90 --skip-if-larger --force "$OUTPUT_PNG" --ext ".png"
 90
 91size_after=$(wc -c < "$OUTPUT_PNG") # can be removed 
 92
 93# -------- SIZE CALCULATIONS, can be removed --------
 94human() {
 95    local bytes=$1
 96    for u in B KiB MiB GiB; do
 97        (( bytes < 1024 )) && echo "$bytes$u" && return
 98        bytes=$(( bytes / 1024 ))
 99    done
100}
101
102if (( size_before == 0 )); then
103    pct=0
104else
105    pct=$(( 100 - size_after * 100 / size_before ))
106fi
107
108# -------- CLEANUP --------
109rm -f "$TEMP_D2" "$TEMP_PNG"
110
111# -------- RESULT LOG --------
112echo "Size: [$(human $size_before)] > [$(human $size_after)] = saved $(human $((size_before - size_after))) (–$pct%)"
113echo "Output: $OUTPUT_PNG"

How to use V1 Bash Script (Workflow)

  1. Save the script, run chmod +x yourScript.sh to give it executable permissions.
  2. Run the Bash script by either dragging the .sh file into a Terminal window or use a text expander4 for quick access when you want to generate an OG image.
  3. The prompt will tell you to enter the multi-line title. Press ENTER twice to run the script to produce an Open Graph image.
  4. The image will be saved to the OUTPUT_PNG path set at the top of the script.
  5. Move the OG image to the location of choice (in Hugo, it’ll probably be in the page bundle).
  6. Rename the image if necessary.

Screenshot of terminal script prompt for title text and output messages.

Simple prompt for title and outputs the correct sized image in one step

I’m always quite amazed and how much file size can be saved with pngquant! I actually use it, along with cwebp for all the images on this blog.5

Final Generated OG Image

Open Graph image example for this blog post about generating OG:image using D2 and Bash scripts.

Ta-da!

Update: Upgrade From Semi-Auto to Full-Auto

The manual title input + two manual steps after generating the image bugged me a little; which is why I worked on a 2.0 version that automates: title extraction, file moving, and auto commit!

See the newly added section V2: No Prompt Bash.

Conclusion

I am not sure if my diagram-as-code method is just a super convoluted way to generate Open Graph images, but I really enjoyed working on it and getting everything to work out! It is good knowing I don’t have to rely on third-party services, or use any “design skills” per se—having to use a graphics app every time I wrote a post is too much…

After the initial setup hurdle, I think it is pretty straight forward to use! In fact, I’m working on automating it even more by integrating it into the CI/build pipeline so it auto generates whenever there is a new post.

Update

So trying to integrate it into the CI runner failed! It required too many dependencies. But I got another more automated script to work. Read on below for the updated Bash script, version 2.

V2: No Prompt Bash

To further automate the whole process of generating OG images for new blog posts, I figured out how to get Bash to do the rest of the manual steps automatically, with the following logic/rules:

  • Use Git history to find differences and run when there are changes in /content.
  • Read and extract title text string from a specific front matter parameter, d2ogimg, to be used as input text for OG image (and only run generation script if this parameter is present).
  • After generating an OG image (same as V1), instead of moving image manually, automatically identify page bundle directory and outputs image there.
    • I added an extra step to naming the image to match my page bundle naming convention.
  • Finish script by auto committing the new image(s) so it is ready for a git push deployment.

Bash Script Code (V2): d2ogimg_auto.sh

Here’s the more sophisticated version of the auto OG image generation script. Tip: Remember to run it from your site’s directory, as it needs to compare against previous Git commits to detect content changes.

  1#!/usr/bin/env bash
  2set -euo pipefail
  3SITENAME="yourSiteRepo" # Change SITENAME to match your root directory
  4echo "Starting OG image generation..."
  5
  6# -------- FIND ALL CONTENT CHANGES (change master to main or your branch name) --------
  7tmpfile=$(mktemp)
  8trap 'rm -f "$tmpfile"' EXIT
  9# Find changes since last push
 10if git rev-parse --verify origin/master >/dev/null 2>&1; then
 11  # Compare against remote
 12  git diff --name-only origin/master...HEAD 2>/dev/null \
 13    | grep '^content/' | grep -v '^$' > "$tmpfile" || true
 14else
 15  # Fallback to last commit only
 16  git diff-tree --no-commit-id --name-only -r HEAD^..HEAD 2>/dev/null \
 17    | grep '^content/' | grep -v '^$' > "$tmpfile" || true
 18fi
 19# If still empty, check working directory changes
 20if [ ! -s "$tmpfile" ]; then
 21  git diff --name-only HEAD 2>/dev/null \
 22    | grep '^content/' | grep -v '^$' > "$tmpfile" || true
 23fi
 24
 25echo "Changed content files:"
 26cat "$tmpfile"
 27
 28# ── D2 RENDERING CONSTANTS ──
 29D2_FILE="tools/og_img_gen.d2" # Relative to SITENAME
 30TARGET_W=1200
 31TARGET_H=630
 32
 33# -------- VALIDATION --------
 34if [[ ! -f "$D2_FILE" ]]; then
 35  echo "Error: D2 file not found: $D2_FILE"
 36  exit 1
 37fi
 38
 39# -------- EXTRACT TITLE STRING FROM d2ogimg PARAM --------
 40count=0
 41while IFS= read -r POST_FILE; do
 42  [ -z "$POST_FILE" ] && continue
 43  [ -f "$POST_FILE" ] || continue
 44  echo "Processing: $POST_FILE"
 45
 46  if ! grep -q '^[[:space:]]*d2ogimg[[:space:]]*=' "$POST_FILE"; then
 47    echo "No d2ogimg= field found in $POST_FILE — skipping"
 48    continue
 49  fi
 50
 51  TEMP_D2="tmp-fill.d2"
 52  TEMP_PNG="tmp-d2-output.png"
 53  cleanup_temp() {
 54  rm -f "$TEMP_D2" "$TEMP_PNG" 2>/dev/null || true
 55}
 56trap cleanup_temp EXIT
 57
 58# -------- DETECT FRONT MATTER TOML/YAML --------
 59if grep -q '^+++[[:space:]]*$' "$POST_FILE" 2>/dev/null; then
 60  delimiter='+++'
 61elif grep -q '^---[[:space:]]*$' "$POST_FILE" 2>/dev/null; then
 62  delimiter='---'
 63  else
 64    echo "No Hugo/TOML/YAML front matter found in $POST_FILE — skipping OG generation"
 65    rm -f "$TEMP_D2" "$TEMP_PNG"
 66    continue
 67  fi
 68
 69# -------- EXTRACT FRONT MATTER (keep backslashes as-is)--------
 70front=""
 71current_delimiter_count=0
 72while IFS= read -r line; do
 73  line=${line%$'\r'}
 74  if [ "$line" = "$delimiter" ]; then
 75    current_delimiter_count=$((current_delimiter_count + 1))
 76    if [ "$current_delimiter_count" -eq 2 ]; then
 77      break
 78    fi
 79    continue
 80  fi
 81  if [ "$current_delimiter_count" -eq 1 ]; then
 82    front="${front}${line}"$'\n'
 83  fi
 84done < "$POST_FILE"
 85
 86OGVAL=$(printf '%s' "$front" | sed -n 's/^[[:space:]]*d2ogimg[[:space:]]*=[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' | sed -n '1p' 2>/dev/null || true)
 87
 88if [ -z "$OGVAL" ]; then
 89  line=$(printf '%s' "$front" | sed -n '/^[[:space:]]*d2ogimg[[:space:]]*=/p' | sed -n '1p' 2>/dev/null || true)
 90  if [ -n "$line" ]; then
 91    OGVAL=$(printf '%s' "$line" | sed -e 's/^[^"]*"//' -e 's/"[^"]*$//' )
 92  fi
 93fi
 94# -------- SKIP IF NO d2ogimg PARAM --------
 95if [[ -z "$OGVAL" ]]; then
 96  echo "No d2ogimg= field found — skipping OG gen"
 97  exit 0
 98fi
 99
100USER_TITLE="$OGVAL"
101USER_TITLE="${USER_TITLE%$'\n'}"
102s1=$(printf '%s' "$USER_TITLE" | sed -E 's/\\n/__ESC_N__/g; s/\\/\\\\/g; s/__ESC_N__/\\n/g')
103nl=$'\n'
104s2="${s1//$nl/\\n}"
105s3="${s2//\"/\\\"}"
106FINAL_TITLE="\"$s3\""
107
108# -------- PATCH D2 FILE WITH EXTRACTION --------
109line_no=$(awk '/^[[:space:]]*postTitle[[:space:]]*:[[:space:]]*".*"/ { print NR; exit }' "$D2_FILE")
110  if [ -z "$line_no" ]; then
111    echo "ERROR: could not find postTitle: line in $D2_FILE"
112    continue
113  fi
114
115if sed -n "${line_no}p" "$D2_FILE" | grep -q '{[[:space:]]*$'; then
116    endbrace=' {'
117else
118    endbrace=''
119fi
120if [ "$line_no" -eq 1 ]; then
121    : > "$TEMP_D2"
122else
123    head -n $((line_no - 1)) "$D2_FILE" > "$TEMP_D2"
124fi
125printf 'postTitle: %s%s\n' "$FINAL_TITLE" "$endbrace" >> "$TEMP_D2"
126tail -n +"$((line_no + 1))" "$D2_FILE" >> "$TEMP_D2"
127echo "Inserted title in $TEMP_D2, line $line_no"
128
129# -------- D2 RENDER --------
130echo "Rendering D2 diagram..."
131d2 "$TEMP_D2" "$TEMP_PNG" \
132  --font-bold="tools/fonts/KodeMono-Bold.ttf" \
133  --font-semibold="tools/fonts/KodeMono-SemiBold.ttf" \
134  --font-regular="tools/fonts/KodeMono-Medium.ttf" \
135  --pad 80 --scale=2;
136
137# -------- CROP  --------
138echo "Cropping image..."
139magick "$TEMP_PNG" \
140  -background none \
141  -gravity center \
142  -resize "${TARGET_W}x${TARGET_H}"^ \
143  -extent "${TARGET_W}x${TARGET_H}" \
144  "$TEMP_PNG.tmp"
145  mv "$TEMP_PNG.tmp" "$TEMP_PNG"
146
147# -------- COMPRESS --------
148if command -v pngquant >/dev/null 2>&1; then
149  pngquant --speed 1 --quality 80-90 --skip-if-larger --force "$TEMP_PNG" --ext ".png" >/dev/null
150fi
151
152# -------- MOVE TO PAGE BUNDLE --------
153echo "Moving to final location..."
154
155if [[ -f "${POST_FILE%/*}/index.md" ]]; then
156  OUTPUT_DIR="${POST_FILE%/*}"
157else
158  OUTPUT_DIR="$(dirname "$POST_FILE")"
159fi
160# Extract bundle directory name and look for 3-digit prefix (this is specific to my setup)
161bundle_dirname=$(basename "$OUTPUT_DIR")
162if [[ $bundle_dirname =~ ^([0-9]{3}) ]]; then
163  prefix="${BASH_REMATCH[1]}"
164  FINAL_OUTPUT="$OUTPUT_DIR/og_img_${prefix}.png"
165else
166  FINAL_OUTPUT="$OUTPUT_DIR/og_img_D2GEN.png"
167fi
168
169# -------- CREATE DIR AND MOVE OUTPUT --------
170mkdir -p "$(dirname "$FINAL_OUTPUT")"
171mv -f "$TEMP_PNG" "$FINAL_OUTPUT"
172echo "Saved: $FINAL_OUTPUT"
173
174# -------- CLEAN UP --------
175rm -f "$TEMP_D2" "$TEMP_PNG" 2>/dev/null || true
176
177((count++))
178if [ "$count" -gt 100 ]; then
179  echo "Too many files — aborting"
180  break
181fi
182done < "$tmpfile"
183
184echo "⭕ OG image generation complete for $count image(s)"
185
186# -------- AUTO COMMIT  --------
187if git status --porcelain | grep -q 'og_img.*\.png'; then
188  echo "Committing generated OG images..."
189  git add content/**/og_img*.png
190  git commit --no-verify -m "assets: generate D2 OG images"
191  echo "Committed new OG images."
192else
193  echo "No new OG images to commit."
194fi
195
196echo "⭕ Task complete. Run git push to deploy."

How To Use V2 Bash Script (Workflow)

  1. Create the script, save it, chmod +x yourScript.sh to give it executable permissions.

  2. Write your Hugo posts as usual. In the front matter, add a parameter called d2ogimg. Use \n to indicate new lines/line breaks. For example:

    d2ogimg = "How To Use\nScripts To Generate\nOpen Graph Images"

  3. Commit your blog content changes. (The FIND ALL CONTENT CHANGES section has multiple fallbacks, so even if your new d2ogimg parameter is changed a few commits ago, it should still pick it up. )

  4. Ensure you’re in your site directory, e.g. cd siteName

  5. Run the script, ~/siteName/scripts/d2ogimg_auto.sh

    To verify that everything is working correctly, check the following:

    • Does the newly generated OG image have the correct title from the post front matter?
    • Is it saved in the proper output path?
    • Has it been added to the repository with the automated git commit?
  6. Run git push to deploy changes.

Closing

Both the auto and prompt versions have their own use case. The prompt script would be handy to generate images for old posts or once off images, whereas the auto one would be convenient for posts going forward as it is more automated.

There are probably many ways I could improve these Bash scripts to make them more robust, but I’m still new to scripting. If you spot any obvious improvements or errors, I’d really appreciate your feedback in the comments.🙏

If you end up using D2 to auto generate OG images for your blog, or actually used my code, I’d love to hear from you. Did it work for your setup? How did you design your D2 image? I’d be happy to add examples into the post.

Consider buying me a coffee if you made use of the scripts or got something out of my guide. I really appreciate it! Thanks for reading my rather long Open Graph image tutorial—hope you found it interesting or useful.

P.S.: Would code-heavy posts like this be more useful if I included a public Git repository with example and template files?


  1. Found a useful comparison site while researching about scripting languages↩︎

  2. The Bash snippet looks a bit complex (to me) because of a \n new line text replacement issue which wouldn’t play well with the D2 base script. I got it working in the end though! ↩︎

  3. There are alternatives like oxipng and pngcrush, but I haven’t tried those. ↩︎

  4. I can’t live without Espanso↩︎

  5. I actually created a Python script recently to process all the screenshots and images used in BurgeonLab. It combines watermarking + image compression + webp conversion. If anyone is interested, let me know, and I write a post on it. ↩︎

Read the original on burgeonlab.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.