Mechanical Keyboard
$129
-20%Documentation
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.
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
Install the package, grab a builder, and render a template against any model. Zero configuration — the built-in pipes are already registered.
dotnet add package NgSharp
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
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.
Compiled template renders concurrently across threads.JsonElement is read in place as expressions demand it — no intermediate data store is built per render.JsonElement / NgElement path; the reflection-based object overload and component binding are opt-in. No Regex anywhere in the library.Start
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
Each block shows what NgSharp renders, with the template and the C# behind the tabs.
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.
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>
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> }
}
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>
}
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 }}).
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>
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.
| Attribute | Block form | Notes |
|---|---|---|
<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 · $last | identical | The 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.
| # | Item | Qty |
|---|---|---|
| 1 | Pen | 3 |
| 2 | Book | 1 |
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>
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.
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>
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.
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>
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.
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>
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.
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).
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.
{ or } in static text skews @if / @for matching — every block language shares this trait, Razor included. Escape a lone brace as an interpolated literal: {{ '}' }}.@if (x) {{ X }} parses as block-open plus a literal brace run — write @if (x) { {{ X }} }.json pipe. A raw {{ Name }} between hand-written quotes breaks on the first " or newline in the data — {{ Name | json }} never does.Guide · cookbook
Complete templates that combine several features. See the rendered result, then flip to the C# that produced it and the template itself.
$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>
| Item | Qty | Total |
|---|---|---|
| Cable | 2 | $19.98 |
| Adapter | 1 | $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>
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>
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>
}
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
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.
Billed to Acme Corp · Jul 15, 2026
| License · Pro | 3 × $49.00 | $147.00 |
| Onboarding | 1 × $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>
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>
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>
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 · @else | Block conditional. |
@switch · @case · @default | Block 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 · $last | Loop 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) && c | Parentheses group sub-expressions — they override precedence anywhere an expression is accepted. |
a?.b | Safe navigation — resolves like a.b (never throws; a missing step is null). |
Items[0].Name · .Count · .Length | Array / 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.Text | Non-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 world → Hello 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
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.
var tpl = HtmlBuilder.Create().Compile(
"<li>{{ Name }}</li>");
foreach (var user in users)
output.Append(tpl.Render(user));
// 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
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.
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);
// 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
Every public type is documented with XML comments, so the same descriptions surface in your IDE's IntelliSense.
HtmlBuilderThe entry point: registers pipes/directives/components and renders templates. HtmlBuilder.Create() returns a builder pre-loaded with the built-in pipes.
static HtmlBuilder Create()methodCreates 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)methodSame, 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])overloadsRenders 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])methodParses 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])methodReports 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>()methodsRegisters a pipe / component / directive. Each T is instantiated via its parameterless constructor.
void RegisterPipe(IPipe) · RegisterDirective(IDirective) · RegisterComponent<T>(T instance)methodsInstance 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 · ComponentspropertiesThe registered extensions, keyed by name.
CompiledTemplateA 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])overloadsRenders 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])overloadsRenders 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])overloadsThe 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; }propertyThe dialect the template was compiled with (TemplateMode.Html / TemplateMode.Text) — captured at Compile time; a contradicting mode in Render options throws.
bool Strict { get; }propertyTrue 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.
TemplateOptionsThe 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; }propertyCompile-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; }propertyBoth 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; }propertyRender-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; }propertyRender-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 DefaultfieldThe canonical empty options — every property null. Passing Default, null, or nothing at all are equivalent.
NgElementThe 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)factoriesFromJson 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)factoriesScalar 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)methodResolves 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() · …gettersTyped 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 KeypropertiesThe 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
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 instance — RegisterPipe(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.
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"
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>
IPipe · IDirective · IComponentThe 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)interfaceA value transform for {{ value | pipeName }}.
IDirective : string DirectiveName · void Apply(DirectiveElement el, NgElement content)interfaceMutates a host element via [directive]="expr". DirectiveElement exposes TagName, GetAttribute, SetAttribute, RemoveAttribute.
IComponent : string ComponentName · string Render()interfaceA 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()pipesdate · 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; }contractThe 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
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 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.
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.
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).
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.
NgSharpException, naming the path.?. ({{ Delivery?.Date }}) are declared optional and never throw.@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.0).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 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.
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.
TemplateDiagnosticOne finding from HtmlBuilder.Validate(template, mode).
DiagnosticSeverity Severity · string Message · int PositionpropertiesSeverity 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
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.
RenderLimitsOpt-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)constructorDeliberately generous defaults — roomy for any legitimate document, fatal for a runaway one. Each cap must be positive.
int MaxOutputChars · MaxLoopIterations · MaxRenderDepth · static RenderLimits NonepropertiesMaxOutputChars 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
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
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
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
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
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
Four suites, the same logical document ported to each engine's own dialect, every port rendering byte-identical output:
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:
<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 …
]
};
| Engine | Mean | Allocated | vs NgSharp |
|---|---|---|---|
| Cold — first parse + render | |||
| NgSharp | 32.2 µs | 41 KB | 1.00× |
| Fluid | 55.9 µs | 61 KB | 1.74× |
| Stubble | 85.8 µs | 193 KB | 2.66× |
| Scriban | 168.1 µs | 265 KB | 5.22× |
| Handlebars.Net | 9,765 µs | 382 KB | 303× |
| RazorLight | 28,821 µs | 3.2 MB | 894× |
| Warm — pre-parsed, render only | |||
| NgSharp | 25.0 µs | 32.9 KB | 1.00× |
| RazorLight | 37.4 µs | 97.8 KB | 1.49× |
| Handlebars.Net | 48.3 µs | 90.4 KB | 1.93× |
| Fluid | 67.4 µs | 53.3 KB | 2.69× |
| Scriban | 152.9 µs | 241.6 KB | 6.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:
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:
All rendering byte-identical HTML, gated before every run.
| Engine | Mean | Allocated | vs NgSharp |
|---|---|---|---|
| Cold — build from scratch + render | |||
| NgSharp | 457 µs | 313 KB | 1.00× |
| Fluid | 579 µs | 711 KB | 1.27× |
| Handlebars.Net | 45,522 µs | 2.5 MB | 100× |
| RazorLight | 130,646 µs | 12.0 MB | 286× |
| Warm — prepared once, render only | |||
| NgSharp | 297 µs | 228 KB | 1.00× |
| RazorLight | 361 µs | 492 KB | 1.22× |
| Handlebars.Net | 396 µs | 513 KB | 1.33× |
| Fluid | 526 µs | 654 KB | 1.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.
<!-- 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:
The expected outputs are committed goldens; every engine × every archetype is gated for byte identity before measuring.
| Engine | Cold | Cold alloc | Warm | Warm alloc |
|---|---|---|---|---|
| Commercial quote — 31 KB, pipe-heavy | ||||
| NgSharp | 106.1 µs | 127 KB | 71.4 µs | 74.7 KB |
| Handlebars.Net | 32,367 µs | 1.2 MB | 106.3 µs | 163 KB |
| Fluid | 160.4 µs | 173 KB | 127.6 µs | 130 KB |
| Scriban | 771.6 µs | 735 KB | 618.2 µs | 614 KB |
| Product sheet — 4.5 KB | ||||
| NgSharp | 27.9 µs | 39.6 KB | 6.9 µs | 10.2 KB |
| Handlebars.Net | 14,830 µs | 688 KB | 10.9 µs | 28.4 KB |
| Fluid | 37.1 µs | 41.2 KB | 14.3 µs | 17.6 KB |
| Scriban | 155.6 µs | 281 KB | 116.8 µs | 221 KB |
| Card grid — 17 KB | ||||
| NgSharp | 40.9 µs | 55.1 KB | 26.3 µs | 38.4 KB |
| Handlebars.Net | 11,045 µs | 488 KB | 57.4 µs | 87.7 KB |
| Fluid | 70.4 µs | 72.4 KB | 57.9 µs | 57.9 KB |
| Scriban | 274.8 µs | 406.7 KB | 233.8 µs | 368.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.
| Engine | Mean | Allocated | vs NgSharp |
|---|---|---|---|
| Cold — parse + render | |||
| NgSharp TemplateMode.Text | 52.4 µs | 75 KB | 1.00× |
| Fluid | 94.0 µs | 103 KB | 1.79× |
| Scriban | 390.7 µs | 445.7 KB | 7.45× |
| Handlebars.Net | 13,353 µs | 970 KB | 255× |
| Warm — prepared once, render only | |||
| NgSharp TemplateMode.Text | 37.4 µs | 39.6 KB | 1.00× |
| Handlebars.Net | 46.5 µs | 60.6 KB | 1.24× |
| Fluid | 62.2 µs | 72.6 KB | 1.66× |
| Scriban | 310.0 µs | 356.9 KB | 8.29× |
// 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.