Bengal supports custom template engines through a protocol-based interface. Your engine automatically gets access to all 200+ template functions, filters, and tests (74 global functions, 135 filters, and 6 tests).
Required Protocols
Bengal defines two protocols for template engines:
TemplateEnvironment Protocol
Your engine's environment object must satisfy this protocol for template function registration:
from typing import Protocol, MutableMapping, Callable, Any
class TemplateEnvironment(Protocol):
"""Minimal interface for template function registration."""
globals: MutableMapping[str, Any] # Global variables
filters: MutableMapping[str, Callable] # Filter functions
tests: MutableMapping[str, Callable] # Test functions
If your environment hasglobals,filters, andtestsas dict-like attributes, Bengal automatically registers:
- 200+ template functions, filters, and tests (74 globals, 135 filters, 6 tests)
- String functions (truncate, slugify, markdownify, strip_html, etc.)
- Collection functions (sort_by, group_by, where, first, last, etc.)
- Date/time filters (strftime, relative_date, days_ago, etc.)
- Navigation helpers (breadcrumbs, toc, auto_nav, etc.)
- SEO/sharing functions (meta tags, Open Graph, social sharing URLs)
- And much more...
TemplateEngine Protocol
For a complete engine implementation:
from bengal.protocols import TemplateEngine, EngineCapability
class MyEngine:
"""Custom template engine implementation."""
site: Site
template_dirs: list[Path]
def render_template(self, name: str, context: dict[str, Any]) -> str:
"""Render a named template."""
...
def render_string(self, template: str, context: dict[str, Any]) -> str:
"""Render an inline template string."""
...
def template_exists(self, name: str) -> bool:
"""Check if template exists."""
...
def get_template_path(self, name: str) -> Path | None:
"""Resolve template to filesystem path."""
...
def list_templates(self) -> list[str]:
"""List all available templates."""
...
def validate(self, patterns: list[str] | None = None) -> list[TemplateError]:
"""Validate templates for syntax errors."""
...
@property
def capabilities(self) -> EngineCapability:
"""Return supported capabilities."""
return EngineCapability.NONE
def has_capability(self, cap: EngineCapability) -> bool:
return cap in self.capabilities
Step-by-Step Implementation
Step 1: Create Your Environment Class
# my_engine/environment.py
from collections.abc import MutableMapping
from typing import Any, Callable
class MyEnvironment:
"""Template environment that satisfies TemplateEnvironment protocol."""
def __init__(self):
# These three attributes are REQUIRED for protocol compliance
self.globals: dict[str, Any] = {}
self.filters: dict[str, Callable[..., Any]] = {}
self.tests: dict[str, Callable[..., bool]] = {}
# Your engine-specific setup
self._templates: dict[str, str] = {}
def add_global(self, name: str, value: Any) -> None:
"""Add a global variable."""
self.globals[name] = value
def add_filter(self, name: str, func: Callable) -> None:
"""Add a filter function."""
self.filters[name] = func
def add_test(self, name: str, func: Callable) -> None:
"""Add a test function."""
self.tests[name] = func
Step 2: Create Your Engine Class
# my_engine/engine.py
from pathlib import Path
from typing import Any
from bengal.core import Site
from bengal.protocols import EngineCapability
from bengal.rendering.engines.errors import TemplateError, TemplateNotFoundError
from bengal.rendering.template_functions import register_all
from .environment import MyEnvironment
class MyEngine:
"""Custom template engine implementation."""
def __init__(self, site: Site):
self.site = site
self.template_dirs = self._build_template_dirs()
# Create environment that satisfies TemplateEnvironment protocol
self._env = MyEnvironment()
# Register all 200+ Bengal template functions, filters, and tests automatically!
register_all(self._env, site, engine_type="generic")
def _build_template_dirs(self) -> list[Path]:
"""Build ordered list of template directories."""
dirs = []
# Project templates (highest priority)
project_templates = self.site.root_path / "templates"
if project_templates.exists():
dirs.append(project_templates)
# Theme templates (simplified - for theme inheritance, see Kida engine)
# For basic cases, this works. For parent/child theme support, use
# resolve_theme_chain() as shown in bengal.rendering.engines.kida
if hasattr(self.site, 'theme_path') and self.site.theme_path:
theme_templates = self.site.theme_path / "templates"
if theme_templates.exists():
dirs.append(theme_templates)
return dirs
def render_template(self, name: str, context: dict[str, Any]) -> str:
"""Render a named template with context."""
template_path = self.get_template_path(name)
if not template_path:
raise TemplateNotFoundError(f"Template not found: {name}")
source = template_path.read_text()
return self._render(source, context)
def render_string(self, template: str, context: dict[str, Any]) -> str:
"""Render an inline template string."""
return self._render(template, context)
def _render(self, source: str, context: dict[str, Any]) -> str:
"""Core rendering logic - implement for your engine."""
# Merge globals into context
full_context = {**self._env.globals, **context}
# Your rendering implementation here
# Access filters via self._env.filters
# Access tests via self._env.tests
...
def template_exists(self, name: str) -> bool:
"""Check if template exists."""
return self.get_template_path(name) is not None
def get_template_path(self, name: str) -> Path | None:
"""Find template in search directories."""
for dir in self.template_dirs:
path = dir / name
if path.exists():
return path
return None
def list_templates(self) -> list[str]:
"""List all available templates."""
templates = set()
for dir in self.template_dirs:
for path in dir.rglob("*.html"):
templates.add(str(path.relative_to(dir)))
return sorted(templates)
def validate(self, patterns: list[str] | None = None) -> list[TemplateError]:
"""Validate templates for syntax errors."""
errors = []
for name in self.list_templates():
try:
path = self.get_template_path(name)
if path:
source = path.read_text()
self._validate_syntax(source)
except Exception as e:
errors.append(TemplateError(name=name, message=str(e)))
return errors
@property
def capabilities(self) -> EngineCapability:
"""Declare engine capabilities."""
return EngineCapability.NONE
def has_capability(self, cap: EngineCapability) -> bool:
return cap in self.capabilities
Step 3: Register Your Engine
# my_engine/__init__.py
from bengal.rendering.engines import register_engine
from .engine import MyEngine
# Register with Bengal
register_engine("myengine", MyEngine)
Step 4: Configure Bengal
# bengal.yaml
site:
template_engine: myengine
Verification
Verify your environment satisfies the protocol:
from bengal.protocols import TemplateEnvironment
env = MyEnvironment()
# Runtime check (protocol is @runtime_checkable)
assert isinstance(env, TemplateEnvironment), "Environment doesn't satisfy protocol"
# Check required attributes exist
assert hasattr(env, 'globals') and hasattr(env.globals, 'update')
assert hasattr(env, 'filters') and hasattr(env.filters, 'update')
assert hasattr(env, 'tests') and hasattr(env.tests, 'update')
print("✓ Environment satisfies TemplateEnvironment protocol")
What You Get Automatically
Once your environment satisfiesTemplateEnvironment,register_all()provides:
| Category | Functions | Examples |
|---|---|---|
| Strings | 15+ | truncate,slugify,markdownify,strip_html |
| Collections | 15+ | sort_by,group_by,where,first,last |
| Dates | 10+ | strftime,days_ago,relative_date |
| URLs | 5+ | absurl,relurl,url_encode |
| Content | 10+ | markdown,highlight,excerpt |
| Navigation | 10+ | breadcrumbs,toc,auto_nav,pagination |
| SEO | 5+ | meta_tags,og_tags,schema_org |
| i18n | 5+ | t,current_lang,available_langs |
| Debug | 5+ | dump,inspect,type_of |
Engine Capabilities
Declare optional capabilities your engine supports:
from bengal.protocols import EngineCapability
@property
def capabilities(self) -> EngineCapability:
return (
EngineCapability.BLOCK_CACHING | # {% cache %} support
EngineCapability.INTROSPECTION # Template analysis
)
Available capabilities:
BLOCK_CACHING— Supports{% cache %}blocksBLOCK_LEVEL_DETECTION— Can detect block-level changesINTROSPECTION— Can analyze template structurePIPELINE_OPERATORS— Supports|>operatorPATTERN_MATCHING— Supports{% match %}syntax
Examples
Minimal Engine (Dict-Based)
class DictEngine:
"""Minimal engine using Python string formatting."""
def __init__(self, site):
self.site = site
self.template_dirs = [site.root_path / "templates"]
# Minimal environment
self._env = type('Env', (), {
'globals': {},
'filters': {},
'tests': {}
})()
register_all(self._env, site)
def render_template(self, name: str, context: dict) -> str:
path = self.template_dirs[0] / name
template = path.read_text()
return template.format(**{**self._env.globals, **context})
Wrapping an Existing Engine
class MakoEngine:
"""Wrap Mako template engine."""
def __init__(self, site):
from mako.lookup import TemplateLookup
self.site = site
self.template_dirs = [site.root_path / "templates"]
self._lookup = TemplateLookup(directories=self.template_dirs)
# Create protocol-compatible wrapper
self._env = MakoEnvironmentWrapper(self._lookup)
register_all(self._env, site)
class MakoEnvironmentWrapper:
"""Make Mako compatible with TemplateEnvironment protocol."""
def __init__(self, lookup):
self._lookup = lookup
self.globals = {}
self.filters = {}
self.tests = {}
Next Steps
- Template Functions Reference — All available functions
- Engine Protocol Reference — Full protocol details
- Kida Source Code — Reference implementation
Seealso
- Add Custom Filter — Add functions to existing engines
- Extension Points — Other customization options