Syntaxe des templates
Tout ce que comprend le parseur tient sur cette page. Il y a cinq constructions — interpolation, blocs, filtres, partials et commentaires — et aucun moyen d’insérer du JavaScript arbitraire.
Interpolation
Les doubles accolades insèrent une valeur. Le résultat est échappé en HTML, avec des règles choisies selon l’endroit où se trouve l’interpolation.
<p>{{user.name}}</p> // text context
<a href="{{link}}">…</a> // URL context
<div style="width:{{w}}px">…</div> // style context
Dans un contexte d’URL, une valeur dont le schéma n’est ni http, ni https, ni mailto, ni un chemin relatif est remplacée par about:blank. Ce n’est volontairement pas configurable.
Désactiver l’échappement
Les triples accolades insèrent la valeur telle quelle :
<div class="body">{{{post.renderedHtml}}}</div>
Les triples accolades sont le seul endroit où Verso peut produire une injection. Compilez avec { allowRaw: false } pour en faire une erreur de compilation dans tout le projet — utile quand les templates sont écrits par des personnes qui ne font pas de revue de sécurité.
Conditions
Les blocs s’ouvrent avec # et se ferment avec /. Les conditions acceptent un chemin, une comparaison ou un chemin passé dans des filtres — rien d’autre.
{{#if user}}
Welcome back.
{{#elseif invite.valid}}
You were invited by {{invite.from}}.
{{#else}}
<a href="/sign-in/">Sign in</a>
{{/if}}
La véracité suit JavaScript, à une différence près : un tableau vide est faux. Six ans de bugs de templates ont mené à cette décision.
Les opérateurs de comparaison sont ==, !=, <, <=, >, >= et in. L’égalité est stricte ; il n’existe pas de variante souple.
{{#if order.status == 'shipped'}}…{{/if}}
{{#if role in allowedRoles}}…{{/if}}
{{#unless flags.beta}}…{{/unless}}
Boucles
#each parcourt les tableaux, les Map, les Set et les objets simples. La clause facultative as nomme l’élément et, en second, l’indice ou la clé.
{{#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}}
La branche #else d’une boucle s’exécute quand la collection est vide ou absente. Dans le corps, quatre variables sont disponibles :
| Variable | Signification |
|---|---|
@index | Position à partir de zéro |
@first | true à la première itération |
@last | true à la dernière itération |
@length | Nombre total d’éléments |
Les boucles imbriquées masquent ces variables. Pour atteindre celles d’une boucle extérieure, nommez-la dans la clause as — Verso n’a volontairement pas de syntaxe ../ pour les chemins parents.
Filtres
Un tube applique un filtre. Les filtres s’enchaînent de gauche à droite et prennent des arguments positionnels après deux-points.
{{title | upper}}
{{post.body | strip | truncate: 280, '…'}}
{{price | number: 2 | prepend: '€'}}
Dix-huit filtres sont intégrés :
| Filtre | Arguments | Effet |
|---|---|---|
upper / lower | — | Changement de casse, selon la langue |
capitalize | — | Première lettre en majuscule, le reste inchangé |
trim / strip | — | Supprimer les espaces / retirer toutes les balises HTML |
truncate | longueur, points de suspension | Coupe sur une limite de mot |
number | décimales | Nombre fixe de décimales avec séparateur de milliers |
date | motif, fuseau horaire | Formate une Date ou une chaîne ISO |
join | séparateur | Joint un tableau |
sort | clé | Tri stable, éventuellement par propriété |
reverse / unique | — | Utilitaires de tableau |
first / last | nombre | Extrait depuis l’une ou l’autre extrémité |
default | valeur | Remplace vide, null ou undefined |
json | indentation | Sérialise, sans risque dans <script> |
prepend / append | texte | Concaténation |
Filtres personnalisés
Déclarez les vôtres à la compilation. Un filtre est une fonction pure ; il reçoit d’abord la valeur, puis les arguments.
const page = await compileFile('views/page.vs', {
filters: {
money: (v, currency = 'EUR') =>
new Intl.NumberFormat('en', { style: 'currency', currency }).format(v)
}
});
La sortie des filtres est échappée comme toute autre valeur. Pour renvoyer du balisage depuis un filtre, il faut l’envelopper dans raw(), exporté précisément dans ce but et facile à repérer en revue.
Partials et slots
> inclut un autre template. Les chemins sont résolus par rapport au fichier qui inclut.
{{> './partials/card.vs'}}
{{> './partials/card.vs' with item}}
{{> './partials/card.vs' with { title: post.name, tone: 'muted' }}
Sans with, le partial voit l’objet de données courant. Avec cette clause, il ne voit que ce que vous lui passez — ce qui permet de tester les partials isolément.
Mises en page
Les mises en page sont des partials avec des slots nommés :
<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}}
Le contenu de #slot sert de valeur par défaut quand aucun #fill ne correspond. Un template peut étendre une seule mise en page, et les mises en page peuvent en étendre d’autres.
Blocs différés
En mode streaming, #defer marque un bloc dont les données ne sont pas encore prêtes. La page autour est envoyée immédiatement et le bloc arrive quand sa promesse est réglée.
<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>
En dehors de renderToStream(), #defer attend simplement la promesse sur place. La branche #pending n’est jamais émise, et une promesse rejetée produit #failed au lieu de lever une exception.
Espaces et commentaires
Un tiret à l’intérieur des accolades supprime les espaces adjacents, ce qui compte dès qu’on génère autre chose que du HTML :
{{#each rows as r -}}
{{r.a}},{{r.b}}
{{/each}}
Les commentaires n’atteignent jamais la sortie :
{{!-- reviewed 2026-02-14, keep in sync with the API schema --}}