Localized String Templating in .NET

I’ve been building a mustache-style string template system for my Saas app. It will mainly be used for e-mail notifications sent to users via Amazon’s SES. The idea is simple; you have a text template where you want to substitute the tokens {{…}} with send-time specific data:

{{Title}}
Here's a sample template for {{Person.Firstname}}! 
Generated on {{CreationDate:d}}

There’s a couple of important features to note here. Firstly, you can reference nested properties in the tokens – handy for passing existing business entities. Secondly, you can add format strings to determine how the token value should be formatted. This is a nice-to-have which means that if you have a locale associated with the user, you can format dates in e-mails to the user’s locale, not the sending server’s locale (i.e. mm/dd/yy or dd/mm/yy)

Here’s an simple example of how it would be called:

String template = @"{{Title}}
Here's a sample template for {{Person.Firstname}}! 
Generated on {{CreationDate:d}}";

PersonEntity Person = new PersonEntity();
Person.Firstname = "Brendan";
Person.Surname = "Whelan";
Person.Locale = "en-IE";

String localizedConcreteString = template.Inject(new {
                                                      Title = "Sample Injected Title", 
                                                      Person,
                                                      CreationDate = DateTime.UtcNow}, 
                                                      CultureInfo.GetCultureInfo(Person.Locale));

This generates localizedConcreteString as follows:

Sample Injected Title
Here's a sample template for Brendan!
Generated on 23/08/2013 

All the work for this is done by the Inject extension method, which means that it can be used generally, on any String where templating might be needed.

public static class StringInjectExtension
{
    public static string Inject(this string TemplateString, object InjectionObject)
    {
         return Inject(TemplateString, InjectionObject, CultureInfo.InvariantCulture);
    }

    public static string Inject(this string TemplateString, object InjectionObject, CultureInfo Culture)
    {
         return Inject(TemplateString, GetPropertyHash(InjectionObject), Culture);
    }

    public static string Inject(this string TemplateString, Hashtable values, CultureInfo Culture)
    {
         string result = TemplateString;

         //Assemble all tokens to replace
         Regex tokenRegex = new Regex("{{((?<noprops>\\w+(?:}}|(?\<hasformat>:(.[^}]*))}}))|(\<hasprops>(\\w|\\.)+(?:}}|(?\<hasformat>:(.[^}]*))}})))",
                                         RegexOptions.IgnoreCase | RegexOptions.Compiled);
            
         foreach (Match match in tokenRegex.Matches(TemplateString))
         {
             string replacement = match.ToString();

             //Get token version without mustache braces
             string shavenToken = match.ToString();
             shavenToken = shavenToken.Substring(2, shavenToken.Length - 4);

             //Formatted?
             string format = null;
             if (match.Groups["hasformat"].Length > 0)
             {
                 format = match.Groups["hasformat"].ToString();
                 shavenToken = shavenToken.Replace(format, null);
                 format = format.Substring(1);
             }
                
             if (match.Groups["noprops"].Length > 0) //matched {{foo}}
             {
                 replacement = FormatValue(values, shavenToken, format, Culture);
             }
             else //matched {{foo.bar[...]}}
             {
                 //Get the value of the nested property from the token and
                 //store it in value hashtable to avoid having to get it again (in case reused in current template)
                 if(!values.ContainsKey(shavenToken)){

                        string[] properties = shavenToken.Split(new char[] { '.' });
                        object propertyObject = values[properties[0]];
                        for(int propIdx = 1; propIdx < properties.Length; propIdx++){
                            if (propertyObject == null) break;
                            propertyObject = GetPropValue(propertyObject, properties[propIdx]);
                        }
                        values.Add(shavenToken, propertyObject);
                 }
                 replacement = FormatValue(values, shavenToken, format, Culture);
             }
                
             result = result.Replace(match.ToString(), replacement);
            }
            return result;
        }

        private static string FormatValue(Hashtable values, string key, string format, CultureInfo culture){
            var value = values[key];

            if (format != null)
            {
                //do a double string.Format - first to build the proper format string, and then to format the replacement value
                string attributeFormatString = string.Format(culture, "{{0:{0}}}", format);
                return string.Format(culture, attributeFormatString, value);
            }
            else
            {
                return (value ?? String.Empty).ToString();
            }
        }

        private static object GetPropValue(object PropertyObject, string PropertyName)
        {
            PropertyDescriptorCollection props = TypeDescriptor.GetProperties(PropertyObject);
            PropertyDescriptor prop = props.Find(PropertyName, true);

            return prop.GetValue(PropertyObject);
        }

        private static Hashtable GetPropertyHash(object properties)
        {
            Hashtable values = new Hashtable();
            if (properties != null)
			{
				PropertyDescriptorCollection props = TypeDescriptor.GetProperties(properties);
				foreach (PropertyDescriptor prop in props)
				{
				    values.Add(prop.Name, prop.GetValue(properties));
				}
			}
			return values;
		}

	}
}

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.