This post is part of the Adding MermaidJS Without Any JS series.
Woodpecker CI, Hugo & MermaidJSAdding icons to Mermaid chartsLight/dark theme for MermaidJSGenerate MermaidJS graphs faster
After implementing support for light/dark themed MermaidJS graphs in my CI engine of choice, I was pretty happy with the solution. But after a few more months of iterating on drafts through my deployment chain I got tired of the time it took – often more than 5 minutes per build! Time to fix that.
Table of Contents
Problem Description
Initially I had several issues slowing me down:
- I ran my Woodpecker agents in my homelab Kubernetes cluster, in which disk access is pretty slow, frequently taking more than 1 minute to pull the repository sources (7.5 MiB according to Forgejo)
- The
mermaid-clicontainer was approx. 1.5 GiB, so even with a local pull-through caching proxy it was a heavy container to download, load, and cache on each worker node mermaid-clispawns a chromium instance on each invocation (per file), consuming a lot of CPU/memory as well as a lot of time- Each time we try to render a graph, we download the icon pack JSON from GitHub. That file is approx. 1 MiB, and if you do it almost 50 times per build, it takes a lot of time!
After accidentally breaking my Woodpecker setup in Kubernetes I migrated to running the server and agents as Incus containers on my homelab NAS. This sped up execution a lot as local NVMe is faster than longhorn over gigabit Ethernet…but the graph generation was still slow.
Luckily, I found Latias94/merman, which was a drop-in
replacement for mermaid-cli! And, I realized that pre-generating
these graphs can be done in parallel without much complexity or
effort.
s/mermaid/merman/
First of all, I decided to rewrite the script which is used to generate light/dark mode graphs and override how these are displayed. It was poorly written and felt brittle, and I realized that it could be simplified while I was touching the file anyways.
The script is run before we rebuild the site using Hugo, which means we can modify each entry by itself without risking anything. That also mean that we can process all entries in parallel, so I decided to use GNU parallel for this.
In the end, my script.sh looked like this:
#!/usr/bin/env bash
set -euo pipefail
f="$1"
dir=$(dirname "$f")
base=$(basename "$f" .md)
tdir=$(mktemp -d)
rdir=$(pwd)
pushd "$dir"
cp -v "$base.md" "$base-dark.md"
echo "Generate light versions"
merman-cli -q -i "$base.md" -o "$tdir/$base-light.md" -e svg \
--theme default --backgroundColor transparent \
--iconPacksNamesAndUrls "azure#$rdir/icons.json"
mv -v "$tdir"/$base-light-*.svg .
mv -v "$tdir"/"$base-light.md" "$base-rendered.md"
echo "Generate dark versions"
merman-cli -q -i "$base-dark.md" -o "$tdir/$base-dark.md" -e svg \
--theme dark --backgroundColor transparent \
--iconPacksNamesAndUrls "azure#$rdir/icons.json"
mv -v "$tdir"/$base-dark-*.svg .
gawk -f $rdir/transform.awk "$base-rendered.md" > "$base.md"
rm "$base-dark.md" "$base-rendered.md"
popd
rm -rf "$tdir"
The eagle-eyed reader might notice I have replaced my old sed hack with an AWK script1, which I got help from an LLM to put together:
# Usage: gawk -f convert-images.awk in.md > out.md
{
line = $0
out = ""
while (match(line, /!\[(\\.|[^]])*\]\([^)]*\)/)) {
before = substr(line, 1, RSTART - 1)
m = substr(line, RSTART, RLENGTH)
line = substr(line, RSTART + RLENGTH)
out = out before
if (match(m, /^!\[((\\.|[^]])*)\]\(\.\/([^ )"]+)( "([^"]*)")?\)$/, g)) {
alt = g[1]
src = g[3]
caption = g[5]
alt = gensub(/\\(.)/, "\\1", "g", alt)
gsub(/\\/, "\\\\", alt); gsub(/"/, "\\\"", alt)
gsub(/\\/, "\\\\", src); gsub(/"/, "\\\"", src)
gsub(/\\/, "\\\\", caption); gsub(/"/, "\\\"", caption)
repl = "{{< adaptive-image src=\"" src "\" alt=\"" alt "\"" (g[4] != "" ? " caption=\"" caption "\"" : "") " >}}"
out = out repl
} else {
out = out m
}
}
out = out line
print out
}
My build.yaml (and the same for publish.yaml) for Woodpecker
was changed into something like this:
when:
- event: pull_request
steps:
mermaidcharts:
image: registry.example.com/merman-cli/merman-cli:latest
pull: true
commands:
# Download the icons.json file once per run
- wget -q https://raw.githubusercontent.com/NakayamaKento/AzureIcons/refs/heads/main/icons.json
# Call the script for all files containing mermaid charts
- parallel ./script.sh ::: $(grep -RlE '^```mermaid[[:space:]]*$' --include="*.md")
build:
image: registry.example.com/dockerhub/hugomods/hugo:go-git-0.150.1
depends_on:
- mermaidcharts
commands:
- hugo --baseURL=$INTERNAL_URL --minify --buildDrafts --buildFuture
environment:
INTERNAL_URL:
from_secret: internal-url
pagefind:
image: registry.example.com/dockerhub/alpine:latest
depends_on:
- build
commands:
- apk add wget
- wget --quiet https://github.com/Pagefind/pagefind/releases/download/v1.4.0/pagefind-v1.4.0-x86_64-unknown-linux-musl.tar.gz -O /tmp/pagefind.tar.gz
- tar zxf /tmp/pagefind.tar.gz -C /tmp
- /tmp/pagefind
publish:
image: registry.example.com/dockerhub/minio/mc
depends_on:
- pagefind
environment:
ACCESS_KEY:
from_secret: garage-access
SECRET_KEY:
from_secret: garage-secret
commands:
- mc alias set garage --insecure https://s3.example.com $ACCESS_KEY $SECRET_KEY
- mc mirror --insecure --overwrite --remove --quiet public/ garage/monotux-tech-stage
The file for publishing the site is nearly identical.
Bonus: Packaging pagefind In A Container
I also realized that pagefind takes a few seconds to setup and
downloads the binary on every run, which feels unnecessary. I used
buildah in a separate repository+pipeline to package pagefind,
something like this:
# .woodpecker/publish.yaml
---
when:
- event: [push, tag, manual]
branch: trunk
steps:
- name: buildah
image: registry.example.com/ghcr/404systems/plugin-buildah:v1.1.1
privileged: true
volumes:
- /usr/local/share/ca-certificates/rootCA.crt:/etc/containers/certs.d/registry.example.com/ca.crt:ro
settings:
mirror: registry.example.com/dockerhub
cache_repo: pagefind-oci/pagefind-oci-cache
repo: pagefind-oci/pagefind-oci
tags: latest
registry: registry.example.com
username:
from_secret: docker_username
password:
from_secret: docker_password
And the Containerfile used:
FROM alpine:latest
RUN apk add --no-cache wget && \
wget --quiet "https://github.com/Pagefind/pagefind/releases/download/v1.5.2/pagefind-v1.5.2-x86_64-unknown-linux-musl.tar.gz" -O /tmp/pagefind.tar.gz && \
cd /usr/local/bin && \
tar zxf /tmp/pagefind.tar.gz && \
rm /tmp/pagefind.tar.gz
VOLUME /work
WORKDIR /work
ENTRYPOINT ["/usr/local/bin/pagefind"]
Now the pagefind step looks like this:
# ...
pagefind:
image: registry.services.home.arpa/pagefind-oci/pagefind-oci:latest
pull: true # force woodpecker to pull on each run
depends_on:
- build
Bonus 2: merman-cli In OCI
This image isn’t available anywhere aside from my internal registry, but it looks like this:
FROM debian:bookworm-slim
ARG MERMAN_VERSION=0.7.0
ARG MERMAN_URL=https://github.com/Latias94/merman/releases/download/v${MERMAN_VERSION}/merman-cli-x86_64-unknown-linux-gnu.tar.xz
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
xz-utils \
ripgrep \
parallel \
gawk \
wget \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "${MERMAN_URL}" -o /tmp/merman-cli.tar.xz \
&& tar -xJf /tmp/merman-cli.tar.xz -C /usr/local/bin --strip-components=1 \
&& rm /tmp/merman-cli.tar.xz
WORKDIR /work
ENTRYPOINT ["merman-cli"]
CMD ["--help"]
I should probably rebase this on Alpine Linux at some point. I build
and publish this image using the same buildah workflow like above.
Results
Some of the improvements here are caused by moving from distributed storage to local NVMe, but I am including them due to reasons.
| Step | Old time | New time |
|---|---|---|
| clone | 1–2 minutes | 1–2 seconds |
| mermaidcharts | 2–3 minutes | 1–2 seconds |
| build | 1–2 seconds | <1 second |
| pagefind | 5–10 seconds | 1–2 seconds |
| publish | 5–10 seconds | 5–10 seconds |
| total | 4–7 minutes | 10–20 seconds |
I am quite happy with these improvements!
Great success – the AWK script is 20+ lines compared to the three line sed hack from before, yet its still hard to read! ↩︎