Templatesyntaxis
Alles wat de parser begrijpt, past op deze pagina. Er zijn vijf constructies — interpolatie, blokken, filters, partials en commentaar — en geen manier om willekeurig JavaScript in te voegen.
Interpolatie
Dubbele accolades voegen een waarde in. Het resultaat wordt HTML-geëscapet, met regels die afhangen van waar de interpolatie staat.
<p>{{user.name}}</p> // text context
<a href="{{link}}">…</a> // URL context
<div style="width:{{w}}px">…</div> // style context
In URL-context wordt een waarde waarvan het schema niet http, https, mailto of een relatief pad is, vervangen door about:blank. Dit is bewust niet instelbaar.
Escaping uitschakelen
Drievoudige accolades voegen de waarde letterlijk in:
<div class="body">{{{post.renderedHtml}}}</div>
Drievoudige accolades zijn de enige plek waar Verso een injectie kan veroorzaken. Compileer met { allowRaw: false } om ze in een heel project een compileerfout te maken — handig als templates worden geschreven door mensen die geen beveiligingsreviews doen.
Voorwaarden
Blokken openen met # en sluiten met /. Voorwaarden accepteren een pad, een vergelijking of een pad door filters — verder niets.
{{#if user}}
Welcome back.
{{#elseif invite.valid}}
You were invited by {{invite.from}}.
{{#else}}
<a href="/sign-in/">Sign in</a>
{{/if}}
Waarheid volgt JavaScript met één verandering: een lege array is falsy. Er zijn zes jaar templatebugs in die beslissing gegaan.
Vergelijkingsoperatoren zijn ==, !=, <, <=, >, >= en in. Gelijkheid is strikt; er is geen losse variant.
{{#if order.status == 'shipped'}}…{{/if}}
{{#if role in allowedRoles}}…{{/if}}
{{#unless flags.beta}}…{{/unless}}
Lussen
#each doorloopt arrays, Maps, Sets en gewone objecten. De optionele clausule as geeft het element een naam en, als tweede, de index of sleutel.
{{#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}}
De tak #else van een lus draait als de collectie leeg is of ontbreekt. In de body zijn vier variabelen beschikbaar:
| Variabele | Betekenis |
|---|---|
@index | Positie, vanaf nul |
@first | true bij de eerste iteratie |
@last | true bij de laatste iteratie |
@length | Totaal aantal elementen |
Geneste lussen schaduwen deze variabelen. Om bij een buitenste te komen, geef je die een naam in de clausule as — Verso heeft bewust geen ../-syntaxis voor ouderpaden.
Filters
Een pijp past een filter toe. Filters worden van links naar rechts geketend en nemen positionele argumenten na een dubbele punt.
{{title | upper}}
{{post.body | strip | truncate: 280, '…'}}
{{price | number: 2 | prepend: '€'}}
Er zijn achttien ingebouwde filters:
| Filter | Argumenten | Doet |
|---|---|---|
upper / lower | — | Hoofdletters/kleine letters, taalafhankelijk |
capitalize | — | Eerste letter hoofdletter, rest ongewijzigd |
trim / strip | — | Witruimte inkorten / alle HTML-tags verwijderen |
truncate | lengte, weglatingsteken | Knipt af op een woordgrens |
number | decimalen | Vast aantal decimalen met groepering |
date | patroon, tijdzone | Formatteert een Date of ISO-string |
join | scheidingsteken | Voegt een array samen |
sort | sleutel | Stabiele sortering, optioneel op eigenschap |
reverse / unique | — | Arrayhelpers |
first / last | aantal | Stuk vanaf een van beide uiteinden |
default | waarde | Vervangt leeg, null of undefined |
json | inspringing | Serialiseert, veilig binnen <script> |
prepend / append | tekst | Samenvoegen |
Eigen filters
Registreer je eigen filters bij het compileren. Een filter is een pure functie; die krijgt eerst de waarde en daarna de argumenten.
const page = await compileFile('views/page.vs', {
filters: {
money: (v, currency = 'EUR') =>
new Intl.NumberFormat('en', { style: 'currency', currency }).format(v)
}
});
De uitvoer van filters wordt geëscapet zoals elke andere waarde. Om markup uit een filter terug te geven, moet je het in raw() verpakken, dat precies daarvoor wordt geëxporteerd en in een review makkelijk te vinden is.
Partials en slots
> voegt een ander template in. Paden worden opgelost relatief aan het invoegende bestand.
{{> './partials/card.vs'}}
{{> './partials/card.vs' with item}}
{{> './partials/card.vs' with { title: post.name, tone: 'muted' }}
Zonder with ziet het partial het huidige dataobject. Met die clausule ziet het alleen wat je meegeeft — waardoor partials los te testen zijn.
Layouts
Layouts zijn partials met benoemde 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}}
Inhoud binnen #slot is de reserve als geen #fill past. Een template mag precies één layout uitbreiden, en layouts mogen layouts uitbreiden.
Uitgestelde blokken
In streamingmodus markeert #defer een blok waarvan de data nog niet klaar is. De omringende pagina wordt meteen doorgestuurd en het blok komt zodra zijn promise is afgehandeld.
<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>
Buiten renderToStream() wacht #defer gewoon ter plekke op de promise. De tak #pending wordt nooit uitgevoerd, en een afgewezen promise rendert #failed in plaats van een fout te gooien.
Witruimte en commentaar
Een streepje binnen de accolades haalt aangrenzende witruimte weg, wat belangrijk is als je iets anders dan HTML genereert:
{{#each rows as r -}}
{{r.a}},{{r.b}}
{{/each}}
Commentaar komt nooit in de uitvoer:
{{!-- reviewed 2026-02-14, keep in sync with the API schema --}}