Automatic TypeScript Model Generation With TDS

Cover Image for Automatic TypeScript Model Generation With TDS

New Tech, New Problems

Building stable Sitecore sites with NextJS is best done with TypeScript (not plain JS). Front end developers (FEDs) simply need the IDE feedback that TypeScript provides; particularly around null pointers. In my experience, even if you tell FEDs that there are always cases in which almost any field or item or value can be null, there's just too much to remember. TypeScript is a great way to help FEDs remember.

The future landscape of content serialization and model generation is a bit unclear at the moment. The transition to headless and JS feels like a step backwards in some ways because I haven't yet seen a compelling way to generate TypeScript models from Sitecore. For example, serialization is moving towards Sitecore Content Serialization, but it does not yet support model generation.

This is a problem because many of our sites will have hundreds of templates and perhaps even thousands of fields. Manually creating TypeScript models for all of these is too slow.

"What do you need, Neo?"

"Models. Lots of Models."

Matrix

Matrix Code

TDS Model Generation

One of the great features of TDS is that it supports automatic model generation out of the box. It does so using T4 Text Templates (.tt files). T4 is a code generation tool that is built into Visual Studio. It's a bit tedious to work with, but it's a great way to generate code from other code.

TDS Model Generation

With the click of a single button, or the syncing of a single item, TDS will generate for us a glorious monolithic .cs file containing all of our models:

Monolith Model File

From there, ORM tools such as GlassMapper can be used to map Sitecore items to these models.

The Challenge

I wanted a way to hook into the existing TDS model generation process in a way that was fully automatic so developers wouldn't forget to sync. I also wanted to write directly to a .ts file.

Keep in mind that the templates.tt file runs once per template, but I haven't yet found a OOTB way to track where we are in the loop (start and end is crucial here).

The Solution

If you don't already have TDS code generation set up, be sure to check out the TDS guide on code generation.

Make sure that the top of your common.tt file looks like this:


_3
<#@ template language="C#" debug="true" hostspecific="true" #>
_3
<#@ import namespace="System.IO" #>
_3
<#@ import namespace="System.Linq" #>

Further down, add this:


_109
public static string AppendTypeScriptModelToFile(SitecoreTemplate template, string relativePathToTsModelFile)
_109
{
_109
var destPath = relativePathToTsModelFile;
_109
_109
List<string> outputLines = new List<string>();
_109
_109
DateTime lastModTime = File.GetLastWriteTime(destPath);
_109
TimeSpan timeSinceLastMod = DateTime.Now - lastModTime;
_109
_109
// Here is where we get a bit cheeky and take a simple heuristic approach to determining if the loop is just starting
_109
if (timeSinceLastMod.TotalSeconds >= 45)
_109
{
_109
// Clear the file and append header
_109
System.IO.File.WriteAllText(destPath, string.Empty);
_109
outputLines.Add("/* eslint-disable */");
_109
outputLines.Add("/* This file is automatically generated. Manual changes will be overwritten. */");
_109
outputLines.Add("import { Field, ImageField, Item, LinkField } from '@sitecore-jss/sitecore-jss-nextjs';");
_109
outputLines.Add("");
_109
System.IO.File.AppendAllLines(destPath, outputLines);
_109
outputLines = new List<string>();
_109
}
_109
_109
var namespaceParts = template.Namespace.Split('.');
_109
var indent = new string(' ', namespaceParts.Length * 2);
_109
_109
for (int i = 0; i < namespaceParts.Length; i++)
_109
{
_109
var namespaceName = CleanName(namespaceParts[i]);
_109
_109
outputLines.Add(new string(' ', i * 2) + ((i == 0) ? "export namespace " : "export namespace ") + namespaceName + " {");
_109
}
_109
_109
outputLines.Add(indent + "/**");
_109
_109
var templateDescription = GetValue(template.SitecoreFields, "__Short description");
_109
_109
if (!string.IsNullOrEmpty(templateDescription))
_109
{
_109
outputLines.Add(indent + $" * {templateDescription}");
_109
outputLines.Add(indent + " *");
_109
}
_109
_109
outputLines.Add(indent + $" * @interface '{template.Name}'");
_109
outputLines.Add(indent + $" * @templatepath '{template.Path}'");
_109
_109
var templateIdStr = template.ID.ToString("B").ToUpperInvariant();
_109
outputLines.Add(indent + $" * @templateid '{templateIdStr}'");
_109
_109
outputLines.Add(indent + " */");
_109
outputLines.Add(indent + $"export interface {GetInterfaceName(template)}{AppendBaseInterfacesTypeScript(template)}" + " {");
_109
_109
foreach(SitecoreField field in GetFieldsForTemplate(template, false))
_109
{
_109
var fieldId = field.ID.ToString("B").ToUpperInvariant();
_109
var fieldTypeTS = GetFieldTypeTypeScript(field, true).Replace("|* UNKNOWN *|", "/* UNKNOWN */");
_109
_109
outputLines.Add(indent + $" /**");
_109
outputLines.Add(indent + $" * @field '{field.Name}'");
_109
outputLines.Add(indent + $" * @fieldtype '{field.Type}'");
_109
outputLines.Add(indent + $" * @fieldid '{fieldId}'");
_109
outputLines.Add(indent + $" */");
_109
outputLines.Add(indent + $" {GetPropertyNameTypeScript(field)}: {fieldTypeTS};");
_109
}
_109
_109
outputLines.Add(indent + "};");
_109
outputLines.Add(indent + "/**");
_109
_109
if (!string.IsNullOrEmpty(templateDescription))
_109
{
_109
outputLines.Add(indent + $" * {templateDescription}");
_109
outputLines.Add(indent + " *");
_109
}
_109
_109
outputLines.Add(indent + $" * @type '{template.Name}'");
_109
outputLines.Add(indent + $" * @templatepath '{template.Path}'");
_109
outputLines.Add(indent + $" * @templateid '{templateIdStr}'");
_109
outputLines.Add(indent + $" */");
_109
outputLines.Add(indent + $"export type {GetTypeName(template)} = " + "{");
_109
_109
// Pay special attention to the "?" here. This is what will flag possible null ref errors in TypeScript.
_109
outputLines.Add(indent + $" id?: string;");
_109
outputLines.Add(indent + $" url?: string;");
_109
outputLines.Add(indent + $" fields: " + "{");
_109
_109
foreach(SitecoreField field in GetFieldsForTemplate(template, true))
_109
{
_109
var fieldId = field.ID.ToString("B").ToUpperInvariant();
_109
var fieldTypeTS = GetFieldTypeTypeScript(field, true).Replace("|* UNKNOWN *|", "/* UNKNOWN */");
_109
outputLines.Add(indent + $" /**");
_109
outputLines.Add(indent + $" * @field '{field.Name}'");
_109
outputLines.Add(indent + $" * @fieldtype '{field.Type}'");
_109
outputLines.Add(indent + $" * @fieldid '{fieldId}'");
_109
outputLines.Add(indent + $" */");
_109
outputLines.Add(indent + $" {GetPropertyNameTypeScript(field)}: {fieldTypeTS};");
_109
}
_109
_109
outputLines.Add(indent + " };");
_109
outputLines.Add(indent + "};");
_109
_109
for (int i = namespaceParts.Length - 1; i >= 0; i--)
_109
{
_109
outputLines.Add(new string(' ', i * 2) + "}");
_109
}
_109
_109
System.IO.File.AppendAllLines(destPath, outputLines);
_109
_109
return string.Empty;
_109
}
_109
#>

How to Call The Code

In your templates.tt file, ensure the header of your file contains:


_9
<#@ template language="C#" debug="true" hostspecific="true" #>
_9
<#@ assembly name="System.Core" #>
_9
<#@ import namespace="System.IO" #>
_9
<#@ import namespace="System.Collections.Generic" #>
_9
<#@ import namespace="System.Linq" #>
_9
<#@ import namespace="HedgehogDevelopment.SitecoreProject.VSIP.CodeGeneration.Models" #>
_9
<#@ parameter name="Model" type="HedgehogDevelopment.SitecoreProject.VSIP.CodeGeneration.Models.SitecoreItem" #>
_9
<#@ parameter name="DefaultNamespace" type="System.String" #>
_9
<#@ include file="Common.tt" #>

If you've set up TDS code generation properly, you will already have plenty of logic in this file. At the bottom of the file, here is how you would make the call to the method we set up in common.tt:


_5
<#
_5
var currentPath = Host.ResolvePath(string.Empty);
_5
var relativePathToTsModelFile = Path.Combine(currentPath, @"..\..\relative\path\to\your\model.ts");
_5
AppendTypeScriptModelToFile(template, relativePathToTsModelFile);
_5
#>

End Result

The output model.ts file will look something like this:


_76
/* eslint-disable */
_76
/* This file is automatically generated. Manual changes will be overwritten. */
_76
import { Field, ImageField, Item, LinkField } from '@sitecore-jss/sitecore-jss-nextjs';
_76
_76
export namespace Website {
_76
export namespace Foundation {
_76
export namespace LocalDatasource {
_76
/**
_76
* @interface 'Rendering Options'
_76
* @templatepath '/sitecore/templates/System/Layout/Sections/Rendering Options'
_76
* @templateid '{D1592226-3898-4CE2-B190-090FD5F84A4C}'
_76
*/
_76
export interface IRenderingOptions {
_76
/**
_76
* @field 'Datasource Location'
_76
* @fieldtype 'Datasource'
_76
* @fieldid '{B5B27AF1-25EF-405C-87CE-369B3A004016}'
_76
*/
_76
datasourceLocation: Field<string> /* UNKNOWN */;
_76
/**
_76
* @field 'Detect Content'
_76
* @fieldtype 'Checkbox'
_76
* @fieldid '{56A91E93-0B26-44E4-B44F-135FF7879B5B}'
_76
*/
_76
detectContent: Field<boolean>;
_76
/**
_76
* @field 'JSONPath Queries'
_76
* @fieldtype 'Name Value List'
_76
* @fieldid '{99A3F0DF-D3BF-4CF5-A919-3D1B09356ADB}'
_76
*/
_76
jsonPathQueries: Field<string>;
_76
/**
_76
* @field 'Supports Local Datasource'
_76
* @fieldtype 'Checkbox'
_76
* @fieldid '{017DF358-AC81-4ABF-9801-3617B4667396}'
_76
*/
_76
supportsLocalDatasource: Field<boolean>;
_76
};
_76
/**
_76
* @type 'Rendering Options'
_76
* @templatepath '/sitecore/templates/System/Layout/Sections/Rendering Options'
_76
* @templateid '{D1592226-3898-4CE2-B190-090FD5F84A4C}'
_76
*/
_76
export type RenderingOptions = {
_76
id?: string;
_76
url?: string;
_76
fields: {
_76
/**
_76
* @field 'Datasource Location'
_76
* @fieldtype 'Datasource'
_76
* @fieldid '{B5B27AF1-25EF-405C-87CE-369B3A004016}'
_76
*/
_76
datasourceLocation: Field<string> /* UNKNOWN */;
_76
/**
_76
* @field 'Detect Content'
_76
* @fieldtype 'Checkbox'
_76
* @fieldid '{56A91E93-0B26-44E4-B44F-135FF7879B5B}'
_76
*/
_76
detectContent: Field<boolean>;
_76
/**
_76
* @field 'JSONPath Queries'
_76
* @fieldtype 'Name Value List'
_76
* @fieldid '{99A3F0DF-D3BF-4CF5-A919-3D1B09356ADB}'
_76
*/
_76
jsonPathQueries: Field<string>;
_76
/**
_76
* @field 'Supports Local Datasource'
_76
* @fieldtype 'Checkbox'
_76
* @fieldid '{017DF358-AC81-4ABF-9801-3617B4667396}'
_76
*/
_76
supportsLocalDatasource: Field<boolean>;
_76
};
_76
};
_76
}
_76
}
_76
}

There you have it; a viable method for automating TS model generation. Now that the ground has been broken, there are many ways that this can be customized / optimized.

Future Hacks

There are also some creative things you can do beyond this. For example, you can run command line commands from .tt files in case you want to do any other kind of post processing after the models have been generated.

The solution below is incomplete, but it's a start:


_36
public static string RunArbitraryCommand()
_36
{
_36
// Note that if you call this from templates.tt, this will be called MANY times, so you'll want to implement a cheeky solution like what was done in the common.tt file.
_36
var command = "npm run custom:thing";
_36
var currentPath = Host.ResolvePath(string.Empty);
_36
var workingDirectory = Path.Combine(currentPath, @"..\..\relative\path\to\your\package.json");
_36
_36
var processStartInfo = new ProcessStartInfo()
_36
{
_36
FileName = "cmd",
_36
UseShellExecute = false,
_36
RedirectStandardOutput = true,
_36
RedirectStandardInput = true,
_36
WorkingDirectory = workingDirectory
_36
};
_36
_36
var process = Process.Start(processStartInfo);
_36
_36
if (process == null)
_36
{
_36
throw new Exception("Process should not be null.");
_36
}
_36
_36
process.StandardInput.WriteLine($"{command} & exit");
_36
process.WaitForExit();
_36
_36
var output = process.StandardOutput.ReadToEnd();
_36
_36
return string.Empty;
_36
}
_36
_36
If you have any comments or suggestions, feel free to reach out.
_36
_36
Keep on innovating,
_36
_36
MG


More Stories