JOPA

Simple thus hackable static site generator

Documentation

Jopa [ˈʐopə] is a very small, but powerful static website generator written in bash. It uses a couple of conventions to make writing stuff fun and easy.

The main idea is to [ab]use env variables to do both configuration of a website and also its generation. Everything in Jopa world is a bash script - including posts you write, layouts and, of course, ./jopa itself.

  1. Quickstart
  2. Internals
  3. Code
  4. Extending Jopa
  5. License

To start using Jopa, you first need to make sure you have prerequisites installed. Chances are you already have everything on your system. Jopa uses bash >= 3 and envsubst from the gettext-base package. If these are not installed, go do, I'll wait.

Okay, now, when dependencies are ready, you need to grab the actual Jopa code. You can grab the code directly from GitHub or just copy-paste it from the code page. Put the code in a file named jopa and do not forget to make it executable:

chmod +x ./jopa

Now, you need to create layout.jsh file, where the base layout for all your pages will be defined. Something like below, I'll use the simplest possible HTML5 page there and you can extend it later.

cat <<'EOF' > layout.jsh

website_title="My wonderful website"

multiline layout << 'JOPA'
<!doctype html>
<meta charset=utf-8>
<title>${title} | ${website_title}</title>
${content}
JOPA

EOF

Okay, let's jump to the fun stuff and create the first page of your. future website The page will be located in pages/ directory, so we. need to create one beforehand .

mkdir -p pages

cat <<'EOF' > pages/index.jsh

title="My first page"

multiline content << 'JOPA'
Hello world!
<br>
I am a happy Jopa user.
JOPA

EOF

Okay, we all set, let's just ask Jopa to do its work:

./jopa

Done! Jopa created your page in www folder, you can now open it in your browser:

open www/index.html

Congratulations! You are now an official Jopa blogger. You can now create more pages, edit example layout template, or jump into more advanced topics.

Deploy to GitHub Pages with GitHub Actions

To build and deploy your site automatically on GitHub Pages, add just these two steps to your workflow:

# on other systems, the install command may differ
- run: sudo apt-get install -y gettext
- uses: neoascetic/jopa@master

That's it! The action will take care of everything else.

The idea of Jopa is extendability through simplicity. Everything in Jopa's world is a bash script, and almost everything is controlled through the use of environment variables.

This is done for you to be in control of what is going on and be able to apply any logic at any step of site generation. You can do anything bash scripts can, and modify your content as you wish - no need for unnecessary plugins, libs, yadda-yadda-yadda. Just you and your shell - grep, cat and cut, anything.

But how does Jopa actually work?

Pages

First, it goes through the list of pages, specified in $pages variable, and, to render them, does source them as a bash script. This leads the environment for further processing. The sourcing is done on a per-page scope, to avoid interference.

By default, $pages equals to pages/*.jsh wildcard. The extension is short for "Jopa shell", to differ your posts from regular shell scripts (remember, everything is a script).

Pages can have any arbitrary logic, but most of the time they just define metadata such as title, description and, obviously, the actual page content. To make text easier to write, Jopa defines multiline helper with an optional "filter" that can be used to transform content from one format to another (we are generating a website, after all, so we probably want the content to be in HTML). Here is an example of a page:

title="My wonderful page"

multiline markdown content << 'JOPA'

# Hello World!

Just my blog post, how are you?

JOPA

Here, we define two env variables: $title and $content (names are arbitrary), but $content is defined with multiline helper that passes text through markdown utility (which you should install independently) to generate HTML content. Notice << 'JOPA' on the line with multiline call and JOPA at the very end. These are so-called "heredocs" and in bash to define multiline strings. You can read about them somewhere else, just note, that most of the time you want the first delimiting identifier to be in single quotes, otherwise your text is subject of command evaluation which is a dangerous thing... Imagine the following page:

multiline content << JOPA

Don't do $(rm -rf ~/) in your scripts!

JOPA

You probably don't want this to be executed... But sometimes you do want the evaluation in these strings (for example, for in-line variable expansion), okay, it's up to you. Just be warned.

Another note is that you can use any thing as your delimiter, even Emojis. Isn't this fun?

Layouts

Next, after a page is sourced env variables are (re)defined, but if $layout is not defined yet while $layout_file is, the latter is also sourced. The script in this file must define $layout (and can do this conditionally). Again, you can have any logic, for example, have defaults for $title:

website_title="My Website"

# prepend website title to any page's title
if [[ "$title" ]]; then
  head_title="$title | $website_title"
else
  head_title="$website_title"
fi

# define mandatory layout variable
multiline layout << 'JOPA'

<!doctype html>
<meta charset=utf-8>
<title>${head_title}</title>
<h1>${title}</h1>
${content}

JOPA

Then, the string in $layout is used as a template to render the resulting file using envsubst utility, which basically replaces all variables in the template with their actual values. Logic-less templates you say? Kind of.

Well, is that simple.

Indexes

But here is one more thing. What about the index page, table of contents, Atom/RSS feeds and all alike? We need to display links to our wonderful pages somehow, in most cases on the main page - the website's index. For this purpose, there are indexes and the each helper.

Indexes are regular pages, except that they are processed separately from other pages and, obviously, are not "indexed", i. e. will not be present in the resulting list of pages. You must specify indexes in space-separated $indexes env variable, for example:

# add Table Of Contents page to the list of indexes
indexes="$indexes $from/toc.jsh"

By default, only $from/index.jsh is in this list.

Below is an example of such a page:

multiline indexer_main << 'JOPA'
  <li>
    <a href="/${target}">${title}</a>
  </li>
JOPA
index_main="$(each "$pages" render "$indexer_main")"

multiline content << JOPA
  My posts:
  <ol>${index_main}</ol>
JOPA

Here, we have regular page content defined, but it uses $index_main variable, which is defined as the result of each function call on a set of $pages, and each page is then fed into render "$indexer_main" function, i. e. rendered with the defined template. You also see that $target variable is used - this is a file name for a page being processed and you can use it to refer to the page.

Of course, instead of calling render you can call your own function which can do filtering, skip some pages you don't want to be displayed, and only then call render from.

Hope this sheds some light on how this pretty simple thing is working for you to be able to move to the next (but optional!) step - extending Jopa.

Development of Jopa happens on GitHub, but you don't need to go there to grab the code. Here is the full Jopa code, just 62 lines:

#!/usr/bin/env bash

export from=${from-"$PWD/pages"}
export to=${to-"$PWD/www"}
export ext=${ext-".html"}
export indexes=${indexes-"$from/index.jsh"}
export layout_file=${layout_file-"layout.jsh"}
shopt -s nullglob
export pages=${pages-"$from/*.jsh"}

multiline() { # multiline [markdown] varname << 'JOPA'
  if [[ "$#" -eq 2 ]]; then 
    read -r -d '' "${@:2}"
    read -r -d '' $2 <<< "$($1 <<< "${!2}")"
  else
    read -r -d '' "$@"
  fi
}

page_id() { echo "_page_env_${1//[^a-zA-Z0-9]/_}"; }
read_page() {
  set -a
  src="${1##*/}"
  target="${src%.*}$ext"
  source "$1" >&2
}
store_page() { printf -v $(page_id $1) '%s' "$(read_page $1; declare -px)"; }
load_page() { local id=$(page_id $1); echo "${!id}"; }

each() {
  for p in $1; do
    (set -a; eval "$(load_page $p)"; "${@:2}")
  done
}

render() { envsubst <<< "$1"; }

to_layout() {
  [[ ! "$layout" && -f "$layout_file" ]] && source "$layout_file" >&2
  render "$layout" > "$to/$target"
}

process() { each "$pages $indexes" to_layout; }

jopa() {
  mkdir -p "$to"
  pages="$(for p in $pages; do
             for i in $indexes; do [[ "$p" -ef "$i" ]] && continue 2; done
             echo $p
           done)"
  for p in $pages; do store_page $p; done
  for i in $indexes; do store_page $i; done
  process
}

if [[ "$0" = "$BASH_SOURCE" ]]; then
  if [[ "$#" -ne 0 ]]; then
    printf "$0 does not accept any arguments; use env variables instead"
    exit 1
  fi
  jopa
fi

Other site generators looking at Jopa's source code

Jopa's true power lies not just in its simplicity, but in its extensibility. Since everything in Jopa is a bash script, you're essentially in control of every aspect of site generation. This means you can modify, extend, or completely transform Jopa to fit your needs without touching the core code. Want to use Markdown instead of bash scripts? No problem. Need to add custom processing? Easy. The possibilities are endless.

In this article, we'll explore how to extend Jopa to work with plain Markdown files while keeping all the power and flexibility of the original approach. We'll see how a few lines of code can transform Jopa into a Markdown-powered static site generator that still uses the same layout system and generation logic. This is the beauty of Jopa's design - you can change the input format without changing the core architecture.

Creating a Markdown Extension

To extend Jopa for Markdown support, we'll create a separate script called extended-jopa. This approach keeps the original Jopa untouched while giving us full control over the extension.

The basic structure is simple - we source the original Jopa and then override the functions we need:

#!/usr/bin/env bash

# Override pages to look for .md files instead of .jsh
export pages="pages/*.md"

# Source the original Jopa
source jopa

# Now we can override functions as needed...

# Call the main function
jopa

Adding Markdown Processing

Now let's add the actual Markdown processing functionality. We need to override the read_page() function to handle .md files differently:

Overriding read_page()

The read_page() function is the heart of Jopa's page processing. It's responsible for reading a file and setting up the environment variables that will be used during rendering. In the original Jopa, this function sources each .jsh file as a bash script, which populates variables like title, content, and description.

By overriding this function, we can change how different file types are processed. Our version checks if the file is a Markdown file (based on its extension) and handles it differently from regular .jsh files - by calling slurp_markdown function, which we will cover next.

# Override read_page to handle Markdown files
read_page() {
  set -a
  src="${1##*/}"
  target="${src%.*}$ext"
  if [[ "${src##*.}" == "md" ]]; then
    slurp_markdown $1
  else
    source "$1" >&2
  fi
}
Creating slurp_markdown()

The slurp_markdown() function is where the real magic happens. This function takes a Markdown file and transforms it into the same environment variables that Jopa expects - title, description, content, etc.

The function works in two phases. First, it parses the YAML front matter at the top of the file (the metadata section between --- markers). This is where you define variables like title and description. Second, it takes the remaining Markdown content and converts it to HTML using the redcarpet tool.

Note: Before using this extension, you'll need to install the redcarpet Ruby gem. On Ubuntu/Debian systems, you can install it with:

sudo apt install ruby-redcarpet

The parsing uses a simple state machine with three modes. Mode 1 looks for the start of YAML front matter, Mode 2 reads the YAML metadata and converts it to bash variables, and Mode 3 collects the actual Markdown content. Think of it as a very patient robot reading your file line by line, trying to figure out what you want.

# Function to parse Markdown with YAML front matter
slurp_markdown() {
  local mode=1  # Start in mode 1: looking for first ---

  while IFS= read p; do
    if [[ $mode -eq 3 ]]; then
      # Mode 3: collecting Markdown content
      printf -v content '%s\n%s' "$content" "$p"
    elif [[ $mode -eq 2 && $p != "---" ]]; then
      # Mode 2: parsing YAML metadata (skip the closing ---)
      read var val <<< "$p"
      printf -v "${var%:}" '%s' "$val"  # Remove colon and set variable
    elif [[ $p == "---" ]]; then
      # Found a --- marker, move to next mode
      ((mode++))
    else
      # No YAML front matter found
      echo "No YAML front matter detected"
      exit 1
    fi
  done < $1

  # Convert Markdown content to HTML using redcarpet
  printf -v content '%s' "$(redcarpet --smarty --parse-fenced-code-blocks <<< "$content")"
}
Complete extended-jopa Script

Here's the complete extended-jopa script that combines everything:

#!/usr/bin/env bash

export pages="pages/*.md"

source jopa

read_page() {
  set -a
  src="${1##*/}"
  target="${src%.*}$ext"
  if [[ "${src##*.}" == "md" ]]; then
    slurp_markdown $1
  else
    source "$1" >&2
  fi
}

slurp_markdown() {
  local mode=1
  while IFS= read p; do
    if [[ $mode -eq 3 ]]; then
      printf -v content '%s\n%s' "$content" "$p"
    elif [[ $mode -eq 2 && $p != "---" ]]; then
      read var val <<< "$p"
      printf -v "${var%:}" '%s' "$val"
    elif [[ $p == "---" ]]; then
      ((mode++))
    else
      echo "No YAML front matter detected"
      exit 1
    fi
  done < $1
  printf -v content '%s' "$(redcarpet --smarty --parse-fenced-code-blocks <<< "$content")"
}

jopa

Using the Extension

Now that we have our extended-jopa script, let's see how to use it. First, save the script as extended-jopa and make it executable:

chmod +x extended-jopa

Create a Markdown file in your pages/ directory with YAML front matter:

---
title: My First Markdown Page
description: This is a test page written in Markdown
---

# Welcome to My Site

This is a **Markdown** page that will be processed by our Jopa extension.

## Features

- Supports GitHub Flavored Markdown
- YAML front matter for metadata
- Same layout system as regular Jopa
- Code blocks with syntax highlighting

```bash
echo "Hello, Jopa!"
```

Then run the extension:

./extended-jopa

The script will process all .md files in your pages/ directory and generate HTML files in the www/ directory, just like the original Jopa but with Markdown support. It's like giving Jopa a new superpower without changing its personality.

The Power of Extension

This example demonstrates the true power of Jopa's design. By creating a simple wrapper script, we've transformed Jopa from a bash-script-based generator into a Markdown-powered static site generator, all without modifying the core code. The same layout system, the same generation logic, the same flexibility - just a different input format.

You can apply this same principle to extend Jopa in countless other ways. Want to support AsciiDoc? Just add AsciiDoc processing to your extended-jopa script. Need to process JSON files? Add JSON parsing functions to the same script. The possibilities are limited only by your imagination and bash scripting skills.

This is the beauty of Jopa's simplicity - it's not just a tool, it's a framework for building your own static site generators.

Have fun!

           DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
                   Version 2, December 2004
 
Copyright (C) 2004 Sam Hocevar 

Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
 
           DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
  TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION

 0. You just DO WHAT THE FUCK YOU WANT TO.