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.
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:
Limitations of Generating Open Graph Images Using Cloud Tools
But, to be honest, it doesn’t really cut it for me:
- No saved styles—hard to keep consistent
- Not enough customization options
- Third-party reliance
- 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:
- Run the script in Terminal.
- Prompt asks me for the title text (with multi-line support).
- 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:
- Move the image into the right page bundle
- Manually
git add addandgit committhe new image
Prerequisites
- Install D2, see d2/docs/INSTALL.md. I used
brew install d2on 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.
- ImageMagick
brew install imagemagick - Pngquant
brew install pngquant3
- ImageMagick
D2 ‘Base’ Script
D2 files end in .d2. They’re like the “recipe” file for generating diagrams.
- Choose a
theme-idfrom 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 thewidth,height,nearattributes 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
--padand--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
.d2script, 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.pngAutomate 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.shfor 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:
- Replace D2
postTitlefrom prompt input. - Compile D2 with new postTitle.
- Resize image to 1200x630px with
magick. - Compress PNG with `pngquant.
- Calculate before and after size.
- Remove temporary files used in script.
- 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)
- Save the script, run
chmod +x yourScript.shto give it executable permissions. - Run the Bash script by either dragging the
.shfile into a Terminal window or use a text expander4 for quick access when you want to generate an OG image. - The prompt will tell you to enter the multi-line title. Press ENTER twice to run the script to produce an Open Graph image.
- The image will be saved to the
OUTPUT_PNGpath set at the top of the script. - Move the OG image to the location of choice (in Hugo, it’ll probably be in the page bundle).
- Rename the image if necessary.
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
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 pushdeployment.
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)
Create the script, save it,
chmod +x yourScript.shto give it executable permissions.Write your Hugo posts as usual. In the front matter, add a parameter called
d2ogimg. Use\nto indicate new lines/line breaks. For example:d2ogimg = "How To Use\nScripts To Generate\nOpen Graph Images"Commit your blog content changes. (The
FIND ALL CONTENT CHANGESsection has multiple fallbacks, so even if your newd2ogimgparameter is changed a few commits ago, it should still pick it up. )Ensure you’re in your site directory, e.g.
cd siteNameRun the script,
~/siteName/scripts/d2ogimg_auto.shTo 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?
Run
git pushto 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?
Found a useful comparison site while researching about scripting languages. ↩︎
The Bash snippet looks a bit complex (to me) because of a
\nnew line text replacement issue which wouldn’t play well with the D2 base script. I got it working in the end though! ↩︎There are alternatives like oxipng and pngcrush, but I haven’t tried those. ↩︎
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. ↩︎






Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.