Directives are block-level content elements that extend Markdown with custom rendering. Bengal uses MyST-style syntax (:::{name}) and provides a structural DirectiveHandlerprotocol for creating your own directives.
The public API lives inbengal.parsing.backends.patitas.directives. There is no bengal.directivespackage — import from the path shown in every example below.
Shortcodes vs Directives
| Use | Shortcode | Directive |
|---|---|---|
| When | Simple HTML, no validation | Validation, nesting, complex logic |
| How | Template file intemplates/shortcodes/ |
Python class |
| Syntax | {{ < name args > }} |
:::{name} |
Use a shortcode when output is simple HTML from args and you want template authors to add or customize without code. Use a directive when you need validation (e.g., YouTube 11-char ID), parent-child nesting, or structured errors.
See Template Shortcodes for the template-only path.
Directive Basics
Directives in Bengal:
- Parse content using the MyST fenced syntax
- Support typed options with automatic coercion (frozen dataclasses)
- Can define parent-child nesting relationships via contracts
- Are registered through a
DirectiveRegistryBuilder
A directive is a stateless handler that implements theDirectiveHandler
protocol. It is structural typing — you do not subclass anything. A handler
declares two class attributes and two methods:
names: tuple[str, ...]— directive names this handler responds to.token_type: str— token type identifier used for AST dispatch.parse(self, name, title, options, content, children, location)— builds aDirectiveAST node.render(self, node, rendered_children, sb)— appends HTML to aStringBuilder. It returnsNone; it does not return a string.
Handlers must be stateless
Multiple threads may call the same handler instance concurrently. Keep all
state in the (immutable) AST node or in the method arguments. Options are
frozen dataclasses, so read them with attribute access (options.width),
never mutate them.
Creating a Custom Directive
Implement theDirectiveHandlerprotocol:
from typing import ClassVar
from patitas.nodes import Directive
class AlertDirective:
"""Custom alert box directive."""
# Required: names that trigger this directive
names: ClassVar[tuple[str, ...]] = ("alert", "callout")
# Required: token type for the AST
token_type: ClassVar[str] = "alert"
def parse(self, name, title, options, content, children, location):
"""Build the directive AST node."""
return Directive(
location=location,
name=name,
title=title or "info",
options=options,
children=tuple(children),
)
def render(self, node, rendered_children, sb):
"""Append HTML to the StringBuilder (returns None)."""
level = node.title or "info"
sb.append(f'<div class="alert alert-{level}">')
sb.append(rendered_children)
sb.append("</div>")
Usage in Markdown:
:::{alert} warning
:class: my-custom-class
This is a warning message.
:::
Handler Attributes
names (Required)
A tuple of directive names that trigger this handler:
from typing import ClassVar
class DropdownHandler:
names: ClassVar[tuple[str, ...]] = ("dropdown", "details", "collapsible")
All names map to the same handler instance.
token_type (Required)
String identifier for the AST node, used to match the parse and render phases:
from typing import ClassVar
class DropdownHandler:
names: ClassVar[tuple[str, ...]] = ("dropdown",)
token_type: ClassVar[str] = "dropdown"
options_class (Optional)
A frozen-dataclass subclass ofDirectiveOptionsfor typed option parsing.
Options are parsed from:key: valuelines and coerced to the field's type
automatically:
from dataclasses import dataclass
from typing import ClassVar
from patitas.nodes import Directive
from bengal.parsing.backends.patitas.directives import DirectiveOptions
@dataclass(frozen=True, slots=True)
class DropdownOptions(DirectiveOptions):
open: bool = False
icon: str | None = None
class DropdownHandler:
names: ClassVar[tuple[str, ...]] = ("dropdown",)
token_type: ClassVar[str] = "dropdown"
options_class: ClassVar[type[DropdownOptions]] = DropdownOptions
def parse(self, name, title, options, content, children, location):
# options is a DropdownOptions instance (frozen — read, don't mutate)
return Directive(
location=location,
name=name,
title=title or "Details",
options=options,
children=tuple(children),
)
def render(self, node, rendered_children, sb):
is_open = " open" if node.options.open else ""
sb.append(f"<details{is_open}>")
sb.append(f"<summary>{node.title}</summary>")
sb.append(rendered_children)
sb.append("</details>")
DirectiveOptions also ships with reusable bases such as StyledOptions
(addsclass_ and name) and typed option classes for the built-ins
(AdmonitionOptions, FigureOptions, TabSetOptions, and more).
contract (Optional)
Define valid parent-child nesting relationships withDirectiveContract:
from typing import ClassVar
from bengal.parsing.backends.patitas.directives import DirectiveContract
class StepHandler:
names: ClassVar[tuple[str, ...]] = ("step",)
token_type: ClassVar[str] = "step"
contract: ClassVar[DirectiveContract] = DirectiveContract(
allows_parent=("steps",),
)
This ensures :::{step} is only valid inside :::{steps}. A
ContractViolationis raised when nesting rules are broken.
Methods to Implement
parse
Build theDirectiveAST node from the parsed components. The node fields are
location, name, title, options, children, and an optional
raw_content:
from patitas.nodes import Directive
def parse(self, name, title, options, content, children, location):
return Directive(
location=location, # SourceLocation for error messages
name=name, # The directive name actually used
title=title, # Optional text after the directive name
options=options, # Typed options (frozen dataclass)
children=tuple(children), # Parsed child blocks
)
render
Append HTML to theStringBuilder. render() returns None— write output
withsb.append(...) (or sb.append_line(...)), do not build and return a
string:
def render(self, node, rendered_children, sb):
sb.append(f"<details><summary>{node.title}</summary>")
sb.append(rendered_children) # pre-rendered HTML of child blocks
sb.append("</details>")
Parse-only and render-only handlers
If you only need to customize one phase, theDirectiveParseOnlyand
DirectiveRenderOnlyprotocols document that intent (they require only the
relevant method plusnames/token_type):
from bengal.parsing.backends.patitas.directives import (
DirectiveParseOnly,
DirectiveRenderOnly,
)
Example: Embed Directive
A directive for embedding external content via an iframe. HTML is escaped with
the standard library'shtml.escape:
from dataclasses import dataclass
from html import escape as escape_html
from typing import ClassVar
from patitas.nodes import Directive
from bengal.parsing.backends.patitas.directives import DirectiveOptions
@dataclass(frozen=True, slots=True)
class EmbedOptions(DirectiveOptions):
width: str = "100%"
height: str = "400px"
title: str = ""
class EmbedDirective:
"""Embed external content via iframe."""
names: ClassVar[tuple[str, ...]] = ("embed", "iframe")
token_type: ClassVar[str] = "embed"
options_class: ClassVar[type[EmbedOptions]] = EmbedOptions
def parse(self, name, title, options, content, children, location):
# Title contains the URL
return Directive(
location=location,
name=name,
title=(title or "").strip(),
options=options,
children=tuple(children),
)
def render(self, node, rendered_children, sb):
opts = node.options
url = escape_html(node.title or "")
width = escape_html(opts.width)
height = escape_html(opts.height)
title = escape_html(opts.title or "Embedded content")
sb.append(
f'<iframe src="{url}" '
f'width="{width}" height="{height}" '
f'title="{title}" frameborder="0" '
f'allowfullscreen loading="lazy"></iframe>'
)
Usage:
:::{embed} https://www.youtube.com/embed/dQw4w9WgXcQ
:width: 560px
:height: 315px
:title: Video Tutorial
:::
Registration
Directives are registered through aDirectiveRegistryBuilder, which produces
an immutableDirectiveRegistry. The built-in registry is assembled in
bengal/parsing/backends/patitas/directives/registry.pyby
create_default_registry(); you can build your own registry that includes both
the built-ins and your custom handlers:
from bengal.parsing.backends.patitas.directives import (
DirectiveRegistryBuilder,
create_default_registry,
)
# Start from the built-in handlers, then add your own.
base = create_default_registry()
builder = DirectiveRegistryBuilder()
builder.register_all(list(base.handlers))
builder.register(AlertDirective())
registry = builder.build()
assert registry.has("alert")
handler = registry.get("alert")
Note
External plugin registration is wired through Bengal's plugin system
(create_default_registry(plugin_registry=...)applies plugin-contributed
directives). The custom handlers above also work directly against the
registry builder shown here.
Testing Directives
Test the two phases by instantiating your handler directly.parse()returns a
Directive node; render() appends to a StringBuilder:
from patitas.stringbuilder import StringBuilder
from patitas.location import SourceLocation
from your_directives import AlertDirective # your custom handler
def test_alert_directive_parse():
handler = AlertDirective()
node = handler.parse(
name="alert",
title="warning",
options=None,
content="Test content",
children=(),
location=SourceLocation(lineno=1, col_offset=0),
)
assert node.name == "alert"
assert node.title == "warning"
def test_alert_directive_render():
handler = AlertDirective()
node = handler.parse(
name="alert",
title="warning",
options=None,
content="",
children=(),
location=SourceLocation(lineno=1, col_offset=0),
)
sb = StringBuilder()
handler.render(node, "<p>Test content</p>", sb)
html = sb.build()
assert 'class="alert alert-warning"' in html
assert "<p>Test content</p>" in html
For full parse-and-render integration, use the public PatitasParser. It wires
up the directive registry (including typed option parsing) the same way a real
build does:
from bengal.parsing.backends.patitas import PatitasParser
def test_directive_full_integration():
parser = PatitasParser()
source = """:::{note}
This is a note.
:::"""
html = parser.parse(source, metadata={})
assert "admonition" in html
assert "note" in html
To register your own handlers for the parse, build a registry and drive the lower-level parse/render contexts directly:
from bengal.parsing.backends.patitas import (
ParseConfig,
RenderConfig,
parse_config_context,
render_config_context,
)
from bengal.parsing.backends.patitas.directives import create_default_registry
To look up a built-in handler, query the registry rather than a global getter:
from bengal.parsing.backends.patitas.directives import create_default_registry
def test_dropdown_handler_is_registered():
registry = create_default_registry()
handler = registry.get("dropdown")
assert handler is not None
Related
- Directives Reference for built-in directives
- Build Pipeline for understanding when directives are processed