How This Blog Works: A 200-Line Python Static Site Generator

How This Blog Works

Most developers reach for Hugo, Jekyll, or Next.js when they need a blog. I wrote my own static site generator in about 200 lines of Python. It does everything I need: Markdown to HTML, Jinja2 templates, tags, archives, sitemap, and automatic deployment via GitLab CI/CD.

Here's how it all fits together.

graph LR
    A[Markdown posts] --> B[build.py]
    B --> C[Jinja2 templates]
    C --> D[Static HTML]
    D --> E[GitLab CI]
    E --> F[GitLab Pages]

The entire codebase:

build.py:    191 lines
templates:   222 lines
CSS:         504 lines

That's it. No framework, no plugins, no magic.

Project Structure

.
├── content/          # Markdown posts with frontmatter
├── static/           # CSS, images, favicon, llms.txt
├── templates/        # Jinja2 HTML templates
├── build.py          # The entire static site generator
├── build_test.py     # pytest test suite
├── Makefile          # Build automation
├── .gitlab-ci.yml   # CI/CD pipeline
└── public/           # Generated output (gitignored)

Source content goes in, static HTML comes out.

No database. No JavaScript framework. No build toolchain. Just Python.

The Build Script

The core of the blog is build.py. It does five things:

  1. Copies static assets (CSS, images) to public/
  2. Parses Markdown files with frontmatter metadata
  3. Renders HTML using Jinja2 templates
  4. Generates tag pages grouping posts by topic
  5. Creates machine-readable files — sitemap.xml, robots.txt, and llms.txt

Parsing Posts

Each Markdown file has YAML frontmatter at the top:

---
title: "My Post Title"
date: "2026-03-10"
tags: [Python, CI/CD]
description: "A short summary for SEO and social cards."
---

The python-frontmatter library parses this, and Python's markdown library converts the body to HTML:

post = frontmatter.load(filepath)
html = markdown.markdown(
    post.content, extensions=["fenced_code", "tables"]
)

Posts marked draft: true are skipped entirely. Posts with invalid or missing dates are logged as warnings and excluded from the build.

Jinja2 Templates

Templates use Jinja2's inheritance model. A single base.html.j2 defines the page shell — sidebar, nav, footer, meta tags — and child templates fill in the content:

base.html.j2          # Layout, head, sidebar, footer
├── index.html.j2     # Homepage with post list
├── post.html.j2      # Individual blog post
├── tag.html.j2       # Posts filtered by tag
└── archives.html.j2  # All posts grouped by year

The base template handles Open Graph tags, Twitter Cards, canonical URLs, and dynamic copyright year — all in one place. Child templates only define what's unique to each page type.

Tags and Archives

Tags are collected during parsing and stored in a dictionary mapping each tag to its list of posts. The generator creates a separate HTML page per tag at /tags/<tag>.html, making every tag linkable and crawlable.

Archives group posts by year using an OrderedDict, so the template receives pre-sorted data and stays logic-free.

Styling

The entire blog is styled with a single CSS file using CSS custom properties for theming:

:root {
  --bg: #1e1e1e;
  --text: #e0e0e0;
  --accent: #4ec9b0;
  --muted: #888;
}

This makes it trivial to adjust the color scheme globally. The layout is a sidebar + content flexbox that collapses to a single column on mobile via one @media query. No CSS framework, no preprocessor.

Syntax highlighting is handled client-side by highlight.js with the github-dark theme — zero build-time cost.

CI/CD Pipeline

The .gitlab-ci.yml defines three stages:

stages:
  - lint
  - test
  - build

On merge requests, the pipeline runs:

  • lint-markdown — validates Markdown with markdownlint
  • lint-python — checks Python with flake8
  • test-python — runs the pytest suite

On push to main, the pipeline runs:

  • pages — installs dependencies, runs build.py, and deploys public/ to GitLab Pages

That's the entire GitOps workflow: write a post in Markdown, push to main, and GitLab builds and deploys the site automatically. No manual steps, no SSH, no FTP.

Testing

The test suite in build_test.py covers the build logic:

  • Parsing valid posts returns correct metadata and HTML
  • Draft posts are excluded
  • Invalid dates produce warnings and are skipped
  • Missing frontmatter fields get sensible defaults
  • Year-based grouping for archives works correctly

Running tests locally:

make test    # runs flake8 + pytest
make lint    # runs flake8 only

Local Development

The Makefile handles everything:

make build   # generate the site into public/
make serve   # build + serve at localhost:8000
make clean   # delete public/
make test    # lint + test

The first run creates a virtual environment automatically, so there's no setup beyond having Python 3 installed.

Why Not Use Hugo or Jekyll?

Three reasons:

  1. Simplicity — the entire generator is one Python file. I can read and modify every line of it. There's no plugin system to learn, no theme marketplace to navigate, no config file with 50 options.

  2. Control — I decide exactly how Markdown maps to HTML, how tags work, what goes in the sitemap. When I wanted to add table support, it was one line: adding "tables" to the extensions list.

  3. Learning — building it taught me more about static site generation, Jinja2, SEO, and CI/CD than any framework would have. The constraints forced me to understand every layer.

Machine-Readable Files

The generator also produces a few files for machines, not humans:

  • robots.txt — tells search crawlers what to index
  • sitemap.xml — lists every page for search engines
  • llms.txt — a plain-text site summary for AI crawlers and language models

The llms.txt format is relatively new but increasingly adopted. It gives AI systems a structured overview of who you are and what your site contains, without requiring them to crawl every page.

Future Improvements

A few things I may add later:

  • RSS/Atom feed generation
  • Automatic Open Graph image generation
  • Full-text search with a lightweight JS index
  • Incremental builds (only rebuild changed posts)

Source Code

The full source is available on GitLab: jamorenasimon/itsfoss

← Back to Home