Składnia szablonów
Wszystko, co rozumie parser, mieści się na tej stronie. Jest pięć konstrukcji — interpolacja, bloki, filtry, partiale i komentarze — i żadnego sposobu na osadzenie dowolnego JavaScriptu.
Interpolacja
Podwójne nawiasy klamrowe wstawiają wartość. Wynik jest escapowany jako HTML, a reguły zależą od tego, gdzie stoi interpolacja.
<p>{{user.name}}</p> // text context
<a href="{{link}}">…</a> // URL context
<div style="width:{{w}}px">…</div> // style context
W kontekście URL wartość, której schemat nie jest http, https, mailto ani ścieżką względną, jest zastępowana przez about:blank. Celowo nie da się tego skonfigurować.
Wyłączanie escapowania
Potrójne nawiasy wstawiają wartość dosłownie:
<div class="body">{{{post.renderedHtml}}}</div>
Potrójne nawiasy to jedyne miejsce, w którym Verso może wytworzyć wstrzyknięcie. Kompiluj z { allowRaw: false }, by uczynić je błędem kompilacji w całym projekcie — przydatne, gdy szablony piszą osoby, które nie robią przeglądów bezpieczeństwa.
Warunki
Bloki otwiera #, a zamyka /. Warunki przyjmują ścieżkę, porównanie albo ścieżkę przepuszczoną przez filtry — nic więcej.
{{#if user}}
Welcome back.
{{#elseif invite.valid}}
You were invited by {{invite.from}}.
{{#else}}
<a href="/sign-in/">Sign in</a>
{{/if}}
Prawdziwość działa jak w JavaScripcie, z jedną zmianą: pusta tablica jest fałszywa. Na tę decyzję złożyło się sześć lat błędów w szablonach.
Operatory porównania to ==, !=, <, <=, >, >= i in. Równość jest ścisła; nie ma luźnego wariantu.
{{#if order.status == 'shipped'}}…{{/if}}
{{#if role in allowedRoles}}…{{/if}}
{{#unless flags.beta}}…{{/unless}}
Pętle
#each iteruje po tablicach, obiektach Map, Set i zwykłych obiektach. Opcjonalna klauzula as nazywa element i, jako drugie, indeks lub klucz.
{{#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}}
Gałąź #else pętli wykonuje się, gdy kolekcja jest pusta lub jej brak. W treści dostępne są cztery zmienne:
| Zmienna | Znaczenie |
|---|---|
@index | Pozycja liczona od zera |
@first | true w pierwszej iteracji |
@last | true w ostatniej iteracji |
@length | Łączna liczba elementów |
Zagnieżdżone pętle przesłaniają te zmienne. Aby dostać się do zewnętrznej, nazwij ją w klauzuli as — Verso celowo nie ma składni ../ dla ścieżek nadrzędnych.
Filtry
Kreska pionowa stosuje filtr. Filtry łączą się od lewej do prawej i przyjmują argumenty pozycyjne po dwukropku.
{{title | upper}}
{{post.body | strip | truncate: 280, '…'}}
{{price | number: 2 | prepend: '€'}}
Wbudowanych filtrów jest osiemnaście:
| Filtr | Argumenty | Działanie |
|---|---|---|
upper / lower | — | Zmiana wielkości liter, z uwzględnieniem języka |
capitalize | — | Pierwsza litera wielka, reszta bez zmian |
trim / strip | — | Przytnij białe znaki / usuń wszystkie znaczniki HTML |
truncate | długość, wielokropek | Przycina na granicy słowa |
number | miejsca po przecinku | Stała liczba miejsc po przecinku z grupowaniem |
date | wzorzec, strefa czasowa | Formatuje Date lub łańcuch ISO |
join | separator | Łączy tablicę |
sort | klucz | Stabilne sortowanie, opcjonalnie po właściwości |
reverse / unique | — | Pomocnicze dla tablic |
first / last | liczba | Wycinek z dowolnego końca |
default | wartość | Zastępuje pusty, null lub undefined |
json | wcięcie | Serializuje, bezpiecznie wewnątrz <script> |
prepend / append | tekst | Łączenie |
Własne filtry
Zarejestruj własne przy kompilacji. Filtr to czysta funkcja; najpierw dostaje wartość, potem argumenty.
const page = await compileFile('views/page.vs', {
filters: {
money: (v, currency = 'EUR') =>
new Intl.NumberFormat('en', { style: 'currency', currency }).format(v)
}
});
Wynik filtrów jest escapowany jak każda inna wartość. Zwrócenie znaczników z filtra wymaga opakowania ich w raw(), eksportowane właśnie w tym celu i łatwe do znalezienia podczas recenzji.
Partiale i sloty
> dołącza inny szablon. Ścieżki są rozwiązywane względem pliku dołączającego.
{{> './partials/card.vs'}}
{{> './partials/card.vs' with item}}
{{> './partials/card.vs' with { title: post.name, tone: 'muted' }}
Bez with partial widzi bieżący obiekt danych. Z tą klauzulą widzi tylko to, co mu przekażesz — dzięki temu partiale można testować w izolacji.
Układy
Układy to partiale z nazwanymi slotami:
<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}}
Treść wewnątrz #slot jest zastępcza, gdy żaden #fill nie pasuje. Szablon może rozszerzać dokładnie jeden układ, a układy mogą rozszerzać inne układy.
Odroczone bloki
W trybie strumieniowym #defer oznacza blok, którego dane nie są jeszcze gotowe. Otaczająca strona jest wysyłana od razu, a blok przychodzi, gdy jego obietnica się rozstrzygnie.
<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>
Poza renderToStream() #defer po prostu czeka na obietnicę w miejscu. Gałąź #pending nigdy nie jest wypisywana, a odrzucona obietnica renderuje #failed zamiast rzucać wyjątek.
Białe znaki i komentarze
Myślnik wewnątrz nawiasów przycina sąsiednie białe znaki, co ma znaczenie przy generowaniu czegokolwiek poza HTML:
{{#each rows as r -}}
{{r.a}},{{r.b}}
{{/each}}
Komentarze nigdy nie trafiają do wyniku:
{{!-- reviewed 2026-02-14, keep in sync with the API schema --}}