The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  4. Chapter 4: Functions
  5. Chapter 5: Control Flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and Pattern Matching
  9. Chapter 9: Classes and Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

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, the if / for / while / let directives, and Template values. Fences render the same way under loup run and in a Wasm build run by loup-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, html fences do not escape anything, and you must escape user data yourself with the escape filter. 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: name is the function's parameter.
  • {$ role | upper $} passes the value through the upper filter.
  • ``` on a line of its own closes the fence, and ; ends the let.
  • A fence is an expression whose value is a Template. t.render({}) turns it into a string.

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:

This code does not compile
@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 Err throws RenderError where the fence is evaluated: an error should not quietly become page text.
  • A Task must be awaited first: cannot insert Task[T] into template: use 'await' first.
  • bytes and lists have no single obvious text form, so you choose one with a filter.

Here is the bytes case:

This code does not compile
@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:

This code does not compile
@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>&lt;b&gt;Tom &amp; Jerry&lt;/b&gt;</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:

This code does not do what it should
@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:

This code does not do what it should
@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:

  1. Pipe every untrusted value through escape. It encodes &, <, >, " and ', which is enough for text and for quoted attribute values.
  2. Never put an untrusted value in a URL attribute without checking its scheme yourself; escape does not make javascript: safe.
  3. 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&amp;b=2">Docs</a>
<a href="#">&lt;b&gt;win&lt;/b&gt;</a>
<a href="https://example.com/&quot;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), Url and UrlError (core.text), the scheme check E_URL_SCHEME_UNSAFE, the lint on a bare string in a URL attribute, and the lint on raw applied to anything but a SafeHtml or a constant (§1-16.6.4–§1-16.6.7) are not implemented. In Loup 1.25, SafeHtml is an undefined identifier and core.text is not a module.

The sketch below shows the designed shape of a blog-post renderer, for when those types arrive:

Fragment — not a complete program
@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:

This code does not compile
@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. StyleSheet and ClassName (web.css), hash-scoped class names, styles.get("gridItem") for non-identifier selectors, collection of ClassNames during server-side rendering, and the shadow-root rule: a css fence evaluated in the render() method of a @webcomponent class 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 a Template; render({}) turns it into a string.
  • 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 fence at the end of the file.
  • {$ expr $} takes any expression in lexical scope; render's dict does not supply names. Pass data in as function parameters.
  • Option, Result, numbers, classes and enums render as println renders them; Err throws RenderError; bytes, lists and Tasks 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, do and let directives work; trim markers, {% match %} and the {% try %} emitter do not yet.
  • html fences do not escape in Loup 1.25. Use | escape on untrusted values in text and quoted attributes, check URL schemes yourself, and never interpolate into <script> or <style>. Auto-escaping, SafeHtml, Url, StyleSheet and ClassName are planned.