Custom Directives

Create custom MyST directive blocks for specialized content

7 min read 1333 words
Edit this page

Was this page helpful?

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 aDirectiveRegistryBuilder

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 a DirectiveAST node.
  • render(self, node, rendered_children, sb)appends HTML to a StringBuilder. It returns None; 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:

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

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:

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

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

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

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

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

PYTHON
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):

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

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

MARKDOWN
:::{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:

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

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

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

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

PYTHON
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