"A Journey from WordPress to Markdown and Jinja"
Embark on a journey transitioning from a heavy CMS to a streamlined static site generator using Markdown and Jinja. This guide navigates the complexities of CMS, outlining benefits and drawbacks, and then transitions to the simplicity and efficiency of static site generation. By leveraging Markdown for content creation and Jinja for templating, this approach reduces overhead, improves site speed, and enhances customization.
- Strengths and limitations of using a CMS like WordPress for web development
- Benefits of transitioning to static site generation for simpler content management
- How to write and manage content in Markdown
- Techniques for storing and using metadata with JSON
- Utilizing Jinja for powerful and flexible templating
- Steps to set up a local development environment for previewing changes
- Methods for converting Markdown content to HTML
- Tips for rendering source code beautifully within posts
- Workflow automation using GitHub for site rebuilding and republishing
- Deploying a fast, cost-effective static site using Cloudflare Workers
The main idea is to publish markdown content in HTML, using Jinja Templates.
Github Repository | Posts in Markdown | Demo Website
A long time ago, I manually coded my website in HTML, CSS, and JavaScript. Then, about 10 years ago, I started using WordPress, which allowed me to focus more on the content.
Even today, WordPress is a popular content management system (CMS) that uses PHP and MySQL to create and manage websites and blogs. Its core components are PHP and SQL:
- Purpose: The scripting language used to build and run WordPress.
- Functionality: PHP files generate HTML dynamically at runtime, handling everything from displaying content to processing form data.
- Customization: Users can write custom PHP code to create themes and plugins, extending WordPress' functionality.
- Themes: PHP templates control the layout and design of a site. Custom themes can be created using PHP, HTML, and CSS.
- Plugins: Extend WordPress functionality. Plugins often contain PHP scripts that interact with the database using SQL queries.
- Purpose: The relational database management system is used to store and retrieve site data.
- Functionality: WordPress uses SQL queries to interact with the MySQL database, managing content, user information, and site settings.
- Customization: Users can write custom SQL queries for advanced data manipulation and retrieval.
- Posts and Pages: Stored in MySQL tables. PHP scripts retrieve and display this content on the website.
- Media Library: Images and other media files are referenced in the database and managed through PHP functions.
- PHP and SQL: PHP scripts use SQL queries to fetch data from the MySQL database and display it to users.
- Forms and Submissions: PHP handles form submissions, interacting with the database to store or update information.
Despite the following drawbacks, WordPress remains a popular choice due to its flexibility and extensive ecosystem.
- Frequent target for hackers.
- Vulnerabilities in third-party plugins and themes.
- Can be slow if not optimized.
- May require higher server resources.
- Steep learning curve for beginners.
- Requires ongoing maintenance and updates.
- Plugin conflicts can cause functionality problems.
- Updates can break existing features.
- Extensive customization needs coding skills.
- Design constraints without custom development.
- Often relies on plugins for effective SEO.
- Improper settings can lead to duplicate content issues.
- Premium themes and plugins can be expensive.
I'm maintaining two websites that I update maybe once a week. There are hardly any interactive parts, and all visitors get the same content. Considering this, the aforementioned process seems like overkill. So, what would a reasonable workflow look like that I would actually enjoy and would encourage me to create more and better content?
- Write content in Markdown. Here is its Basic Syntax and a tutorial.
- Store content metadata (e.g., a post's category, whether it's featured or not, dates, etc.) in a single JSON file.
- Ensure the "presentation layer" is not limited by a small selection of templates.
- Preview the website locally before pushing changes to the remote GitHub repository.
- Render source code beautifully since I often write about computer programming.
- Store the content in a GitHub repository rather than a SQL database.
- Trigger a rebuild/republish of the website upon changes to the GitHub repository.
- Ensure the public website loads fast while keeping hosting costs low.
There are several popular frameworks for building static websites, such as:
- Hugo: A static site generator written in Go, optimized for speed, easy use, and configurability. It takes a directory with content and templates and renders them into a full HTML website.
- Gridsome: A scalable generator that uses Vue.js to help you create static pages.
- Gatsby: Great for building blazing-fast, modern apps and websites with React.
However, each comes with its own considerations:
- Steeper Learning Curve: Hugo has a steeper learning curve compared to some other static site generators, particularly due to its templating language and configuration options.
- Complex Configuration: Its configuration can become quite complex, especially for larger sites with many custom layouts and features.
- Vue-Specific: Gridsome is tightly coupled with Vue.js, so it's not the best choice for developers who prefer or are more experienced with React or other frameworks.
- Complex Setup: The setup and configuration of Gatsby can be complex and overwhelming for beginners, especially with its reliance on React and GraphQL.
- Dependency Management: Gatsby has many dependencies, and keeping them all updated and compatible can be a challenge.
- High Resource Usage: Developing with Gatsby can be resource-intensive, requiring more powerful hardware to run efficiently.
Before starting to code, let's have some fun creating content for the website.
Let's ask ChatGPT to create some cool stories (including summaries and cover images) for the blog. I used the following three prompts:
- "Hey storyteller, please write 5 short stories about Python programming. Written in Markdown code that I can download."
- "Can you write a one-sentence summary for each story?"
- "Can you create an engaging cover image for each story that makes you really want to read the story?"
Of course, you can reuse the stories and images ChatGPT created for me, but it is important to arranged the content in a systematic way:
- Start a new Python project in VSCode and create a virtual environment.
- Create a
postsdirectory and copy the 5 Markdown files into it. - I named those files:
triumph.md,dilemma.md,wizard.md,dream.md, anddiscovery.md. - Create an
assets/imagesdirectory and copy the generated images into it. - Create a
context.jsonfile with this initial content:
{
"content": {
"posts" : []
}
}- Finally, create a dictionary like the one below for every story, and put it into the content's posts list:
{
"name": "triumph",
"cover": "triumph.png",
"title": "The Beginner's Triumph",
"summary": "Emma embarks on her programming journey with Python and triumphs over her first coding challenges.",
"cat": "software",
"featured": true
}Choosing a static site generator can feel like picking a CMS a decade ago, with options like WordPress, Joomla, and Drupal. But the eight workflow requirements outlined earlier seem straightforward enough to tackle them one by one.
Writing content in Markdown is easy. Check out this post on How to Use Markdown in VSCode. Create a new folder (e.g., posts) and start putting Markdown files into it.
Let's add some site metadata to a context.json file. E.g.:
{
"site_dir": "public",
"templates_dir": "templates",
"posts_dir": "posts",
"static_dirs": ["assets"],
"copyright_year": "2024",
"title": "Static Web Site Generator Demo",
"description": "This site was generated by jinja templates.",
"content": {
"posts": [
{
"name": "triumph",
"cover": "triumph.png",
"title": "The Beginner's Triumph.",
"summary": "Emma embarks on her programming journey with Python and triumphs over her first coding challenges.",
"cat": "software",
"featured": true
},
{
"name": "dilemma",
"cover": "dilemma.png",
"title": "The Debugger's Dilemma",
"summary": "Liam, a seasoned developer, solves a persistent bug by taking a step back and rethinking his approach.",
"cat": "software",
"featured": false
}
]
}
}There are many websites offering HTML site templates. Here are a few examples:
- HTML5up - Free, responsive HTML5 site templates.
- Lexington Themes - Free and premium multipage themes and UI kits.
- Code Stitch - HTML and CSS Template Library.
- Envato - HTML Templates and Website Templates.
- Pure CSS - Small, responsive CSS modules for every web project.
- W3.CSS Templates - Simple responsive W3.CSS website templates.
The main idea is to modify an existing site template by replacing some HTML with Jinja code, essentially turning HTML files into Jinja files.
Jinja is a templating language for Python developers. A template contains variables that are replaced by values when the template is rendered. Special placeholders in the template allow writing code similar to Python syntax. For example:
- {%....%} are for statements
- {{....}} are expressions used to print to template output
Here’s a simple example. After installing Jinja2 with pip install jinja2, the following Python program:
from jinja2 import Template
from json import load
jinja_template = '''
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
</head>
<body>
<ol>
{% for post in content.posts if post.cat == "software" %}
<li>{{ post.title }}</li>
{% endfor %}
</ol>
</body>
</html>
'''
with open('context.json') as json_file:
context = load(json_file)
html = Template(jinja_template).render(context)
print(html)prints this output:
<!DOCTYPE html>
<html>
<head>
<title>Static Web Site Generator Demo</title>
</head>
<body>
<ol>
<li>The Beginner's Triumph.</li>
<li>The Debugger's Dilemma</li>
</ol>
</body>
</html>jinja_template is a muliline string, storing HTML code with some jinja expressions and statements. The context variable provides access to a dictionary, containing everything we previously put into the context.json file. A Template is instantiated from the jinja_template string, and the render method is called with the context as an argument, providing access to the context dictionary.
Here is a link to the Jinja Project and a good primer
Moving closer to production code, a jinja_to_html method might look something like this:
from pathlib import Path
from json import load
from jinja2 import Template
class Site_Generator:
""" Generate a static website from markdown files and jinja2 templates. """
def __init__(self, context_file: str):
"""Initialize the site generator
Args: context_file: name of the context file
"""
with open(context_file, encoding="utf-8") as json_file:
self.context = load(json_file)
self.templates = Path.cwd().joinpath(self.context.get("templates_dir"))
self.posts = Path.cwd().joinpath(self.context.get("posts_dir"))
def jinja_to_html(self, jinja_file: str, html_file: str) -> None:
""" Render a jinja template
Args: jinja_file: name of the template file
html_file: name of the target file
"""
with self.templates.joinpath(jinja_file).open(encoding="utf-8") as src:
code = Template(src.read(),
trim_blocks=True, # remove whitespace
lstrip_blocks=True, # remove leading whitespace
extensions=("jinja2.ext.do") # allow do sttmnts
).render(self.context)
trg_path = Path.cwd().joinpath(self.context.get("site_dir"), html_file)
trg_path.parent.mkdir(parents=True, exist_ok=True)
with trg_path.open(mode="w", encoding="utf-8") as trg:
trg.write(code)
print(f"Generated: {trg_path}")I will admit it ain't pretty, but for demonstration purposes, the W3.CSS Blog Template is rich enough to show how an HTML site template can be converted into Jinja templates. Save the HTML code as templates/index.jinja and replace some of the HTML with Jinja.
For instance, change the header to this:
<!-- Header -->
<header class="w3-container w3-center w3-padding-32">
<h1><b>{{ title }}</b></h1>
<p>{{ description }}></p>
</header>Replace the three Blog entry divs with this Jinja for-loop:
<!-- Blog entries -->
<div class="w3-col l8 s12">
{% for post in content.posts if post.featured %}
<!-- Blog entry -->
<div class="w3-card-4 w3-margin w3-white">
{% if post.cover %}
<img src="/assets/images/{{ post.cover }}" style="width:50%">
{% endif %}
<div class="w3-container">
<h3><b>{{ post.title }}</b></h3>
<h5>{{ post.cove }}</h5>
</div>
<div class="w3-container">
<p>{{ post.summary }}</p>
<div class="w3-row">
<div class="w3-col m8 s12">
<p><button class="w3-button w3-padding-large w3-white w3-border"
onclick="location.href='/{{ post.name }}.html'" type="button"><b>READ MORE »</b></button></p>
</div>
</div>
</div>
</div>
{% endfor %}
<!-- END BLOG ENTRIES -->
</div>Optionally, add three more key-value pairs (author_name, author_about, author_avatar) to the context.json file and modify the About Card to this:
<!-- About Card -->
<div class="w3-card w3-margin w3-margin-top">
<img src="/assets/images/{{ author_avatar }}" style="width:50%">
<div class="w3-container w3-white">
<h4><b>{{ author_name }}</b></h4>
<p>{{ author_about }}</p>
</div>
</div>
<hr>Replace the list of featured posts with this Jinja loop:
<!-- Posts -->
<div class="w3-card w3-margin">
<div class="w3-container w3-padding">
<h4>Popular Posts</h4>
</div>
<ul class="w3-ul w3-hoverable w3-white">
{% for post in content.posts if not post.featured %}
<li class="w3-padding-16" onclick="location.href='/{{ post.name }}.html'">
{% if post.cover %}
<img src="/assets/images/{{ post.cover }}" alt="Image" class="w3-left w3-margin-right" style="width:50px">
{% endif %}
<span class="w3-large">{{ post.title }}</span><br>
<span>{{ post.summary }}</span>
</li>
{% endfor %}
</ul>
</div>You might be tempted, running this:
Site_Generator("context.json").jinja_to_html("index.jinja", "index.html")However, there is still a little bit of setup work required. Let's add a generate_site method to the Site_Generator class and run that instead.
def generate_site(self):
"""Generate the static website.
1. Remove the site_dir and re-create it
2. Copy the static directories
3. Render the index page
"""
site_dir = Path.cwd().joinpath(self.context.get("site_dir"))
rmtree(site_dir, ignore_errors=True)
site_dir.mkdir()
for dir in self.context["static_dirs"]:
copytree(dir, site_dir.joinpath(dir))
self.jinja_to_html("index.jinja", "index.html") # render index pageif __name__ == "__main__":
Site_Generator("context.json").generate_site()To preview the just generated index.html file, install the ritwickdey.liveserver VSCode extension, to "launch a development local Server with live reload feature for static & dynamic pages".
I also added this key/value pair to the ./.vscode/settings.json file: "liveServer.settings.root": "/public", which points the local webserver to the site's root directory. For starting the server look at the bottom/right in VSCode.
Notice that this page contains only metadata, information stored in the context.json file and there is still some work ahead of us. The markdown documents still need to be converted into HTML and for that we need another jinja template.
Once again, I used with the index.html file as a starting point and saved it as templates/post.jinja. At its core, it looks something like this now:
<!-- Header -->
<header class="w3-container w3-center w3-padding-32">
<h1><b>{{ post.title }}</b></h1>
<p>{{ post.summary }}</p>
</header>
<!-- Grid -->
<div class="w3-row">
<!-- Post -->
<div class="w3-card-4 w3-margin w3-white">
{% if post.cover %}
<img src="/assets/images/{{ post.cover }}" style="width:100%">
{% endif %}
<div class="w3-container">
{{ post.text }}
</div>
</div>
<hr>
<!-- END Post -->
</div>Converting Markdown to HTML is straightforward, after pip installing the markdown module:
pip install markdowndef md_to_html(self, file_name: str) -> str:
"""Convert a markdown file to html.
Args: file_name: name of the markdown file
Returns: html: the html content
"""
with self.posts.joinpath(file_name).open(encoding="utf-8") as file:
return markdown(file.read().strip(), extensions=['fenced_code', 'codehilite'])The Markdown module has a Code Hilite extension, but it requires Pygments, a generic syntax highlighter, which is unfortunately not installed as a dependency, but is just one more pip install away.
We also need a cool colortheme, which can be previewed here. I liked dracula and therefore ran these commands in VSCode's terminal:
pip install Pygments
mkdir ./assets/css
pygmentize -S dracula -f html -a .codehilite > ./assets/css/dracula.cssA link to the newly created dracula.css file still needs to be put into the post.jinja, which at the very top, now looks like this:
<!DOCTYPE html>
<html>
<head>
<title>{{post.title}}</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/assets/css/dracula.css" />
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Raleway">
<style>
body,h1,h2,h3,h4,h5 {font-family: "Raleway", sans-serif}
</style>
</head>To render all markdown posts, the Site_Generator's generate_site methods needs to be expanded, like shown below:
def generate_site(self):
"""Generate the static website.
1. Remove the site_dir and re-create it
2. Copy the static directories
3. Render the index page
4. Render the posts
"""
site_dir = Path.cwd().joinpath(self.context.get("site_dir"))
rmtree(site_dir, ignore_errors=True)
site_dir.mkdir()
for dir in self.context["static_dirs"]:
copytree(dir, site_dir.joinpath(dir))
self.jinja_to_html("index.jinja", "index.html") # render index page
# render posts
posts = self.context["content"]["posts"]
k = len(posts)
for i, post in enumerate(posts):
post["text"] = self.md_to_html(f"{post['name']}.md")
post["prev"] = posts[i - 1]["name"] if i > 0 else None
post["next"] = posts[i + 1]["name"] if i < k-1 else None
self.context["post"] = post
self.jinja_to_html("post.jinja", f"{post['name']}.html")
self.context["post"] = NoneAfter re-running Site_Generator("context.json").generate_site(), html pages for all posts were generated and code snippets within those pages look beautiful.
I created a new repo on https://github.com and named it static-site-generator. Locally, I ran these commands inside VSCode's terminal:
git init
git branch -M main
git remote add origin [email protected]:wolfpaulus/static-site-generator.gitNot everything needs to be tracked in the repo. While the ./public folder contains the generated html files, it does not belong into the repo since the build process will generate those files. Therefore, I added this to the ./.gitignore file.
.DS_Store
.venv/
public/
__pycache__/
*.pyc
The idea is that everytime a repo change is detected, Site_Generator("context.json").generate_site() is run. To prepare for that, I added this at the bottom of the main.py script:
if __name__ == "__main__":
Site_Generator("context.json").generate_site()Running the main.py script remotely, requires a requirements.txt file that contains all the 3rd party modules that were pip installed along the way. E.g.:
jinja2
markdown
Pygments
Now it's time to add everything to git, commit, and push. It's all in place now to build and host the site at Cloudflare
Deploy your site using Cloudflare Workers for fast and cost-effective hosting. Follow the instructions on Cloudflare Workers to connect your GitHub repository and deploy the site.
After logging in to Cloudflare and navigating to Compute and "Workers & Pages":
- Click the Create button
- Click the Get started button next to "Import a repository"
- Click the Connect to Git button
- Connect your
static-site-generatorrepo - Fill out the Configure your project form:
- Enter a Project name:
static-site-generatorwhich defaults to the name of the git repository. - Enter the git branch:
main - Build command:
python3 main.py - Deploy command:
npx wrangler deploy --compatibility-date 2025-06-04 --assets ./public/ - Click the Save and Deploy button.
Initially, it takes a few minutes for the new domain name to propagate. From now on, every change pushed to Github will result in a regeneration of the site. My site is up and running here now: https://static-site-generator.wolfpaulus.workers.dev. You can also add your own domain or subdomain.
Cloudflare Workers is so much more than CICD, which is what it's used for here. However, it's very cost-effective, and it makes it very easy to deploy code. With regards to CICD, a free account comes with the following limits. E.g. the free plan includes 3.000 build minutes per month .. building the static-site-generator site took 75 seconds.
Once the static assets have been built, requests to static assets are free and unlimited and there is no additional cost for storing those assets.
Transitioning from WordPress to a static site generator using Markdown and Jinja offers a lightweight, efficient, and customizable approach to content management. This guide provides a comprehensive workflow to write content in Markdown, manage metadata with JSON, and leverage Jinja for flexible templating. By setting up a local development environment, automating workflow with GitHub, and deploying via Cloudflare, you can achieve a fast, cost-effective, and streamlined static site that encourages better content creation and management.