Contents

How to Build a Markdown Blog Engine in 100 Lines of Python

You can build a working static site generator in about 100 lines of Python. The result reads Markdown files from a content directory, parses their YAML front matter, converts the Markdown to HTML, wraps everything in Jinja2 templates, and writes the output to a public/ folder ready to be served by any web server. It is the same fundamental pipeline that powers tools like Hugo , Jekyll , and Eleventy - just stripped down to the essentials so you can see exactly how the pieces fit together.

Building your own teaches you things that documentation alone cannot. When you later configure Hugo’s content directory or customize a Jekyll layout, you will already understand what is happening underneath: front matter parsing, template inheritance, slug generation, output directory structure. These concepts click into place much faster when you have written the code yourself.

Why Build Your Own Static Site Generator

A static site generator does three things. It reads source files (usually Markdown), converts them into HTML, and wraps that HTML in templates before writing the output to disk. Everything else - tags, pagination, RSS feeds, asset pipelines - is layered on top of that core loop.

That simplicity makes building one from scratch a useful exercise. You stop treating your SSG as a black box and start seeing it as a sequence of straightforward operations. The configuration files make more sense. The error messages make more sense. You develop an intuition for where things can go wrong in the content pipeline because you built the pipeline yourself.

The scope of this project is deliberately limited. You will end up with a single-template blog that has an index page listing all posts sorted by date, individual post pages with titles and publication dates, and static asset copying. There are no tags, no pagination, and no RSS feed. Those are natural extensions, and we will discuss them later, but the core engine stays under 100 lines precisely because it skips them.

Python is a good fit for this kind of project. The standard library handles file operations and directory traversal, and three pip packages cover everything else. There are no compiled dependencies, no build chains, and no configuration files to set up before you can start writing code.

The Core Dependencies and Project Structure

The project layout follows a convention that should look familiar if you have used any static site generator before:

my-blog/
  content/
    posts/
      hello-world.md
      second-post.md
  templates/
    base.html
    post.html
    index.html
  static/
    style.css
  public/          # generated output goes here
  build.py         # the generator script

Source Markdown files live in content/posts/. Jinja2 templates define the HTML structure. Static assets like CSS files are copied directly to the output. The public/ directory is what you deploy.

Install the three required packages:

pip install markdown Jinja2 python-frontmatter

That is the entire requirements.txt. For pinned versions, use markdown==3.10.2, Jinja2==3.1.6, and python-frontmatter==1.1.0, which are the current releases as of early 2026.

Here is what each package does:

  • python-frontmatter parses YAML front matter between --- delimiters. It returns a Post object with .metadata (a dictionary of your front matter fields) and .content (the raw Markdown string below the front matter). It handles the same format used by Hugo and Jekyll.
  • Python-Markdown converts Markdown text to HTML. It supports extensions like fenced_code, tables, and toc that you can enable with a single parameter.
  • Jinja2 is the template engine used by Flask and Ansible. It loads templates from a directory and renders them with variables you pass in. Template inheritance via {% extends %} and {% block %} lets you share a common HTML skeleton across pages.

Each Markdown post uses this front matter convention:

---
title: "My First Post"
date: 2026-03-15
slug: "my-first-post"
draft: false
---

The slug field is optional. If missing, the build script derives it from the filename. The draft field lets you exclude unfinished posts from the build.

Writing the Build Script Step by Step

The entire generator lives in a single build.py file. We will go through it in stages, then see the complete script at the end.

Loading Posts

The first step is finding all Markdown files and parsing them:

import frontmatter
from pathlib import Path

def load_posts(content_dir="content/posts"):
    posts = []
    for filepath in Path(content_dir).glob("*.md"):
        post = frontmatter.load(filepath)
        if post.metadata.get("draft", False):
            continue
        slug = post.metadata.get("slug", filepath.stem)
        posts.append({
            "title": post.metadata["title"],
            "date": post.metadata["date"],
            "slug": slug,
            "content": post.content,
        })
    return sorted(posts, key=lambda p: p["date"], reverse=True)

pathlib.Path.glob("*.md") finds every Markdown file in the content directory. frontmatter.load() handles the YAML parsing. Draft posts are skipped. If a post lacks a slug field, filepath.stem extracts the filename without its extension - so hello-world.md becomes hello-world.

Converting Markdown to HTML

Each post’s raw Markdown content needs to be converted:

import markdown

def render_markdown(text):
    return markdown.markdown(text, extensions=["fenced_code", "tables", "toc"])

The fenced_code extension enables triple-backtick code blocks. The tables extension handles pipe-delimited tables. The toc extension generates a table of contents from headings if you ever need one. You can add more extensions later without changing the function signature.

Rendering Templates

Jinja2 loads templates from a directory and renders them with the variables you provide:

from jinja2 import Environment, FileSystemLoader

def get_templates(template_dir="templates"):
    env = Environment(loader=FileSystemLoader(template_dir))
    return {
        "post": env.get_template("post.html"),
        "index": env.get_template("index.html"),
    }

Writing the Output

The final step creates the output directory structure and writes the rendered HTML:

import shutil

def build(output_dir="public"):
    output = Path(output_dir)
    if output.exists():
        shutil.rmtree(output)
    output.mkdir(parents=True)

    posts = load_posts()
    templates = get_templates()

    for post in posts:
        post["html_content"] = render_markdown(post["content"])
        post_dir = output / "posts" / post["slug"]
        post_dir.mkdir(parents=True, exist_ok=True)
        html = templates["post"].render(
            title=post["title"],
            date=post["date"],
            content=post["html_content"],
        )
        (post_dir / "index.html").write_text(html)

    index_html = templates["index"].render(posts=posts)
    (output / "index.html").write_text(index_html)

    static_src = Path("static")
    if static_src.exists():
        shutil.copytree(static_src, output / "static")

    print(f"Built {len(posts)} posts to {output_dir}/")

if __name__ == "__main__":
    build()

Each post gets its own directory at public/posts/<slug>/index.html, which gives you clean URLs. The index page gets the full sorted post list. Static assets are copied verbatim.

The Complete Script

Here is the whole thing in one block, ready to copy into a file:

#!/usr/bin/env python3
"""Minimal static blog generator in ~80 lines of Python."""

import shutil
from pathlib import Path

import frontmatter
import markdown
from jinja2 import Environment, FileSystemLoader


def load_posts(content_dir="content/posts"):
    """Load and parse all non-draft Markdown posts."""
    posts = []
    for filepath in sorted(Path(content_dir).glob("*.md")):
        post = frontmatter.load(filepath)
        if post.metadata.get("draft", False):
            continue
        slug = post.metadata.get("slug", filepath.stem)
        posts.append({
            "title": post.metadata["title"],
            "date": post.metadata["date"],
            "slug": slug,
            "content": post.content,
        })
    return sorted(posts, key=lambda p: p["date"], reverse=True)


def render_markdown(text):
    """Convert Markdown text to HTML with common extensions."""
    return markdown.markdown(
        text, extensions=["fenced_code", "tables", "toc"]
    )


def get_templates(template_dir="templates"):
    """Load Jinja2 templates from disk."""
    env = Environment(loader=FileSystemLoader(template_dir))
    return {
        "post": env.get_template("post.html"),
        "index": env.get_template("index.html"),
    }


def build(output_dir="public"):
    """Build the static site."""
    output = Path(output_dir)
    if output.exists():
        shutil.rmtree(output)
    output.mkdir(parents=True)

    posts = load_posts()
    templates = get_templates()

    # Render individual post pages
    for post in posts:
        post["html_content"] = render_markdown(post["content"])
        post_dir = output / "posts" / post["slug"]
        post_dir.mkdir(parents=True, exist_ok=True)
        html = templates["post"].render(
            title=post["title"],
            date=post["date"],
            content=post["html_content"],
        )
        (post_dir / "index.html").write_text(html)

    # Render index page
    index_html = templates["index"].render(posts=posts)
    (output / "index.html").write_text(index_html)

    # Copy static assets
    static_src = Path("static")
    if static_src.exists():
        shutil.copytree(static_src, output / "static")

    print(f"Built {len(posts)} posts to {output_dir}/")


if __name__ == "__main__":
    build()

That is 78 lines including imports, docstrings, and the main block. The remaining 20 or so lines of your budget can go toward error handling, which we will cover shortly.

Creating the Jinja2 Templates

The templates define your blog’s HTML structure. You need three files.

base.html - the shared skeleton:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}My Blog{% endblock %}</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <nav><a href="/">Home</a></nav>
    <main>
        {% block content %}{% endblock %}
    </main>
</body>
</html>

post.html - individual post pages:

{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<article>
    <h1>{{ title }}</h1>
    <time datetime="{{ date }}">{{ date }}</time>
    {{ content | safe }}
</article>
{% endblock %}

The | safe filter tells Jinja2 not to escape the HTML that Python-Markdown already generated. Without it, you would see raw HTML tags rendered as text on the page.

index.html - the post listing:

{% extends "base.html" %}
{% block title %}Blog{% endblock %}
{% block content %}
<h1>Posts</h1>
<ul>
    {% for post in posts %}
    <li>
        <a href="/posts/{{ post.slug }}/">{{ post.title }}</a>
        <time datetime="{{ post.date }}">{{ post.date }}</time>
    </li>
    {% endfor %}
</ul>
{% endblock %}

Template inheritance is the key concept here. Both post.html and index.html extend base.html, so changes to the navigation or head section happen in one place. This is the same pattern used in Django and Flask, so understanding it here transfers directly to web framework development.

For minimal but readable styling, add a static/style.css:

body {
    max-width: 720px;
    margin: 0 auto;
    padding: 1rem;
    font-family: system-ui, -apple-system, sans-serif;
    line-height: 1.6;
    color: #333;
}
a { color: #0066cc; }
time { color: #666; font-size: 0.9rem; }
pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; }

A Sample Post and Running the Build

Create a test post at content/posts/hello-world.md:

---
title: "Hello World"
date: 2026-03-15
---

This is my first post built with a **custom static site generator**.

## A Code Example

Here is some Python:

```python
print("Hello from my blog engine!")

And a simple table:

FeatureSupported
Front matterYes
Code blocksYes
TablesYes

Run the build:

```bash
python build.py

You should see Built 1 posts to public/. Serve the output with Python’s built-in HTTP server:

python -m http.server 8000 -d public

Open http://localhost:8000 in a browser, and you have a working blog.

Adding Error Handling and a Serve Flag

The basic script assumes perfect input. Real posts will have typos in front matter, missing required fields, or duplicate slugs. Here is how to handle that without bloating the code:

import re
import sys

def validate_post(post_data, filepath):
    """Check for required fields and valid slug format."""
    required = ["title", "date"]
    for field in required:
        if field not in post_data.metadata:
            print(f"Warning: {filepath} missing '{field}', skipping")
            return False
    slug = post_data.metadata.get("slug", filepath.stem)
    if not re.match(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", slug):
        print(f"Warning: {filepath} has invalid slug '{slug}', skipping")
        return False
    return True

Add a --serve flag so you can build and preview in one command:

if __name__ == "__main__":
    build()
    if "--serve" in sys.argv:
        import http.server
        import functools
        handler = functools.partial(
            http.server.SimpleHTTPRequestHandler, directory="public"
        )
        server = http.server.HTTPServer(("localhost", 8000), handler)
        print("Serving at http://localhost:8000")
        server.serve_forever()

Now python build.py --serve builds the site and starts a local server on port 8000.

Extending the Engine Beyond 100 Lines

Once the core works, there are several natural next steps. Each one maps to a feature you have seen in full-featured SSGs.

The most obvious addition is an RSS feed. Render a feed.xml Jinja2 template with the 10 most recent posts. This adds roughly 15 lines and produces the same kind of feed Hugo generates automatically. The template uses the same {% for post in posts[:10] %} loop as the index page, but outputs XML instead of HTML.

Tag support is another common extension. Parse a tags list from front matter, collect all unique tags, and build a tags/<tag>/index.html page for each one listing its posts. This adds about 25 lines and mirrors Hugo’s taxonomy system. The data structure is a dictionary mapping tag names to lists of posts.

Pagination comes up once your index page gets long. Split it into pages of 10 posts each, generating page/2/index.html, page/3/index.html, and so on. This adds about 20 lines. Divide the total post count by the page size, iterate over the chunks, and render each one with “newer” and “older” navigation links.

For a proper development workflow, add live reload using the watchdog library to monitor content/ and templates/ for changes, re-run the build automatically, and combine it with the HTTP server. This adds about 30 lines and replicates the hugo server experience. Install it with pip install watchdog and set up a FileSystemEventHandler that calls your build() function on every file change.

Finally, syntax highlighting is a quick win. Enable the codehilite extension in Python-Markdown and generate a Pygments CSS file with pygmentize -S monokai -f html > static/codehilite.css. This gives you server-side syntax highlighting with no JavaScript required.

How It Compares to Real Static Site Generators

This Python engine is not going to replace Hugo or Eleventy for a production blog. The performance gap is real. Hugo, written in Go, builds 10,000 pages in roughly one second. This Python script handles 100 posts in about half a second, which is perfectly fine for a personal blog but shows why Go-based generators dominate at scale.

GeneratorLanguage100 Posts1,000 Posts10,000 Posts
HugoGo~0.1s~0.3s~1.0s
EleventyJavaScript~0.5s~3.0s~25s
JekyllRuby~2.0s~12s~120s
build.pyPython~0.5s~4.0s~35s

The build times above are approximate and depend on hardware, template complexity, and post length. Hugo’s advantage comes from Go’s compiled nature and its use of parallel rendering. For a personal blog with under 500 posts, the difference between half a second and a tenth of a second is irrelevant.

Where this project does deliver value is in understanding. After building your own generator, deploying the output is the same regardless of which tool created it. The public/ directory contains plain HTML, CSS, and images. You can host it on Netlify by dragging the folder into their dashboard, on Cloudflare Pages by connecting your Git repository, or on GitHub Pages by pushing the output to a gh-pages branch. The deployment target does not care whether Hugo or your 100-line Python script built the files. If you are deciding which generator to use for a real project, the 2026 static site generator comparison covers Hugo, Eleventy, Astro, and their tradeoffs in depth.

Packaging as a CLI Tool

If you want to make the generator installable and shareable, wrap it in a small CLI using argparse :

import argparse

def main():
    parser = argparse.ArgumentParser(description="Minimal blog generator")
    parser.add_argument("--content", default="content/posts", help="Content directory")
    parser.add_argument("--output", default="public", help="Output directory")
    parser.add_argument("--serve", action="store_true", help="Start dev server after build")
    parser.add_argument("--port", type=int, default=8000, help="Dev server port")
    args = parser.parse_args()

    build(content_dir=args.content, output_dir=args.output)

    if args.serve:
        serve(args.output, args.port)

Add a pyproject.toml with a [project.scripts] entry, and pip install -e . gives you a blog-build command you can run from anywhere. The whole packaging setup is about 15 lines of TOML. For richer terminal interfaces beyond argparse, Textual and Rich give you full-screen Python apps with widgets and reactive state.

This exercise gives you a mental model of what every static site generator does under the hood. Once you have written the content pipeline yourself, tools like Hugo become engineering decisions you can reason about rather than opaque software you configure by trial and error.