oxie
oxie is a small static site/blog generator written in Python. It converts Markdown into a styled, SEO-friendly static website using Jinja2 templates and Tailwind CSS v4.
One package, many sites: each site supplies its own content, templates and
SiteConfig, and oxie does the rest. oxie init gives you all three,
pre-wired and building.
Requirements
- Python 3.10+ (3.9 is the floor, since
zoneinfois stdlib from 3.9, but only 3.10–3.12 are exercised by CI) - Node.js and npm — Tailwind CSS is a required part of the build
Quick start
pip install oxie
oxie init myblog --title "My Blog" --author "Your Name"
cd myblog
npm install # Tailwind CSS v4
python build.py # writes docs/
Open docs/index.html, and you have a working blog: home page, a sample post,
an about page, a blog index and a category page, with compiled Tailwind
styling and syntax highlighting.
oxie init never overwrites an existing file unless you pass --force, so it
is safe to run in a directory that already has content.
What init creates
source/ # content — edit this
index.md # home page
updates.yaml # "Recent updates" on the home page
post/*.md # blog posts
page/*.md # standalone pages
image/ # images, copied to docs/image
static/ # raw files, copied to the site root
src/ # Jinja2 templates + meta_data.json + styles.css
base.html # shared layout the others extend
index.html template.html blog_template.html category_template.html
404.html # served by GitHub Pages for unknown paths
docs/ # generated output (GitHub Pages friendly)
build.py # your site's config
package.json # Tailwind v4 via @tailwindcss/cli
build.py is the whole per-site program:
from oxie import Site, SiteConfig
config = SiteConfig(
collect_dirs={"source/image": "docs/image", "source/static": "docs"},
pygments_style="github-dark",
css_build_command=("npm", "run", "build:css"),
)
if __name__ == "__main__":
Site(config).build()
Styling
Tailwind v4 is configured CSS-first — there is no tailwind.config.js and
no postcss.config.js. Everything lives in src/styles.css:
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source "../src/**/*.html";
@source "../docs/**/*.html";
@source "../source/**/*.md";
Because the Jinja templates in src/ are scanned as well as the generated
HTML in docs/, classes are never purged just because CSS was built before
HTML. Rendered Markdown is wrapped in the typography plugin's prose classes,
and the bundled templates support light and dark via dark: variants.
The templates copied into src/ are yours — edit them freely. If you delete
one, oxie falls back to its bundled copy, so a site always renders; set
use_bundled_templates=False to make a missing template an error instead.
What a build does
Site.build() runs, in order:
clean_old_files()— removes generated HTML, the stylesheet and the image directory from the output folder.generate_html()— Markdown → HTML for every post and page, thenposts_metadata.jsonld(Schema.orgBlogPostingdata), the per-category pages, the blog index, and the home page.render_simple_pages()— any extra one-off template pages.render_photography_page()— optional; parses aphotos.mdalbum file.render_error_page()— optional;404.html.generate_sitemap()— optional;sitemap.xml.generate_feed()— optional; RSS 2.0 atfeed.xml.generate_robots_txt()— optional;robots.txtpointing at the sitemap.collect_static_files()— copies images and static files to the output.generate_thumbnails()— optional; gallery thumbnails via Pillow.build_css()— optional; runs your CSS command (e.g.npm run build:css).build_pygments_css()— optional; syntax-highlighting stylesheet.
Ordering notes: HTML is generated before CSS, so Tailwind sees the
finished HTML — including 404.html, whose classes would otherwise be purged.
The discovery files are written before static files are collected, so a
hand-written source/static/robots.txt overrides the generated one.
Configuration
Every path and feature lives on SiteConfig:
| Field | Default | Purpose |
|---|---|---|
source_dir |
source |
Markdown content root |
template_dir |
src |
Jinja2 templates |
output_dir |
docs |
Generated site |
meta_data_file |
src/meta_data.json |
Site metadata (title, link, image, phrases…) |
use_bundled_templates |
True |
Fall back to oxie's templates for anything the site omits |
markdown_extensions / ..._configs |
pymdownx set | Markdown pipeline |
collect_dirs |
source/image→docs/image, source/static→docs/page |
Static asset copying |
timezone |
"UTC" |
IANA name; a front matter date with no offset is read in this zone |
default_post_time |
time(12, 0) |
Time of day given to a date-only entry |
index_excluded_titles |
Terms of Service, Privacy Policy | Pages hidden from the index listing |
simple_pages |
{} |
template name → output name, rendered once |
photography / photos_md |
False |
Photo album page |
thumbnails / thumbnail_dir / thumbnail_width |
False / … / 600 |
Gallery thumbnails |
updates_file |
source/updates.yaml |
Recent updates for the home page; see Recent updates |
updates_limit |
5 |
How many updates reach the index template |
sitemap / sitemap_file |
True / sitemap.xml |
XML sitemap of every indexable page |
robots_txt / robots_file |
True / robots.txt |
Permissive robots.txt pointing at the sitemap |
feed / feed_file / feed_max_items |
True / feed.xml / 20 |
RSS 2.0 feed of recent posts |
error_page / error_page_template |
True / 404.html |
Not-found page |
css_build_command |
("npm", "run", "build:css") |
Set to None to skip |
pygments_style |
None |
e.g. "github-dark" |
All four discovery outputs build their URLs from meta_data["link"], so that
must be the site's real base URL. Set any switch to False to skip it — the
matching file is then removed from the output directory rather than left
stale.
Content format
Posts and pages use YAML frontmatter with capitalised keys:
---
Title: Silver Age
Summary: A short story about a deduplication officer.
Authors: Puyu Wang
Date: 2026-07-17
Category: Story
Tags: [Story]
Last_modified: 2026-07-18 # optional, defaults to Date
Image: image/cover.jpg # optional, defaults to meta_data["image"]
---
Your markdown here.
index.md uses the Markdown meta extension style instead (Key: value
lines at the top of the file, no --- fences).
Dates
Date and Last_modified take a plain date, a date and time, or a date, time
and offset — they can be mixed freely across a site:
Date: 2026-08-22 # placed at default_post_time (noon)
Date: 2026-08-22 09:30:00 # read in the site timezone
Date: 2026-08-22 09:30:00+01:00 # keeps the offset as written
Whichever form you use, oxie converts it to a timezone-aware datetime as it reads the file, so posts always sort chronologically and the feed, JSON-LD and sitemap agree on when a post was published.
The noon default is deliberate. A date-only entry has to be given some time of
day, and midnight in the site's timezone is still the previous calendar day for
any reader west of it — a post dated the 22nd would show as the 21st. Noon is
the right day for every offset from UTC−12 to UTC+11. Set default_post_time
to change it, and timezone to something other than UTC if your dates are
local:
config = SiteConfig(timezone="Europe/London")
Templates the generator expects in template_dir: template.html (posts and
pages), index.html, blog_template.html, category_template.html, plus any
you list in simple_pages and photography_template.html if enabled. oxie init writes the first four (and a shared base.html they extend); the
generator falls back to its bundled copies for any you remove.
Context passed to each template differs, and the comment at the top of each
bundled template lists exactly what it gets. Every page receives canonical
(its absolute URL) and, where there is structured data to embed, jsonld.
site_url and feed_url are Jinja globals, available in every template
without being passed. Post and page renders additionally get full_link and
image_url for social preview tags.
Recent updates
The home page can show a short list of recent news. oxie passes the five most
recent entries to the index template as updates, taking them from the first
source that applies:
source/updates.yaml, when that file exists;- otherwise the Google Sheet named by
update_spreedsheet_idinmeta_data.json; - otherwise nothing, and the box is hidden.
From a file
Keep updates in source/updates.yaml, next to your content. oxie init
writes a starter file. Updates then live in git, need no network access at
build time, and appear the next time you build.
# Order doesn't matter: oxie sorts by date, newest first.
- date: 2026-05-01
content: >-
Short update. HTML is allowed, e.g.
<a href="https://example.org">a link</a>.
- date: 2025-05-20
content: |-
A talk at the Digital Scholarship coffee morning.
Time: 20th May, 10.30 – 11.30 AM
Venue: Weston Library
- Each entry needs a
dateandcontent. Keys are case-insensitive, soDate/Contentwork too; other keys are ignored. dateisYYYY-MM-DD, or a date and time. Like a post date, a date-only value becomes noon in the sitetimezone.DD/MM/YYYYis not accepted.- Write
contentas a>-block, as above. A plain one-line value breaks as soon as the text contains:("Time: 20th May"). Use|-instead to keep line breaks. contentis inserted as written, so HTML works. Surrounding whitespace is stripped.- An empty file means no updates.
A malformed entry fails the build with an error naming the file and the entry number, rather than being skipped quietly:
ValueError: source/updates.yaml: update 3 has a bad date (use YYYY-MM-DD): …
Set updates_file to read a different path, and updates_limit to show more
or fewer than five. oxie.load_updates_file(path, config) reads a file
directly, if you need the updates elsewhere.
From a Google Sheet
The alternative, for sites with no updates.yaml: if meta_data.json
contains update_spreedsheet_id, oxie reads that public spreadsheet's CSV
export (columns Date and Content) on every build. A failed download fails
the build. This needs the optional Sheets dependencies:
pip install 'oxie[sheets]'
The file wins whenever it exists, so you can keep the key as a fallback while moving to a file.
Development
uv venv && uv pip install -e '.[sheets]' --python .venv/bin/python
.venv/bin/python -m unittest discover -s tests -v
The test suite runs fully offline.
SEO and syndication
Out of the box a built site carries a sitemap.xml, a robots.txt, an RSS 2.0
feed, a 404.html, canonical link tags, Open Graph and Twitter card metadata,
and inline Schema.org JSON-LD (BlogPosting per post, ItemList on the home
page). posts_metadata.jsonld is still written for anything that consumed it.
Generated files are deterministic: no build timestamps are used anywhere,
so rebuilding unchanged content produces byte-identical output instead of a
spurious diff. Note that the bundled base.html picks a random footer phrase,
which does change per build — pin phrases to a single entry if you need
fully reproducible HTML.
Status
Version 0.5.0.
Three behaviours carried over from that codebase were fixed in 0.4.0 and will
change your output on upgrade: category order on the blog index is now sorted
rather than set-iteration order; IndexPage no longer overwrites
meta_data["title"]; and the home page description is no longer truncated to
its first character. The metadata key update_spreedsheet_id keeps its
original spelling, since renaming it would break existing sites.
Date handling was reworked in 0.4.1 and changes generated output once, with no
front matter edits needed: feed pubDate moves from midnight to noon, and
JSON-LD dates gain a time and offset.
0.5.0 adds source/updates.yaml as a source for recent updates. Existing sites
build exactly as before until they add the file. See
CHANGELOG.md.
Licence
Apache License 2.0 — see the LICENSE.
Metadata
Release files for oxie 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| oxie-0.5.0.tar.gz | 53.2 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| oxie-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 96.1 kB
Release files / oxie-0.5.0.tar.gz
| Download URL | oxie-0.5.0.tar.gz |
|---|---|
| Size | 53.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
964db34ef6a0ad9ae1391eb6f3f4c93cbff8a6b4d3e65aa537cabb254eceffb9
|
|
BLAKE2b-256 checksum How to use checksums |
06f945cde0a9b6766f68cf2df8f8394e26739d3d8bdb183fc85d5370c866ca04
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logRelease files / oxie-0.5.0-py3-none-any.whl
| Download URL | oxie-0.5.0-py3-none-any.whl |
|---|---|
| Size | 42.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6d0619f02e2a69bf367061edaa691c44887d76e16ca18f4f1314176719532e14
|
|
BLAKE2b-256 checksum How to use checksums |
d3be5e87ce8bac27f07452550f9cfe986a5faff627aa01cd79177e535a09f13b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logSecurity for oxie 0.5.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for oxie-0.5.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.