Simple Static Site Builder with Jinja2
At the other end of the spectrum are fully featured static site generators (SSGs) like Hugo, Jekyll, and Gatsby. They're powerful, but they can also be overwhelming and feel like overkill when all you want is a simple website with a few reusable components.
In this post, I'll discuss building a lightweight SSG using Jinja2 in python. By treating your HTML files as templates, you can reuse layouts, components, and other shared code without introducing a complex toolchain. The result is a simple workflow that's easy to understand, easy to customize, and perfect for smaller projects where you want just enough structure without the extra baggage.
If you're interested in the concept but don't feel like developing it yourself, check out plainly, a super simple SSG I developed that builds on the exact concepts I'll discuss in this post (with a few additional features such as sitemap generation, RSS feed parsing, custom rendering context, etc.). It's completely free and very easy to use for deploying static sites on CSPs like Netlify, Cloudflare or Render.
Why Use Templates?
Templating is most commonly used for dynamic sites where the HTML is rendered on the server for each request before being returned to the client (e.g., Django, Razor or ERB). However, this doesn't have to be the only use case. One of the greatest benefits of templating is inheritance, or more generally the ability to share code between several webpages.
A common example of where this is useful is in designing navbars or footer elements that are shared among several pages on a site. With a conventional, plain HTML static site, such as one you may encounter when working with pre-designed HTML templates, there is a lot of repeated code. You'll typically have files such as index.html, about.html, contact.html, etc. If each of these pages shares the same header, navbar, and footer, you'll end up with three separate copies of each shared element. This becomes a pain when you want to change one of these elements, as you need to make the same change in each of the files. And if you end up missing something, you end up with inconsistencies between the pages in your site.
This is exactly the issue that template inheritance aims to fix. For example, you could create a template called layout.html, that may look like the following:
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
<nav> ... </nav>
<div class="content">
{% block content %}{% endblock %}
</div>
<div class="footer"> ... </div>
</body>
</html>
This file would contain the HTML header code, the navbar, footer and other shared layout code, as well as shared CSS and JavaScript imports. Essentially acting as the base, or parent template for other pages which require the same code. The content block, defined by {% block content %}, is telling the template engine where inside the parent file to put the contents contained within the corresponding block in the child page. Let's take a look at an example child page to see how this would work in practice.
{% extends "layout.html" %}
{% block content %}
<h1> Example Page </h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore ...
</p>
{% endblock content %}
The example page above is how one could make use of template inheritance for sharing layout features between multiple pages. In addition to inheritance, you can also use include directives ({% include "<FILENAME>" %}) which allows you to embed templates within pages. These are the primary methods by which code can be reused when utilizing templates, and are a major benefit of this approach to building static sites.
Now the key here is that this rendering doesn't necessarily need to occur at runtime like with common web frameworks. The template engine could instead be integrated into the build process for the site such that the output includes all of the individual pages fully rendered into plain HTML. This is what the remainder of this post will focus on.
Integrating Jinja2
As mentioned previously, Jinja is most commonly used in conjunction with web frameworks like Django or Flask. However, it can also be invoked programmatically, directly from python code. First, install Jinja standalone via pip.
pip install Jinja2
Next, we'll define a basic python script. This script defines a function named build, which is called from the __main__ entry point and is where the full build pipeline will be implemented. The script also defines some directory names, called src and dist, which are going to be the input and output directories respectively.
import os
from jinja2 import Environment, FileSystemLoader
SRC_DIR = 'src'
OUT_DIR = 'dist'
def build():
pass
if __name__=='__main__':
build()
The next step is to add some meat to the build function. The first part of the build pipeline is configuring the template rendering environment. This can be done by creating an instance of the Environment class (imported above). Additionally, an instance of the FileSystemLoader class will be created, passing in the defined template directory which enables Jinja to load templates from the filesystem located inside that template directory. In this case, Jinja will look for templates in the directory src/templates/.
template_dir = os.path.join(SRC_DIR, 'templates')
env = Environment(loader=FileSystemLoader(template_dir))
Once the environment is initialized, it can be used to start rendering full HTML documents. So we'll define another directory variable as follows, this time for the HTML pages representing the actual content of the website.
page_dir = os.path.join(SRC_DIR, 'pages')It's useful to keep pages and templates in separate directories because it clearly distinguishes files that should be rendered as standalone documents (pages) from files that are intended to be reused or extended by other pages (templates). This also gives the static site builder an unambiguous way to determine which files should become documents in the output. To be clear, the files in the pages directory are still templates in the technical sense, as they can utilize Jinja2 template syntax. However, unlike the files in the templates directory, each file in the pages directory is intended to produce a corresponding HTML document in the output directory.
With that, we now want to recursively iterate through all files contained within the pages directory and any subdirectories, rendering each page with Jinja and replicating the internal structure within the output directory. This stage accounts for the bulk of the build pipeline, and can be implemented as follows.
for dirpath, _, files in os.walk(page_dir):
for file in files:
if not file.lower().endswith(".html"):
continue
# determine absolute path and path relative to page dir
page_path = os.path.join(dirpath, file)
rel_dir = os.path.relpath(dirpath, page_dir)
rel_file = os.path.join(rel_dir, file) if rel_dir != "." else file
# open page file and load template from resulting string
with open(page_path, "r", encoding="utf-8") as f:
template_source = f.read()
template = env.from_string(template_source)
# create output directory if doesn't exist
output_path = os.path.join(OUT_DIR, rel_file)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# render template and write to output file
with open(output_path, "w", encoding="utf-8") as out:
rendered_html = template.render()
out.write(rendered_html)
We start by utilizing os.walk, passing in the path for the directory containing the pages. This function is a generator used to recursively traverse directories by walking down a directory tree. It yields a 3-tuple containing the current directory path, all subdirectories and all files in the current directory. For all subdirectories contained within the entire tree of the pages directory, we perform another iteration through all files within that directory.
For each file, we do the following:
- Check if the file is an HTML file. If not, we skip it.
- If the file is an HTML file, we start by preparing the necessary paths. The main path that we need is the path of the current file relative to the directory containing the pages (the
rel_file). This is needed because this tells us where to write the file within the output directory such that we can replicate the same file structure as for the pages directory. For example, a file located atsrc/pages/home/about.htmlshould produce an output file atdist/home/about.html. - Next, we'll open the file and read its contents as a string. The string then gets passed to the
env.from_stringmethod on the environment instance, which creates aTemplateobject. (see the note below for more information) - Before rendering the template, we need to ensure the output directory is created. This is done by creating a finalized output path for the file, then using os.makedirs to create the directory structure if it doesn't already exist.
- Finally, we render the template using the
template.rendermethod, and write the resulting output to the file path we set in the previous step.
from_string method is used here instead of the FileSystemLoader because (if you refer back to when we initialized the environment) the loader doesn't have access to the directory containing the pages, only the templates directory. This separation is intentional; the loader is useful for automatically resolving templates referenced by pages, whereas pages can be read directly with standard python I/O as we iterate over them to reproduce their directory structure in the output. Since pages must already be loaded manually, using from_string avoids giving the loader access to the pages directory unnecessarily.This will allow our SSG to function very similarly to the aforementioned web frameworks when it comes to template rendering. Within a page, simply specify the template path you want to use (relative to the templates directory), using extend or include blocks, and this build pipeline will automatically render them.
It's also worth noting that this is only a basic proof of concept implementation, rather than a fully featured build pipeline. It doesn't include things like copying static files, error handling, content retrieval, postprocessing, or other features that would be necessary for a production deployment. I'll leave those details up to the reader if you're interested in taking this concept further.
Testing the Static Site Builder
For testing, I'll be making use of the example site bundled with the plainly code repository. It's a very simple custom site to demonstrate the functionalities we discussed here. It contains three pages, a layout template, two reusable component templates and a static CSS file.
hello-site/
└── src/
├── pages/
│ ├── about.html
│ ├── contact.html
│ └── index.html
├── static/
│ └── css/
│ └── style.css
└── templates/
├── base.html
├── card.html
└── contact-form.html
After navigating to the hello-site directory, we can run the following command to build the site and start the development server.
cd examples/hello-site
plainly --run
Running this command should generate a dist directory containing the following files.
dist/
├── about.html
├── contact.html
├── index.html
└── static/
└── css/
└── style.css
Navigating to http://localhost:8080 in a web browser should show the following page. This page is generated from the index.html file. If you inspect the file, you'll notice that it's making use of several of the templates, including inheriting from base.html for the layout in addition to containing multiple instances of the card.html component shown towards the bottom. It also imports the style.css file contained within the output.
Conclusion
At this point, we have the beginnings of a SSG, demonstrating how python and Jinja2 can be combined to turn a collection of templates and pages into a static website. While there's still a lot that would be needed before this could be considered a complete SSG, the fundamental rendering pipeline is now in place.
From here, there's plenty of room to expand the system with features such as markdown support, content management, asset processing, incremental builds, and better error handling. But those features are largely extensions of the same basic concepts we've covered here.
Hopefully this provides a useful starting point for anyone interested in building their own SSG, or simply wanting to better understand how tools like Jinja2 are used behind the scenes.
And if you're interested in seeing where these concepts can lead, check out plainly, the static site generator I built using the same approach we discussed here.
Comments
Post a Comment