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_renderingpre_finalization,post_finalizationpre_health,post_health
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)