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:
- It's a good learning experience to design my own web site from scratch.
- Many static site generators are complex and take time to learn to configure exactly as desired.
- A custom solution grants complete control over how exactly the site is generated, allowing for changes to be made when needed.
I had a few requirements for the site generator I was to create:
- It needed to support syntax highlighting of code blocks without javascript (so no highlight.js)
- It needed to have support for generating an rss feed.
- It needed to be simple, for easy extension and modification when necessary.
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:
- markdown for markdown parsing and extensions
- frontmatter for writing metadata on each post for the site generator to use
- feedgen for generating the rss feed
- jinja2 for creating html templates for each post and the post list
- pygments for syntax highlighting support
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:
{{postTitle}}
- This will represent the title of the post in question.{{postBody}}
- This will represent the contents of the post.{{posts}}
- This will represent the list of posts that we generate.
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:
- title: The title of the post (to be displayed in the browser tab/window title)
- date: The date the post was written (in the format shown)
- description: A description of the post's contents
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:
- fenced_code - Allows for use of "```" style code blocks.
- codehilite - The syntax highlighting extension that highlights code blocks with pygments.
- toc - Allows for creating a table of contents.
- sane_lists - Makes the behavior of ordered and unordered lists less surprising.
- footnotes - Allows the creation of footnotes.
- tables - Allows the creation of tables.
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!