Chapter 14: Template Fences — HTML and CSS
A template fence embeds a block of another language (HTML, CSS, SQL, plain text) in Loup source and fills it with Loup values. It is the tool a web app uses to produce markup.
This chapter teaches the part that works today and marks the part that does not:
- Works in Loup 1.25. Fence syntax,
{$ expr $}insertion, filters, theif/for/while/letdirectives, andTemplatevalues. Fences render the same way underloup runand in a Wasm build run byloup-launcher. - ⚠ Planned. The HTML safety layer of the Language Reference §1-16.6
(context-aware auto-escaping,
SafeHtml,Url, the<script>/<style>prohibition) and the CSS layer of §1-16.7 (StyleSheet,ClassName, scoped class names). Until they ship,htmlfences do not escape anything, and you must escape user data yourself with theescapefilter. This chapter shows how.
Putting a rendered fence into a page (the ui fence, mount, components) is
Chapter 16, and is also planned.
A first fence
@mode(web.compiled)
func card(name: string, role: string): string {
let t = ```(html):(none)
<div class="card">
<h2>{$ name $}</h2>
<p>{$ role | upper $}</p>
</div>
```;
return t.render({});
}
func main(): void {
println(card("Ada Lovelace", "engineer"));
}
Listing 14-1: A function that renders a card with an html fence
<div class="card">
<h2>Ada Lovelace</h2>
<p>ENGINEER</p>
</div>
Read it from the top:
```(html):(none)opens the fence. The descriptor is(language):(format);none(no formatter) is the default, so```(html)means the same thing.{$ name $}inserts a value. The expression is an ordinary Loup expression, resolved in the lexical scope around the fence:nameis the function's parameter.{$ role | upper $}passes the value through theupperfilter.```on a line of its own closes the fence, and;ends thelet.- A fence is an expression whose value is a
Template.t.render({})turns it into astring.
The {$ … $} expressions are evaluated when the fence itself is evaluated,
not when render is called (Language Reference §1-16.4, ruling TPL-1). A
Template is a finished piece of text waiting to be collected.
Fence layout: the anchor rule
The fence body is indented with the code around it, and Loup strips that indentation for you. The rule (§1-16.1, shared with multiline strings) is:
- The anchor is the whitespace at the start of the line that holds the
opener. In Listing 14-1 that line is
let t = ```(html):(none), so the anchor is four spaces. - Every content line must start with the anchor; the anchor is removed.
- The closer is the anchor followed by exactly as many backticks as the opener, alone on its line.
The mistake everyone makes once is to write the closer flush left, the way Markdown fences look:
@mode(web.compiled)
func badge(label: string): string {
let t = ```(html):(none)
<span class="badge">{$ label $}</span>
```;
return t.render({});
}
func main(): void {
println(badge("new"));
}
main.lpweb:6:1: warning: line does not start with expected indentation
main.lpweb:8:1: warning: line does not start with expected indentation
main.lpweb:10:1: warning: line does not start with expected indentation
main.lpweb:12:1: warning: line does not start with expected indentation
main.lpweb:13:1: error[E0004]: unterminated template fence
main.lpweb:13:1: error[E0002]: expected closing ``` for template fence
main.lpweb:13:1: error[E0002]: expected `}`
A closer is only recognised at the anchor, so the flush-left ``` on line
6 is just content. The fence swallows the rest of the file, the compiler warns
about every line that lacks the four-space anchor, and it reports the fence as
unterminated at the end of the file (line 13). When you see E0004 far from
the fence you just wrote, look at its closer. Indent it to match the let
line:
@mode(web.compiled)
func badge(label: string): string {
let t = ```(html):(none)
<span class="badge">{$ label $}</span>
```;
return t.render({});
}
func main(): void {
println(badge("new"));
}
<span class="badge">new</span>
The language reference also allows a fence opened with four or more backticks,
so that the body can contain three-backtick lines (for example, Markdown that
shows code). Stage0 does not implement that yet: a four-backtick fence closes at
the first inner ``` line and the outer closer is a syntax error.
⚠ Planned. Fences of four or more backticks for nesting (§1-16.1) and the alternative delimiters (
expr_open,stmt_open, …).
Values in a fence
Every {$ … $} renders its value with the same rules println and str
use (§1-16.4):
@mode(web.compiled)
class Point {
pub let x: int;
pub let y: int;
}
enum Shape { Circle(r: float), Square }
func main(): void {
let some: Option[string] = Some("x");
let none: Option[string] = None;
let ok: Result[int, string] = Ok(3);
let p = Point(x = 1, y = 2);
let s = Shape.Circle(r = 2.0);
let t = ```(html):(none)
<p>[{$ some $}] [{$ none $}] [{$ ok $}] [{$ 2.5 $}] [{$ true $}]</p>
<p>[{$ p $}] [{$ s $}] [{$ Shape.Square $}]</p>
```;
println(t.render({}));
}
Listing 14-2: How values of different types render in a fence
<p>[x] [] [3] [2.5] [true]</p>
<p>[Point(x: 1, y: 2)] [Circle(2.0)] [Square]</p>
Some(v) and Ok(v) render as v, None as nothing, and class instances and
enum variants structurally. Three kinds of value are refused, each for a
reason:
- An
ErrthrowsRenderErrorwhere the fence is evaluated: an error should not quietly become page text. - A
Taskmust be awaited first:cannot insert Task[T] into template: use 'await' first. bytesand lists have no single obvious text form, so you choose one with a filter.
Here is the bytes case:
@mode(web.compiled)
func main(): void {
let avatar = b"hi";
let t = ```(html):(none)
<img alt="{$ avatar $}">
```;
println(t.render({}));
}
main.lpweb:6:15: error[E0001]: cannot render bytes in template; use a filter (base64, hex, utf8) to convert first
The message names the fix. Choose the encoding the receiving end expects:
@mode(web.compiled)
func main(): void {
let avatar = b"hi";
let t = ```(html):(none)
<img src="data:text/plain;base64,{$ avatar | base64 $}" title="{$ avatar | hex $}">
```;
println(t.render({}));
}
<img src="data:text/plain;base64,aGk=" title="6869">
A list gets the same treatment (type 'list[int]' is not renderable in template context); write {$ tags | join(", ") $}.
Fences see only their lexical scope
render takes a dict argument, and a reader coming from Jinja or Handlebars
expects it to supply the template's variables:
@mode(web.compiled)
func main(): void {
let t = ```(html):(none)
<p>Welcome back, {$ who $}</p>
```;
println(t.render({"who": "Ann"}));
}
main.lpweb:5:25: error[E0001]: undefined identifier `who`
A fence is checked like any other Loup expression, so who has to be in
scope where the fence is written. The dict passed to render does not
declare names. To reuse a fence with different data, put it in a function and
make the data parameters. A function can return the Template itself and let
the caller decide when to render it:
@mode(web.compiled)
func row(label: string, count: int): Template {
return ```(html):(none)
<tr><td>{$ label $}</td><td>{$ count $}</td></tr>
```;
}
func main(): void {
let rows = [row("apples", 3), row("pears", 5)];
var html = "<table>";
for (r in rows) {
html = html + r.render({});
}
println(html + "</table>");
}
Listing 14-3: A function that returns a Template
<table><tr><td>apples</td><td>3</td></tr><tr><td>pears</td><td>5</td></tr></table>
Filters
A filter post-processes the rendered text of an expression. Filters chain left to right, and some take arguments:
@mode(web.compiled)
func main(): void {
let tags = ["web", "wasm", "loup"];
let t = ```(html):(none)
<p>{$ tags | join(", ") $}</p>
<p>[{$ "" | default("n/a") $}]</p>
<p>{$ "A very long title indeed" | truncate(10) $}</p>
<p>{$ " pad " | trim $}|</p>
<p>{$ "hello world" | title $}</p>
<p>{$ "Mixed" | lower $} {$ "abc" | reverse $} {$ "héllo" | length $}</p>
<p>{$ "a-b-c" | replace("-", "+") $} {$ "x" | wrap("[", "]") $}</p>
<p>{$ "<b>Tom & Jerry</b>" | escape $}</p>
```;
println(t.render({}));
}
Listing 14-4: The built-in filters
<p>web, wasm, loup</p>
<p>[n/a]</p>
<p>A very lon…</p>
<p>pad|</p>
<p>Hello World</p>
<p>mixed cba 5</p>
<p>a+b+c [x]</p>
<p><b>Tom & Jerry</b></p>
| Filter | Effect |
|---|---|
upper, lower, title |
Change case (ASCII) |
trim, trim_start, trim_end, strip |
Remove whitespace |
escape, escape_html |
Encode &, <, >, ", ' as HTML entities |
join(sep) |
Join the items of a list |
default(fallback) |
Use fallback when the text is empty |
truncate(n) |
Keep n characters and add … |
replace(from, to), wrap(open, close) |
Edit the text |
reverse, length |
Reverse it, or render its length |
base64, hex, utf8 |
Turn bytes into text |
raw |
Insert unescaped (meaningful once auto-escaping ships) |
Any function whose first parameter is a string and that returns a string
is also a filter. Further arguments come from the call:
@mode(web.compiled)
func shout(value: string): string {
return value + "!";
}
func stars(value: string, n: int): string {
var out = value;
var i = 0;
while (i < n) {
out = out + "*";
i = i + 1;
}
return out;
}
func main(): void {
let t = ```(html):(none)
<p>{$ "hey" | shout $} {$ "rated" | stars(3) $} {$ "loud" | upper | shout $}</p>
```;
println(t.render({}));
}
Listing 14-5: User-defined filters
<p>hey! rated*** LOUD!</p>
A misspelled filter name is reported like any other unknown name, for
example error[E0001]: undefined identifier `shoutify` .
Directives
{% … %} runs template-level control flow. The working directives are
if / elif / else, for (x in xs), while, do (run an expression for
its side effect), and let / var; each block ends with {% end %}:
@mode(web.compiled)
func userList(users: list[string]): string {
let t = ```(html):(none)
{% if (users.len() == 0) %}
<p>No users yet.</p>
{% else %}
<ul>
{% for (u in users) %}
<li>{$ u $}</li>
{% end %}
</ul>
{% end %}
```;
return t.render({});
}
func main(): void {
println(userList(["ann", "bob"]).trim());
println(userList([]).trim());
}
Listing 14-6: if and for directives
<ul>
<li>ann</li>
<li>bob</li>
</ul>
<p>No users yet.</p>
(trim() removes the blank lines at the start and end.) The output is
correct HTML, but it has blank lines inside: a directive produces no text, and
the newline after it stays. Browsers ignore the extra whitespace.
If you are producing text where whitespace matters, put the directives on the
same line as the content:
@mode(web.compiled)
func main(): void {
let xs = [1, 2, 3];
var i = 0;
let t = ```(html):(none)
<p>{% for (x in xs) %}{% if (x == 2) %}<i>two</i>{% elif (x == 3) %}<i>three</i>{% else %}<i>{$ x $}</i>{% end %}{% end %}</p>
<p>{% while (i < 3) %}<b>{$ i $}</b>{% do i = i + 1; %}{% end %}</p>
<p>{% let total = xs.len() * 10; %}total: {$ total $}</p>
```;
println(t.render({}));
}
Listing 14-7: Inline directives with elif, while, do and let
<p><i>1</i><i>two</i><i>three</i></p>
<p><b>0</b><b>1</b><b>2</b></p>
<p>total: 30</p>
A bare expression is not allowed as a directive; write {% do expr; %}.
⚠ Planned. Three directive features from §1-16.2 do not work in Loup 1.25: the trim markers (
{$- x -$},{%- … -%}) are not recognised;{% match %}is not implemented in the template parser; and the buffered{% try %} … {% catch %}emitter parses but does not catch: an exception thrown inside it escapes the fence. Compute a value that might fail before the fence, and branch on the result with{% if %}.
Interpolated strings
For a one-line string, use an interpolated string instead of a fence. It
uses the same {$ … $} syntax and the same filters, but it is a string
directly. The i prefix is what turns interpolation on, and leaving it off is
not an error:
@mode(web.compiled)
func main(): void {
let name = "Ann";
println("Hello, {$ name $}!");
}
Hello, {$ name $}!
A plain "…" string is literal text, so the braces are printed. Add the
prefix:
@mode(web.compiled)
func main(): void {
let name = "Ann";
let unread = 3;
println(i"Hello, {$ name $}! You have {$ unread $} new messages.");
println(i"Status: {$ "online" | upper $}");
}
Hello, Ann! You have 3 new messages.
Status: ONLINE
Use i"…" for log lines, messages and short labels; use a fence for anything
with structure. One current difference matters for Wasm builds: in Loup
1.25.1 an i"…" string traps when the compiled module runs under
loup-launcher (call_dynamic: no exported function RenderContext), while
fences work there. Under loup run both work.
HTML escaping: specified, not yet implemented
The Language Reference makes html fences escape every inserted value
according to where it lands (§1-16.6.1):
| Context | Example position | Specified escaping |
|---|---|---|
| text | <p>{$ x $}</p> |
&, <, > become entities |
| attribute value | class="{$ x $}" |
also " and ' |
| URL attribute | href="{$ x $}" |
value must be a Url; javascript: and vbscript: rejected |
| raw | {$ x | raw $} |
none |
It also makes interpolation inside <script> or <style> a compile-time
error (E_SCRIPT_STYLE_INTERPOLATION) and requires quoted attribute values
(E_HTML_UNQUOTED_ATTR).
None of this is implemented in Loup 1.25. An html fence inserts the
rendered text exactly as it is, in every position:
@mode(web.compiled)
func main(): void {
let comment = "<img src=x onerror=alert(1)>";
let name = "x\"; alert(1); //";
let t = ```(html):(none)
<p>{$ comment $}</p>
<div data-user={$ name $}></div>
<a href="{$ "javascript:alert(1)" $}">profile</a>
<script>var user = "{$ name $}";</script>
```;
println(t.render({}));
}
Listing 14-8: An html fence does not escape anything today
<p><img src=x onerror=alert(1)></p>
<div data-user=x"; alert(1); //></div>
<a href="javascript:alert(1)">profile</a>
<script>var user = "x"; alert(1); //";</script>
Every line of that output is a cross-site-scripting hole if comment or
name came from a user, and the compiler accepted all four positions. Until
auto-escaping ships, follow three rules:
- Pipe every untrusted value through
escape. It encodes&,<,>,"and', which is enough for text and for quoted attribute values. - Never put an untrusted value in a URL attribute without checking its
scheme yourself;
escapedoes not makejavascript:safe. - Never interpolate into
<script>or<style>, and always quote attributes.
@mode(web.compiled)
func safeHref(raw: string): string {
let url = raw.trim().to_lowercase();
if (url.starts_with("https://") or url.starts_with("http://") or url.starts_with("/")) {
return raw;
}
return "#";
}
func link(target: string, label: string): string {
let t = ```(html):(none)
<a href="{$ safeHref(target) | escape $}">{$ label | escape $}</a>
```;
return t.render({});
}
func main(): void {
println(link("/docs?a=1&b=2", "Docs"));
println(link("JavaScript:alert(1)", "<b>win</b>"));
println(link("https://example.com/\"x", "Example"));
}
Listing 14-9: Escaping by hand until auto-escaping ships
<a href="/docs?a=1&b=2">Docs</a>
<a href="#"><b>win</b></a>
<a href="https://example.com/"x">Example</a>
safeHref allows only http, https and site-relative URLs, after trimming
and lower-casing so that JavaScript: and javascript: are caught too. The
label and the URL are then escaped for their quoted positions. When §1-16.6
ships, the fence does this itself and Url.of replaces safeHref.
The other fence languages (css, sql, text, …) are never escaped, even in
the design: a sql fence is a string builder, not a parameterised query. Do
not build SQL from user input with it.
⚠ Planned: SafeHtml, Url and the raw filter
⚠ Planned.
SafeHtml(lang.template),UrlandUrlError(core.text), the scheme checkE_URL_SCHEME_UNSAFE, the lint on a barestringin a URL attribute, and the lint onrawapplied to anything but aSafeHtmlor a constant (§1-16.6.4–§1-16.6.7) are not implemented. In Loup 1.25,SafeHtmlis an undefined identifier andcore.textis not a module.
The sketch below shows the designed shape of a blog-post renderer, for when those types arrive:
@mode(web.compiled)
from core.text import (Url);
from lang.template import (SafeHtml);
struct Post {
title: string;
slug: string;
author: string;
body: SafeHtml; # already-sanitised HTML from a Markdown renderer
tags: list[string];
}
func renderPost(post: Post): Template {
return ```(html):(none)
<article class="post">
<h1><a href="{$ Url.trusted(i"/posts/{$ post.slug $}") $}">{$ post.title $}</a></h1>
<p class="meta">By {$ post.author $}</p>
<ul class="tags">
{% for (tag in post.tags) %}
<li><a href="{$ Url.of(i"/tags/{$ tag $}") $}">#{$ tag $}</a></li>
{% end %}
</ul>
<section class="body">{$ post.body | raw $}</section>
</article>
```;
}
Listing 14-10: (design sketch) a renderer using Url and SafeHtml
In the design, title, author and tag are entity-escaped because they
land in text, Url.of validates a URL built at run time, Url.trusted marks
one the program built from constants, and | raw passes SafeHtml through
without a lint warning.
CSS fences
A css fence works today as a text template: its value is a Template, and
render gives you the CSS with the values filled in.
@mode(web.compiled)
func cardStyles(spacing: int, brand: string): string {
let styles = ```(css):(none)
.card {
padding: {$ spacing $}px;
border-color: {$ brand $};
border-width: {$ spacing // 4 $}px;
}
```;
return styles.render({});
}
func main(): void {
println(cardStyles(16, "#0066cc"));
}
Listing 14-11: A css fence used as a text template
.card {
padding: 16px;
border-color: #0066cc;
border-width: 4px;
}
Numbers are inserted without units, so write the unit after the $}.
The Language Reference designs much more for css (§1-16.7): the fence's
value is a StyleSheet, each top-level class becomes a ClassName property
with a collision-free hashed name, and toCSS() returns the compiled sheet.
If you write code from that section today, it fails at the first property
access:
@mode(web.compiled)
func main(): void {
let styles = ```(css):(none)
.container { padding: 16px; }
.button { font-size: 14px; }
```;
println(styles.container);
println(styles.toCSS());
}
main.lpweb:8:13: error[E0001]: type 'Template' does not support member access
main.lpweb:9:13: error[E0001]: type 'Template' has no method 'toCSS'
The value is a plain Template, which has render and nothing else. Until
StyleSheet ships, render the fence and choose your class names yourself, as
in Listing 14-11.
⚠ Planned.
StyleSheetandClassName(web.css), hash-scoped class names,styles.get("gridItem")for non-identifier selectors, collection ofClassNames during server-side rendering, and the shadow-root rule: acssfence evaluated in therender()method of a@webcomponentclass is attached to the element's shadow root instead of being name-mangled (§1-16.7.1, Chapter 17).
Summary
- A template fence
```(language):(format)…```is an expression whose value is aTemplate;render({})turns it into astring. - The fence body and closer must start with the anchor, the indentation of
the opener's line. A misplaced closer shows up as
E0004 unterminated template fenceat the end of the file. {$ expr $}takes any expression in lexical scope;render'sdictdoes not supply names. Pass data in as function parameters.Option,Result, numbers, classes and enums render asprintlnrenders them;ErrthrowsRenderError;bytes, lists andTasks must be converted (base64,join,await) first.- Built-in and user-defined filters work in fences and in
i"…"strings. A plain"…"string does not interpolate. if/elif/else,for,while,doandletdirectives work; trim markers,{% match %}and the{% try %}emitter do not yet.htmlfences do not escape in Loup 1.25. Use| escapeon untrusted values in text and quoted attributes, check URL schemes yourself, and never interpolate into<script>or<style>. Auto-escaping,SafeHtml,Url,StyleSheetandClassNameare planned.