Referentie

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>
Per definitie onveilig

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:

VariabeleBetekenis
@indexPositie, vanaf nul
@firsttrue bij de eerste iteratie
@lasttrue bij de laatste iteratie
@lengthTotaal aantal elementen
Let op

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:

FilterArgumentenDoet
upper / lower—Hoofdletters/kleine letters, taalafhankelijk
capitalize—Eerste letter hoofdletter, rest ongewijzigd
trim / strip—Witruimte inkorten / alle HTML-tags verwijderen
truncatelengte, weglatingstekenKnipt af op een woordgrens
numberdecimalenVast aantal decimalen met groepering
datepatroon, tijdzoneFormatteert een Date of ISO-string
joinscheidingstekenVoegt een array samen
sortsleutelStabiele sortering, optioneel op eigenschap
reverse / unique—Arrayhelpers
first / lastaantalStuk vanaf een van beide uiteinden
defaultwaardeVervangt leeg, null of undefined
jsoninspringingSerialiseert, veilig binnen <script>
prepend / appendtekstSamenvoegen

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:

layouts/base.vs
<main>{{#slot}}{{/slot}}</main>
<aside>{{#slot 'sidebar'}}Nothing here.{{/slot}}</aside>
views/article.vs
{{#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>
Renderen naar een string

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 --}}