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.
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.