Writing Custom Extensions
Extensions are how you add new @yourhelper(...) directives to the language. Every built‑in verb (if, list, date, …) is just an extension; yours work exactly the same way. This page covers the contract, the data flow, the attributes, and registration.
The built‑ins in src/Heddle/Extensions are the best worked examples — IfExtension, ListExtension, and DateExtension are referenced throughout.
The contract
An extension implements IExtension, but you will almost always derive from one of the base classes instead:
AbstractExtension— the standard base. Emits output as‑is.AbstractHtmlExtension— adds opt‑in HTML encoding; overrideProcessDataInternal/RenderDataInternalinstead of the plain methods.
The interface:
public interface IExtension : IDisposable
{
void SetUpRenderType(RenderType renderType);
ExType InitStart(InitContext initContext, ExType dataType, ExType chainedType, ExType parent);
void CompleteInit(CompileScope newScope, ParseContext parseContext);
object ProcessData(in Scope scope); // build & return a string result
void RenderData(in Scope scope); // stream output directly to scope.Renderer
BlockPosition Position { get; set; }
}Lifecycle
InitStart(compile time) — called once while compiling. You receive the incomingdataType(the model type at this point), thechainedType, and theparenttype, and you return the output type your extension produces. This is where the engine threads types through a chain, so returning the rightExTypematters. Callbase.InitStart(...)to let the base compile your{{ … }}subtemplate (it stores it for later rendering).CompleteInit(compile time, optional) — for extensions that need a second pass (e.g.PartialExtensioncompiles a referenced template here, after the main pass).ProcessData/RenderData(render time) — called per render. Implement both:RenderDatastreams toscope.Renderer(the fast path),ProcessDatareturns a string (used when a parent needs your result as a value, e.g. inside a chain).
Implement both
ProcessDataandRenderDatawith equivalent behavior. The engine chooses between them depending on context (direct rendering vs. value composition).
Helpers from the base class
AbstractExtension gives you:
GetInnerResult(in Scope scope)→ renders your subtemplate body to a string.RenderInnerResult(in Scope scope)→ streams your subtemplate body to the renderer.InnerExist→ whether a{{ … }}body was provided.
The Scope
At render time you read data from Scope and write via its Renderer. The relevant fields:
| Field | Meaning |
|---|---|
ModelData | The current model (your parameter value). |
ChainedData | The chained value — the output of the call to your right in a chain (chains run right‑to‑left), and the loop index for iteration extensions. |
ParentModelData | The enclosing scope's model. |
RootData | The root model (what :: / @root resolve to). |
CallerData | Caller context. |
Renderer | The output sink (Renderer.Render(string)). |
Scope is a readonly struct with pure transforms used to build the scope for your subtemplate:
scope.Parent()/scope.Parent(chained)— step back to the parent (caller) model, optionally setting a new chained value. This is the "one step back": rendering your body againstscope.Parent()makes the body see the surrounding model instead of your parameter — exactly what the conditionals and formatters do (e.g. so@if(flag){{ @(Title) }}still sees the caller's model). See Language Reference → stepping back.scope.Model(model)/scope.Model(model, chained)— descend into a child model.scope.Chain(chained)— set the chained value while keeping the model.
For example, ListExtension iterates and renders its body once per element with the index as the chained value:
var index = 0;
foreach (var item in (IEnumerable)scope.ModelData)
{
var itemScope = scope.Model(item, index); // model = element, chained = index
RenderInnerResult(itemScope);
index++;
}A minimal example
A @upper(...) extension that uppercases a string and HTML‑encodes the result:
using System.Globalization;
using Heddle.Attributes;
using Heddle.Core;
using Heddle.Data;
namespace MyApp.Extensions
{
[ExtensionName("upper")]
[DataType(typeof(string))]
[EncodeOutput] // HTML‑encode the output
public class UpperExtension : AbstractHtmlExtension
{
public override ExType InitStart(InitContext init, ExType dataType, ExType chainedType, ExType parent)
{
// produce a string; let the base compile any {{ }} body
return base.InitStart(init, parent, chainedType, null);
}
protected override object ProcessDataInternal(in Scope scope)
{
return scope.ModelData is string s
? s.ToUpper(CultureInfo.InvariantCulture)
: string.Empty;
}
protected override void RenderDataInternal(in Scope scope)
{
if (scope.ModelData is string s)
scope.Renderer.Render(s.ToUpper(CultureInfo.InvariantCulture));
}
}
}Usage in a template: @upper(Name).
Compare with the real StringExtension and DateExtension, which follow the same shape.
Attributes
Declared in src/Heddle/Attributes:
| Attribute | Target | Purpose |
|---|---|---|
[ExtensionName("name")] | class | The verb used in templates (@name(...)). Required. The empty name "" is reserved for the unnamed @(...) extension. |
[DataType(typeof(T))] | class | The model type the extension expects. Repeatable (e.g. int and long on IntegerExtension). |
[ChainedType(typeof(T))] | class | The expected chained‑input type. |
[EncodeOutput] | class | HTML‑encode the output by default (pairs with AbstractHtmlExtension). |
[ExtensionReplace] | class | Marks an extension intended to replace another of the same name. |
[NotEncode] | model property | Mark a model property as pre‑trusted so its value isn't HTML‑encoded. |
[Hidden] | model property | Hide a model property from template resolution. |
[Options("fieldName")] | member | Override the name a property is addressed by in templates. |
[ExtensionName] is AllowMultiple = true, so one class can answer to several names. A later registration of the same name replaces an earlier one.
Registering your extensions
Two steps:
Export the extension(s) from the assembly with the assembly‑level attribute
ExportExtensions:csharpusing Heddle.Attributes; [assembly: ExportExtensions(typeof(MyApp.Extensions.UpperExtension))] // or export several: // [assembly: ExportExtensions(typeof(A), typeof(B))] // or export everything discoverable in the assembly: // [assembly: ExportExtensions]Configure the engine with your startup assembly so the export is discovered:
csharpHeddleTemplate.Configure(typeof(Program).GetTypeInfo().Assembly);
Configure walks the given assembly and its references, so exporting from any referenced assembly is sufficient as long as that assembly is reachable from the one you pass.
Common categories in practice
Production projects tend to add a handful of extensions for cross‑cutting concerns. Knowing the shapes helps you recognise where a custom extension is the right tool:
- Escapers / encoders — value transforms used in attributes and URLs, e.g.
@quote(Name)(attribute‑safe text) or a stricter HTML encoder. These wrap a value and emit a string; model the API onStringExtension. - Asset / URL helpers — e.g.
@asset_url(Image)to map a path to a CDN URL (often combined with a literal suffix in the template:@asset_url(Image).webp). A value‑in, string‑out extension. - Region collectors — extensions like
@head(){{ … }}or@script(){{ … }}that capture their body and emit it elsewhere in the document (the<head>, end‑of‑body scripts). These consume a subtemplate and defer its output; they pair naturally with a layout definition that renders the collected regions. - Declarations — extensions that configure compilation and emit nothing (like the built‑in
using/model).
See Patterns → custom extensions in practice for how these read at the call site.
Tips
- Return the correct type from
InitStart. It drives compile‑time type checking for any call chained after yours. - Keep
ProcessDataandRenderDataconsistent. Divergent behavior between them causes output that depends on context. - Use the base helpers (
GetInnerResult/RenderInnerResult/InnerExist) rather than re‑implementing subtemplate rendering. - Dispose owned resources by overriding
Dispose(bool)and callingbase.Dispose(...)(seePartialExtension). - For deeper internals (how
InitStart/CompleteInitfit into the compile pass), see Architecture.