Plugins extend Bengal with custom directives, template functions, content sources, validators, and build lifecycle hooks — all through a singleregister()method. The plugin system uses Python entry points for automatic discovery and a builder→immutable pattern for thread safety.
Quick Start
Create a plugin in three steps:
1. Implement the Plugin Protocol
# my_bengal_plugin/__init__.py
from bengal.plugins.protocol import Plugin
from bengal.plugins.registry import PluginRegistry
class MyPlugin(Plugin):
name = "my-plugin"
version = "1.0.0"
def register(self, registry: PluginRegistry) -> None:
registry.add_template_filter("shout", lambda v: v.upper() + "!")
2. Declare the Entry Point
# pyproject.toml
[project.entry-points."bengal.plugins"]
my-plugin = "my_bengal_plugin:MyPlugin"
3. Install and Build
uv pip install -e ./my-bengal-plugin
bengal build # Plugin auto-discovered
Your filter is now available in templates:
{{ page.title | shout }}
Plugin Protocol
Every plugin must havename, version, and a register()method:
from bengal.plugins.protocol import Plugin
from bengal.plugins.registry import PluginRegistry
class MyPlugin(Plugin):
name: str = "my-plugin"
version: str = "1.0.0"
def register(self, registry: PluginRegistry) -> None:
# Register extensions here
...
Plugin is a runtime_checkableProtocol — no base class inheritance required. Any object with the right attributes works.
Extension Points
ThePluginRegistryprovides 9 registration methods:
Current integration status:
- Ready in builds: template functions, template filters, template tests, build phase hooks
- Registered but pending subsystem wiring: directives, roles, content sources, health validators, shortcodes
Usebengal plugin list, bengal plugin info <name>, and
bengal plugin validateto inspect installed plugins and see whether their
registered capabilities are active in the current Bengal build pipeline.
Template Extensions
def register(self, registry: PluginRegistry) -> None:
# Global function: {{ my_func(page) }}
registry.add_template_function("my_func", my_func, phase=1)
# Filter: {{ value | my_filter }}
registry.add_template_filter("my_filter", my_filter)
# Test: {% if value is my_test %}
registry.add_template_test("my_test", my_test)
The phase parameter on add_template_functioncontrols registration order (1–9), matching Bengal's internal template function phases.
Registry validation happens duringregister(). Bengal raises explicit errors
for empty extension names, non-callable template helpers or phase hooks, content
sources that are not classes, shortcode templates that are not non-empty
strings, and template function phases outside1..9. The frozen registry
returned after registration is immutable and safe to share across parallel
build workers.
Directives and Roles
def register(self, registry: PluginRegistry) -> None:
# Block directive: :::{my_directive}
registry.add_directive(MyDirectiveHandler)
# Inline role: {my_role}`text`
registry.add_role(MyRoleHandler)
Directive and role registration is discoverable through plugin introspection, but parser injection is not wired yet.
Content Sources
def register(self, registry: PluginRegistry) -> None:
registry.add_content_source("my-source", MyContentSource)
Content source registration is discoverable through plugin introspection, but content discovery does not consume plugin sources yet.
Health Validators
def register(self, registry: PluginRegistry) -> None:
registry.add_health_validator(MyValidator())
Health validator registration is discoverable through plugin introspection, but health checks do not consume plugin validators yet.
Shortcodes
def register(self, registry: PluginRegistry) -> None:
registry.add_shortcode(
"alert",
'<div class="alert alert-{{ type | default("info") }}">{{ content }}</div>',
)
Shortcode registration is discoverable through plugin introspection, but the shortcode registry does not consume plugin shortcodes yet.
Build Phase Hooks
def register(self, registry: PluginRegistry) -> None:
registry.on_phase("pre_render", self.before_render)
registry.on_phase("post_render", self.after_render)
def before_render(self, site, build_context):
print(f"Rendering {len(site.pages)} pages")
def after_render(self, site, build_context):
print("Render complete")
Lifecycle hook names currently emitted by builds:
build_start,build_completepre_discovery,post_discoverypre_content,post_contentpre_parsing,post_parsingpre_snapshot,post_snapshotpre_assets,post_assetspre_render,post_renderpre_rendering,post_rendering(back-compat aliases ofpre_render/post_render)pre_finalization,post_finalizationpre_health,post_health
build_completeis a teardown hook: it fires exactly once per build, even when
a mid-build phase raises. Use it for cleanup (closing connections, writing a
build summary) that must run regardless of build success or failure. The build
error still propagates to the caller after your callback has run.
How Discovery Works
Bengal discovers plugins via thebengal.pluginsentry point group:
- On startup,
discover_plugins()callsimportlib.metadata.entry_points(group="bengal.plugins") - Each entry point is loaded and validated against the
Pluginprotocol - Valid plugins call
register()on a mutablePluginRegistry - The registry is frozen into an immutable
FrozenPluginRegistrybefore rendering begins
The frozen registry is a frozen dataclass with tuple fields — safe to share across threads during parallel rendering.
Combining Multiple Extensions
A single plugin can register any number of extensions:
class KitchenSinkPlugin(Plugin):
name = "kitchen-sink"
version = "1.0.0"
def register(self, registry: PluginRegistry) -> None:
registry.add_template_filter("slugify", self._slugify)
registry.add_template_function("badge", self._badge, phase=1)
registry.add_directive(MyDirective)
registry.add_health_validator(MyValidator())
registry.on_phase("post_render", self._post_render)
Programmatic Registration
If you don't want entry-point discovery, pass plugins directly:
from bengal.plugins.loader import load_plugins
frozen = load_plugins(extra_plugins=[MyPlugin()])
See Also
- Extension Points — Full reference for all extension points
- Custom Directives — Directive authoring guide
- Template Shortcodes — Template-only shortcodes (no Python required)