Template-Syntax
Alles, was der Parser versteht, passt auf diese Seite. Es gibt fünf Konstrukte — Interpolation, Blöcke, Filter, Partials und Kommentare — und keine Möglichkeit, beliebiges JavaScript einzubetten.
Interpolation
Doppelte geschweifte Klammern fügen einen Wert ein. Das Ergebnis wird HTML-escaped, wobei die Escaping-Regeln danach gewählt werden, wo die Interpolation steht.
<p>{{user.name}}</p> // text context
<a href="{{link}}">…</a> // URL context
<div style="width:{{w}}px">…</div> // style context
Im URL-Kontext wird ein Wert, dessen Schema nicht http, https, mailto oder ein relativer Pfad ist, durch about:blank ersetzt. Das ist bewusst nicht konfigurierbar.
Escaping abschalten
Dreifache Klammern fügen den Wert unverändert ein:
<div class="body">{{{post.renderedHtml}}}</div>
Dreifache Klammern sind die einzige Stelle, an der Verso eine Injection erzeugen kann. Kompilieren Sie mit { allowRaw: false }, um sie im ganzen Projekt zum Kompilierfehler zu machen — nützlich, wenn Templates von Leuten geschrieben werden, die keine Sicherheitsreviews machen.
Bedingungen
Blöcke öffnen mit # und schließen mit /. Bedingungen akzeptieren einen Pfad, einen Vergleich oder einen Pfad durch Filter — sonst nichts.
{{#if user}}
Welcome back.
{{#elseif invite.valid}}
You were invited by {{invite.from}}.
{{#else}}
<a href="/sign-in/">Sign in</a>
{{/if}}
Die Wahrheitswerte folgen JavaScript mit einer Änderung: Ein leeres Array ist falsy. In diese Entscheidung sind sechs Jahre Template-Bugs eingeflossen.
Vergleichsoperatoren sind ==, !=, <, <=, >, >= und in. Gleichheit ist strikt; eine lockere Variante gibt es nicht.
{{#if order.status == 'shipped'}}…{{/if}}
{{#if role in allowedRoles}}…{{/if}}
{{#unless flags.beta}}…{{/unless}}
Schleifen
#each iteriert über Arrays, Maps, Sets und einfache Objekte. Die optionale Klausel as benennt das Element und an zweiter Stelle den Index oder Schlüssel.
{{#each releases as release, i}}
<li class="{{#if i == 0}}latest{{/if}}">
{{release.version}} — {{release.date | date: 'MMM yyyy'}}
</li>
{{#else}}
<li>No releases yet.</li>
{{/each}}
Der Zweig #else einer Schleife läuft, wenn die Sammlung leer ist oder fehlt. Im Rumpf stehen vier Variablen zur Verfügung:
| Variable | Bedeutung |
|---|---|
@index | Position, ab null gezählt |
@first | true im ersten Durchlauf |
@last | true im letzten Durchlauf |
@length | Gesamtzahl der Elemente |
Verschachtelte Schleifen verdecken diese Variablen. Um eine äußere zu erreichen, benennen Sie sie in der Klausel as — eine ../-Syntax für Elternpfade hat Verso absichtlich nicht.
Filter
Ein Pipe-Zeichen wendet einen Filter an. Filter werden von links nach rechts verkettet und nehmen Positionsargumente nach einem Doppelpunkt.
{{title | upper}}
{{post.body | strip | truncate: 280, '…'}}
{{price | number: 2 | prepend: '€'}}
Achtzehn Filter sind eingebaut:
| Filter | Argumente | Wirkung |
|---|---|---|
upper / lower | — | Groß-/Kleinschreibung, gebietsschemaabhängig |
capitalize | — | Erster Buchstabe groß, Rest unverändert |
trim / strip | — | Leerraum kürzen / alle HTML-Tags entfernen |
truncate | Länge, Auslassung | Kürzt an einer Wortgrenze |
number | Nachkommastellen | Feste Nachkommastellen mit Tausendertrennung |
date | Muster, Zeitzone | Formatiert ein Date oder einen ISO-String |
join | Trennzeichen | Verbindet ein Array |
sort | Schlüssel | Stabile Sortierung, optional nach Eigenschaft |
reverse / unique | — | Array-Helfer |
first / last | Anzahl | Ausschnitt von einem der Enden |
default | Wert | Ersetzt leere Werte, null oder undefined |
json | Einrückung | Serialisiert, sicher innerhalb von <script> |
prepend / append | Text | Verkettung |
Eigene Filter
Registrieren Sie eigene beim Kompilieren. Ein Filter ist eine reine Funktion; er bekommt zuerst den Wert und danach die Argumente.
const page = await compileFile('views/page.vs', {
filters: {
money: (v, currency = 'EUR') =>
new Intl.NumberFormat('en', { style: 'currency', currency }).format(v)
}
});
Die Ausgabe von Filtern wird escaped wie jeder andere Wert. Um aus einem Filter Markup zurückzugeben, muss es in raw() verpackt werden, das genau dafür exportiert wird und sich im Review leicht per grep finden lässt.
Partials und Slots
> bindet ein anderes Template ein. Pfade werden relativ zur einbindenden Datei aufgelöst.
{{> './partials/card.vs'}}
{{> './partials/card.vs' with item}}
{{> './partials/card.vs' with { title: post.name, tone: 'muted' }}
Ohne with sieht das Partial das aktuelle Datenobjekt. Mit dieser Klausel sieht es nur, was Sie übergeben — dadurch lassen sich Partials isoliert testen.
Layouts
Layouts sind Partials mit benannten Slots:
<main>{{#slot}}{{/slot}}</main>
<aside>{{#slot 'sidebar'}}Nothing here.{{/slot}}</aside>
{{#extends '../layouts/base.vs'}}
{{#fill}}<article>{{{post.html}}}</article>{{/fill}}
{{#fill 'sidebar'}}{{> '../partials/toc.vs'}}{{/fill}}
{{/extends}}
Inhalt innerhalb von #slot ist der Ersatz, wenn kein #fill passt. Ein Template darf genau ein Layout erweitern, und Layouts dürfen Layouts erweitern.
Verzögerte Blöcke
Im Streaming-Modus markiert #defer einen Block, dessen Daten noch nicht bereit sind. Die umgebende Seite wird sofort ausgegeben, und der Block folgt, sobald sein Promise erfüllt ist.
<section>
{{#defer recommendations as list}}
{{#each list as item}}<li>{{item.title}}</li>{{/each}}
{{#pending}}
<p class="skeleton">Loading recommendations…</p>
{{#failed as err}}
<p>Could not load recommendations.</p>
{{/defer}}
</section>
Außerhalb von renderToStream() wartet #defer einfach an Ort und Stelle auf das Promise. Der Zweig #pending wird nie ausgegeben, und ein abgelehntes Promise rendert #failed, statt eine Ausnahme zu werfen.
Leerraum und Kommentare
Ein Bindestrich in den Klammern entfernt angrenzenden Leerraum, was bei allem außer HTML wichtig ist:
{{#each rows as r -}}
{{r.a}},{{r.b}}
{{/each}}
Kommentare erreichen die Ausgabe nie:
{{!-- reviewed 2026-02-14, keep in sync with the API schema --}}