site is a simple, lightweight static site generator built in Python. It was extracted from my personal blog and designed to be easy to use with clear goals in mind.
Mostly because I'm lazy and got tired of trying to learn other systems like Hugo. But it also comes with very clear goals:
If you have uv installed, you can run commands directly without cloning the repository:
uvx --from git+https://github.com/bradmontgomery/site site inituvx --from git+https://github.com/bradmontgomery/site site builduvx --from git+https://github.com/bradmontgomery/site site newuvx --from git+https://github.com/bradmontgomery/site site new \
--title "My Post" --tags "python,web" \
--description "A quick note." --no-draftuvx --from git+https://github.com/bradmontgomery/site site serverClone the repository and install dependencies with uv:
$ git clone https://github.com/bradmontgomery/site
$ cd site
$ uv syncThen use commands with uv run:
$ uv run site init # Initialize site structure
$ uv run site build # Build the site
$ uv run site new # Create a new post
$ uv run site server # Run preview serverOrganize your content in the following directory structure:
content/
blog/
<title-slug>/index.md
page/
<title>.mdThe index page should be a listing of recent posts.
Run uvx --from git+https://github.com/bradmontgomery/site site --help for detailed command information:
Usage: site [OPTIONS] COMMAND [ARGS]...
Static site generator.
╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ build Build the site. │
│ init Initialize a new site structure. │
│ new Create a new blog post. │
│ server Run a local preview HTTP server. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯Blog post dates are stored and transmitted in UTC. When a page loads, a small JavaScript module (timezone.js) converts <time> elements to the reader's local timezone using the browser's Intl.DateTimeFormat API.
Use the Jinja macro for consistent markup:
{% import 'macros/dates.html' as dates %}
{{ dates.local_date(post.date, post.date_iso) }}
{{ dates.local_date(post.date, post.date_iso, format='long') }}
{{ dates.local_date(post.date, post.date_iso, format='date-only') }}If JavaScript is disabled, readers see the UTC fallback text rendered by the server. RSS/Atom feeds are unaffected and continue to use UTC.
A small inline-SVG sparkline showing blog post counts per week over the last 90 days (13 weekly buckets). Bar height encodes posts published in that 7-day window; weeks with zero posts render no bar. Purely visual — no interactivity.
The default index.html includes it in the page header. Custom templates can opt in by including the partial:
{% include "_sparkline.html" %}Two CSS hooks let you restyle it from your own stylesheet: .sparkline targets the <svg> container (size, color), and .sparkline-bar targets each <rect> bar (fill, opacity).
Any tag archive page (/blog/tags/<tag>/) can get its own look. Drop a template named base_<tag>.html into your templates_dir and it becomes the Jinja parent for that tag's list page. Opt-in, one tag at a time: without a matching file the page renders from the default base.html. Output is functionally unchanged from earlier versions, though not byte-identical (for example tags.html now loads timezone.js).
The filename uses the normalized tag, the same rule that builds the URL: ASCII-folded (accents dropped, non-ASCII removed), lowercased, non-word characters removed, spaces and dashes collapsed to -. A tag written Home Cooking lives at /blog/tags/home-cooking/ and is themed by base_home-cooking.html; Café becomes base_cafe.html. Tags with no ASCII characters can't be themed.
{% extends "base.html" %}
{% block head %}
<link rel="stylesheet" href="/static/css/food.css">
{% endblock %}
{% block header %}
<header class="food-theme"><h1>{{ tag }}</h1></header>
{% endblock %}Anything you leave out falls through to the shipped markup — the example above restyles the header and adds a stylesheet while the post list is still rendered by the default index.html. Tag list pages receive tag, posts, title and subtitle. Only per-tag archives are themable; the tag index at /blog/tags/ always renders from tags.html.
Existing sites keep building unchanged. If your templates_dir has its own base.html without a page_content block, a custom index.html that doesn't extend base_template, or a theme file with a non-normalized name, the build logs a warning explaining how to opt in.