Writing Plugins

Create plugins that add directives, template functions, content sources, and build hooks

3 min read 515 words

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:

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.

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)

Content Sources

def register(self, registry: PluginRegistry) -> None:
    registry.add_content_source("my-source", MyContentSource)

Health Validators

def register(self, registry: PluginRegistry) -> None:
    registry.add_health_validator(MyValidator())

Shortcodes

def register(self, registry: PluginRegistry) -> None:
    registry.add_shortcode(
        "alert",
        '<div class="alert alert-{{ type | default("info") }}">{{ content }}</div>',
    )

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")

How Discovery Works

Bengal discovers plugins via thebengal.pluginsentry point group:

  1. On startup,discover_plugins() calls importlib.metadata.entry_points(group="bengal.plugins")
  2. Each entry point is loaded and validated against thePluginprotocol
  3. Valid plugins callregister() on a mutable PluginRegistry
  4. The registry is frozen into an immutableFrozenPluginRegistrybefore rendering begins

The frozen registry is a 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