First post
Published on 2026-08-23
Well, here we are again. Another case of boredom led me down a rabbit hole, which led me to start posting crap on Internet Of Bots. So I guess I’m just writing this to pass the time on my off days. Maybe it will go somewhere, maybe it won’t. There isn’t much here right now, so I guess I’ll write about the SSG I wrote to build the site. The program is a Bash script with minimal dependencies.
Lets start with the writing experience. Here’s start of this page:
{
"title": "First post",
"date": "2026-08-22",
"description": "Why am I here again?",
"keywords": "bash, awk, html, static"
"blogPost": true
}
Well, here we are again. Another case of boredom led me down a rabbit hole, which led me to start posting crap on Internet Of Bots. So I guess I'm just writing this to pass the time on my off days. Maybe it will go somewhere, maybe it won't. There isn't much here right now, so I guess I'll write about the SSG I wrote to build the site. The program is a Bash script with minimal dependencies.
I’m using JSON as the “front matter”, a trio of +s as a separator, and Markdown to write the actual content. I decided to use jq to parse and modify the JSON, and cmark to render my content. The rest is handled with Bash and a bit of heavy lifting from Awk.
Why?
This is a good question. We got chatbots that can spit out tons of code, site builders of every shape and size, and hundreds of static site generators scattered across the Internet. Why would I take the time to grind out my own? Mainly boredom, but it’s also fun to make something that didn’t previously exist. And because it’s my code, I know how it works. I don’t have to spend hours combing through poor documentation, obscure forum posts, and mountains of AI slop in the guise of search results.
By limiting dependencies and writing the code myself, I know it will work just about anywhere with Bash, Awk, cmark, and jq, even without an Internet connection. That includes the vast majority of Linux distributions, BSD, and possibly other Unix-like operating systems.
Part 1: Dummy proofing.
Before my script can run, it has to check to see if everything is going to work. That means checking if the specified source and destination directories exist, and if the required commands are available.
# Global vars
MD_PARSER="cmark"
SRC="src"
DEST="public"
JSON_PARSER="jq"
TEMPLATE="templates/main.html"
# Check if 'cmark' is installed.
if command -v $MD_PARSER &> /dev/null; then
echo "Found $MD_PARSER"
else
echo "Need a valid Markdown parser!"
exit 1
fi
# Check if 'jq' is installed.
if command -v $JSON_PARSER &> /dev/null; then
echo "Found $JSON_PARSER"
else
echo "Need a valid Markdown parser!"
exit 1
fi
# Check to make sure that the src exists.
if [ -d $SRC ] ; then
echo "Found $SRC"
else
echo "Source not found!"
exit 1
fi
# Same for dest.
if [ -d $DEST ]; then
echo "Found $DEST"
else
echo "Destination not found!"
exit 1
fi
# Same for the template file.
if [ -f $TEMPLATE ]; then
echo "Found $TEMPLATE"
else
echo "Template not found!"
exit 1
fi
There isn’t much going on here. It just checks if all of the parts are installed and exits if one of them is missing.
Part 2: Main functions
Things get a bit more interesting in the function section. Each page contains front matter that must be processed to build a list of pages, and content that must be rendered. The program needs a way to split the page and parse each half individually.
getJson() {
# Prints everything before the separator.
local infile="$1"
awk '/^\+\+\+/ {
exit
}
{
print
}' "$infile"
}
getContent() {
# Prints everything after the separator.
local infile="$1"
awk '/^\+\+\+/ {
found = 1;
next
}
found {
print
}' "$infile"
}
Awk is probably one of the easiest data processing languages to work with. It loops through lines of input automatically, and it runs <condition> { <do something> } on each line. In getJson(), it prints every line until it encounters one that starts with +++. In getContent(), it skips every line until is finds the separator. Then it sets found to the equivalent of “true”. From that point, it prints out the rest of the file.
And yes, Awk allows for the creation of variables anywhere in the code, and all variables are global by default.
After separating the Markdown and JSON, they have to be parsed. The jq command turned out to be a bit too complex to shove into a function, so I chose to use it directly. As for the cmark command, I wanted to run it with the same flags every time.
renderMarkdown() {
local content="$1"
echo "$content" | cmark --smart --unsafe
}
Part 3: Scanning the pages
After all of the functions and dummy-proofing, the program rounds up a list of Markdown files with find.
# Build list of files.
fileList=$(find "$SRC" -type f -name "*.md")
Just before processing the list of pages, the program wipes the contents of the destination directory. I figured that it was the easiest thing to do.
After that, the program runs its first while loop.
# Clear the destination directory.
rm -rf $DEST/*
# Iterate over the output of 'find' to build a map of content.
jsonMap='{
"pages": []
}'
while IFS= read -r line ; do
# Set the out file and path.
inFile="$line"
outFile=$(echo "$line" | sed "s/$SRC/$DEST/" | sed "s/\.md/\.html/")
outPath=$(dirname "$line" | sed "s/$SRC/$DEST/")
# URL too.
url=$(echo "$outFile" | sed "s/$DEST//")
# Extract details from json.
json=$(getJson "$line")
# Add extra keys to page object.
pageObject=$(jq ". += {
\"infile\": \"$inFile\",
\"outpath\": \"$outPath\",
\"outfile\": \"$outFile\",
\"url\": \"$url\"
}" <<< "$json")
# Add the page object to the main page list.
jsonMap=$(jq ".pages += [$pageObject]" <<< "$jsonMap")
done <<< "$fileList"
This is when I learned that jq can edit JSON, not just pull data from it. I took advantage of this in the first loop of the program, where it builds page objects and adds them to the pages array in the jsonMap object. It took a while to get the syntax right because Bash has a strange way of iterating over the lines of a multi-line string. jq also has some strange and janky syntax for adding objects to existing chunks of JSON.
For now the jsonMap object only has one key called pages. In the future, I plan to add categories and other page collections.
After that, I sort the pages by date. This is something that jq and just do without much effort. It sorted pages from oldest to newest, so I reversed it.
# Sort the pages by date, newest to oldest.
sortedPages=$(jq ".pages | sort_by(.date) | reverse" <<< "$jsonMap")
jsonMap=$(jq ".pages = $sortedPages" <<< "$jsonMap")
Part 4: Grabbing menu items and blog posts
The next section is where I built the menu and the list of blog posts that appears on the home page. I had to define a few variables first.
# Need to set some variables for rendering.
SITE_NAME="RJKCodes"
SITE_DESCRIPTION="Endless jank awaits."
SITE_KEYWORDS="code, linux, computers"
FOOTER="Copyright 2026 Robert J Kight, all rights reserved"
MENU="<ul id=\"menu-ul\">"
BLOGLIST='<ul id="post-list">'
Next came the second loop, where I extracted the blog posts and built the menu.
# The easiest way to do this is to get the number of pages and write a 'while'
# loop. And we have to do it twice. First to generate the menu, then again to
# generate the actual pages.
pageArrayLength=$(jq '.pages | length' <<< "$jsonMap")
curIndex=0
while [ "$curIndex" -lt "$pageArrayLength" ]; do
url=$(jq ".pages[$curIndex].url" <<< "$jsonMap")
title=$(jq -r ".pages[$curIndex].title" <<< "$jsonMap")
menu=$(jq ".pages[$curIndex].menu" <<< "$jsonMap")
blog_post=$(jq ".pages[$curIndex].blogPost" <<< "$jsonMap")
description=$(jq -r ".pages[$curIndex].description" <<< "$jsonMap")
date=$(jq -r ".pages[$curIndex].date" <<< "$jsonMap")
if [ "$menu" == "true" ]; then
# NOTE: Discovered that no backwhacking is required. Key values are already
# double quoted. Also, this looks strange, but Bash allows literal
# newlines.
MENU="$MENU
<li class=\"menu-li\"><a href=$url>$title</a></li>"
fi
# While we're here, build up the blog list.
if [ "$blog_post" == "true" ]; then
item="<li class=\"post-item\"><h2><a href=$url>$title -- $date</a></h2>"
if [ "$description" != "null" ]; then
item="$item<p class=\"post-description\">$description</p>"
fi
item="$item</li>"
# Add the item
BLOGLIST="$BLOGLIST
$item"
fi
curIndex=$((curIndex + 1))
done
# Cap off the blog post list
BLOGLIST="$BLOGLIST
</ul>
"
MENU="$MENU
</ul>
"
Injecting HTML into multi-lines strings was a bit annoying because every " had to be escaped. Or as the cool kids put it, “backwhacked”. Also, I couldn’t figure out how to do a for loop, so I just grabbed the length of the page list and referenced the page objects by their indexes. After that, I capped off the menu and blog post lists.
Part 5: Building the pages
With all of the pieces in place, the program can finally render some pages. But first, lets look at the template for this site.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="VV_DESCRIPTION_VV">
<meta name="keywords" content="VV_KEYWORDS_VV">
<title>VV_SITE_NAME_VV == VV_TITLE_VV</title>
<link rel="stylesheet" href="/assets/main.css">
<link rel="stylesheet" href="/assets/highlightjs/styles/monokai.min.css">
<script src="/assets/highlightjs/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
</head>
<body>
<header>
<nav>
VV_MENU_VV
</nav>
</header>
<main>
<h1>VV_TITLE_VV</h1>
<p id="page-date">Published on VV_DATE_VV</p>
VV_CONTENT_VV
</main>
<footer>
<p>VV_FOOTER_VV</p>
</footer>
</body>
</html>
The template system, if you could even call it that, is just a search-and-replace system similar to Mustache. The strings I want to replace use the syntax VV_VAR_NAME_VV.
After building the menu and a list blog posts for the home page, the program enters the final loop. This is where Awk is used to build the pages.
curIndex=0
while [ "$curIndex" -lt "$pageArrayLength" ]; do
# These are all template variables
v_title=$(jq -r ".pages[$curIndex].title" <<< "$jsonMap")
v_description=$(jq -r ".pages[$curIndex].description" <<< "$jsonMap")
v_keywords=$(jq -r ".pages[$curIndex].keywords" <<< "$jsonMap")
v_url=$(jq ".pages[$curIndex].url" <<< "$jsonMap")
v_date=$(jq -r ".pages[$curIndex].date" <<< "$jsonMap")
v_site_name="$SITE_NAME"
v_menu="$MENU"
# These are required for reading and writing files.
infile=$(jq -r ".pages[$curIndex].infile" <<< "$jsonMap")
outpath=$(jq -r ".pages[$curIndex].outpath" <<< "$jsonMap")
outfile=$(jq -r ".pages[$curIndex].outfile" <<< "$jsonMap")
index=$(jq -r ".pages[$curIndex].index" <<< "$jsonMap")
# Use site description and keywords if they are not present.
if [ "$v_description" == "null" ]; then
v_description="$SITE_DESCRIPTION"
fi
if [ "$v_keywords" == "null" ]; then
v_keywords="$SITE_KEYWORDS"
fi
echo "Working on '$v_title' at '$infile'"
# Generate the content first. Add the blog post list to home page.
raw=$(getContent "$infile")
v_content=$(renderMarkdown "$raw")
if [ "$index" == "true" ]; then
v_content="$v_content$BLOGLIST"
fi
# Create the outpath
mkdir -p "$outpath"
echo "Writing outfile '$outfile'"
# Cookie cutter search and replace
# Awk is the way to go here, but something strange happens when I try to use
# it to insert the content with 'sub'. So I had to get creative.
cat "$TEMPLATE" | awk -v title="$v_title" \
-v description="$v_description" \
-v keywords="$v_keywords" \
-v url="$v_url" \
-v site_name="$v_site_name" \
-v menu="$v_menu" \
-v content="$v_content" \
-v footer="$FOOTER" \
-v date="$v_date" 'index($0, "VV_CONTENT_VV") {
print content
next
}
!index($0, "VV_CONTENT_VV") {
sub("VV_DESCRIPTION_VV", description)
sub("VV_KEYWORDS_VV", keywords)
sub("VV_SITE_NAME_VV", site_name)
sub("VV_TITLE_VV", title)
sub("VV_MENU_VV", menu)
sub("VV_FOOTER_VV", footer)
sub("VV_DATE_VV", date)
print $0
}' > "$outfile"
curIndex=$((curIndex + 1))
done
As noted by the comment above, I had to match VV_CONTENT_VV separately and tell Awk to go to the next line immediately after printing the page content. I did this to prevent Awk from eating it’s own output and generating nonsense when encountering code samples like the one above.
On each iteration, it also checks to see if the page has index set to “true”. That’s how it finds the home page and attaches the list of blog posts to the content. Also added a bit of output to tell the user which page it’s working on.
After that, the only thing left to do if copy the assets.
# Finally, copy the CONTENTS or the assets directory, if there is one
if [ -d "$SRC/assets" ]; then
echo "Coppying assets..."
cp -r $SRC/assets $DEST/
fi
echo "DONE"
The end
And that’s it. At 254 lines, including comments, it’s a nice little script. I may expand it’s features and add some more error handling in the future. Or I could go the other way and play code golf.