Paragon

Template helper reference

Everything a template can write, with the output each example actually produces. Every example on this page is asserted in HelperReferenceTests — a reference that drifts from the code is worse than no reference, because someone will follow it.

The data used throughout:

{
  "customer": { "name": "Acme Ltd", "vatNumber": null },
  "invoice": {
    "number": "INV-1001",
    "issuedOn": "2026-08-12T00:00:00",
    "total": 1875.5,
    "paid": false,
    "lines": [
      { "description": "Consulting", "amount": 1500, "hours": 10 },
      { "description": "Support", "amount": 375.5, "hours": 3 }
    ]
  }
}

Values

Written Renders
{{customer.name}} Acme Ltd
{{invoice.lines.[0].description}} Consulting
{{customer.nickname}} (nothing)
{{invoice.lines.Count}} 2

An unknown path renders as nothing. There is no input schema by design, so a field the caller did not send is empty rather than an error — a typo costs you a blank, not a failed report.

An unknown helper is not so forgiving. {{t "invoice.title"}} fails the whole report with TemplateBindingException naming the helper. Paths are lenient; helper names are not. Note Count, not length — the model is bound as .NET objects, and length returns nothing.

{{ }} escapes. {{{ }}} does not — see Security before reaching for it.


Conditionals

Written Renders
{{#if invoice.paid}}paid{{else}}due{{/if}} due
{{#unless invoice.paid}}due{{/unless}} due
{{#if customer.vatNumber}}vat{{else}}none{{/if}} none

null, false, 0, an empty string and an empty array are all falsy.

Because an attribute value is just text, this is also how conditional styling works:

<tr class="{{#if invoice.paid}}paid{{else}}unpaid{{/if}}">

renders <tr class="unpaid">. The same trick works for an inline style, a colspan, anything.


Comparisons and logic

These return values rather than writing, so they work as subexpressions inside a condition:

Helper Example Renders
gt gte lt lte {{#if (gt invoice.total 1000)}}large{{else}}small{{/if}} large
eq ne {{#if (eq customer.name "Acme Ltd")}}yes{{/if}} yes
lte {{#if (lte invoice.total 1875.5)}}within{{/if}} within
and not {{#if (and (gt invoice.total 1000) (not invoice.paid))}}chase{{/if}} chase
or {{#if (or invoice.paid (gt invoice.total 5000))}}quiet{{else}}chase{{/if}} chase

Nest them freely — the brackets are what make it a subexpression rather than an argument.


Iteration

Written Renders
{{#each invoice.lines}}{{description}};{{/each}} Consulting;Support;
{{#each invoice.lines}}{{@index}}{{/each}} 01
{{#each invoice.lines}}{{#unless @last}}{{description}}, {{/unless}}{{/each}} Consulting,
{{#with customer}}{{name}}{{/with}} Acme Ltd

Inside #each: @index (from 0), @first, @last, @key for objects, and this for the current item. @last is what stops a trailing separator — or a trailing page break, which is a blank final page.


Numbers, money and dates

Written Renders
{{number invoice.total "N2" "en-US"}} 1,875.50
{{money invoice.total "en-US"}} $1,875.50
{{date invoice.issuedOn "dd MMM yyyy" "en-GB"}} 12 Aug 2026
{{date invoice.issuedOn "d" "en-US"}} 8/12/2026

The second argument is a culture if it contains a hyphen, and a format otherwise — so {{money x "en-US"}} and {{money x "C2" "en-US"}} both work.

Three traps, all covered in Pagination and right-to-left:

  • {{number}} without a format does not group. The default is G, the round-trip format: 1234567.5 renders as 1234567.5. Ask for "N2".
  • ar-SA returns Hijri dates. Use ar-EG or ar-AE for Gregorian output in Arabic.
  • No culture produces Arabic-Indic digits. They change separators only.

Formats are standard .NET format strings, so anything valid in ToString(format, culture) works.


Getting data into JavaScript

<script id="report-data" type="application/json">{{json}}</script>

Put this in the template, not in a page. The data belongs to the report, and the template is merged into every page — so writing it once puts a copy in each, already bound. A new report is created with it. {{json invoice.lines}} writes a slice instead of the whole payload; if a page ever needs its own, give that island a different id, because getElementById answers with the first of two.

renders the model as JSON — [{"description":"Consulting","amount":1500,…}] — for chart code to read:

const model = JSON.parse(document.getElementById('report-data').textContent);

This is the sanctioned route. Building JavaScript string literals out of {{ }} is not, and the exporter flags it: the encoder used here is what stops a value containing </script> from ending the island early, and string concatenation has no such protection.

{{{json .}}} writes the whole model.


Page breaks

{{pageBreak}}

renders <div style="break-after: page"></div>. Use it when the break comes from the data:

{{#each invoices}}
  <h1>{{number}}</h1>
  {{#unless @last}}{{pageBreak}}{{/unless}}
{{/each}}

When the break is structural, prefer CSS — section { break-before: page } — and see Pagination and right-to-left for the rest of it.


What is not here

  • No t / localization helper. Reports needing two languages currently need two templates.
  • No arithmetic. Compute totals in the data rather than in the template.
  • No partials or template inclusion. One report is one file.
  • No date arithmetic. Send the dates you intend to print.

The engine binds what it is given. Anything the report has to work out belongs in the application that calls it, where it can be tested.