Views
On this page
The V in MVC. Templates live in resources/views/, use a directive
syntax that will be familiar from PHP templating engines, and are addressed by
dotted name.
resources/views/
layouts/app.view.html
home.view.html
posts/show.view.html
mail/welcome.view.html
let html = View::instance().render("posts.show", &json!({ "post": post }))?;
Ok(Response::html(html))
posts.show → resources/views/posts/show.view.html. Dots are directory
separators.
Rendering
// Through the facade
View::instance().render("home", &data)?;
// Through a View value
let view = View::with("posts.index", json!({ "posts": posts }))?;
View::instance().render_view(&view)?;
// Building the data up
let view = View::new("home")
.add("title", "Hello")?
.add("user", &user)?;
The engine is a port. Views (the newtype the facade resolves) wraps
Arc<dyn ViewEngine>, so swapping TemplateEngine for Tera or Askama does not
change a single call site:
pub trait ViewEngine: Send + Sync + 'static {
fn render(&self, name: &str, data: &Value) -> Result<String>;
fn render_view(&self, view: &View) -> Result<String> { … }
}
Rainier::new(".").with_views(Arc::new(MyEngine::new()))
The syntax
Output
{{ user.name }} escaped
{!! post.body_html !!} raw
{{ }} HTML-escapes. {!! !!} does not. The safe form is the short one,
so the way you write a value without thinking is the way that cannot inject a
<script>. Emitting raw HTML is possible but has to be asked for — and it is
visibly ugly, which is the point.
Paths are dotted, and numeric segments index arrays:
{{ user.address.city }}
{{ items.0.name }}
A path that does not resolve renders as empty rather than failing. A template is the wrong place to discover that the controller forgot a key, and a blank gap is easier to spot than a 500.
Conditionals
@if(user)
Hello, {{ user.name }}.
@elseif(guest_allowed)
Hello, stranger.
@else
Please log in.
@endif
@if(!post.published)
<span class="draft">Draft</span>
@endif
@if(post.views > 1000)
<span class="popular">Popular</span>
@endif
A condition is one of:
| Form | Meaning |
|---|---|
@if(path) | present and truthy |
@if(!path) | absent or falsy |
@if(path == literal) | and !=, >, >=, <, <= |
A literal is a quoted string, a number, true, false, or null.
Loops
@foreach(posts as post)
<li>{{ post.title }}</li>
@endforeach
@foreach(settings as key => value)
<dt>{{ key }}</dt><dd>{{ value }}</dd>
@endforeach
Works over arrays and over objects, where the key => value form gives you the
field names.
Includes
@include('partials.nav')
The partial renders with the same data as its parent.
Layouts
layouts/app.view.html:
<!doctype html>
<html>
<head><title>@yield('title')</title></head>
<body>
<main>@yield('content')</main>
</body>
</html>
home.view.html:
@extends('layouts.app')
@section('title')Home@endsection
@section('content')
<h1>{{ heading }}</h1>
@foreach(posts as post)
<article>{{ post.title }}</article>
@endforeach
@endsection
@extends names the layout, @section fills a slot, @yield is where the
layout drops it. A @yield with no matching section renders empty.
What it deliberately cannot do
There is no arbitrary expression evaluation and no way to call a function.
You cannot write {{ user.posts().count() }}, or {{ price * 1.2 }}, or
@if(count(items) > 3).
That is not an unfinished feature. A template that can compute is a template that ends up holding business logic — and business logic in a template is untestable, uncacheable, and invisible to anyone reading the controller.
Prepare the data in the controller; let the template lay it out:
// In the controller, where it can be tested.
let data = json!({
"posts": posts,
"post_count": posts.len(),
"total_with_tax": total * 1.2,
"is_popular": views > 1000,
});
The full directive list is {{ }}, {!! !!}, @if/@elseif/@else/@endif,
@foreach/@endforeach, @include, @extends, @section/@endsection,
@yield, and @vite for Vite-managed asset tags. That is the
whole language.
There is also no {{-- comment --}} directive — use an HTML comment, or leave
the note in the controller where the data is prepared.
The engine
TemplateEngine::new("resources/views")
.without_cache() // re-read every render
.with_extension("view.html") // the default
Templates are parsed once and cached by default. Call without_cache() in
development so an edit shows up without a restart — which is what the sample
project does outside production:
match mode {
Mode::Running => TemplateEngine::new("resources/views"),
Mode::Testing => TemplateEngine::new("resources/views").without_cache(),
}
flush() clears the cache at runtime.
Path safety
path_for sanitises every segment of a view name: empty segments, . and
.. are dropped. A view name is frequently derived from user input somewhere
up the stack, and on Windows Path::join with a rooted path discards the
base — so path_for("..secrets") had to be made incapable of escaping
resources/views. It is.
Testing views
MemoryEngine holds templates as strings, so a test needs no files:
let engine = MemoryEngine::new()
.with("greeting", "Hi {{ name }}")
.with("posts.index", "@foreach(posts as p){{ p.title }}@endforeach");
assert_eq!(engine.render("greeting", &json!({ "name": "Ada" }))?, "Hi Ada");
This is also how mailables are tested — the mailer takes a
ViewEngine, so a MemoryEngine makes the rendered body assertable without a
resources/ directory.
Escaping directly
use rainier_framework::view::escape_html;
escape_html("Hello & <welcome>"); // "Hello & <welcome>"
Escapes &, <, >, " and '.
This page is the framework’s docs/views.md at 7b8cb15.
Found a mistake? Edit it on GitHub.