LEVIATHAN v962456e · 962456eee1

atlantis

Views — server-rendered templates

The Atlantis template engine that renders .lhtml files against a JSON model, with escaping by default, partials, layouts, boot-time checking and a development reload mode.

since 0.1.0-alpha.1linux

Description

The Views engine is the part of Atlantis that turns a template file and a model into HTML. It is a self-contained package feature: an application that only serves JSON never uses it, and removing it changes nothing for such an application.

Templates are files with the extension .lhtml under one root directory (by convention Resources/Views). A template's name is its path relative to that root, without the extension: Resources/Views/links/index.lhtml is "links/index". A name may use . instead of / between directories. A partial is an ordinary template whose last name segment starts with _ (links/_list); that is a naming convention only.

All templates are read and parsed once, when the engine is constructed. Any syntax error, and any reference from one template to another that does not exist, is collected and thrown together as one ViewException that lists every file:line: message. An application with a broken template therefore refuses to start rather than failing on the first request that reaches it.

Syntax. The syntax is fixed: there are no operators, filters or nested layouts.

construct meaning
{{ expr }} output the value, HTML-escaped
{{{ expr }}} output the value without escaping
{{#if expr}} … {{#else}} … {{/if}} conditional; the {{#else}} part is optional
{{#for x in expr}} … {{/for}} repeat for each element of an array
{{> name}} include another template, with the current model and loop variables
{{#layout "name"}} wrap this template in a layout; it must be the first construct and appears at most once
{{#block "name"}} … {{/block}} content the template hands to its layout; top level only
{{#yield "name"}} and {{#yield}} in a layout, the named block, or the template's main body
{{csrf}} a hidden form input carrying the request's CSRF token
{{asset "css/app.css"}} the URL of a static file, prefixed with the engine's asset prefix
{{! comment }} removed from the output

An expr is a path or a literal. A path is a name followed by .name or .number steps, such as user.name or items.0.title, looked up in the model; a number steps into an array. A literal is a double-quoted string, an integer, true or false. The one built-in name is $index, the zero-based counter of the innermost {{#for}}.

Truthiness in {{#if}}: a missing path, null, false, the empty string and the empty array are false. Every number, including 0, is true, and so is everything else.

Missing values. A path that does not exist in an output position {{ a.b }} is an error that depends on the mode. In development mode (EngineConfig.dev is true) rendering throws a ViewException such as p.lhtml:1 — path 'user.name' not found, with a hint listing the keys of the nearest object. In production mode it renders as an empty string and writes one structured warning to the Atlantis::Log logger. A missing path in an {{#if}} or {{#for}} is simply false or empty in both modes.

The model is a JsonValue. A controller can pass one it built by hand, or a value of any class that provides toJson() returning a JsonValue.

Rendering from a controller. A controller builds a Views::View with view(name, model) (or fragment(name, model) for a partial page that skips the layout) and passes it to render(ctx, view), which returns an ordinary HttpResponse with status 200 and the content type text/html; charset=utf-8. wantsFragment(ctx) is true when the request carries the header HX-Request: true, so a single route can return a full page or just a fragment.

Installing the engine. The context a handler receives gives no access to a dependency container, so the engine is held in a process-wide slot: call Atlantis::Views::install(Engine(config, logger)) once at startup. Every render, and the simpler Atlantis::Http::View("name").with("key", "value").render(ctx) helper, uses the installed engine. If none was installed they throw a RuntimeException.

Flash messages ride the session. Atlantis::Views::flash(ctx, "notice", "Saved") stores a message for the next request; render removes every stored message and passes them to the template as the object flash ({{ flash.notice }}). Each message is read once. Flash needs the session middleware; without a session it does nothing.

Render a template without a server

uses Atlantis::Http;
uses Atlantis::Views;
uses Atlantis::Log;

void writeTemplate(string path, string text) {
    File f = File(path, std::write);
    f.write(text);
    f.close();
}

// A real application keeps its templates under Resources/Views; this example
// writes one to a scratch folder so that it is self-contained.
std::sysMkdir("views-demo");
writeTemplate("views-demo/home.lhtml",
    "<h1>{{ title }}</h1>\n" +
    "{{#for item in items}}<li>{{ item.label }}</li>\n{{/for}}" +
    "{{#if admin}}<p>admin</p>{{#else}}<p>guest</p>{{/if}}\n" +
    "<p>raw: {{{ note }}}</p>\n<p>escaped: {{ note }}</p>");

Engine engine = Engine(EngineConfig("views-demo", false, "/public"), Logger(CaptureSink(), 0));
install(engine);

Map<string, JsonValue> a = Map();
a["label"] = JsonValue::ofStr("first");
Map<string, JsonValue> b = Map();
b["label"] = JsonValue::ofStr("second");
Map<string, JsonValue> model = Map();
model["title"] = JsonValue::ofStr("Links");
model["items"] = JsonValue::ofArray([JsonValue::ofObject(a), JsonValue::ofObject(b)]);
model["admin"] = JsonValue::ofBool(false);
model["note"] = JsonValue::ofStr("<b>bold</b>");

Context ctx = Context(HttpRequest());
HttpResponse res = render(ctx, view("home", JsonValue::ofObject(model)));
console.writeln(res.status);
console.writeln(res.headers.firstOr("Content-Type", "-"));
console.writeln(res.body);

std::sysRemove("views-demo/home.lhtml");
std::sysRemove("views-demo");
200
text/html; charset=utf-8
<h1>Links</h1>
<li>first</li>
<li>second</li>
<p>guest</p>
<p>raw: <b>bold</b></p>
<p>escaped: &lt;b&gt;bold&lt;/b&gt;</p>

Rules

  • Escaping applies to {{ expr }}: it replaces &, <, >, " and ' with &amp;, &lt;, &gt;, &quot; and the numeric character reference for the apostrophe. Use {{{ expr }}} only for text that is already safe HTML.
  • Interpolation inside a <script> or <style> element, inside an event-handler attribute such as onclick="…", or directly after = in an unquoted attribute, is a boot error because HTML escaping alone does not make those contexts safe. A comment {{! lint:allow }} on the line before turns the error into a warning for the next line.
  • A {{#layout}} must be the first construct in a template. A layout cannot itself declare a layout. A {{#block}} with no matching {{#yield}} in the layout is a warning, not an error.
  • {{> name}} and {{#layout "name"}} must name a template that exists. Include cycles are rejected at boot.
  • Templates are cached for the life of the engine. In production mode nothing on disk is read again after the engine is constructed.
  • Engine.renderNamed, Frame, RenderEnv, the node classes and the parser are the implementation of the engine. Application code uses Engine, EngineConfig, install, view, fragment, render, wantsFragment and flash.
  • A number in the model is a float, so an integer-valued field is written with six decimals (3.000000), exactly as JsonValue.render() writes it. {{ $index }} is written the same way (0.000000). Pass a string, such as "3", when the text must be exact.

Examples

A layout, a partial, a fragment, the truth table and the asset helper:

Layouts, partials, fragments and truthiness

uses Atlantis::Http;
uses Atlantis::Views;
uses Atlantis::Log;

void writeTemplate(string path, string text) {
    File f = File(path, std::write);
    f.write(text);
    f.close();
}

std::sysMkdir("views-layout");
std::sysMkdir("views-layout/layouts");
writeTemplate("views-layout/layouts/main.lhtml",
    "<title>{{#yield \"title\"}}</title>\n<link href=\"{{asset \"css/app.css\"}}\">\n<main>{{#yield}}</main>");
writeTemplate("views-layout/_row.lhtml", "<li>{{ item.name }}</li>");
writeTemplate("views-layout/page.lhtml",
    "{{#layout \"layouts/main\"}}{{#block \"title\"}}{{ title }}{{/block}}<ul>\n" +
    "{{#for item in items}}{{> _row}}\n{{/for}}</ul>");
writeTemplate("views-layout/truth.lhtml",
    "zero:{{#if zero}}T{{#else}}F{{/if}} empty:{{#if empty}}T{{#else}}F{{/if}} " +
    "list:{{#if list}}T{{#else}}F{{/if}} null:{{#if nothing}}T{{#else}}F{{/if}} " +
    "missing:{{#if nope.deeper}}T{{#else}}F{{/if}} text:{{#if text}}T{{#else}}F{{/if}}");

install(Engine(EngineConfig("views-layout", false, "/static"), Logger(CaptureSink(), 0)));

Map<string, JsonValue> ann = Map();
ann["name"] = JsonValue::ofStr("Ann");
Map<string, JsonValue> bo = Map();
bo["name"] = JsonValue::ofStr("Bo & Co");
Map<string, JsonValue> model = Map();
model["title"] = JsonValue::ofStr("People");
model["items"] = JsonValue::ofArray([JsonValue::ofObject(ann), JsonValue::ofObject(bo)]);
model["zero"] = JsonValue::ofNum(0.0);
model["empty"] = JsonValue::ofStr("");
model["list"] = JsonValue::ofArray([]);
model["nothing"] = JsonValue::ofNull();
model["text"] = JsonValue::ofStr("x");
JsonValue m = JsonValue::ofObject(model);

Context ctx = Context(HttpRequest());
console.writeln(render(ctx, view("page", m)).body);
console.writeln("--- fragment");
console.writeln(render(ctx, fragment("page", m)).body);
console.writeln("--- truth table");
console.writeln(render(ctx, view("truth", m)).body);

std::sysRemove("views-layout/layouts/main.lhtml");
std::sysRemove("views-layout/layouts");
std::sysRemove("views-layout/_row.lhtml");
std::sysRemove("views-layout/page.lhtml");
std::sysRemove("views-layout/truth.lhtml");
std::sysRemove("views-layout");
<title>People</title>
<link href="/static/css/app.css">
<main><ul>
<li>Ann</li>
<li>Bo &amp; Co</li>
</ul></main>
--- fragment
<ul>
<li>Ann</li>
<li>Bo &amp; Co</li>
</ul>
--- truth table
zero:T empty:F list:F null:F missing:F text:T

The simple string-keyed helper Atlantis::Http::View renders through the same installed engine, and a class that provides toJson() can be the model directly:

A typed model and the Http::View helper

uses Atlantis;
uses Atlantis::Http;
uses Atlantis::Views;
uses Atlantis::Log;

@Serializable
class Page : IJsonSerializable {
    string title;
    Array<string> tags;
    new Page(string title, Array<string> tags) {
        this.title = title;
        this.tags = tags;
    }
    JsonValue toJson() {
        Map<string, JsonValue> m = Map();
        m["title"] = JsonValue::ofStr(this.title);
        Array<JsonValue> ts = [];
        for (string t in this.tags) {
            ts = ts.add(JsonValue::ofStr(t));
        }
        m["tags"] = JsonValue::ofArray(ts);
        return JsonValue::ofObject(m);
    }
}

std::sysMkdir("views-typed");
File f = File("views-typed/page.lhtml", std::write);
f.write("<h1>{{ title }}</h1>{{#for t in tags}} #{{ t }}{{/for}}");
f.close();
File g = File("views-typed/hello.lhtml", std::write);
g.write("Hello, {{ who }}!");
g.close();

install(Engine(EngineConfig("views-typed", false, "/public"), Logger(CaptureSink(), 0)));
Context ctx = Context(HttpRequest());
console.writeln(render(ctx, view("page", Page("Typed", ["a", "b"]))).body);
console.writeln(Atlantis::Http::View("hello").with("who", "<Ann>").render(ctx).body);
View v = fragment("page", Page("Frag", []));
console.writeln(v.asFragment);
console.writeln(wantsFragment(ctx));

std::sysRemove("views-typed/page.lhtml");
std::sysRemove("views-typed/hello.lhtml");
std::sysRemove("views-typed");
<h1>Typed</h1> #a #b
Hello, &lt;Ann&gt;!
true
false

A broken template refuses to boot, and every problem in it is reported at once. The same script shows the two modes for a missing value, and what happens when no engine is installed:

Boot errors, missing values and the development mode

uses Atlantis::Http;
uses Atlantis::Views;
uses Atlantis::Log;

void writeTemplate(string path, string text) {
    File f = File(path, std::write);
    f.write(text);
    f.close();
}

Map<string, JsonValue> none = Map();
JsonValue model = JsonValue::ofObject(none);
Context ctx = Context(HttpRequest());

try {
    render(ctx, view("p", model));
} catch (RuntimeException ex) {
    console.writeln(ex.message);
}

std::sysMkdir("views-bad");
writeTemplate("views-bad/a.lhtml",
    "<h1>{{ title }}</h1>\n{{> nope}}\n{{ }}\n<script>alert({{ data }})</script>\n{{#if user}}\nnever closed\n");
try {
    Engine broken = Engine(EngineConfig("views-bad", false, "/public"), Logger(CaptureSink(), 0));
} catch (IException ex) {
    console.writeln(ex.message);
}
std::sysRemove("views-bad/a.lhtml");
std::sysRemove("views-bad");

std::sysMkdir("views-ok");
writeTemplate("views-ok/p.lhtml", "name=[{{ user.name }}]");
CaptureSink sink = CaptureSink();
install(Engine(EngineConfig("views-ok", false, "/public"), Logger(sink, 0)));
console.writeln(render(ctx, view("p", model)).body);
console.writeln(sink.count());
install(Engine(EngineConfig("views-ok", true, "/public"), Logger(CaptureSink(), 0)));
try {
    render(ctx, view("p", model));
} catch (IException ex) {
    console.writeln(ex.message);
}
try {
    render(ctx, view("unknown/page", model));
} catch (IException ex) {
    console.writeln(ex.message);
}
std::sysRemove("views-ok/p.lhtml");
std::sysRemove("views-ok");
Atlantis::Views: no Engine installed — call Atlantis::Views::install(Engine(cfg, logger)) at boot before rendering any view
view boot failed:
views-bad/a.lhtml:3: empty {{ }} expression
views-bad/a.lhtml:4: interpolation inside <script> (context-blind escaping is unsafe here — move data out of script, or {{! lint:allow }})
views-bad/a.lhtml:5: unclosed {{#if}} (opened line 5)
views-bad/a.lhtml:2: {{> nope}} targets an unknown template
name=[]
1
views-ok/p.lhtml:1 — path 'user.name' not found (nearest: the root model is an object with keys [])
view 'unknown/page' not found

In development mode the engine notices when a template file changes and parses only that file again.

Development mode picks up edits

uses Atlantis::Http;
uses Atlantis::Views;
uses Atlantis::Log;

void writeTemplate(string path, string text) {
    File f = File(path, std::write);
    f.write(text);
    f.close();
}

std::sysMkdir("views-dev");
writeTemplate("views-dev/greet.lhtml", "Hello, {{ name }}!");
install(Engine(EngineConfig("views-dev", true, "/public"), Logger(CaptureSink(), 0)));

Map<string, JsonValue> m = Map();
m["name"] = JsonValue::ofStr("World");
JsonValue model = JsonValue::ofObject(m);
Context ctx = Context(HttpRequest());

console.writeln(render(ctx, view("greet", model)).body);
writeTemplate("views-dev/greet.lhtml", "Hi there, {{ name }}! (edited)");
console.writeln(render(ctx, view("greet", model)).body);
writeTemplate("views-dev/late.lhtml", "added after boot: {{ name }}");
console.writeln(render(ctx, view("late", model)).body);

std::sysRemove("views-dev/greet.lhtml");
std::sysRemove("views-dev/late.lhtml");
std::sysRemove("views-dev");
Hello, World!
Hi there, World! (edited)
added after boot: World

Notes

Development reload. With EngineConfig.dev set, every render checks each template's modification time and size and parses a file again only when one of them differs. The two edits must differ in size or fall in different seconds to be noticed, because file modification times can have one-second resolution. A name that is not loaded triggers a rescan of the root directory, which is how a template added after boot is found. A production engine never looks at the disk after it is constructed.

The views command. Running an application's views command constructs the engine, which performs the same checks as boot, and then lists each template as name -> file, followed by its layout, its blocks and the partials it includes. It exits with an error that lists every finding when the templates are broken. It is to templates what the routes command is to routes.

CSRF and assets. {{csrf}} needs a request that carries a session token; used without one, it is reported like a missing value (an exception in development mode, a warning and no output in production mode). {{asset "path"}} prefixes the path with EngineConfig.assetPrefix, which must match the directory under which the application serves its static files.

See also