A Python-based static site generator for building sites from Markdown and JSON files
Project description
Shodo Static Site Generator 🪶✒️📜
Shodo is a framework for rapidly building a static site from markdown files, json, and Jinja templates. Simply make changes to your site in the src directory, run the build command, and access the build in the dist directory. Easily deploy to Netlify in just a few clicks.
Check out shodo.dev for the latest project updates and documentation!
Why Shodo?
There is no shortage of options out there for building websites and apps, but they can quickly feel overcomplicated when all you need is a simple website with a few reusable components, or a quick solution to setting up a blog with an RSS feed. The goal of Shodo is to make publishing content to the web as simple and elegant as possible for developers, whether it's a personal blog, a portfolio, documentation, or a professional marketing site.
Key Features:
- ✅ Write content in Markdown with front matter support
- ✅ Powerful Jinja2 templating with custom API functions
- ✅ Query JSON data with filtering, sorting, and pagination
- ✅ Automatic page generation from markdown articles
- ✅ Built-in pagination for article listings
- ✅ RSS/Atom feed generation support
- ✅ Nested layouts and partial templates
- ✅ Fast build times with automatic asset compilation
Getting Started
Installing the package
-
Create a new project directory and start a virtual environment using your preferred method
-
Install the
shodo_ssgpackage by running one of the following commands:
Via pip:
pip install shodo-ssg
Via pipenv:
pipenv install shodo-ssg
Via Poetry:
poetry add shodo-ssg
Via uv:
uv add shodo-ssg
- Once the package is installed, you can scaffold a new project using the command
start-shodo-project <name of project directory>
To create the project in the current directory, run
start-shodo-project .
- Build the starter site and serve it to localhost by running the following command from the root directory of the project:
python serve.py
You should now be able to view the site on localhost and can start by making changes to home.jinja. When you simply want to build the static site, run the following command from the root directory:
python site_builder.py
and you can find your static site located in the dist/ directory
How it works
First, there is the main home page template located at src/theme/views/home.jinja that can render partial sub-views, which can either be other Jinja2 templates located in src/theme/views/partials, or markdown files located in src/theme/markdown.
Templates
This project uses Jinja2 as its templating engine, so it would be beneficial to visit the Jinja docs. This project leverages Jinja to integrate with Python and build HTML from templates that have access to functions and variables.
Front Matter
Both Jinja templates and Markdown files support front matter - metadata enclosed by @frontmatter and @endfrontmatter tags. Front matter is written in JSON format and can include:
@frontmatter
{
"title": "My Page Title",
"description": "Page description for SEO",
"author": "Your Name",
"body_class": "custom-page-class",
"body_id": "custom-page-id",
"keywords": ["web", "development", "shodo"],
"head_extra": [
"<link rel='stylesheet' href='/custom.css'>",
"<script src='/custom.js'></script>"
]
}
@endfrontmatter
Available front matter options:
title: Page title meta (used in<title>tag)description: Meta description for SEOauthor: Page author metakeywords: Array of keywords for meta tagslang: Language codecanonical: Main site urlcharset: ex. UTF-8theme_color: Optionally render the theme-color meta taggoogle_font_link: Optional link to google fontsbody_class: CSS class(es) to add to<body>tagbody_id: ID to add to<body>taghead_extra: Array of additional custom HTML to inject in<head>file_type: Specify file type other than html (e.g.,"xml"for RSS feeds)no_wrapper: Set totrueto skip HTML wrapper (useful for XML/RSS)paginate: Enable pagination (e.g.,"shodo_get_articles")per_page: Number of items per page when paginating- OG values, including:
og_imageog_image_altog_titleog_descriptionog_typeog_site_nameog_urlog_locale
Available front matter options specifically for markdown pages:
These values will get pulled from markdown page frontmatter and be included in the article object when the layout template is rendered.
titledescriptionsummarykeywordsauthorcategorytagspublished_datetime(will also generatepublished_dt_localif timezone is set)modified_datetime(will also generatemodified_dt_localif timezone is set)draft(boolean, default false)image(url)image_altextra(optional nested json of custom metadata)
content and link will be automatically generated and included in the article object as well.
Pages
Any template added to the pages/ directory will be written as an index.html file in its own subfolder within the dist directory. When linking between pages, simply write a backslash followed by the page name, exluding any file extensions. So if you wanted to link to pages/linked-page.jinja from home.jinja, the anchor tag would be
<a href="/linked-page">Click Here!</a>
Nested Pages
You can create nested pages by adding a subdirectory within pages/ with the name of the route. For routes with multiple pages, the index page of that route will need to be on the same level as the route subdirectory with the same name followed by the .jinja extension. For example:
__pages/
____about.jinja (template for '/about')
____nested.jinja (index template for '/nested')
____nested/
______nested-page.jinja (template for '/nested/nested-page')
Markdown
Partial markdown content to include in templates
Any markdown files added to the /markdown/partials directory will be exposed to Jinja templates with a variable name identical to the markdown file name, minus the extension. So, the contents of summary.md can be passed to the Jinja template as {{ summary }}, where it will be converted to HTML upon running the build script.
Prefixed variables for nested markdown directories
In order to avoid any naming conflicts, The articles further nested in directories within "articles/partials/" will have a variable prefix that is the accumulated names of the preceding directories in dot notation (excluding '/partials' and higher).
For example, a markdown file located in markdown/partials/collections/quotes/my_quote.md, will be exposed to all templates with the following variable using the jinja variable syntax:
{{ collections.quotes.my_quote }}
Generating full pages from markdown
In addition to partial variables that can be included in templates, entire new pages can also be automatically be generated from markdown files added to the
markdown/articles directory. The url path to the page will match what is defined in the markdown/articles directory.
Articles from the markdown/articles directory are rendered with a reusable template defined in views/articles/ Just add a layout.jinja file under a subdirectory that matches the subdirectory tree used in markdown/articles, or simply define a layout.jinja at the root of views/articles if you want to use a single layout template for all articles. In the layout.jinja file, control where you would like your content to be dynamically inserted by passing in the reserved {{ article }} variable. More below.
The site builder will always match up the layout template that is closest in the tree, so markdown/articles/blog/updates/new-post.md would be matched with views/articles/blog/layout.jinja if no layout is defined for the updates directory.
__markdown/
____articles/
______blog/
________updates/
__________new-post.md
__views/
____articles/
______layout.jinja (default root layout for all markdown 'articles')
______blog/
________layout.jinja (Maps to markdown/articles/blog, overwrites root layout)
________updates/
__________layout.jinja (Maps to markdown/articles/blog/updates, overwrites other previous layouts in tree)
layout.jinja
The layout.jinja is just a normal jinja template, but the {{ article }} variable has been reserved for passing in the content from each file in markdown/articles. Simply define whatever repeated layout you would like to wrap the {{ article }} content, such as a header and footer.
Here is an example layout template:
@frontmatter
{
"title": "Blog Layout",
"body_class": "blog-page"
}
@endfrontmatter
<div class="container">
<header class="page-header">
<h1>{{ article.title }}</h1>
<time datetime="{{ article.published_datetime.strftime("%Y-%m-%d %H:%M:%S") }}">
{{ article.published_datetime.month }}-{{ article.published_datetime.day }}-{{ article.published_datetime.year }}
</time>
</header>
<main>
{{ article.content }}
</main>
<footer>Thanks for reading</footer>
</div>
Template API Functions
Shodo provides several built-in functions that can be called from within templates:
shodo_get_articles()
Query and filter articles from the markdown/articles directory.
Basic usage:
{% for post in shodo_get_articles() %}
<h2>{{ post.title }}</h2>
<p>{{ post.excerpt }}</p>
<a href="{{ post.link }}">Read more</a>
{% endfor %}
With filters:
{% for post in shodo_get_articles(filters={
"where": {
"category": "technology",
"tags": {"contains": "python"}
},
"order_by": {"desc": "date"},
"limit": 5
}) %}
<article>
<h2>{{ post.title }}</h2>
<time>{{ post.date }}</time>
<p>{{ shodo_get_excerpt(post.content, 150) }}</p>
</article>
{% endfor %}
Filter operators:
equals: Exact matchcontains: Check if value is in a liststarts_with: String starts with valueends_with: String ends with valuegt,gte,lt,lte: Comparison operatorsin: Value is in listnot_in: Value is not in listnot_equals: Not equal to valuenot_contains: Value not in listregex: Regular expression match
Logical operators:
{% for post in shodo_get_articles(filters={
"where": {
"and": [
{"category": "tech"},
{"tags": {"contains": "python"}}
],
"or": [
{"author": "John"},
{"author": "Jane"}
]
}
}) %}
shodo_query_store()
Query JSON data from the /store directory with the same powerful filtering as shodo_get_articles().
{% for item in shodo_query_store(
filters={
"collection": "products",
"where": {"price": {"lt": 100}},
"order_by": {"asc": "name"},
"limit": 10
}
) %}
<div>{{ item.name }} - ${{ item.price }}</div>
{% endfor %}
shodo_get_excerpt()
Extract a text excerpt from content with a specified character limit.
{{ shodo_get_excerpt(article.content, 200) }}
get_rfc822()
Convert a datetime to RFC 822 format (required for RSS feeds).
<pubDate>{{ get_rfc822(article.published_datetime) }}</pubDate>
rel_to_abs()
Convert relative URLs to absolute URLs (required for RSS feeds). Uses config.url_origin value set in store directory, otherwise the second argument is required with the base url origin.
{{ rel_to_abs(article.content, "https://example.com") }}
current_dt()
Get the current datetime during build.
<lastBuildDate>{{ get_rfc822(current_dt()) }}</lastBuildDate>
Pagination
Shodo supports automatic pagination for article listings and store queries. Simply add pagination configuration to your template's front matter:
@frontmatter
{
"title": "Blog Archive",
"paginate": "shodo_get_articles",
"per_page": 10
}
@endfrontmatter
<h1>Blog Posts</h1>
{% for post in shodo_get_articles(filters={
"where": {"category": "technology"},
"order_by": {"desc": "date"}
}) %}
<article>
<h2><a href="{{ post.link }}">{{ post.title }}</a></h2>
<p>{{ shodo_get_excerpt(post.content, 150) }}</p>
</article>
{% endfor %}
{# Pagination navigation is automatically injected #}
{{ pagination.page_links|safe }}
The pagination object provides:
pagination.current_page: Current page numberpagination.total_pages: Total number of pagespagination.has_previous: Boolean for previous pagepagination.has_next: Boolean for next pagepagination.previous_page: Previous page numberpagination.next_page: Next page numberpagination.previous_page_url: URL to previous pagepagination.next_page_url: URL to next pagepagination.page_links: HTML markup for pagination navigation
Pages are automatically generated at:
- First page:
/blog/index.html - Subsequent pages:
/blog/page/2/index.html,/blog/page/3/index.html, etc.
RSS/Atom Feeds
Generate RSS or Atom feeds by creating an XML template with file_type: xml in the front matter:
@frontmatter
{
"file_type": "xml"
}
@endfrontmatter
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>My Blog</title>
<link>https://example.com</link>
<description>Latest posts from my blog</description>
<lastBuildDate>{{ get_rfc822(current_dt()) }}</lastBuildDate>
{% for post in shodo_get_articles(filters={
"order_by": {"desc": "date"},
"limit": 20
}) %}
<item>
<title>{{ post.title }}</title>
<link>https://example.com{{ post.link }}</link>
<pubDate>{{ get_rfc822(post.published_datetime) }}</pubDate>
<description><![CDATA[
{{ rel_to_abs(post.content, "https://example.com") }}
]]></description>
</item>
{% endfor %}
</channel>
</rss>
Note: Unlike the default behavior for HTML files, XML files will be rendered directly as the template name plus the .xml extension (instead of an index file being rendered in a directory named after the template).
JSON data in the /store directory
For easy configuration and keeping repeated values in one place, any property defined in a .json file within the /store directory will be passed to Jinja templates with an identical variable to the property name. Each nested object can be accessed using dot notation in the templates.
For example, to access the name value from /store/products.json:
{
"my_product": {
"name": "wrench",
"category": "hardware"
}
}
in the template, you would use the following syntax:
{{ my_product.name }}
You can also query store data dynamically using shodo_query_store() with filtering, sorting, and pagination:
{# Access store data directly #}
<h1>Products</h1>
{# Or query it with filters #}
{% for product in shodo_query_store(
filters={
"collection": "products",
"where": {"category": "electronics"},
"order_by": {"asc": "price"}
}
) %}
<div>{{ product.name }} - ${{ product.price }}</div>
{% endfor %}
The config namespace has been reserved for setting default global values that will be used when building the site. These include any of the metadata tags that go in frontmatter, as well as a url_origin and timezone field:
{
"config": {
"metadata": {
"title": "Default title for <head> that gets overwritten by frontmatter",
"description": "Default description for <head> that gets overwritten by frontmatter",
"author": "Default author for <head> that gets overwritten by frontmatter",
"google_font_link": "Default Google fonts link to optionally use across the site. Also gets overwritten by frontmatter"
},
"url_origin": "Some parts of the build process may require dynamically adding in the site url, such as building rss feeds. ex: 'https://my-shodo-site.dev'",
"timezone": "IANA identifier that is used if you want to display local times"
}
}
build_settings.json
This is where all source paths and project settings are defined.
NOTE: Any path included in root_template_paths will have all of its children directories recursively added to the search path for Jinja2, so only top level paths should be included in the settings. In most cases, "root_template_paths": [ "src/theme/views/" ] should suffice, but it would be possible to add another path to src/theme/assets/images for example if you wanted to use the templates for working with an SVG but still wanted to maintain separation of concerns.
CLI Commands
Shodo provides helpful CLI commands:
Generate UTC Timestamp
Generate an ISO 8601 formatted UTC timestamp for use in front matter or RSS feeds:
shodo now
Output: 2025-11-28T19:45:32Z
This is useful for setting publication dates in article front matter:
@frontmatter
{
"title": "My Article",
"published_datetime": "2025-11-28T19:45:32Z"
}
@endfrontmatter
Deploy to Netlify
-
Allow Netlify to install the project dependencies. If you are using pipenv, Netflify will install dependencies directly from the
pipfile. Otherwise, you will need to generate arequirements.txtfile viapip freeze > requirements.txt,poetry export --format=requirements.txt > requirements.txt,uv pip compile pyproject.toml -o requirements.txt, or similar to allow the dependencies to be installed via pip. -
Create a new repository on GitHub and push the Shodo project up to it
-
Now, we have everything we will need to build and deploy the static site on Netlify. We will have to make a few specifications since Netlify won't be able to autodetect everything about the build configuration
-
Choose "Add new site" on Netlify, and select the repository with your site
-
For the
build command, specifypython site_builder.py -
Luckily, Netlify supports Python and will be able to automatically install dependencies from either the pipfile or requirements.txt. The only extra step we need to take is to change the default python version from 3.8 to the required python version in your
requirements.txtorPipfile.lock. To do this, go to the environment variables section and addPYTHON_VERSIONfor the variable name, and update the value. -
Now click to deploy the site. After around a minute, verify that the build was successful, and you should be able to view the deployed site!
Reference: Netlify Python Documentation
Project Conventions
Jinja templates
For all jinja templates, use the .jinja file extension. Other extensions such as .j2 or .jinja2 are not fully supported at this time.
Syntax Highlighting
If you're using VSCode, the Better Jinja extension is recommended for full syntax highlighting out of the box using the .jinja extension. Other extensions will work, although you might need to configure the settings to look for the .jinja extension.
For Contributors
This project uses the Black Formatter and follows the current style guide
Pulling down the repository and installing locally
-
Start up a virtual environment and install the dev dependencies using your preferred method after pulling down the repository
-
Once your virtual environment is activated, in the root of the project directory run
pip install -e . -
Upon successful install, navigate to an entirely separate directory and run
start-shodo-project <name of new project directory>
Upon success, a new starter project template should have been set up in the specified directory
Start editing by making changes to src/theme/views/home.jinja
- Run
Python site_builder.pyfrom the main project directory when your ready to generate the site
Find your static site located in the dist/ directory
For development, run Python serve.py from the root project directory. This will build the site in the dist directory with the latest changes from src and serve it on localhost.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file shodo_ssg-0.0.dev4.tar.gz.
File metadata
- Download URL: shodo_ssg-0.0.dev4.tar.gz
- Upload date:
- Size: 93.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.17
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4bf6e889434446398a1939fe72036eb9b3e502172581713b6c966e3fcf9a2714
|
|
| MD5 |
a1099d0df51e0a218e41ef92c5c1f76f
|
|
| BLAKE2b-256 |
2995f07b1a1f264b169dfe75ff98cd1f3b74ce6b15695e88c1b3a961251a287a
|
File details
Details for the file shodo_ssg-0.0.dev4-py3-none-any.whl.
File metadata
- Download URL: shodo_ssg-0.0.dev4-py3-none-any.whl
- Upload date:
- Size: 78.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.17
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2e515300073dd38f98dcb34ebbf3f9e5dde019a56cb217ff80fd13c4ad5440f3
|
|
| MD5 |
b3592f88015f736b622f0475692b5169
|
|
| BLAKE2b-256 |
6c61f64eb8dfaf31c016bae0066549d74e60516596bbc0a2abd1408c1dab771a
|