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

Bengal provides extension points for customizing content processing, adding new content sources, defining typed content schemas, and integrating external build tools.

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

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

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

Content Collections

Define typed schemas for your content:

# 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 subclassingBengalDirective:

from bengal.directives import BengalDirective, DirectiveToken

class AlertDirective(BengalDirective):
    NAMES = ["alert"]
    TOKEN_TYPE = "alert"

    def parse_directive(self, title, options, content, children, state):
        return DirectiveToken(
            type=self.TOKEN_TYPE,
            attrs={"level": title or "info"},
            children=children,
        )

    def render(self, renderer, text, **attrs):
        level = attrs.get("level", "info")
        return f'<div class="alert alert-{level}">{text}</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:

[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

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

JavaScript Bundling with esbuild

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

Icon Sprite Generation

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

Post-Build Validation

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

Multi-Step Asset Pipeline

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

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:

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

Define an npm script:

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

# ✅ 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:

# 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 base class for creating your own directives.

Directive Basics

Directives in Bengal:

  • Parse content using the MyST fenced syntax
  • Support typed options with automatic coercion
  • Can define parent-child nesting relationships
  • Register automatically when added to the directive factory

Creating a Custom Directive

SubclassBengalDirectiveand implement the required methods:

from bengal.directives import BengalDirective, DirectiveToken

class AlertDirective(BengalDirective):
    """Custom alert box directive."""

    # Required: Names that trigger this directive
    NAMES = ["alert", "callout"]

    # Required: Token type for the AST
    TOKEN_TYPE = "alert"

    def parse_directive(self, title, options, content, children, state):
        """Parse the directive into a token."""
        return DirectiveToken(
            type=self.TOKEN_TYPE,
            attrs={
                "level": title or "info",
                "class": options.get("class", ""),
            },
            children=children,
        )

    def render(self, renderer, text, **attrs):
        """Render the token to HTML."""
        level = attrs.get("level", "info")
        css_class = attrs.get("class", "")
        classes = f"alert alert-{level} {css_class}".strip()
        return f'<div class="{classes}">{text}</div>'

Usage in Markdown:

:::{alert} warning
:class: my-custom-class

This is a warning message.
:::

Class Attributes

NAMES (Required)

List of directive names that trigger this directive:

NAMES = ["dropdown", "details", "collapsible"]

All names map to the same directive class.

TOKEN_TYPE (Required)

String identifier for the AST node:

TOKEN_TYPE = "dropdown"

Used internally to match parse and render phases.

OPTIONS_CLASS (Optional)

Typed dataclass for parsing directive options:

from bengal.directives import DirectiveOptions
from dataclasses import dataclass

@dataclass
class DropdownOptions(DirectiveOptions):
    open: bool = False
    icon: str | None = None

class DropdownDirective(BengalDirective):
    NAMES = ["dropdown"]
    TOKEN_TYPE = "dropdown"
    OPTIONS_CLASS = DropdownOptions

    def parse_directive(self, title, options, content, children, state):
        # options is now a DropdownOptions instance
        return DirectiveToken(
            type=self.TOKEN_TYPE,
            attrs={
                "title": title or "Details",
                "open": options.open,  # Typed access
                "icon": options.icon,
            },
            children=children,
        )

CONTRACT (Optional)

Define valid parent-child nesting relationships:

from bengal.directives import DirectiveContract

class StepDirective(BengalDirective):
    NAMES = ["step"]
    TOKEN_TYPE = "step"
    CONTRACT = DirectiveContract(requires_parent=("steps",))

This ensures :::{step} can only appear inside :::{steps}.

Methods to Implement

parse_directive

Build the AST token from parsed components:

def parse_directive(
    self,
    title: str,          # Text after directive name
    options: Options,    # Parsed options (or DirectiveOptions instance)
    content: str,        # Raw content inside directive
    children: list,      # Parsed child tokens
    state: Any,          # Parser state (for advanced use)
) -> DirectiveToken:
    return DirectiveToken(
        type=self.TOKEN_TYPE,
        attrs={"title": title},
        children=children,
    )

render

Convert the token to HTML:

def render(
    self,
    renderer: Any,   # Patitas renderer (for rendering children)
    text: str,       # Pre-rendered HTML of children
    **attrs: Any,    # Attributes from parse_directive
) -> str:
    title = attrs.get("title", "")
    return f"<details><summary>{title}</summary>{text}</details>"

Built-in Utilities

The package provides HTML generation helpers:

from bengal.directives import (
    escape_html,        # Escape HTML entities
    build_class_string, # Build CSS class string from list
    class_attr,         # Generate class="..." attribute
    data_attrs,         # Generate data-* attributes
    bool_attr,          # Generate boolean attributes (open, disabled)
)

def render(self, renderer, text, **attrs):
    classes = build_class_string("alert", attrs.get("class", ""))
    data = data_attrs(level=attrs.get("level"))
    return f'<div class="{classes}" {data}>{text}</div>'

Example: Embed Directive

A directive for embedding external content:

from dataclasses import dataclass
from bengal.directives import (
    BengalDirective,
    DirectiveOptions,
    DirectiveToken,
    escape_html,
)

@dataclass
class EmbedOptions(DirectiveOptions):
    width: str = "100%"
    height: str = "400px"
    title: str = ""

class EmbedDirective(BengalDirective):
    """Embed external content via iframe."""

    NAMES = ["embed", "iframe"]
    TOKEN_TYPE = "embed"
    OPTIONS_CLASS = EmbedOptions

    def parse_directive(self, title, options, content, children, state):
        # Title contains the URL
        url = title.strip() if title else ""
        return DirectiveToken(
            type=self.TOKEN_TYPE,
            attrs={
                "url": url,
                "width": options.width,
                "height": options.height,
                "title": options.title or "Embedded content",
            },
        )

    def render(self, renderer, text, **attrs):
        url = escape_html(attrs.get("url", ""))
        width = escape_html(attrs.get("width", "100%"))
        height = escape_html(attrs.get("height", "400px"))
        title = escape_html(attrs.get("title", ""))

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

Custom directives must be registered with the Patitas parser. The standard approach is to add them to the directive registry inbengal/directives/registry.py:

# In bengal/directives/registry.py, add to _DIRECTIVE_MAP:
_DIRECTIVE_MAP: dict[str, str] = {
    # ... existing directives ...
    "alert": "your_module.alert_directive",
    "callout": "your_module.alert_directive",
}

Note

Currently, custom directives require modification to the Bengal codebase or a fork. A plugin system for external directive registration is on the roadmap.

Testing Directives

Test both parse and render phases by instantiating your directive class directly:

import pytest
from your_directives import AlertDirective  # Your custom directive

def test_alert_directive_parse():
    directive = AlertDirective()

    # Test parse_directive
    token = directive.parse_directive(
        title="warning",
        options={},
        content="Test content",
        children=[],
        state=None,
    )

    assert token.attrs["level"] == "warning"

def test_alert_directive_render():
    directive = AlertDirective()

    html = directive.render(
        renderer=None,
        text="<p>Test content</p>",
        level="warning",
    )

    assert 'class="alert alert-warning"' in html
    assert "<p>Test content</p>" in html

Testing with Parser Integration (using ContextVar configuration):

from bengal.parsing.backends.patitas import (
    ParseConfig, RenderConfig,
    parse_config_context, render_config_context,
)
from bengal.parsing.backends.patitas.renderers.html import HtmlRenderer
from bengal.directives import create_default_registry
from patitas.parser import Parser

def test_directive_full_integration():
    registry = create_default_registry()
    source = """
:::{note}
This is a note.
:::
"""
    # Parse with directive registry
    with parse_config_context(ParseConfig(directive_registry=registry)):
        parser = Parser(source.strip())
        ast = parser.parse()

    # Render with directive registry
    with render_config_context(RenderConfig(directive_registry=registry)):
        renderer = HtmlRenderer()
        html = renderer.render(ast)

    assert "admonition" in html
    assert "note" in html

For built-in directives, use get_directive()to retrieve registered classes:

from bengal.directives import get_directive

def test_dropdown_directive():
    DropdownDirective = get_directive("dropdown")
    directive = DropdownDirective()
    # ... test implementation
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:

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

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:

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:

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:

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:

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

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:

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

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:

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

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

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:

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:

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

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:

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

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

Frontmatter:

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

Collection Configuration

define_collection Options

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:

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

Lenient mode allows extra fields:

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

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:

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:

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:

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:

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:

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:

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:

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

3. Document Your Schemas

Add docstrings for IDE support:

@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

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:

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

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

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

/* 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:

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

/* 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:

/* 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:

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

[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

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

Creating a Custom Theme

For extensive customization, create your own theme:

Theme Structure

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:

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:

{# 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):

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

With the prefix, templates explicitly target a specific theme:

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

Using Your Theme

Reference it inbengal.toml:

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

Template Variables

Site Variables

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

Page Variables

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

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

templates/
└── partials/
    └── header.html  # Just the header

2. Use Design Tokens

Prefer token overrides over hard-coded values:

/* ✅ 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 %}:

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

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

/* 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:

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

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