I was looking for a static site generator to use for my website, in the past I've used Hugo and Jekyll, however I'm not really a huge fan of them due to their complexity. What I really wanted was a script that generates a website from markdown and liquid files in a "source" directory. Python seems perfectly suited for this, all I need is a markdown and liquid library.
Liquid is a programming language for creating html templates. It has conditionals, loops, and variables which allow for doing more advanced things than you can do in plain html. Markdown is a mark-up language used for formatting text. Embedding html in the markdown is easy, since the whole thing gets converted into html in the end. For the markdown library I went with marko which converts markdown to html. For liquid I used a library called well, liquid.
Here is a brief explanation of how the liquid
python library works - There's a render()
function which takes the liquid and compiles it to plain html. A parse()
function which parses some liquid source but doesn't render it. An Environment
instance, which is an object that handles template loading and sets up the environment.
For a quick example of how this works, here's some demonstration code:
test.html
:
<html>
<head>
<title>Test</title>
</head>
<body>
<h1>Hello, my name is {{ name }}<h1>
<p>Current date today is {{ date | date: "%B %d, %Y" }}</p>
</body>
</html>
demo.py
:
from liquid import Environment
from liquid import FileSystemLoader
import datetime
env = Environment(loader=FileSystemLoader(".")) # Loads current directory
template = env.get_template("test.html")
with open("output.html", "w") as output_file:
output_file.write(template.render(name="Alice", date=datetime.datetime.now(),))
This outputs an html site which looks like this:
The resulting file looks like this:
output.html
:
<html>
<head>
<title>Test</title>
</head>
<body>
<h1>Hello, my name is Alice<h1>
<p>Current date today is June 24, 2025</p>
</body>
</html>
So, the render()
function takes in variables, these variables can be used to populate the html dynamically at compile time. This is good for things like creating an index of posts using post metadata such as post metadata like dates and titles. Shown here is how I use this for indexing posts:
post-index.html
:
<!doctype html>
<html lang="en">
<head>
{% render 'head.html' %}
</head>
<body>
<a href="/">‹- Home</a>
<h1>Posts</h1>
<ul>
{% assign posts_by_date = posts | sort: 'date' | reverse %}
{% for post in posts_by_date %}
<p><a href="{{ post.path }}"> {{ post.title }} </a> {{ post.date | date: "%B %d, %Y"}} </p>
{% endfor %}
</ul>
</body>
{% render 'copyright.html' %}
</html>
Here is the liquid used in post generation:
post-template.html
:
<!doctype html>
<html lang="en">
<head>
{% render 'head.html' %}
</head>
<body>
<a href="/posts">‹- Posts</a>
<h1>{{ post_title }}</h1>
<h5>Published on: {{ post_date | date: "%B %d, %Y" }}</h5>
{{ post_html }}
</body>
{% render 'copyright.html' %}
</html>
As well as the python code for generating the index:
def generate_post_index(posts, site_directory, templates_directory, posts_directory, output_directory):
posts_list = []
for post in posts:
posts_dict = {"title": post.title, "date": post.date, "path": f"{post.name}"}
posts_list.append(posts_dict)
posts_data = { "posts" : posts_list }
env = Environment(loader=FileSystemLoader(f"{site_directory}/{templates_directory}/"))
template = env.get_template("posts-index.html")
with open(f"{output_directory}/{posts_directory}/index.html", "w") as output_file:
print(f"Generating {output_directory}/{posts_directory}/index.html")
output_file.write(template.render(**posts_data))
Now what about markdown? I mentioned using a markdown library called marko, which is used for the body of the generated websites generally. Marko works by taking markdown and converting it into html, which can be embedded in the html of our website. Marko has a function marko.convert()
which takes in a string containing the markdown contents as input, and outputs a string with the html. Additionally, some kind of metadata parsing is needed. I handle this using a python library called frontmatter.
Marko includes a command line tool which can be used for demonstration purposes, to demonstrate how this library works I wrote a markdown file and use marko to convert it to html.
hello-world.md
:
# Hello World
The universe is a very vast place, with an infinite number of galaxies. To all the worlds out there, hello world!
The command to convert this to html is: marko hello-world.md -o hello-world.html
hello-world.html
:
<h1>Hello World</h1>
<p>The universe is a very vast place, with an infinite number of galaxies. To all the worlds out there, hello world!</p>
Frontmatter loads the markdown file into an object containing the contents and metadata. The metadata gets proxied as keys in a dictionary, which can be viewed using post.keys()
. The metadata strings can be accessed as dictionary keys, and this way it's simple to work with.
For indexing purposes, I made each post it's own object containing metadata and html.
class Post:
def __init__(self, name, title, date, html):
self.name = name
self.title = title
self.date = datetime.datetime.strptime(date, '%Y-%m-%d %H:%M')
self.html = html
I have a function which takes each markdown file in the posts
directory and produces an array of post
objects. This allows for easily indexing each post on an index page, as each post and its metadata are already organized in the array. I've found that using a dictionary works for passing the post data to the template.render()
function to be compiled into a list.
I'm using a for loop in the liquid to index each post by date. This is how that looks:
<!doctype html>
<html lang="en">
<head>
{% render 'head.html' %}
</head>
<body>
<a href="/">‹- Home</a>
<h1>Posts</h1>
<ul>
{% assign posts_by_date = posts | sort: 'date' | reverse %}
{% for post in posts_by_date %}
<p><a href="{{ post.path }}"> {{ post.title }} </a> {{ post.date | date: "%B %d, %Y"}} </p>
{% endfor %}
</ul>
</body>
{% render 'copyright.html' %}
</html>
I have a simple but highly flexible static site generator with blog-style post rendering, and a way to index each post by date. Are there ways to improve this? Yes, I would like to make improvements in the future. Some potential improvements I can make are code clean up, extension support, post categorization, improved templates, and a proper packaging. This has been a neat learning experience, and if you want to check it out you can do so here. The information in this post may help you with making your own, or just learning how a static site generator works. I encourage you to check out the actual source code since there are things I didn't write about in detail.