From Jinja2

Convert existing Jinja2 templates to Kida syntax

11 min read 2280 words

Learn how to convert existing Jinja2 templates to Kida syntax for unified block endings, pattern matching, and pipeline operators.

Goal

Migrate your Jinja2 templates to Kida syntax while maintaining functionality.

Prerequisites

  • Existing Jinja2 templates
  • Bengal site configured
  • Understanding of template syntax

Migration Strategy

Phase 1: Compatibility Mode (Zero Changes)

Kida is Bengal's default engine and can parse Jinja2 syntax. Your existing Jinja2 templates work without changes.

Tip

Already using Kida: Since Kida is the default engine, new Bengal sites already use Kida. Your Jinja2 templates work, but you can migrate to Kida syntax to use pattern matching, pipeline operators, and unified{% end %}blocks.

Phase 2: Gradual Migration

Migrate templates incrementally, starting with:

  1. New templates (use Kida syntax)
  2. Frequently-used templates (biggest performance gain)
  3. Simple templates (easiest to migrate)
  4. Complex templates (last)

Syntax Changes

Block Endings

Jinja2:

{% if condition %}
  Content
{% endif %}

{% for item in items %}
  {{ item }}
{% endfor %}

Kida:

{% if condition %}
  Content
{% end %}

{% for item in items %}
  {{ item }}
{% end %}

Migration: Replace{% endif %},{% endfor %},{% endblock %}, etc. with{% end %}.

Template Variables

Jinja2:

{% set site_title = site.config.title %}
{# Available in current block only #}

Kida:

{% let site_title = site.config.title %}
{# Available throughout template #}

Migration: Replace{% set %}with{% let %}for template-wide variables. Keep{% set %}for block-scoped variables (variables that should only exist within a specific block).

Scoping Differences:

  • {% let %}: Template-scoped (available throughout template)
  • {% set %}: Block-scoped (only available within the block)
  • {% export %}: Promotes variables from inner scopes to template scope

Pattern Matching

Jinja2:

{% if page.type == "blog" %}
  Blog post
{% elif page.type == "doc" %}
  Documentation
{% elif page.type == "tutorial" %}
  Tutorial
{% else %}
  Default
{% endif %}

Kida:

{% match page.type %}
  {% case "blog" %}
    Blog post
  {% case "doc" %}
    Documentation
  {% case "tutorial" %}
    Tutorial
  {% case _ %}
    Default
{% end %}

Migration: Replace longif/elifchains with{% match %}...{% case %}.

Pipeline Operator

Jinja2:

{{ items | selectattr('published') | sort(attribute='date') | first }}

Kida (Jinja2-compatible filters):

{{ items |> selectattr('published') |> sort(attribute='date') |> first }}

Kida (Bengal template functions - default):

{{ items |> where('published', true) |> sort_by('date') |> first }}

Migration:

  • Replace|with|>for the pipeline operator
  • Kida supports Jinja2 filters (selectattr,sort) for compatibility
  • Bengal provides simpler alternatives (where,sort_by) that are recommended for new code

Fallback Values

Jinja2:

{{ items | default([]) | length }}
{{ name | default('Anonymous') | upper }}

Kida (simple fallbacks):

{{ user.name ?? 'Anonymous' }}
{{ config.timeout ?? 30 }}

Kida (fallbacks with filter chains):

{# Use | default() when applying filters after the fallback #}
{{ items | default([]) | length }}
{{ name | default('') | upper }}

Warning

Precedence gotcha: The??operator has lower precedence than|, so filters bind to the fallback, not the result:

{# ❌ Parses as: items ?? ([] | length) — returns list, not length! #}
{{ items ?? [] | length }}

{# ✅ Correct: use | default() for filter chains #}
{{ items | default([]) | length }}

Migration: Keep using| default()when you need to apply filters after the fallback. Use??for simple direct output.

Fragment Caching

Jinja2 (requires extension):

{% cache "key" %}
  Expensive content
{% endcache %}

Kida (built-in):

{% cache "key" %}
  Expensive content
{% end %}

Migration: Replace{% endcache %}with{% end %}.

Note

Cache TTL: Fragment cache TTL is configured at the environment level inbengal.yaml, not per-key. Setkida.fragment_ttl(in seconds) to control cache expiration for all fragments.

Step-by-Step Migration

Step 1: Identify Template

Choose a template to migrate:

# List your templates
find templates/ -name "*.html"

Step 2: Create Backup

# Backup original
cp templates/blog/single.html templates/blog/single.html.jinja2

Step 3: Replace Block Endings

# Replace all block endings with {% end %}
sed -i 's/{% endif %}/{% end %}/g' templates/blog/single.html
sed -i 's/{% endfor %}/{% end %}/g' templates/blog/single.html
sed -i 's/{% endblock %}/{% endblock %}/g' templates/blog/single.html  # Keep endblock for compatibility

Step 4: Replace Template Variables

Find{% set %}used for template-wide variables:

{# Before #}
{% set site_title = site.config.title %}

{# After #}
{% let site_title = site.config.title %}

Step 5: Convert Pattern Matching

Find longif/elifchains:

{# Before #}
{% if page.type == "blog" %}
  Blog
{% elif page.type == "doc" %}
  Doc
{% else %}
  Default
{% endif %}

{# After #}
{% match page.type %}
  {% case "blog" %}
    Blog
  {% case "doc" %}
    Doc
  {% case _ %}
    Default
{% end %}

Step 6: Update Filter Chains

Replace filter syntax (two options):

Option 1: Keep Jinja2 filters, change operator:

{# Before #}
{{ items | selectattr('published') | sort(attribute='date') }}

{# After (Jinja2-compatible) #}
{{ items |> selectattr('published') |> sort(attribute='date') }}

Option 2: Use Bengal template functions (recommended):

{# Before #}
{{ items | selectattr('published') | sort(attribute='date') }}

{# After (Bengal functions) #}
{{ items |> where('published', true) |> sort_by('date') }}

Step 7: Add Fragment Caching

Add caching to expensive operations:

{# Before #}
{% for post in expensive_calculation() %}
  {{ post.title }}
{% endfor %}

{# After #}
{% cache "posts-list" %}
  {% for post in expensive_calculation() %}
    {{ post.title }}
  {% end %}
{% end %}

Step 8: Test

# Build and test
bengal build
bengal serve

Functions vs Filters

Bengal distinguishes between filters (transform values) and functions (standalone operations).

Jinja2 mixes both:

{{ items | selectattr('published') }}  {# Filter #}
{{ range(10) }}                        {# Function #}

Bengal separates them:

{{ items |> where('published', true) }}  {# Filter #}
{{ get_page('path') }}                   {# Function #}

When migrating:

  • Jinja2 filters → Bengal filters (use|or|>)
  • Jinja2 functions → Bengal functions (direct call)

See Functions vs Filters for complete explanation.

Filter Name Mapping

Kida supports Jinja2 filters for compatibility, and Bengal provides additional template functions:

Jinja2-Compatible Filters

Kida uses the same filter names as Jinja2. The main difference is the pipeline operator|>instead of|:

Jinja2 Filter Kida Filter Notes
selectattr('key') selectattr('key') Same name, use ` >` operator
selectattr('key', 'eq', val) selectattr('key', 'eq', val) Same name, use ` >` operator
sort(attribute='key') sort(attribute='key') Same name, use ` >` operator
sort(attribute='key', reverse=true) sort(attribute='key', reverse=true) Same name, use ` >` operator

Bengal provides simpler alternatives that work with both|and|>:

Jinja2 Filter Bengal Function Notes
selectattr('key', 'eq', val) where('key', val) Simpler syntax, supports operators ('eq','ne','gt','in', etc.)
sort(attribute='key') sort_by('key') Simpler syntax, supportsreverse=true
first(n) take(n) Different name: usetakeinstead offirst
last(n) `take(n) > reverse` Different name: usetakewithreverse

Note

Template Functions:where,sort_by,take, and other collection functions are Bengal template functions automatically available in all templates. They work with both|(Jinja2-style) and|>(Kida pipeline) operators.

Common Patterns

Collection Filtering

Jinja2:

{% set posts = site.pages | selectattr('type', 'eq', 'blog') | selectattr('draft', 'eq', false) | sort(attribute='date', reverse=true) %}

Kida (using Bengal template functions - default):

{% let posts = site.pages
  |> where('type', 'blog')
  |> where('draft', false)
  |> sort_by('date', reverse=true) %}

Kida (using Jinja2-compatible filters):

{% let posts = site.pages
  |> selectattr('type', 'eq', 'blog')
  |> selectattr('draft', 'eq', false)
  |> sort(attribute='date', reverse=true) %}

Conditional Rendering

Jinja2:

{% if page.type == "blog" %}
  <article class="blog">{{ page.content | safe }}</article>
{% elif page.type == "doc" %}
  <article class="doc">{{ page.content | safe }}</article>
{% else %}
  <article>{{ page.content | safe }}</article>
{% endif %}

Kida:

{% match page.type %}
  {% case "blog" %}
    <article class="blog">{{ page.content | safe }}</article>
  {% case "doc" %}
    <article class="doc">{{ page.content | safe }}</article>
  {% case _ %}
    <article>{{ page.content | safe }}</article>
{% end %}

Template Variables

Jinja2:

{% set site_title = site.config.title %}
{% set nav_items = site.menus.main %}

Kida:

{% let site_title = site.config.title %}
{% let nav_items = site.menus.main %}

Migration Checklist

  • Enable Kida inbengal.yaml
  • Test templates work in compatibility mode
  • Replace{% endif %},{% endfor %}, etc. with{% end %}
  • Replace template-wide{% set %}with{% let %}
  • Convert longif/elifchains to pattern matching
  • Update filter chains to use|>and Kida filter names
  • Add fragment caching where appropriate
  • Test all templates
  • Remove Jinja2 compatibility code

Troubleshooting

Validate Templates Before Building

Run template validation to catch syntax errors before a slow build:

# Validate all templates
bengal validate --templates

# Validate with migration hints
bengal validate --templates --fix

# Validate specific templates
bengal validate --templates --templates-pattern "autodoc/**/*.html"

Template Not Found

# Check template lookup order
bengal build --verbose

Filter Not Found

Kida uses different filter names. Check the Kida Syntax Reference for available filters.

Syntax Errors

Kida is stricter than Jinja2. Check:

  • All blocks properly closed with{% end %}
  • Variables defined before use
  • Filter names match Kida syntax

Common Migration Gotchas

Macros Not Supported

Kida does not support Jinja2's{% macro %}syntax. Use{% def %}instead, which provides lexical scoping (functions can access outer variables).

Jinja2 (not supported in Kida):

{% macro hello(name) %}
  Hello {{ name }}
{% endmacro %}
{{ hello('World') }}

Kida (use{% def %}instead):

{% def hello(name) %}
  Hello {{ name }}
{% enddef %}
{{ hello('World') }}

Key Difference:{% def %}functions have access to outer template variables (likesiteandconfig), while Jinja2 macros are isolated. This means you don't need to pass common variables as parameters.

Note

Need full Jinja2 compatibility? If your templates rely heavily on{% macro %}, you can use the Jinja2 engine by settingtemplate_engine: jinja2in yourbengal.yamlconfig.

Include with Variables

Jinja2 allows passing variables directly in the include statement:

Jinja2 (not supported in Kida):

{% include 'partial.html' with param=value %}

Kida (set variables before include):

{% let param = value %}
{% include 'partial.html' %}

Dict Key Access (Method Name Conflicts)

Python dict methods (items,keys,values,get) conflict with key access. Using dotted notation returns the method, not the key value.

Problem:

{# This returns the items() method, not the 'items' key! #}
{{ schema.items }}

Solutions:

{# Solution 1: Bracket notation #}
{{ schema['items'] }}

{# Solution 2: get() filter (cleaner syntax) #}
{{ schema | get('items') }}
{{ schema | get('items', default_value) }}

Slice Filter Behavior

Kida'sslicefilter groups items (like Jinja2's slice), it doesn't do string slicing.

{# This groups items into 3 slices, not string slicing #}
{{ items | slice(3) }}

{# For string/list slicing, use Python slice syntax #}
{{ text[:5] }}
{{ items[1:4] }}

Undefined Variables with Nil-Coalescing

In strict mode, use??to handle undefined variables safely:

{# May error if 'schemas' is undefined in strict mode #}
{% let schema = schemas[name] if schemas else none %}

{# Safe - ?? handles undefined #}
{% let schemas_dict = schemas ?? {} %}
{% let schema = schemas_dict | get(name) %}

Complete Example

Before (Jinja2):

{% extends "baseof.html" %}

{% set site_title = site.config.title %}
{% set recent_posts = site.pages | selectattr('type', 'eq', 'blog') | selectattr('draft', 'eq', false) | sort(attribute='date', reverse=true) | slice(5) %}

{% block content %}
  {% if page.type == "blog" %}
    <article class="blog-post">
      <h1>{{ page.title }}</h1>
      {{ page.content | safe }}
    </article>
  {% elif page.type == "doc" %}
    <article class="doc">
      <h1>{{ page.title }}</h1>
      {{ page.content | safe }}
    </article>
  {% else %}
    <article>
      <h1>{{ page.title }}</h1>
      {{ page.content | safe }}
    </article>
  {% endif %}
{% endblock %}

After (Kida):

{% extends "baseof.html" %}

{% let site_title = site.config.title %}
{% let recent_posts = site.pages
  |> selectattr('type', 'eq', 'blog')
  |> selectattr('draft', 'eq', false)
  |> sort(attribute='date', reverse=true)
  |> take(5) %}

{% block content %}
  {% match page.type %}
    {% case "blog" %}
      <article class="blog-post">
        <h1>{{ page.title }}</h1>
        {{ page.content | safe }}
      </article>
    {% case "doc" %}
      <article class="doc">
        <h1>{{ page.title }}</h1>
        {{ page.content | safe }}
      </article>
    {% case _ %}
      <article>
        <h1>{{ page.title }}</h1>
        {{ page.content | safe }}
      </article>
  {% end %}
{% endblock %}

Next Steps

Seealso