Using Liquid for text-based templates with .NET

Martin Tirion
5 min readAug 31, 2022

--

In a solution you sometimes need something text-based (a template) that you can combine with data to provide some output. Maybe it’s a template for an e-mail template that needs to result in a specific e-mail to someone combined with that persons data. You could call it an on-the-fly mail-merge functionality.

Of course there are lots of other scenario’s for this. For instance, process an HTML page from a template in combination with data. For an earlier project with an enterprise customer, we needed a JSON template, parse it with data from a backend and output the proper JSON that we could then ingest into a service.

In our search for a solution, we came across the Liquid template language. Liquid is an open-source template language created by Shopify. It’s very simple in use and also has support for (basic) logic like looping over data, if-statements and such. And because it’s text-based, it’s easy to add or modify templates while the system is running. The only thing you need to know is how to access the data.

The Liquid language

Liquid templates have (usually) the extension `.liquid` and the language is a combination of objects, tags and filters. This is a simple example of text combined with a piece of Liquid. The double braces indicate the Liquid code.

This is sample output of just a string {{ "Hello Liquid!" }}

When this is parsed, it outputs:

This is sample output of just a string Hello Liquid!

Not very useful of course, you could have typed that in the first place 🤓. But when you combine it with data that’s available, you can do something like this:

This is the first table name: {{ model.Tables[0].Name }}

In my test environment (and backing code) this results in:

This is the first table name: Claims

When applying Liquid code for logic, you could do something like this to see all table names available:

{%- comment -%}for loop{%- endcomment -%}
Tables in the model:
{% for table in model.Tables -%}
{{ table.Name }}
{% endfor -%}

You can also assign variables, do filter (simple) operations which can be used in the template logic. Here is an example that defines a string with a list of team members, splits it into an array, show the size of the array and the team members.

{%- comment -%}Split{%- endcomment %}
{% assign teamWindmill = "Laurent, Jan, Carlos, Ibrahim, Bart, Isabel, Martin" | split: ", " %}
Team Windmill has {{ teamWindmill | size }} members:
{% for member in teamWindmill -%}
{{ member }}
{% endfor -%}

This method of filtering can also be done on the data we receive from the model. For instance, this is an example to list the id, name and gender of all female patients in our data set:

{% assign list = model.Patients.Records | where: "gender", "F" -%}
Female Patients:
{% for entry in list -%}
{{ entry.Id }} = {{ entry.Name }} ({{ entry.gender }})
{% endfor -%}

All nice things to do, but you might wonder … how does the template know where to get the data from? Let me explain …

Combining a Liquid template with data

To combine Liquid templates with data, you’ll need to write some code. That’s what we did for our solution. We used .NET 6 in combination with the NuGet package Fluid. The code for the package is open source and can be found on GitHub.

The core flow of using this package is:

  1. Create a FluidParser
  2. Pre-parse the template into a IFLuidTemplate
  3. Create a TemplateContext where you can add data
  4. Call the Render method on the IFluidTemplate object which outputs a string.

An brief example in code:

string templateContent = "Some liquid {{ model.Tables[0].Name }}";
...
var parser = new FluidParser();if (parser.TryParse(templateContent,
out IFluidTemplate template,
out string error))
{
TemplateOptions options = new TemplateOptions();
options.MemberAccessStrategy = new UnsafeMemberAccessStrategy();
var ctx = new TemplateContext(
new { model = data }, options, true);
try
{
string output = template.Render(ctx);
}
catch (Exception ex)
{
// handling of parser error
}
}
else
{
// handling of the template parsing error
}

You can see that we pass in the data object as model. This means that the Liquid code can access model. to get to whatever is in the model. It can be an object, but also a collection or even a tree of objects and collections. You can even provide more than one object to the Liquid code.

Developing Liquid Templates

Once you have this in place, you need the templates of course. And they can be very big or complex. There is a nice way to use sub components, which is actually a partial template stored in separate files. But still, the work on templates can be hard to test — as you need to parse it through your software solution to see if it works.

In our project we did some work to educate the customer on the Liquid language, but also on the solution we build for them with the Liquid templates. For that purpose we created a Visual Studio Code extension called Liquid Notebook. It gives you a notebook in the style of Jupyter-notebook, but then with Liquid as a supported language.

Then we created two notebooks using that extension:

Once you understand the Liquid language and how to use the VSCode Extension, then you can use a Liquid Notebook file for your own project to work on templates, try out small things, and document them at the same time in the same file.

Conclusion

Liquid is a very powerful template language that is really useful if you need some kind of mail-merge or quick-find-and-replace processing based on text. As you can store Liquid templates in files, but also in databases, it’s pretty easy to include this in a project.

--

--

Martin Tirion

Senior Software Engineer at Microsoft working on Azure Services and Spatial Computing for enterprise customers around the world.