JavaScript on this site is used to render LaTeX math, but is non-essential. blamster19 - all stuff blamster
blamster19 - all stuff blamster

Static blog generation with Bash

Published: 2026-08-20T12:30:00Z
Last modified: 2026-08-20T13:51:34Z

Contents

  1. What's wrong with normal SSGs?
  2. Blog page structure
  3. Outcome

What's wrong with normal SSGs?

When I created this blog (2023) I used Jekyll Static Site Generator (SSG) to produce HTML from Markdown files. You write the post in plain Markdown and the engine compiles it into a beautiful page with header and footer, pretty simple. I used GitHub Action to compile the site on every pushed commit.

Over time I've noticed several things that annoyed me so bad that I started to seek an alternative:

Other than that, I had a few other goals I wanted to accomplish:

Blog page structure

Header and footer

The first stage of rewriting the static site generator is the creation of header and footer. The content, written in Markdown and converted to (X)HTML, sits between those. Building the page comes down to essentially one command:

cat header.html content.html footer.html

I wrote a new header and footer to match my old layout, wrote a CSS stylesheet to make my site pretty and then wrote the parsing part. When I was writing the HTML I tried to follow the rules laid out in the smolweb guidelines. I don't think I followed them very faithfully, but I hope I managed to produce something accessible that degrades gracefully. Browsing the page in Lynx works well enough.

Body

Processing the content was fun. I decided to use md2html utility available in Debian repos as a starting point. I could have written the parser myself in Bash, but I wanted to allocate finite time to the project and parsing Markdown was not a priority. Still, the output of that tool had to be sanitized. My posts were originally written for Jekyll, so they contain the Yaml front-matter with page data at the beginning of the file. I had to parse it separately from the proper body and extract the title, type and creation date. Figuring out how to make the converted output work in the context of the whole page was fun. This is the pipeline that I made:

# footnotes parsing
  sed -E '
 s/^\[\^([^][]+)\]:[[:space:]]*/<br \/><a id="fn\1" href="#ref\1"><sup>\1<\/sup><\/a> /
 s/\[\^([^][]+)\][[:space:]]*/<a href="#fn\1" id="ref\1"><sup>\1<\/sup><\/a> /g' | md2html --github -x |
# lower the headings
  sed \
    -e 's@<h5>@<h6>@g; s@</h5>@</h6>@g' \
    -e 's@<h4>@<h5>@g; s@</h4>@</h5>@g' \
    -e 's@<h3>@<h4>@g; s@</h3>@</h4>@g' \
    -e 's@<h2>@<h3>@g; s@</h2>@</h3>@g' |
# lower h1 to h2 and add id for toc linking
  perl -pe 's{<h1>(<a href="#fn<" id="ref<"><sup><</sup></a> +)</h1>}{
   $t = $1;
   $id = lc($t);
   $id =~ s/<a href="#fna-z0-9" id="refa-z0-9"><sup>a-z0-9</sup></a> +/-/g;
   $id =~ s/^-+|-+$//g;
   "<h2 id=\"$id\">$t</h2>"}ge' |
# remove image CSS parameters from legacy posts
  sed -E 's/(<img<a href="#fn>" id="ref>"><sup>></sup></a> *>)\s*\{:<a href="#fn}" id="ref}"><sup>}</sup></a> *\}/\1/g'

I generate the HTML ToC, the title and publishing and modification dates and combine all that to create the content body.

If the layout of the page in the front-matter says "home", then it is the index page that lists all the blog posts. To list all posts with their titles, dates and excerpts I do the following:

local files=($(grep -l '^date: ' * | while read -r file; do
  local date=$(grep '^date: ' "$file" | cut -d' ' -f2)
  printf "%s\n" "$file"
done | sort -n | cut -f2-))
for ((i = ${#files[@]} - 1; i >= 0; i--)); do
  local post_file="${files[i]}"
  local date_md=$(grep '^date: ' "$post_file" | cut -d' ' -f2-)
  local date_utc=$(date -u -d "$date_md" +"%Y-%m-%dT%H:%M:%SZ")
  local title=$(grep '^title: ' "$post_file" | cut -d' ' -f2-)
  local excerpt=$(grep '^excerpt: ' "$post_file" | cut -d' ' -f2-)
  local url=$(
    printf "%s" "$post_file" |
      sed 's/^\(.\{4\}\)./\1\//;
   s/^\(.\{7\}\)./\1\//;
   s/^\(.\{10\}\)./\1\//;
   s/\.markdown/\.html/'
  )
  echo "<a href="$url"><h3>$title</h3></a>"
  echo "<small>$date_utc</small><br />"
  echo "<p>$excerpt</p>"

This goes through all the posts in creation chronological order and writes the titles, UTC dates and excerpts.

Compilation

The script I wrote compiles a single site. My blog is of course composed of many files and not all of them need to be compiled every time I change something. The obvious choice of a tool to conditionally compile changed files was make. This was the most tedious and time-consuming part of my project because I cannot write Makefiles for the life of me. I managed to make something like this:

PAGES_SRC := pages-md
POSTS_SRC := _posts
HTML_OUT := html

PAGES_MD := $(wildcard $(PAGES_SRC)/*.markdown)
POSTS_MD := $(wildcard $(POSTS_SRC)/*.markdown)

PAGES_HTML := $(patsubst $(PAGES_SRC)/%.markdown,$(HTML_OUT)/%.html,$(PAGES_MD))
POSTS_HTML := $(foreach f,$(POSTS_MD),$(HTML_OUT)/$(shell echo $(notdir $(f)) | sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})-(.*)\.markdown$$/\1\/\2\/\3\/\4.html/'))
POSTS_HTML_DIRS := $(foreach f,$(POSTS_MD),$(HTML_OUT)/$(shell echo $(notdir $(f)) | sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})-(.*)\.markdown$$/\1\/\2\/\3/'))

define post_rule
$(HTML_OUT)/$(shell echo $(notdir $(1)) | sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})-(.*)\.markdown$$/\1\/\2\/\3\/\4.html/'): $(1) | $(HTML_OUT)/$(shell echo $(notdir $(1)) | sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})-(.*)\.markdown$$/\1\/\2\/\3/')
 /bin/bash ./blampub.sh $$< > $$@
endef

all: $(PAGES_HTML) $(POSTS_HTML) $(HTML_OUT)/feed.xml

$(HTML_OUT)/%.html: $(PAGES_SRC)/%.markdown
 /bin/bash ./blampub.sh $< > $@

$(foreach f,$(POSTS_MD),$(eval $(call post_rule,$(f))))

$(POSTS_MD): | $(POSTS_HTML_DIRS)

$(POSTS_HTML_DIRS):
 mkdir -p $@

$(HTML_OUT)/feed.xml:
 /bin/bash ./feedgen.sh > $@

clean:
 find $(HTML_OUT)/ -mindepth 1 -not -path '*/assets*' -delete

The main complication was the handling of post files names. Each markdown source file has a name like yyyy-mm-dd-arbitrary-post-title.markdown. The output page is stored in yyyy/mm/dd/arbitrary-post-title.html. I needed to extract the date from the filename, create the proper directory structure and preserve the rest of the filename. The fact that the name could contain hyphens complicated the matter, because I could not simply turn every - into / and have the path for free. This is why I need the lengthy post_rule that generates appropriate rule for every source file that exists in the foreach loop.

Feed

The Atom feed was trivial to implement. It is mostly echoing the XML boilerplate and the parsed Markdown file. This time I do not lower the heading levels. I reused the code for parsing and listing posts in chronological order.

Outcome

The result I got is a nice, understandable static site generator that I hope will serve me well. I learned a bit of Bash in the process which is what I wanted. In the process of migrating to the new generator I had to upgrade some posts to remove MathJax script CDN linking. Now I use a minimal installation of Temml that sits in the website assets and is loaded in the browser to turn every $$text$$ into LaTeX math.