Fluid is an open-source .NET template engine based on the Liquid template language.
Fluid is an open-source .NET template engine based on the Liquid template language.
Fluid is an open-source .NET template engine based on the Liquid template language. It is a secure template language that is also very accessible for non-programmer audiences.
This document describes Fluid 3.0, which is under development on
main. Preview packages are available from the preview feed; stable releases are available on NuGet.org. To see the corresponding content for v1.0, use this version
Deane Barker wrote a very comprehensive tutorial on how to write Liquid templates with Fluid. For a high-level overview, read The Four Levels of Fluid Development, which describes different stages of using Fluid.
<ul id="products">
{% for product in products %}
<li>
<h2>{{product.name}}</h2>
Only {{product.price | price }}
{{product.description | prettyprint | paragraph }}
</li>
{% endfor %}
</ul><ul id="products">
<li>
<h2>Apple</h2>
$329
Flat-out fun.
</li>
<li>
<h2>Orange</h2>
$25
Colorful.
</li>
<li>
<h2>Banana</h2>
$99
Peel it.
</li>
</ul>Notice
<li> tags are at the same index as in the template, even though the {% for } tag had some leading spaces<ul> and <li> tags are on contiguous lines even though the {% for } is taking a full line.You can directly reference the NuGet package.
The code samples in this document assume you have registered the Fluid namespace with using Fluid;.
After a successful build triggered by a push to main, preview packages are published to the
Fluid feed on feedz.io.
Versions follow 3.0.0-preview-<run number>, using the GitHub Actions build run number.
These packages contain the latest development changes and are intended for testing before release.
Tagged releases continue to be published to NuGet.org.
Add the preview feed alongside NuGet.org, then install the latest prerelease version:
dotnet nuget add source https://f.feedz.io/sebastienros/fluid/nuget/index.json --name fluid-preview
dotnet add package Fluid.Core --prereleaseKeep NuGet.org enabled so dependencies can be restored. If your NuGet.config uses package source
mapping, also map Fluid.* and MinimalApis.LiquidViews to the fluid-preview source.
var parser = new FluidParser();
var model = new { Firstname = "Bill", Lastname = "Gates" };
var source = "Hello {{ Firstname }} {{ Lastname }}";
if (parser.TryParse(source, out var template, out var error))
{
var context = new TemplateContext(model);
Console.WriteLine(template.Render(context));
}
else
{
Console.WriteLine($"Error: {error}");
}Hello Bill Gates
Fluid templates can read public properties and fields from the model and from objects reachable through it. This is by design; a model passed to TemplateContext should be treated as the template's readable data boundary.
When rendering an untrusted template, pass a dedicated model that contains only the data the template is allowed to read. Do not pass domain entities, service objects, configuration objects, or other object graphs that may expose sensitive data through public members.
A FluidParser instance is thread-safe and should be shared by the whole application. A common pattern is to declare the parser in a local static variable:
private static readonly FluidParser _parser = new FluidParser();An IFluidTemplate instance is thread-safe and can be cached and reused by multiple threads concurrently.
A TemplateContext instance is not thread-safe, and a new instance should be created every time an IFluidTemplate instance is used.
Values registered in TemplateOptions.GlobalValues are shared by every context created from those options. Values set directly on a TemplateContext belong to that rendering. Custom tags that need temporary values should use a scope lease:
using var scope = context.EnterScope(ScopeBehavior.Local);
context.SetValue("temporary", value);Local scopes inherit values and keep assignments local. WriteThrough scopes keep values assigned with LocalScope.SetOwnValue temporary while normal assignments update the caller, which matches include and loop behavior. Isolated scopes can only read the context's initial values and GlobalValues, which matches the render tag.
Fluid works when targeting NativeAOT and trimmed deployments.
MemberAccessStrategy.Register<T...> APIs are available for custom mappings.TemplateOptions instances (for example, at app startup).MemberAccessStrategy.Register<T...> calls, execute them during application startup before rendering templates.TemplateContext, or use [FluidRegister] for types that are not visible at a context construction site.dotnet publish -c Release -r <RID> -p:PublishAot=trueNativeAOT compatibility and trimming compatibility are related but separate. Fluid's reflection fallback does not emit code and can run when dynamic code is unavailable. A trimmed application must also preserve every member that the fallback discovers at runtime.
| Usage | NativeAOT with trimming |
|---|---|
new TemplateContext(concreteModel, customOptions) with the source generator enabled and matching compile-time and runtime model types |
Compatible. Eligible public fields and properties are accessed directly by generated code. |
A model registered with [FluidRegister] |
Compatible. Use this for boxed models, nested model types, models created in another assembly, or types not visible at a TemplateContext construction site. |
An explicit MemberAccessStrategy.Register<T...> mapping or custom MemberAccessor that accesses members directly |
Compatible. The application supplies the access logic instead of relying on member discovery. |
The one-argument new TemplateContext(model) constructor or TemplateOptions.Default |
No accessor is inferred. Rendering uses an explicit registration if one exists; otherwise it falls back to reflection. |
A model passed as object, an interface or base type whose runtime type differs, or an unregistered nested model |
No accessor is inferred for the runtime type. Use [FluidRegister] for the concrete runtime type or register an accessor explicitly. |
| Reflection fallback for an unregistered type | NativeAOT-compatible only when the required public member metadata is preserved from trimming. Prefer source generation or explicit registration rather than relying on linker configuration. |
A reflection-discovered Task<T> member |
Avoid in trimmed NativeAOT applications because the reflection fallback uses runtime dynamic binding to read the result. A generated or custom accessor handles Task<T> without dynamic binding. |
Source generation covers public readable properties and public fields that can be referenced from generated code. Members that cannot be generated continue through the normal registration and reflection fallback paths. If any required member uses a fallback path, validate the published application rather than assuming that source generation preserved it.
When the Fluid.SourceGenerator analyzer is enabled, Fluid can generate strongly-typed member accessors for model types discovered at compile time.
The model type is inferred automatically when its compile-time and runtime types match and it is passed with custom options:
var options = new TemplateOptions();
var context = new TemplateContext(person, options);The generated accessor is activated on the DefaultMemberAccessStrategy of the options instance passed to that constructor. Explicit registrations on options.MemberAccessStrategy still take precedence. The one-argument TemplateContext(model) constructor does not infer or activate a model accessor because it uses the shared TemplateOptions.Default instance.
Use FluidRegisterAttribute when a model is passed as object, is created outside the compilation using the source generator, or when nested model types also need generated accessors. The recommended explicit pattern is to declare a custom TemplateOptions subclass and add one attribute per model type:
using Fluid;
[FluidRegister(typeof(Person))]
[FluidRegister(typeof(Address))]
public partial class PublicTemplateOptions : TemplateOptions
{
}Use the generated options type like any other TemplateOptions instance:
var options = new PublicTemplateOptions();The generated registrations are instance-scoped and are applied automatically to each PublicTemplateOptions instance. Runtime registrations still work and can be added normally:
options.MemberAccessStrategy.Register<Product, object>((product, name) => product.Name);Custom accessors derive from MemberAccessor and return a ValueTask<FluidValue> directly.
private sealed class ProductDisplayNameAccessor : MemberAccessor
{
public override ValueTask<FluidValue> GetAsync(
object obj,
string name,
TemplateContext context)
{
return CreateValueTask(((Product)obj).Name, context);
}
}
options.MemberAccessStrategy.Register<Product>(
"display_name",
new ProductDisplayNameAccessor());T
No open issues yet, or sync has not completed.