ssh git@grit.deadkey.org man

mailmanpat/jenny · 2026-09-06 09:36:46 +08002026-9-6 +8git clone …

FILES / README.md

NameLast CommitTimestampSize
.github ad9c389 h test: replace the cloned-blog CI job with a committed fixture 26-08-24 11:38 -
bin 5479d3a h Take the markdown parser as a submodule instead of vendoring it 26-09-05 10:06 -
share 4853a27 h Sit the theme control on the header line, without a box 26-09-06 09:36 -
test b70d686 h Take the parser's escape-by-default contract 26-09-05 10:21 -
.gitignore b4efcdc h chore: do not track agent worktrees 26-08-25 11:02 174 B
.gitmodules 5479d3a h Take the markdown parser as a submodule instead of vendoring it 26-09-05 10:06 138 B
.shellcheckrc e11b323 h ci: add test workflow, shellcheck baseline, auditable image, VERSION 26-08-20 09:50 818 B
Dockerfile e11b323 h ci: add test workflow, shellcheck baseline, auditable image, VERSION 26-08-20 09:50 1 KB
LICENSE.txt 1e61a7e h Update license 21-04-06 21:58 1 KB
Makefile e11b323 h ci: add test workflow, shellcheck baseline, auditable image, VERSION 26-08-20 09:50 734 B
README.md 5a5d303 h Name a nav in its own file, count what it hides, label the tag row 26-08-26 13:20 12 KB
VERSION e11b323 h ci: add test workflow, shellcheck baseline, auditable image, VERSION 26-08-20 09:50 6 B
action.yml e11b323 h ci: add test workflow, shellcheck baseline, auditable image, VERSION 26-08-20 09:50 452 B

Jenny

Jenny is a static blog generator. Its aim is to work with minimal requirements and a small footprint while providing some modern conveniences.

  • [x] Lightweight default theme
  • [x] Markdown with Footnotes support, care of a modified md2html.awk
  • [x] Basic pagination with fixed page numbers
  • [x] Plug your own Markdown parser
  • [x] Heredocs-based template syntax
  • [x] Draft/ignore support (by leaving out the date)
  • [x] Forward-posting, i.e. ignores posts with dates in the future
  • [x] Tags support
  • [x] Modifiable installation prefix
  • [x] Define run-time settings in command arguments
  • [x] Include static directories in build
  • [x] Unique URL slug generation
  • [x] Render only changed or new posts
  • [x] RSS/Atom feed
  • [x] Tests
  • [x] Navs: page-declared navigation and theme-baked site navs

publish on gh packages

Getting started, four ways

1. You can install jenny to your local bin folder

git clone https://github.com/hmngwy/jenny.git
make install

2. Or, skip installation and use Docker, all references to the command jenny is interchangeable with the below

docker run -it -v $PWD:/blog ghcr.io/hmngwy/jenny:latest

The Docker image contains Multimarkdown 6 so you can have MARKDOWN_COMMAND="multimarkdown " in your .blogrc.

3. Or, as a Github Action to automatically build your blog

This repository is also the Github Action source, simply reference it in your Worflow steps, below is an example.

on: [push]

jobs:
  build_job:
    runs-on: ubuntu-latest
    name:  Build with Jenny
    steps:
    - uses: actions/checkout@v2
    - uses: hmngwy/jenny@master

4. Or, fork this template repo

You can fork the hmngwy/jenny-template repo to start using jenny without installing a thing.


Setting up a Blog

Prepare the directory for your blog posts.

mkdir ./blog && cd blog

Use the subcommand below to initialize your blog folder with a .blogrc file and a sample first post.

jenny init

Creating a Blog Post

A dated filename will be recognized as a published post, everything else is a draft and ignored, the format is:

YYYY-MM-DD a-published-post.md

Create a file with a date so that Jenny recognizes it as a published post.

cat <<EOT >> "$(date +%Y-%m-%d) hello-world.md"
# Hello World

Jenny is a static blog generator using bash, sed, and awk.
EOT

Building the Blog

Run jenny on your blog directory.

CLI Arguments

To override .blogrc settings at run-time use command line arguments. Other options are also available, use jenny -h to display the message below:

jenny usage:
   -d          ./dir   Override the build directory setting
   -l          ./dir   Override layout directory setting
   -p          10      Override posts per page setting
   -m          mmd     Override the markdown parser command setting
   -v                  Display more information during building
   -n                  Ignore the .bloglock file when building
   -c                  Empty out the build folder prior to building
   -f                  Force a full rebuild without emptying the lock
   -h                  Show this help message
   --version   Show the version and exit

Handy Shortcuts

# Publish file with current date
jenny publish filename.md

# Edit file without typing date/full filename
jenny edit partial-filename

# Serve the build directory at http://localhost:8000/ (port is optional)
jenny serve
jenny serve 9000

jenny serve roots the server at DIST, so relative links behave exactly as they will once deployed. It uses python3 -m http.server, falling back to python 2 or busybox.

Customize defualt behaviors with .blogrc settings

To configure posts per page:

POSTS_PER_PAGE=10

To use your own markdown parser, set MARKDOWN_COMMAND:

MARKDOWN_COMMAND=multimarkdown

If the parser exits non-zero, or produces no output for a non-empty post, the build fails loudly rather than writing an empty page.

To cap the number of items in the feed (default 20):

FEED_MAX_ITEMS=20

To control how post URLs are written, set URL_STYLE:

URL_STYLE=html            # /post/slug.html  (default)
URL_STYLE=extensionless   # /post/slug

html works on any static server, including jenny serve and a bare nginx.

extensionless suits hosts that treat the extensionless path as canonical — Cloudflare Pages, Netlify, Vercel. Those hosts permanently redirect /post/slug.html to /post/slug, so leaving the default there costs every internal link a redirect hop.

The file written to disk always keeps .html in either mode; those hosts resolve /post/slug by looking for slug.html. Do not use extensionless on a server that serves files literally — the links will 404.

To run a script after the build process, write a post_hook function in .blogrc:

function post_hook() {
  echo "Done!"
}

To set a different build folder, set DIST:

DIST=~/blog/.dist

To copy directories into the build folder:

STATIC_DIRS="images files"

The 404 page

Every build writes a 404.html. Static hosts that resolve extensionless URLs — Cloudflare Pages, Netlify, Vercel — serve index.html for any path they cannot match when no 404 page exists, so a typo returns HTTP 200 with a copy of your front page.

It is rendered from layout/404.sh like any other page, so it picks up your blog title. A custom LAYOUT_DIR without a 404.sh simply does not get one — jenny will not inject a page whose styling would not match your theme. Copy the shipped one into your layout directory to customise it.

Post Formatting

Titles are Markdown H1s. Multimarkdown footnotes syntax supported in the vendored parser.

To use tags add the below into a post where tagname is both filename and URL friendly:

tags: tagname anothertag

Jenny supports two kinds of navigation:

Page-declared navs are content: a page declares which nav it belongs to, and that nav appears on the page. Use these to show structure within a section (e.g. a tutorial's chapters).

Nav: tutorial-toc

# Chapter 1
...

The nav file navs/tutorial-toc.tsv is tab-separated labels and site-root-relative paths:

Introduction	/tutorial/intro
Chapter 1	/tutorial/ch1
Chapter 2	/tutorial/ch2

Template navs are presentation: a layout renders a nav without any page mentioning it, so the same links appear on every page without each page asking for them.

The default theme ships render_footer_nav, which reads navs/site-footer.tsv, but does not call it — the default theme renders no footer. It is kept as a worked example of the mechanism: call it from your own layout, or write your own reader, to put a theme-declared nav on every page.

Comments (#) and blank lines are ignored, so you can annotate a nav file without the notes appearing on the page:

# reviewed 2026-08; drop the legal links when the new ones land
Home	/
About	/about

Naming a nav

A line beginning with ### is the nav's own name. The default theme shows it on the collapsed nav on a phone, alongside a count of the entries behind it; without one it says "Contents".

### Study Contents
Introduction	/study/intro
Method	/study/method

A file names itself once. A second ### is ignored — a file naming itself twice is being edited, not renamed, and taking the last one silently would hide the duplicate.

Sections

A line beginning with ## is a section: a label that names the group of entries under it. Sections are flat — a section groups the entries that follow it and sections do not contain other sections.

## Site structure
Home	/
About	/about

## Legal
Privacy	/privacy
Terms	/terms

## rather than # so that existing nav files do not change meaning: comments were always dropped, and turning every one of them into a visible heading would change what already-written navs render.

Nesting

An entry indented by two spaces is a child of the entry above it. Nesting applies to entries, not sections, and can go as deep as you like:

## Guide
Overview	/guide
  Setup	/guide/setup
    Requirements	/guide/setup/reqs
  Usage	/guide/usage
Back at top	/top

Depth comes from each line's own indentation, so an entry returns to any level simply by being indented that far. Indent with spaces, not tabs — tab is the field separator between a label and its path.

The default theme indents each level and stops adding indent past the third, so a deep entry stays inside the sidebar column rather than running out of it. A theme that wants a different treatment can style .nav-depth-0 through .nav-depth-3, or ignore depth entirely.

What a nav means

A nav carries no meaning of its own. It is an ordered list of names and places, with grouping and depth attached; what to do with that is the template's decision. The default page layout renders it as a sidebar with section labels and indented children. The footer nav in the same file reads the same structure and deliberately flattens it, because a footer is a row of links.

Customizing template navs

If you customize the layout, you can add template navs to your own templates. Source the nav helper and call read_nav with --optional:

source "${JENNY_LIB_DIR}/nav.sh"

my_nav=$(read_nav "${NAVS_DIR}" "my-nav" --optional)

Note: a typo in a nav name (e.g., "my-nav" vs. "my-navv") will silently render nothing, not fail the build. Validate nav names during theme development.

Theme Customization

From the project folder, copy the layout folder contents and modify to your liking, mind the template tags.


Let Jenny know where to find your customized layout files.

cat <<EOT >> .blogrc LAYOUT_DIR=~/blog/.layout EOT


## Plugs 🔌

Sites built with `jenny`.

Me - [@hmngwy](https://github.com/hmngwy): [manilafunctional.com](https://manilafunctional.com/)<sup>[src](https://github.com/hmngwy/blog)</sup>

Send a PR my way if you want to be on here. 


## Alternative Installation Modes

To soft install or symlink into your bin folder, good for contributing/development: 

sudo make sym-install [PREFIX=$PWD][prefix=$pwd]


To install into a custom location do: ```make install [PREFIX=~/.local]```

To uninstall, in the project folder run: ```make uninstall```

## Credits

- Layout inspired by n-o-d-e.net
- Some colors from Solarized by Ethan Schoonover
- Makefile inspired by [moebiuseye/skf](https://github.com/moebiuseye/skf)

## Running the tests

The suite is plain bash -- no test framework to install. It runs against the
working tree, so no `make install` is needed first.

test/run.sh


| file | covers |
| --- | --- |
| `test/units.sh` | `helpers.sh` functions in isolation |
| `test/smoke.sh` | init, build, and the overall output structure |
| `test/regressions.sh` | one case per fixed bug |

`test/lib/assert.bash` also provides a link checker: it walks every generated
HTML file, extracts each `href`/`src`, and stats the target with `DIST` as the
document root. Both the smoke and regression suites assert that no link is
broken.

CI runs the suite on Linux with gawk, on Linux with mawk (stricter about
dynamic regexes), and on macOS, plus a bash 3.2 leg for the system bash that
ships there.

## License

MIT License

Copyright (c) 2024

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE