Template Language Syntax
A layout can do more than insert names into fixed text. It can calculate a small value, choose a passage, or repeat a passage for each item in a collection. This page shows how to write those parts of a layout and what values they expect from the render context.
Expression, Statement, and Comment Delimiters
The examples on this page use the default syntax.
Text outside a marker is copied to the result as written.
An expression between {{ and }} inserts a value, a statement between {% and %} controls rendering, and a
comment between {# and #} disappears from the result.
For example, {{ quest.title }} inserts a title, while {% if ready %} begins a conditional passage.
These markers belong to the EnvironmentOptions used when
creating the Environment.
If your document format already uses braces, you can choose other expression, statement, and comment delimiters.
The environment page shows that setup.
Every layout in an environment then uses its configured markers; the examples below assume the defaults.
Here an environment uses [[ ... ]] for expressions and <% ... %> for statements.
The layout uses those markers throughout, including the if statement around its inserted value.
text/RenderLayouts/EnvironmentExamples.cpp
void customSyntax() {
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "current.txt"_el)
.content()
.writeTextOrThrow("(# note #)<% if active %>[[ current ]]<% endif %>\n"_el);
auto options = el::render::EnvironmentOptions{};
options.setExpressionDelimiters(el::render::Delimiters{"[["_el, "]]"_el})
.setStatementDelimiters(el::render::Delimiters{"<%"_el, "%>"_el})
.setCommentDelimiters(el::render::Delimiters{"(#"_el, "#)"_el});
const auto environment = el::render::Environment::create(options);
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
el::io::print(environment->render(
"current.txt"_el, el::render::Context{}.set("active"_el, true).set("current"_el, "Ligure"_el)));
}
Output
Ligure
The language will look familiar if you know Jinja, but it is a deliberately smaller, Jinja-like language.
Its familiar {{ ... }}, if, for, filter, include, and inheritance forms make ordinary layouts easy to read.
Do not assume that an arbitrary Jinja template will work unchanged: this renderer has its own supported expressions,
statements, and value rules.
The rendering reference gives the exact language boundary.
Value Expressions and Filters
An expression can read a name from the context, then follow map members with dots.
In the next example, quest.title reads title from the quest map.
The completed + 1 expression calculates the next step number, while steps | length gets the size of a list.
A pipe passes a value to a filter; trim removes surrounding whitespace and join(' → ') turns list entries into
one line.
text/RenderLayouts/LanguageExpressions.cpp
/// Combine named values, expressions, and filters in a quest note.
///
/// Dotted lookup reads a member from a map. An expression can calculate a value before insertion, and a filter can
/// transform the result. Missing names resolve to null, which the default filter can replace with useful text.
/// @notest{Demo function verified by the documentation executable.}
void languageExpressions() {
using el::render::Value;
using el::render::ValueList;
using el::render::ValueMap;
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "quest.txt"_el)
.content()
.writeTextOrThrow(
"依頼: {{ quest.title | trim }}\n"
"次の段階: {{ completed + 1 }} / {{ steps | length }}\n"
"手順: {{ steps | join(' → ') }}\n"
"担当: {{ owner | default('未定') }}\n"_el);
// The layout reads a nested map, a list, and an integer from one local context.
auto quest = ValueMap{};
quest.set("title"_el, Value{" 星空を調べる "_el});
const auto steps = ValueList{Value{"望遠鏡を準備"_el}, Value{"月を観察"_el}, Value{"星図を記録"_el}};
const auto context = el::render::Context{}.set("quest"_el, quest).set("steps"_el, steps).set("completed"_el, 1);
const auto environment = el::render::Environment::create();
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
el::io::print(environment->render("quest.txt"_el, context));
}
Output
依頼: 星空を調べる
次の段階: 2 / 3
手順: 望遠鏡を準備 → 月を観察 → 星図を記録
担当: 未定
The context supplies structured values, and the layout decides how to present them.
owner is absent here, so lookup produces null; default('未定') supplies text for that case.
Without the filter, null would insert an empty string.
A list or map cannot be inserted directly: select a scalar member, join suitable list entries, or iterate instead.
You can also write string, number, Boolean, null, list, and map literals in an expression.
Parentheses clarify a calculation, and comparisons such as >= or in let a condition examine a value.
The language supports and, or, and not for combining conditions; and and or stop as soon as the
result is decided.
Filters run from left to right, so title | trim | upper trims before it changes case.
The Template Feature Cheat Sheet lists every supported operator, test, and built-in filter with a compact
example.
The rendering reference supplies their exact value rules.
Conditional Branches and Local Assignments
When only one passage belongs in the result, keep the choice in the layout.
An if tests an expression; optional elif branches test further cases, and else provides a final case.
The first matching branch is rendered, and endif closes the statement.
text/RenderLayouts/LanguageConditions.cpp
/// Select one status line from an if, elif, and else chain.
///
/// Conditions inspect context values without requiring the caller to choose or assemble layout fragments.
/// @notest{Demo function verified by the documentation executable.}
void languageConditions() {
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "status.txt"_el)
.content()
.writeTextOrThrow(
"{% if completed >= required %}達成"
"{% elif completed > 0 %}進行中"
"{% else %}未着手{% endif %}\n"_el);
const auto environment = el::render::Environment::create();
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
// Reuse the same layout for three states of the quest.
for (const auto completed : {0, 1, 3}) {
const auto context = el::render::Context{}.set("completed"_el, completed).set("required"_el, 3);
el::io::print(environment->render("status.txt"_el, context));
}
}
Output
未着手
進行中
達成
The same layout renders three states because only the context values change.
Conditions use the renderer’s truth rules: null, false, numeric zero, empty text, and empty collections are false.
This makes {% if steps %} useful for a nonempty list, while a comparison states the intended threshold more clearly
for a numeric progress value.
Missing names resolve to null, so a missing optional value can be checked with is none.
If a calculation deserves a name inside a layout, {% set remaining = required - completed %} creates a render-local
value.
It does not change the caller’s Context.
An assignment inside a branch runs only when that branch runs.
For exact assignment and scope rules, see the rendering reference.
Loops over Lists and Maps
A list loop gives one name to each item.
Here step is a text value, and loop.index numbers the lines starting at one.
The optional else passage runs when the list is empty.
The second layout shows the two-name form for an ordered map: one name receives the key, the other its value.
To print a fixed number of list items per line, loop over numbers | batch(10) and join each row with
{{ row | join(' ') }}.
The final row can contain fewer than ten items.
text/RenderLayouts/LanguageIterations.cpp
/// Render a list and an ordered map with for loops.
///
/// A list loop exposes one item and loop position data. A map loop exposes a key and value. The optional else branch
/// gives a useful result when a collection is empty.
/// @notest{Demo function verified by the documentation executable.}
void languageIterations() {
using el::render::Value;
using el::render::ValueList;
using el::render::ValueMap;
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "steps.txt"_el)
.content()
.writeTextOrThrow(
"{% for step in steps %}{{ loop.index }}. {{ step }}\n"
"{% else %}手順なし\n{% endfor %}"_el);
(directory->path() / "rewards.txt"_el)
.content()
.writeTextOrThrow("{% for name, count in rewards %}{{ name }}: {{ count }}\n{% endfor %}"_el);
const auto environment = el::render::Environment::create();
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
// List entries appear in their original order; an empty list selects the else branch.
const auto steps = ValueList{Value{"望遠鏡を準備"_el}, Value{"月を観察"_el}};
el::io::print(environment->render("steps.txt"_el, el::render::Context{}.set("steps"_el, steps)));
el::io::print(environment->render("steps.txt"_el, el::render::Context{}.set("steps"_el, ValueList{})));
// A map supplies two loop targets: its key and its value.
auto rewards = ValueMap{};
rewards.set("星図"_el, Value{1}).set("観測記録"_el, Value{2});
el::io::print(environment->render("rewards.txt"_el, el::render::Context{}.set("rewards"_el, rewards)));
}
Output
1. 望遠鏡を準備
2. 月を観察
手順なし
星図: 1
観測記録: 2
A map is visited in StringMap key order.
The loop value also offers index0, first, last, length, and reverse indexes when the presentation
needs them.
Each pass has its own scope, so a loop item or an assignment made inside one pass does not replace a name in the
caller’s context or leak into the next pass.
If a value might be null, prepare a list or map in the context before iterating: for expects the collection type
that matches its target names.
Whitespace Control in Templates
Statements and expressions affect only the text they surround; ordinary spaces and line breaks in the source are
preserved.
When a control line leaves an unwanted blank line, a - whitespace marker beside a delimiter removes adjacent ASCII
whitespace.
Use it where the result needs it, since it also removes source line breaks that may make a rendered document easier to
read.
For longer documents, Loading Render Layouts shows how to split reusable passages into named layouts. Blocks and inheritance let related layouts share a document structure; Sharing Layouts with Inheritance introduces that workflow. When you need to recall a spelling while editing a layout, use the Template Feature Cheat Sheet.
Complete demo sources
text/RenderLayouts/EnvironmentExamples.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include <DemoCommon.hpp>
#include <erbsland/conf/ValueList.hpp>
#include <erbsland/EscapeFormat.hpp>
#include <erbsland/ItemCount.hpp>
#include <erbsland/ItemIndex.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/PathContent.hpp>
#include <erbsland/PathOperations.hpp>
#include <erbsland/render/Context.hpp>
#include <erbsland/render/Delimiters.hpp>
#include <erbsland/render/Environment.hpp>
#include <erbsland/render/EnvironmentOptions.hpp>
#include <erbsland/render/FileSystemLoader.hpp>
#include <erbsland/render/RenderError.hpp>
#include <erbsland/render/RenderErrorCategory.hpp>
#include <erbsland/render/RenderErrorContext.hpp>
#include <erbsland/render/RenderLimits.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/StringList.hpp>
#include <erbsland/TempDirectory.hpp>
namespace demo {
using namespace el::unit;
/// Share stable values across renders through the environment's global context.
///
/// A global context supplies names to every layout rendered by the environment. Replacing it publishes a new
/// snapshot for subsequent calls without changing the layout or the loader.
/// @notest{Demo function verified by the documentation executable.}
void sharedValues() {
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "current.txt"_el)
.content()
.writeTextOrThrow("Corrente: {{ current }}; costa: {{ coast }}\n"_el);
const auto environment = el::render::Environment::create();
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
environment->setGlobalContext(el::render::Context{}.set("current"_el, "Ligure"_el).set("coast"_el, "Italia"_el));
el::io::print(environment->render("current.txt"_el));
environment->setGlobalContext(
el::render::Context{}.set("current"_el, "Adriatica"_el).set("coast"_el, "Croazia"_el));
el::io::print(environment->render("current.txt"_el));
}
/// Give one render its own values while retaining shared defaults.
///
/// A local name wins when it is also present in the global context. Other global names remain available to
/// the layout, so a caller only supplies the values that vary for one document.
/// @notest{Demo function verified by the documentation executable.}
void localValues() {
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "observation.txt"_el)
.content()
.writeTextOrThrow("{{ station }}: {{ current }} ({{ season }})\n"_el);
const auto environment = el::render::Environment::create();
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
environment->setGlobalContext(
el::render::Context{}.set("station"_el, "Punto nord"_el).set("season"_el, "estate"_el));
const auto local = el::render::Context{}.set("station"_el, "Punto sud"_el).set("current"_el, "Ligure"_el);
el::io::print(environment->render("observation.txt"_el, local));
}
/// Add one application filter for a value transformation used by a layout.
///
/// A filter receives the piped value at index zero and any positional arguments after it. Validate the
/// arguments in the callback before reading their concrete types. Reject invalid template arguments with
/// RenderError; the renderer supplies the filter's source location.
/// @notest{Demo function verified by the documentation executable.}
void customFilter() {
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "current.txt"_el).content().writeTextOrThrow("{{ current | label('Corrente: ') }}\n"_el);
const auto environment = el::render::Environment::create();
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
environment->addFilter("label"_el, [](const el::render::ValueList &values) -> el::render::Value {
if (values.count() != ItemCount{2U} || !values.getRef(ItemIndex{0U}).isText() ||
!values.getRef(ItemIndex{1U}).isText()) {
throw el::render::RenderError{el::render::RenderErrorContext{
el::render::RenderErrorCategory::Runtime,
"Invalid label filter arguments"_el,
"The label filter needs text and one text prefix."_el}};
}
return el::StringList{values.getRef(ItemIndex{1U}).asText(), values.getRef(ItemIndex{0U}).asText()}.join();
});
el::io::print(environment->render("current.txt"_el, el::render::Context{}.set("current"_el, "Ligure"_el)));
}
/// Choose delimiters that fit the text format containing a layout.
///
/// Expression, statement, and comment delimiters are configured together before creating the environment.
/// @notest{Demo function verified by the documentation executable.}
void customSyntax() {
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "current.txt"_el)
.content()
.writeTextOrThrow("(# note #)<% if active %>[[ current ]]<% endif %>\n"_el);
auto options = el::render::EnvironmentOptions{};
options.setExpressionDelimiters(el::render::Delimiters{"[["_el, "]]"_el})
.setStatementDelimiters(el::render::Delimiters{"<%"_el, "%>"_el})
.setCommentDelimiters(el::render::Delimiters{"(#"_el, "#)"_el});
const auto environment = el::render::Environment::create(options);
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
el::io::print(environment->render(
"current.txt"_el, el::render::Context{}.set("active"_el, true).set("current"_el, "Ligure"_el)));
}
/// Configure escaping for layouts whose suffix identifies the output format.
///
/// Built-in suffix mappings cover common formats. A longer custom suffix can override one mapping; automatic
/// escaping can also be disabled when a trusted plain-text output needs its original characters.
/// @notest{Demo function verified by the documentation executable.}
void escapingOptions() {
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "note.html"_el).content().writeTextOrThrow("{{ title }}\n"_el);
(directory->path() / "note.raw.html"_el).content().writeTextOrThrow("{{ title }}\n"_el);
auto options = el::render::EnvironmentOptions{};
options.setEscapeFormatForSuffix(".raw.html"_el, el::EscapeFormat::None);
const auto environment = el::render::Environment::create(options);
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
const auto context = el::render::Context{}.set("title"_el, "Mare & vento"_el);
el::io::print(environment->render("note.html"_el, context));
el::io::print(environment->render("note.raw.html"_el, context));
auto plainOptions = el::render::EnvironmentOptions{};
plainOptions.setAutomaticEscapingEnabled(false);
const auto plainEnvironment = el::render::Environment::create(plainOptions);
plainEnvironment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
el::io::print(plainEnvironment->render("note.html"_el, context));
}
/// Bound the amount of text a layout may generate.
///
/// Limits are chosen when the environment is created. A limit failure is a RenderError, so callers can use
/// the same error boundary as for loading and syntax failures.
/// @notest{Demo function verified by the documentation executable.}
void renderingLimit() {
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "current.txt"_el).content().writeTextOrThrow("Corrente Ligure"_el);
auto limits = el::render::RenderLimits{};
limits.setGeneratedOutputBytes(8U);
auto options = el::render::EnvironmentOptions{};
options.setRenderLimits(limits);
const auto environment = el::render::Environment::create(options);
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
try {
el::io::print(environment->render("current.txt"_el));
} catch (const el::render::RenderError &) {
el::io::printLine("The output limit was reached."_el);
}
}
}
text/RenderLayouts/LanguageExpressions.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include <DemoCommon.hpp>
#include <erbsland/conf/ValueList.hpp>
#include <erbsland/List.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/PathContent.hpp>
#include <erbsland/PathOperations.hpp>
#include <erbsland/render/Context.hpp>
#include <erbsland/render/Environment.hpp>
#include <erbsland/render/FileSystemLoader.hpp>
#include <erbsland/render/Value.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/TempDirectory.hpp>
namespace demo {
/// Combine named values, expressions, and filters in a quest note.
///
/// Dotted lookup reads a member from a map. An expression can calculate a value before insertion, and a filter can
/// transform the result. Missing names resolve to null, which the default filter can replace with useful text.
/// @notest{Demo function verified by the documentation executable.}
void languageExpressions() {
using el::render::Value;
using el::render::ValueList;
using el::render::ValueMap;
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "quest.txt"_el)
.content()
.writeTextOrThrow(
"依頼: {{ quest.title | trim }}\n"
"次の段階: {{ completed + 1 }} / {{ steps | length }}\n"
"手順: {{ steps | join(' → ') }}\n"
"担当: {{ owner | default('未定') }}\n"_el);
// The layout reads a nested map, a list, and an integer from one local context.
auto quest = ValueMap{};
quest.set("title"_el, Value{" 星空を調べる "_el});
const auto steps = ValueList{Value{"望遠鏡を準備"_el}, Value{"月を観察"_el}, Value{"星図を記録"_el}};
const auto context = el::render::Context{}.set("quest"_el, quest).set("steps"_el, steps).set("completed"_el, 1);
const auto environment = el::render::Environment::create();
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
el::io::print(environment->render("quest.txt"_el, context));
}
}
text/RenderLayouts/LanguageConditions.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include <DemoCommon.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/PathContent.hpp>
#include <erbsland/PathOperations.hpp>
#include <erbsland/render/Context.hpp>
#include <erbsland/render/Environment.hpp>
#include <erbsland/render/FileSystemLoader.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/TempDirectory.hpp>
#include <initializer_list>
namespace demo {
/// Select one status line from an if, elif, and else chain.
///
/// Conditions inspect context values without requiring the caller to choose or assemble layout fragments.
/// @notest{Demo function verified by the documentation executable.}
void languageConditions() {
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "status.txt"_el)
.content()
.writeTextOrThrow(
"{% if completed >= required %}達成"
"{% elif completed > 0 %}進行中"
"{% else %}未着手{% endif %}\n"_el);
const auto environment = el::render::Environment::create();
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
// Reuse the same layout for three states of the quest.
for (const auto completed : {0, 1, 3}) {
const auto context = el::render::Context{}.set("completed"_el, completed).set("required"_el, 3);
el::io::print(environment->render("status.txt"_el, context));
}
}
}
text/RenderLayouts/LanguageIterations.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include <DemoCommon.hpp>
#include <erbsland/conf/ValueList.hpp>
#include <erbsland/List.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/PathContent.hpp>
#include <erbsland/PathOperations.hpp>
#include <erbsland/render/Context.hpp>
#include <erbsland/render/Environment.hpp>
#include <erbsland/render/FileSystemLoader.hpp>
#include <erbsland/render/Value.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/TempDirectory.hpp>
namespace demo {
/// Render a list and an ordered map with for loops.
///
/// A list loop exposes one item and loop position data. A map loop exposes a key and value. The optional else branch
/// gives a useful result when a collection is empty.
/// @notest{Demo function verified by the documentation executable.}
void languageIterations() {
using el::render::Value;
using el::render::ValueList;
using el::render::ValueMap;
const auto directory = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
(directory->path() / "steps.txt"_el)
.content()
.writeTextOrThrow(
"{% for step in steps %}{{ loop.index }}. {{ step }}\n"
"{% else %}手順なし\n{% endfor %}"_el);
(directory->path() / "rewards.txt"_el)
.content()
.writeTextOrThrow("{% for name, count in rewards %}{{ name }}: {{ count }}\n{% endfor %}"_el);
const auto environment = el::render::Environment::create();
environment->addLayoutLoader(el::render::FileSystemLoader::create(directory->path()));
// List entries appear in their original order; an empty list selects the else branch.
const auto steps = ValueList{Value{"望遠鏡を準備"_el}, Value{"月を観察"_el}};
el::io::print(environment->render("steps.txt"_el, el::render::Context{}.set("steps"_el, steps)));
el::io::print(environment->render("steps.txt"_el, el::render::Context{}.set("steps"_el, ValueList{})));
// A map supplies two loop targets: its key and its value.
auto rewards = ValueMap{};
rewards.set("星図"_el, Value{1}).set("観測記録"_el, Value{2});
el::io::print(environment->render("rewards.txt"_el, el::render::Context{}.set("rewards"_el, rewards)));
}
}