New version of Jank!

Published on 2026-09-05

I rewrote a major chunk of my SSG called Jank. The first version used a completely static HTML template with keywords that were replaced with Awk. It worked well as a proof of concept, but it had absolutely no flexibility.

For example, if I wanted blog posts and regular pages to use different HTML or load different CSS, adding the required code to Jank would have been the only way to do it. Then Jank would have become part of my site, generating more and more custom bits of HTML over time. I want Jank to be a little more generic than that.

I could have solved the problem by picking from a very wide selection of template systems, but I didn’t want to add a bunch of dependencies to Jank. So I turned my templates into Bash scripts! This isn’t as insane as it sounds because the templates only reside on my computer, not the server hosting this site.

Using Bash for templates saves me from having to reinvent the wheel. Bash already has functions, conditionals, and variables. The only things that are missing are a data source, and a way to extract information from that source. JSON and jq fill that gap nicely.

Part 1: The main template

The main template is used for every page, either on its own, or wrapped around sub-templates.

# main.bash
# This a template script is meant to be run by Jank.

# Need page JSON, rendered page content, and site JSON in that order.
page_data="$1"
page_content="$2"
site_data="$3"

page_title=$(jq -r ".title" <<< "$page_data")
page_description=$(jq -r ".description" <<< "$page_data")
page_keywords=$(jq -r ".keywords" <<< "$page_data")
page_url=$(jq ".url" <<< "$page_data")

site_name=$(jq -r ".name" <<< "$site_data")
site_menu=$(jq ".menu" <<< "$site_data")
site_description=$(jq ".description" <<< "$site_data")
site_keywords=$(jq ".keywords" <<< "$site_data")
site_footer=$(jq -r ".footer" <<< "$site_data")

This is basically what the top of every template looks like. It receives page and site data as JSON strings and uses jq to pick out what the template requires. The content is either rendered Markdown, or the output of a sub-template.

Let’s look at some of the logic that’s possible.

# Build page head and metadata.
template="<!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=$page_description>
    <meta name=\"keywords\" content=$page_keywords>
    <title>$site_name == $page_title</title>
    <link rel=\"stylesheet\" href=\"/main.css\">
    <link rel=\"stylesheet\" href=\"/highlightjs/styles/monokai.min.css\">
    <script src=\"/highlightjs/highlight.min.js\"></script>
    <script>hljs.highlightAll();</script>
  </head>
  <body>
    <div id=\"wrapper\">
      <header>
        <nav>
          <ul>
"

# Generate page menu
site_menu_length=$(jq '. | length' <<< "$site_menu")
cur_index=0
while [ "$cur_index" -lt "$site_menu_length" ]; do
  current_page=$(jq ".[$cur_index]" <<< "$site_menu")

  url=$(jq ".url" <<< "$current_page")
  title=$(jq -r ".title" <<< "$current_page")

  template="$template
  <li><a href=$url>$title</a></li>"
  cur_index=$((cur_index + 1))
done

Because Bash supports multi-line strings, chunks of HTML can be properly indented. Of course browsers don’t care if you put everything all on one line, but indentation makes debugging easier for humans. As for the logic, notice how it iterates over pages in the .menu node of $site_data. This is the kind stuff that can’t be done with a keyword-based search-and-replace system.

From there, the rest of the template flows like you would expect.

template="$template
          </ul>
        </nav>
      </header>
      <main>
        <h1>$page_title</h1>
        $page_content
      </main>
      <footer>
        <p>$site_footer</p>
      </footer>
    </div>
  </body>
</html>
"

echo "$template"

The list of navigation links gets closed off, the content and footer are inserted, and the result is sent back to Jank with echo.

Part 2: The new SITE and DEFAULT_TEMPLATE variables

To use the new templates, major portions of Jank had to be rewritten. The first step was defining a new global JSON object called SITE.

# Make a primary JSON object to hold site data.
# EDIT WITH CARE!
SITE='{
  "name": "RJKCodes",
  "description": "Endless jank awaits.",
  "keywords": "code, linux, computers",
  "footer": "Copyright 2026 Robert J Kight, all rights reserved",
  "pages": [],
  "menu": [],
  "blogPosts": [],
  "categories": {}
}'

The pages and blogPosts lists start off blank just like before. I aso added a blank object for page categories that I plan to use in the future. The only real difference is that they start off as part of a JSON object.

DEFAULT_TEMPLATE is a new global variable set to main.bash. I adjusted the idiot-proofing to search for it in the templates/ directory.

if [ -f templates/$DEFAULT_TEMPLATE ]; then
  echo "Found $DEFAULT_TEMPLATE"
else
  echo "Template not found!"
  exit 1
fi

Part 3: Scanning the pages… again

Before the templates are processed, the pages have to be found and scanned. I rewrote this section of Jank to make sure that every page object has a template key.

# Build list of files.
file_list=$(find "$SRC" -type f -name "*.md")

# Iterate over the output of 'find' to build the initial list of 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")

  # If the page has no template, give it the default one.
  pg_template=$(jq -r ".template" <<< "$json")
  if [ "$pg_template" == "null" ]; then
    pg_template="$DEFAULT_TEMPLATE"
  fi

  
  # Make sure the template exist.
  if [ ! -f templates/$pg_template ]; then
    echo "Template '$pg_template' specified in '$infile' DOES NOT EXIST!"
    exit 1
  fi

 
  # Add extra keys to page object.
  page_object=$(jq ". += {
    \"infile\": \"$infile\",
    \"template\": \"$pg_template\",
    \"outpath\": \"$outpath\",
    \"outfile\": \"$outfile\",
    \"url\": \"$url\"
  }" <<< "$json")

  # Add the page object to the main page list.
  SITE=$(jq ".pages += [$page_object]" <<< "$SITE")
  
done <<< "$file_list"

If the page doesn’t have a template, DEFAULT_TEMPLATE is used. If the page does have a template, the program terminates if the file doesn’t exist.

After that, the pages still get sorted and processed. This time however, they also get added to separate lists for blogposts, menu items, and categories within the $SITE JSON object.

# Sort the pages by date, newest to oldest.
sorted_pages=$(jq ".pages | sort_by(.date) | reverse" <<< "$SITE")
SITE=$(jq ".pages = $sorted_pages" <<< "$SITE")

# Sort the pages into lists.
page_array_length=$(jq '.pages | length' <<< "$SITE")
cur_index=0
while [ "$cur_index" -lt "$page_array_length" ]; do
  current_page=$(jq ".pages[$cur_index]" <<< "$SITE")
  #echo "$current_page"
  url=$(jq ".url" <<< "$current_page")
  template=$(jq ".template" <<< "$current_page")
  title=$(jq -r ".title" <<< "$current_page")
  menu=$(jq ".menu" <<< "$current_page")
  blog_post=$(jq ".blogPost" <<< "$current_page")
  description=$(jq -r ".description" <<< "$current_page")
  date=$(jq -r ".date" <<< "$current_page")
  category=$(jq -r ".category "<<< "$current_page")

  # Build up the site menu
  if [ "$menu" == "true" ]; then
    SITE=$(jq ".menu += [$current_page]" <<< "$SITE")
  fi

  # While we're here, build up the blog list.
  if [ "$blog_post" == "true" ]; then
    SITE=$(jq ".blogPosts += [$current_page]" <<< "$SITE")
  fi

  # Add the page to a category in SITE.
  if [ "$category" != "null" ]; then
    # If SITE does not already have the category, create a new empty list for it.
    if [ $(jq ".categories.$category" <<< "$SITE") == "null" ]; then
      SITE=$(jq ".categories.$category = []" <<< "$SITE")
      #echo "JSON DOES NOT have '$category'"
    fi

    # Add the page to the category.
    SITE=$(jq ".categories.$category += [$current_page]" <<< "$SITE")
  fi

  cur_index=$((cur_index + 1))
done

Part 4: The new page building function

The page building logic was moved to its own function. It gets the raw content of the source file, renders it with cmark, and passes the output to a template script. Then it writes the final output to a file.

buildPage() {
  local page="$1"
  
  local template=$(jq -r ".template" <<< "$page")
  local outpath=$(jq -r ".outpath" <<< "$page")
  local infile=$(jq -r ".infile" <<< "$page")
  local outfile=$(jq -r ".outfile" <<< "$page")
  local title=$(jq -r ".title" <<< "$page")
  
  # Tell the user what we're doing.
  echo "Working on TITLE: $title, INFILE: $infile"

  # Render content.
  raw=$(getContent "$infile")
  content=$(renderMarkdown "$raw")

  # Make the outpath.
  mkdir -p "$outpath"

  # If the default template IS NOT being used, run the specified sub-template.
  if [ "$template" != "$DEFAULT_TEMPLATE" ]; then
    echo "Using page template '$template'"
    output=$(bash "templates/$template" "$page" "$content" "$SITE")
    #bash "templates/$template" "$page" "$content" "$SITE"
    content="$output"
  else
    echo "Using default template '$DEFAULT_TEMPLATE'"
  fi
  
  # Run the final template.
  bash "templates/$DEFAULT_TEMPLATE" "$page" "$content" "$SITE" > "$outfile"
}

If a page does not have a sub-template defined, DEFAULT_TEMPLATE (main.bash in this case) is run with the output from cmark. If the page does have a template defined, it feeds the rendered Markdown to the sub-template first.

To make troubleshooting easier, the function also tells the user which page it’s working on, and what template it’s using.

And now let’s look at the function that calls buildPage(): buildPages(). It just loops over SITE.pages, feeding each one to buildPage().

# Loop over SITE.pages. 
buildPages() {
  local json_map="$1"

  local page_array_length=$(jq ".pages | length" <<< "$json_map")
  local page=""
  local cur_index=0
  while [ "$cur_index" -lt "$page_array_length" ]; do
    page=$(jq ".pages[$cur_index]" <<< "$json_map")

    buildPage "$page"

    cur_index=$((cur_index + 1))
  done
}

After building the pages, the program copies assets under $SRC/assets/ to $DEST/ like before.

# Finally, copy the CONTENTS of the assets directory, if there is one
if [ -d "$SRC/assets" ]; then
  echo "Coppying assets..."
  cp -r $SRC/assets/* $DEST/
fi

Part 5: Sub-templates

Sub-templates can now be defined within the metadata of a page like this:

 {
   "title": "New version of Jank!",
   "date": "2026-09-05",
   "description": "I turned templates into shell scripts, and removed Awk.",
   "keywords": "bash, jq, html, static",
   "blogPost": true,
   "template": "blogPost.bash" <==
 }
 +++

Here’s what the sub-template looks like:

# This template script is meant to be run by Jank.
# blogPost.bash

# Need page JSON, rendered page content, and site JSON in that order.
page_data="$1"
page_content="$2"

# Not used, but no harm in including it.
site_data="$3"

page_date=$(jq -r ".date" <<< "$page_data")


# Extract page values.
template="<p id=\"page-date\">Published on $page_date</p>
        $page_content
"

echo "$template"

Nothing fancy here. Just the page date and the content. Recall that buildPage() looks for this file, runs it, and sends the output to templates/main.bash.

...
  # If the default template IS NOT being used, run the specified sub-template.
  if [ "$template" != "$DEFAULT_TEMPLATE" ]; then
    echo "Using page template '$template'"
    output=$(bash "templates/$template" "$page" "$content" "$SITE")
    #bash "templates/$template" "$page" "$content" "$SITE"
    content="$output"
  else
    echo "Using default template '$DEFAULT_TEMPLATE'"
  fi
  
  # Run the final template.
  bash "templates/$DEFAULT_TEMPLATE" "$page" "$content" "$SITE" > "$outfile"
...

And now for the for home.bash, which generates the list of blog posts.

# This a template script is meant to be run by Jank.
# home.bash

# Need page JSON, rendered page content, and site JSON in that order.
page_data="$1"
page_content="$2"
site_data="$3"

site_blogPosts=$(jq -r ".blogPosts" <<< "$site_data")

# Extract page values.
template="$page_content
"

# Generate list of blog posts.
site_blogPosts_length=$(jq '. | length' <<< "$site_blogPosts")
cur_index=0
while [ "$cur_index" -lt "$site_blogPosts_length" ]; do
  current_post=$(jq ".[$cur_index]" <<< "$site_blogPosts")

  url=$(jq ".url" <<< "$current_post")
  title=$(jq -r ".title" <<< "$current_post")
  date=$(jq -r ".date" <<< "$current_post")
  description=$(jq -r ".description" <<< "$current_post")

  template="$template
  <section class=\"post-item\">
    <h2><a href=$url>$title</a><span class=\"post-item-date\"> &raquo; $date</span></h2>"

  if [ "$description" != "null" ]; then
    template="$template
    <p class=\"post-item-description\">$description</p>"
  fi

  template="$template
  </section>"
  cur_index=$((cur_index + 1))
done

echo "$template"

So yes, Bash makes an excellent template language when combined with jq. And before wrapping things up, I’m going to point out that there’s nothing stopping a template script from calling other scripts, or even other programs.

Source dump: jank.bash

I’ll get around to packing up Jank into a archive soon. In the meantime, here’s the full source code.

# Jank.bash by Robert J Kight
# Version 2
# Copyright Robert J Kight 2026
# License: BSD 3 clause

MD_PARSER="cmark"
SRC="src"
DEST="public"
JSON_PARSER="jq"
DEFAULT_TEMPLATE="main.bash"

# Make a primary JSON object to hold site data.
# EDIT WITH CARE!
SITE='{
  "name": "RJKCodes",
  "description": "Endless jank awaits.",
  "keywords": "code, linux, computers",
  "footer": "Copyright 2026 Robert J Kight, all rights reserved",
  "pages": [],
  "menu": [],
  "blogPosts": [],
  "categories": {}
}'

renderMarkdown() {
  local content="$1"
  echo "$content" | cmark --smart --unsafe 
}

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"
}

buildPage() {
  local page="$1"
  
  local template=$(jq -r ".template" <<< "$page")
  local outpath=$(jq -r ".outpath" <<< "$page")
  local infile=$(jq -r ".infile" <<< "$page")
  local outfile=$(jq -r ".outfile" <<< "$page")
  local title=$(jq -r ".title" <<< "$page")
  
  # Tell the user what we're doing.
  echo "Working on TITLE: $title, INFILE: $infile"

  # Render content.
  raw=$(getContent "$infile")
  content=$(renderMarkdown "$raw")

  # Make the outpath.
  mkdir -p "$outpath"

  # If the default template IS NOT being used, run the specified sub-template.
  if [ "$template" != "$DEFAULT_TEMPLATE" ]; then
    echo "Using page template '$template'"
    output=$(bash "templates/$template" "$page" "$content" "$SITE")
    #bash "templates/$template" "$page" "$content" "$SITE"
    content="$output"
  else
    echo "Using default template '$DEFAULT_TEMPLATE'"
  fi
  
  # Run the final template.
  bash "templates/$DEFAULT_TEMPLATE" "$page" "$content" "$SITE" > "$outfile"
}

# Loop over SITE.pages. 
buildPages() {
  local json_map="$1"

  local page_array_length=$(jq ".pages | length" <<< "$json_map")
  local page=""
  local cur_index=0
  while [ "$cur_index" -lt "$page_array_length" ]; do
    page=$(jq ".pages[$cur_index]" <<< "$json_map")

    buildPage "$page"

    cur_index=$((cur_index + 1))
  done
}

# 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 templates/$DEFAULT_TEMPLATE ]; then
  echo "Found $DEFAULT_TEMPLATE"
else
  echo "Template not found!"
  exit 1
fi

# Build list of files.
file_list=$(find "$SRC" -type f -name "*.md")

# Clear the destination directory.
rm -rf $DEST/*

# Iterate over the output of 'find' to build the initial list of 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")

  # If the page has no template, give it the default one.
  pg_template=$(jq -r ".template" <<< "$json")
  if [ "$pg_template" == "null" ]; then
    pg_template="$DEFAULT_TEMPLATE"
  fi

  
  # Make sure the template exist.
  if [ ! -f templates/$pg_template ]; then
    echo "Template '$pg_template' specified in '$infile' DOES NOT EXIST!"
    exit 1
  fi

 
  # Add extra keys to page object.
  page_object=$(jq ". += {
    \"infile\": \"$infile\",
    \"template\": \"$pg_template\",
    \"outpath\": \"$outpath\",
    \"outfile\": \"$outfile\",
    \"url\": \"$url\"
  }" <<< "$json")

  # Add the page object to the main page list.
  SITE=$(jq ".pages += [$page_object]" <<< "$SITE")
  
done <<< "$file_list"

# Sort the pages by date, newest to oldest.
sorted_pages=$(jq ".pages | sort_by(.date) | reverse" <<< "$SITE")
SITE=$(jq ".pages = $sorted_pages" <<< "$SITE")

# Sort the pages into lists.
page_array_length=$(jq '.pages | length' <<< "$SITE")
cur_index=0
while [ "$cur_index" -lt "$page_array_length" ]; do
  current_page=$(jq ".pages[$cur_index]" <<< "$SITE")
  #echo "$current_page"
  url=$(jq ".url" <<< "$current_page")
  template=$(jq ".template" <<< "$current_page")
  title=$(jq -r ".title" <<< "$current_page")
  menu=$(jq ".menu" <<< "$current_page")
  blog_post=$(jq ".blogPost" <<< "$current_page")
  description=$(jq -r ".description" <<< "$current_page")
  date=$(jq -r ".date" <<< "$current_page")
  category=$(jq -r ".category "<<< "$current_page")

  # Build up the site menu
  if [ "$menu" == "true" ]; then
    SITE=$(jq ".menu += [$current_page]" <<< "$SITE")
  fi

  # While we're here, build up the blog list.
  if [ "$blog_post" == "true" ]; then
    SITE=$(jq ".blogPosts += [$current_page]" <<< "$SITE")
  fi

  # Add the page to a category in SITE.
  if [ "$category" != "null" ]; then
    # If SITE does not already have the category, create a new empty list for it.
    if [ $(jq ".categories.$category" <<< "$SITE") == "null" ]; then
      SITE=$(jq ".categories.$category = []" <<< "$SITE")
      #echo "JSON DOES NOT have '$category'"
    fi

    # Add the page to the category.
    SITE=$(jq ".categories.$category += [$current_page]" <<< "$SITE")
  fi

  cur_index=$((cur_index + 1))
done

buildPages "$SITE"
#echo "$SITE"

# Finally, copy the CONTENTS of the assets directory, if there is one
if [ -d "$SRC/assets" ]; then
  echo "Coppying assets..."
  cp -r $SRC/assets/* $DEST/
fi

echo "DONE"