Learning Track

Extension Developer

Create custom directives, content sources, and build integrations.

1 min read 37 words
Edit this page

Was this page helpful?

1
2
3
4
5
6
Step 1 of 6

Extend Bengal beyond its defaults. Build custom MyST directives, fetch content from external APIs, integrate build tools like Tailwind or esbuild, and create typed content collections.

Tip

Duration: ~90 min | Prerequisite: Python familiarity, basic Jinja2

1

Extending Bengal

Create custom directives, content sources, collections, and build hooks

Extending Bengal

Need a custom directive, remote source, or build hook? This section covers Bengal's extension points. Most sites never need this — start with Authoring and Theming first.

Plugin authors should read Contributor Quickstart for dev setup, then pick an extension type below.

Extension Points

Bengal supports several extension mechanisms:

Extension Type Use Case Difficulty
Build Hooks Run external tools (Tailwind, esbuild) before/after builds Easy
Theme Customization Override templates and CSS Easy
Content Collections Type-safe frontmatter with schema validation Moderate
Template Shortcodes Add template-only embeds without Python Easy
Custom Directives Create new MyST directive blocks Advanced
Custom Content Sources Fetch content from APIs, databases, or remote services Advanced

Architecture Overview

Extensions integrate at different stages of the build pipeline:

flowchart TB subgraph "Pre-Build" A[Build Hooks] end subgraph "Discovery Phase" B[Content Sources] C[Collections & Schemas] end subgraph "Processing Phase" D[Custom Directives] end subgraph "Rendering Phase" E[Theme Templates] F[CSS Customization] end subgraph "Post-Build" G[Build Hooks] end A --> B B --> C C --> D D --> E E --> F F --> G

Quick Start Examples

Build Hooks

Integrate external tools by adding hooks to yourbengal.toml:

TOML
[dev_server]
pre_build = [
    "npx tailwindcss -i src/input.css -o assets/style.css"
]
post_build = [
    "echo 'Build complete!'"
]

Theme Customization

Override any theme template by placing a file with the same name in your project'stemplates/directory:

TREE-SITTER-QUERY
your-project/
├── templates/
│   └── page.html      # Overrides theme's page.html
└── bengal.toml

Content Collections

Define typed schemas for your content:

PYTHON
# collections.py
from dataclasses import dataclass
from datetime import datetime
from bengal.collections import define_collection

@dataclass
class BlogPost:
    title: str
    date: datetime
    author: str = "Anonymous"

collections = {
    "blog": define_collection(schema=BlogPost, directory="content/blog"),
}

Custom Directives

Create new directive blocks by implementing theDirectiveHandlerprotocol:

PYTHON
from typing import ClassVar

from patitas.nodes import Directive

class AlertDirective:
    names: ClassVar[tuple[str, ...]] = ("alert",)
    token_type: ClassVar[str] = "alert"

    def parse(self, name, title, options, content, children, location):
        return Directive(
            location=location,
            name=name,
            title=title or "info",
            options=options,
            children=tuple(children),
        )

    def render(self, node, rendered_children, sb):
        level = node.title or "info"
        sb.append(f'<div class="alert alert-{level}">')
        sb.append(rendered_children)
        sb.append("</div>")

When to Extend

Choose the right extension mechanism for your needs:

  • Build hooks: Integrate CSS preprocessors, JavaScript bundlers, or custom scripts
  • Theme customization: Modify page layouts, add partials, or change styling
  • Collections: Enforce frontmatter requirements and get IDE autocompletion
  • Custom directives: Add domain-specific content blocks (alerts, embeds, widgets)
  • Content sources: Pull content from GitHub, Notion, REST APIs, or databases
2

Build Hooks

Run external tools before and after Bengal builds

Build hooks let you run shell commands before and after Bengal builds. Use them to integrate CSS preprocessors, JavaScript bundlers, icon generators, or custom validation scripts.

Configuration

Add hooks to yourbengal.toml under the [dev_server]section:

TOML
[dev_server]
pre_build = [
    "npm run build:icons",
    "npx tailwindcss -i src/input.css -o assets/style.css"
]
post_build = [
    "echo 'Build complete!'",
    "node scripts/validate-output.js"
]

How Hooks Work

Hooks execute sequentially in the order defined:

  1. Pre-build hooks run before the build starts
  2. Bengal processes and renders your site
  3. Post-build hooks run after all pages are written

If a pre-build hook fails (non-zero exit code), the build stops immediately and reports the error. If a post-build hook fails, a warning is logged but the build is considered complete.

Common Use Cases

CSS Preprocessing with Tailwind

TOML
[dev_server]
pre_build = [
    "npx tailwindcss -i src/input.css -o assets/css/tailwind.css --minify"
]

JavaScript Bundling with esbuild

TOML
[dev_server]
pre_build = [
    "npx esbuild src/main.ts --bundle --outfile=assets/js/bundle.js --minify"
]

Icon Sprite Generation

TOML
[dev_server]
pre_build = [
    "npx svg-sprite --symbol --dest assets/icons src/icons/*.svg"
]

Post-Build Validation

TOML
[dev_server]
post_build = [
    "python scripts/check-links.py public/",
    "npx html-validate public/**/*.html"
]

Multi-Step Asset Pipeline

TOML
[dev_server]
pre_build = [
    "npm run build:icons",
    "npm run build:css",
    "npm run build:js"
]

Environment and Working Directory

Hooks run in your project's root directory with access to your shell environment. This means:

  • npm, npx, and other tools in your PATH work as expected
  • Environment variables from your shell are available
  • Relative paths resolve from the project root

Error Handling

When a pre-build hook fails:

  1. The command's stderr is logged (truncated to 500 characters)
  2. The build stops immediately
  3. An error message shows which hook failed

Example error output:

PLAINTEXT
Pre-build hook failed - skipping build

When a post-build hook fails:

  1. The command's stderr is logged
  2. A warning is logged, but the build is considered successful
  3. Your site output is still available

Successful hook output (stdout) is logged at debug level. Runbengal serve --debugto see full command output during development.

Limitations

  • Hooks run synchronously (one at a time)
  • Each command has a 60-second timeout
  • No shell features like pipes (|) or redirects (>) — commands are parsed as argument lists, not passed to a shell interpreter
  • Hooks run for every build, including incremental rebuilds in dev server mode

Tips

Use npm Scripts for Complex Commands

Instead of long inline commands:

TOML
# ❌ Hard to read
pre_build = [
    "npx tailwindcss -i src/input.css -o assets/style.css --config tailwind.config.js --minify"
]

Define an npm script:

JSON
// package.json
{
  "scripts": {
    "build:css": "tailwindcss -i src/input.css -o assets/style.css --config tailwind.config.js --minify"
  }
}

Then use it in your hooks:

TOML
# ✅ Clean and maintainable
pre_build = ["npm run build:css"]

Skip Hooks in CI

If your CI pipeline handles asset building separately, you can use environment variables:

TOML
# Only run hooks if BENGAL_HOOKS is set
pre_build = []  # Override in local config

Or use a local config override file that's not committed to version control.

3

Custom Directives

Create custom MyST directive blocks for specialized content

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
4

Custom Content Sources

Fetch content from APIs, databases, or remote services

Content sources let Bengal fetch content from anywhere—local files, GitHub repositories, REST APIs, Notion databases, or custom backends. You can create custom sources by implementing theContentSourceabstract class.

Built-in Sources

Bengal includes four content source types:

Source Type ID Use Case
LocalSource local, filesystem Local markdown files (default)
GitHubSource github GitHub repository content
RESTSource rest, api REST API endpoints
NotionSource notion Notion database pages

Using Built-in Sources

Local Source (Default)

The default source for local markdown files:

PYTHON
# collections.py
from bengal.collections import define_collection
from bengal.content.sources import local_loader

collections = {
    "docs": define_collection(
        schema=Doc,
        loader=local_loader("content/docs", exclude=["_drafts/*"]),
    ),
}

GitHub Source

Fetch content from a GitHub repository:

PYTHON
from bengal.content.sources import github_loader

collections = {
    "api-docs": define_collection(
        schema=APIDoc,
        loader=github_loader(
            repo="myorg/api-docs",
            branch="main",
            path="docs/",
            token=os.environ.get("GITHUB_TOKEN"),
        ),
    ),
}

Requires: pip install bengal[github]

REST Source

Fetch content from a REST API:

PYTHON
from bengal.content.sources import rest_loader

collections = {
    "posts": define_collection(
        schema=BlogPost,
        loader=rest_loader(
            url="https://api.example.com/posts",
            headers={"Authorization": "Bearer ${API_TOKEN}"},
            content_field="body",
            frontmatter_fields={"title": "title", "date": "published_at"},
        ),
    ),
}

Requires: pip install bengal[rest]

Notion Source

Fetch pages from a Notion database:

PYTHON
from bengal.content.sources import notion_loader

collections = {
    "wiki": define_collection(
        schema=WikiPage,
        loader=notion_loader(
            database_id="abc123...",
            token=os.environ.get("NOTION_TOKEN"),
        ),
    ),
}

Requires: pip install bengal[notion]

Creating a Custom Source

Implement theContentSourceabstract class:

PYTHON
from bengal.content.sources.source import ContentSource
from bengal.content.sources.entry import ContentEntry

class MyAPISource(ContentSource):
    """Fetch content from a custom API."""

    @property
    def source_type(self) -> str:
        return "my-api"

    async def fetch_all(self):
        """Fetch all content entries."""
        # Get items from your data source
        items = await self._fetch_items()

        for item in items:
            yield ContentEntry(
                id=item["id"],
                slug=item["slug"],
                content=item["body"],
                frontmatter={
                    "title": item["title"],
                    "date": item["created_at"],
                },
                source_type=self.source_type,
                source_name=self.name,
            )

    async def fetch_one(self, id: str):
        """Fetch a single entry by ID."""
        item = await self._fetch_item(id)
        if not item:
            return None

        return ContentEntry(
            id=item["id"],
            slug=item["slug"],
            content=item["body"],
            frontmatter={
                "title": item["title"],
                "date": item["created_at"],
            },
            source_type=self.source_type,
            source_name=self.name,
        )

    async def _fetch_items(self):
        """Your API call implementation."""
        import aiohttp
        async with aiohttp.ClientSession() as session:
            async with session.get(self.config["api_url"]) as resp:
                return await resp.json()

    async def _fetch_item(self, id: str):
        """Fetch single item."""
        import aiohttp
        async with aiohttp.ClientSession() as session:
            url = f"{self.config['api_url']}/{id}"
            async with session.get(url) as resp:
                if resp.status == 404:
                    return None
                return await resp.json()

ContentEntry Structure

Each source yieldsContentEntryobjects:

PYTHON
@dataclass
class ContentEntry:
    id: str                        # Unique identifier within source
    slug: str                      # URL-friendly slug for routing
    content: str                   # Raw markdown content
    frontmatter: dict[str, Any]    # Parsed metadata dictionary
    source_type: str               # Source type (e.g., "github", "notion")
    source_name: str               # Source instance name
    source_url: str | None         # Original URL for attribution
    last_modified: datetime | None # Last modification time
    checksum: str | None           # Content hash for caching

Registering Custom Sources

Option 1: Direct Registration

Register your source instance directly:

PYTHON
from bengal.content.sources import ContentLayerManager

manager = ContentLayerManager()
manager.register_custom_source("my-content", MyAPISource(
    name="my-content",
    config={"api_url": "https://api.example.com/content"},
))

Option 2: With Collections

Use your source as a collection loader:

PYTHON
# collections.py
from bengal.collections import define_collection

my_source = MyAPISource(
    name="my-content",
    config={"api_url": "https://api.example.com/content"},
)

collections = {
    "external": define_collection(
        schema=ExternalContent,
        loader=my_source,
    ),
}

Caching

Content sources support caching to avoid redundant fetches:

PYTHON
class MyAPISource(ContentSource):
    # ...

    def get_cache_key(self) -> str:
        """Generate cache key for this source configuration."""
        # Default implementation hashes config
        # Override for custom cache key logic
        return super().get_cache_key()

    async def is_changed(self, cached_checksum: str | None) -> bool:
        """Check if source content has changed."""
        # Return True to force refetch
        # Return False if content is unchanged
        current = await self._get_current_checksum()
        return current != cached_checksum

    async def get_last_modified(self):
        """Return last modification time for cache invalidation."""
        # Return datetime or None
        return None

Sync Wrappers

For convenience,ContentSourceprovides sync wrappers:

PYTHON
# Async (preferred for performance)
async for entry in source.fetch_all():
    process(entry)

# Sync (convenience wrapper)
for entry in source.fetch_all_sync():
    process(entry)

# Single entry
entry = source.fetch_one_sync("my-id")

Error Handling

Handle errors gracefully in your source:

PYTHON
async def fetch_all(self):
    try:
        items = await self._fetch_items()
    except aiohttp.ClientError as e:
        logger.error(f"Failed to fetch from {self.config['api_url']}: {e}")
        return  # Yield nothing on error

    for item in items:
        try:
            yield self._to_entry(item)
        except KeyError as e:
            logger.warning(f"Skipping malformed item {item.get('id')}: {e}")
            continue

Testing Custom Sources

PYTHON
import pytest
from unittest.mock import AsyncMock, patch

@pytest.mark.asyncio
async def test_my_api_source():
    source = MyAPISource(
        name="test",
        config={"api_url": "https://api.example.com"},
    )

    with patch.object(source, "_fetch_items", new_callable=AsyncMock) as mock:
        mock.return_value = [
            {"id": "1", "slug": "test", "title": "Test", "body": "Content", "created_at": "2025-01-01"},
        ]

        entries = [entry async for entry in source.fetch_all()]

        assert len(entries) == 1
        assert entries[0].frontmatter["title"] == "Test"
5

Content Collections

Define typed schemas for frontmatter validation and IDE support

Content collections let you define typed schemas for your content's frontmatter. Bengal validates content against these schemas during discovery, catching errors early and providing IDE autocompletion.

Quick Start

Create acollections.pyfile in your project root:

PYTHON
from dataclasses import dataclass, field
from datetime import datetime
from bengal.collections import define_collection

@dataclass
class BlogPost:
    title: str
    date: datetime
    author: str = "Anonymous"
    tags: list[str] = field(default_factory=list)
    draft: bool = False

collections = {
    "blog": define_collection(
        schema=BlogPost,
        directory="content/blog",
    ),
}

Now any file in content/blog/must have valid frontmatter:

YAML
---
title: My First Post
date: 2025-01-15
author: Jane Doe
tags: [python, tutorial]
---

Schema Definition

Using Dataclasses

Define your schema as a Python dataclass:

PYTHON
from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class DocPage:
    title: str                              # Required
    weight: int = 0                         # Optional with default
    description: str | None = None          # Optional, nullable
    tags: list[str] = field(default_factory=list)  # Mutable default

Field Types

Bengal automatically coerces frontmatter values to these types:

Type YAML Example Notes
str title: "Hello" Basic string
int weight: 10 Integer
float rating: 4.5 Float
bool draft: true Boolean
datetime date: 2025-01-15 ISO date string
date published: 2025-01-15 Date only
list[str] tags: [a, b, c] List of strings
T | None author: null Optional/nullable

Nested Schemas

Schemas can contain nested dataclasses:

PYTHON
@dataclass
class Author:
    name: str
    email: str | None = None

@dataclass
class BlogPost:
    title: str
    author: Author  # Nested schema

Frontmatter:

YAML
---
title: My Post
author:
  name: Jane Doe
  email: jane@example.com
---

Collection Configuration

define_collection Options

PYTHON
define_collection(
    schema=BlogPost,           # Required: dataclass or Pydantic model
    directory="content/blog",  # Directory containing content
    glob="**/*.md",            # File matching pattern (default: all .md)
    strict=True,               # Reject unknown fields (default: True)
    allow_extra=False,         # Store extra fields in _extra (default: False)
    loader=None,               # Custom content source
)

Strict vs Lenient Mode

Strict mode (default) rejects content with unknown frontmatter fields:

PYTHON
# Strict: unknown fields cause validation errors
collections = {
    "docs": define_collection(schema=DocPage, directory="content/docs", strict=True),
}

Lenient mode allows extra fields:

PYTHON
# Lenient: extra fields are ignored or stored
collections = {
    "docs": define_collection(
        schema=DocPage,
        directory="content/docs",
        strict=False,
        allow_extra=True,  # Store extras in _extra dict
    ),
}

Built-in Schemas

Bengal provides ready-to-use schemas for common content types:

PYTHON
from bengal.collections.schemas import (
    BlogPost,      # title, date, author, tags, draft, description, image, excerpt
    DocPage,       # title, weight, category, tags, toc, description, deprecated, since
    APIReference,  # title, endpoint, method, version, auth_required, rate_limit
    Tutorial,      # title, difficulty, duration, prerequisites, series, order
    Changelog,     # title, date, version, breaking, draft, summary
)

collections = {
    "blog": define_collection(schema=BlogPost, directory="content/blog"),
    "docs": define_collection(schema=DocPage, directory="content/docs"),
}

Extending Built-in Schemas

Add custom fields by subclassing:

PYTHON
from dataclasses import dataclass
from bengal.collections.schemas import BlogPost

@dataclass
class MyBlogPost(BlogPost):
    """Extended blog post with custom fields."""
    series: str | None = None
    reading_time: int | None = None
    featured: bool = False

Validation Errors

When content fails validation, Bengal reports detailed errors:

PLAINTEXT
Content validation failed: content/blog/my-post.md (collection: blog)
  └─ title: Required field 'title' is missing
  └─ date: Cannot parse 'January 15' as datetime
  └─ author.email: Invalid value for type 'str'

Validation Result

The validator returns aValidationResult:

PYTHON
from bengal.collections import SchemaValidator, ValidationResult

validator = SchemaValidator(BlogPost)
result: ValidationResult = validator.validate(frontmatter_dict)

if result.valid:
    post: BlogPost = result.data  # Typed instance
else:
    for error in result.errors:
        print(f"{error.field}: {error.message}")

Using with Remote Sources

Collections work with remote content sources:

PYTHON
from bengal.content.sources import github_loader, notion_loader

collections = {
    # Local content
    "docs": define_collection(
        schema=DocPage,
        directory="content/docs",
    ),

    # GitHub repository
    "api-docs": define_collection(
        schema=APIReference,
        loader=github_loader(repo="myorg/api-docs", path="docs/"),
    ),

    # Notion database
    "wiki": define_collection(
        schema=WikiPage,
        loader=notion_loader(database_id="abc123"),
    ),
}

IDE Support

With typed collections, your IDE provides:

  • Autocompletion for frontmatter fields
  • Type checking for field values
  • Go to definition for schema classes
  • Inline documentation from docstrings

Pydantic Support

Bengal also supports Pydantic models for advanced validation:

PYTHON
from pydantic import BaseModel, EmailStr, HttpUrl

class Author(BaseModel):
    name: str
    email: EmailStr
    website: HttpUrl | None = None

class BlogPost(BaseModel):
    title: str
    author: Author

    class Config:
        extra = "forbid"  # Strict mode

Best Practices

1. Start with Built-in Schemas

Use Bengal's built-in schemas and extend as needed:

PYTHON
from dataclasses import dataclass
from bengal.collections.schemas import DocPage

@dataclass
class MyDocPage(DocPage):
    custom_field: str | None = None

2. Use Strict Mode in Production

Catch frontmatter errors early:

PYTHON
define_collection(schema=DocPage, directory="content/docs", strict=True)

3. Document Your Schemas

Add docstrings for IDE support:

PYTHON
@dataclass
class BlogPost:
    """
    Blog post content schema.

    Attributes:
        title: Post title displayed in listings and page header
        date: Publication date (ISO format: YYYY-MM-DD)
        author: Author name for byline
        tags: List of topic tags for categorization
    """
    title: str
    date: datetime
    author: str = "Anonymous"
    tags: list[str] = field(default_factory=list)
6

Theme Customization

Override templates and customize CSS styling

Bengal themes are fully customizable through template overrides, CSS customization, and design token configuration. You can modify the default theme or create your own.

Template Overrides

Override any theme template by placing a file with the same name in your project'stemplates/directory.

Project Structure

TREE-SITTER-QUERY
your-project/
├── templates/
│   ├── page.html          # Overrides theme's page.html
│   ├── partials/
│   │   └── header.html    # Overrides theme's header partial
│   └── layouts/
│       └── blog.html      # Overrides blog layout
├── content/
└── bengal.toml

Override Priority

Bengal searches for templates in this order:

  1. Projecttemplates/directory (highest priority)
  2. Themetemplates/directory
  3. Built-in fallback templates

Common Overrides

Custom Page Layout

Createtemplates/page.html:

JINJA
{% extends "layouts/base.html" %}

{% block content %}
<article class="custom-page">
    <header>
        <h1>{{ page.title }}</h1>
        {% if page.date %}
        <time datetime="{{ page.date | isodate }}">
            {{ page.date | date("%B %d, %Y") }}
        </time>
        {% end %}
    </header>

    <div class="prose">
        {{ page.content | safe }}
    </div>
</article>
{% end %}
Custom Header

Createtemplates/partials/header.html:

JINJA
<header class="site-header">
    <nav>
        <a href="/" class="logo">{{ site.title }}</a>
        <ul class="nav-links">
            {% for item in site.menus.main %}
            <li><a href="{{ item.url }}">{{ item.name }}</a></li>
            {% endfor %}
        </ul>
    </nav>
</header>
Custom 404 Page

Createtemplates/404.html:

JINJA
{% extends "layouts/base.html" %}

{% block content %}
<div class="error-page">
    <h1>Page Not Found</h1>
    <p>The page you're looking for doesn't exist.</p>
    <a href="/">Return home</a>
</div>
{% end %}

CSS Customization

Method 1: Custom Stylesheet

Create a custom CSS file and reference it in your templates:

CSS
/* assets/css/custom.css */
:root {
    --color-primary: #3b82f6;
    --color-text: #1f2937;
    --font-family-body: 'Inter', sans-serif;
}

.site-header {
    background: var(--color-primary);
}

Include it in your base template or head.htmlpartial:

JINJA
<link rel="stylesheet" href="{{ 'assets/css/custom.css' | asset_url }}">

Method 2: Override Design Tokens

Bengal's default theme uses CSS custom properties (design tokens). Override them in your custom CSS:

CSS
/* Override semantic tokens */
:root {
    /* Colors */
    --color-primary: #3b82f6;
    --color-primary-hover: #2563eb;
    --color-text-primary: #1f2937;
    --color-bg-primary: #ffffff;

    /* Typography */
    --font-family-body: 'Inter', system-ui, sans-serif;
    --font-family-heading: 'Cal Sans', sans-serif;
    --font-family-mono: 'JetBrains Mono', monospace;

    /* Spacing */
    --space-4: 1rem;
    --space-8: 2rem;
}

/* Dark mode overrides */
[data-theme="dark"] {
    --color-text-primary: #f3f4f6;
    --color-bg-primary: #1a1a1a;
}

Method 3: Import and Extend

Import the theme's styles and add your own:

CSS
/* assets/css/main.css */

/* Import theme base styles */
@import '../themes/default/assets/css/style.css';

/* Your customizations */
.my-custom-component {
    padding: var(--space-4);
    background: var(--color-bg-secondary);
}

Theme Configuration

Configure theme features inbengal.toml:

TOML
[theme]
name = "default"

[theme.appearance]
default_mode = "system"  # light, dark, or system
palette = "default"      # Color palette variant

[theme.features]
navigation.toc = true           # Table of contents
navigation.breadcrumbs = true   # Breadcrumb trail
navigation.footer_nav = true    # Previous/next links
content.code_copy = true        # Copy button on code blocks
content.heading_anchors = true  # Anchor links on headings

Feature Flags

Toggle theme features without template overrides:

TOML
[theme.features]
# Navigation
navigation.toc = true
navigation.breadcrumbs = true
navigation.sidebar = true
navigation.footer_nav = true

# Content
content.code_copy = true
content.heading_anchors = true
content.reading_time = true
content.math = true           # LaTeX math via KaTeX (~200KB)

# Search
search.enabled = true
search.keyboard_shortcut = true

Creating a Custom Theme

For extensive customization, create your own theme:

Theme Structure

TREE-SITTER-QUERY
themes/my-theme/
├── theme.yaml           # Theme configuration
├── templates/
│   ├── layouts/
│   │   └── base.html
│   ├── page.html
│   ├── section.html
│   └── partials/
│       ├── header.html
│       ├── footer.html
│       └── nav.html
└── assets/
    ├── css/
    │   └── style.css
    ├── js/
    │   └── main.js
    └── icons/           # Custom icons
        ├── logo.svg
        └── custom.svg

Theme Configuration

Createtheme.toml:

TOML
name = "my-theme"
extends = "default"  # Optional: inherit from another theme

Cross-Theme Template Extends

When your theme extends another theme (likedefault), you can explicitly reference the parent theme's templates using the theme_name/template.htmlsyntax:

JINJA
{# templates/layouts/base.html #}
{% extends "default/base.html" %}

{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ 'css/my-theme.css' | asset_url }}">
{% end %}

{% block content %}
<div class="my-theme-wrapper">
    {{ super() }}
</div>
{% end %}

This explicit syntax is useful when:

  • Building distributable themes - Reference parent templates by name for clarity
  • Avoiding ambiguity - Specify exactly which theme's template to extend
  • Debugging - Make inheritance chain visible in templates

Without the prefix, templates use priority-based resolution (project > child theme > parent theme > default):

JINJA
{# Uses whichever base.html is found first in the chain #}
{% extends "layouts/base.html" %}

With the prefix, templates explicitly target a specific theme:

JINJA
{# Always extends default theme's base.html #}
{% extends "default/layouts/base.html" %}

Using Your Theme

Reference it inbengal.toml:

TOML
[theme]
name = "my-theme"
path = "themes/my-theme"  # Path to theme directory

Template Variables

Site Variables

JINJA
{{ site.title }}          # Site title
{{ site.baseurl }}        # Base URL
{{ site.config }}         # Full site config
{{ site.menus.main }}     # Navigation menus
{{ site.pages }}          # All pages

Page Variables

JINJA
{{ page.title }}          # Page title
{{ page.content }}        # Rendered HTML content
{{ page.href }}           # URL with baseurl applied
{{ page.date }}           # Publication date
{{ page.metadata }}       # All frontmatter
{{ page.section }}        # Parent section
{{ page.toc }}            # Table of contents

Template Functions

Bengal provides 80+ template functions:

JINJA
{# String functions #}
{{ title | slugify }}
{{ content | truncatewords(50) }}

{# Date functions #}
{{ page.date | date("%B %d, %Y") }}
{{ page.date | isodate }}

{# URL functions #}
{{ "/about/" | absolute_url }}
{{ "image.png" | asset_url }}

{# Content functions #}
{{ page.content | reading_time }}
{{ pages | sort_by("date", reverse=True) }}

Best Practices

1. Start Small

Override only what you need:

TREE-SITTER-QUERY
templates/
└── partials/
    └── header.html  # Just the header

2. Use Design Tokens

Prefer token overrides over hard-coded values:

CSS
/* ✅ Good: Uses tokens */
.button {
    background: var(--color-primary);
    padding: var(--space-2) var(--space-4);
}

/* ❌ Avoid: Hard-coded values */
.button {
    background: #3b82f6;
    padding: 0.5rem 1rem;
}

3. Extend, Don't Replace

Use Kida's{% block %} and {% extends %}:

JINJA
{% extends "layouts/base.html" %}

{% block head %}
{{ super() }}  {# Keep parent content #}
<link rel="stylesheet" href="{{ 'custom.css' | asset_url }}">
{% end %}

4. Test Dark Mode

Ensure customizations work in both modes:

CSS
.custom-component {
    background: var(--color-bg-primary);
    color: var(--color-text-primary);
}

/* Automatically works in dark mode via tokens */

Directive Styling

Bengal automatically includes base CSS for all directives (tabs, dropdowns, steps, admonitions, etc.). Your theme only needs to provide aesthetic styles—functional requirements are handled for you.

What's Included Automatically

Directive Base CSS Provides Your Theme Adds
Tabs Show/hide panes, list resets, focus-visible Layout, colors, animations
Dropdowns Summary marker reset, accessibility Borders, colors, icons, animations
Steps Counter reset, list reset Layout, connector lines, colors
Admonitions Reduced-motion Layout, colors, icons
Cards Focus-visible Grid layout, shadows, hover effects

Styling Directives

Override directive aesthetics using CSS custom properties or direct selectors:

CSS
/* Customize tab appearance */
.tab-nav {
  display: flex;
  background: var(--color-bg-secondary);
  border-bottom: 1px solid var(--color-border);
}

.tab-nav li.active a {
  color: var(--color-primary);
  border-bottom: 2px solid var(--color-primary);
}

/* Add animation to active pane (base CSS handles show/hide) */
.tab-pane.active {
  animation: fadeIn 0.2s ease-out;
}

Prose Contamination

Base CSS includes list-style resets to prevent tabs and steps from inheriting unwanted bullet points inside prose containers. If you need extra safety, use!important:

CSS
.prose .tab-nav,
.prose .tab-nav li {
  list-style: none !important;
  margin: 0 !important;
}

Accessibility

Base CSS provides:

  • focus-visible states for keyboard navigation
  • prefers-reduced-motion media queries that disable animations

Your theme animations will automatically respect reduced-motion preferences.

Icon Customization

Add custom icons or override defaults by placing SVG files in your theme'sassets/icons/directory:

TREE-SITTER-QUERY
themes/my-theme/assets/icons/
├── company-logo.svg   # New icon
└── warning.svg        # Overrides default

Icons are resolved in priority order: site theme → theme → parent theme → Bengal defaults.

See Icon Reference for SVG format requirements and configuration options.

✓ Track Complete