This is the complete reference for the Heddle template language. Every construct below is grounded in the ANTLR grammar — HeddleLexer.g4 (tokens) and HeddleParser.g4 (rules) — and illustrated with clear, self‑contained examples built around one small model (see below). The examples are written to teach; for real, runnable templates that exercise edge cases, see the test fixtures in src/Heddle.Tests/TestTemplate.
Delimiters at a glance:
@% … %@wraps definition blocks and{{ … }}wraps bodies / subtemplates.
- Mental model
- Symbol cheat sheet
- Text and the
@escape - Output blocks
- Output profiles
- Member expressions
@(A.B.C) - Root reference
@(::Member) - Context and data flow
- Embedded C# expressions
- Definitions
@% … %@ - Default output
-> chain - Type annotation
:: Type - Props
<name(prop: Type = default)> - Parameterized slots
out:: Type - Inheritance and override
<child:base> - Named content regions
<:name> - Subtemplates
{{ … }} - Chaining with
: - Recursion
- Imports
@<<{{ … }} - Comments
@* … *@ - Raw blocks
@{ … }@and@: - Whitespace trimming
@\ - How the lexer reads a template (modes)
- Behavioral nuances summary
A Heddle document (heddle rule) is a sequence of:
- text — literal output, copied verbatim;
- output blocks —
@…directives that call extensions and emit their results; - definitions —
@% … %@blocks that declare reusable named templates; - imports —
@<<{{ path }}that pull definitions in from another file; - raw blocks — verbatim regions that are not parsed.
document = ( text | output | definition | import | raw )*
Everything special starts with @. Plain text never needs escaping unless it contains @.
Notation. The "shape" boxes below use a simplified syntax, not the real grammar:
[ x ]= optional,( x )*= zero or more,a | b= either, and literal symbols (@,{{,:,::, …) are written as you'd type them. For the exact rules see the authoritative HeddleLexer.g4 / HeddleParser.g4.
Every example in this reference renders the same little blog model. Keep it in mind as you read:
class Blog { string Title; int Year; ICollection<Article> Articles; }
class Article { string Title; Author Author; DateTime PublishedOn;
bool IsFeatured; IList<string> Tags; ICollection<Comment> Comments; }
class Author { string Name; string Email; }
class Comment { string Author; string Text; ICollection<Comment> Replies; }So the root model is a Blog; inside @list(Articles){{ … }} the current model is an
Article; and so on.
If you are coming from Razor, two of Heddle's design choices will surprise you. Both are deliberate, and both are what make templates compose:
-
Context is relative, not absolute. A member path
@(Title)is resolved against the current context, and that context changes as you nest calls (a call either descends into its parameter or, for conditionals and formatters, keeps the caller's context). This is the Gotext/template(./$) and Mustache model, not Razor's always‑absoluteModel.X. The escape hatch back to the original model is::; the full picture is in Context and data flow. -
Heddle definitions are abstract by default; types bind late. A definition written without
:: Typehas no fixed model type — it is a polymorphic section. Its real type is bound when it's actually used (compiled against whatever concrete model reaches that call site) or when an inheriting definition narrows it with:: Type. This is closer to a C++ template than a C# generic: there are no type parameters or constraints — each use re‑substitutes the concrete model and is type‑checked on its own. Write the section once; every call site is its own specialization. See Type annotation and Inheritance.
| Syntax | Name | Meaning |
|---|---|---|
@(expr) |
Output expression | Emit a member value or C# expression. |
@name(param) |
Extension call | Invoke extension name (e.g. @list, @if). |
a:b:c |
Chain | Compose calls right‑to‑left; the leftmost renders, each call feeds the one to its left. |
{{ … }} |
Block / subtemplate | Body of a definition or the inline template for a call. |
@% … %@ |
Definition block | Declare one or more named templates. |
<name> |
Definition name | Names a template inside @% … %@. |
<child:base> |
Inheritance | Define child based on base. |
<name(p: T = d)> |
Props | Typed named parameters with literal defaults; passed by name (@name(model, p: v)). |
out:: Type |
Slot parameter | Declares a typed slot; @out(expr) renders the caller body once per value as its model. |
this |
Current model | The current scope's model as an expression (e.g. @out(this)). |
-> chain |
Default output | Marks a definition to render automatically; chain selects its data. |
:: Type |
Type annotation | Strongly types a definition's model. |
::Member |
Root reference | Read Member from the root model, not the current one. |
@<<{{path}} |
Import | Include definitions from another template file (path taken verbatim — no spaces). |
@* … *@ |
Comment | Ignored; never emitted. |
@{ … }@ |
Raw block | Emitted verbatim; not parsed. |
@: … |
Raw line | Rest of the line emitted verbatim. |
@\ |
Whitespace trim | Eat the following run of whitespace. |
Any character that is not part of a directive is literal text and is emitted unchanged.
Unicode is fully supported — <h1>Café — Привет!</h1> renders as written.
The only special character is @. To emit a single literal @, double it: @@ is the
character‑doubling escape, in top‑level text and in subtemplate bodies alike. Runs of @ pair
greedily left to right (@@@@ → @@; @@@(Title) → @ followed by the value of @(Title)):
<p>Reach us at @@example.com</p> @* outputs: Reach us at @example.com *@
One accepted limit: @@ immediately followed by * is not an escape — it stays
directive‑@ + comment‑start (@*…*@). To output a literal @*, use a raw region (@{@*}@)
or a raw line (@:@*).
For bulk literal text, raw regions remain the tool — the raw block
@{ … }@ or the raw line @: — which are emitted verbatim and never parsed (inside them @@
stays two characters):
<p>Reach us at @{support@example.com}@</p> @* outputs: support@example.com *@
@* @: makes the entire rest of the line literal (comment stripped, line below emitted as-is): *@
@: Reach us at support@example.com
Those same raw regions are the way to emit a block of literal text that contains many @ or
{{ }} characters without escaping each one.
An output block is @ followed by a chain of one or more calls, with
an optional subtemplate:
output = @ chain [ {{ body }} ]
chain = call ( : call )*
call = [name] ( parameter )
parameter = member-path | @ C#-expression | chain | (empty)
A call has three parts: an optional extension name, a parenthesized parameter, and an
optional {{ … }} body. The parameter is one of:
- a member expression —
@(Title),@list(Articles); - a native expression — the built‑in expression tier, e.g.
@(Price * Qty),@if(Count > 0)(see Native expressions); - a C# expression — introduced with an inner
@, e.g.@(@2026),@list(@model.Articles.Where(a => a.IsFeatured)); - another chain — nested calls;
- named prop arguments —
@card(model, title: "Hi")(see Props); - empty —
@(),@out(),@list(){{ … }}— meaning "use the current value as‑is".
When the extension name is omitted, the call uses the empty extension
(@(...) is @ + an unnamed call). The empty/html extensions simply stringify the
current value — so @(Title) prints the title and @() prints the current value itself
(handy inside a list of strings). See
Built‑in Extensions.
Examples:
<h1>@(Title)</h1> @* a member value (Blog.Title) *@
<p>Est. @(@model.Year - 1) — present</p> @* a C# expression *@
<p>@string(Title)</p> @* the "string" extension: stringify + HTML-encode *@
@list(Articles){{ <li>@(Title)</li> }} @* a call with a body, once per article *@
Give the unnamed call both a parameter and a body, and it renders the body with the
current model set to expr — an inline way to "zoom in" on a value without declaring a
definition:
@(Author)
{{
<span class="byline">@(Name) <@(Email)></span> @* model here is the Author *@
}}
Inside the block, @(Name)/@(Email) resolve against Author. It is the lightweight, inline
counterpart to passing a value into a definition, and
it pairs well with a C# expression — e.g. render markup for just the last item of a list:
@(@model.Articles.LastOrDefault())
{{
<li class="last">@(Title)</li>
}}
(Mechanically this is the empty extension with a body: with a body it renders the body against its model; with no body it just stringifies the value.)
Whether the bare @(value) output HTML‑encodes is governed by the output profile, not by
the syntax. There are two:
OutputProfile.Html— the default since 2.0. A bodiless@(value)HTML‑encodes by default, closing the XSS‑by‑default gap.@raw(value)opts a trusted value out; a bodied@(value){{…}}remains a raw rescoping container (its literal body markup is never encoded — only value leaves inside).OutputProfile.Text— the 1.x‑compatibility setting.@(value)emits raw text (text/JSON/code generation), rendering 1.x templates byte‑identically.
@profile(){{ html }}
<p>@(UserInput)</p> @* <script> … becomes <script> … *@
<aside>@raw(TrustedHtml)</aside> @* explicit opt-out: emitted verbatim *@
Two ways to select it, both feeding the same effective profile (the directive wins for output compiled after it):
- Host —
TemplateOptions.OutputProfile, or theTemplateResolverdefault‑profile constructor. File extensions carry no semantics. - Template — the
@profile()directive (text/html), which flows into bodies, partials, and imports compiled after it.
Encoding runs once, at the emitting leaf: containers and nested producers forward raw, so a
value is never double‑encoded by nesting. See
Built‑in Extensions → HTML encoding for the HED2001–
HED2003 diagnostics and the 2.0 default flip.
member-path = [::] name ( . name )*
Beyond a bare member path, the same parentheses accept native expressions — operators, literals, and registered functions such as
@(Price * Quantity)or@(Name ?? "anon")— which compile without Roslyn. See Native Expressions.
A member expression is a dotted path of identifiers resolved against the current model:
@(Title) @* current model's Title *@
@(Author.Name) @* nested property access *@
@(Author.Email) @* …any depth *@
Member access works on statically‑typed models (resolved by reflection) and on dynamic
models (resolved with the C# runtime binder). The "current model" is whatever value the
enclosing extension established — for example, inside @list(Articles){{ … }} the current
model of the body is one Article, so @(Author.Name) there is that article's author.
Prefix a member path with :: to read from the root model — the value originally passed
to Generate — regardless of how deeply nested the current scope is. This is what lets you
reach page‑level data (the site title, the current year, a culture) from inside a loop.
@list(Articles)
{{
<article>
<h2>@(Title)</h2> @* the Article's title (current model) *@
<a href="/">← back to @(::Title)</a> @* the Blog's title (root model) *@
</article>
}}
Inside the loop the current model is an Article, so @(Title) is the article's title and
@(::Title) reaches past it to the root Blog. The equivalent embedded‑C# form is
@(@root.Title); the :: form is pure template syntax and does not require
the C# tier (ExpressionMode.FullCSharp).
In real templates this is how you reach page‑ and app‑level context from anywhere — site host,
build number for cache‑busting, the active culture — without threading it through every call.
For instance, a date can be formatted with a pattern that itself comes from the root culture:
@date(PublishedOn){{ @(::Site.Culture.DateTimeFormat.ShortDatePattern) }}. See
Patterns → root for app/page context.
This section ties the previous two together. At every point while rendering, there is a
current context — and the whole reason :: root exists is that
this context moves as you descend through calls. Understanding the flow is the key to
threading data through a template.
What keeps this tidy rather than ad‑hoc is that the model moves predictably: a call either
descends into its parameter (iteration and component calls) or keeps the caller's
context and treats its parameter as a value (conditionals and formatters — see
Stepping back). Alongside the model, a single chained
channel carries the loop index, the value piped through a chain, and the content projected by
@out() — three things most engines handle with three separate features. And because the flow
is tracked at compile time, the context stays statically typed throughout.
Three data channels are in scope at any point:
| Channel | Read in templates as | What it is |
|---|---|---|
| model (current) | @(X), or @model in C# |
The data the current block renders against. Changes on every descent. |
| chained | @out(), or chained in C# |
A side value passed along, independent of the model (e.g. a loop index). |
| root | @(::X), or @root in C# |
The original model passed to Generate. Never changes. |
Iteration and component calls re‑base the context for their body: the parameter is evaluated against the current model and becomes the new current model inside the body. (Conditionals and formatters are the exception — see Stepping back.) Nesting these calls walks you down the object graph:
@* current model = Blog *@
@list(Articles)
{{
@* current model = one Article *@
<h2>@(Title)</h2> @* Article.Title *@
<ul>
@list(Tags)
{{
@* current model = one Tag (a string) *@
<li>@()</li> @* the tag itself *@
}}
</ul>
}}
@(Title) inside the first body is the article's title, not the blog's — the context shifted
when you entered @list(Articles). A dotted path (@(Author.Name)) still walks within the
current model; it doesn't change which model is current.
This is the everyday way data flows down: you take a value from the current context and pass it in as a call's parameter.
@list(Articles)
{{
@author_badge(Author) @* hand this article's Author to a definition *@
@article_card() @* empty parameter → forward the whole current Article *@
}}
@author_badge(Author)passes the current article'sAuthordown; insideauthor_badge(e.g.:: Author) the current model is thatAuthor, so@(Name)there is the author's name.@article_card()with an empty parameter forwards the current model unchanged — the wholeArticle.
So you steer context explicitly: name a member to narrow into it, or pass nothing to forward what you already have.
A call's parameter is always evaluated in the current context, but what the body sees depends on the kind of call:
- Descending calls —
@listand definitions — rebind the body to the parameter (the previous two subsections). - Value calls — the conditionals and formatters (
@if,@ifnot,@date,@time,@int,@money,@guid,@string) and@for— treat their parameter as a value to test or format, not a new model, so their body steps one level back to the caller's context:
@list(Articles)
{{
@* current model = one Article *@
@if(IsFeatured)
{{
@* still the Article here — not the bool *@
<h2>@(Title)</h2> @* Article.Title *@
}}
}}
Without this step‑back, @(Title) inside the @if would try to resolve against the boolean
IsFeatured. Because @if keeps the caller's context, the body still sees the surrounding
Article. The same holds for @date(PublishedOn){{ … }}, @money(Price){{ … }}, and the
rest: the parameter is the value being formatted, and the body renders against the model
around the call. @for works the same way — its body runs against the caller's model, with
the iteration index on the chained channel.
This step‑back is exactly one level (it is scope.Parent() under the hood — see
Writing Custom Extensions). There is no operator to climb further; for
anything higher, use the root or pass values down explicitly.
For the descend‑vs‑step‑back classification of every built‑in (and what a nested @out() sees
in each body), see the authoritative table
Built‑in Extensions → Body context at a glance.
@if/@ifnot can be followed by @elif (alias @elseif) and a terminal @else to form a
branch set — only the winning branch renders:
@if(IsFeatured){{ <b>Featured</b> }}
@elif(IsArchived){{ <i>Archived</i> }}
@else(){{ Regular }}
These are ordinary extensions, not keywords — there is no {{endif}}. What structures a set is not
a fixed list of names but a role each block plays, declared with [BranchRole]: an opener
starts the set (@if/@ifnot), a continuation extends it (@elif/@elseif), and an optional
terminal closes it (@else). The built‑ins are simply the engine's own role‑carrying
extensions; any custom extension with the same roles joins a set on equal footing (see
Building your own branch set), and roles even
interoperate across families in one set.
A set is the run of adjacent branch blocks at one body level; text between them is stripped at
compile time (a non‑whitespace gap warns HED3001), while text before the opener and after the last
branch renders normally. A terminal binds to the set state (satisfied once any branch fired), not
to a specific opener; an orphan terminal is a compile error (HED3003). Each @list/@for
iteration and nested body gets its own set state. Full rules, the orphan diagnostics, and the public
BranchState channel are in Built‑in extensions → Branch sets
and Writing Custom Extensions → the local context channel.
Because the current model keeps narrowing, a value you need may no longer be in reach — deep
inside the article loop, the blog's own title is "above" you. There is no parent path you
can write in a member expression (no ..), and the automatic
step‑back only climbs one level.
:: is the escape hatch that jumps straight back to the original
model from any depth:
@list(Articles){{ <a href="/">← @(::Title)</a> }} @* Blog.Title, from inside the loop *@
For anything between current and root, pass it down explicitly — or, when you control the call site, seed it through the chained channel.
A chained value flows alongside the model, independently of it:
- A chain
a():b()runsbfirst and feeds its output toa(the leftmost call renders) asa's chained input. @listand@forexpose the current index as the chained value.@out()emits the chained value, and@swap()exchanges the model and chained values for its body.
In embedded C# the three channels are simply the identifiers
model, root, and chained:
@for(@new Heddle.Models.Range(0, model.Articles.Count(), 3))
{{
@* model = the Blog (the for body keeps the outer model); chained = the loop index *@
@list(@model.Articles.Skip(chained).Take(3)){{ @article_card() }}
}}
(There is also callerData, a fourth value you may pass to Generate and read from custom
extensions — see Scope.)
Inside a call's parentheses, an inner @ switches the lexer into C# mode (CS mode,
HeddleLexer.g4), so the rest of the parameter is parsed
as a real C# expression and compiled by Roslyn. This requires
ExpressionMode.FullCSharp (TemplateOptions.ExpressionMode = ExpressionMode.FullCSharp).
The lexer covers the C# expression grammar through C# 14, so supported forms include numeric
literals (digit separators, binary, hex — 1_000, 0b1010, 0xFF), all string forms (regular,
verbatim @"…", interpolated $"…{x}…", and raw """…""" including the $‑prefixed interpolated
raw forms), Unicode and verbatim (@class) identifiers, member access, method calls, LINQ, lambdas
(both expression‑ and block‑bodied x => { … }), and object/collection initializers. Nested
parentheses are handled (the lexer balances (/) within the expression):
@(@model.Title.ToUpper()) @* method call *@
@(@model.PublishedOn.Year) @* member of a member *@
@(@$"{model.Title} ({model.PublishedOn.Year})") @* string interpolation *@
@list(@model.Articles.Where(a => a.IsFeatured)) @* LINQ + lambda *@
@list(@model.Articles.OrderByDescending(a => a.PublishedOn).Take(5))
@if(@model.Comments.Count > 0){{ <h3>Comments</h3> }} @* a boolean expression *@
The grammar tokenizes the full C# 14 expression syntax, but a given feature only compiles if the Roslyn version bundled for your target framework supports it (e.g. raw strings and
u8need C# 11+; thenetstandard2.0build used on .NET Framework ships an older Roslyn).
Two well‑known identifiers are available in embedded C#:
model— the current model (e.g.@model.Articles);root— the root model (e.g.@root.Title), equivalent to the::reference.
There is also a chained value available to extensions such as @for (the loop index);
see Scope.
Parser nuance. A C# expression is just a run of C# tokens up to the matching
), so nested parentheses inside it (as in@Foo(1)) are part of the expression. The named call form@x(@Foo(1))and the unnamed form@(@Foo(1))therefore classify those tokens identically.
A definition block declares one or more reusable named templates. Once declared, a
definition is invoked like any extension: @name().
definitions = @% def+ %@
def = < name [( props )] [: base] > [ -> chain ] {{ body }} [ :: Type ]
Anatomy of one definition — an article_card component:
@%
<article_card> @* name, wrapped in < > *@
{{ @* body (subtemplate) *@
<article>
<h2>@(Title)</h2>
<p class="byline">by @(Author.Name)</p>
@out() @* the caller's content drops in here *@
</article>
}} :: Article @* optional type annotation *@
%@
A single @% … %@ block may declare many definitions back‑to‑back (e.g. article_card,
comment, layout, …). Definition blocks may appear at the top level or nested inside a
subtemplate, so a call's body can introduce local definitions before using them.
Invoking a definition — pass it a model, and optionally a {{ … }} body that it surfaces via
@out():
@list(Articles)
{{
@article_card() @* model = the current Article *@
{{
<a href="/read">Read more →</a> @* this is what @out() renders *@
}}
}}
Here each Article in the list is passed to article_card, and the <a> link is the body
that the card exposes through @out().
Most definitions are inert: they render only when you call them by name (@name()). Adding a
-> turns a definition into an output — it renders automatically at the end of the
document (in declaration order), using the chain after -> to select its data. (This is how
a template made of nothing but definitions still produces output.)
< name > -> chain {{ body }}
@%
<article_list> -> (Articles) @* the -> makes this render automatically *@
{{
<ul>
@list(){{ <li>@(Title)</li> }}
</ul>
}} :: Blog
%@
-> (Articles) does two things: it selects the body's data (take Articles off the root
Blog, so @list() with an empty parameter iterates them), and it marks the definition as an
output that emits automatically at the end of the document, in declaration order. The empty
form -> () means "render using the current value as‑is". (An @<< import re‑bases the imported
file's own output chains to the import site, but a default -> chain still emits at document
end.)
Don't also call it. Because a
->definition already renders on its own (at the end of the document), writing@article_list()as well would render the list a second time. Use->for the regions a template should emit on its own; leave the->off for reusable definitions you invoke by name (likearticle_cardabove).The compiler warns when it catches this (diagnostic HED4002): "Definition '<name>' (declared at <pos>) has a default output ('->') and is also called by name — it renders twice." The warning names both the definition's declaration and the offending call. Remove the
->or remove the call to resolve it. (It is a warning, not an error — the render is well-defined, just usually not what you meant.)
A trailing :: Type strongly types a definition's model. The type name is resolved against
the namespaces brought in by @using() (and the model
type’s own assembly).
<article_card>
{{ … }} :: Article @* a concrete type *@
<feed>
{{ … }} :: ICollection<Article> @* generic types are allowed *@
<widget>
{{ … }} :: dynamic @* opt into dynamic dispatch *@
dynamic makes member access late‑bound (resolved at render time via the C# runtime
binder), which is how @(Title) works against an ExpandoObject, a dictionary shape, or a
public named type. One shape it does not work against is an anonymous type from
another assembly: anonymous types are internal to the assembly that declares them, so the
runtime binder inside the Heddle assembly can't see their members and throws at render time —
pass a public named type (or an ExpandoObject) instead. Concrete types (e.g. Article,
ICollection<Article>) enable compile‑time member checking and faster access.
Generic and array type names are recognized by the lexer's type rule
(ID_TYPE, HeddleLexer.g4), which accepts
Namespace.Type<T1, T2>[] forms.
The annotation is optional, and leaving it off is a feature, not a shortcut. A definition
with no :: Type is abstract — it has no fixed model type. Its type is bound late:
- When it's used. Each call compiles the definition's body against whatever concrete model
reaches that call site — the current model (
@panel()), the type of a member parameter (@badge(Author)), or the chained type. The same source is specialized per use site. - When it's narrowed by inheritance. An inheriting definition can pin the type with
:: Type(see Inheritance).
@%
<panel> @* no :: Type → abstract / polymorphic *@
{{ <section class="panel"><h3>@(Title)</h3>@out()</section> }}
%@
@panel() @* current model = Blog → @(Title) binds to Blog.Title *@
@list(Articles){{ @panel() }} @* current model = Article → @(Title) binds to Article.Title *@
The same panel is reused against two different model types, and each use is independently
type‑checked — point it at a model with no Title and that call site fails to compile.
This is the C++ template model, not C# generics. There are no type parameters and no
where‑style constraints to satisfy up front: the body is simply re‑substituted with the
concrete model at each call site and checked there (compile‑time monomorphisation, or
"duck typing"). A C# generic, by contrast, is compiled once behind its constraints and
abstracted away — it is not late‑bound, and its body may only touch members the constraints
guarantee.
This is not the same as
:: dynamic. An abstract definition is still statically typed — just typed per use at compile time.:: dynamicdefers member resolution to the runtime binder. Reach for abstract definitions to share structure (layouts, cards, wrappers) across many model shapes; reach fordynamicwhen the shape genuinely isn't known until render time.
In practice this is common: a reusable section is left untyped and simply invoked from inside a typed page, where it binds to that page's model. See Patterns → abstract section in a typed page.
Alongside its positional model, a definition can declare typed named parameters — props — in parentheses right after the name. A prop has a name, a type, and an optional literal default:
@%
<card(style: string = "plain", compact: bool = false)>
{{
<article class="card @(style)">
<h2>@(Title)</h2>
@ifnot(compact){{ <p>@(Summary)</p> }}
@out()
</article>
}} :: Article
%@
- Types resolve exactly like a
:: Typeannotation (C# keyword types, dotted names, generics, arrays —List<string>,int[]). - Defaults are literals only — the phase‑1 literal forms (
"…",42,1.5,true,'c',null, and a leading-on a number). A default must be convertible to the prop's type under the same rule call‑site arguments use;<card(style: string = "plain")>is fine,<pad(width: int = "x")>is a compile error (HED5009). - A prop with no default is required — every call site must supply it.
outandthisare reserved and cannot be prop names (HED5015);out::declares a slot (below).
Passing props — named arguments. At the call site, props are passed by name after the
positional model. The values are ordinary native expressions
evaluated in the caller's context (paths off the caller model, :: root refs, functions,
operators, this, literals):
@card(Article, style: "wide", compact: true)
@card(Article) @* both props take their defaults *@
@card(Article, style: ::Site.DefaultCardStyle) @* value from the root context *@
Missing, unknown, mistyped, or duplicated props are hard compile errors (with positions):
a required prop left unbound is HED5002, an unknown name is HED5001 (the message lists
the declared props), a value whose type does not convert is HED5003, and the same name
twice is HED5004. Named arguments may only be passed to a target that declares a named
parameter surface — a definition that declares props, or a custom extension that declares
[Prop] parameters (see custom‑extensions → Extension parameters,
which reuses this exact contract and its diagnostics). Passing them to any other target — a
parameter‑less extension, a function, or a prop‑less definition — is HED5005/HED5006.
The one conversion set (identity, implicit numeric widening, T→T?, reference assignability,
boxing to object, the null literal for reference/Nullable<T> props) governs defaults and
arguments alike — there is no narrowing, no string parsing, and no implicit ToString().
Reading props in the body. A prop is read by its bare name, exactly like a model member —
@(style), @if(compact), @list(items). Props are statically typed by their declared type
even in a :: dynamic definition. When a prop shares a name with a model member, the
prop wins and the compiler warns (HED5011); the model member is still reachable with the
explicit this.<name> escape in an expression (@(this.style)). A ::‑rooted path never reads
a prop.
Named arguments are expression‑tier, so under
ExpressionMode.MemberPathsOnly a call carrying them reports HED1014 — but
prop reads are member‑tier and work under every mode, so an all‑defaults call still renders.
@out() normally splices the caller's {{ … }} content as‑is. A definition can instead declare
a slot parameter and hand a typed value back into that content, which the caller's body
receives as its model. Declare it in the prop list with the ::‑means‑type token, out:: Type,
and project values with @out(expr):
@%
<picker(out:: MenuOption)>
{{ <ul>@list(Options){{ <li>@out(this)</li> }}</ul> }} :: Menu
%@
@picker(Menu){{ <a href="/go?id=@(Id)">@(Label)</a> }}
The caller body {{ <a …>@(Label)</a> }} is compiled against MenuOption (the slot type),
and rendered once per @out(expr) execution with expr as its model — so the picker emits
one <li><a>…</a></li> per option. @out(this) passes the current model (the option) as the
slot value.
Rules (all positioned compile errors):
- Inside a slot‑declaring body every
@outmust pass a value — a bare@out()is HED5013 — and a slot‑mode@out(expr)is bodiless (@out(expr){{…}}is HED5018). - The value's static type must be assignable to the slot type (identity/widening/lifting/ reference assignability), else HED5014; a dynamic value is rejected too.
- Conversely,
@outwith a value where no slot is declared — including outside any definition — is HED5012. (This supersedes the old accepted‑and‑ignored@out(X): the argument was always discarded, so delete it, or declareout:: T.)
Exactly one slot per definition (a second out:: is HED5017); foo:: Type — any name other
than out before :: — is HED5016.
A definition can inherit from another by name using ::
< child : base >
<featured_card:article_card> @* featured_card inherits article_card *@
{{
<div class="featured">
@article_card() @* call the base, then decorate it *@
</div>
}}
@featured_card() now renders the base article_card wrapped in a <div class="featured">.
Full override. Re‑declaring an existing name as <name:name> replaces that definition
from that point onward:
<article_card:article_card>
{{ <article class="compact">@(Title)</article> }}
After this declaration, later @article_card() calls render the compact version. Overrides
are layered in document order, so you can render a page, redefine a region, and render
again with the new look — without touching the call sites.
Type compatibility rule (narrowing). Inheritance is where an abstract definition gets pinned down. A child's type must be assignable to its base's type; otherwise compilation fails with "The new definition type <X> isn't assignable to base <Y>" (HeddleCompiler.WalkValidateDefinitionType). Two consequences:
- If the base is abstract (no
:: Type, i.e.object), a child may narrow it to any concrete type — this is the normal way to turn a shared, typeless section into a typed one. - If the base is typed, children may only narrow to assignable (more‑derived) types; a type fixed anywhere in the chain can't be widened or swapped for an incompatible one.
Props inherit too. A child inherits every prop of its base
and may append new ones. Re‑declaring an inherited prop name is the sanctioned way to change
its default — it keeps the base's slot position and replaces the default; the re‑declared type
must be assignable to the inherited type (the same narrowing direction as the model), else
HED5008. So <fancy(tone: string = "info", style: string = "wide"):card> inherits card's
compact, re‑defaults style to "wide", and adds tone.
Composition without coupling (the standout feature). Definitions are declarative extension points, not forward dependencies. A layout exposes named regions and any page supplies or overrides them. Put the layout in its own file:
@* layout.heddle — a reusable shell *@
@%
<sidebar>{{ <aside>Recent posts…</aside> }}
<layout>
{{
<html>
<head><title>@(Title)</title></head>
<body>
@sidebar()
<main>@out()</main>
<footer>© @(Year) @(Title)</footer>
</body>
</html>
}} :: Blog
%@
Then a page imports it and overrides only what it needs:
@* home.heddle *@
@<<{{layout.heddle}} @* pull in the layout + sidebar definitions *@
@%
<sidebar:sidebar> @* this page wants a different sidebar *@
{{ <aside>Welcome, subscriber!</aside> }}
%@
@layout()
{{
<h1>Latest articles</h1>
@list(Articles){{ @article_card() }}
}}
This is the direct equivalent of Razor's @RenderSection/@RenderBody, but without Razor's
directionality: in Razor a page declares sections that the layout consumes, so the
rendered entry point must be the final page and a page can't easily become a base for another.
In Heddle the relationship is symmetric — layout knows nothing about home, any template that
exposes regions can serve as a base for anything, and because it all compiles into a single
execution‑ready document, this composition costs nothing at render time. (The
performance benchmark uses exactly this home + layout
shape.) See Architecture → Performance.
A definition can expose more than one overridable content region — beyond the single
@out() slot — by declaring inner definitions marked public with a leading colon:
< :name > @* a public region *@
< :name :: Type > @* a public region with a typed model *@
< name > @* a private inner definition (the default) *@
A region is a definition: the component renders it by calling it (@heading()), projects a
typed value into it the way @out(expr) projects into the slot (@item(this)), and gives it
default content a caller may replace. The region's type sits inside the angle brackets —
distinct from a definition's trailing }} :: Type — and a region header carries no prop list,
no base, and no default output chain.
@%
<feed(theme: string = "light", title: string = "Home")>
{{
@%
<:heading>{{ <h2 class="@(theme)">@(title)</h2> }} @* public, untyped *@
<:item :: Article>{{ <li>@(Title)</li> }} @* public, typed *@
<divider>{{ <hr class="@(theme)"> }} @* private *@
%@
@heading()
<ul>@list(Articles){{ @item(this) }}</ul>
@divider()
@out() @* the one anonymous slot, unchanged *@
}} :: Feed
%@
A caller fills a public region with the normal <name:name> override it already knows,
inside a @% … %@ block in the call body — no new fill directive:
@feed()
{{
@%
<heading:heading>{{ <h2 class="hero">Latest</h2> }}
<item:item>{{ <li>@(Title) — #@(Id)</li> }}
%@
<p class="lede">Intro copy renders through @out() as usual.</p>
}}
Rules:
- Call-scoped. A fill applies to that call only and reaches region calls at any depth
of the component body (inside
@if/@list/@forbodies). A second@feed()with no override block renders the defaults. - Region-context scope. An override body runs in the region's own context — the
component's props (
@(theme),@(title)) plus the region's model (@(Title)againstArticle) — exactly like the default body it replaces. An@out(value)inside a region body is the ordinary "no slot" error (HED5012); a region is not the host of the component's slot. - Self- and sibling calls. Inside a fill body, calling the region's own name renders the region's default (no recursion); calling a sibling region resolves that sibling's fill (or default) — exactly like the default body it replaces.
- Narrowing. An override may narrow the region's type with a trailing
:: Type, in the assignable-only direction — the same rule as<child:base>narrowing, with the same "isn't assignable to base" error when violated. An untyped override inherits the region's type. - Visibility. Overriding a private region from a call site is HED5019; declaring two
public regions of one name in one component is HED5020. A
<x:x>whose base is in scope at the call site stays a normal override (the local override wins — it is never rerouted to a region fill); a<x:x>naming neither a region nor anything in scope keeps today's "Base definition x couldn't be found" error. - Both backends. Region defaults and overridden fills precompile natively under the source generator and render byte-identically to the dynamic engine.
The pre-existing sibling-override idiom (document-scope sibling definitions the caller overrides) remains fully supported and unchanged; named regions are the component-scoped, visibility-gated evolution of it.
A {{ … }} block is a full nested template. It appears as a definition body and as the
inline body attached to a call.
{{ ...any template content... }}
Because the body is itself a full template, subtemplates may contain text, output blocks,
nested definitions, imports, and raw blocks — to any depth. A call hands its subtemplate to
its extension; @list(Articles){{ … }}, for instance, renders its body once per element with
the element as the current model:
@list(Articles)
{{
<li>
<a href="/post">@(Title)</a> — @(Author.Name)
</li>
}}
Calls in a chain are evaluated right to left, and the leftmost call renders the final
output. Each call receives the output of the call to its right as its chained value,
which it can splice into its own result with @out().
call : call : call ...
So a chain nests like function composition — @a():b():c() makes a the outer wrapper around
b around c: c runs first, its output is handed to b, b's output is handed to a, and
a renders. For example:
@%
<heading> {{ <h2>@out()</h2> }} @* wraps its chained content in <h2> *@
<emphasis> {{ <em>@(Title)</em> }}
%@
@heading():emphasis()
emphasis runs first and produces <em>…Title…</em>; that becomes heading's chained value,
which heading's @out() drops inside its <h2>. Result: <h2><em>…Title…</em></h2>.
The engine tracks the data type flowing through the chain, so each call sees the correct
chained‑input type and the final output type is known at compile time. Among the built‑ins,
@out() is the usual consumer of a chained value (it emits whatever the call to its right
produced), and @swap() exchanges the model and chained values for the duration of its body.
Definitions may call themselves, which is the natural way to render trees — like a threaded
comment list, where each Comment has Replies that are themselves Comments:
@%
<comment>
{{
<li>
<strong>@(Author)</strong>: @(Text)
@if(Replies.Count > 0)
{{
<ul>
@list(Replies){{ @comment() }} @* recurse into each reply *@
</ul>
}}
</li>
}} :: Comment
%@
The example uses the native expression tier (Replies.Count > 0), so it works under the default
ExpressionMode.Native; the C#-tier form @if(@model.Replies.Count > 0) would need
ExpressionMode.FullCSharp. Rendering @list(Comments){{ @comment() }} then walks the whole tree to any depth. Recursion
is bounded by TemplateOptions.MaxRecursionCount (default 100); see the
C# API Reference.
Heddle has one import spelling — @<<{{ path }}. The legacy @import() extension has been
removed: any @import call site now fails to compile with a positioned HED4003 error
naming its replacements. For sharing definition libraries, use @<<; to embed another template's
rendered output inline, use @partial().
@<<{{path/to/file}}
@<<{{layout.heddle}}
The path between {{ }} is resolved relative to TemplateOptions.RootPath (an absolute path
wins, per Path.Combine). The path is taken verbatim — no trimming — so keep the braces
tight (@<<{{layout.heddle}}); a stray space inside becomes part of the filename.
@<<{{ path }} — the composition import. Handled at parse time by dedicated @<< syntax
(HeddleMainListener.ExitImport_block). It parses
the referenced file, merges its definitions into the current document (callable after the
import line — a name already defined before the import is a compile error, so imports compose
rather than silently override), re‑bases the imported file's own output chains to render at
the import position, and carries its default (-> ) chains into the document. Imported
static text never transfers — only definitions and chains. This is the canonical way to share
a library of definitions or a layout across templates. A @<< import must appear at the top
level of a document — nesting it inside a subtemplate (an @if/@for body, an output block, or
a definition body) is a compile error (HED4004), because composition merges definitions and
re‑bases chains into the document as a whole and has no well‑defined meaning at a nested scope.
@import(){{ path }} — removed. The old compile‑time include
(ImportExtension.cs) merged nothing into the
importing document and had surprising, offset-dependent isolation semantics. It no longer
compiles: every @import() call site now produces a single positioned HED4003 error at
the call, naming both replacements — @<<{{ path }} to share definitions and layouts across
files, or @partial(){{ name }} to embed another template's rendered output inline. The name
import is kept registered only as a tombstone so the diagnostic is a targeted migration
signpost, not a generic "unknown extension" error.
Imports vs
@partial().@<<pulls in definitions at compile time (no output of its own beyond the imported chains).@partial()compiles a separate template by name and renders its output inline at run time. Choose@<<to share reusable definitions and layouts; choose@partial()to embed another rendered template.
Comments are removed during lexing (routed to a hidden channel) and never appear in output.
@* ...comment text... *@
Comments may appear almost anywhere — including inside other tokens:
<h2>@(Title)</h2>@* the article headline *@
@if(@* featured only *@ @model.IsFeatured){{ <span class="badge">★</span> }}
<a href="/po@* even mid-word *@sts">Posts</a>
Comments are recognized in every lexer mode (top level, subtemplate, definition, import, call, output), so they are safe to drop in anywhere.
Raw regions are emitted verbatim and are not parsed for directives.
@{ ...literal text... }@ block form
@: ...literal to end of line line form
-
Block
@{ … }@— everything between the delimiters is literal. Handy for emitting code samples or text full of characters that would otherwise be parsed:<pre>@{ Use @(Title) and {{ … }} in a template — none of this is evaluated. }@</pre> -
Line
@: …— everything to the end of the line is literal:@: Raw line: @(Title) and @if() are printed verbatim, exactly as typed.
Raw blocks are recognized at the top level, in subtemplates, in output mode, and after a call returns, so they compose with the rest of the syntax.
@\ followed by any run of whitespace is consumed and produces no output. Use it to keep
templates readable while controlling the emitted whitespace — particularly at the end of a
line, to suppress the trailing newline.
@\ followed by whitespace → consumed (emits nothing)
Declaration lines that should produce no output of their own are a common place for it — end
them with @\ so they don't leave a blank line behind:
@using(){{System.Linq}}@\
@model(){{Blog}}@\
<h1>@(Title)</h1>
Without the @\, each @using/@model line would emit its trailing newline when
TrimDirectiveLines is off (the 1.x default) — see below. Whitespace that
is not trimmed is preserved exactly — Heddle is whitespace‑significant, so the spaces and
newlines you write around directives appear in the output as written.
In practice, HTML templates rarely need
@\— browsers collapse insignificant whitespace, so the stray newlines around directives don't matter. Reach for@\mainly in the declaration preamble and when emitting whitespace‑sensitive text (plain text,<pre>, JSON, etc.).
@\ controls whitespace one directive at a time. When a whole line is nothing but a directive
that produces no output, the option TemplateOptions.TrimDirectiveLines removes the need for
@\ on it entirely. With it on, a directive that occupies its line by itself — only spaces
or tabs before it, only spaces/tabs then a line terminator (or end of file) after it — swallows
that whole line: its leading indentation, its trailing spaces, and one line terminator
(\n, \r\n, or \r).
@using(){{System.Linq}} @* whole line — swallowed under TrimDirectiveLines *@
@model(){{Blog}}
@* a comment on its own line *@
<h1>@(Title)</h1> @* renders with no blank lines above it *@
The eligible blocks are exactly the ones the compiler removes from the document anyway:
zero‑output directives (@using, @model, @profile(){{…}}, and any custom
extension whose InitStart returns null), definition blocks (@% … %@), @<< imports, and
whole‑line comments. Blocks that do produce output (raw blocks, branch blocks, @param(...)
— which stays in the document and renders nothing by itself), text that shares its line with
other content, and author‑written blank lines are all left untouched.
- Default:
truesince 2.0 — whole‑line directives swallow their line. Set it back tofalseto keep 1.x whitespace byte‑for‑byte (the 1.x default). @\keeps working unchanged and is still the tool for mid‑line whitespace control; the option only removes the boilerplate@\at the end of whole‑line directives.- The setting participates in template‑cache identity (
Equals/GetHashCode) because it changes the rendered bytes — see the C# API Reference.
Heddle is context‑sensitive: the same characters mean different things depending on where they
appear. The ANTLR lexer implements this with a stack of modes
(HeddleLexer.g4). Understanding the modes explains why,
for example, }} ends a subtemplate but is plain text elsewhere.
| Mode | Entered when | Role | Notable exits |
|---|---|---|---|
| (default) | start of document | Top‑level text, @… directives, definitions, imports, raw blocks. |
@%→DEF, @<<→IMPORT, @→OUT |
SUB_BLOCK |
after {{ |
Inside a subtemplate body; like default but }} closes it. |
}}→pop |
DEF |
after @% |
Inside a definition block: names < >, : base, :: type, -> default, %@ close. |
(→DEF_PROPS, {{→SUB_BLOCK, ->→OUT, %@→pop |
DEF_PROPS |
after ( in a definition header |
Reads the typed prop list (prop: Type = default) — the phase‑5 prop surface. |
)→pop |
IMPORT_MODE |
after @<< |
Reads the import path between {{ }}. |
}}→pop |
OUT_MODE |
after @ |
Reads an extension name, then ( opens the parameter. |
(→CALL, raw/sub/def/import→transition |
CALL |
after ( |
Inside a parameter: member ids, ., :: root ref, : delim, nested (, plus v2 native‑expression tokens (literals, operators, [ ] brackets, , commas, this). |
)→pop, inner @→CS |
CALL_RETURNED |
after ) |
Decides what follows a call: : (chain), @ (next out), {{ (subtemplate), raw, etc. |
many |
CS |
inner @ inside a parameter |
Embedded C# expression; balances nested ( ), ends at the matching ). |
matching )→pop |
The lexer declares 13 modes in total (default plus 12 named); the table above shows the
principal ones. Omitted are the four C#-string modes reached from embedded C# — CS_NESTED,
INTERP_STR, INTERP_VERBATIM_STR, and INTERP_HOLE.
You normally never think about modes — but they are the reason comments work everywhere, why C# expressions can contain arbitrary parentheses, and why whitespace handling differs slightly between a definition header and a body. For the full picture see Architecture → Lexing.
- A literal
@is written@@— the doubling escape works in text and subtemplate bodies (except immediately before*, where@*starts a comment; use@{@*}@for a literal@*). Raw regions (@{ … }@/@:) remain the tool for bulk literal text. Everything else that isn't a directive is literal text. - Whitespace is significant. Use
@\to trim; otherwise spaces and newlines are emitted as written. - Comments can appear mid‑token and are always stripped.
- Embedded C# requires
ExpressionMode.FullCSharp;model,root, andchainedare the available identifiers. ::Memberreads the root model; plainMemberreads the current model.- Definitions are invoked like extensions (
@name()), can be nested, typed, inherited, and fully overridden in document order. - HTML encoding follows the output profile — under
OutputProfile.Html(the default since 2.0) a bodiless@(value)HTML‑encodes by default and@raw(value)opts out; underOutputProfile.Text(the 1.x‑compatibility setting) the unnamed@(...)output is raw.[EncodeOutput]extensions (@string,@html, …) always encode under both profiles. Select the profile withTemplateOptions.OutputProfileor the@profile()directive — see Output profiles and Built‑in Extensions → Output profiles. - Recursion is capped by
TemplateOptions.MaxRecursionCount(default 100). - Type mismatches and unknown members fail at compile time (or, in
DEBUG, whenGenerateis given a model of the wrong type) — see error handling.
Continue to the Built‑in Extensions reference for the helpers you can call from these constructs.