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."
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.
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:
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:
_109public static string AppendTypeScriptModelToFile(SitecoreTemplate template, string relativePathToTsModelFile)
_109 var destPath = relativePathToTsModelFile;
_109 List<string> outputLines = new List<string>();
_109 DateTime lastModTime = File.GetLastWriteTime(destPath);
_109 TimeSpan timeSinceLastMod = DateTime.Now - lastModTime;
_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 // 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 var namespaceParts = template.Namespace.Split('.');
_109 var indent = new string(' ', namespaceParts.Length * 2);
_109 for (int i = 0; i < namespaceParts.Length; i++)
_109 var namespaceName = CleanName(namespaceParts[i]);
_109 outputLines.Add(new string(' ', i * 2) + ((i == 0) ? "export namespace " : "export namespace ") + namespaceName + " {");
_109 outputLines.Add(indent + "/**");
_109 var templateDescription = GetValue(template.SitecoreFields, "__Short description");
_109 if (!string.IsNullOrEmpty(templateDescription))
_109 outputLines.Add(indent + $" * {templateDescription}");
_109 outputLines.Add(indent + " *");
_109 outputLines.Add(indent + $" * @interface '{template.Name}'");
_109 outputLines.Add(indent + $" * @templatepath '{template.Path}'");
_109 var templateIdStr = template.ID.ToString("B").ToUpperInvariant();
_109 outputLines.Add(indent + $" * @templateid '{templateIdStr}'");
_109 outputLines.Add(indent + " */");
_109 outputLines.Add(indent + $"export interface {GetInterfaceName(template)}{AppendBaseInterfacesTypeScript(template)}" + " {");
_109 foreach(SitecoreField field in GetFieldsForTemplate(template, false))
_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 outputLines.Add(indent + "};");
_109 outputLines.Add(indent + "/**");
_109 if (!string.IsNullOrEmpty(templateDescription))
_109 outputLines.Add(indent + $" * {templateDescription}");
_109 outputLines.Add(indent + " *");
_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 // 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 foreach(SitecoreField field in GetFieldsForTemplate(template, true))
_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 outputLines.Add(indent + " };");
_109 outputLines.Add(indent + "};");
_109 for (int i = namespaceParts.Length - 1; i >= 0; i--)
_109 outputLines.Add(new string(' ', i * 2) + "}");
_109 System.IO.File.AppendAllLines(destPath, outputLines);
_109 return string.Empty;
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:
_5var currentPath = Host.ResolvePath(string.Empty);
_5var relativePathToTsModelFile = Path.Combine(currentPath, @"..\..\relative\path\to\your\model.ts");
_5AppendTypeScriptModelToFile(template, relativePathToTsModelFile);
End Result
The output model.ts file will look something like this:
_76/* This file is automatically generated. Manual changes will be overwritten. */
_76import { Field, ImageField, Item, LinkField } from '@sitecore-jss/sitecore-jss-nextjs';
_76export namespace Website {
_76 export namespace Foundation {
_76 export namespace LocalDatasource {
_76 * @interface 'Rendering Options'
_76 * @templatepath '/sitecore/templates/System/Layout/Sections/Rendering Options'
_76 * @templateid '{D1592226-3898-4CE2-B190-090FD5F84A4C}'
_76 export interface IRenderingOptions {
_76 * @field 'Datasource Location'
_76 * @fieldtype 'Datasource'
_76 * @fieldid '{B5B27AF1-25EF-405C-87CE-369B3A004016}'
_76 datasourceLocation: Field<string> /* UNKNOWN */;
_76 * @field 'Detect Content'
_76 * @fieldtype 'Checkbox'
_76 * @fieldid '{56A91E93-0B26-44E4-B44F-135FF7879B5B}'
_76 detectContent: Field<boolean>;
_76 * @field 'JSONPath Queries'
_76 * @fieldtype 'Name Value List'
_76 * @fieldid '{99A3F0DF-D3BF-4CF5-A919-3D1B09356ADB}'
_76 jsonPathQueries: Field<string>;
_76 * @field 'Supports Local Datasource'
_76 * @fieldtype 'Checkbox'
_76 * @fieldid '{017DF358-AC81-4ABF-9801-3617B4667396}'
_76 supportsLocalDatasource: Field<boolean>;
_76 * @type 'Rendering Options'
_76 * @templatepath '/sitecore/templates/System/Layout/Sections/Rendering Options'
_76 * @templateid '{D1592226-3898-4CE2-B190-090FD5F84A4C}'
_76 export type RenderingOptions = {
_76 * @field 'Datasource Location'
_76 * @fieldtype 'Datasource'
_76 * @fieldid '{B5B27AF1-25EF-405C-87CE-369B3A004016}'
_76 datasourceLocation: Field<string> /* UNKNOWN */;
_76 * @field 'Detect Content'
_76 * @fieldtype 'Checkbox'
_76 * @fieldid '{56A91E93-0B26-44E4-B44F-135FF7879B5B}'
_76 detectContent: Field<boolean>;
_76 * @field 'JSONPath Queries'
_76 * @fieldtype 'Name Value List'
_76 * @fieldid '{99A3F0DF-D3BF-4CF5-A919-3D1B09356ADB}'
_76 jsonPathQueries: Field<string>;
_76 * @field 'Supports Local Datasource'
_76 * @fieldtype 'Checkbox'
_76 * @fieldid '{017DF358-AC81-4ABF-9801-3617B4667396}'
_76 supportsLocalDatasource: Field<boolean>;
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:
_36public static string RunArbitraryCommand()
_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 var processStartInfo = new ProcessStartInfo()
_36 UseShellExecute = false,
_36 RedirectStandardOutput = true,
_36 RedirectStandardInput = true,
_36 WorkingDirectory = workingDirectory
_36 var process = Process.Start(processStartInfo);
_36 throw new Exception("Process should not be null.");
_36 process.StandardInput.WriteLine($"{command} & exit");
_36 process.WaitForExit();
_36 var output = process.StandardOutput.ReadToEnd();
_36If you have any comments or suggestions, feel free to reach out.