Take control of how your site looks. Start with CSS variable overrides, progress to template recipes, and end with a complete custom theme.

Tip

Duration: ~90 min | Prerequisite: HTML, CSS, basic Jinja2

1

Themer Quickstart

Customize themes and create your own designs

Themer Quickstart

Learn to customize Bengal themes and create your own designs.

Prerequisites

0/4 complete
  • Bengal installed
  • Basic knowledge of HTML
  • Familiarity with CSS (or willingness to learn)
  • Basic Jinja2 template syntax

Understand Theme Resolution

Bengal looks for templates in this order:

  1. Your projecttemplates/(overrides everything)
  2. Your themethemes/your-theme/templates/
  3. Installed themes — Via pip/uv
  4. Default theme — Built into Bengal

You only need to override what you want to change.

Create a Custom Theme

bengal new theme my-theme

This creates:

themes/my-theme/
├── theme.yaml
├── templates/
│   ├── base.html
│   ├── page.html
│   └── partials/
│       ├── header.html
│       └── footer.html
└── static/
    ├── css/
    │   └── style.css
    └── js/
        └── main.js

Configure Your Theme

Editconfig/_default/theme.yaml:

1
2
theme:
  name: "my-theme"

Override Templates Selectively

You don't need to copy all templates. Extend the default:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{# themes/my-theme/templates/base.html #}
{# Extend the base template if you want to reuse structure #}
{% extends "base.html" %}

{% block header %}
<header class="custom-header">
    <h1>{{ site.title }}</h1>
    {% for item in menu.main %}
    <a href="{{ item.url }}">{{ item.name }}</a>
    {% endfor %}
</header>
{% endblock %}

Everything not overridden inherits from the default theme (or parent theme).

Add Custom CSS

Createthemes/my-theme/static/css/custom.css:

1
2
3
4
5
6
7
8
9
:root {
    --color-primary: #3498db;
    --color-text: #2c3e50;
}

.custom-header {
    background: var(--color-primary);
    padding: 2rem;
}

Include in your template:

1
2
3
{% block extra_head %}
<link rel="stylesheet" href="{{ asset_url('css/custom.css') }}">
{% endblock %}

Template Variables

Key variables available in templates:

Variable Description
site.title Site title
site.pages All pages
page.title Current page title
page.content Raw content
page.rendered_html Rendered HTML
page.url Page URL
menu.main Main navigation menu

Debug Theme Issues

1
2
3
4
5
6
7
8
# Check theme resolution
bengal utils theme debug

# List available themes
bengal utils theme list

# Get theme info
bengal utils theme info default

Next Steps

2

Customize Themes

Customize existing themes without breaking updates

Customize Themes

Customize Bengal themes without breaking theme updates. Use theme inheritance, template overrides, and CSS customization techniques.

Understand Theme Resolution

Bengal resolves themes in this order:

  1. Project themes -themes/your-theme/(highest priority)
  2. Installed themes - Installed via pip/uv
  3. Bundled themes - Built into Bengal (e.g.,default)

Check Active Theme

1
2
3
4
5
6
7
8
# List available themes
bengal utils theme list

# Get theme info
bengal utils theme info default

# Debug theme resolution
bengal utils theme debug

Create a Project Theme

Option 1: Start from Scratch

bengal new theme my-custom-theme

This creates:

themes/my-custom-theme/
├── theme.yaml
├── templates/
│   ├── base.html
│   ├── page.html
│   └── partials/
│       ├── header.html
│       └── footer.html
└── static/
    ├── css/
    │   └── style.css
    └── js/
        └── main.js

Configure Your Theme

Editbengal.toml:

1
2
3
[theme]
name = "my-custom-theme"
default_appearance = "light"

Override Templates Selectively

Template Inheritance

You don't need to copy all templates. Override only what you need:

themes/my-custom-theme/templates/base.html:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{# Extend default theme's base template #}
{% extends "default::base.html" %}

{# Override only the header block #}
{% block header %}
<header class="custom-header">
    <h1>{{ site.title }}</h1>
    <nav>
        {% for item in menu.main %}
        <a href="{{ item.url }}">{{ item.name }}</a>
        {% endfor %}
    </nav>
</header>
{% endblock %}

{# Everything else inherits from default theme #}

Partial Overrides

Override specific partials:

themes/my-custom-theme/templates/partials/footer.html:

1
2
3
4
<footer class="custom-footer">
    <p>&copy; {{ site.author }} {{ "now" | date("%Y") }}</p>
    <p>Custom footer content</p>
</footer>

Bengal will use your partial instead of the theme's default.

Customize CSS

Method 1: Override Theme CSS

Create your own CSS file that overrides theme styles:

themes/my-custom-theme/static/css/custom.css:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/* Override theme colors */
:root {
    --color-primary: #3498db;
    --color-text: #2c3e50;
}

/* Custom styles */
.custom-header {
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    padding: 2rem;
}

Include in your base template:

themes/my-custom-theme/templates/base.html:

1
2
3
4
5
{% extends "default::base.html" %}

{% block extra_head %}
<link rel="stylesheet" href="{{ asset_url('css/custom.css') }}">
{% endblock %}

Method 2: Use CSS Variables

Many themes support CSS variables. Override them:

themes/my-custom-theme/static/css/overrides.css:

1
2
3
4
5
6
:root {
    /* Override default theme variables */
    --theme-primary: #3498db;
    --theme-secondary: #2ecc71;
    --theme-font-sans: 'Inter', sans-serif;
}

Theme Configuration Options

Themes can expose configuration options:

themes/my-custom-theme/theme.yaml:

1
2
3
4
5
6
7
8
9
name: my-custom-theme
version: 1.0.0
description: Customizable theme

params:
  show_author: true
  show_date: true
  sidebar_position: left
  color_scheme: light

Access in templates:

1
2
3
{% if theme.config.params.show_author %}
<p>By {{ page.author or site.author }}</p>
{% endif %}

Configure inbengal.toml:

1
2
3
4
5
6
7
[theme]
name = "my-custom-theme"

[theme.params]
show_author = true
sidebar_position = "right"
color_scheme = "dark"

Best Practices

Don't Modify Installed Themes

Bad:

1
2
# Don't edit installed theme directly
vim $(python -m site --user-site)/bengal/themes/default/templates/base.html

Good:

1
2
3
# Create project theme that extends default
bengal new theme my-theme
# Override only what you need

Use Theme Inheritance

Good:

1
2
3
4
{% extends "default::base.html" %}
{% block header %}
  {# Only override header #}
{% endblock %}

Bad:

1
2
3
4
5
{# Copying entire base.html #}
<!DOCTYPE html>
<html>
  {# ... hundreds of lines ... #}
</html>

Troubleshooting

Seealso

3

Template Cookbook

Common templating patterns and Bengal-specific features

Template Cookbook

Practical examples showing how to accomplish common tasks with Bengal's templating system.

Content Queries

Work with pages, sections, and taxonomies.

Example What You'll Learn
List Recent Posts where,sort_by,limitfilters
Group by Category group_byfilter, nested loops
Filter by Multiple Tags Chaining filters,inoperator

Page Features

Add features to individual pages.

Example What You'll Learn
Add Table of Contents page.toc, scroll highlighting
Show Reading Time reading_timefilter

Quick Reference

The Essentials

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{# Get pages from a section #}
{% set posts = site.pages | where('section', 'blog') %}

{# Sort by date, newest first #}
{% set recent = posts | sort_by('date', reverse=true) %}

{# Limit to 5 #}
{% set latest = recent | limit(5) %}

{# Or chain it all #}
{% set latest = site.pages
  | where('section', 'blog')
  | sort_by('date', reverse=true)
  | limit(5) %}

Common Filters

Filter Purpose Example
where Filter by field pages \| where('draft', false)
sort_by Sort results pages \| sort_by('title')
limit Take first N pages \| limit(10)
group_by Group by field pages \| group_by('category')
first Get first item pages \| first

See Template Functions for the complete reference.

4

Show Reading Time

Display estimated reading time using Bengal's reading_time filter

Show Reading Time

Display estimated reading time using Bengal'sreading_timefilter.

The Pattern

1
2
3
<span class="reading-time">
  {{ page.content | reading_time }} min read
</span>

That's it. Bengal'sreading_timefilter calculates based on word count (200 wpm default).

What's Happening

Component Purpose
page.content Raw content of the page
reading_time Bengal filter: counts words, divides by 200

Variations

With Word Count

Bengal provides aword_countfilter that strips HTML and counts words:

<span>{{ page.content | word_count }} words · {{ page.content | reading_time }} min read</span>

Both filters work together seamlessly since they use the same word counting logic.

Custom Calculation

1
2
3
4
5
{# 250 words per minute instead of 200 #}
{% set words = page.content | word_count %}
{% set minutes = (words / 250) | round(0, 'ceil') | int %}

<span>{{ minutes }} min read</span>

Handle Short Content

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{% set minutes = page.content | reading_time %}

<span class="reading-time">
{% if minutes < 1 %}
  Quick read
{% elif minutes == 1 %}
  1 min read
{% else %}
  {{ minutes }} min read
{% endif %}
</span>

Frontmatter Override

Allow manual override for complex content:

1
2
3
4
5
{% if page.metadata.reading_time %}
  {% set minutes = page.metadata.reading_time %}
{% else %}
  {% set minutes = page.content | reading_time %}
{% endif %}

Then in frontmatter:

1
2
3
4
---
title: Complex Technical Guide
reading_time: 25  # Override calculated time
---

Seealso

5

List Recent Posts

Query and display recent content using Bengal's filters

List Recent Posts

Display the most recent posts from a section using Bengal's query filters.

The Pattern

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{% set posts = site.pages
  | where('section', 'blog')
  | where('draft', false)
  | sort_by('date', reverse=true)
  | limit(5) %}

<ul class="recent-posts">
{% for post in posts %}
  <li>
    <a href="{{ post.url }}">{{ post.title }}</a>
    <time>{{ post.date | date('%B %d, %Y') }}</time>
  </li>
{% endfor %}
</ul>

What's Happening

Filter Purpose
where('section', 'blog') Only pages in theblog/directory
where('draft', false) Exclude drafts
sort_by('date', reverse=true) Newest first
limit(5) Take only 5

Variations

From Any Section

1
2
3
4
5
{# All non-draft pages, any section #}
{% set recent = site.pages
  | where('draft', false)
  | sort_by('date', reverse=true)
  | limit(10) %}

With Featured Post

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{% set posts = site.pages | where('section', 'blog') | sort_by('date', reverse=true) %}
{% set featured = posts | first %}
{% set rest = posts | offset(1) | limit(4) %}

<div class="featured">
  <h2>{{ featured.title }}</h2>
  <p>{{ featured.description }}</p>
</div>

<ul class="more-posts">
{% for post in rest %}
  <li><a href="{{ post.url }}">{{ post.title }}</a></li>
{% endfor %}
</ul>

Exclude Current Page

1
2
3
4
5
6
7
8
9
{# In a sidebar, show recent posts excluding the current one #}
{% set others = site.pages
  | where('section', 'blog')
  | sort_by('date', reverse=true)
  | limit(6) %}

{% for post in others if post.url != page.url %}
  <a href="{{ post.url }}">{{ post.title }}</a>
{% endfor %}

Seealso

6

Templating

Jinja2 layouts, inheritance, and partials

Jinja2 Templating

Bengal uses Jinja2 for all templates. If you know Python, you'll feel at home.

Template Lookup Order

flowchart LR A[Page Request] --> B{templates/ ?} B -->|Found| C[Use Your Template] B -->|Not Found| D{Theme templates/ ?} D -->|Found| E[Use Theme Template] D -->|Not Found| F[Use Bengal Default]

Bengal searches: Your projectThemeBengal defaults

Quick Start

1
2
3
4
5
6
7
8
9
{# templates/layouts/single.html #}
{% extends "baseof.html" %}

{% block content %}
<article>
  <h1>{{ page.title }}</h1>
  {{ page.content | safe }}
</article>
{% endblock %}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{# templates/baseof.html #}
<!DOCTYPE html>
<html>
<head>
  <title>{% block title %}{{ page.title }}{% endblock %}</title>
</head>
<body>
  {% block header %}{% include "partials/header.html" %}{% endblock %}
  {% block content %}{% endblock %}
  {% block footer %}{% include "partials/footer.html" %}{% endblock %}
</body>
</html>
1
2
3
4
5
6
7
8
{# templates/partials/header.html #}
<header>
  <nav>
    {% for item in site.menus.main %}
      <a href="{{ item.url }}">{{ item.title }}</a>
    {% endfor %}
  </nav>
</header>

Key Concepts

Concept Syntax Purpose
Extends {% extends "base.html" %} Inherit from parent template
Block {% block name %}...{% endblock %} Replaceable section
Include {% include "partial.html" %} Insert another template
Variable {{ page.title }} Output a value
Filter {{ text \| truncate(100) }} Transform a value

Template Inheritance

flowchart TB A["baseof.html<br/>(blocks: head, content, footer)"] B["single.html<br/>(extends baseof)"] C["list.html<br/>(extends baseof)"] D["doc.html<br/>(extends single)"] A --> B A --> C B --> D

Tip

Override sparingly: You only need to create templates you want to customize. Start by copying one template from your theme, modify it, and let the rest fall through to defaults.

7

Template Functions Reference

Complete reference for Bengal's template filters and functions

Template Functions Reference

Bengal provides powerful template filters for querying, filtering, and transforming content collections.

Collection Filters

These filters work with lists of pages, dictionaries, or any iterable.

where

Filter items where a key matches a value. Supports comparison operators.

Basic Usage:

1
2
3
4
5
{# Filter by exact value (default) #}
{% set tutorials = site.pages | where('category', 'tutorial') %}

{# Filter by nested attribute #}
{% set track_pages = site.pages | where('metadata.track_id', 'getting-started') %}

With Comparison Operators:

Operator Description Example
eq Equal (default) where('status', 'published', 'eq')
ne Not equal where('status', 'draft', 'ne')
gt Greater than where('date', one_year_ago, 'gt')
gte Greater than or equal where('priority', 5, 'gte')
lt Less than where('weight', 100, 'lt')
lte Less than or equal where('order', 10, 'lte')
in Value in list where('tags', 'python', 'in')
not_in Value not in list where('status', ['archived'], 'not_in')

Operator Examples:

1
2
3
4
5
6
7
8
{# Pages newer than a year ago #}
{% set recent = site.pages | where('date', one_year_ago, 'gt') %}

{# Pages tagged with 'python' #}
{% set python_posts = site.pages | where('tags', 'python', 'in') %}

{# Exclude archived pages #}
{% set live = site.pages | where('status', ['archived', 'draft'], 'not_in') %}

where_not

Filter items where a key does NOT equal a value.

1
2
{# Exclude drafts #}
{% set published = site.pages | where_not('draft', true) %}

sort_by

Sort items by a key, with optional reverse order.

1
2
3
4
5
{# Sort by date, newest first #}
{% set recent = site.pages | sort_by('date', reverse=true) %}

{# Sort alphabetically by title #}
{% set alphabetical = site.pages | sort_by('title') %}

group_by

Group items by a key value, returning a dictionary.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{% set by_category = site.pages | group_by('category') %}

{% for category, pages in by_category.items() %}
<h2>{{ category }}</h2>
<ul>
  {% for page in pages %}
  <li><a href="{{ page.url }}">{{ page.title }}</a></li>
  {% endfor %}
</ul>
{% endfor %}

limit

Take the first N items from a list.

1
2
{# Latest 5 posts #}
{% set latest = site.pages | sort_by('date', reverse=true) | limit(5) %}

offset

Skip the first N items from a list.

1
2
{# Skip first 10 items (pagination page 2) #}
{% set page_2 = items | offset(10) | limit(10) %}

first / last

Get the first or last item from a list.

1
2
{% set featured = site.pages | where('metadata.featured', true) | first %}
{% set oldest = site.pages | sort_by('date') | last %}

reverse

Reverse a list (returns a new list).

{% set newest_first = chronological | reverse %}

uniq

Remove duplicate items while preserving order.

{% set unique_tags = all_tags | uniq %}

flatten

Flatten nested lists into a single list.

{% set all_tags = nested_tags | flatten | uniq %}

Set Operations

Perform set operations on lists.

union

Combine two lists, removing duplicates.

{% set combined = featured | union(recent) %}

intersect

Get items that appear in both lists.

{% set featured_python = featured | intersect(python) %}

complement

Get items in the first list that are NOT in the second list.

{% set regular = all_posts | complement(featured) %}

Chaining Filters

Filters can be chained for powerful queries:

1
2
3
4
5
6
{% set result = site.pages
  | where('category', 'tutorial')
  | where('tags', 'python', 'in')
  | where('draft', false)
  | sort_by('date', reverse=true)
  | limit(10) %}

Quick Reference

Filter Purpose Example
where(key, val) Filter by value pages \| where('type', 'post')
where(key, val, 'gt') Greater than pages \| where('date', cutoff, 'gt')
where(key, val, 'in') Value in list pages \| where('tags', 'python', 'in')
where_not(key, val) Exclude value pages \| where_not('draft', true)
sort_by(key) Sort ascending pages \| sort_by('title')
sort_by(key, reverse=true) Sort descending pages \| sort_by('date', reverse=true)
group_by(key) Group by value pages \| group_by('category')
limit(n) Take first N pages \| limit(5)
offset(n) Skip first N pages \| offset(10)
first First item pages \| first
last Last item pages \| last
reverse Reverse order pages \| reverse
uniq Remove duplicates tags \| uniq
flatten Flatten nested lists nested \| flatten
union(list2) Combine lists list1 \| union(list2)
intersect(list2) Common items list1 \| intersect(list2)
complement(list2) Difference list1 \| complement(list2)

Seealso

8

Theme Variables Reference

Complete reference of all variables available in Jinja2 templates

Theme Variables Reference

Complete reference for all variables, objects, and functions available in Bengal templates.

Global Objects

These variables are available in all templates.

site

The global site object.

Attribute Type Description
site.title str Site title from config
site.baseurl str Base URL (e.g.,https://example.com)
site.author str Site author name
site.language str Language code (e.g.,en)
site.pages list[Page] All pages in the site
site.regular_pages list[Page] Content pages only (no list pages)
site.sections list[Section] Top-level sections
site.taxonomies dict Map of taxonomies (tags, categories)
site.data dict Data loaded fromdata/directory
site.config dict Full configuration object

page

The current page being rendered.

Attribute Type Description
page.title str Page title
page.content str Raw content
page.rendered_html str Rendered HTML content
page.date datetime Publication date
page.url str URL with baseurl applied (for display in templates)
page.relative_url str Relative URL without baseurl (for comparisons)
page.permalink str Alias forurl(backward compatibility)
page.metadata dict All frontmatter keys
page.toc str Auto-generated Table of Contents
page.is_home bool True if homepage
page.is_section bool True if section index

URL Properties

Bengal provides three URL properties with clear purposes:

page.url - Primary property for display

  • Automatically includes baseurl (e.g.,/bengal/docs/page/)
  • Use in<a href>,<link>,<img src>attributes
  • Works correctly for all deployment scenarios

page.relative_url - For comparisons and logic

  • Relative URL without baseurl (e.g.,/docs/page/)
  • Use for comparisons:{% if page.relative_url == '/docs/' %}
  • Use for menu activation, filtering, and conditional logic

page.permalink - Backward compatibility

  • Alias forurl(same value)
  • Maintained for compatibility with existing themes

Example: Usage

1
2
3
4
5
6
7
{# Display URL (includes baseurl) #}
<a href="{{ page.url }}">{{ page.title }}</a>

{# Comparison (without baseurl) #}
{% if page.relative_url == '/docs/' %}
  <span class="active">Current Section</span>
{% endif %}

Global Functions

Functions available in all templates.

asset_url(path)

Generates a fingerprint-aware URL for an asset.

1
2
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
<!-- Outputs: /assets/css/style.a1b2c3d4.css -->

url_for(page_or_slug)

Generates a URL for a page object or slug.

<a href="{{ url_for(page) }}">Link</a>

dateformat(date, format)

Formats a date object.

{{ dateformat(page.date, "%B %d, %Y") }}

get_menu(menu_name)

Retrieves a navigation menu.

1
2
3
{% for item in get_menu('main') %}
  <a href="{{ item.url }}">{{ item.name }}</a>
{% endfor %}

Template Helpers

Bengal includes categorized helper modules:

  • Strings:truncate,slugify,markdownify
  • Collections:where,group_by,sort_by
  • Dates:time_ago,date_iso
  • Images:image_processed,image_url
  • Taxonomies:related_posts,popular_tags

Tip

Debugging You can inspect available variables by printing them in a comment: <!-- {{ page.metadata }} -->

Seealso