A Python-based static site generator using Jinja templates.
Inspired by staticjinja, jekyll, and hugo.
Requires Python 3.11 or newer.
pip install jinjabreadpython -m jinjabread new mysitepython -m jinjabread build mysitepython -m jinjabread serve mysite
# Visit http://127.0.0.1:8000 in your browser.- Write pages in Markdown, HTML, or text.
- Use Jinja2 templating language in Markdown, HTML, or text.
- Supports YAML metadata in Markdown pages.
- Keep static media alongside static pages.
- Index pages (i.e.,
index.html) can list all other pages in the same directory. - Preview your static site locally with a built-in web server.
- Prettify all generated HTML (because why not?)
With prettify_html enabled (the default), jinjabread re-indents every generated page for readability. It is a rendering-preserving reformatter rather than a byte-preserving one, and it makes a specific contract:
- It preserves how the page renders. The output displays identically to the input: inline elements are never broken across lines, significant whitespace is kept, and
pre,textarea,script, andstylecontent is emitted verbatim. Only insignificant whitespace between block-level elements changes. - It is idempotent. Prettifying already-pretty output changes nothing.
- It ends every file with a single newline. Non-empty output is terminated with exactly one
\n, following the POSIX convention that a text file's last line ends in a newline. Empty input stays empty. - It adds no wrapper of its own. A body-level fragment, such as several top-level
<p>elements or plain text, is emitted as-is and is never wrapped in a<div>or<span>. - It parses and repairs markup. Every page round-trips through lxml's HTML parser, so unclosed tags are closed and misnested tags are corrected, matching how a browser reads the input. The output is always well-formed HTML; invalid or non-standard structure is not preserved verbatim.
- It distinguishes documents from fragments. A whole document, or a document-level element such as a lone
<script>or<head>, is emitted as a complete document with an HTML5 doctype; a body-level fragment is emitted without a doctype or wrapper.
| Name | Description | Example path |
|---|---|---|
| Project directory | The project root | mysite |
| Content directory | Contains site content where each file becomes a site page | mysite/content |
| Layouts directory | Contains page layouts that gets used by the site content | mysite/layouts |
| Static directory | Contains static media that gets copied to the output directory | mysite/static |
| Output directory | The complete generated site, ready to be hosted | mysite/public |
| Config file | Custom site configurations in TOML format | mysite/jinjabread.toml |
mysite/
├── content
│ ├── about.html
│ ├── index.html
│ └── posts
│ ├── index.html
│ ├── my-journey
│ │ ├── index.html
│ │ └── profile-photo.jpg
│ └── my-story.html
├── jinjabread.toml
├── layouts
│ └── markdown.html
├── public
│ ├── about.html
│ ├── index.html
│ ├── posts
│ │ ├── index.html
│ │ ├── my-journey
│ │ │ ├── index.html
│ │ │ └── profile-photo.jpg
│ │ └── my-story.html
│ └── static
│ └── style.css
└── static
└── style.css
| File path | URL path |
|---|---|
public/index.html |
/ |
public/about.html |
/about |
public/posts/index.html |
/posts/ |
public/posts/my-story.html |
/posts/my-story |
public/posts/my-journey/index.html |
/posts/my-journey/ |
public/posts/my-journey/profile-photo.jpg |
/posts/my-journey/profile-photo.jpg |
public/static/style.css |
/static/style.css |
content_dir = "content"
layouts_dir = "layouts"
static_dir = "static"
output_dir = "public"
prettify_html = true
[context]
[[pages]]
type = "jinjabread.MarkdownPage"
glob_pattern = "**/*.md"
layout_name = "markdown.html"
[[pages]]
type = "jinjabread.Page"
glob_pattern = "**/*"# jinjabread.toml
output_dir = "dist"# jinjabread.toml
[context]
site_name = "My site"
url_origin = "https://mysite.com"# jinjabread.toml
[[pages]]
type = "jinjabread.MarkdownPage"
glob_pattern = "**/*.md"
layout_name = "post.html"
[[pages]]
type = "jinjabread.Page"
glob_pattern = "**/*"# jinjabread.toml
[[pages]]
type = "jinjabread.MarkdownPage"
glob_pattern = "**/*.md"
layout_name = "markdown.html"
[[pages]]
type = "jinjabread.Page"
glob_pattern = "**/*.txt"
[pages.context]
foo = "bar"
[[pages]]
type = "jinjabread.Page"
glob_pattern = "**/*"| Page type | Keyword arguments |
|---|---|
jinjabread.Page |
- glob_pattern |
jinjabread.MarkdownPage |
- glob_pattern - layout_name |
Markdown content supports full YAML metadata.
For example, given the following content and layout:
---
# content/my-blog-post.md
title: My blog post
author: John Smith
description: A very nice story.
keywords:
- thrilling
- must-read
---
It was a cold stormy night...<!-- layouts/markdown.html -->
<h1>{{ title }}</h1>
<h2>{{ description }}</h2>
<h3>Written by {{ author }}</h3>
{{ content }}Results in the following output:
<!-- public/my-blog-post.html -->
<h1>
My blog post
</h1>
<h2>
A very nice story.
</h2>
<h3>
Written by John Smith
</h3>
<p>
It was a cold stormy night...
</p>All pages have these context variables:
| Name | Description | Example value |
|---|---|---|
url_path |
The URL path of the current page | / |
file_path |
The file path of the current page, relative to the output directory | index.html |
Page-specific context variables override global site context variables.
For example:
# jinjabread.toml
[context]
fee = "fie"
foe = "fum"
[[pages]]
type = "jinjabread.Page"
glob_pattern = "**/*.txt"
[pages.context]
foe = "foo"
[[pages]]
type = "jinjabread.Page"
glob_pattern = "**/*.html"
[pages.context]
foe = "bar".txtpages will have the following extra context variables:fee = "fie" foe = "foo"
.htmlpages have the following extra context variables:fee = "fie" foe = "bar"
All index pages (i.e., index.*) has an extra context variable named pages which is list of dictionaries of context variables from its sibling files.
For example, given the following file structure:
mysite/content/posts/
├── index.html
├── post1.md
└── post2.md
You can list all the pages in the posts directory using:
<!-- mysite/content/posts/index.html -->
{% for page in pages %}
<a href="{{ page.url_path }}">
{{ page.url_path.split('/') | last | title }}
</a>
{% endfor %}Resulting in:
<!-- mysite/public/posts/index.html -->
<a href="/posts/post1">Post1</a> <a href="/posts/post2">Post2</a>Install uv (https://docs.astral.sh/uv/getting-started/installation/), then:
uv syncuv run python -m unittestuv buildexport TWINE_USERNAME='__token__'
export TWINE_PASSWORD='secret-token'
uv run twine upload dist/*