A modern, Python-first static site generator — routes-as-code, content collections, deterministic + incremental builds, and a no-JavaScript-by-default philosophy.
pip install epresso
epresso new blog myblog && cd myblog
epresso dev # develop with live reload
epresso build # deterministic, incremental → dist/- Content and routes are separate.
content/is data (collections, validated with Pydantic);pages/is where routes live as templates that consume the data — a content-first model in Python. - Incremental from day one. A content digest + build graph + reverse index means a content edit re-renders only the affected pages.
- Deterministic builds. Same source + config → same output (hashed filenames, stable ordering).
- JS is optional. A pure-Python site needs zero Node. esbuild/Tailwind/PostCSS are external binaries used only when you declare JS/CSS.
- No arbitrary Python in templates. Templates see a curated set of globals — safe and predictable.
- Content collections (glob + Python/remote loaders) with Pydantic schemas, data collections (JSON/YAML/TOML), content references, drafts & scheduled-content exclusion.
- Routes-as-code: filesystem routing,
{param}/{...param}dynamic routes,get_static_paths()sidecars,.epsingle-file routes (Python frontmatter + Jinja), static endpoints (JSON/XML/text), clean URLs, redirects. - Jinja2 templates with inheritance, curated globals (
url,asset,image,picture,seo,get_collection,get_entry, …). .epcomponents with strict PydanticPropsvalidation, scoped CSS (<style>blocks,:global()opt-out), and client<script>blocks (bundled page-level JS).- Dev server (Starlette + watchfiles) with WebSocket live reload, sharing the production incremental engine.
- Assets: content-hashed
asset(),public/passthrough, esbuild JS bundling, PostCSS/Tailwind CSS pipeline, Pillow responsive images (image()→ WebP srcset). - First-class client behavior — a
.epcomponent's<script>block is bundled (esbuild) page-level JS, injected before</body>. No separate islands/ dir. - Generated outputs:
sitemap.xml,robots.txt,404.html,search-index.json, and an RSS/Atom helper. - Plugin API — a capability registry: named plugins with lifecycle hooks that receive a scoped
Capabilitieshandle (never the rawSite), so extensions are deterministic and isolated. See the plugin guide. - Theme scaffolding —
epresso new docs|blog.
Requires Python 3.12+.
pip install epresso # or: uv tool install epressoepresso new blog mysite # scaffold from the blog theme (or: epresso new docs)
cd mysite
epresso dev # http://127.0.0.1:4321 with live reload (next free port if busy)
epresso build # deterministic + incremental build → dist/
epresso preview # build then serve dist/ (production preview)
epresso check # validate config + content, list routesA blank project:
epresso init mysite && cd mysite
epresso buildsite.toml # configuration (TOML)
content.config.py # define collections + schemas (Pydantic)
content/ # data — collections (markdown / JSON / YAML / TOML)
pages/ # routes (.ep, .md, or .py endpoints)
layouts/ # layout templates (.ep)
components/ # reusable components (.ep), grouped into subdirs
ui/ # presentational building blocks
layout/ # page/site structure (Header, Nav, Footer, Search)
sections/ # visual page regions
behavior/ # client-side enhancement / interaction
features/ # business-feature components
styles/ # global stylesheets (CSS)
assets/ # buildable assets (images, js); top-level files land at root
public/ # files copied verbatim to the output root
Back-compat: a legacy
templates/root (withcomponents/+layouts/subdirs) is still loaded when present.
epresso supports per-environment configuration with a
.env.development / .env.preview / .env.production split:
site.<env>.toml— deep-merged oversite.toml(per-env URL, toggles, …).env.<env>— dotenv-style vars (EP_SESSIONS_API=...) exposed to content loaders/plugins viaos.environand to templates viaenv_vars.
Select with epresso build --env <name> (or EPRESSO_ENV); epresso dev defaults to
development, epresso build/preview/check to production. In templates,
{{ env }} is the active env name and {{ env_vars.KEY }} any env var.
[site]
name = "My Site"
url = "https://example.com"
language = "en"
[build]
output = "dist"
trailing_slash = "always" # always | never
[assets]
css = ["css/main.css"] # entry points (esbuild / postcss / tailwind)
js = ["js/app.js"]
[seo]
sitemap = true
robots = true
[search]
enabled = true
index = "search-index.json"
plugins = ["mypkg:MyPlugin"] # dotted-path plugin specsDefine collections in content.config.py:
from pydantic import BaseModel
from epresso.content import define_collection, reference
class Post(BaseModel):
title: str
date: str
tags: list[str] = []
author: reference("authors") # content reference
draft: bool = False
posts = define_collection("posts", glob="*.md", base="./content/posts", schema=Post)
authors = define_collection("authors", loader=fetch_authors) # remote/derivedFenced code blocks in Markdown are syntax-highlighted with Pygments by
default (markdown.highlight = false opts out). Highlighted blocks use
<code class="language-<lang>"> with token spans; include the generated CSS in
your layout:
<style>{{ pygments_css() }}</style>A markdown post with YAML front matter:
---
title: Hello
date: 2026-01-01
tags: [python]
---
# Hello
Your **content** here.get_collection("posts"), get_entry("posts", id), and get_entries(...) resolve data in templates and in get_static_paths(). In production builds, draft: true and future-dated entries are automatically excluded.
content/ is data; pages/ produces URLs. Three kinds of route:
-
Direct Markdown page —
pages/about.md→/about/(layout via front matter). -
.epsingle-file route —pages/blog/[slug].epwith Python frontmatter + Jinja body (recommended):--- from epresso.routing import Route def get_static_paths(): return [Route(path=f"/blog/{p.id}/", params={"slug": p.id}, data=p) for p in site.get_collection("posts")] --- {% extends 'base.html' %} {% block title %}{{ props.title }}{% endblock %} {% block content %}<h1>{{ props.title }}</h1>{{ content|safe }}{% endblock %}The
--- … ---block is Python (run withsiteinjected); its variables are exposed to the template. A static.eproute needs noget_static_paths(). -
Template + sidecar —
pages/blog/[slug].html+pages/blog/[slug].py(legacy):from epresso.routing import Route def get_static_paths(): return [Route(path=f"/blog/{p.id}/", params={"slug": p.id}, data=p) for p in site.get_collection("posts")]
-
Static endpoint —
pages/robots.txt.pyexportingget():def get(): return "text/plain", "User-agent: *\nAllow: /\n"
Pagination is a helper, not magic:
from epresso.routing import paginate
def get_static_paths():
return paginate(site.get_collection("posts"), per_page=10, base_path="/blog/")Redirects are configured in site.toml and emitted as routes in the build graph
(so they participate in incremental builds). Targets may be a string (permanent
301) or a {destination, status} dict for 302:
[[redirects]]
"/old-home/" = "/"
[[redirects]]
"/legacy/" = { destination = "/new/", status = 302 }Each emits a browser-safe meta-refresh page under dist/<from>/index.html;
data-epresso-status reflects the configured HTTP status for edge/host rewrites.
Set [build] redirects = false to disable. Invalid targets (missing
destination) are rejected at load time.
.ep files unify routes, layouts, and components into a single file:
Python frontmatter (--- … ---) + Jinja body. Content stays in
Markdown; data stays in YAML/JSON/TOML.
components/Card.ep validates props against a strict Pydantic model
before rendering — no unvalidated kwargs reach the template:
---
from pydantic import BaseModel
class Props(BaseModel):
title: str
level: int = 3
---
<div class="card"><h{{ props.level }}>{{ props.title }}</h{{ props.level }}>
{{ content }}</div>
Used from any template with the {% component %} tag:
{% component "Card", title="Hi", level=2 %}Body text{% endcomponent %}Components can also be written with HTML/JSX-like syntax — epresso rewrites these
to {% component %} blocks before parsing, so you don't need the tag at all:
<Header />
<main>
<Hero />
<FeatureSection layout="three-column" count={items|length} />
<Card title="Hi">Body <strong>text</strong></Card>
</main>- Only tags that resolve to a registered component are converted —
<main>,<div>,<section>etc. pass through as plain HTML. - Attributes are JSX-style:
key="value"/key='value'(string),key={expr}(expression), or a barekey(booleantrue). - Paired components take children:
<Card>…</Card>renders…as the component'scontent, and children may themselves contain nested components. - The legacy
{% component %}tag still works and can be mixed freely.
A <style> block in a .ep file is extracted, scoped to the component/route's
output (a data-epresso-<hash> attribute), and linked from the head:
---
---
<style>
.card { border: 1px solid #ccc; }
:global(.reset) { margin: 0; } /* opt out of scoping */
</style>
<div class="card">{{ content }}</div>
The scoped CSS is written to dist/_scoped/epresso-<hash>.css and a <link> is
injected into any page that uses it. Legacy .html components and .html+.py
routes continue to work unchanged.
A .ep component is scoped only when it contains a scoped <style> block:
it is then wrapped in a data-epresso-* div and its selectors are rewritten. A
component with no scoped CSS — or only <style is:global> — renders unscoped
(no wrapper). So a layout shell that emits a full <!doctype html> document is
automatically unscoped, and any layout <style> is made global with
<style is:global> or by linking a global stylesheet —
a layout is just a component whose body renders {{ content }} (the default
slot):
---
---
<!doctype html><html><head><title>{{ title }}</title></head>
<body>{{ content }}</body></html>
<BaseLayout title={title}>
<Header />
<main>…</main>
<Footer />
</BaseLayout>Layout components may live in layouts/ (resolved as a component in
addition to components/), so BaseLayout can stay alongside your
layouts while being composed as a component.
Components get a default slot ({{ content }}, the children between the
open/close tags) plus named slots mirroring <slot name=…>: a child
<Fragment slot="name">…</Fragment> contributes to slots["name"] and is
removed from the default content.
<Card title="Hi">
<Fragment slot="header">Header content</Fragment>
Default body content
</Card>---
---
<div class="card"><h3>{{ props.title }}</h3><header>{{ slot('header') }}</header>{{ content }}</div>
Use {{ slot('name') or 'fallback' }} for slot fallback content. Works for both
.ep and .html components.
A <script> block in a .ep file is extracted, bundled with esbuild (falling
back to a raw module when esbuild isn't installed — JS stays optional), and
loaded on any page that uses the route/component:
---
---
<style>.count { color: red; }</style>
<script>
document.querySelector('.count').addEventListener('click', () => alert('hi'));
</script>
<button class="count">{{ content }}</button>
The bundle is written to dist/_epresso/scripts/<hash>.js and a
<script type="module" src="/_epresso/scripts/<hash>.js"> tag is injected before
</body> on the pages that use it. Identical script blocks dedupe to one file.
So a single .ep file bundles: Python frontmatter
(template logic) + Jinja body (markup) + <style> (scoped CSS) + <script>
(page-level client JS).
Jinja2 with curated globals — no arbitrary Python:
{% extends "layouts/base.html" %}
{% block title %}{{ props.title }}{% endblock %}
{% block content %}
{{ seo(title=props.title, path=route.path) }}
<article>
<h1>{{ props.title }}</h1>
<img src="{{ image('photos/hero.jpg', widths=[400,800,1200], alt='Hero') }}">
{{ content | safe }}
</article>
{% endblock %}Globals: site, url(), asset(), image(), seo(), get_collection(), get_entry(), route, params, props, content.
from epresso.plugins import Plugin
def greeter(*, text="hello"):
def on_setup(caps):
caps.add_global("greeting", lambda: text)
return Plugin(name="greeter", hooks={"on_setup": on_setup})Plugins are deduplicated by name and run in priority order; each hook
receives a narrow Capabilities handle rather than the Site, and can add
template globals/filters, register content collections & Markdown extensions,
and transform rendered HTML. Enable them from a project plugins.py (build with
options via a factory) or [plugins] dotted paths in site.toml. Lifecycle
hooks: before_load, on_setup, after_load, before_build, after_build,
on_assets. See examples/plugins/ and the plugin guide.
epresso new docs mydocs # docs theme (sidebar nav, ToC-friendly)
epresso new blog myblog # blog theme (posts, index, RSS feed)Themes are git-cloned from their own repos and given to you as a starter project you fully own.
| Command | Description |
|---|---|
epresso init |
Scaffold a blank project |
epresso new <theme> <dest> |
Scaffold from a theme (docs/blog) |
epresso dev |
Development server with live reload |
epresso build |
Deterministic + incremental production build |
epresso preview |
Build then serve dist/ (production preview) |
epresso docs |
Build + serve the documentation (port 4321) |
epresso clean |
Remove dist/ and the build cache |
epresso check |
Validate config + content, list routes |
epresso version |
Print the version |
git clone https://github.com/epresso-ssg/epresso
cd epresso
uv sync --extra dev
uv run pytest # 276 tests
uv run ruff check .
uv run pyright src/epresso
uv run pytest --cov=epressoThe repo ships runnable theme projects under themes/ (basic, blog,
docs, website). From the repo root, point any epresso command at one with
--project .:
uv run --project . epresso dev themes/basic # dev server with live reload
uv run --project . epresso build themes/basic # build → themes/basic/dist
uv run --project . epresso check themes/basic # validate config + content
uv run --project . epresso preview themes/basic # build + serve dist/The website example is the most feature-rich — it exercises .ep components
and scoped CSS.
Neovim / Vim syntax highlighting for .ep files (Python frontmatter +
Jinja2 body) ships in extras/nvim/. Add it to your runtimepath once and every
epresso project is highlighted automatically:
-- ~/.config/nvim/init.lua
vim.opt.rtp:append("/path/to/epresso/extras/nvim")See extras/nvim/README.md for details and LazyVim
instructions.
MIT
