Chapter 14: Template fences
Loup has two built-in ways to produce text from values. Interpolated strings, i"…{$ expr $}…", build a string on the spot and work everywhere, including native binaries. Template fences, blocks opened with three backticks and a language descriptor, build a Template value with loops and conditions inside: an HTML report, a SQL script, a config file. This chapter covers both, and is precise about the limits of Loup 1.25.1: fences run under loup run and in WebAssembly builds but not yet in native binaries, and HTML fences do not escape their input.
Interpolated strings and filters
An interpolated string evaluates each {$ expr $} and renders the value as text (Language Reference §1-15). A value can be passed through filters with |:
@mode(host.native)
func main(): void {
let host = " db.internal ";
let retries = 3;
let note = "";
println(i"connecting to {$ host | trim | upper $} ({$ retries $} retries)");
println(i"note: {$ note | default("none") $}");
println(i"summary: {$ "replica lag above threshold" | truncate(12) $}");
}
Listing 14-1: Interpolation with filters
connecting to DB.INTERNAL (3 retries)
note: none
summary: replica lag …
The built-in filters include upper, lower, trim, escape, default(x), truncate(n), replace(a, b), join(sep) and title (Language Reference §1-16.4). A plain "…{$ x $}…" string without the i prefix is never interpolated; it prints the braces literally.
A first fence
A fence starts with three backticks and a descriptor (language):(format), and ends with three backticks on a line of their own. Its value is a Template; render(scope) turns it into a string. Expressions inside refer to variables in the surrounding code, so the scope argument is usually an empty dictionary:
@mode(host.dual)
func main(): void {
let service = "billing";
let replicas = 3;
let manifest = ```(yaml):(none)
service: {$ service $}
replicas: {$ replicas $}
```;
println(manifest.render({}));
}
Listing 14-2: A YAML fragment from a fence
service: billing
replicas: 3
The listings in this chapter use @mode(host.dual): a host.dual program can be run by the interpreter or built as WebAssembly, both of which support fences today.
⚠ Planned. Native binaries cannot contain template fences yet.
loup build --target nativestops witherror[E-BUILD-NATIVE]: unsupported in managed native (libc foundation): an unsupported operation (async / templates / signals / advanced dispatch).Interpolated strings (Listing 14-1) do build natively, except for thejoinfilter on the macOS preview lane. Until fences land in the native code generator, a native program that needs templated output can build its text with interpolated strings, or move the templating into ahost.dualtool.
Mistake: unindented fence lines
The fence's lines are measured against the indentation of the code around it, and that indentation is removed from the output. Content written flush against the left margin, as you might in a Python triple-quoted string, breaks the fence:
@mode(host.dual)
func main(): void {
let who = "Ann";
let t = ```(html):(none)
<p>{$ who $}</p>
```;
println(t.render({}));
}
main.loup:6:1: warning: line does not start with expected indentation
main.loup:6:1: warning: line does not start with expected indentation
main.loup:7:1: warning: line does not start with expected indentation
main.loup:9:1: warning: line does not start with expected indentation
main.loup:10:1: error[E0004]: unterminated template fence
main.loup:10:1: error[E0002]: expected closing ``` for template fence
main.loup:10:1: error[E0002]: expected `}`
The closing backticks at column 1 are not recognized as the closer, so the fence swallows the rest of the file. Indent the content and the closer to the level of the statement, as in Listing 14-2. A fence that must contain three backticks itself uses a longer run (four or more) for its own opener and closer.
Directives
Inside a fence, {% … %} directives provide control flow: {% if (cond) %} … {% else %} … {% end %} and {% for (x in xs) %} … {% end %} (Language Reference §1-16.2). Here a function renders one table row, and the page loops over the results:
@mode(host.dual)
struct TestResult {
name: string;
millis: int;
passed: bool;
message: string;
}
func row(t: TestResult): string {
let status = if (t.passed) { "PASS" } else { "FAIL" };
let cell = ```(html):(none)
<tr><td>{$ t.name | escape $}</td><td>{$ t.millis $} ms</td><td class="{$ status | lower $}">{$ status $}</td><td>{$ t.message | escape $}</td></tr>
```;
return cell.render({});
}
func report(results: list[TestResult]): string {
var failed = 0;
for (r in results) {
if (not r.passed) {
failed = failed + 1;
}
}
let page = ```(html):(none)
<h1>Test report: {$ failed $} of {$ results.length() $} failed</h1>
<table>
{% for (r in results) %}
{$ row(r) $}
{% end %}
</table>
```;
return page.render({});
}
func main(): void {
let results = [
TestResult(name = "parse <empty>", millis = 2, passed = true, message = ""),
TestResult(name = "checksum", millis = 1, passed = false, message = "expected 42, got 41"),
];
println(report(results));
}
Listing 14-3: An HTML test report
<h1>Test report: 1 of 2 failed</h1>
<table>
<tr><td>parse <empty></td><td>2 ms</td><td class="pass">PASS</td><td></td></tr>
<tr><td>checksum</td><td>1 ms</td><td class="fail">FAIL</td><td>expected 42, got 41</td></tr>
</table>
The directive lines leave blank lines in the output. The specification's trim markers on directives ({%- … -%}) do not parse in 1.25.1; for whitespace-sensitive output, put the directives on the same line as the content they wrap.
Notice the | escape filters on the test name and message. They are not optional, as the next section shows.
Known gap: HTML fences do not escape
The Language Reference specifies context-aware escaping for html fences: text placed in an element is HTML-escaped, text in an attribute is attribute-escaped, a URL attribute requires a validated Url, and interpolation inside <script> is refused (§1-16.6). Loup 1.25.1 implements none of this. An interpolated value is inserted exactly as it is:
@mode(host.dual)
func main(): void {
let comment = "<script>steal(document.cookie)</script>";
let page = ```(html):(none)
<p class="comment">{$ comment $}</p>
```;
println(page.render({}));
}
<p class="comment"><script>steal(document.cookie)</script></p>
Anyone who can put text into comment can put script into the page. Until context-aware escaping lands, pass every untrusted value through the escape filter, which encodes &, <, >, " and ':
let comment = "<script>steal(document.cookie)</script>";
let page = ```(html):(none)
<p class="comment">{$ comment | escape $}</p>
```;
println(page.render({}));
Listing 14-4: Escaping by hand
<p class="comment"><script>steal(document.cookie)</script></p>
escape is right for element text and for quoted attribute values. It does not make a URL safe: a href built from user input still needs checking for schemes such as javascript:.
⚠ Planned. The types that carry these guarantees are specified but not defined in 1.25.1:
SafeHtml(already-escaped markup that is not escaped again),Url(a validated URL), and the CSS fence'sStyleSheetandClassName(§1-16.6.7, §1-16.7.1).let s: SafeHtml = …iserror[E0001]: undefined type 'SafeHtml'. Acssfence works today as a plainTemplate.
Templates as values
A fence is an expression, so functions can return templates and assemble them. A Template has render(scope) and renderWith(ctx, scope); composing two templates means rendering the inner one and interpolating its text:
func header(title: string): Template {
return ```(html):(none)
<header><h1>{$ title | escape $}</h1></header>
```;
}
func main(): void {
let top = header("Q3 <draft>");
let page = ```(html):(none)
{$ top.render({}) $}
<main>quarterly numbers</main>
```;
println(page.render({}));
}
Listing 14-5: Composing templates
<header><h1>Q3 <draft></h1></header>
<main>quarterly numbers</main>
The fence's expressions are evaluated when the fence itself is evaluated, not when render is called (Language Reference §1-16.4), so the values captured are those at the point the Template was built.
The GPU shader fences (wgsl, metal, glsl) and the ui layout fence are part of the planned GPU and desktop UI support (chapters 23 and 22).
Summary
i"…{$ expr | filter $}…"builds a string and works in every mode, including native binaries.- A fence
```(lang):(format) … ```builds aTemplate;render({})produces the text. Its lines and closer must be indented to the surrounding code (E0004 unterminated template fenceotherwise). {% if %}and{% for (x in xs) %}directives work; directive trim markers do not.- HTML fences do not escape in 1.25.1: use
| escapeon every untrusted value.SafeHtml,Url,StyleSheetandClassNameare ⚠ planned. - Fences run under
loup runand in WebAssembly builds; native builds refuse them (⚠ planned), so templated code belongs inhost.dualfor now. - Chapter 15 begins the ownership chapters with parameter verbs.