Référence

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>
Dangereux par définition

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 :

VariableSignification
@indexPosition à partir de zéro
@firsttrue à la première itération
@lasttrue à la dernière itération
@lengthNombre total d’éléments
Remarque

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 :

FiltreArgumentsEffet
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
truncatelongueur, points de suspensionCoupe sur une limite de mot
numberdécimalesNombre fixe de décimales avec séparateur de milliers
datemotif, fuseau horaireFormate une Date ou une chaîne ISO
joinséparateurJoint un tableau
sortcléTri stable, éventuellement par propriété
reverse / unique—Utilitaires de tableau
first / lastnombreExtrait depuis l’une ou l’autre extrémité
defaultvaleurRemplace vide, null ou undefined
jsonindentationSérialise, sans risque dans <script>
prepend / appendtexteConcaté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 :

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

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>
Rendu dans une chaîne

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