Views

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:

FormMeaning
@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 &amp; &lt;welcome&gt;"

Escapes &, <, >, " and '.

This page is the framework’s docs/views.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.