.NET String Templates

By Kyle Manuel

NLightTemplate Version 1.0.2 (current version as of this post) Full source on github released under the MIT License.  A lightweight string template renderer for .NET Framework, .NET Standard or .NET Core.

TLDR;

NLightTemplate was born out of a recurring need (and subsequent fractured code bases) for server-side rendered, user-defined templates. Our research and testing of the available template engines failed to find one that was lightweight and provided the functionality we required. We rolled our own and this is the result of internal iterations on the concept.

View the README.md to find out how to use it.

The Story

A few projects back, we had a requirement for a SaaS application that each tenant have a custom set of email templates that can be created with no code changes (except the HTML of the email).  We developed a convention-based selector that would pick up the email templates something like “~\{tenantName}\confirmation.html”.  The whole template was sent through a token replacement with a dictionary of token:value pairs.  The subject was parsed from a meta tag and it all worked great.

The next project had a similar need for sending emails, so we copied the code and made a few tweaks.  Then the next project had a similar need for sending emails, so we copied the code and made a few tweaks.  Then another project had a similar need…. you get the point.

Somewhere along the way, we added support for enumerables using a foreach token.  After a while, this all turned into one big set of boilerplate code.  We had to populate a dictionary of values, then process the enumerable as another dictionary, then pass it through this renderer.  The foreach only worked to a depth of one and didn’t support more than a single instance.  Any custom formatting had to be done to the value before being placed into the dictionary and couldn’t be set in the template.

NLightTemplate was born one frustrating afternoon after attempting to do something that I’ve done many times before.  I copied the code to a new .NET Standard project.  I began to extract extraneous code and simplify the replacements.  I added a recursive BuildPropertyDictionary method that uses reflection (The overhead here was acceptable.  All tests run in single or double digit milliseconds).  I also added dot notation for property access on reference types and string.Format syntax for padding and numerical/date formats.

The Gory

This is the core method:

internal static string ReplaceText(string text, Dictionary<string, object> replacements, StringTemplateConfiguration cfg) =>
	replacements.ToList().OrderBy((kvp) => (kvp.Value is IEnumerable && kvp.Value.GetType() != typeof(string)) ? 1 : 2).Aggregate(text, (c, k) =>
		(k.Value is IEnumerable enumerable && !(k.Value is string) && c.IndexOf($"{cfg.OpenToken}{cfg.ForeachToken} {k.Key}{cfg.CloseToken}") >= 0 && c.IndexOf($"{cfg.OpenToken}/{cfg.ForeachToken} {k.Key}{cfg.CloseToken}") > 0) ?
			new Regex(string.Format(
					@"{0}(?<inner>(?>{0}(?<LEVEL>)|{1}(?<-LEVEL>)|(?!{0}|{1}).)+(?(LEVEL)(?!))){1}",
					string.Join("", $@"{cfg.OpenToken}{cfg.ForeachToken} {k.Key}{cfg.CloseToken}".ToCharArray().Select(ch => $"\\u{((int)ch).ToString("X4")}")),
					string.Join("", $@"{cfg.OpenToken}/{cfg.ForeachToken} {k.Key}{cfg.CloseToken}".ToCharArray().Select(ch => $"\\u{((int)ch).ToString("X4")}"))
					),
				RegexOptions.IgnorePatternWhitespace | RegexOptions.Singleline)
			.Matches(text).Cast<Match>().Aggregate(c, (prev, match) => prev.Replace(match.Captures[0].Value,
				string.Join("", enumerable.Cast<object>().Select(item => ReplaceText(match.Groups[1].Value, BuildPropertyDictionary(item), cfg)))))
		:
		ReplaceToken(c, k.Key, k.Value, cfg)
	);

It looks like there’s a lot going on here so let’s take this a step at a time.  The dictionary of replacements are converted to a list then ordered by enumerables first.  This is important so we can keep scope within foreach blocks.  The ordered list is then sent through the Linq Aggregate method.  This method checks if we have an enumerable and extracts the body of a foreach token and passes it through the ReplaceText method recursively.  That Regex deserves its own post!  If we just have a normal property, then it passes along to the ReplaceToken method.

This is how we generate the property dictionary:

public static Dictionary<string, object> BuildPropertyDictionary(object obj)
{
	string prefix(string p) => string.IsNullOrEmpty(p) ? "" : $"{p}.";

	IEnumerable<KeyValuePair<string, object>> CollectProperties(string pre, object o) =>
		o.GetType().GetTypeInfo().DeclaredProperties.Where(p => p.GetMethod?.IsPublic ?? false)
			.SelectMany(prop => new[] { new KeyValuePair<string, object>($"{prefix(pre)}{prop.Name}", prop.GetValue(o)) }
			.Concat((prop.PropertyType.GetTypeInfo().IsClass && prop.PropertyType != typeof(string) && !typeof(IEnumerable).GetTypeInfo().IsAssignableFrom(prop.PropertyType.GetTypeInfo())) ?
				CollectProperties($"{prefix(pre)}{prop.Name}", prop.GetValue(o))
				.Select(kvp => new KeyValuePair<string, object>($"{prefix(pre)}{kvp.Key}", kvp.Value)) : new KeyValuePair<string, object>[0]));

	return CollectProperties(string.Empty, obj).ToDictionary(x => x.Key, x => x.Value);
}

If you’ve done reflection in .NET Framework before, you’ll notice a few differences here.  To support .NET Standard >= 1.0, we have to use GetTypeInfo().DeclaredProperties rather than GetProperties().  Also, there’s the use of GetTypeInfo() rather than GetType().  Otherwise, it’s essentially the same.

Let’s take this one step at a time again.  We first setup a simple, locally scoped function to grab the prefix consistently.  Then we create another locally scoped function which builds the property dictionary.  In it, we get all the public properties from the supplied object and create key:value pairs.  Then we concat all non-enumerable reference types recursively, prefixing each one with its owner’s property name.  This gives us the dot notation accessors.  You may be wondering why we check if the property is not a string here.  Well, System.String implements the IEnumerable interface.

Now that we know where we’re replacing a token and with what value, we get to the ReplaceToken method:

internal static string ReplaceToken(string original, string key, object value, StringTemplateConfiguration cfg)
{
	var typeInfo = value?.GetType().GetTypeInfo();
	var toStringMethod = (typeInfo?.IsEnum ?? false ? typeInfo?.BaseType.GetTypeInfo() : typeInfo)?
		.GetDeclaredMethods("ToString")
		.Where(p =>
			p.GetParameters().Select(q => q.ParameterType).SequenceEqual(new Type[] { typeof(string) })
		).FirstOrDefault();

	return Regex.Matches((original = original.Replace($"{cfg.OpenToken}{key}{cfg.CloseToken}", value?.ToString() ?? string.Empty))
			, $@"{cfg.OpenToken}(?<key>{key})(,(?<pad>-*?\d+))*?(:(?<fmt>[^}}]+))*?{cfg.CloseToken}")
		.Cast<Match>()
		.Aggregate(original, (s, match) =>
	{
		var v = toStringMethod == null ? value?.ToString() : toStringMethod.Invoke(value, new[] { match.Groups["fmt"]?.Value ?? string.Empty }) as string;
		if (int.TryParse(match.Groups["pad"]?.Value ?? string.Empty, out int padding))
		{
			v = padding < 0 ? v.PadRight(Math.Abs(padding)) : v.PadLeft(Math.Abs(padding));
		}
		return s.Replace(match.Value, v);
	});
}

Here, we start off checking if this particular object has a ToString() method with the signature of ToString(string format).  We then try to find matches for padding or formatting, replacing simple tokens along the way.  The matches are then passed through the Aggregate method.  Here, we either use the formatted ToString() method if available, or the default ToString().  Then we attempt to parse the padding value and PadRight for negative values and PadLeft for positive values.

Summary

That’s basically all the code.  Check out the source yourself.  With using statements, whitespace, and comments, it’s only 132 lines.  I think this post is actually longer.

We are currently using this in .NET Framework and .NET Core applications to build emails, generate user configured blocks of text for reports and mail merges, write custom errors to the event log, and a few other things.