Plugins
Plugins extend epresso through a capability registry. A plugin is a named,
configurable object exposing lifecycle hooks; each hook receives a narrow
Capabilities handle — never the raw Site — so a plugin can only touch the
extension surface epresso exposes. Plugins are deterministic: deduplicated by
name, run in priority order, and re-loading a site never double-applies a
contribution. Bundled, runnable example plugins ship under examples/plugins/
(see Bundled example plugins).
Writing a plugin
Build a plugin with a factory so you can pass options:
pythonfrom epresso.plugins import Plugindef greeter(*, text: str = "hello", shout: bool = False): def on_setup(caps): caps.add_global("greeting", lambda: text.upper() if shout else text) caps.add_filter("shout", lambda s: str(s).upper()) return Plugin(name="greeter", hooks={"on_setup": on_setup})
A subclass style is also supported — on_* methods are collected
automatically:
pythonfrom epresso.plugins import Pluginclass Greeter(Plugin): name = "greeter" def on_setup(self, caps): caps.add_global("greeting", lambda: "hello")
Capabilities
| Capability | Valid in | Effect |
|---|---|---|
add_global(name, value) |
on_setup |
expose a Jinja template global |
add_filter(name, fn) |
on_setup |
register a Jinja template filter |
register_collection(...) |
before_load |
add a content collection (via a loader) to the store |
add_markdown_extension(spec) |
before_load |
register a Markdown extension (idempotent) |
add_markdown_it_plugin(fn) |
any | apply a markdown-it-py plugin fn(md) (e.g. from mdit_py_plugins) to every Markdown renderer |
add_markdown_source_transform(fn) |
any | rewrite Markdown source before rendering (str -> str) |
add_markdown_render_transform(fn) |
any | fn(md, src, depth) -> str — for syntax that renders inner Markdown (tabs) |
add_layer(root) |
before_load |
add a component/layout/asset root after the site's own |
add_route(rel, file) |
before_load |
serve file as if it were pages/<rel> (e.g. "[...slug].ep") |
add_static(dir, prefix, exclude=()) |
before_load |
publish dir's non-Markdown files verbatim under prefix, minus the subdirs in exclude |
transform_html(fn) |
any | rewrite every rendered HTML page: fn(html, ctx) -> html |
inject_head(fragment) |
any | insert a fragment into <head> of every page |
Each hook also receives caps.config, caps.site, and caps.logger (a
namespaced [plugin:<name>] logger; debug lines gate on
EPRESSO_DEBUG=plugin:<name>).
caps.options is the plugin's own [plugin.<name>] table from site.toml
({} when absent). Core passes it through untouched, and site.<env>.toml
overrides it like any other key:
tomlplugins = ["epresso_docs"][plugin.epresso_docs]base = "/docs/"
Using a capability at the wrong time raises a CapabilityError with a hint —
for example, register_collection must run in before_load so the collection
is loaded before content, and add_global/add_filter need the Jinja
environment, which exists from on_setup onward.
Layers vs plugins
For components and layouts that live in another package or repo, prefer
layers — a declarative [layers] use entry with no code to write.
Reach for a plugin when you need build-time behaviour: template globals, HTML
transforms, content collections, markdown extensions. A package can do both.
Registering a collection from a plugin
register_collection plugs a loader into the same store as content.config.py:
pythondef quotes(items): def before_load(caps): caps.register_collection( "quotes", loader=lambda: [{"id": "a", "data": {"text": "stay deterministic"}}], ) return Plugin(name="quotes", hooks={"before_load": before_load})
Templates then read it with get_collection("quotes") / get_entry("quotes", …),
exactly like a content.config.py collection.
Transforming rendered output
transform_html(fn) runs a pure function over each rendered HTML route. ctx
carries {"path", "params", "data"} for the route (data is the route's
content entry, if any). The page cache stores HTML before transforms run, and
transforms re-run on reused pages too, so a transform never sees or leaves stale
output and doesn't turn incremental builds off:
pythondef watermark(text="Made by epresso"): def on_setup(caps): def transform(html, ctx): if "</pre>" not in html or "epresso-mark" in html: return html return html.replace("</pre>", f'<span class="epresso-mark">{text}</span></pre>') caps.transform_html(transform) return Plugin(name="watermark", hooks={"on_setup": on_setup})
Transforms are applied at render time in both build and dev. Because a transform can depend on plugin code (not just content), a build that has any registered transform re-renders every route rather than reusing a cached output — content body caching still holds.
Enabling
For published plugins, uv add the package and list it under [plugins] in
site.toml (dotted path — installable modules only):
tomlplugins = ["epresso-greeter:greeter", "myorg:thing"]
For local plugins (or any that take options), add a plugins.py at the site
root that builds each plugin with its options and leaves Plugin instances as
module attributes:
pythonfrom greeter import greetergreeting = greeter(text="hello from a plugin")
Options can only be passed via the code path (a dotted-path string cannot carry them).
Lifecycle & ordering
- Plugins are deduplicated by name — registering the same
nametwice raises aPluginError. - Plugins run in
priorityorder (lower first; ties keep registration order). - Re-running a load/build is idempotent — contributions (globals, filters, collections, Markdown extensions, html transforms) are re-applied against a freshly built environment each load.
The lifecycle hooks, in order: before_load, on_setup, after_load,
before_build, after_build(caps, result), on_assets.
Bundled plugins
These ship with epresso; list them in plugins = [...] (the docs theme enables
the first seven). EPRESSO_DISABLE_PLUGINS=a,b turns named plugins off for one
build without editing site.toml.
| Plugin | What it does | Guide |
|---|---|---|
epresso_docs |
the docs collection from one or many Markdown sources |
below |
epresso_blog |
blog: posts, paginated index, archive, categories, RSS (MkDocs-compatible) | Blog |
epresso_mkdocs |
MkDocs/Material Markdown syntax: admonitions, tabs, attr_list, snippets, … (epresso_mkdocs_tabs is an alias) |
Migrate from MkDocs |
epresso_social |
og:image social cards |
Social cards |
epresso_optimize |
responsive WebP srcset for content images |
Images |
epresso_pandoc |
Pandoc fenced-code / image attributes | — |
epresso_umami |
Umami analytics snippet (UMAMI_WEBSITE_ID) |
— |
epresso_page_feedback |
"Was this page helpful?" widget on docs pages | — |
Docs from several sources: epresso_docs
The bundled epresso_docs plugin owns a docs collection built from one or
more Markdown sources — local directories or git repositories — merged into one
sidebar, search index and prev/next chain:
tomlplugins = ["epresso_docs"][plugin.epresso_docs]base = "/docs/" # optional: docs under a path, inside this site[[plugin.epresso_docs.sources]]source = "docs" # local dir (relative to the site root)[[plugin.epresso_docs.sources]]source = "github:org/plugin-a@v1"dir = "docs" # dir inside the source (default "docs" for git, "." local)prefix = "/plugins/a/" # URL prefix under base (default "/")title = "Plugin A" # sidebar group labelrepo_url = "" # "view source" base override
Two sources producing the same page are a build error — give one a prefix.
Each source's images and other non-Markdown files are published under
<base>/_docs-assets/<n>/, so relative paths like 
resolve (dist/, node_modules/ and dot-dirs inside a source are skipped).
Under base, the host's [markdown] adopts the theme's code-block component
(DocsHighlight) unless the host sets its own code_component.
With base set, the plugin layers the docs theme under your site
(theme = "path" picks another) and serves its docs page; your pages, layouts
and global CSS are untouched. Without base the site is the docs theme, which
is what epresso docs and docs.toml build.
Behaviour worth knowing:
- A prefix without an
index.mdis a sidebar section heading only — no page is generated at that URL. Add anindex.mdto the source to give it a landing page. - The theme is brand-neutral. It ships only its fonts (under
/docs-theme/fonts/, a name no site uses), never a logo or favicon. Alogo.svg,favicon.icoorog-image.pngat a source's root brands the docs pages, unless[theme] logo/favicon/og_imageis set. Underbasethe host's own favicon and pages are untouched.
Every source's files (images, downloads) are published, then after the build
only the files some built page, stylesheet or script references are kept.
Relative URLs resolve like MkDocs: Markdown ![]()/[]() against the source
file, raw HTML <img src> against the page's own URL.
Bundled example plugins
Ready-made, runnable example plugins ship in examples/plugins/. Each module is
a real plugin you can read and copy into your own project:
| Module | Demonstrates | Capabilities used |
|---|---|---|
greeter.py |
factory style + options + template hooks | add_global, add_filter |
quotes.py |
a content collection contributed at load time | register_collection (before_load) |
watermark.py |
a pure html transform stamping a “Made by epresso” pill on every code block | transform_html |
A runnable demo site in examples/plugins/demo/ wires all three and builds a
small page. From the repo root:
bashuv run epresso build examples/plugins/demo # then open dist/index.html
demo/plugins.py shows the recommended local enablement pattern:
sys.path-insert the folder, build each plugin with its options via a factory,
and leave the resulting Plugin instances as module attributes for epresso to
discover. For published plugins, uv add the package and reference it from
site.toml (see Enabling). The demo output shows a shouted
greeting, a shouted filter value, two quotes from a plugin-registered collection,
and a “Made by epresso” pill stamped inside every code block.
Was this page helpful?