Views and Templates
Views and Templates
Maat uses Khnum to render server-side HTML. Controllers run Dart, perform I/O,
and prepare view data; .khnum.html files turn that data into HTML.
Route.get('/posts', (Request request) async { final posts = await Post.query().get(); return view('posts.index', {'posts': posts});});posts.index resolves to resources/views/posts/index.khnum.html.
Khnum is inspired by the convenience of server templates, but its expression
rules follow Dart’s type and null-safety model. A template does not execute
Dart source. Application logic, asynchronous work, and arbitrary method calls
belong in .dart files.
Configure Maat
Add khnum_maat to pubspec.yaml and ViewServiceProvider.new to the
providers in bootstrap/app.dart.
return Application.configure(basePath: Directory.current.path) .withProviders([ ViewServiceProvider.new, ]) .create();The optional config/view.dart values are:
final Map<String, dynamic> view = { 'paths': 'resources/views', 'components': 'resources/views/components', 'cache': !envBool('APP_DEBUG'),};view(name, data) returns an HTML Response. Use renderView(name, data)
when you need the string or want to choose another status:
return Response.html( await renderView('posts.form', {'titleError': error}), status: 422,);View.exists(name) checks for a template. View.share(name, value) exposes a
value to every template; the provider already shares the configured app name
as appName.
Use Khnum without Maat
Khnum has no dependency on Maat or an HTTP framework:
final khnum = Khnum( viewsPath: 'resources/views', environment: TemplateEnvironment.production,);
final html = await khnum.render('greeting', {'name': 'Ann'});With Shelf, return that string as HTML:
return Response.ok( html, headers: {'content-type': 'text/html; charset=utf-8'},);Pass data explicitly
Render data is a Map<String, Object?>:
return view('dashboard', { 'title': 'Dashboard', 'user': user, 'orders': orders, 'titleError': errors['title']?.first,});Raw maps use brackets for keys, as they do in Dart. Dot access on maps is
reserved for real collection properties such as length, isEmpty,
isNotEmpty, keys, values, and entries.
{{ settings["theme"] }}{{ settings.length }}{{ orders.first }}Objects with toJson() expose those declared fields with dot access:
{{ user.name }}{{ post.author.email }}Register a resolver for an object that has no toJson() representation:
View.engine.resolve<DateTime>((date, property) => switch (property) { 'year' => date.year, 'iso' => date.toIso8601String(), _ => null,});Missing variables, properties, and map keys always throw an error with the template name and line. Pass optional values explicitly instead of letting a misspelling silently render empty output.
Display and escape values
{{ expression }} HTML-escapes its result. A null value renders as an empty
string.
<h1>{{ title }}</h1>Use {!! expression !!} only for trusted HTML. HtmlString also marks a value
as trusted. Neither form sanitizes user input.
{!! renderedArticle !!}Inside <script>, the built-in json() function safely encodes data and
escapes characters that could close the script block:
<script> window.page = {{ json({"user": user, "items": items}) }};</script>Khnum comments do not appear in the output:
{{-- Explain template intent here. --}}Expression rules
Khnum supports presentation expressions: literals, list and map literals,
property and index access, arithmetic, comparisons, &&, ||, !, ??,
?:, and registered function calls.
Conditions and boolean operators require bool. Values are never treated as
truthy or falsey:
@if (items.isNotEmpty) <p>{{ items.length }} results</p>@endif
@if (user != null) <p>Welcome, {{ user.name }}.</p>@endifNull-aware access and assertion are explicit:
{{ user?.name ?? "Guest" }}{{ values?[0] }}{{ requiredUser!.email }}?? only handles a present value that is null; it does not hide a missing
name or map key. Arithmetic requires numbers. + joins strings only when both
operands are strings, so format mixed values in Dart or with a registered
function.
Conditions and loops
Use @if, @elseif, @else, and @endif for boolean branches:
@if (order.isPaid) <span>Paid</span>@elseif (order.isOverdue) <span>Overdue</span>@else <span>Pending</span>@endifUse one Dart-shaped loop form. The value after in must be an Iterable:
@for (final order in orders) <li>{{ order.title }}</li>@endforMaps are iterated explicitly through .entries:
@for (final entry in settings.entries) <dt>{{ entry.key }}</dt> <dd>{{ entry.value }}</dd>@endforInside a loop, loop exposes index, iteration, remaining, count,
first, last, even, odd, depth, and parent.
Layouts and partials
Define named placeholders in a layout:
<!doctype html><html><head><title>@yield("title", appName)</title></head><body>@yield("content")</body></html>Fill them from a page:
@extends("layouts.app")
@section("title", title)
@section("content") <h1>{{ title }}</h1>@endsection@parent includes the parent layout’s section content. @show closes and
renders a section immediately; @stop closes it without rendering.
Includes see the parent scope and may receive overrides:
@include("partials.card", {"title": "Recent", "items": recentItems})View names are validated before filesystem access, including dynamic include names. They cannot leave the configured views directory.
Anonymous components
A file at resources/views/components/alert.khnum.html is rendered with
<x-alert>:
@props({"type": "info"})<div {{ attributes.merge({"class": "alert alert-" + type}) }}> {{ slot }}</div><x-alert type="success">Saved.</x-alert><x-user-card :user="user" />Literal attributes are strings. A leading : evaluates an expression.
Undeclared attributes stay in the escaped attributes bag. Named slots use
<x-slot name="header"> or <x-slot:header>.
Components have an isolated variable scope. Pass every required value as a prop; slot content is evaluated in the caller’s scope.
Call selected Dart functions
Register a Dart function when templates need shared formatting behavior:
View.engine.function('money', (arguments) { final amount = arguments.first as num; return '\$${amount.toStringAsFixed(2)}';});{{ money(order.total) }}Function output still passes through normal {{ }} escaping. This explicit
registry is the AOT-safe boundary between templates and Dart; templates cannot
dispatch arbitrary methods.
ViewServiceProvider registers asset() for public-file URLs:
<link rel="stylesheet" href="{{ asset('css/app.css') }}">Custom directives are for markup-producing extensions:
View.engine.directive('datetime', (context, arguments) { final date = arguments.first as DateTime; return escapeHtml(date.toIso8601String());});Directive output is unescaped. Call escapeHtml for every user-controlled
value. Register directives before rendering templates that use them.
Loading, caching, and errors
Development mode reloads a file when its modification time changes. Production mode parses each template once and keeps the AST in memory.
Khnum.inMemory({...}) embeds templates in a map. Implement TemplateLoader
when templates come from another trusted source.
Syntax and render exceptions carry the template name and line. Layouts,
includes, and components share maxDepth (default 64) so accidental
recursion throws instead of overflowing.
Templates must remain trusted application files. They cannot execute arbitrary Dart, but they can read all data passed to them and run expensive loops.
Observing renders
Tooling that wants to know which templates a request rendered can set
Khnum.observer, a static void Function(String view, Map<String, Object?> data, Duration elapsed) called after each render with the view name, the
data it was given, and how long it took. It is null by default, so a
render nobody is watching costs one extra null check, and it is static
rather than per-instance — one engine per application, matching
Log.listener — so nothing has to thread an observer through every
construction site for what is normally a development-only concern.
An observer that throws does not break the render; the exception is caught
and discarded after the HTML is already produced. Setting Khnum.observer
replaces whatever was there, so a consumer should chain onto the previous
value instead of overwriting it — call the existing observer first, inside
its own try/catch, then run its own logic. This is unrelated to template
rendering itself and is only useful when building observability tooling.
Migrating to Khnum 0.2.0
| Old form | Dart-native form |
|---|---|
@foreach(items as item) |
@for (final item in items) |
@endforeach |
@endfor |
@unless(value) |
@if (!value) |
@if(items) |
@if (items.isNotEmpty) |
@if(user) |
@if (user != null) |
nullable user.name |
user?.name |
raw map map.name |
map["name"] |
| missing value treated as null | pass an explicit nullable value |
helper() |
function() |
The Dart helper() alias is deprecated during 0.2.x and will be removed in
0.3.0. Removed template syntax is rejected with a source-located migration
message.