Markdown → static site generator
A Jinja2 site builder with generators, async file I/O, watch mode, and a published package.
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-pagesProject 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).aiohttporuvicorn + 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` scaffoldSpec
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 = 20Front-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
- Load
site.toml. - Walk
content/→ list of source paths. - Parse front-matter + render Markdown →
Pageobjects (concurrent viaProcessPoolExecutor). - Run plugins (sitemap, rss, taxonomies).
- Render pages through Jinja2 (concurrent threads — I/O bound).
- Copy
static/→dist/. - 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
siteforge newcreates a working starter site in < 1 s.siteforge buildof 100 posts: under 2 s on a modern laptop.siteforge devreloads browser in < 200 ms after a Markdown edit.- Tests cover: front-matter, Markdown rendering, taxonomies, plugin loading.
- mypy --strict passes.
- Published to PyPI; install + run works on fresh machine.
- Docs site (built with
siteforgeitself) athttps://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 newto 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.