NgSharp net8.0 · netstandard2.1 v3.0.0

Documentation

Angular-style HTML templates, interpreted.

Bind a model into HTML with {{ }}, @if/@for, pipes, directives, fragments and server components. No runtime compilation — so it renders under Native AOT, trimming and serverless, where Razor-based engines can't.

And in the benchmarks below it is the fastest engine measured: warm and cold, in time and in allocations.

Result
  • ADA
  • LINUS
var html = HtmlBuilder.Create().BuildFromTemplate(template, new {
    Users = new[] {
        new { Name = "Ada",   IsVip = true  },
        new { Name = "Linus", IsVip = false },
    }
});
<ul>
  @for (Users)
  {
    <li [class.vip]="IsVip">{{ Name | upper }}</li>
  }
</ul>

Start

Get started in a minute.

Install the package, grab a builder, and render a template against any model. Zero configuration — the built-in pipes are already registered.

1 · Install

terminal
dotnet add package NgSharp

2 · Render your first template

Result

Hello, Ada!

using NgSharp;

var html = HtmlBuilder.Create().BuildFromTemplate(
    template, new { Name = "Ada" });
<h1>Hello, {{ Name }}!</h1>

The model can be any object, a System.Text.Json.JsonElement, or a pre-built NgElement. Values are HTML-escaped automatically.

Start

How it works.

The V3 engine parses your template in a single fused pass: the scan emits a folded program directly — static runs of markup are already precomputed constants — with no intermediate tree.

A stateless renderer then walks that program against your model, reading it lazily in place. No code generation, no per-render state — which is where its guarantees come from.

Template Fused single-pass parser Folded program Stateless renderer + lazy model reads HTML
InterpretedNo runtime code generation — nothing to Roslyn-compile or emit at first use, so it starts without the codegen cost Razor and Handlebars pay.
Immutable & thread-safeThe folded program never mutates and the renderer holds no state, so one Compiled template renders concurrently across threads.
Lazy, zero-copy model readsYour object or JsonElement is read in place as expressions demand it — no intermediate data store is built per render.
Inline caches, pooled outputEach access site caches its last resolution (monomorphic inline caches); pipes format as spans straight into the pooled output buffer.
AOT & trim cleanOn the JsonElement / NgElement path; the reflection-based object overload and component binding are opt-in. No Regex anywhere in the library.
Zero dependenciesOnly System.Text.Json — in the shared framework on .NET 8+, a single package on netstandard2.1. The HTML parser is purpose-built, not AngleSharp.

Start

Good to know.

A handful of behaviours worth knowing before your first render — the things that would otherwise surprise you.

HtmlBuilder.Create() is a factory, not a singleton. It returns a new builder pre-loaded with the built-in pipes on every call. Capture one instance — var builder = HtmlBuilder.Create(); — then register and render on that same instance; registering on one builder and rendering on another loses the registration.

Output is verbatim. Rendering emits your template's whitespace exactly as written — newlines and indentation included, and correctly inside <pre> / <textarea>. There is no minifier pass: what you write is what ships.

Object identity is path-dependent. A == A on the same object node holds for CLR models (live-reference identity) but not across separately-resolved JsonElement nodes — compare scalar values, not object nodes, when the model comes from JSON.

<script> / <style> are raw text. Their content is preserved verbatim and interpolations inside them render unescaped{{ AccentColor }} in a stylesheet stays CSS, not HTML entities. Only bind values you trust there.

Pipes use the current culture — or the one you pass per render. date, number, currency, titlecase and largeNumber format with the thread's CultureInfo. To serve multiple locales from one process, set TemplateOptions.Culture on any BuildFromTemplate call or on CompiledTemplate.Render.

The engine swaps CurrentCulture/CurrentUICulture around the render and restores them in a finally — thread-local, so concurrent renders elsewhere are unaffected; null keeps the ambient culture.

The sample outputs on this page are en-US. Two culture-proof exceptions, the json pipe and bare interpolations in text mode, write invariant machine literals.

The expression language is small but real. Paths, safe-navigation ?., indices, .Count / .Length, literals, comparisons == != < > <= >=, logical && / || / !, arithmetic + - * / % (+ also concatenates strings), ternary and pipes.

No method calls; ordering comparisons are numeric. Test existence with a comparison — @if (Items.Count > 0), not @if (Items).

Truthiness is strict; misses are silent. Only a real boolean is truthy, and a path that doesn't resolve renders empty rather than throwing. Empty templates and unknown pipes do throw.

Rendering is synchronous. Rendering is CPU-bound — there is nothing to await and no async overload at all: BuildFromTemplate returns the rendered string directly (or use Compile(template).Render(model) for parse-once / render-many).

Components use reflection. Unlike pipes and directives, component [prop] binding is reflection-based — RegisterComponent<T>() carries the trimmer annotations, so the component's constructor and properties are preserved automatically under trimming / Native AOT. And a component's Render() output is trusted raw HTML, injected verbatim like [html] — escape user data inside the component (WebUtility.HtmlEncode).

Two caveats: complex-typed properties bound from a JsonElement model deserialize through reflection-based System.Text.Json (under trimming, enable JsonSerializerIsReflectionEnabledByDefault and keep the element types rooted — annotations aren't transitive), and a binding that can't convert sets nothing, silently. Components also receive CLR model objects by live reference, and any public settable property is bindable (case-insensitive) — treat bound objects as read-only.

FromJson reads lazily. The JsonElement path reads your document in place — keep its JsonDocument undisposed for the life of the render, or pass json.Clone() if it may be disposed first.

Where it fits. Reach for NgSharp when you need HTML-correct, escaped output with a compile-free cold start and zero dependencies — Native AOT, trimming, serverless, short-lived processes.

For a full MVC view engine wired into ASP.NET's pipeline, Razor remains the native choice. On pure template rendering, the benchmarks below have NgSharp ahead of the compiled engines warm as well as cold.

Guide

Learn the template syntax by example.

Each block shows what NgSharp renders, with the template and the C# behind the tabs.

Interpolation & pipes {{ }}

Write {{ expression }} to bind a value. Expressions support dotted paths, array indices, the computed members .Count / .Length, and pipes — transforms applied with | that chain left to right.

Result

ADA has 2 tags

Joined 2021

var html = builder.BuildFromTemplate(template, new {
    User = new { Name = "ada", Tags = new[] { "a", "b" }, Joined = new DateTime(2021, 6, 1) }
});
<p>{{ User.Name | upper }} has
   {{ User.Tags.Count }} tags</p>
<p>Joined {{ User.Joined | date:'yyyy' }}</p>

Conditionals @if · @else if · @else · @switch

Write @if (…) { } @else if (…) { } @else { } around any markup — the chain takes the first true branch. Conditions accept comparisons and && / ||; truthiness is strict — only a real boolean is truthy.

Conditionals also exist as attributes on a single element — [if], [else-if], [else]: see the attribute idiom.

@switch (…) selects on a value instead of chaining conditions: the expression is evaluated once, the first @case (…) equal to it renders (the == operator's equality), then @default, then nothing. Only @case / @default are legal between the switch's braces — both dialects, text mode included.

@switch (Order.Status)
{
  @case ('shipped') { <b>shipped</b> }
  @case ('pending') { <i>pending</i> }
  @default { <span></span> }
}
Result — User = { IsAdmin: false, Age: 34 }
member
var html = builder.BuildFromTemplate(template, new {
    User = new { IsAdmin = false, Age = 34 }
});
@if (User.IsAdmin)
{
  <b>admin</b>
}
@else if (User.Age >= 18)
{
  <span>member</span>
}
@else
{
  <span>minor</span>
}

Loops @for · @for (x of …) · $index

Repeat a body once per item with @for (Items) { } — each item becomes the local context, and outer scope is still reachable. Name the loop variable — @for (i of Items) { } — and the item is i while the outer scope stays the direct context.

The attribute forms — [for], which repeats its host element, and [not-empty] / [empty] — have their own section.

Inside any loop, four implicit variables track the iteration — $index (0-based), $count, $first and $last — resolved against the nearest enclosing loop (Angular's @for contextual variables). {{ $index + 1 }} numbers invoice lines; [class.last]="$last" styles the closing row. Outside a loop they are null. One consequence: a model key starting with $ is shadowed by these variables when it is the path's first segment — reach it through a nested segment ({{ Data.$special }}).

Result
  • 1/2 · Pen ×3 — Acme
  • 2/2 · Book ×1 — Acme
var html = builder.BuildFromTemplate(template, new {
    Store = "Acme",
    Items = new[] {
        new { Name = "Pen",  Qty = 3 },
        new { Name = "Book", Qty = 1 },
    }
});
<ul>
  @for (i of Items)
  {
    <li>{{ $index + 1 }}/{{ $count }} · {{ i.Name }} ×{{ i.Qty }} — {{ Store }}</li>
  }
</ul>

The attribute idiom [if] · [else-if] · [else] · [for] · [not-empty] · [empty]

Control flow also rides the element itself. [if], [else-if] / [else] and [for] scope the condition or the loop to their host tag: the element is what gets kept, dropped or repeated, with no braces around the markup. A distinct idiom, not a synonym for the blocks.

AttributeBlock formNotes
<span [if]="expr">…</span>@if (expr) { <span>…</span> }Same output; the attribute keeps or drops its host element.
<span [else-if]="expr"> · <span [else]="">@else if (expr) { … } · @else { … }The attribute chain runs across immediate sibling elements.
<tr [for]="Lines">…</tr>@for (Lines) { <tr>…</tr> }Both make each item the context: {{ Name }} reads the item directly.
@for (x of Lines) { … }Block-only. The item answers to x ({{ x.Name }}); bare names resolve on the outer scope.
$index · $count · $first · $lastidenticalThe loop variables work the same in both forms; the nearest enclosing loop wins.
<ul [not-empty]="Items">Attribute-only: renders the element when the collection is non-empty. No block equivalent.
<p [empty]="Items">Attribute-only: the dual of [not-empty] — renders the element when the collection is empty or absent. No block equivalent.

The context rule is the one to internalize. [for] switches the context implicitly — inside the repeated element, {{ Name }} is the current item's Name, and outer fields stay reachable through the scope chain. The named block @for (x of Lines) does the opposite: the item answers only to x, and a bare name resolves on the outer scope.

Prefer the attribute idiom when the markup is the point: print and PDF documents dense with tables, where <tr [for]="Lines"> repeats the row without adding a single brace. The realistic-benchmark templates — mirrors of production documents — are written in it.

Result — Stock = 12
#ItemQty
1Pen3
2Book1
stock: low
var html = builder.BuildFromTemplate(template, new {
    Stock = 12,
    Lines = new[] {
        new { Name = "Pen",  Qty = 3 },
        new { Name = "Book", Qty = 1 },
    }
});
<table>
  <thead><tr><th>#</th><th>Item</th><th>Qty</th></tr></thead>
  <tbody>
    <tr [for]="Lines">
      <td>{{ $index + 1 }}</td>
      <td>{{ Name }}</td>
      <td>{{ Qty }}</td>
    </tr>
  </tbody>
</table>

<span [if]="Stock >= 100">stock: plenty</span>
<span [else-if]="Stock > 0">stock: low</span>
<span [else]="">stock: out</span>

Attribute, class & style bindings [attr.] [class.] [style.] [html]

Bind attributes from expressions. A null [attr.x] omits the attribute; [class.name] toggles a class when truthy; [style.prop] appends an inline style; [html] injects trusted raw HTML, verbatim and unescaped (an innerHTML-style assignment) — escape user-supplied data before binding it there.

Result
var html = builder.BuildFromTemplate(template, new {
    Url = "/home", IsActive = true, Color = "red", Label = "Home"
});
<a [attr.href]="Url"
   [class.active]="IsActive"
   [style.color]="Color">{{ Label }}</a>

Components & directives <my-component> · [directive]

A component is a class returning HTML for a custom element; a directive mutates a host element. Register either on the builder, then use it in any template.

A component's Render() output is trusted raw HTML — injected verbatim, without escaping, like an [html] binding. Escape user-supplied data inside your component (e.g. WebUtility.HtmlEncode) before embedding it in the returned markup.

Result — Status = "live"
live
public class Badge : IComponent
{
    public string ComponentName => "badge";
    public string Label { get; set; }
    public string Render() =>
        $"<span class='badge'>{Label}</span>";
}

builder.RegisterComponent<Badge>();
<badge [label]="Status"></badge>

Fragments & containers <ng-template> · @render · <ng-container>

Define a fragment once with <ng-template #name> — it emits nothing where it stands — and stamp it anywhere with @render(name, context). The fragment renders against exactly the context you pass, nothing else (isolated scope).

<ng-container> is the transparent wrapper: structural directives apply to it, but no element reaches the output.

Result
Design — $250,000
R&D — $400,000

Directory is live.

var html = builder.BuildFromTemplate(template, new {
    Ready = true,
    Departments = new[] {
        new { Name = "Design", Budget = 250000 },
        new { Name = "R&D",    Budget = 400000 },
    }
});
<ng-template #kpi>
  <div class="kpi"><b>{{ Name }}</b> — {{ Budget | number:'C0' }}</div>
</ng-template>

@render(kpi, Departments[0])
@render(kpi, Departments[1])

<ng-container [if]="Ready">
  <p>Directory is live.</p>
</ng-container>

Text mode TemplateMode.Text

The same engine renders non-HTML output — plain-text emails, JSON, CSV. Pass new TemplateOptions { Mode = TemplateMode.Text } to BuildFromTemplate or Compile; the default everywhere stays TemplateMode.Html, so existing code is unaffected.

Text mode keeps interpolations, pipes, expressions and the @if / @else / @for blocks — and emits everything raw. No HTML escaping: a number pipe's non-breaking group separator stays a real character.

Bare interpolations write machine literals: booleans come out true / false and numbers culture-invariant — 3.14 even on a fr-FR thread. That's what keeps your JSON JSON, whatever the server culture. Pipes are the opposite by design: they format for humans, with the current culture.

For string values in JSON templates, the json pipe emits the complete JSON literal — quoted and escaped: "name":{{ Name | json }}. The quotes come from the pipe, so a ", a backslash or a newline in the data can never break the document.

Tags, components, directives and <ng-template> are HTML-mode concepts — in text mode they are just characters, output verbatim. A literal @ (email addresses…) passes through untouched.

Result
Hello Ada,

Your invoices:
- 1042: $1,250.50
- 1043: $880.00

Thanks for your loyalty!

Questions? Write to billing@acme.dev.
var text = builder.BuildFromTemplate(template, new {
    Name = "Ada", Premium = true,
    Invoices = new[] {
        new { Id = 1042, Total = 1250.50 },
        new { Id = 1043, Total = 880.00 },
    }
}, new TemplateOptions { Mode = TemplateMode.Text });
Hello {{ Name }},

Your invoices:
@for (i of Invoices) {- {{ i.Id }}: {{ i.Total | number:'C2' }}
}
@if (Premium) {Thanks for your loyalty!
}
Questions? Write to billing@acme.dev.

Compile-once works the same way: builder.Compile(template, new TemplateOptions { Mode = TemplateMode.Text }) returns a CompiledTemplate that carries its Mode — a contradicting mode in Render options throws (the dialect is baked in at compile).

Whitespace control {{- · -}}

Text output is unforgiving: every space and line break in the template lands in the email. Scriban/Liquid-style trim markers fix that, resolved at parse time — zero render cost, and templates without markers stay byte-identical. {{- expr }} trims the whitespace (line breaks included) that precedes the interpolation, {{ expr -}} the whitespace that follows — each up to the previous / next interpolation, block or tag boundary. {{- -}} is the empty whitespace eater: renders nothing, trims both sides.

A marker is only active flush against its braces with a space on the expression side, so negation and subtraction are never captured: {{ -X }} and {{-X }} both mean minus-X, and {{ A - B }} stays a subtraction.

The @if / @for braces carry no trim syntax of their own — the eater covers the classic email need, making the block's own lines vanish cleanly in both branches:

Bonjour {{ Name }},
@if (Vip) {
{{- -}}
Merci pour votre fidélité !
}{{- -}}
Cordialement

renders Bonjour Alice, / Merci pour votre fidélité ! / Cordialement when Vip is true and Bonjour Alice, / Cordialement — no blank line — when it is false. Markers work in HTML mode too (escaping unchanged), and only ever trim template whitespace: a value's own spaces always survive.

Grammar edges to know.

  • Braces scope blocks. An unpaired { or } in static text skews @if / @for matching — every block language shares this trait, Razor included. Escape a lone brace as an interpolated literal: {{ '}' }}.
  • Don't glue an interpolation to a block brace. @if (x) {{ X }} parses as block-open plus a literal brace run — write @if (x) { {{ X }} }.
  • String data in JSON templates wants the json pipe. A raw {{ Name }} between hand-written quotes breaks on the first " or newline in the data — {{ Name | json }} never does.

Guide · cookbook

Real-world examples.

Complete templates that combine several features. See the rendered result, then flip to the C# that produced it and the template itself.

Product card interpolation · [class.] · @if · pipe

Result
Mechanical Keyboard

Mechanical Keyboard

$129

-20%
var html = builder.BuildFromTemplate(template, new {
    Name = "Mechanical Keyboard", ImageUrl = "/img/kbd.jpg",
    Price = 129, OnSale = true, Discount = 20, InStock = true
});
<article class="product" [class.sale]="OnSale">
  <img [attr.src]="ImageUrl" [attr.alt]="Name">
  <h3>{{ Name }}</h3>
  <p class="price">{{ Price | number:'C0' }}</p>
  @if (OnSale)
  {
    <span class="badge">-{{ Discount }}%</span>
  }
  @if (InStock)
  {
    <button>Add to cart</button>
  }
</article>

Order table @for · [class.] · pipe

Result
ItemQtyTotal
Cable2$19.98
Adapter1$12.50
var html = builder.BuildFromTemplate(template, new {
    Lines = new[] {
        new { Product = "Cable",   Qty = 2, Total = 19.98m, Backordered = false },
        new { Product = "Adapter", Qty = 1, Total = 12.50m, Backordered = true  },
    }
});
<table class="orders">
  <thead><tr><th>Item</th><th>Qty</th><th>Total</th></tr></thead>
  <tbody>
    @for (Lines)
    {
      <tr [class.backorder]="Backordered">
        <td>{{ Product }}</td><td>{{ Qty }}</td>
        <td>{{ Total | number:'C2' }}</td>
      </tr>
    }
  </tbody>
</table>

Notification feed [not-empty] · @for · [class.] · date

Result
  • New login
  • Payment received
var html = builder.BuildFromTemplate(template, new {
    Items = new[] {
        new { Title = "New login",        At = new DateTime(2026,7,20, 9,15,0), Unread = true  },
        new { Title = "Payment received", At = new DateTime(2026,7,19,18,40,0), Unread = false },
    }
});
<ul class="feed" [not-empty]="Items">
  @for (Items)
  {
    <li [class.unread]="Unread">
      <strong>{{ Title }}</strong>
      <time>{{ At | date:'MMM d, HH:mm' }}</time>
    </li>
  }
</ul>

Conditional banner @if · @else if · @else · comparison

Result — Order.Status = "processing"
Preparing your order.
var html = builder.BuildFromTemplate(template, new {
    Order = new { Status = "processing" }
});
@if (Order.Status == "shipped")
{
  <div class="alert success">On its way 🚚</div>
}
@else if (Order.Status == "processing")
{
  <div class="alert info">Preparing your order.</div>
}
@else
{
  <div class="alert warn">Payment pending.</div>
}

Nav with active link @for · [attr.] · [class.]

Result
var html = builder.BuildFromTemplate(template, new {
    Links = new[] {
        new { Label = "Home", Href = "/",     Current = true  },
        new { Label = "Docs", Href = "/docs", Current = false },
        new { Label = "Blog", Href = "/blog", Current = false },
    }
});
<nav>
  @for (Links)
  {
    <a [attr.href]="Href" [class.active]="Current">{{ Label }}</a>
  }
</nav>

Guide · advanced

Putting it all together.

Where the engine earns its keep: nested loops that reach outer scope, conditionals and pipes inside iterations, computed .Count, and state-driven [class.] bindings — the kind of document you actually ship.

Invoice document @for · @if · [class.] · nested object · pipes

Result

Invoice INV-2042

Billed to Acme Corp · Jul 15, 2026

License · Pro3 × $49.00$147.00
Onboarding1 × $200.00$200.00

Discount: −$20.00

Total: $327.00

var html = builder.BuildFromTemplate(template, new {
    Number = "INV-2042", Status = "paid", IsPaid = true,
    Customer = new { Name = "Acme Corp" },
    IssuedAt = new DateTime(2026, 7, 15),
    Lines = new[] {
        new { Product = "License · Pro", Qty = 3, UnitPrice = 49m,  LineTotal = 147m },
        new { Product = "Onboarding",    Qty = 1, UnitPrice = 200m, LineTotal = 200m },
    },
    Discount = 20m, Total = 327m
});
<div class="invoice">
  <header>
    <h2>Invoice {{ Number }}</h2>
    <span class="status" [class.paid]="IsPaid">{{ Status | upper }}</span>
  </header>
  <p>Billed to {{ Customer.Name }} · {{ IssuedAt | date:'MMM d, yyyy' }}</p>
  <table>
    @for (Lines)
    {
      <tr>
        <td>{{ Product }}</td>
        <td>{{ Qty }} × {{ UnitPrice | number:'C2' }}</td>
        <td>{{ LineTotal | number:'C2' }}</td>
      </tr>
    }
  </table>
  @if (Discount > 0)
  {
    <p class="discount">Discount: −{{ Discount | number:'C2' }}</p>
  }
  <p class="total">Total: {{ Total | number:'C2' }}</p>
</div>

Nested orders @for in @for · outer scope · .Count · nested @if

Result

Order #1001 — 2 items

  • Keyboard — $129
  • Mug — $15🎁 gift for Sam

Order #1002 — 1 items

  • Cable — $9
var html = builder.BuildFromTemplate(template, new {
    Orders = new[] {
        new { Id = 1001, Items = new[] {
            new { Name = "Keyboard", Price = 129, IsGift = false, Recipient = (string)null },
            new { Name = "Mug",      Price = 15,  IsGift = true,  Recipient = "Sam" },
        }},
        new { Id = 1002, Items = new[] {
            new { Name = "Cable", Price = 9, IsGift = false, Recipient = (string)null },
        }},
    }
});
<div class="orderlist" [not-empty]="Orders">
  @for (Orders)
  {
    <section class="order">
      <h3>Order #{{ Id }} — {{ Items.Count }} items</h3>
      <ul>
        @for (Items)
        {
          <li [class.gift]="IsGift">
            {{ Name }} — {{ Price | number:'C0' }}
            @if (IsGift)
            {
              <span class="tag">🎁 gift for {{ Recipient }}</span>
            }
          </li>
        }
      </ul>
    </section>
  }
</div>

Metrics dashboard @for · largeNumber · @if / @else if · [class.] state

Result
Revenue1.2M▲ 12%
Signups8.4K▲ 4%
Churn320▼ 2%
var html = builder.BuildFromTemplate(template, new {
    Metrics = new[] {
        new { Label = "Revenue", Value = 1200000, TrendUp = true,  TrendDown = false, Change = "12%" },
        new { Label = "Signups", Value = 8400,    TrendUp = true,  TrendDown = false, Change = "4%"  },
        new { Label = "Churn",   Value = 320,     TrendUp = false, TrendDown = true,  Change = "2%"  },
    }
});
<div class="stats">
  @for (Metrics)
  {
    <div class="stat" [class.up]="TrendUp" [class.down]="TrendDown">
      <span class="label">{{ Label }}</span>
      <strong>{{ Value | largeNumber }}</strong>
      @if (TrendUp)
      {
        <span class="trend">▲ {{ Change }}</span>
      }
      @else if (TrendDown)
      {
        <span class="trend">▼ {{ Change }}</span>
      }
    </div>
  }
</div>

Syntax cheat sheet

Every construct at a glance.

{{ expr }}Interpolation — writes the escaped value of an expression.
{{ expr | pipe:arg }}Pipe — transforms a value (pipes chain left to right).
{{- expr }} · {{ expr -}} · {{- -}}Whitespace control (parse-time) — {{- trims the whitespace before, -}} the whitespace after (newlines included); {{- -}} eats both sides and renders nothing. Active only flush against the braces with a space on the expression side — {{ -X }} stays a negation. See text mode.
@if · @else if · @elseBlock conditional.
@switch · @case · @defaultBlock selection — the value is evaluated once; the first equal @case renders (the == equality), else @default, else nothing.
[if]="expr"Attribute form — keeps its host element when the condition is truthy. See the attribute idiom.
[else-if]="expr" · [else]=""Chains attribute conditionals across sibling elements.
@for (Items) { … }Repeats a body once per item; the item becomes the context.
@for (x of Items) { … }Named loop variable — the item is x, the outer scope stays the context.
[for]="Items"Attribute form — repeats its host element once per item; the item becomes the context.
$index · $count · $first · $lastLoop variables inside any @for/[for] — 0-based index, item count, boundary booleans; nearest loop wins, null outside a loop. {{ $index + 1 }} numbers lines.
[not-empty]="Items"Renders only when the collection is non-empty.
[empty]="Items"The dual — renders only when the collection is empty or absent.
<ng-template #n> · @render(n, ctx)Defines a reusable fragment; stamps it against the passed context (isolated).
<ng-container>Transparent grouping — directives apply, no wrapper element is emitted.
[attr.name]="expr"Sets an attribute (null omits it).
[name]="expr"Bare property binding — sets the attribute, like [attr.name].
[class.name]="expr"Toggles a class when truthy.
[style.prop]="expr"Appends an inline style.
[html]="expr"Injects trusted raw HTML, unescaped — escape user data first.
! == != < > <= >= && || + - * / % ?:Logical, comparison, arithmetic (+ also concatenates) and ternary operators.
(a || b) && cParentheses group sub-expressions — they override precedence anywhere an expression is accepted.
a?.bSafe navigation — resolves like a.b (never throws; a missing step is null).
Items[0].Name · .Count · .LengthArray / string indexers and the computed count and length members.
<script> · <style>Raw text — content verbatim, interpolations render unescaped.
<my-component [prop]="e">Renders a registered component.
[directive]="expr"Applies a registered custom directive.
TemplateMode.TextNon-HTML raw output (text emails, JSON, CSV) — blocks and pipes, no tags, no escaping; bare bools/numbers write machine literals (true, invariant digits).
{{ Name | json }}The complete JSON literal of a value — recursive: objects and arrays serialize whole (nested members included); strings quoted and JSON-escaped, numbers invariant, bools lowercase, null. The quotes come from the pipe: "name":{{ Name | json }}.
{{ Nickname | default:'—' }}Substitutes the argument for null/undefined or a blank string — false and 0 are values, kept.
{{ Price | currency:'EUR' }}Current-culture currency format with the symbol pinned by ISO code — 12,50 € under fr-FR, €12.50 under en-US; without argument, the plain current-culture format.
{{ Name | lower }} · {{ Name | upper }}Lowercases / uppercases (current culture).
{{ Title | titlecase }}Capitalizes each word, lowercases the rest — HELLO worldHello World.
{{ Summary | truncate:80 }}Caps at N characters, included as the last one; N defaults to 50.
{{ Tags | join:' - ' }}Joins a collection's items; the separator defaults to ', '; items format like interpolations.
{{ Id | pad:6 }}Left-pads with 0 to the width — 42 becomes 000042.
TemplateOptions { Culture = … }Per-render CultureInfo on BuildFromTemplate / CompiledTemplate.Render — swapped around the render, restored in a finally; null keeps the ambient culture.
builder.Validate(template)Parse-level diagnostics, never throws — unclosed {{, @for (x in …), unclosed blocks, orphan [else], bad pipes — each with its source position. See strict mode & validation.
TemplateOptions { Strict = true }Strict mode — a path missing from the model, a non-boolean non-null @if/[if] condition and a division/modulo by zero throw NgSharpException naming the culprit (present-but-null still renders empty; ?. exempts a path); the compile refuses a template with validation errors.
HtmlBuilder.Create(strict: true)Builder-level strict default — every Build/Compile on the builder is strict without repeating the flag; an explicit TemplateOptions.Strict overrides it (call wins).
RegisterPipe(instance) · RegisterDirective(instance) · RegisterComponent(instance)DI-friendly instance registrations — pipe/directive instances are shared across renders (thread-safe/immutable); a component instance is a prototype, each render activates a fresh one.
tpl.Render(model, writer) · await tpl.RenderAsync(model, writer, ct)TextWriter sinks on a CompiledTemplate — the walk stays CPU-synchronous, the await is the write to the writer (real I/O); atomic: a throwing render writes zero characters.

Performance

Compile once, render many.

Parsing is the bulk of a one-shot render. When you render the same template repeatedly, compile it once.

The single-pass parser emits the folded program directly — static runs of markup (text, comments, fixed-attribute tags) are already precomputed constants, a partial evaluation done in the parse itself, no code generation — so each render pays only for the data-dependent parts.

The result is immutable and the renderer stateless, so a compiled template is safe to render concurrently. This is the warm path measured in the benchmarks below.

C#
var tpl = HtmlBuilder.Create().Compile(
    "<li>{{ Name }}</li>");

foreach (var user in users)
    output.Append(tpl.Render(user));
note
// Parse + fold the template once,
// render it N times with N models.
// Render(object | JsonElement
//        | NgElement) available.
// Multi-locale? Render(user,
//   new TemplateOptions { Culture =
//     new CultureInfo("fr-FR") }).

Output

Straight into a TextWriter — and honest async.

A compiled template also renders into any TextWriter — a StringWriter, a file, an HTTP response. Both forms take the same three model kinds and the same TemplateOptions; what the sink saves is the final output string.

RenderAsync is honest about where the async lives. The walk is CPU-bound and stays synchronous; the await is the write to your writer — the real I/O. No Task wrapped around CPU work, ever.

And the sink is atomic. Nothing reaches the writer until the render has fully succeeded: a throwing render — a strict miss, an exceeded cap — writes zero characters. For all-or-nothing documents, that guarantee is the point.

C#
var tpl = builder.Compile(template);

tpl.Render(model, writer);          // sync sink

await using var sw = new StreamWriter(response.Body);
await tpl.RenderAsync(model, sw,
    cancellationToken: ct);
note
// The walk renders fully, then the
// writer receives the output in one
// awaited write — real I/O.
// Atomic: a throwing render writes
// zero characters to the sink.
// Same models, same options as
// Render(model, options).

API reference

The public surface, in full.

Every public type is documented with XML comments, so the same descriptions surface in your IDE's IntelliSense.

class

HtmlBuilder

The entry point: registers pipes/directives/components and renders templates. HtmlBuilder.Create() returns a builder pre-loaded with the built-in pipes.

static HtmlBuilder Create()method

Creates a new builder pre-loaded with the built-in pipes (date, number, currency, largeNumber, upper, lower, titlecase, default, truncate, join, pad, image, json). Register your extensions on the instance you keep.

static HtmlBuilder Create(bool strict)method

Same, with the builder's strict default set once: every Build/Compile on the builder is strict without repeating the flag. An explicit TemplateOptions.Strict in a call's options still overrides it (call wins over builder) — see strict mode & validation.

string BuildFromTemplate(string template, object | JsonElement | NgElement model[, TemplateOptions options])overloads

Renders a template against a model — synchronously, the real path (rendering is CPU-bound). The object overload reads it directly via reflection (honors [JsonPropertyName]/[JsonIgnore], not custom converters); the JsonElement overload is reflection-free with full System.Text.Json fidelity (AOT / trim); the NgElement overload is the hot-path opt-in.

Each takes one optional TemplateOptions carrying everything tunable: Mode (TemplateMode.Text renders non-HTML output raw — see Text mode; omitted or null means TemplateMode.Html), Strict (fail loud — see strict mode), Culture (swapped and restored around the render; null keeps the ambient culture) and Limits (opt-in resource caps for untrusted templates). Null options mean all defaults.

CompiledTemplate Compile(string template[, TemplateOptions options])method

Parses and folds the template once and returns a reusable CompiledTemplate (parse-once / render-many). options.Mode is baked into the result (default TemplateMode.Html). With Strict = true the compile throws on Validate errors and every render throws on a path missing from the model; Strict left null inherits the builder's Create(strict:) default — see strict mode & validation. options.Culture / options.Limits are memorized as the compiled template's render defaults, overridable per render.

IReadOnlyList<TemplateDiagnostic> Validate(string template[, TemplateMode mode])method

Reports every problem the lenient renderer would swallow silently — unclosed {{, unparsable expressions, @for (x in …), unclosed blocks, orphan [else], malformed pipes, and (as warnings) unregistered pipes, dashed tags not registered as components, statically always-false conditions and literal division by zero — without throwing, each with a source position. Empty list = clean template; made for CI. See strict mode & validation.

void RegisterPipe<T>() · RegisterComponent<T>() · RegisterDirective<T>()methods

Registers a pipe / component / directive. Each T is instantiated via its parameterless constructor.

void RegisterPipe(IPipe) · RegisterDirective(IDirective) · RegisterComponent<T>(T instance)methods

Instance registrations — the DI-friendly path for extensions with constructor-injected configuration. Pipe/directive instances are shared across renders (must be thread-safe, ideally immutable). A component instance is a prototype: each render still activates a fresh T via its public parameterless constructor, so constructor state does not flow into renders.

IReadOnlyDictionary<string, …> Pipes · Directives · Componentsproperties

The registered extensions, keyed by name.

sealed class

CompiledTemplate

A template parsed once and rendered many times, from HtmlBuilder.Compile. Immutable folded program + stateless renderer — safe to render concurrently.

string Render(object | JsonElement | NgElement model[, TemplateOptions options])overloads

Renders the compiled template against a model — same three model kinds as BuildFromTemplate. The options' Culture, Limits and Strict override the defaults memorized at compile time (one compiled template serves multiple locales); a Mode contradicting the compiled dialect throws — the dialect was baked in at compile. Omitted options keep the compile-time defaults; no limits means nothing is enforced and nothing is paid.

void Render(object | JsonElement | NgElement model, TextWriter writer[, TemplateOptions options])overloads

Renders into a TextWriter sink — same three model kinds, same options. Nothing reaches the writer until the render has fully succeeded: a throwing render (strict miss, exceeded cap) writes zero characters (atomic). What the sink saves is the final output string.

Task RenderAsync(object | JsonElement | NgElement model, TextWriter writer[, TemplateOptions options, CancellationToken cancellationToken])overloads

The honest async form: the walk is CPU-bound and synchronous; the await is the write to your writer (real I/O). Same atomicity as the sync sink — a throwing render writes zero characters, and an already-canceled token throws before anything is rendered or written.

TemplateMode Mode { get; }property

The dialect the template was compiled with (TemplateMode.Html / TemplateMode.Text) — captured at Compile time; a contradicting mode in Render options throws.

bool Strict { get; }property

True when compiled with Strict = true (or from a strict builder): every render throws NgSharpException on a path missing from the model, instead of rendering empty (present-but-null still renders empty; ?.-guarded paths never throw) — see strict mode & validation.

sealed record

TemplateOptions

The options facade: one immutable record carrying everything a render or compile call can tune — every property optional, null meaning "use the default". A pure DTO: share one instance across calls and threads, derive variants with with.

TemplateMode Mode { get; init; }property

Compile-time. The dialect the template is parsed in; null defaults to TemplateMode.Html. A CompiledTemplate bakes it in — a contradicting mode in Render options throws.

bool? Strict { get; init; }property

Both times. At compile it gates the template through Validate (any error throws); at render a missing model path, a non-boolean non-null condition and a division/modulo by zero throw. Null inherits the builder's Create(strict:) default — or, on CompiledTemplate.Render, the compiled Strict; an explicit true/false always wins.

CultureInfo Culture { get; init; }property

Render-time. Swapped in around the render, restored afterwards; null keeps the ambient culture. Given to Compile, it becomes the compiled template's default culture, overridable per render.

RenderLimits Limits { get; init; }property

Render-time. Opt-in resource caps for untrusted templates; null enforces nothing. Given to Compile, they become the compiled template's default caps, overridable per render.

static readonly TemplateOptions Defaultfield

The canonical empty options — every property null. Passing Default, null, or nothing at all are equivalent.

readonly struct

NgElement

The data model — a lightweight, read-only view over your data (CLR object or JSON), resolved lazily as the renderer reads it. Usually built for you; construct one directly for the hot path or to inspect data.

It is a 16-byte readonly struct: copy semantics, passed by value (in registers) — assigning one copies the view, never the data, and there is no instance to mutate or to null-check. The miss sentinel is default(NgElement), exposed as IsUndefined.

static NgElement FromJson(JsonElement json) · FromObject(object value)factories

FromJson is reflection-free (AOT / trim safe) and reads the JsonElement lazily — keep its JsonDocument undisposed while you render, or pass json.Clone(). FromObject reads a CLR object in place via reflection — no JSON round-trip, no copy.

static NgElement Parse(string literal) · FromStringLiteral(string text)factories

Scalar factories, both reflection-free. Parse coerces a template literal — null / true / false and culture-invariant numbers become their types, anything else stays a string. FromStringLiteral wraps the text with no coercion at all: "42" stays the string "42".

NgElement SelectToken(string path)method

Resolves a path — property names, dotted paths, array indices ("Items[0].Name"), and the computed .Count / .Length.

string GetString() · int? GetInt() · double? GetDouble() · bool? GetBoolean() · DateTime? GetDateTime() · …getters

Typed accessors for the leaf value (also GetLong, GetDecimal, GetFloat); each returns null when not convertible.

object Value · JsonValueKind ValueKind · Children · Properties · int Count · Length · string Keyproperties

The scalar value and kind, the child/property collections, and the computed Count / Length. Key always returns string.Empty in 3.0 — lazy nodes don't record their position in the parent; enumerate Properties when you need the names.

API reference · extend

Extend the engine.

Three interfaces grow the template language: IPipe (value transforms), IDirective (host-element mutations) and IComponent (server components). Implement one, register it, use it.

Pipes and directives are plain interface calls (reflection-free); component property binding uses reflection — RegisterComponent<T>() carries the trimmer annotations, so those members are preserved automatically under trimming / Native AOT.

Each registration also takes an instanceRegisterPipe(new BrandPipe(options)) — the DI-friendly path for extensions with constructor-injected configuration. A pipe or directive instance is shared by every render (keep it thread-safe, ideally immutable); a component instance is a prototype — each render still activates a fresh instance via the public parameterless constructor, so constructor state does not flow into renders.

Custom pipe IPipe

Result
AL
using NgSharp;
using NgSharp.Pipes;   // IPipe

public class InitialsPipe : IPipe
{
    public string PipeName => "initials";

    public string Transform(string tag, NgElement value, string arg)
    {
        var name = value.GetString() ?? "";
        return string.Concat(name.Split(' ')
            .Where(p => p.Length > 0)
            .Select(p => char.ToUpper(p[0])));
    }
}

builder.RegisterPipe<InitialsPipe>();
<span class="avatar">{{ Name | initials }}</span>

// Name = "Ada Lovelace"

Custom directive IDirective

Result — hover the button
using NgSharp;
using NgSharp.Directives;   // IDirective, DirectiveElement

public class TooltipDirective : IDirective
{
    public string DirectiveName => "tooltip";

    public void Apply(DirectiveElement el, NgElement content)
    {
        var text = content.GetString();
        if (!string.IsNullOrEmpty(text))
            el.SetAttribute("title", text);
    }
}

builder.RegisterDirective<TooltipDirective>();
<button [tooltip]="Hint">Save</button>

// Hint = "Ctrl+S"  →  renders <button title="Ctrl+S">Save</button>
reference

IPipe · IDirective · IComponent

The three contracts, and the built-in pipes registered on every builder from HtmlBuilder.Create(). Each contract lives in its own namespace: NgSharp.Pipes, NgSharp.Directives, NgSharp.Components.

IPipe : string PipeName · string Transform(string tag, NgElement value, string arg)interface

A value transform for {{ value | pipeName }}.

IDirective : string DirectiveName · void Apply(DirectiveElement el, NgElement content)interface

Mutates a host element via [directive]="expr". DirectiveElement exposes TagName, GetAttribute, SetAttribute, RemoveAttribute.

IComponent : string ComponentName · string Render()interface

A server component for <component-name>; its writable properties are bound from [prop] attributes. Its Render() output is trusted raw HTML — injected verbatim, like [html]: escape user data inside the component (WebUtility.HtmlEncode).

// built-in pipes · HtmlBuilder.Create()pipes

date · number · currency (ISO-pinned symbol) · largeNumber · upper · lower · titlecase · default (null/blank fallback) · truncate · join · pad · image · json (recursive — objects and arrays serialize whole). Register your own with RegisterPipe<T>().

"Logo | image"ImageData { string FileName; byte[] FileContent; }contract

The image pipe reads an ImageData-shaped value — FileName, whose extension gives the MIME type, and FileContent, the raw bytes (base64 in a JSON model) — and emits a data URI without ever decoding the content.

On an <img> it emits the bare URI — [src]="Logo | image"data:image/png;base64,…. On any other element it wraps it for CSS — [style.background-image]="Logo | image"url(data:…). A null value or empty content renders as an empty string.

A ready-made component: NgSharp.Map. A separate package (it carries a SkiaSharp dependency, which is why it isn't in the core) shipping a <map> server component — a static map with SkiaSharp-drawn markers, embedded as a data URI for print/PDF documents.

Correctness

Strict mode & validation.

By default the engine is lenient: a mistyped path renders as an empty string, an unknown construct stays literal, and the document still ships. Two opt-in tools turn those silent holes into signals — Validate for CI, TemplateOptions.Strict for the render itself.

Validate — catch template mistakes before they ship

Validate parses the template and reports everything the lenient renderer would swallow silently — without throwing, each finding carrying a character Position into the source. An empty list means the template is clean: one assertion per template makes a complete CI gate.

C#
var diagnostics = builder.Validate(template);   // IReadOnlyList<TemplateDiagnostic> — never throws

foreach (var d in diagnostics)
    Console.WriteLine(d);
// Error [position 23]: '@for (x in Items)' uses 'in' — did you mean '@for (x of Items)'? …
// Error [position 87]: Unclosed interpolation '{{' — no matching '}}', so it renders as literal text.

Errors — the template will not render as written: unclosed {{ interpolations, empty or unparsable expressions, @for (x in …) (NgSharp loops use of — the classic slip that renders an empty block), unclosed @if/@for blocks, orphan @else / [else-if] / [else] branches, malformed pipe segments ({{ X | }}, {{ X | number: }}). Warnings — it renders, but probably not as intended: pipes not registered on this builder, interpolations kept literal because their body spans a line break, dashed tags (<user-card>) not registered as components on this builder (if it is a component, register it before Compile; a genuine custom element renders verbatim and can ignore the warning), statically always-false @if/[if] conditions (a string or number literal, or an arithmetic result — strict truthiness never coerces, so the body can never render), and division or modulo by a literal zero (the lenient render always yields 0).

Validation parses against the builder's registrations — register your pipes, components and directives first.

Strict rendering — missing data fails loud

TemplateOptions.Strict = true on Compile — or on any BuildFromTemplate call — makes a path that does not exist in the model throw instead of rendering empty. HtmlBuilder.Create(strict: true) makes it the builder's default: every Build/Compile on that builder is strict without repeating the flag, and an explicit Strict in a call's options still overrides it (call wins over builder).

C#
var builder = HtmlBuilder.Create(strict: true);   // strict everywhere on this builder
var tpl = builder.Compile(template);              // throws if Validate finds errors

tpl.Render(model);
// NgSharpException: Strict mode: the path 'Customer.Nane' was not found in the model …

builder.Compile(template, new TemplateOptions { Strict = false });   // per-call override: lenient

The rules are precise.

  • A path that does not exist in the model throws NgSharpException, naming the path.
  • A property present with a null value renders empty, as always — strict distinguishes absent from null.
  • Paths guarded with ?. ({{ Delivery?.Date }}) are declared optional and never throw.
  • An @if/[if] condition that evaluates to a non-boolean non-null value throws, naming the condition — the Angular habit of *ngIf="items.length" fails loud instead of silently rendering nothing (strict truthiness: only real booleans are truthy). Null and ?.-guarded conditions stay silently falsy.
  • Division or modulo by zero throws, naming the expression (non-strict keeps rendering 0).
  • An unknown pipe throws at render, strict or not (it always has).
  • A strict Compile runs Validate first and refuses a template with errors.

Zero cost when off. The non-strict path is untouched — same output byte for byte: the flag rides the render scope and is only read when a resolution fails.

Strict by convention

Strict mode earns its keep when it is the default, not an option someone remembers. A ten-line team wrapper makes it structural: one static class owns the app's only builder, configured strict once, and every render in the app goes through it.

C#
public static class Templates
{
    public static readonly HtmlBuilder Builder = CreateBuilder();

    public static readonly TemplateOptions Legacy = new TemplateOptions { Strict = false };

    private static HtmlBuilder CreateBuilder()
    {
        var builder = HtmlBuilder.Create(strict: true);   // strict is the house default
        // register your pipes, directives and components here — once

        return builder;
    }
}

New templates compile through Templates.Builder.Compile(template) and are strict without anyone thinking about it. The templates you inherited keep rendering through Templates.Builder.Compile(oldTemplate, Templates.Legacy) — a visible, deliberate exception. Lenient mode is for legacy templates; strict is the convention for new ones.

sealed class

TemplateDiagnostic

One finding from HtmlBuilder.Validate(template, mode).

DiagnosticSeverity Severity · string Message · int Positionproperties

Severity is Error (blocks a strict compile) or Warning (probable mistake). Position is the character offset in the template source — exact for structural findings; for a finding inside an expression it points at the expression's first occurrence. ToString() gives the log-friendly Error [position 23]: … line.

Security

Untrusted templates.

Everything above assumes the template is yours. When templates come from users or tenants, opt into RenderLimits — resource caps enforced during the render, off (and free) by default.

sealed class

RenderLimits

Opt-in caps passed via TemplateOptions.Limits to CompiledTemplate.Render or any BuildFromTemplate / Compile call (given at compile, they become the template's render defaults). Exceeding a cap throws NgSharpException with a "Render limit exceeded: …" message. Omitting them — or passing RenderLimits.None — enforces nothing and costs nothing: the default render path is unchanged, byte for byte.

new RenderLimits(int maxOutputChars = 1_000_000, int maxLoopIterations = 10_000, int maxRenderDepth = 50)constructor

Deliberately generous defaults — roomy for any legitimate document, fatal for a runaway one. Each cap must be positive.

int MaxOutputChars · MaxLoopIterations · MaxRenderDepth · static RenderLimits Noneproperties

MaxOutputChars bounds the rendered output's length; MaxLoopIterations bounds a single @for/[for] loop (per loop, checked against the collection's count before iterating); MaxRenderDepth bounds @render fragment nesting — it generalizes the engine's built-in depth-50 guard and throws instead of silently truncating. None is the zero-cost default.

var capped = new TemplateOptions {
    Limits = new RenderLimits(maxOutputChars: 500_000, maxLoopIterations: 5_000, maxRenderDepth: 20),
};

builder.Compile(template).Render(model, capped);   // compiled path
builder.BuildFromTemplate(template, model, capped);  // one-shot path

What the caps cover. Unbounded output, oversized loops, runaway fragment recursion — the resource-exhaustion vectors a hostile template controls. The expression language itself is bounded by design: paths, comparisons, arithmetic and pipes — no method calls, no arbitrary code.

What they don't — this is not a sandbox. A hostile model stays out of scope: the data you bind is your code's responsibility. So are the two trusted-raw-HTML doors — [html] bindings and component Render() output (see components): an untrusted template must not be given components or data that emit unescaped user input. And interpolations inside <script>/<style> render unescaped by design.

Tooling

VS Code extension.

The repo ships a VS Code extension — tooling/vscode-ngsharp/ — that lights up NgSharp templates without leaving .html files: a TextMate grammar injected on top of the normal HTML highlighting, plus snippets for the common constructs. No language server, no settings — grammar and snippets only.

HTML templates. Colorizes {{ }} interpolations end to end — pipes, operators, $index and friends, literals — plus [if]/[for]/[else-if]/[else]/[not-empty] as control-flow keywords, the [attr.x]/[class.x]/[style.x]/[html] and bare [prop] bindings (the quoted value is tokenized as an expression, not left as a string), the @if/@else/@for/@render blocks, and <ng-template #x>/<ng-container>.

Text templates. An ngsharp-text language for text-mode templates — plain-text emails, JSON, CSV. Files named *.ngt or *.ng.txt are picked up automatically; any other .txt can be switched via Change Language Mode. Interpolations and blocks highlight on raw text; tags stay plain characters, exactly like the engine treats them.

Snippets. ngfor, ngif, ngifelse, the [if]/[for]/[else-if] attribute forms, ng-template + @render, ng-container and a server-component tag — in HTML mode; block and pipe snippets in text mode.

Install. Not on the marketplace yet — package it from the repo and install the .vsix:

# from the repo root
cd tooling/vscode-ngsharp
npx --yes @vscode/vsce package          # produces ngsharp-0.1.0.vsix
code --install-extension ngsharp-0.1.0.vsix

Upgrade

Migrating from 2.x.

3.0 is a breaking release. Eight changes, each with its remedy — the package release notes, expanded.

HtmlBuilder.Default is removed. It was a factory disguised as a singleton: every access returned a new builder, so registrations made on one access were silently lost on the next. Replace it with HtmlBuilder.Create() and keep the instance you register on.

HtmlBuilder.MinifyHtml is removed. Rendering now emits your template verbatim, and the opt-in minifier went with it. It was a four-line Regex utility — trivial to copy into your own code if you still want it.

HtmlBuilder.Token is removed. It resolved a path against a context, falling back to parsing the token as a literal. Compose the two calls it wrapped yourself: content.SelectToken(path), then NgElement.Parse(literal) when the result's IsUndefined is true.

BuildFromTemplateAsync is removed. Rendering is CPU-bound and completes synchronously — the wrappers only returned Task.FromResult. Replace await builder.BuildFromTemplateAsync(...) with builder.BuildFromTemplate(...) (drop the await).

FromObject / FromJson lose their parent / key parameters. The engine never used them — delete the extra arguments and the calls compile again.

FromJson reads the JsonElement lazily. There is no document store anymore: rendering reads your JsonDocument in place. Keep it undisposed for the life of the render — or pass json.Clone() if it may be disposed first.

NgElement.Key always returns string.Empty. Lazy nodes no longer record their position in the parent. If you need property names, enumerate Properties — its keys are the names.

The built-in pipes are sealed (DatePipe, NumberPipe, UpperPipe, LargeNumberPipe, ImagePipe). Inheriting to tweak one no longer compiles — register your own pipe under a different name, or wrap a built-in instance by composition and delegate to it.

Stability

Versioning promise.

The 1.x → 3.0 churn was the engine finding its shape. That phase is over — the API churn ends here.

3.x is stable. No breaking change to the public API without a major version bump — semver, applied literally.

Deprecate before delete. Anything scheduled for removal ships [Obsolete] first, with a message naming its replacement, for at least one minor release before a major removes it.

Output is part of the contract. Rendered bytes only change behind an explicit opt-in or a major — the golden-locked test suite is what holds that line.

The boundary is drawn. Streaming output (TextWriter sinks) and honest async are delivered — in the one form that keeps the output contract: the walk renders fully, then the writer receives the final output in a single awaited write. A throwing render writes nothing (atomic). What stays excluded is incremental rendering — flushing mid-walk, while the tree is still being evaluated — because it would sacrifice exactly that atomicity: a failing render would leave half a document in your sink. No version is promised for it.

Help

Troubleshooting.

The failure modes you are most likely to meet, and what each one means.

“Can't replace an empty html template”. The template string handed to the builder is null or empty — usually a file read that produced nothing. Check the template's path and the read, not the engine.

“Unknown pipe 'x'. Register it with RegisterPipe<T>() before rendering.” The template uses a pipe this builder doesn't know. Call RegisterPipe<T>() before rendering — on the same builder instance you render with.

Output is silently empty where a value should be. A path that doesn't resolve renders empty by design — no exception. Check the property path and its casing against the model: {{ user.name }} finds nothing when the model says User.Name. To hunt these down, run builder.Validate(template) for parse-level mistakes, or render with TemplateOptions.Strict = true — a missing path then throws NgSharpException naming it (see strict mode & validation).

A component tag renders as plain markup. Component names are read at parse time: call RegisterComponent<T>() before Compile or the first render, on the builder doing the compiling.

A registration “disappears”. HtmlBuilder.Create() returns a fresh builder on every call. Capture it once — var builder = HtmlBuilder.Create(); — then register and render on that same instance.

Comparison

Engine comparison.

Where NgSharp sits next to the .NET engines it is benchmarked against. Cold-start figures are the measured catalogue numbers from the benchmarks below; the last row comes from the byte-identical ports in this repo.

Capability NgSharp Fluid Handlebars.Net Scriban RazorLight
Template language Angular-style HTML Liquid Handlebars/Mustache Scriban (Liquid-like) Razor (C#)
Runtime code generation none — interpreted none — interpreted IL/delegate codegen none — interpreted full Roslyn compile
Cold start measured, catalogue suite ~32 µs ~56 µs ~10,000 µs ~168 µs ~29,000 µs
Native AOT / trimming reflection-free JsonElement path limited codegen
Third-party dependencies none Parlot parser none none Roslyn + MSBuild stack
HTML-escaped output by default opt-in encoder manual
Non-HTML output mode raw text / JSON / CSV TemplateMode.Text — proven byte-identical and benchmarked (Suite 4) @-syntax geared to markup
HTML-aware structural templates [if]/[for] ON the element, void/self-closing semantics text engine text engine text engine text engine
Switch dispatch @switch / @case / @default native case/when nested #if or helpers case/when C# switch
Attribute / class / style bindings [attr.x], [class.x], [style.x], [html] native manual interpolation manual manual manual
Reusable fragments <ng-template> + @render isolated context {% render %} partials include partials/layouts
Server components custom tag → class, bound props incl. complex types native full ASP.NET only
Custom element directives mutate the host tag native
Value formatting pipes, .NET format strings native filters custom filters needed for .NET formats helpers functions inline C#
Model ingestion object / JsonElement / pre-built NgElement object + options object / dictionaries ScriptObject typed model
What the byte-identical showcase port required native 14 custom filters + inlining 21 helpers + 1 partial not ported realistic docs: custom functions inline C# + static helpers

Every engine here is excellent in its own lane — this matrix is positioning, not a ranking. The last row comes from the real ports in this repo.

What the matrix shows: NgSharp is the only one that is HTML-aware, structural and component-based — without code generation.

Benchmarks

The numbers.

Four suites, the same logical document ported to each engine's own dialect, every port rendering byte-identical output:

  • a product catalogue across six engines,
  • a feature-complete showcase across four,
  • realistic print/PDF documents across four,
  • and a text-mode JSON export across four.

Cold is first parse + render; warm is steady state. Lower is better.

NgSharp interprets — no runtime code generation — and comes out first on every cell: time and allocations, cold and warm, including against the engines that compile to code.

Suite 1 — the catalogue. A product catalogue of 8 categories × 12 products (96 items) with nested loops, several fields per item, and two conditionals — the shared subset all six engines can express.

This is a real page, not a one-liner. The NgSharp template and its model:

The benchmarked document
<div class="catalog">
  <header>
    <h1>{{ Title }}</h1>
    <p class="meta">{{ TotalProducts }} products</p>
  </header>
  <section [for]="Categories">
    <h2>{{ Name }} ({{ Count }})</h2>
    <ul>
      <li [for]="Products">
        <span class="name">{{ Name }}</span>
        <span class="sku">{{ Sku }}</span>
        <span class="price">{{ Price }}€</span>
        <span [if]="InStock == true" class="ok">in stock</span>
        <span [if]="OnSale == true" class="sale">sale</span>
        <span class="rating">{{ Rating }}/5</span>
      </li>
    </ul>
  </section>
</div>
new PageModel {
    Title = "Product Catalogue",
    TotalProducts = 96,
    Categories = [   // 8 categories, each with 12 products
        new Category { Name = "Category 0", Count = 12, Products = [
            new Product { Name = "Product 0-0", Sku = "SKU-000",
                Price = 10, InStock = false, OnSale = true, Rating = 1 },
            // … 11 more products …
        ]},
        // … 7 more categories …
    ]
};
EngineMeanAllocatedvs NgSharp
Cold — first parse + render
NgSharp32.2 µs41 KB1.00×
Fluid55.9 µs61 KB1.74×
Stubble85.8 µs193 KB2.66×
Scriban168.1 µs265 KB5.22×
Handlebars.Net9,765 µs382 KB303×
RazorLight28,821 µs3.2 MB894×
Warm — pre-parsed, render only
NgSharp25.0 µs32.9 KB1.00×
RazorLight37.4 µs97.8 KB1.49×
Handlebars.Net48.3 µs90.4 KB1.93×
Fluid67.4 µs53.3 KB2.69×
Scriban152.9 µs241.6 KB6.11×

Reading the cold column. Cold, NgSharp parses and renders in 32.2 µs — ~2× ahead of the next interpreter (Fluid) — while the engines that compile to code pay their codegen up front: 303× (Handlebars) and 894× (RazorLight), which is exactly what hurts in serverless, Native AOT and short-lived processes.

Warm — steady-state render of a fresh model — NgSharp (25.0 µs / 32.9 KB) leads every engine including RazorLight's compiled delegate (37.4 µs), with the least allocation of the field.

What gets it there with no codegen:

  • the parser emits a folded program in a single pass,
  • model reads are lazy and zero-copy,
  • each access site carries a monomorphic inline cache,
  • and pipes format as spans straight into the pooled output buffer.
BenchmarkDotNet 0.15.2 Apple M1 Max · 10 cores .NET 10.0.9 10 iterations · 5 warmups

Suite 2 · Feature-complete showcase

The comparison above is capped at what all six engines share — nested loops, truthy conditionals, plain interpolation.

This second one drops that ceiling: a corporate report four levels deep (departments → teams → members → tasks, 90 KB of output) that uses everything NgSharp does — fragments, transparent containers, named loop variables, attribute conditional chains, @switch dispatch, the [not-empty]/[empty] guards, rawtext <style> interpolation, every binding kind, twelve of the thirteen built-in pipes (json belongs to the text arena), and a custom pipe, directive and component.

Four of the six reach the same bytes:

  • NgSharp — natively, in one template,
  • Handlebars — with twenty-one registered C# helpers plus a partial,
  • Fluid — with fourteen custom filters,
  • RazorLight — because it is Razor (full C#).

All rendering byte-identical HTML, gated before every run.

fragments <ng-template> · @render · ng-container @for (x of …) · [for] 4 deep · [not-empty] · [empty] @if/@else if/@else · [if]/[else-if]/[else] · ternary @switch/@case/@default first match + fallback bindings [class] [attr] [style] [html] pipes date · number · currency · upper · lower · titlecase · default · truncate · join · pad · largeNumber · image + custom rawtext <style> · indexers · .Count / .Length custom directive · custom component
EngineMeanAllocatedvs NgSharp
Cold — build from scratch + render
NgSharp457 µs313 KB1.00×
Fluid579 µs711 KB1.27×
Handlebars.Net45,522 µs2.5 MB100×
RazorLight130,646 µs12.0 MB286×
Warm — prepared once, render only
NgSharp297 µs228 KB1.00×
RazorLight361 µs492 KB1.22×
Handlebars.Net396 µs513 KB1.33×
Fluid526 µs654 KB1.77×

Where the compiled engines catch up. Cold, the compiled engines pay their cliff in full — 45.5 ms for Handlebars, 131 ms and 12 MB for RazorLight's Roslyn assembly — where NgSharp parses and renders the whole document in 457 µs, still ahead of Fluid, the other interpreter.

Warm, NgSharp (297 µs / 228 KB) leads every engine: the compiled delegates take 361–396 µs at ~2.2× the allocations, and Fluid takes 526 µs at 2.9×. Reusing a prebuilt NgElement context measures the same (304 µs) — model reads are lazy now, so the fresh-model path is the hot path; there is no conversion tax left to skip.

NgSharp writes it in one interpreted, native template — operators, formatting, fragments and every binding inline; Handlebars needs twenty-one registered helpers and a partial, Fluid fourteen custom filters, RazorLight the Roslyn compiler.

The showcase document — same byte-identical output, four engines
<!-- one native template: fragments, ng-container, named @for, [else-if] chain, rawtext <style>, pipes, bindings, custom directive + component -->
<article class="company" [data-headcount]="Headcount">
  <style>.masthead { border-bottom: 2px solid {{ AccentColor }}; }{{ PrintCss }}</style>
  <h1>{{ Name | upper }}</h1>
  <img class="brand" [src]="Logo | image" [attr.alt]="Name">
  <p>Founded {{ FoundedAt | date:'yyyy' }} · {{ Headcount | largeNumber }} people across {{ Departments?.Count + ' departments' }}</p>
  <p>{{ Name | initials }} · {{ Name.Length }} letters</p>
  <ng-template #kpi><div class="kpi"><b>{{ Name }}</b> operates on {{ Budget | number:'C0' }}</div></ng-template>
  <section class="highlights" [style.background-image]="Logo | image">
    @render(kpi, Departments[0])
    @render(kpi, Departments[1])
  </section>
  <nav class="directory">
    @for (d of Departments)
    {
      <a class="dir">{{ d.Name }} · {{ Name | initials }}</a>
    }
  </nav>
  <ng-container [if]="Headcount != 0"><p>{{ Headcount }} active employees</p></ng-container>
  <section class="dept" [for]="Departments" [class.core]="IsCore" [audit]="Budget >= 500000">
    <h2 [style.color]="ThemeColor">{{ Name }}</h2>
    <span [if]="Budget >= 600000" class="band">expanded</span>
    <span [else-if]="Budget >= 400000" class="band">standard</span>
    <span [else]="" class="band">lean</span>
    <!-- … Teams → Members → Tasks: three more [for] levels — @if/@else if/@else, ternary,
         [html], [not-empty], && == * — then the extended catalogue: @switch/@case/@default,
         [empty], and the currency/lower/titlecase/default/truncate/join/pad pipes —
         full template: src/NgSharp.Benchmark/Templates/NgSharp/showcase.html -->
  </section>
  <headcount-badge [total]="Headcount"></headcount-badge>
</article>
// same output — but operators, pipes and the fragment become {{helpers}} and a partial
<article class="company" data-headcount="{{Headcount}}">
  <style>.masthead { border-bottom: 2px solid {{AccentColor}}; }{{{PrintCss}}}</style>
  <h1>{{upper Name}}</h1>
  <img class="brand" src="{{{imgsrc Logo}}}" alt="{{Name}}">
  <p>Founded {{date FoundedAt "yyyy"}} · {{largeNumber Headcount}} people across {{Departments.Count}} departments</p>
  <p>{{initials Name}} · {{len Name}} letters</p>
  <section class="highlights" style="background-image: {{{imgurl Logo}}}">
    {{> kpi Departments.[0]}}
    {{> kpi Departments.[1]}}
  </section>
  <nav class="directory">
    {{#each Departments}}
      <a class="dir">{{Name}} · {{initials ../Name}}</a>
    {{/each}}
  </nav>
  {{#if (ne Headcount 0)}}<p>{{Headcount}} active employees</p>{{/if}}
  {{#each Departments}}
    <section class="dept{{#if IsCore}} core{{/if}}"{{#if (gte Budget 500000)}} data-audit="required"{{/if}}>
      {{#if (gte Budget 600000)}}<span class="band">expanded</span>{{else}}{{#if (gte Budget 400000)}}<span class="band">standard</span>{{else}}<span class="band">lean</span>{{/if}}{{/if}}
      <!-- … three more {{#each}} levels down to tasks … -->
    </section>
  {{/each}}
</article>

// + registered once in C#, because Handlebars has no operators/formatting/fragments built in:
hb.RegisterHelper("gte", (c, a) => ToNum(a[0]) >= ToNum(a[1]));
// …and gt, eq, ne, and, or, upper, num, date, largeNumber, len, initials, imgsrc, imgurl,
// lower, titlecase, pad, default, truncate, join, currency — twenty-one helpers
hb.RegisterTemplate("kpi", …);   // the <ng-template>/@render counterpart
// Liquid — the other interpreter; pipes become custom filters, the fragment must be inlined
<article class="company" data-headcount="{{ Headcount }}">
  <style>.masthead { border-bottom: 2px solid {{ AccentColor }}; }{{ PrintCss }}</style>
  <h1>{{ Name | upper }}</h1>
  <p>Founded {{ FoundedAt | date: 'yyyy' }} · {{ Headcount | largeNumber }} people across {{ Departments.size }} departments</p>
  <section class="highlights" style="background-image: {{ Logo | imgurl }}">
    <div class="kpi"><b>{{ Departments[0].Name }}</b> operates on {{ Departments[0].Budget | number: 'C0' }}</div>
    <div class="kpi"><b>{{ Departments[1].Name }}</b> operates on {{ Departments[1].Budget | number: 'C0' }}</div>
  </section>
  {% if Headcount != 0 %}<p>{{ Headcount }} active employees</p>{% endif %}
  {% for d in Departments %}
    <section class="dept{% if d.IsCore %} core{% endif %}"{% if d.Budget >= 500000 %} data-audit="required"{% endif %}>
      {% if d.Budget >= 600000 %}<span class="band">expanded</span>{% elsif d.Budget >= 400000 %}<span class="band">standard</span>{% else %}<span class="band">lean</span>{% endif %}
      <!-- … three more {% for %} levels down to tasks … -->
    </section>
  {% endfor %}
</article>

// + fourteen custom filters registered in C#: number, date, upper, largeNumber, initials, imgsrc, imgurl,
// lower, titlecase, pad, default, truncate, join, currency
// full C# — no helpers needed — but the first render compiles a Roslyn assembly
<article class="company" data-headcount="@Model.Headcount">
  <style>.masthead { border-bottom: 2px solid @Model.AccentColor; }@Raw(Model.PrintCss)</style>
  <h1>@Model.Name.ToUpper()</h1>
  <p>Founded @Model.FoundedAt.ToString("yyyy") · @Model.Headcount people across @Model.Departments.Count departments</p>
  <section class="highlights" style="background-image: @Raw(Showcase.ImageUrl(Model.Logo))">
    <div class="kpi"><b>@(Model.Departments[0].Name)</b> operates on @Raw(Model.Departments[0].Budget.ToString("C0"))</div>
    <div class="kpi"><b>@(Model.Departments[1].Name)</b> operates on @Raw(Model.Departments[1].Budget.ToString("C0"))</div>
  </section>
  @if (Model.Headcount != 0) { <p>@Model.Headcount active employees</p> }
  @foreach (var d in Model.Departments)
  {
    <section class="dept@(d.IsCore ? " core" : "")"@Raw(d.Budget >= 500000 ? " data-audit=\"required\"" : "")>
      @if (d.Budget >= 600000) { <span class="band">expanded</span> }
      else if (d.Budget >= 400000) { <span class="band">standard</span> }
      else { <span class="band">lean</span> }
      <!-- … three more @foreach levels down to tasks … -->
    </section>
  }
</article>
var company = new Company
{
    Name = "Contoso", LogoHtml = "<svg …/>", Headcount = 100,
    FoundedAt = new DateTime(2009, 6, 1),
    AccentColor = "#1E47B5", PrintCss = "article.company > .dept { break-inside: avoid; }",
    Logo = new ImageData { FileName = "logo.png", FileContent = pngBytes },   // the image pipe never decodes
    Departments =                       // 5 depts × 4 teams × 5 members × 4 tasks = 400 tasks
    [
        new Department { Name = "Department 0", Budget = 250000m, IsCore = true, ThemeColor = "#1E47B5",
            Teams = [
                new Team { Name = "Team 0-0", Members = [
                    new Member { Name = "Person 000", Role = "Engineer", Salary = 55000m, Age = 22,
                        IsLead = true, IsRemote = false, JoinedAt = …, StatusHtml = "<b>active</b>",
                        RemoteLabel = "remote", OnsiteLabel = "on-site",
                        Tasks = [
                            new TaskItem { Title = "Task 0-0-0-0", Priority = "high", Done = true, Points = 1 },
                            // … 3 more tasks …
                        ]},
                    // … 4 more members …
                ]},
                // … 3 more teams …
            ]},
        // … 4 more departments …
    ]
};

Suite 3 · Realistic documents

The suite closest to production: three print/PDF document archetypes, ported byte-identically to Fluid, Handlebars and Scriban:

  • a pipe-heavy commercial quote (31 KB of output: dates, currency, totals, discount rows),
  • a product sheet (4.5 KB),
  • and a card grid (17 KB).

The expected outputs are committed goldens; every engine × every archetype is gated for byte identity before measuring.

EngineColdCold allocWarmWarm alloc
Commercial quote — 31 KB, pipe-heavy
NgSharp106.1 µs127 KB71.4 µs74.7 KB
Handlebars.Net32,367 µs1.2 MB106.3 µs163 KB
Fluid160.4 µs173 KB127.6 µs130 KB
Scriban771.6 µs735 KB618.2 µs614 KB
Product sheet — 4.5 KB
NgSharp27.9 µs39.6 KB6.9 µs10.2 KB
Handlebars.Net14,830 µs688 KB10.9 µs28.4 KB
Fluid37.1 µs41.2 KB14.3 µs17.6 KB
Scriban155.6 µs281 KB116.8 µs221 KB
Card grid — 17 KB
NgSharp40.9 µs55.1 KB26.3 µs38.4 KB
Handlebars.Net11,045 µs488 KB57.4 µs87.7 KB
Fluid70.4 µs72.4 KB57.9 µs57.9 KB
Scriban274.8 µs406.7 KB233.8 µs368.1 KB

Three documents, one pattern. First on every cell — cold and warm, time and allocations, on each of the three documents.

On the quote, compiled Handlebars stays 1.5× behind warm (106.3 µs / 163 KB vs 71.4 µs / 74.7 KB) after paying a 32 ms compile up front; on the small product sheet the gap warm is 1.6× (6.9 µs vs 10.9). No codegen, no compile cliff to amortize.

Suite 4 · Text mode — the JSON export

The text-mode arena: the same commercial quote model, exported as strict-valid JSON (5.7 KB of output — nested @for over sections and lines, conditional discount properties, json and date pipes), rendered with TemplateMode.Text.

This is the native terrain of the text engines — pure text output, none of NgSharp's HTML machinery in play. Ported byte-identically to Fluid, Handlebars and Scriban; the textcmp gate checks the four outputs byte-for-byte and runs JsonDocument.Parse — the export must be valid JSON, not just identical bytes.

EngineMeanAllocatedvs NgSharp
Cold — parse + render
NgSharp TemplateMode.Text52.4 µs75 KB1.00×
Fluid94.0 µs103 KB1.79×
Scriban390.7 µs445.7 KB7.45×
Handlebars.Net13,353 µs970 KB255×
Warm — prepared once, render only
NgSharp TemplateMode.Text37.4 µs39.6 KB1.00×
Handlebars.Net46.5 µs60.6 KB1.24×
Fluid62.2 µs72.6 KB1.66×
Scriban310.0 µs356.9 KB8.29×
The JSON export template — same byte-identical output, four engines
// the native template, rendered with TemplateMode.Text — nested @for, conditional discount property, json + date pipes, raw output
{
  "export":{
    "kind":"quote",
    "source":"arena-text",
    "generatedAt":"{{ IssueDate }}"
  },
  "number":{{ Number | json }},
  "reference":{{ Reference | json }},
  "issueDate":"{{ IssueDate | date:'yyyy-MM-dd' }}",
  "validUntil":"{{ ValidUntil | date:'yyyy-MM-dd' }}",
  "campaign":{
    "name":{{ CampaignName | json }},
    "start":"{{ CampaignStart | date:'yyyy-MM-dd' }}",
    "end":"{{ CampaignEnd | date:'yyyy-MM-dd' }}",
    "impressions":{{ TotalImpressions }}
  },
  "issuer":{
    "name":{{ Issuer.Name | json }},
    "city":{{ Issuer.City | json }},
    "siret":{{ Issuer.Siret | json }},
    "tva":{{ Issuer.Tva | json }},
    "email":{{ Issuer.Email | json }}
  },
  "client":{
    "name":{{ Client.Name | json }},
    "city":{{ Client.City | json }},
    "contact":{{ Client.Contact | json }},
    "email":{{ Client.Email | json }}
  },
  "sections":[
    @for (Sections) {
      {
        "title":{{ Title | json }},
        "discounted":{{ HasDiscount }},
        "lines":[
          @for (Lines) {
            {
              "ref":{{ Ref | json }},
              "qty":{{ Quantity }},
              "unit":{{ UnitPrice }},
              @if (Discount > 0) {
                "discount":{{ Discount }},
              }
              "ht":{{ TotalHT }}
            },
          }
          {
            "type":"subtotal",
            "ht":{{ SubtotalHT }},
            "impressions":{{ SectionImpressions }}
          }
        ]
      },
    }
    {
      "type":"totals",
      "ht":{{ TotalHT }},
      "tvaRate":{{ TvaRate }},
      "tva":{{ TotalTva }},
      "ttc":{{ TotalTTC }},
      "impressions":{{ TotalImpressions }}
    }
  ],
  "hasOptions":{{ HasOptions }},
  "options":{{ Options | json }},
  "terms":{{ Terms | json }},
  "notes":{{ Notes | json }}
}
// Liquid — same shape; decimals need a custom jnum filter, the trailing comma a {% unless forloop.last %}
{
  "export":{
    "kind":"quote",
    "source":"arena-text",
    "generatedAt":"{{ IssueDate | iso }}"
  },
  "number":{{ Number | json }},
  "reference":{{ Reference | json }},
  "issueDate":"{{ IssueDate | date:'yyyy-MM-dd' }}",
  "validUntil":"{{ ValidUntil | date:'yyyy-MM-dd' }}",
  "campaign":{
    "name":{{ CampaignName | json }},
    "start":"{{ CampaignStart | date:'yyyy-MM-dd' }}",
    "end":"{{ CampaignEnd | date:'yyyy-MM-dd' }}",
    "impressions":{{ TotalImpressions }}
  },
  "issuer":{
    "name":{{ Issuer.Name | json }},
    "city":{{ Issuer.City | json }},
    "siret":{{ Issuer.Siret | json }},
    "tva":{{ Issuer.Tva | json }},
    "email":{{ Issuer.Email | json }}
  },
  "client":{
    "name":{{ Client.Name | json }},
    "city":{{ Client.City | json }},
    "contact":{{ Client.Contact | json }},
    "email":{{ Client.Email | json }}
  },
  "sections":[
    {% for s in Sections %}
      {
        "title":{{ s.Title | json }},
        "discounted":{{ s.HasDiscount }},
        "lines":[
          {% for line in s.Lines %}
            {
              "ref":{{ line.Ref | json }},
              "qty":{{ line.Quantity }},
              "unit":{{ line.UnitPrice | jnum }},
              {% if line.Discount > 0 %}
                "discount":{{ line.Discount | jnum }},
              {% endif %}
              "ht":{{ line.TotalHT | jnum }}
            },
          {% endfor %}
          {
            "type":"subtotal",
            "ht":{{ s.SubtotalHT | jnum }},
            "impressions":{{ s.SectionImpressions }}
          }
        ]
      },
    {% endfor %}
    {
      "type":"totals",
      "ht":{{ TotalHT | jnum }},
      "tvaRate":{{ TvaRate | jnum }},
      "tva":{{ TotalTva | jnum }},
      "ttc":{{ TotalTTC | jnum }},
      "impressions":{{ TotalImpressions }}
    }
  ],
  "hasOptions":{{ HasOptions }},
  "options":[
    {% for o in Options %}
      {"Text":{{ o.Text | json }}}
      {% unless forloop.last %},{% endunless %}
    {% endfor %}
  ],
  "terms":[
    {% for t in Terms %}
      {"Text":{{ t.Text | json }}}
      {% unless forloop.last %},{% endunless %}
    {% endfor %}
  ],
  "notes":{{ Notes | json }}
}
// Handlebars cannot lex the }}} a minified JSON template produces — every object close after an interpolation is a {{cb}} helper, including the root brace
{
  "export":{
    "kind":"quote",
    "source":"arena-text",
    "generatedAt":"{{iso IssueDate}}"
  },
  "number":{{json Number}},
  "reference":{{json Reference}},
  "issueDate":"{{date IssueDate "yyyy-MM-dd"}}",
  "validUntil":"{{date ValidUntil "yyyy-MM-dd"}}",
  "campaign":{
    "name":{{json CampaignName}},
    "start":"{{date CampaignStart "yyyy-MM-dd"}}",
    "end":"{{date CampaignEnd "yyyy-MM-dd"}}",
    "impressions":{{TotalImpressions}}
  {{cb}},
  "issuer":{
    "name":{{json Issuer.Name}},
    "city":{{json Issuer.City}},
    "siret":{{json Issuer.Siret}},
    "tva":{{json Issuer.Tva}},
    "email":{{json Issuer.Email}}
  {{cb}},
  "client":{
    "name":{{json Client.Name}},
    "city":{{json Client.City}},
    "contact":{{json Client.Contact}},
    "email":{{json Client.Email}}
  {{cb}},
  "sections":[
    {{#each Sections}}
      {
        "title":{{json Title}},
        "discounted":{{jbool HasDiscount}},
        "lines":[
          {{#each Lines}}
            {
              "ref":{{json Ref}},
              "qty":{{Quantity}},
              "unit":{{jnum UnitPrice}},
              {{#if (gt0 Discount)}}
                "discount":{{jnum Discount}},
              {{/if}}
              "ht":{{jnum TotalHT}}
            {{cb}},
          {{/each}}
          {
            "type":"subtotal",
            "ht":{{jnum SubtotalHT}},
            "impressions":{{SectionImpressions}}
          {{cb}}
        ]
      },
    {{/each}}
    {
      "type":"totals",
      "ht":{{jnum TotalHT}},
      "tvaRate":{{jnum TvaRate}},
      "tva":{{jnum TotalTva}},
      "ttc":{{jnum TotalTTC}},
      "impressions":{{TotalImpressions}}
    {{cb}}
  ],
  "hasOptions":{{jbool HasOptions}},
  "options":[
    {{#each Options}}
      {"Text":{{json Text}}{{cb}}
      {{#unless @last}},{{/unless}}
    {{/each}}
  ],
  "terms":[
    {{#each Terms}}
      {"Text":{{json Text}}{{cb}}
      {{#unless @last}},{{/unless}}
    {{/each}}
  ],
  "notes":{{json Notes}}
{{cb}}
// Scriban — same shape; a custom jnum function for invariant decimals, for.last for the trailing comma
{
  "export":{
    "kind":"quote",
    "source":"arena-text",
    "generatedAt":"{{ IssueDate | iso }}"
  },
  "number":{{ Number | json }},
  "reference":{{ Reference | json }},
  "issueDate":"{{ IssueDate | date 'yyyy-MM-dd' }}",
  "validUntil":"{{ ValidUntil | date 'yyyy-MM-dd' }}",
  "campaign":{
    "name":{{ CampaignName | json }},
    "start":"{{ CampaignStart | date 'yyyy-MM-dd' }}",
    "end":"{{ CampaignEnd | date 'yyyy-MM-dd' }}",
    "impressions":{{ TotalImpressions }}
  },
  "issuer":{
    "name":{{ Issuer.Name | json }},
    "city":{{ Issuer.City | json }},
    "siret":{{ Issuer.Siret | json }},
    "tva":{{ Issuer.Tva | json }},
    "email":{{ Issuer.Email | json }}
  },
  "client":{
    "name":{{ Client.Name | json }},
    "city":{{ Client.City | json }},
    "contact":{{ Client.Contact | json }},
    "email":{{ Client.Email | json }}
  },
  "sections":[
    {{ for s in Sections }}
      {
        "title":{{ s.Title | json }},
        "discounted":{{ s.HasDiscount }},
        "lines":[
          {{ for line in s.Lines }}
            {
              "ref":{{ line.Ref | json }},
              "qty":{{ line.Quantity }},
              "unit":{{ line.UnitPrice | jnum }},
              {{ if line.Discount > 0 }}
                "discount":{{ line.Discount | jnum }},
              {{ end }}
              "ht":{{ line.TotalHT | jnum }}
            },
          {{ end }}
          {
            "type":"subtotal",
            "ht":{{ s.SubtotalHT | jnum }},
            "impressions":{{ s.SectionImpressions }}
          }
        ]
      },
    {{ end }}
    {
      "type":"totals",
      "ht":{{ TotalHT | jnum }},
      "tvaRate":{{ TvaRate | jnum }},
      "tva":{{ TotalTva | jnum }},
      "ttc":{{ TotalTTC | jnum }},
      "impressions":{{ TotalImpressions }}
    }
  ],
  "hasOptions":{{ HasOptions }},
  "options":[
    {{ for o in Options }}
      {"Text":{{ o.Text | json }}}
      {{ if !for.last }},{{ end }}
    {{ end }}
  ],
  "terms":[
    {{ for t in Terms }}
      {"Text":{{ t.Text | json }}}
      {{ if !for.last }},{{ end }}
    {{ end }}
  ],
  "notes":{{ Notes | json }}
}

Their home turf. Even on pure JSON — the terrain the text engines were built for — NgSharp is first on every cell: time and allocations, cold and warm.

Warm, compiled Handlebars comes closest (46.5 µs, 1.24×) after paying a 13 ms compile up front; Fluid, the other interpreter, sits at 1.66×. Cold, NgSharp parses and renders in 52.4 µs — no cliff to amortize.

One porting anecdote: Handlebars.Net cannot lex the }}} a minified JSON template produces at every object close after an interpolation — the port needs a dedicated helper just to emit the closing brace.

Numbers vary with template, model and hardware — measure on yours with src/NgSharp.Benchmark:

  • --filter *EngineComparison* · *FeatureShowcase* · *RealisticDocument* · *TextDocumentBenchmark* — one suite each.
  • showcmp and realistic-verify check byte identity against the committed goldens before you trust any run.
  • textcmp gates the text arena: four engines byte-identical, plus a strict JSON parse.