Static site generators like Hugo and Jekyll make it easy to get started with posting written content online. With many of them, the average user can get a basic site up and running in minutes.

So why did I write my own solution?

Why?

There are a few reasons why I decided to write my own static site generator instead of using an established solution like Hugo:

I had a few requirements for the site generator I was to create:

Now that the justification is (hopefully) out of the way, let's dive into actually making the thing.

Creating the Site Generator

Project Setup

For this project, I decided to use Python with the uv package/project manager, as Python has a lot of great libraries to use, and uv blows pip away in terms of package download speed.

We are going to be using the following Python libraries:

Let's first start off by creating the uv project and the folder structure:

mkdir site-generator
cd site-generator
uv init --app --no-package
uv add markdown frontmatter feedgen jinja2 pygments
mkdir css
mkdir posts
mkdir posts_markdown
mkdir templates
mv hello.py site-generator.py

To check that everything is working, run uv run site-generator.py, and you'll hopefully see a greeting.

Let's explain the folder structure a bit. The css folder is where our CSS styling is going to be. The posts folder is where the generated HTML posts are going to be. The posts_markdown folder is where we write our markdown posts. The templates folder is where the jinja templates our HTML is generated from are going to be.

All of our Python code will reside in site-generator.py. Right now, it should have the following default code (we'll change this later):

def main():
    print("Hello from site-generator!")


if __name__ == "__main__":
    main()

Creating the Jinja templates

We're going to create two Jinja templates for our site - one to generate the blog posts themselves, and one to generate the blog post index. The following templates are just what I used in my case - yours may look slightly different depending on the layout you want.

Put the following code in templates/post.jinja (this will represent the posts themselves):

<!DOCTYPE html>
<html>
    <head>
        <title>{{postTitle}}</title>
        <link rel="stylesheet" href="../css/styles_codehilite.css">
        <link rel="stylesheet" href="../css/ui_styles.css">
    </head>
    <body>
        <ul class="navbar">
            <li><a href="#" data-item="Home">Home</a></li>
            <li><a href="#" data-item="Projects">Projects</a></li>
            <li><a href="../posts.html" data-item="Blog">Blog</a></li>
        </ul>
        <div class="post-contents">
            {{postBody}}
        </div>
    </body>
</html>

Put the following code in templates/posts.jinja (this will represent the post index):

<!DOCTYPE html>
<html>
    <head>
        <title>Posts</title>
        <link rel="stylesheet" href="css/ui_styles.css">
    </head>
    <body>
        <ul class="navbar">
            <li><a href="#" data-item="Home">Home</a></li>
            <li><a href="#" data-item="Projects">Projects</a></li>
            <li><a href="#" data-item="Blog" class="active">Blog</a></li>
        </ul>
        <div class="posts">
            {{posts}}
        </div>
    </body>
</html>

You will notice that, in addition to the standard HTML, we have three placeholders:

This is all that you need HTML-wise (pretty simple, isn't it?)

Creating the styles

First, we need to generate the css that the syntax highlighting will be using. Since the markdown package uses pygments for this, we will use it to generate the css we need.

Run the following command (replace default with the colorscheme of your choice):

uv run pygmentize -S default -f html -a .codehilite > css/styles_codehilite.css

This will generate a css file containing all the styles that pygments' html formatter will need.

Now, we need the actual website css. This will be written from scratch.

The following is the css I use in css/ui_styles.css:

/* Universal css settings */
body {
  background: #e9e9e9;
  font-family: sans-serif;
  font-size: 15pt;
  width: 100%;
  margin: 0;
  padding: 0;
  line-height: 1.5em;
}

a {
  color: #009955;
  text-decoration: none;
}

/* Fix for text in <code> tags sometimes spilling out of bounds */
.codehilite code {
  display: block;
  overflow-x: auto;
}

/* Navbar */
.navbar {
  list-style-type: none;
  background: linear-gradient(180deg, rgba(0, 170, 119, 1) 0%, rgba(0, 204, 153, 1) 100%);
  width: 100%;
  margin: 0;
  padding: 0;
  overflow: hidden;
  li {
    float: left;
    a {
      display: block;
      color: black;
      text-align: center;
      font-size: 24px;
      padding: 14px 16px;
      text-decoration: none;
      transition: all 0.2s ease-in-out;
      &:hover:not(.active) {
        background-color: #009955;
      }
      &.active {
        background-color: #008855;
        color: white;
      }
    }
  }
}

/* Posts index */
.posts {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  padding-left: 25%;
  padding-right: 25%;
}
.post-entry {
  display: flex;
  flex-direction: column;
  align-items: start;
  padding-left: 0.5em;
  padding-right: 0.5em;
  padding-top: 0.10em;
  padding-bottom: 0.10em;
  background-color: white;
  color: #5f5f5f;
  margin: 10px;
  border-style: solid;
  border-color: white;
  border-radius: 10px;
  transition: all 0.2s ease-in-out;
  text-decoration: none;
  &:visited {
    color: #5f5f5f;
  }
  &:hover {
    background-color: #c9c9c9;
    border-color: #c9c9c9;
  }
  .post-date {
    font-style: italic;
    font-size: 0.75em;
  }
  &:active {
    background-color: #a9a9a9;
  }
}

/* Posts */
.post-contents {
  margin-left: 25%;
  margin-right: 25%;
  margin-top: 1em;
}

Explaining the above css isn't in the scope of this article, but it's not as complicated as it might look. w3schools is a good resource for learning css if you need it.

Markdown

All markdown posts in this system will go in posts_markdown/. Here's a basic idea of what each post will look like:

---
title: Sample Post
date: June 12, 2025
description: A sample markdown post containing lorem ipsum placeholder text.
---

## My post

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Malesuada primis potenti leo amet mi.
Duis euismod vivamus cras leo nam curae vehicula quam odio pellentesque tortor.

Nibh et metus finibus vestibulum imperdiet auctor quis bibendum dignissim commodo maximus augue id vestibulum.
Platea at elit nunc urna sollicitudin malesuada per volutpat suscipit velit.
Sagittis mi eget dignissim nam blandit. Rhoncus ut eu quam nunc dui.
Luctus lacinia eu ante convallis class fermentum suspendisse aenean per mattis laoreet fusce amet.

Ullamcorper integer donec integer magna pretium sollicitudin.
Litora auctor consectetur fusce purus pharetra netus ultricies magna.
Sapien inceptos conubia aliquet luctus senectus senectus vel neque tempus consequat pulvinar posuere duis sem eleifend.

Nibh donec suspendisse integer conubia porttitor tortor arcu phasellus.
Volutpat potenti dolor mattis ipsum nam nam risus massa enim porttitor fermentum platea.
Risus tincidunt varius sodales rutrum ultrices blandit tincidunt velit fermentum.
Class conubia augue ullamcorper vitae litora egestas justo aliquet eget duis nullam posuere.
Nisi aenean euismod pellentesque in dui blandit tempus scelerisque fames sit lorem per augue.

The section in between --- is the metadata section in YAML format, and three entries are required there:

What follows is standard markdown, albeit with a few extensions we'll discuss shortly.

The generator script

Finally, it's time to write site-generator.py to generate everything.

First, we need to import the libraries we need, and delete any HTML posts already in the posts/ folder:

from datetime import datetime, timezone
from feedgen.feed import FeedGenerator
from frontmatter import Frontmatter
from jinja2 import Environment, FileSystemLoader
import markdown
import os


def main() -> None:
    posts = []

    # First remove all posts in "posts" dir
    for post in os.listdir("posts"):
        os.remove(f"posts/{post}")

Then, we need to create the Jinja template objects we'll be using, as well as the markdown parser:

    # Create jinja templates
    jinja_env = Environment(loader=FileSystemLoader("templates"))
    post_template = jinja_env.get_template("post.jinja")
    posts_template = jinja_env.get_template("posts.jinja")

    # Create markdown parser
    md = markdown.Markdown(
        extensions=[
            "fenced_code",
            "codehilite",
            "toc",
            "sane_lists",
            "footnotes",
            "tables",
        ]
    )

You'll notice we use six markdown extensions here. They are:

All of these come with the markdown package (although codehilite needs the pygments dependency), so no additional packages are necessary.

Now, we need to run through the posts, populating the posts list:

    # Run through posts
    for post in os.listdir("posts_markdown"):
        post_md_path = f"posts_markdown/{post}"
        post = Frontmatter.read_file(post_md_path)
        post_metadata = post["attributes"]
        text = post["body"]
        post_body = md.convert(text)
        post_title = str(post_metadata["title"])
        post_date = str(post_metadata["date"])
        post_description = str(post_metadata["description"])
        post_path = f"posts/{post_title.replace(' ', '_').lower()}.html"
        with open(post_path, "w") as f:
            print(
                post_template.render(postTitle=post_title, postBody=post_body), file=f
            )
        posts.append(
            {
                "path": post_path,
                "title": post_title,
                "date": datetime.strptime(post_date, "%B %d, %Y").replace(
                    tzinfo=timezone.utc  # We need timezone data for the feedgen to work
                ),
                "description": post_description,
            }
        )
        md.reset()

There's a lot to unpack here. First, we grab the filepath of each post, and use the frontmatter package to read it. Then, we grab the body of the post, and use the markdown parser to convert it to HTML. Then, we grab the metadata we need (the title, date, and description), and use it to generate the post's HTML page with the Jinja template. Finally, we add the post to the list for when we generate the post index and rss feed later.

Now, we construct the post index and its corresponding web page:

    # Construct index
    posts = sorted(posts, key=lambda x: x["date"], reverse=True)
    posts_html = ""
    for post in posts:
        post_date = datetime.strftime(post["date"], "%B %d, %Y")
        posts_html += f'<a class="post-entry" href="{post["path"]}">'
        posts_html += f"<h3>{post['title']}</h3>"
        posts_html += f"<p>{post['description']}</p>"
        posts_html += f'<p class="post-date">{post_date}</p>'
        posts_html += "</a>"
    with open("posts.html", "w") as f:
        print(posts_template.render(posts=posts_html), file=f)

First, we need to sort the posts by date (this is why we converted the date to a datetime object earlier), to make sure they're ordered properly in the index. Then, it's a simple matter of hand-generating the HTML for each entry and creating the index with the Jinja template.

Finally, we create the rss feed:

    # Construct rss feed
    fg = FeedGenerator()
    fg.id("https://jdugan6240.dev")
    fg.title("jdugan6240.dev")
    fg.author({"name": "James Dugan", "email": "jdugan6240@vivaldi.net"})
    fg.link(href="https://jdugan6240.dev", rel="self")
    fg.language("en")
    fg.description("Ramblings about software and other topics.")

    for post in posts:
        fe = fg.add_entry()
        fe.id(f"https://jdugan6240.dev/{post['path']}")
        fe.title(post["title"])
        fe.author({"name": "James Dugan", "email": "jdugan6240@vivaldi.net"})
        fe.description(post["description"])
        fe.pubDate(post["date"])

    fg.rss_file("feed.xml")

The above code should be fairly self explanatory (the author of the feedgen package did a great job in that department).

The final site-generator.py script looks like this:

from datetime import datetime, timezone
from feedgen.feed import FeedGenerator
from frontmatter import Frontmatter
from jinja2 import Environment, FileSystemLoader
import markdown
import os


def main() -> None:
    posts = []

    # First remove all posts in "posts" dir
    for post in os.listdir("posts"):
        os.remove(f"posts/{post}")

    # Create jinja templates
    jinja_env = Environment(loader=FileSystemLoader("templates"))
    post_template = jinja_env.get_template("post.jinja")
    posts_template = jinja_env.get_template("posts.jinja")

    # Create markdown parser
    md = markdown.Markdown(
        extensions=[
            "fenced_code",
            "codehilite",
            "toc",
            "sane_lists",
            "footnotes",
            "tables",
        ]
    )

    # Run through posts
    for post in os.listdir("posts_markdown"):
        post_md_path = f"posts_markdown/{post}"
        post = Frontmatter.read_file(post_md_path)
        post_metadata = post["attributes"]
        text = post["body"]
        post_body = md.convert(text)
        post_title = str(post_metadata["title"])
        post_date = str(post_metadata["date"])
        post_description = str(post_metadata["description"])
        post_path = f"posts/{post_title.replace(' ', '_').lower()}.html"
        with open(post_path, "w") as f:
            print(
                post_template.render(postTitle=post_title, postBody=post_body), file=f
            )
        posts.append(
            {
                "path": post_path,
                "title": post_title,
                "date": datetime.strptime(post_date, "%B %d, %Y").replace(
                    tzinfo=timezone.utc  # We need timezone data for the feedgen to work
                ),
                "description": post_description,
            }
        )
        md.reset()

    # Construct index
    posts = sorted(posts, key=lambda x: x["date"], reverse=True)
    posts_html = ""
    for post in posts:
        post_date = datetime.strftime(post["date"], "%B %d, %Y")
        posts_html += f'<a class="post-entry" href="{post["path"]}">'
        posts_html += f"<h3>{post['title']}</h3>"
        posts_html += f"<p>{post['description']}</p>"
        posts_html += f'<p class="post-date">{post_date}</p>'
        posts_html += "</a>"
    with open("posts.html", "w") as f:
        print(posts_template.render(posts=posts_html), file=f)

    # Construct rss feed
    fg = FeedGenerator()
    fg.id("https://jdugan6240.dev")
    fg.title("jdugan6240.dev")
    fg.author({"name": "James Dugan", "email": "jdugan6240@vivaldi.net"})
    fg.link(href="https://jdugan6240.dev", rel="self")
    fg.language("en")
    fg.description("Ramblings about software and other topics.")

    for post in posts:
        fe = fg.add_entry()
        fe.id(f"https://jdugan6240.dev/{post['path']}")
        fe.title(post["title"])
        fe.author({"name": "James Dugan", "email": "jdugan6240@vivaldi.net"})
        fe.description(post["description"])
        fe.pubDate(post["date"])

    fg.rss_file("feed.xml")


if __name__ == "__main__":
    main()

Running the Site Generator

When all the above steps are done, you should be able to run the site generator with the following command:

uv run site-generator.py

This will generate the posts.html index file, the feed.xml RSS feed, and the posts themselves in the posts/ dir.

As you can see, creating a simple site generator isn't all that difficult, and yields a system that can be fairly easily modified to suit your needs.

The (up to date) repo for the above site generator can be found here. Keep in mind there may be some differences to what's shown in this article as I refine things. Notably, as of writing this article, we don't support a dark theme yet, which I hope to correct.

As a nice bonus: since the underlying html is so simple, the site doesn't look half bad in TUI web browsers like lynx!