2 min read
Write a custom plugin
Goal: add your own behaviour by tapping a hook, the same way the built-in plugins do.
For the bigger picture of how plugins fit together, read The plugin pipeline first. This guide is the practical recipe.
The plugin shape
A plugin is any object with a name attribute and an apply(builder) method.
Inside apply, you tap one or more hooks. The convention is to ship a small
factory function that returns an instance, so it reads nicely in a config file.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pyssg.core.builder import Builder
class UppercaseTitles:
"""Force every page title to upper case (a tiny example)."""
name = "uppercase_titles"
def apply(self, builder: Builder) -> None:
@builder.hooks.this_compilation.tap(self.name)
def _(build: object) -> None:
# Tap a per-build hook here; see the hook reference for the full set.
...
def uppercase_titles() -> UppercaseTitles:
return UppercaseTitles()
Where to tap
Hooks live in two scopes:
- Builder hooks (
builder.hooks.*) - the long-lived compiler. Tapthis_compilationto reach into each build, ormaketo inject synthetic nodes into the graph (this is howapidocadds its reference pages). - Build hooks (
build.hooks.*) - one compilation. These are the per-document and per-page taps:parse,resolve,finalize_content,route,render_page, and more.
The full list with signatures is in the plugins & hooks reference.
Ordering taps
Taps declare relative order with a coarse stage integer plus before /
after name constraints. For example, external_links taps finalize_content
at stage=300 so it runs after wikilink resolution (100) and internal link
resolution (200) and therefore sees the final hrefs:
@builder.hooks... .tap(self.name, stage=300)
def _(html: str) -> str:
...
Before every call the taps are topologically sorted; a constraint cycle raises
HookOrderError.
The rules every plugin must follow
These are not optional - they are what makes pyssg's build guarantees hold:
- Be pure with respect to declared inputs. No global mutable state, and no
direct
datetime.now()/time/random. Building twice must be byte-identical. - Declare facts; let the engine own the algorithms. Plugins do not propagate dirtiness or manage the cache themselves.
routereturning""means "no page". Aroutetap that returns the empty string suppresses output for that document (this is howi18ndrops files outside any locale directory).- Core stays stdlib-only. Third-party imports belong in the periphery
(
pyssg/plugins/orpyssg/contrib/), never inpyssg/core/.
Use it
Import and add your plugin in pyssg.config.py:
from __future__ import annotations
from pyssg.presets import docs
from mypackage.plugins import uppercase_titles
config = docs(
site={"title": "My Docs"},
extra_plugins=[uppercase_titles()],
)
extra_plugins are appended after the preset's defaults, so they run last.