Skip to content

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>
@endif

Null-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>
@endif

Use one Dart-shaped loop form. The value after in must be an Iterable:

@for (final order in orders)
<li>{{ order.title }}</li>
@endfor

Maps are iterated explicitly through .entries:

@for (final entry in settings.entries)
<dt>{{ entry.key }}</dt>
<dd>{{ entry.value }}</dd>
@endfor

Inside 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.