Templates¶
Keywords: template engine, custom filters, template inheritance, layouts, extends, include, block, macros, variables, conditional statements, loops, jinja templates
Asok features a powerful, built-in template engine with an elegant, intuitive syntax. Designed for performance and security, it requires no external dependencies.
Template Files¶
Asok supports two file extensions for templates:
.html: Support for standard templates..asok: Highly recommended for templates using reactive directives. This extension triggers advanced syntax highlighting and code intelligence in the Asok VS Code Extension.
The framework treats both extensions identically; you can use whichever you prefer for your pages, components, and partials.
Variables¶
{{ name }}
{{ user.email }}
{{ request.path }}
Works with both objects and dicts — user.email resolves user.email (attribute) or user['email'] (dict key).
Auto-escape¶
All {{ expressions }} are HTML-escaped automatically. To opt out (for trusted HTML), use the safe filter or wrap the value in SafeString from Python:
{{ user_input }} → "<script>..."
{{ trusted_html | safe }} → "<strong>...</strong>"
from asok.templates import SafeString
return request.html('page.html', html=SafeString('<b>bold</b>'))
The escape (alias e) filter forces escaping if needed.
Security Note: String literals in template expressions are protected from keyword interference. For example, {{ t('2FA is Enabled') }} correctly treats the entire string as a literal, even though it contains the is keyword. The template compiler only processes is tests (like user is defined) outside of quoted strings, preventing accidental keyword matching within your text content.
Filters¶
{{ name | upper }} → "HELLO"
{{ name | lower }} → "hello"
{{ name | capitalize }} → "Hello"
{{ name | title }} → "Hello World"
{{ text | truncate(50) }} → "Long text..."
{{ text | replace('old', 'new') }} → replaced string
{{ items | join(', ') }} → "a, b, c"
{{ value | default('N/A') }} → "N/A" if empty
{{ html | striptags }} → text without HTML tags
{{ items | length }} → 3
{{ date | date('%d/%m/%Y') }} → "04/04/2026"
{{ count | pluralize('item', 'items') }} → "items"
{{ value | abs }} → absolute value
{{ data | tojson }} → JSON string
{{ items | first }} → first element
{{ items | last }} → last element
{{ html | safe }} → trusted, not escaped
{{ value | escape }} → forced escape (alias: e)
Humanize Filters¶
Asok includes a set of human-friendly formatting filters for common data types:
Filter |
Example |
Description |
|---|---|---|
|
`{{ user.created_at |
time_ago }}` |
|
`{{ file.size |
filesize }}` |
|
`{{ 1200 |
intcomma }}` |
|
`{{ seconds |
duration }}` |
Base64 Image Filter (v0.1.4+)¶
The decode_base64 filter converts base64-encoded images (like signatures) into <img> tags:
<!-- Simple usage -->
{{ user.signature | decode_base64 }}
<!-- With Tailwind CSS -->
{{ user.signature | decode_base64(class_="w-64 h-32 border border-gray-300 rounded") }}
<!-- With custom styling -->
{{ user.signature | decode_base64(style="max-width:300px;border:1px solid #ccc") }}
<!-- Multiple attributes -->
{{ user.avatar | decode_base64(class_="profile-img", width="200", height="200") }}
This is particularly useful for displaying signatures from signature pads or any base64-encoded images stored in the database.
Note: Use class_ with an underscore to avoid conflicts with Python’s class keyword.
Chain filters:
{{ name | lower | truncate(20) }}
Filter blocks¶
Apply filters to entire blocks of template content:
{% filter upper %}
This entire block will be uppercased.
{{ user.name }} will also be uppercased.
{% endfilter %}
{% filter truncate(50) | safe %}
<p>Long HTML content that will be truncated...</p>
{% endfilter %}
You can chain multiple filters in a filter block just like inline filters.
Autoescape control¶
By default, all template output is automatically HTML-escaped. You can control this behavior for entire blocks:
{# Disable auto-escape for trusted content #}
{% autoescape false %}
{{ trusted_html_content }}
<p>Raw HTML here: {{ user.bio }}</p>
{% endautoescape %}
{# Re-enable auto-escape (though it's on by default) #}
{% autoescape true %}
{{ user_input }} {# Will be escaped #}
{% endautoescape %}
Security Warning: Only use autoescape false for content you completely trust. Untrusted user input should always be escaped to prevent XSS attacks. Prefer using the safe filter for individual variables instead of disabling autoescape for entire blocks.
Conditions¶
{% if user.is_admin %}
<span>Admin</span>
{% elif user.is_authenticated %}
<span>{{ user.name }}</span>
{% else %}
<a href="/login">Login</a>
{% endif %}
Template tests — is operator¶
Test variables with the is operator for common conditions:
{% if user is defined %}
Welcome, {{ user.name }}!
{% endif %}
{% if items is not defined or items is none %}
<p>No items available</p>
{% endif %}
{% if count is even %}
<div class="even-row">{{ count }}</div>
{% endif %}
{% if status is true %}
<span class="active">Active</span>
{% endif %}
Available tests:
Test |
Description |
Example |
|---|---|---|
|
Value exists and is not empty string |
|
|
Value is None or empty string |
|
|
Value is exactly None |
|
|
Value is exactly True |
|
|
Value is exactly False |
|
|
Number is even |
|
|
Number is odd |
|
|
Value is a string |
|
|
Value is int or float |
|
|
Value is True or False |
|
|
Value is an integer |
|
|
Value is a float |
|
|
Value is list or tuple |
|
|
Value is a dictionary |
|
|
Value can be iterated |
|
|
String is all lowercase |
|
|
String is all uppercase |
|
Negate any test with is not:
{% if user is not defined %}
<a href="/login">Please log in</a>
{% endif %}
Loops¶
{% for post in posts %}
<h2>{{ post.title }}</h2>
<p>{{ post.body | truncate(100) }}</p>
{% endfor %}
Loop state¶
Inside a loop, the loop variable provides state:
Property |
Description |
|---|---|
|
1-based index (1, 2, 3…) |
|
0-based index (0, 1, 2…) |
|
True if first iteration |
|
True if last iteration |
|
Total number of items |
Example:
<ul>
{% for item in items %}
<li class="{{ 'first' if loop.first else '' }}">
{{ loop.index }}: {{ item }}
</li>
{% endfor %}
</ul>
Loop control¶
You can use {% break %} to exit a loop early and {% continue %} to skip to the next iteration:
{% for user in users %}
{% if user.is_banned %}{% continue %}{% endif %}
<li>{{ user.name }}</li>
{% if loop.index == 10 %}{% break %}{% endif %}
{% endfor %}
Variables¶
Inline assignment¶
{% set greeting = 'Hello' %}
{% set total = items | length %}
<h1>{{ greeting }}, {{ name }}!</h1>
Block assignment — capture template content¶
Capture rendered template content into a variable using block set:
{% set message %}
<p>Welcome <strong>{{ user.name }}</strong>!</p>
<p>You have {{ notifications | length }} new notifications.</p>
{% endset %}
<!-- Later in template -->
<div class="alert">{{ message }}</div>
<!-- Or pass to a macro -->
{{ card(message, title="User Info") }}
This is useful for:
Building complex HTML strings
Capturing repeated content blocks
Passing template fragments to macros or functions
Side effects — do¶
Execute an expression without outputting anything to the template. Useful for calling methods that modify state or for complex assignments:
{% do request.session.set('viewed', True) %}
{% do my_list.append(item) %}
Template inheritance¶
Base layout¶
<!-- src/partials/html/base.html -->
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
{% block styles %}{% endblock %}
</head>
<body>
{% include "html/navbar.html" %}
<main>{% block main %}{% endblock %}</main>
{% block scripts %}{% endblock %}
</body>
</html>
Child page¶
<!-- src/pages/about/page.html -->
{% extends "html/base.html" %}
{% block title %}About{% endblock %}
{% block main %}
<h1>About us</h1>
{% endblock %}
Includes¶
{% include "html/navbar.html" %}
{% include "html/footer.html" %}
Paths are relative to src/partials/.
Macros¶
Define reusable components in a separate file:
<!-- src/partials/html/macros.html -->
{% macro button(text, cls="primary") %}
<button class="{{ cls }}">{{ text }}</button>
{% endmacro %}
{% macro card(title, body) %}
<div class="card">
<h3>{{ title }}</h3>
<p>{{ body }}</p>
</div>
{% endmacro %}
Import and use in any template:
{% from "html/macros.html" import button, card %}
{{ button("Click me") }}
{{ button("Delete", "danger") }}
{{ card("Hello", "World") }}
Advanced Macros — call¶
The {% call %} block allows you to pass a block of template code to a macro, which can then be rendered using the special caller() function. This is similar to component slots but for macros.
Definition:
{% macro card_wrap(title) %}
<div class="card">
<div class="header">{{ title }}</div>
<div class="body">
{{ caller() | safe }}
</div>
</div>
{% endmacro %}
Usage:
{% from "macros.html" import card_wrap %}
{% call card_wrap("Advanced Card") %}
<p>This content is passed to <strong>caller()</strong>.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
{% endcall %}
Output:
<button class="primary">Click me</button>
<button class="danger">Delete</button>
<div class="card">
<h3>Hello</h3>
<p>World</p>
</div>
Macro bodies support the full template syntax (variables, filters, conditions, loops). Paths are relative to src/partials/.
Scoped variables — with¶
{% with total = items | length %}
<p>{{ total }} items</p>
{% endwith %}
Raw blocks¶
Disable template parsing inside a block (useful when outputting {{ }} literally for JS frameworks):
{% raw %}
<p>{{ this_is_not_parsed }}</p>
{% endraw %}
Method calls¶
{{ request.csrf_input() }}
{{ user.to_dict() }}
Passing variables from Python¶
def render(request: Request):
return request.html('page.html',
name='World',
posts=Post.all(),
count=Post.count()
)
All kwargs become template variables.
Built-in context¶
Every template has these available automatically:
Variable |
Description |
|---|---|
|
The current Request object |
|
Translation function |
|
Static file URL helper |
|
Flash messages list |
Partial rendering (blocks)¶
For partial updates or single-page application (SPA) scenarios, request.block() renders only the content of a specific {% block %} instead of the full page.
def render(request: Request):
if request.method == "POST":
# Return only the "form" block after submission
return request.block("page.html", "form", success=True)
return request.html("page.html")
Template (page.html):
{% extends "html/base.html" %}
{% block main %}
<h1>Contact</h1>
{% block form %}
{% if success %}
<p>Message sent!</p>
{% else %}
<form method="POST" hx-post="/contact" hx-target="#form" hx-swap="innerHTML">
<input name="email" />
<button type="submit">Send</button>
</form>
{% endif %}
{% endblock %}
{% endblock %}
request.block("page.html", "form", success=True) returns only the inner content of {% block form %}, without the parent layout (<html>, <head>, etc.).
If the block name doesn’t exist, a TemplateError is raised.
Template Security & Errors¶
Asok’s template engine is sandboxed for security.
SecurityError: Raised if a template tries to access “private” attributes (starting with_) or sensitive Python internals.TemplateError: Raised for syntax errors during compilation or runtime errors during rendering.
Precise Error Localization & Line Numbers¶
To simplify development and debugging, Asok dynamically tracks line numbers through the template compilation process. When an error occurs during template compilation or rendering, Asok raises a TemplateError that includes:
The template filename or path (if rendered via
Request.html()or withtemplate_nameprovided).The exact line number in the template file where the error occurred.
A descriptive error message.
For example, referencing a division by zero in page.html yields:
Template 'page.html' Syntax Error: division by zero on line 14
Unclosed Delimiters Validation¶
Asok validates that all template expressions ({{ ... }}) and statement blocks ({% ... %}) are properly opened and closed. If a delimiter is left unclosed (e.g., {{ name), the compiler detects the syntax error at compile time and raises a TemplateError pointing to the exact line number of the unclosed delimiter:
Template 'home.html' Syntax Error: Unclosed template expression '{{' on line 5
Rich HTML Debug Page¶
When DEBUG = True, any uncaught TemplateError is intercepted by Asok’s WSGI layer to serve a beautifully styled HTML debug page. This page contains:
Error Summary: The template filename, line number, and error message.
Python Traceback: The full traceback highlighting the exact call stack.
Compiled Python Code: The raw Python generator code generated by the template engine, annotated with line numbers.
Context Variables: A table showing all request parameters and variables active during the request.
In production (DEBUG = False), the technical details are hidden, a standard error page is returned, and the full exception is logged securely on the server.
Clean Blocks for SEO¶
By default, Asok injects HTML comment markers (<!-- block:name:start -->) around template blocks to support SPA partial updates. However, for sensitive blocks that end up in browser tab titles or meta tag attributes, these markers are automatically excluded:
titleblock: Content is rendered cleanly so it doesn’t appear in the browser tab title as comments.descriptionblock: Content is rendered cleanly to prevent breaking meta tagcontentattributes.
The SPA engine is intelligent enough to target these blocks directly (using the <title> and <meta> tags) even without the comment markers.
You can also use render_block_string() directly:
from asok.templates import render_block_string
html = render_block_string(template_content, "form", {"success": True}, root_dir="src/partials")
Live updates (data-* attributes)¶
Asok ships a small inline JS runtime (no external dependency) that lets you build reactive pages with HTML attributes — search-as-you-type, infinite scroll, inline delete, polling, SSE, etc. — without writing custom JavaScript.
The runtime is auto-injected into every full HTML response. You don’t import anything.
data-block — swap server fragments into the DOM¶
The simplest case: a form (or link) submits via fetch and the response replaces the contents of a target element.
<form method="post" data-block="result">
{{ request.csrf_input() }}
<input name="email">
<button>Subscribe</button>
</form>
<div id="result">
{% block result %}{% endblock %}
</div>
Server side:
from asok import Request, Form
def render(request: Request):
form = Form({'email': Form.email('Email', 'required|email')}, request)
if form.validate():
Subscriber.create(**form.data)
request.flash('success', 'Subscribed!')
return request.html('page.html', form=form)
The framework detects the partial request via the X-Block header and you can serve a fragment (request.block("page.html", "result")) or the full page — both work. CSRF tokens are rotated automatically and re-injected into the new DOM.
The data-block attribute targets a template block by its name.
data-block="main"— Targets a template block namedmain.
<div id="main">
{% block main %}
Content will be replaced here
{% endblock %}
</div>
When using data-block, the server should return the specific fragment using request.block("template.html", "main").
Examples:
<!-- Target template block "content" -->
<form data-block="content" data-url="/update">...</form>
<!-- Server: return request.block("page.html", "content") -->
<!-- Target template block "result" -->
<form data-block="result" data-url="/search">...</form>
<!-- Server: return request.block("page.html", "result") -->
data-trigger — fire on different events¶
By default forms fire on submit and links on click. data-trigger overrides this:
<!-- Search-as-you-type with debounce -->
<input data-block="results"
data-url="/search"
data-trigger="input delay:300ms"
name="q" placeholder="Search…">
<!-- Filter that reloads on change -->
<select data-block="results"
data-url="/list"
data-trigger="change"
name="category">...</select>
<!-- Auto-save on blur -->
<textarea data-block="status"
data-url="/draft"
data-method="POST"
data-trigger="blur"
name="body"></textarea>
<!-- Lazy load (fire once on page load) -->
<div id="notifs" data-block="notifs" data-url="/notifications" data-trigger="load">
{% block notifs %}{% endblock %}
</div>
<!-- Polling every 5 seconds -->
<div id="stats" data-block="stats" data-url="/stats" data-trigger="every 5s">
{% block stats %}{% endblock %}
</div>
Accepted formats: any DOM event name (submit, click, change, input, blur, focus, …), plus the special triggers load (fire once at page load) and every Ns / every Nms (polling). Add delay:Nms to debounce.
data-swap — swap strategy¶
The default is innerHTML. Other modes:
<!-- Delete a row in place after a server action -->
<button data-block="row-42"
data-url="/rows/42"
data-method="POST"
data-swap="delete">×</button>
<!-- Infinite scroll: append the response instead of replacing -->
<button data-block="rows"
data-url="/rows?page=2"
data-swap="beforeend">Load more</button>
<!-- New chat message at the top -->
<form data-block="msgs" data-swap="afterbegin">...</form>
<!-- Replace the element itself (for inline row editing) -->
<form data-block="row-42" data-swap="outerHTML">...</form>
Mode |
Behavior |
|---|---|
|
Replace target’s content |
|
Replace target itself |
|
Insert HTML before target |
|
Insert HTML at start of target |
|
Insert HTML at end of target (append) |
|
Insert HTML after target |
|
Remove target from the DOM |
|
Do nothing (useful for fire-and-forget) |
data-indicator — loading state¶
<form data-block="list" data-indicator="#spinner">...</form>
<div id="spinner" hidden>Loading…</div>
During the fetch, the class is-loading is added to the indicator element; it’s removed on success or error. Style .is-loading however you want:
.is-loading { opacity: .5; pointer-events: none; }
data-indicator without a value uses the trigger element itself as the indicator.
data-disable — anti double-submit¶
<form data-block="result" data-disable>
<button type="submit">Save</button>
</form>
Disables every <button> and <input type="submit"> inside the form during the request. On a non-form element (e.g. a button alone), it disables the element itself.
data-include — combine inputs from elsewhere¶
<select id="cat" name="category">
<option value="">All</option>
<option value="tech">Tech</option>
</select>
<input data-block="results"
data-url="/search"
data-trigger="input delay:300ms"
data-include="#cat"
name="q">
The fetch will combine q=...&category=.... The selector can match multiple elements (e.g. data-include=".filter").
data-confirm — native confirmation¶
<a data-block="row-42"
data-url="/users/42/delete"
data-method="POST"
data-swap="delete"
data-confirm="Delete this user?">×</a>
Shows a confirm() dialog before firing. Cancel = no fetch, no swap, no indicator. Works on any data-block element.
data-sse — Server-Sent Events stream¶
<!-- Live notifications -->
<div id="notifs" data-sse="/events/notifications">
{% block notifs %}{% endblock %}
</div>
<!-- Append-only log -->
<div id="log" data-sse="/events/log" data-block="log" data-swap="beforeend">
{% block log %}{% endblock %}
</div>
Each SSE message is treated as HTML and swapped according to data-swap (default innerHTML). The target is data-block or, if absent, the element itself by its id.
Server side, return any handler that streams text/event-stream:
import time
def render(request):
def gen():
while True:
html = request.render_string("partials/notifs.html", items=Notif.latest())
yield f"data: {html}\n\n"
time.sleep(5)
return request.stream(gen(), content_type="text/event-stream")
Out-of-band swaps (multi-target updates)¶
Sometimes a single server response needs to update several places at once. Wrap each fragment in a <template data-block="sel"> element. When the JS sees these in the response, it swaps each one independently:
<!-- Server response from a delete handler -->
<template data-block="row-42" data-swap="delete"></template>
<template data-block="flash" data-swap="afterbegin">
<div class="flash success">User deleted</div>
</template>
<template data-block="user-count">23</template>
Three updates from one round-trip: row removed, flash shown at top, counter refreshed.
Reference¶
Attribute |
Role |
|---|---|
|
Template block name to render and replace |
|
Override the target (defaults to first selector of |
|
Explicit URL (required on non-form/non-link elements) |
`data-method=”GET |
POST”` |
|
Trigger event + optional debounce |
|
Swap strategy (see table above) |
|
Update history + handle Back button |
|
Add |
|
Disable form/button during fetch |
|
Include other inputs in the request |
|
Show confirm() before firing |
|
Open SSE stream and swap on each message |
How data is collected¶
Element |
Body / query |
|---|---|
|
Its FormData (POST body or GET querystring) |
|
GET on its |
|
The form’s FormData (so search-as-you-type sends every filter) |
|
Just |
Any other element with |
No body — pure fetch |
The runtime auto-includes the CSRF token in the X-CSRF-Token header and rotates it transparently after every request.
For complex client-side interactivity (state, loops, conditional rendering), Asok includes a native directive system that runs entirely in the browser.
Unlike data-block which relies on server round-trips, asok-* directives handle reactivity locally using a browser-side state.
<div asok-state="{ count: 0 }">
<button asok-on:click="count++">Increment</button>
<p>Count: {{ count }}</p>
</div>
For the full reference of all available directives, see the Asok Directives documentation.
WebSocket helper¶
In addition to SSE, asok can run a WebSocket server in a daemon thread alongside the HTTP server (see WebSockets). A small JS helper is auto-injected for the client side:
<script>
var sock = asokWS('/chat');
sock.onmessage = function(e) { console.log(e.data); };
sock.send('hello');
</script>
asokWS(path) returns a WebSocket instance and resolves the URL automatically: ws://localhost:8001/chat in dev, wss://yoursite.com/ws/chat in production (assuming nginx proxies /ws/ to your WebSocket port). The port can be configured via app.config['WS_PORT'] (defaults to 8001).
Interactive Components & Islands¶
You can render stateful, WebSocket-powered interactive components in your templates. Asok supports Islands Architecture via the client argument to control when components hydrate on the client side:
Inline Helper:
{{ component('Counter', count=10, client='visible') }}
Block Helper (useful when passing slots/nested HTML):
{% component "Counter", count=10, client='idle' %}
<p>Loading interactive counter...</p>
{% endcomponent %}
Hydration Strategies (client)¶
client="load": Hydrates immediately on page load.client="visible": Hydrates only when the component enters the browser viewport.client="idle": Hydrates when the browser main thread is idle.
If no client argument is specified, the component defaults to immediate hydration.
Component Slots (Transclusion)¶
Asok supports passing arbitrary HTML blocks into components using the {% component %}...{% endcomponent %} block tag. This is useful for creating layout components (like cards, modals, or page layouts) where the content isn’t known in advance.
Inside the component, you access the passed content via the {{ slot }} variable.
Example: A Generic Card Component¶
src/components/Card.html:
<div class="card">
<div class="card-header">
<h3>{{ title }}</h3>
</div>
<div class="card-body">
{{ slot }} <!-- The content from the parent goes here -->
</div>
</div>
src/pages/index.html:
{% component "Card", title="Welcome" %}
<p>This is the <strong>body</strong> of the card.</p>
<button data-block="more" data-url="/more">Load more</button>
{% endcomponent %}
Content inside the component block is automatically marked as safe HTML.
Nested components are supported: you can have a component inside another component’s slot.
Components within slots retain their full functionality (reactive state, event handlers, etc.).
Performance¶
Templates are compiled to Python functions and cached automatically. The first render of a template triggers:
Inheritance/includes resolution (file reads)
Regex parsing into tokens
Python code generation
exec()to create a callable function
On subsequent renders of the same template, the compiled function is reused directly — no parsing, no exec(). This makes template rendering as fast as calling a regular Python function.
Comments¶
{# This won't appear in the output #}