Markdown → static site generator

A Jinja2 site builder with generators, async file I/O, watch mode, and a published package.

🛠 Intermediate

Goal

Convert a folder of Markdown files + templates into a static website. Live-reload during development. Build for production. Distribute as a CLI on PyPI.

Why this project

A static site generator (SSG) is the perfect mid-sized Python project: real-world value (your portfolio site, docs site), exercise async I/O (file watching, dev server), templating, packaging, plugin extension points, and a useful artifact you'll keep using.

User experience

powershell
siteforge new my-blog
cd my-blog
siteforge dev                # http://localhost:8000 with live reload
siteforge build              # output: dist/
siteforge deploy --target github-pages

Project layout it generates:

my-blog/
├── site.toml                # config
├── content/
│   ├── index.md
│   ├── about.md
│   └── posts/
│       ├── hello.md
│       └── 2026-launch.md
├── templates/
│   ├── base.html.j2
│   ├── post.html.j2
│   └── index.html.j2
├── static/
│   ├── style.css
│   └── images/
└── dist/                    # build output (gitignored)

Tech stack

  • markdown-it-py (Markdown → HTML, CommonMark + plugins).
  • Jinja2 (templating).
  • watchfiles (file watcher).
  • aiohttp or uvicorn + starlette (dev server with WebSocket reload).
  • httpx (deploy uploads).
  • typer + rich (CLI).
  • pydantic (config validation).

Architecture

src/siteforge/
├── __init__.py
├── cli.py                   # typer commands: new, dev, build, deploy
├── config.py                # site.toml -> SiteConfig (pydantic)
├── content.py               # walk content/, parse front-matter, render md
├── render.py                # jinja2 environment + page renderer
├── pipeline.py              # build orchestration (parallel)
├── server.py                # dev server + WebSocket
├── watcher.py               # watchfiles wrapper
├── plugins.py               # plugin loading via entry points
├── deploy/
│   ├── github_pages.py
│   ├── netlify.py
│   └── s3.py
└── templates_default/       # `siteforge new` scaffold

Spec

site.toml

toml
[site]
title = "My Blog"
base_url = "https://example.com"
language = "en"

[build]
output = "dist"
clean = true
draft = false

[markdown]
plugins = ["footnote", "tasklist", "anchor"]

[server]
host = "127.0.0.1"
port = 8000

[[plugins]]
name = "sitemap"

[[plugins]]
name = "rss"
limit = 20

Front-matter

markdown
---
title: Hello world
date: 2026-06-07
tags: [intro, meta]
draft: false
template: post.html.j2
---

# Hello

This is **markdown**.

Page model

python
@dataclass(slots=True)
class Page:
    src: Path
    out: Path
    url: str
    title: str
    date: datetime
    tags: list[str]
    template: str
    body_html: str
    meta: dict[str, Any]

Build pipeline

  1. Load site.toml.
  2. Walk content/ → list of source paths.
  3. Parse front-matter + render Markdown → Page objects (concurrent via ProcessPoolExecutor).
  4. Run plugins (sitemap, rss, taxonomies).
  5. Render pages through Jinja2 (concurrent threads — I/O bound).
  6. Copy static/dist/.
  7. Print summary: 42 pages, 18 posts, built in 0.42s.

Dev server

  • Watches content/, templates/, static/, site.toml.
  • On change: rebuild only affected pages, push reload signal via WebSocket.
  • Browser injects a tiny <script> that reconnects + reloads on signal.

Plugin entry points

toml
[project.entry-points."siteforge.plugins"]
sitemap = "siteforge.plugins.sitemap:plugin"
rss = "siteforge.plugins.rss:plugin"
search = "my_plugin:search_plugin"

A plugin is a Callable[[Site], None] that mutates the site (adds pages, pre/post hooks).

Acceptance criteria

  1. siteforge new creates a working starter site in < 1 s.
  2. siteforge build of 100 posts: under 2 s on a modern laptop.
  3. siteforge dev reloads browser in < 200 ms after a Markdown edit.
  4. Tests cover: front-matter, Markdown rendering, taxonomies, plugin loading.
  5. mypy --strict passes.
  6. Published to PyPI; install + run works on fresh machine.
  7. Docs site (built with siteforge itself) at https://siteforge.example.com.

Stretch goals

  • Asset pipeline: PostCSS, image optimisation (Pillow), thumbnails.
  • Built-in search (Lunr.js index generation).
  • Pagination + taxonomies (tags, categories, archives).
  • Multilingual: content/en/, content/fr/ with cross-links.
  • Hot module reload for templates (no full page reload).
  • Plugins: SEO meta tags, image lazy-loading, syntax highlighting (pygments).
  • Theme system: install themes via PyPI.

Key implementation hints

Front-matter parsing

python
import re
import tomllib

FM_RE = re.compile(r"^---\n(.*?)\n---\n(.*)$", re.DOTALL)

def split_front_matter(text: str) -> tuple[dict, str]:
    m = FM_RE.match(text)
    if not m:
        return {}, text
    meta = tomllib.loads(m.group(1))     # support YAML too via PyYAML
    return meta, m.group(2)

Markdown rendering with plugins

python
from markdown_it import MarkdownIt
from mdit_py_plugins.anchors import anchors_plugin

md = (
    MarkdownIt("commonmark", {"breaks": True, "html": True})
    .enable("table")
    .use(anchors_plugin, max_level=3)
)
html = md.render(markdown_text)

Parallel render

python
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor() as pool:
    pages = list(pool.map(render_one, src_paths))

Dev server with reload

python
import asyncio
from watchfiles import awatch

async def dev_server(site: Site):
    server_task = asyncio.create_task(run_http_server(site))
    async for changes in awatch(site.content_dir, site.templates_dir):
        site.rebuild(changes)
        await broadcast({"event": "reload"})

WebSocket reload injection

The dev server appends to every HTML response before </body>:

html
<script>
const ws = new WebSocket(`ws://${location.host}/_ws`);
ws.onmessage = (e) => { if (JSON.parse(e.data).event === "reload") location.reload(); };
</script>

Deliverables

  • GitHub repo with full README, badges, gallery of demo sites.
  • Live demo site built with siteforge itself.
  • A 5-min screencast: "From siteforge new to GitHub Pages in 2 minutes."

Lessons exercised

  • 01_fundamentals (files, regex, exceptions)
  • 02_oop (dataclasses, pydantic, protocols)
  • 03_advanced (generators, decorators)
  • 04_stdlib_and_modern (asyncio, concurrency)
  • 06_testing
  • 08_web_and_apis (HTTP for deploy)
  • 09_packaging (entry points!)

Total time: 15–20 hours core; 30–50 hours with stretch goals + theme.