monotux.tech

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:

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.

StepOld timeNew time
clone1–2 minutes1–2 seconds
mermaidcharts2–3 minutes1–2 seconds
build1–2 seconds<1 second
pagefind5–10 seconds1–2 seconds
publish5–10 seconds5–10 seconds
total4–7 minutes10–20 seconds

I am quite happy with these improvements!


  1. Great success – the AWK script is 20+ lines compared to the three line sed hack from before, yet its still hard to read! ↩︎