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

Cover Image for On Sitecore Stack Exchange (SSE)

On Sitecore Stack Exchange (SSE)

> What I've learned, what I see, what I want to see

Cover Image for Symposium 2022 Reflections

Symposium 2022 Reflections

> Sitecore is making big changes

Cover Image for Security Series: App Service IP Restrictions

Security Series: App Service IP Restrictions

> How to manage IP rules "at scale" using the Azure CLI

Cover Image for NextJS/JSS Edit Frames Before JSS v21.1.0

NextJS/JSS Edit Frames Before JSS v21.1.0

> It is possible. We have the technology.

Cover Image for Tips for New Sitecore Developers

Tips for New Sitecore Developers

> If I had more time, I would have written a shorter letter

Cover Image for Early Returns in React Components

Early Returns in React Components

> When and how should you return early in a React component?

Cover Image for Add TypeScript Type Checks to RouteData fields

Add TypeScript Type Checks to RouteData fields

> Inspired by error: Conversion of type may be a mistake because neither type sufficiently overlaps with the other.

Cover Image for Content Editor Search Bar Not Working

Content Editor Search Bar Not Working

> Sometimes it works, sometimes not

Cover Image for Year in Review: 2022

Year in Review: 2022

> Full steam ahead

Cover Image for Azure PaaS Cache Optimization

Azure PaaS Cache Optimization

> App Services benefit greatly from proper configuration

Cover Image for SPE Script Performance & Troubleshooting

SPE Script Performance & Troubleshooting

> Script never ends or runs too slow? Get in here.

Cover Image for Hello World

Hello World

> Welcome to the show

Cover Image for Script: Boost SIF Certificate Expiry Days

Script: Boost SIF Certificate Expiry Days

> One simple script that definitely won't delete your system32 folder

Cover Image for NextJS: Access has been blocked by CORS policy

NextJS: Access has been blocked by CORS policy

> CORS is almost as much of a nuisance as GDPR popups

Cover Image for Critical Security Bulletin SC2024-001-619349 Announced

Critical Security Bulletin SC2024-001-619349 Announced

> And other scintillating commentary

Cover Image for NextJS: Unable to Verify the First Certificate

NextJS: Unable to Verify the First Certificate

> UNABLE_TO_VERIFY_LEAF_SIGNATURE

Cover Image for JSS: Reducing Bloat in Multilist Field Serialization

JSS: Reducing Bloat in Multilist Field Serialization

> Because: performance, security, and error-avoidance

Cover Image for How to Run Old Versions of Solr in a Docker Container

How to Run Old Versions of Solr in a Docker Container

> Please don't make me install another version of Solr on my local...

Cover Image for NextJS: Short URL for Viewing Layout Service Response

NextJS: Short URL for Viewing Layout Service Response

> Because the default URL is 2long4me

Cover Image for Tips for Applying Cumulative Sitecore XM/XP Patches and Hotfixes

Tips for Applying Cumulative Sitecore XM/XP Patches and Hotfixes

> It's probably time to overhaul your processes

Cover Image for On Mentorship and Community Contributions

On Mentorship and Community Contributions

> Reflections and what I learned as an MVP mentor

Cover Image for Tips for Forms Implementations

Tips for Forms Implementations

> And other pro tips

Cover Image for Sitecore Symposium 2022

Sitecore Symposium 2022

> What I'm Watching 👀

Cover Image for Ideas For Docker up.ps1 Scripts

Ideas For Docker up.ps1 Scripts

> Because Docker can be brittle

Cover Image for On Sitecore Development

On Sitecore Development

> Broadly speaking

Cover Image for Don't Ignore the HttpRequestValidationException

Don't Ignore the HttpRequestValidationException

> Doing so could be... potentially dangerous

Cover Image for Troubleshooting 502 Responses in Azure App Services

Troubleshooting 502 Responses in Azure App Services

> App Services don't support all libraries

Cover Image for Super Fast Project Builds with Visual Studio Publish

Super Fast Project Builds with Visual Studio Publish

> For when solution builds take too long

Cover Image for JSS + TypeScript Sitecore Project Tips

JSS + TypeScript Sitecore Project Tips

> New tech, new challenges