Skip to content

Templates

Buraq uses Jinja2 as its sole template engine.


config/settings.py
TEMPLATES_DIR = str(BASE_DIR / "templates") # one path, or a list of them
# APP_DIRS (default True) — also search <app>/templates/ in every INSTALLED_APP
APP_DIRS = True

Buraq searches template directories in priority order:

  1. TEMPLATES_DIR (your project-level templates)
  2. Each installed app’s templates/ subfolder (when APP_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.

TEMPLATE_OPTIONS is passed to Jinja’s Environment, so anything it accepts can be set:

config/settings.py
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.


from buraq.shortcuts import render
async def my_view(request):
return await render(request, "posts/list.html", {"posts": posts})

Buraq adds to Jinja rather than wrapping it, so the whole language is available and its documentation is the reference for it:

Jinja’s own filters are there under the names you would expect, alongside Buraq’s:

abs attr batch capitalize center count default dictsort escape filesizeformat
first float forceescape format groupby indent int items join last length list
lower map max min pprint random reject rejectattr replace reverse round safe
select selectattr slice sort string striptags sum title tojson trim truncate
unique upper urlencode urlize wordcount wordwrap xmlattr

groupby, 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.


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


Buraq uses {{ csrf_input }} — a plain string global — instead of a special {% csrf_token %} tag.

<form method="post">
{{ csrf_input }}
...
</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 modelcsrf_input is just a string injected into env.globals at 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 %}

myapp/templatetags.py
from buraq.template import register
@register.global
def my_tag():
return "hello"
any template — just works
{{ my_tag() }}

Why Buraq wins:

  • Simpler — 1 file, 1 decorator, no {% load %} ever; Django requires a separate templatetags/ directory, __init__.py, a Library() 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 into env.globals at startup
  • More powerful@register.global gives you a full callable with arguments ({{ fn(a, b) }}); simple_tag has 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.


In addition to url, static, csrf_input, and now, Buraq registers several utility globals:

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 %}

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 %}

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 %}

Remove whitespace between HTML tags:

{{ spaceless(content) }}

See Template Tags for the full API.


templates/base.html
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My Site{% endblock %}</title>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
templates/posts/list.html
{% extends "base.html" %}
{% block title %}Posts{% endblock %}
{% block content %}
{% for post in posts %}
<h2>{{ post.title }}</h2>
{% endfor %}
{% endblock %}

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

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.

config/settings.py
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})

Buraq ships 37 built-in filters and 10 globals, registered automatically into every environment — alongside Jinja’s own 54. No {% load %} required.

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"
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)

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

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