Extension Developer
Create custom directives, content sources, and build integrations.
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
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:
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
Related Resources
- Architecture Reference for understanding Bengal internals
- Protocol Layer for interface contracts
- Build Pipeline for pipeline phase details
- Configuration for
bengal.tomloptions
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:
- Pre-build hooks run before the build starts
- Bengal processes and renders your site
- 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:
- The command's stderr is logged (truncated to 500 characters)
- The build stops immediately
- An error message shows which hook failed
Example error output:
Pre-build hook failed - skipping build
When a post-build hook fails:
- The command's stderr is logged
- A warning is logged, but the build is considered successful
- 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.
Related
- Configuration for all
bengal.tomloptions - Build Pipeline for understanding when hooks run
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
Related
- Directives Reference for built-in directives
- Build Pipeline for understanding when directives are processed
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"
Related
- Content Collections for schema validation
- Build Pipeline for understanding discovery phase
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)
Related
- Custom Content Sources for remote content
- Cheatsheet for frontmatter field quick reference
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:
- Project
templates/directory (highest priority) - Theme
templates/directory - 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-visiblestates for keyboard navigationprefers-reduced-motionmedia 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.
Related
- Icon Reference for custom icons and icon library
- Build Hooks for CSS preprocessing
- Configuration for theme settings
- Template Functions for template syntax
- Directives Reference for all available directives