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¶
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¶
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¶
Renders its body at most once per request, however many times the surrounding template (typically a partial or component) is included.
Helpers¶
@class builds a space-joined class string from only the truthy entries
— handy for conditional Tailwind classes (see Frontend & CSS).
Auth and CSRF¶
@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})
@csrf renders the hidden field a real HTML <form> needs:
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}) }}}}",
)
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 @¶
@@ renders as a single literal @ — for text that would otherwise look
like a directive (an email address, @ in prose).