Writing Plugins

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

4 min read 733 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:

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_complete
  • pre_discovery, post_discovery
  • pre_content, post_content
  • pre_parsing, post_parsing
  • pre_snapshot, post_snapshot
  • pre_assets, post_assets
  • pre_render, post_render
  • pre_rendering, post_rendering
  • pre_finalization, post_finalization
  • pre_health, post_health

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