Views and Templates
Return a server-rendered page with the view() helper:
Route.get('/posts', (Request request) async { final posts = await Post.query().get(); return view('posts.index', {'posts': posts});});The dot in posts.index maps to
resources/views/posts/index.khnum.html. Add khnum_maat to pubspec.yaml
and ViewServiceProvider.new to the providers in bootstrap/app.dart before
using the helper.
Rendering Views from Maat
view(name, data) returns an HTML Response. renderView(name, data) returns
the rendered string when you need to choose another status:
return Response.html( await renderView('posts.form', {'errors': errors}), status: 422,);View.exists(name) checks for a template, and View.share(name, value) makes a
value available to every template. The provider already shares
config('app.name') as appName.
Configuration lives in config/view.dart: paths is relative to the
application base path and defaults to resources/views; cache parses a
template once when true and reloads edited templates when false. By default,
caching is disabled while app.debug is on.
Using Khnum Directly
Views separate your HTML from your Dart. khnum renders
.khnum.html files with familiar server-side template syntax: {{ }} for
escaped output, @if and @foreach for control flow, @extends and @section
for layouts, and <x-alert> for components. It is a standalone package with no
dependency on Maat or on any HTTP framework, so it works with Shelf, Dart Frog,
Serverpod, or a bare dart:io server.
{{-- resources/views/greeting.khnum.html --}}<html> <body> <h1>Hello, {{ name }}</h1> </body></html>final khnum = Khnum(viewsPath: 'resources/views');
final html = await khnum.render('greeting', {'name': 'Ann'});Not a PHP interpreter. Laravel’s Blade compiles templates to PHP and lets PHP run them. Dart has no
eval, anddart:mirrorsis unavailable in a compiled binary. Khnum therefore parses its familiar syntax and evaluates a small, safe expression language of its own. Expressions lists exactly what that language allows.
Templates live under the directory you pass as viewsPath. Nested directories are addressed with dots, so resources/views/admin/profile.khnum.html is the view admin.profile:
final khnum = Khnum(viewsPath: 'resources/views');
await khnum.render('admin.profile', {'user': user});render returns a Future<String>. There is also renderSync, which the async form wraps; use whichever fits the calling code. You may check for a view before rendering it:
if (khnum.exists('emails.welcome')) { ... }Returning a Khnum view from Shelf
import 'package:shelf/shelf.dart';
Response home(Request request) { final html = khnum.renderSync('home', {'title': 'Home'}); return Response.ok(html, headers: {'content-type': 'text/html; charset=utf-8'});}A complete server, with a layout, a partial, components and a helper, is in packages/khnum/example/shelf_example.dart:
cd packages/khnum && PORT=8080 dart run example/shelf_example.dartA complete application using views, models and migrations is in
examples/todo.
Passing Data to Views
Data is a Map<String, Object?>. Values may be anything; the template reads them with dot notation.
await khnum.render('dashboard', { 'title': 'Dashboard', 'user': user, 'items': items,});<h1>{{ title }}</h1><p>{{ user.name }} · {{ order.customer.email }} · {{ items[0].name }}</p>How user.name is resolved
| Value on the left of the dot | What .name does |
|---|---|
Map |
map['name']; a missing key follows the missing-variable policy |
List |
.length, .first, .last, .isEmpty, .isNotEmpty; use list[i] for elements |
String |
.length |
null |
null, silently |
an object with toJson() |
calls toJson() once per render and reads the map |
| any other object | a resolver registered with resolve<T>(); otherwise a render error |
Most model classes already have toJson(). For everything else, register a resolver once at startup:
khnum.resolve<DateTime>((date, key) => switch (key) { 'year' => date.year, 'iso' => date.toIso8601String(), _ => null,});Published {{ post.publishedAt.year }}Sharing data with all views
khnum.share('appName', 'Acme');Shared values are visible in every template, partial and component.
Displaying Data
Wrap a variable in curly braces to print it. Output is HTML-escaped: &, <, >, " and ' become entities, and null prints as an empty string.
Hello, {{ name }}.Any expression from the expression language works inside the braces:
{{ user.firstName + " " + user.lastName }}{{ items.length > 0 ? "In stock" : "Sold out" }}{{ nickname ?? "anonymous" }}Unescaped output
When you have trusted HTML, use double-bang braces. Nothing is escaped:
{!! article.renderedBody !!}Never pass user-supplied content through
{!! !!}. If a value must carry markup, sanitise it in Dart and wrap it inHtmlStringso{{ }}will print it verbatim.
{'body': HtmlString(sanitize(userHtml))}Khnum and JavaScript frameworks
Prefix the braces with @ and they are left untouched for Vue, Alpine or Handlebars:
<h1>@{{ message }}</h1>@@ prints a literal @. Any @word that is not a directive is plain text, so CSS @media rules and email addresses need no escaping.
Comments
{{-- This comment is not present in the rendered HTML --}}Expressions
Everything between {{ }}, inside @if(...) and after :attribute= is parsed by one expression grammar.
| Category | Syntax |
|---|---|
| Literals | 'a', "a", 42, 1.5, true, false, null, [1, 2], {"k": v, k2: v2} |
| Variables | name, user.name, items[0], map["key"] |
| Arithmetic | + - * / % (+ concatenates when either side is a string) |
| Comparison | == != < <= > >= (numbers with numbers, strings with strings) |
| Logic | && || ! (return booleans) |
| Null coalescing | a ?? b |
| Conditional | cond ? a : b |
| Helpers | upper(x), count(items), money(total) and anything you register |
| Grouping | ( ) |
Precedence, lowest to highest: ?:, ??, ||, &&, == !=, < <= > >=, + -, * / %, unary ! -, then ., [] and calls.
Truthiness is loose, like Khnum. null, false, 0, '' and empty lists or maps are false. Everything else is true, so @if(items) reads as “if there are any items”.
Methods are not callable. {{ user.name.toUpperCase() }} is an error; write {{ upper(user.name) }}. The only exception is the component attributes bag, which supports .merge(), .has() and .get().
Built-in helpers: upper, lower, count, json.
Directives
If Statements
@if(count(records) == 1) I have one record!@elseif(count(records) > 1) I have multiple records!@else I don't have any records!@endif@unless is the inverse of @if and accepts @else:
@unless(user.verified) Please verify your email.@endunlessLoops
<ul>@foreach(users as user) <li>{{ user.name }}</li>@endforeach</ul>Iterate a map with both key and value, or a list with its index:
@foreach(settings as key => value) {{ key }} = {{ value }}@endforeach@for(item in items) is the same loop with in instead of as. A null collection iterates zero times; anything that is not a list or map is an error.
The loop variable
Inside a loop, loop describes where you are:
| Property | Description |
|---|---|
loop.index |
0-based index |
loop.iteration |
1-based index |
loop.remaining |
items left after this one |
loop.count |
total items |
loop.first / loop.last |
booleans |
loop.even / loop.odd |
based on loop.index |
loop.depth |
nesting level, starting at 1 |
loop.parent |
the enclosing loop’s loop, or null |
@foreach(users as user) @if(loop.first) This is the first user. @endif <tr class="{{ loop.odd ? 'odd' : 'even' }}">...</tr>@endforeachWhitespace
A directive that sits alone on its line leaves no blank line behind, exactly as Khnum does (PHP swallows the newline after ?>). An echo keeps its trailing newline. You rarely need to think about this; it is why the loop above produces tidy <tr> rows.
Layouts
Defining a layout
{{-- resources/views/layouts/app.khnum.html --}}<html> <head> <title>App Name - @yield("title", "Home")</title> </head> <body> @section("sidebar") This is the master sidebar. @show
<div class="container"> @yield("content") </div> </body></html>@yield("name") prints the section a child provides. A second argument is the default, and it is escaped. @section ... @show defines a section and prints it immediately, so children may extend it.
Extending a layout
{{-- resources/views/child.khnum.html --}}@extends("layouts.app")
@section("title", "Page Title")
@section("sidebar") @parent <p>This is appended to the master sidebar.</p>@endsection
@section("content") <p>This is my body content.</p>@endsection@section("name", value) is the inline form; the value is escaped. @parent splices in the layout’s own content for that section. @stop is an alias of @endsection. Layouts may themselves extend other layouts, and an included partial or a component may define a section that the layout yields.
Including Subviews
@include renders another view in place. The included view sees every variable
of the view that included it:
<div> @include("shared.errors") <form>...</form></div>Pass extra data as a map literal; it is merged on top of the parent’s variables:
@include("partials.user-card", {"user": user, "compact": true})The view name may be an expression: @include(partialName).
Components
Components are templates under components/ (or the componentsPath you configure). <x-alert> renders components/alert.khnum.html; <x-forms.input> renders components/forms/input.khnum.html.
{{-- resources/views/components/alert.khnum.html --}}@props({"type": "info"})
<div {{ attributes.merge({"class": "alert alert-" + type}) }} role="alert"> {{ slot }}</div><x-alert type="success" id="profile-alert"> Profile updated successfully.</x-alert><div class="alert alert-success" id="profile-alert" role="alert"> Profile updated successfully.</div>Passing data
| Attribute form | Meaning |
|---|---|
type="success" |
the string success |
class="btn {{ size }}" |
a string with interpolation |
:user="user" |
the value of the expression user |
disabled |
true |
Every attribute is available as a variable inside the component. Components are isolated: they do not see the caller’s variables, only their attributes, their slots and shared data. Pass what they need explicitly:
<x-user-card :user="user" :editable="user.id == currentUser.id" />Slots
The content between the tags is slot. Named slots use <x-slot>:
<x-card> <x-slot name="header">Account</x-slot> Everything else is the default slot. <x-slot:footer><a href="/logout">Log out</a></x-slot:footer></x-card><div class="card"> <div class="card-header">{{ header }}</div> <div class="card-body">{{ slot }}</div> <div class="card-footer">{{ footer }}</div></div>Slot content is rendered in the caller’s scope, then handed to the component as safe HTML, so {{ slot }} prints it without escaping it a second time.
Props and the attributes bag
@props at the top of a component declares the attributes the component consumes and their defaults. Everything that is not declared lands in attributes, which prints as key="value" pairs with escaped values:
@props({"type": "info", "title": null})<div {{ attributes }}>...</div>attributes.merge({...}) adds defaults; class values are concatenated. attributes.has("id") and attributes.get("id") read single values.
Differences from Khnum
- Attributes not declared in
@propsare still available as variables (Khnum hides them). Declaring props remains the way to give defaults and to keep them out ofattributes. - Components are anonymous only; there are no class-based components.
- Attribute names with dashes (
data-id) reach the attributes bag but cannot be read as variables.
Rendering JSON for JavaScript
Use the built-in json helper inside <script>. It escapes the characters
that could close or alter the script block, so a value containing </script>
cannot break out into executable markup:
<script> window.app = {{ json({"user": user, "csrf": token}) }};</script>Linking Assets
ViewServiceProvider registers asset() as a Khnum helper, so a template links to a compiled stylesheet the same way Dart code would:
<link rel="stylesheet" href="{{ asset('css/app.css') }}">It returns a root-relative URL by default (/css/app.css) and appends a ?v=
cache-busting token outside debug mode. See Frontend for the
Tailwind pipeline that produces public/css/app.css, and
Static Files & Assets for URL generation and
serving from public/.
Extending the Engine
Helpers
A helper is a Dart function templates may call:
khnum.helper('money', (args) => '\$${(args.first as num).toStringAsFixed(2)}');{{ money(order.total) }}Helper output goes through {{ }} and is escaped like any value. Return an HtmlString if a helper produces markup.
Custom directives
khnum.directive('datetime', (context, args) { final date = args.first as DateTime; return escapeHtml(date.toIso8601String());});Updated @datetime(post.updatedAt)The handler receives the evaluated arguments and the current RenderContext (context.lookup('name', line) reads a variable). Its return value is written unescaped, because directives usually emit markup; call escapeHtml on anything that came from a user. Register directives before the first render: an unregistered @word is plain text, and registering one clears the template cache.
Loading templates from somewhere else
Implement TemplateLoader to read from a database or a bundle:
class DatabaseLoader implements TemplateLoader { @override TemplateSource? load(String name) { final row = db.findTemplate(name); return row == null ? null : TemplateSource(name, row.source, row.updatedAt); }}
final khnum = Khnum.withLoader(DatabaseLoader());Khnum.inMemory({...}) is the built-in map loader, used throughout the test suite.
Configuration
final khnum = Khnum( viewsPath: 'resources/views', componentsPath: 'resources/views/components', // default extension: '.khnum.html', // default environment: TemplateEnvironment.production, missingVariables: MissingVariables.throwError, // default maxDepth: 64, // default);Caching and reloading
Templates are parsed once and the parsed form is kept in memory. In TemplateEnvironment.development each render checks the file’s modification time and re-parses when it changed, so you edit and refresh. In TemplateEnvironment.production the first parse is final and the disk is never touched again. There is no on-disk cache: parsing is fast and there is no compiled artifact worth persisting.
Missing variables
By default a reference to a variable or map key that does not exist throws UndefinedVariableException with the template name and line, in every environment. If you prefer Khnum-on-PHP-7 behaviour in production, switch it off:
missingVariables: MissingVariables.treatAsNullUndefined names then evaluate to null and {{ }} prints nothing. Member access on null always yields null, regardless of this setting.
Errors
Every exception extends TemplateException and carries template and line:
| Exception | When |
|---|---|
TemplateSyntaxException |
unclosed {{, unterminated @if, bad expression, malformed component tag |
TemplateNotFoundException |
no such view; also names the view and line that asked for it |
UndefinedVariableException |
missing variable or key under the default policy |
TemplateRenderException |
type error in an expression, helper threw, loop over a non-collection, depth exceeded |
TemplateSyntaxException: @foreach is never closed; expected @endforeach (orders.index:12)Security
- Escaped by default. Only
{!! !!},HtmlStringand directive return values bypass escaping. - No code execution. Templates cannot call methods on your objects, construct objects, or reach anything outside the data you pass plus registered helpers and resolvers.
- No path traversal. View names are validated (
letters digits _ - .in dot-separated segments) before any filesystem access, and the resolved path must stay under the views root.../secretis rejected everywhere:render,@include,@extendsand components. - Bounded recursion. Includes, components and layouts share one depth counter; exceeding
maxDepththrows instead of overflowing the stack. - Templates are code. A hostile template cannot execute Dart, but it can read everything in the data you pass and loop as long as it likes. Never render templates uploaded by users.
Not Supported
Deliberately out of scope for this version, so you are not left guessing:
@stack / @push, @once, @php, @verbatim, @forelse / @empty, @switch, @auth / @guest, @csrf / @method (arrive with Maat’s Full edition), @each, @lang, class-based components, method calls on data, an on-disk compiled cache, and streaming output.