Unicorn code generation from your Sitecore templates

Unicorn code generation from your Sitecore templates

If you have ever wanted automatic code generation for your Sitecore templates – with static references to template IDs and field IDs/names, like you can get with TDS and Glass Mapper – then this is one way to get exactly that.

Intro

Some time ago, at my current company Oxygen, we started a new project based on the Sitecore Habitat (or Sitecore Helix) structure with code split into layers and modules. We were also switching from usually TDS to Unicorn instead, but wanted some of the same code generation features that TDS offers, specifically typed and easy access to template IDs and fields.

The Glass Mapper code generation templates for use with TDS offers full model generation for each of your templates, creating classes/interfaces for all your templates and mapping all the template fields to matching properties. I’m not a big fan of this, as it adds a lot of bloat and if you want to change or extend some of the models you have to use partials and/or extenion methods.

So, while it’s possible to do the same with Unicorn, this blog post focuses on generating static references to template IDs, field IDs and names. These can then be used to create and map Glass Mapper models manually for their specific purpose – or just for using the standard Sitecore API. However, it should be pretty straight forward to extend the examples for full on model generation or whatever your needs are.

It took some experimenting with T4 templates and it’s not completely automatic, but it works pretty well and saves a lot of time. This could probably be improved/automated more with a Visual Studio extension or something, but that’s about outside my current knowledge.

T4 templates

I came up with the following three T4 templates to achieve my goal.

Assemblies.tt – Place at solution root (or subfolder)

<#@ assembly name="$(SolutionDir)packages\Rainbow.Core.1.3.1\lib\net45\Rainbow.dll" #>
<#@ assembly name="$(SolutionDir)packages\Rainbow.Storage.Yaml.1.3.1\lib\net45\Rainbow.Storage.Yaml.dll" #>
<#@ assembly name="$(SolutionDir)packages\Sitecore.Kernel.8.1.160302.0\lib\net45\Sitecore.Kernel.dll" #>

First off we need the above to include some needed assemblies, specifically some from Rainbow (Unicorn’s serialization provider) and Sitecore.Kernel to be able to get the information we need for generating the code we want. You will of course have to install the packages from NuGet (or otherwise have the assemblies available) and make sure to point the above paths to the correct folder/version.

Templates.tt – Copy to each project

<#@ template debug="true" hostspecific="true" language="C#" #>
<#@ output extension=".cs" encoding="utf-8" #>

<#@ include file="$(SolutionDir)codegen\Assemblies.tt" #>

<#

var layer = "Foundation|Features|Projects";
var module = "YOUR MODULE NAME";

IgnoreNamespacePath = "/sitecore/templates/"+layer+"/"+module;
BaseNamespace = System.Runtime.Remoting.Messaging.CallContext.LogicalGetData("NamespaceHint").ToString();
CustomNamespace = "";

var solutionPath = this.Host.ResolveAssemblyReference("$(SolutionDir)");
var projectPath = this.Host.ResolveAssemblyReference("$(ProjectDir)");

Configurations = new string[]
{
    solutionPath + "serialization\\Foundation.Serialization\\Templates."+layer+"\\"+layer+"\\"+module
};

#>

<#@ include file="$(SolutionDir)codegen\Unicorn.tt" #>

Next up we have our T4 template with configuration. This one starts out with including Assemblies.tt and then setting some configuration parameters. You need to set the layer and the name of the module that you want to generate code for. The IgnoreNamespacePath variable removes that part from the generated namespace, as it is basically pointless. Customize if needed. You can also set the CustomNamespace variable if you want the generated code to be nested in a custom namespace.

You might need to change the solutionPath variable to suit your project as our solution structure differs a bit from the standard Habitat structure. Also make sure the includes (Assemblies.tt at the top, Unicorn.tt at the bottom of the file) point to the correct path.

This file should copied to each new project where you want to have code generation. Each time you save this file it will (re)create a .cs file with the same name as the template (Templates.cs in this case) which contains all the generated code.

At the bottom we include Unicorn.tt.

Unicorn.tt – Place in solution root (or subfolder)

<#@ assembly name="System.Core" #>
<#@ assembly name="System.Xml" #>

<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Text.RegularExpressions" #>
<#@ import namespace="System.Xml" #>

<#@ import namespace="Rainbow.Model" #>
<#@ import namespace="Rainbow.Storage.Yaml" #>
<#@ import namespace="Sitecore" #>

// ReSharper disable InconsistentNaming
namespace <#= BaseNamespace#>
{
    public static class Templates
    {
<#
foreach (var data in GetTemplateData())
{
    var template = data.Template;
    var fields = data.Fields;
#>
        #region <#= template.Path#>
        /// <summary>
        ///   <#= template.Name#>
        ///   <para>ID: <#= template.Id.ToString("B").ToUpper()#></para>
        ///   <para>Path: <#= template.Path#></para>
        /// </summary>
        public static class <#= GetClassName(template)#>
        {
            public static readonly Sitecore.Data.TemplateID ID = new Sitecore.Data.TemplateID(new Sitecore.Data.ID("<#= template.Id.ToString("B").ToUpper()#>"));
            public const string IdString = "<#= template.Id.ToString("B").ToUpper()#>";

<#
        if (fields.Any())
        {
#>
            public static class Fields
            {
<#
            foreach (var field in fields)
            {
#>
                /// <summary>
                ///   <#= field.Name#>
                ///   <para><#= field.Id.ToString("B").ToUpper()#></para>
                /// </summary>
                public static readonly Sitecore.Data.ID <#= GetFieldName(field)#> = new Sitecore.Data.ID("<#= field.Id.ToString("B").ToUpper()#>");

                /// <summary>
                ///   <#= field.Name#>
                ///   <para><#= field.Id.ToString("B").ToUpper()#></para>
                /// </summary>
                public const string <#= GetFieldName(field)#>_FieldName = "<#= field.Name#>";

<#          }#>
            }
<#      }#>
        }
        #endregion
<#
}
#>
    }
}

<#+

public string GetClassName(IItemData template)
{
    return AsValidWord(template.Name);
}

public string GetFieldName(IItemData field)
{
    return AsValidWord(field.Name);
}

public string GetFullNamespace(IItemData template)
{
    var path = template.Path;

    if (!string.IsNullOrEmpty(IgnoreNamespacePath))
    {
        path = Regex.Replace(path, "^" + IgnoreNamespacePath, "", RegexOptions.IgnoreCase);
    }

    var parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
    parts = parts.Select(AsValidWord).ToArray();
    var templateNamespace = string.Join(".", parts.Take(parts.Length - 1));

    return JoinNamespaces(BaseNamespace, CustomNamespace, templateNamespace);
}

public string JoinNamespaces(params string[] namespaces)
{
    return string.Join(".", namespaces.Where(x => !string.IsNullOrEmpty(x)));
}

public string AsValidWord(string part)
{
    part = TitleCase(part);
    part = part.Replace(" ", "");
    part = Regex.Replace(part, "^_", "");
    part = part.Replace("-", "");
    while (Regex.IsMatch(part, "^\\d"))
    {
        part = Regex.Replace(part, "^1", "One");
        part = Regex.Replace(part, "^2", "Two");
        part = Regex.Replace(part, "^3", "Three");
        part = Regex.Replace(part, "^4", "Four");
        part = Regex.Replace(part, "^5", "Five");
        part = Regex.Replace(part, "^6", "Six");
        part = Regex.Replace(part, "^7", "Seven");
        part = Regex.Replace(part, "^8", "Eight");
        part = Regex.Replace(part, "^9", "Nine");
    }
    return part;
}

public static string TitleCase(string word)
{
    word = Regex.Replace(word, "([a-z](?=[A-Z])|[A-Z](?=[A-Z][a-z]))", "$1+");
    word = System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(word);
    word = word.Replace("+", "");
    return word;
}

public IEnumerable<TemplateData> GetTemplateData() {
    var serializer = new YamlSerializationFormatter(null, null);

    var files = Configurations.SelectMany(x => Directory.EnumerateFiles(x, "*.yml", SearchOption.AllDirectories));

    var items = new List<IItemData>();

    foreach (var file in files)
    {
        using (var reader = File.OpenRead(file))
        {
            var item = serializer.ReadSerializedItem(reader, file);
            items.Add(item);
        }
    }

    var itemsLookup = items.ToLookup(x => x.ParentId, x => x);

    var templates = items.Where(x => x.TemplateId == Sitecore.TemplateIDs.Template.Guid);

    return templates.Select(template => new TemplateData
    {
        Template = template,
        Fields = GetFields(template.Id, itemsLookup)
    });
}

public IList<IItemData> GetSections(Guid templateId, ILookup<Guid, IItemData> lookup)
{
    return lookup[templateId].Where(x => x.TemplateId == Sitecore.TemplateIDs.TemplateSection.Guid).ToList();
}

public IList<IItemData> GetFields(Guid templateId, ILookup<Guid, IItemData> lookup)
{
    var sectionIds = GetSections(templateId, lookup).Select(x => x.Id);
    return sectionIds.SelectMany(x => lookup[x].Where(item => item.TemplateId == Sitecore.TemplateIDs.TemplateField.Guid).ToList()).ToList();
}

public class TemplateData
{
    public IItemData Template { get; set; }
    public IEnumerable<IItemData> Fields { get; set; }
}

private string IgnoreNamespacePath { get; set; }
private string BaseNamespace { get; set; }
private string CustomNamespace { get; set; }
private IEnumerable<string> Configurations { get; set; }

#>

This is a big one. It contains the “template” for the generated code and some helper functions to actually generate the code. You can of course customize the template to suit your needs. For example you might want to use Guid instead of Sitecore’s ID or add model generation.

Conclusion

In our solution we have these three files in a folder called codegen at the root of our solution. Whenever we create a new project (module) we just copy the Templates.tt file to the root of the project. Whenever you save the T4 template (.tt files) in Visual Studio it will compile the template and output the generated code as Templates.cs.

In the end, when you open and save your Templates.tt file you should get something like the below example and you now havde easy and typed access to your Sitecore templates and fields. The only downside is you have to manually open and save the file you have synced changed with Unicorn, but I can live with that.

As mentioned you could probably automate or otherwise improve this with a Visual Studio extension or something like it – I’ll leave that up to you 🙂

Example output

// ReSharper disable InconsistentNaming
namespace Project.Features.Metadata
{
    public static class Templates
    {
        #region /sitecore/templates/Features/Metadata/_Metadata
        /// <summary>
        ///   _Metadata
        ///   <para>ID: {7FD44C39-2C2F-452A-924D-3A2A4ACB1328}</para>
        ///   <para>Path: /sitecore/templates/Features/Metadata/_Metadata</para>
        /// </summary>
        public static class Metadata
        {
            public static readonly Sitecore.Data.TemplateID ID = new Sitecore.Data.TemplateID(new Sitecore.Data.ID("{7FD44C39-2C2F-452A-924D-3A2A4ACB1328}"));
            public const string IdString = "{7FD44C39-2C2F-452A-924D-3A2A4ACB1328}";

            public static class Fields
            {
                /// <summary>
                ///   MetaDescription
                ///   <para>{3C29BC95-3FF5-476F-B291-1DC16C327DBE}</para>
                /// </summary>
                public static readonly Sitecore.Data.ID MetaDescription = new Sitecore.Data.ID("{3C29BC95-3FF5-476F-B291-1DC16C327DBE}");

                /// <summary>
                ///   MetaDescription
                ///   <para>{3C29BC95-3FF5-476F-B291-1DC16C327DBE}</para>
                /// </summary>
                public const string MetaDescription_FieldName = "MetaDescription";

                /// <summary>
                ///   MetaKeywords
                ///   <para>{B313B206-989C-47C8-A661-BE965427FCF5}</para>
                /// </summary>
                public static readonly Sitecore.Data.ID MetaKeywords = new Sitecore.Data.ID("{B313B206-989C-47C8-A661-BE965427FCF5}");

                /// <summary>
                ///   MetaKeywords
                ///   <para>{B313B206-989C-47C8-A661-BE965427FCF5}</para>
                /// </summary>
                public const string MetaKeywords_FieldName = "MetaKeywords";

                /// <summary>
                ///   PageTitle
                ///   <para>{3975E54B-D42B-4C4E-B039-D75790C133C7}</para>
                /// </summary>
                public static readonly Sitecore.Data.ID PageTitle = new Sitecore.Data.ID("{3975E54B-D42B-4C4E-B039-D75790C133C7}");

                /// <summary>
                ///   PageTitle
                ///   <para>{3975E54B-D42B-4C4E-B039-D75790C133C7}</para>
                /// </summary>
                public const string PageTitle_FieldName = "PageTitle";

            }
        }
        #endregion
    }
}

With this we have easy access to template IDs and field IDs/names, which we can then use in our code, for example together with Glass Mapper models.

// Example usage with Glass Mapper models
[SitecoreType(TemplateId = Templates.MetaData.IdString)
public interface IMetaData
{
    [SitecoreField(Templates.MetaData.MetaDescription_FieldName)
    string MetaDescription { get; set; }
}