Skip to content

Blade

Zeython Blade is a Laravel Blade-styled template syntax — @if, @foreach, @extends/@section, components — compiled straight to Jinja2. It's not a separate rendering engine: a .blade.html file is source-to-source compiled into ordinary Jinja2 template source, then handed to the exact same Jinja2 that renders a plain .html file. Every directive is sugar over a native Jinja2 construct, so Jinja2's own autoescaping, template caching, and nesting rules apply unchanged, and a .blade.html template can @extends/@include a plain .html one (or vice versa) freely.

Directives borrow Blade's names and control-flow shape; expressions inside them are plain Python/Jinja2, not PHP. @if(user.is_admin and len(posts) > 0), not @if($user->isAdmin() && count($posts) > 0). If you know Blade, the directives will look immediately familiar; the expressions inside them look like the Python you'd already write in a controller.

A fresh project ships this

resources/views/welcome.blade.html

render() from a controller doesn't change at all — it just looks up a .blade.html file instead of .html:

from zeython.views import render

async def show(request):
    return render(request, "posts/show.blade.html", {"post": post})

A plain .html template still works exactly as before; Blade is additive, not a replacement. See Views.

Output

{{ post.title }}         {# escaped -- the default, same as plain Jinja2 #}
{!! trusted_html !!}     {# raw, unescaped #}
{{-- a comment, stripped entirely from the output --}}

Control flow

@if(user.is_admin)
  Admin
@elseif(user.is_verified)
  Verified
@else
  Guest
@endif

@unless(post.published)
  Draft
@endunless

@isset(user.email)
  {{ user.email }}
@endisset

@foreach(posts as post)
  {{ post.title }}
@endforeach

@foreach(users as id => user)
  {{ id }}: {{ user.name }}
@endforeach

@forelse(posts as post)
  {{ post.title }}
@empty
  No posts yet.
@endforelse

@foreach(items as item)
  @continue(item.hidden)
  {{ item.name }}
  @break(item.is_last)
@endforeach

@forelse/@empty compiles to Jinja2's native {% for %}...{% else %}, which already means "the loop ran zero times" — there's no separate emptiness check to get wrong. Inside any loop, Jinja2's own loop variable is available as usual (loop.index, loop.first, loop.last, ...).

Not implemented: Blade's C-style @for (@for ($i = 0; $i < 10; $i++)) has no Python equivalent worth emulating — use @foreach(range(10) as i). Jinja2 has no while loop tag at all — a @while here would need one built from scratch with no built-in protection against an infinite loop; precompute the sequence in the controller instead.

Layouts

{# resources/views/layouts/app.blade.html #}
<html>
  <head><title>@yield('title', 'My App')</title></head>
  <body>@yield('content')</body>
</html>
{# resources/views/posts/show.blade.html #}
@extends('layouts.app')

@section('title')
  {{ post.title }}
@endsection

@section('content')
  <h1>{{ post.title }}</h1>
  <p>{{ post.body }}</p>
@endsection

layouts.app resolves against resources/views/ the way Laravel's dot-notation does: dots become /, and .blade.html is appended if the name doesn't already end in .html.

@show/@stop are accepted as aliases of @endsection for anyone coming from Blade muscle memory. One real difference from Laravel: Blade uses @show for a section that should render immediately at that position and be available to @yield elsewhere; Jinja2's block model doesn't have that two-step (a child template's @section/{% block %} content already is what a parent's matching @yield/{% block %} placeholder shows, nothing separate needs to happen), so @show and @endsection behave identically here.

@parent (Blade's way of keeping the parent layout's content in a section you're overriding) maps to Jinja2's {{ super() }}.

Includes

@include('partials.header')
@include('partials.alert', {'type': 'error', 'message': 'Something broke'})
@includeIf('partials.optional')

The second argument to @include is a plain Python dict literal, merged into the included template's context alongside everything already in scope. @includeIf silently renders nothing if the template doesn't exist — normal @include raises, same as a missing plain Jinja2 {% include %}.

Components

{# resources/views/components/alert.blade.html #}
<div class="alert alert-{{ type }}">
  <strong>{{ title }}</strong>
  {{ slot }}
</div>
@component('alert', {'type': 'error'})
  @slot('title')
    Careful!
  @endslot
  Something went wrong.
@endcomponent

Content outside any @slot block becomes the default slot, available in the component template as slot (matching Blade's $slot); each @slot('name') block becomes its own named variable. @component('alert', ...) resolves to resources/views/components/alert.blade.html by the same dot/slash convention as @include.

Not implemented: Blade's <x-alert type="error">...</x-alert> HTML-tag component syntax. It needs real HTML-attribute parsing (kebab-case props, self-closing tags, default vs. named slots inferred from child tags) that's a meaningfully bigger and riskier thing to get right than the directive form above, which covers the same capability. @component/ @slot is the supported way to use components for now.

@php

@php(total = subtotal + tax)

@php
  discount = subtotal * 0.1
  total = subtotal - discount
@endphp

Deliberately restricted to name = expression assignment lines, one or more, separated by ; or newlines — compiled to Jinja2 {% set %} tags. Real Blade's @php runs arbitrary PHP; Zeython Blade compiles to Jinja2 rather than executing template source directly, and there's no "run this arbitrary Python statement" tag to give it (nor should there be one — template source is often edited by people who shouldn't need, or be trusted with, general code execution). This restricted form covers the overwhelming majority of real @php usage. Anything more complex belongs in the controller, computed before render() is called.

@once

@once
  <script src="/js/chart.js"></script>
@endonce

Renders its body at most once per request, however many times the surrounding template (typically a partial or component) is included.

Helpers

@json(data)                              {# {{ data|tojson }} #}
@class({'active': is_active, 'text-red-600': has_error})

@class builds a space-joined class string from only the truthy entries — handy for conditional Tailwind classes (see Frontend & CSS).

Auth and CSRF

@auth
  Signed in as {{ user.name }}
@else
  @guest
    <a href="/login">Log in</a>
  @endguest
@endauth

@auth/@guest are sugar over a plain user context variable — pass it in yourself (render(request, "...", {"user": await current_user(request)})); they don't query the database mid-render. This is a deliberate scope boundary, the same reason @can/@cannot aren't implemented at all: authorization checks can be async, and there's no way to await one from inside a template that Jinja2 renders synchronously. Compute the boolean in the controller and branch on it with a plain @if:

can_edit = await authorize(request, "update-post", post)
return render(request, "posts/show.blade.html", {"post": post, "can_edit": can_edit})
@if(can_edit)
  <a href="/posts/{{ post.id }}/edit">Edit</a>
@endif

@csrf renders the hidden field a real HTML <form> needs:

<form method="POST" action="/posts">
  @csrf
  @method('PUT')
  ...
</form>

Zeython's CSRF protection is header-based by default (a fetch/XHR request sets X-CSRF-Token itself) — a plain HTML form can't set a custom header, so CsrfMiddleware also accepts the token as a _token form field, exactly what @csrf renders. @method('PUT') renders a hidden _method field; pair it with the opt-in MethodOverrideMiddleware (app.add_middleware(MethodOverrideMiddleware), in zeython.routing) so a plain <form> — which only ever submits GET or POST — can drive a PUT/PATCH/DELETE route.

Custom directives

# a service provider's register()
from zeython import Views

views = app.container.make(Views)
views.blade.directive(
    "money",
    lambda expr: f"{{{{ '%.2f'|format({expr}) }}}}",
)
@money(order.total)

A directive handler receives the raw text inside the parentheses (None for a bare directive with no parens) and returns Jinja2 source to splice in. Register custom directives at boot, before any request renders a template — Jinja2 caches compiled templates keyed by file mtime, so a directive registered after a .blade.html file has already been compiled won't retroactively change that cached copy.

Escaping a literal @

Contact us: support@@example.com

@@ renders as a single literal @ — for text that would otherwise look like a directive (an email address, @ in prose).