Templates
Buraq uses Jinja2 as its sole template engine.
Configuration
Section titled “Configuration”TEMPLATES_DIR = str(BASE_DIR / "templates") # one path, or a list of them
# APP_DIRS (default True) — also search <app>/templates/ in every INSTALLED_APPAPP_DIRS = TrueBuraq searches template directories in priority order:
TEMPLATES_DIR(your project-level templates)- Each installed app’s
templates/subfolder (whenAPP_DIRS = True)
This means an app can ship its own templates that are automatically available
without any extra configuration — and because your project’s directory is
searched first, you can override any of them by putting a file of the same name
in templates/.
Name an app’s templates <app>/templates/<app>/... so two apps shipping a
detail.html do not collide.
Configuring Jinja itself
Section titled “Configuring Jinja itself”TEMPLATE_OPTIONS is passed to Jinja’s Environment, so anything it accepts can
be set:
TEMPLATE_OPTIONS = { "undefined": "jinja2.StrictUndefined", # a typo raises instead of rendering "" "trim_blocks": True, "lstrip_blocks": True, "extensions": ["jinja2.ext.loopcontrols"], # {% break %} / {% continue %}}undefined and extensions take dotted paths so this file never has to import
jinja2; everything else is passed straight through. autoescape stays on
whatever else you set.
Rendering
Section titled “Rendering”from buraq.shortcuts import render
async def my_view(request): return await render(request, "posts/list.html", {"posts": posts})The Jinja language
Section titled “The Jinja language”Buraq adds to Jinja rather than wrapping it, so the whole language is available and its documentation is the reference for it:
- Template Designer Documentation — the syntax, and every built-in filter and test in detail.
Jinja’s own filters are there under the names you would expect, alongside Buraq’s:
abs attr batch capitalize center count default dictsort escape filesizeformatfirst float forceescape format groupby indent int items join last length listlower map max min pprint random reject rejectattr replace reverse round safeselect selectattr slice sort string striptags sum title tojson trim truncateunique upper urlencode urlize wordcount wordwrap xmlattrgroupby, selectattr, map and batch have no Django equivalent and are
worth knowing — {{ posts|selectattr("published")|list }} replaces a filter
written in Python.
The pages here cover what Buraq adds: its own filters and globals, template loading, and the settings that configure the environment.
Coming from Django
Section titled “Coming from Django”The syntax is close enough to be misleading. These are the differences that actually bite:
| Django | Buraq (Jinja) | |
|---|---|---|
{{ x|date:"Y-m-d" }} |
{{ x|date("Y-m-d") }} |
filter arguments are call parentheses, not a colon |
{% for %}…{% empty %} |
{% for %}…{% else %} |
same meaning, different keyword |
forloop.counter |
loop.index |
both 1-based |
forloop.counter0 |
loop.index0 |
|
forloop.first / .last |
loop.first / loop.last |
|
forloop.revcounter |
loop.revindex |
|
{% comment %}…{% endcomment %} |
{# … #} |
|
{% verbatim %} |
{% raw %} |
|
{% csrf_token %} |
{{ csrf_input }} |
a global, not a tag |
{% url 'name' pk=1 %} |
{{ url("name", pk=1) }} |
|
{% cycle 'a' 'b' %} |
{{ loop.cycle("a", "b") }} |
inside a loop; Jinja’s own, and it remembers per iteration |
{% firstof a b %} |
{{ firstof(a, b) }} |
|
{% load %} |
— | not needed; tags are discovered at startup |
A missing variable renders as empty in both, so a typo is silent in each. Set
TEMPLATE_OPTIONS = {"undefined": "jinja2.StrictUndefined"} to make it raise
instead — see configuring Jinja.
Jinja2 vs Django template syntax
Section titled “Jinja2 vs Django template syntax”Jinja2 and Django templates share similar syntax ({{ var }}, {% block %}, {% for %}, {% if %}) but they are not identical:
| Feature | Jinja2 (Buraq) | Django templates |
|---|---|---|
| Filters | {{ val|upper }} |
{{ val|upper }} — same |
| Tests | {% if x is defined %} |
not available |
| Macros | {% macro foo() %} |
not available |
| Expressions | {{ 1 + 2 }}, {{ loop.index }} |
limited |
{% set %} |
✅ | ✅ |
{% include %} |
✅ | ✅ |
{% extends %} / {% block %} |
✅ | ✅ |
{% with %} |
✅ | ✅ |
| Custom globals | @register.global in templatetags.py |
@register.simple_tag + {% load %} |
| Custom filters | @register.filter in templatetags.py |
@register.filter + {% load %} |
| Custom tests | @register.test in templatetags.py |
not available |
{% load %} |
❌ not needed — tags auto-discovered at startup | required in every template |
| CSRF token | {{ csrf_input }} |
{% csrf_token %} |
Buraq registers its own globals (_(), get_language(), reverse(), etc.) to cover the most common cases without any extra setup.
CSRF token — {{ csrf_input }}
Section titled “CSRF token — {{ csrf_input }}”Buraq uses {{ csrf_input }} — a plain string global — instead of a special {% csrf_token %} tag.
<form method="post"> {{ csrf_input }} ...</form><form method="post"> {% csrf_token %} ...</form>Why {{ csrf_input }} is better:
-
Consistent —
{{ }}outputs values,{% %}is for control flow. A CSRF token is a value, so{{ }}is the right syntax. Using{% csrf_token %}breaks this convention by making a tag output HTML. -
Simple mental model —
csrf_inputis just a string injected intoenv.globalsat startup, the same mechanism as every other global. Nothing special about it. -
Composable — because it’s a plain value, it works anywhere a value is valid:
{# inline in a form #}{{ csrf_input }}{# stored in a variable for reuse #}{% set token = csrf_input %}
Why no {% load %}?
Section titled “Why no {% load %}?”from buraq.template import register
@register.globaldef my_tag(): return "hello"{{ my_tag() }}from django import template
register = template.Library()
@register.simple_tagdef my_tag(): return "hello"{% load myapp_tags %}{% my_tag %}Why Buraq wins:
- Simpler — 1 file, 1 decorator, no
{% load %}ever; Django requires a separatetemplatetags/directory,__init__.py, aLibrary()instance, the decorator, and{% load %}in every template that uses it - Safer — tag errors are caught at startup, not silently at render time when a template forgets
{% load %} - Faster — no
{% load %}parsing on every request; all tags registered once intoenv.globalsat startup - More powerful —
@register.globalgives you a full callable with arguments ({{ fn(a, b) }});simple_taghas limitations around argument handling and can’t be called with keyword args as naturally in template syntax
Buraq auto-discovers templatetags.py in every INSTALLED_APPS app at startup and registers all globals, filters, and tests into the Jinja2 environment once. This means:
- No per-template
{% load %}— no parsing overhead on every request - One file, one decorator — vs Django’s
templatetags/directory,__init__.py,Library()instance, decoration, and{% load %}in every template - Fail loudly at startup — a missing or broken tag file is caught immediately, not silently at render time
Performance — both resolve to the same Jinja2 env.globals / env.filters dict lookup at render time, so runtime speed is identical. The difference is startup: Buraq’s auto-discovery scans once at app startup and registers everything — no per-template {% load %} parsing overhead on every request.
Maintenance — one file (templatetags.py), one decorator, done. The alternative requires: create templatetags/ directory, add __init__.py, create the tag file, instantiate Library(), decorate, then {% load %} in every template that uses it. That’s 5 steps vs 1. When you rename or move a tag, the other approach breaks silently at render time (missing {% load %}); Buraq fails loudly at startup.
Scalability — auto-discovery scales better. As the app grows, new templatetags.py files in new apps are picked up automatically — no central registration, no config changes. {% load %} becomes a maintenance burden across hundreds of templates when tag libraries are reorganized.
Built-in template globals
Section titled “Built-in template globals”In addition to url, static, csrf_input, and now, Buraq registers several utility globals:
regroup(iterable, grouper)
Section titled “regroup(iterable, grouper)”Group a sequence by a common attribute. Returns a list of {"grouper": value, "list": [items]} dicts:
{% set grouped = regroup(people, "city") %}{% for group in grouped %} <h3>{{ group.grouper }}</h3> {% for person in group.list %} <p>{{ person.name }}</p> {% endfor %}{% endfor %}cycle(*values)
Section titled “cycle(*values)”Returns a callable that cycles through values on each call:
{% set row_class = cycle("odd", "even") %}{% for item in items %} <tr class="{{ row_class() }}"> <td>{{ item.name }}</td> </tr>{% endfor %}ifchanged()
Section titled “ifchanged()”Returns a callable that outputs True only when its argument changes between calls:
{% set ic = ifchanged() %}{% for item in items %} {% if ic(item.category) %} <h3>{{ item.category }}</h3> {% endif %} <p>{{ item.name }}</p>{% endfor %}spaceless(html)
Section titled “spaceless(html)”Remove whitespace between HTML tags:
{{ spaceless(content) }}See Template Tags for the full API.
Template inheritance
Section titled “Template inheritance”<!DOCTYPE html><html><head> <title>{% block title %}My Site{% endblock %}</title></head><body> {% block content %}{% endblock %}</body></html>{% extends "base.html" %}
{% block title %}Posts{% endblock %}
{% block content %} {% for post in posts %} <h2>{{ post.title }}</h2> {% endfor %}{% endblock %}Built-in template globals
Section titled “Built-in template globals”Available in every template automatically — no import or passing from views needed:
| Name | Description |
|---|---|
request |
Current request object |
get_messages(request) |
Flash messages |
_() / gettext() |
Translate a string (when USE_I18N = True) |
ngettext() |
Plural translation |
pgettext() |
Context-disambiguated translation |
get_language() |
Active language code |
get_language_bidi() |
True for RTL languages |
csrf_input |
Hidden CSRF <input> field (HTML) |
csrf_token |
Raw CSRF token string |
url(name, **kwargs) |
Reverse a named URL — equivalent to reverse() |
static(path) |
Prepend STATIC_URL to a path |
STATIC_URL |
Value of the STATIC_URL setting |
MEDIA_URL |
Value of the MEDIA_URL setting |
{% cache timeout "key" %} |
Cache a template block — see Cache |
Context processors
Section titled “Context processors”render() automatically calls every processor listed in TEMPLATE_CONTEXT_PROCESSORS and merges the results into the template context before rendering. Caller-supplied keys override processor values.
TEMPLATE_CONTEXT_PROCESSORS = [ "buraq.template.context_processors.request", "buraq.template.context_processors.auth", # add your own]You can still pass extra context explicitly — it takes priority:
return await render(request, "posts/list.html", {"user": override_user})Built-in filters
Section titled “Built-in filters”Buraq ships 37 built-in filters and 10 globals, registered automatically into every environment — alongside Jinja’s own 54. No {% load %} required.
Date & time
Section titled “Date & time”| Filter | Example | Output |
|---|---|---|
date |
{{ post.created_at|date("d M Y") }} |
"05 Aug 2026" |
time |
{{ post.created_at|time("H:i") }} |
"14:30" |
timesince |
{{ post.created_at|timesince }} |
"2 hours ago" |
timeuntil |
{{ event.starts_at|timeuntil }} |
"3 days" |
date supports the full format code set: d, j, D, l, S, m, n, M, N, F, Y, y, H, G, h, g, i, s, A, a, U, W, z, t.
| Filter | Example | Output |
|---|---|---|
truncatechars |
{{ text|truncatechars(30) }} |
truncate to 30 chars, append … |
truncatewords |
{{ text|truncatewords(10) }} |
truncate to 10 words |
wordcount |
{{ body|wordcount }} |
number of words |
capfirst |
{{ name|capfirst }} |
first character uppercased |
addslashes |
{{ value|addslashes }} |
escape ', ", \ |
slugify |
{{ title|slugify }} |
"hello-world" |
linenumbers |
{{ code|linenumbers }} |
prepend line numbers |
pluralize |
{{ count|pluralize }} |
"" / "s" |
yesno |
{{ flag|yesno("yes,no") }} |
"yes" or "no" |
default_if_none |
{{ val|default_if_none("—") }} |
fallback when None |
phone2numeric |
{{ "1-800-COLLECT"|phone2numeric }} |
"1-800-2655328" |
floatformat |
{{ 3.14159|floatformat(2) }} |
"3.14" |
HTML output
Section titled “HTML output”| Filter | Description |
|---|---|
linebreaks |
Wrap paragraphs in <p>, line breaks in <br> |
linebreaksbr |
Replace \n with <br> |
urlize |
Convert plain URLs to <a href="…"> links |
escapejs |
Escape for safe embedding in JS string literals |
json_script(id) |
Wrap JSON in <script type="application/json" id="…"> |
filesizeformat |
Human-readable file size (1.2 MB) |
Jinja2 features
Section titled “Jinja2 features”{# Comments #}
{# Variables and filters #}{{ post.title }}{{ post.title|upper }}{{ post.created_at.strftime("%Y-%m-%d") }}
{# Expressions #}{{ loop.index }}. {{ post.title }}{{ 1 + 2 }}
{# Control flow #}{% if post.is_published %}Published{% else %}Draft{% endif %}
{% for post in posts %} {{ loop.index }}. {{ post.title }}{% else %} No posts.{% endfor %}
{# Tests #}{% if loop.index is even %}<tr class="alt">{% endif %}{% if post is defined %}{{ post.title }}{% endif %}
{# Set variables #}{% set total = items|length %}
{# Include #}{% include "partials/nav.html" %}
{# Macros — reusable snippets #}{% macro render_field(field) %} <div class="field"> <label>{{ field.label }}</label> <input name="{{ field.html_name }}" value="{{ field.value }}"> </div>{% endmacro %}
{{ render_field(form.title) }}Auto-escaping
Section titled “Auto-escaping”Jinja2 auto-escapes HTML output by default. To render trusted HTML, use the safe filter or mark your function with is_safe=True in the tag registry:
{{ post.content|safe }}