Jeremy Davis
Jeremy Davis
Sitecore, C# and web development
Article printed from: https://blog.jermdavis.dev/posts/2020/hacking-tds-into-generator-helix

Hacking TDS into generator-helix

Published 13 April 2020
Sitecore ~4 min. read

My moment of confusion from a while back came in the middle of a big chunk of client development work. The solution already used the generator-helix package, but the work needed to make use of TDS, rather than Unicorn. Since I was going to be involved in creating a set of new features, and potentially a load of TDS projects, I wondered what it would take to make the generator-helix package support TDS...

A plan

While the existing behaviour of generator-helix offers you the choice to create Unicorn setup when you add a project to your solution, I didn't think this was the right model here. For reasons, a chunk of the C# projects had already been added, so it seemed more helpful here to be able to run a command like:

yo helix:tds MyFeature

					

to go through the process of adding a new TDS project to a feature. Some would need multiple TDS projects, and some would need none. So this seemed to give the most flexibility.

And this also seemed like a chance to bolt on some new functionality without breaking any existing behaviour...

The details

If you dig into the folder structure under node_modules folder for generator-helix then you'll find a generators folder that includes the set of "commands" it understands for generating new projects:

OTB Generators

Out of the box you get two: The app folder is the generator for an overall solution. And the add generator is for adding a C# project to your solution. So that suggests that we could modify the behaviour of a copy of the add generator to get what's needed here. And there's not a great deal in there:

Existing Files

The index.js file is the logic for most of the process, and the Templates folder holds the files that are going to get copied into your solution and customised to suit your needs.

So I started by creating a new generator folder named tds and putting a copy of index.js into it, ready to modify.

A trivial change to start with, is that it should say that it's going to generate a TDS project for us. That's done with the message in the init() method - which calls Yeoman's output functions:

init() {
    this.log(yosay('Lets add TDS to a project!'));
    this.templatedata = {};
}

					

Next up, the code needs to ask all the relevant questions to be able to generate the project we need. Some of that is the same as what's defined for C# projects – but some aspects of a TDS project are different. For starters, the question about adding Unicorn can go. That's defined in the askForProjectSettings() method, and we can just delete the chunk that asks about Unicorn, leaving:

askForProjectSettings() {
	let done = this.async();
	let questions = [{
		type: 'input',
		name: 'ProjectName',
		message: 'Name of your project.' + chalk.blue(' (Excluding layer prefix, name can not be empty)'),
		default: this.options.ProjectName,
		validate: util.validateProjectName
	},
	{
		type: 'input',
		name: 'sourceFolder',
		message: 'Source code folder name',
		default: 'src',
		store: true
	},
	{
		type: 'input',
		name: 'VendorPrefix',
		message: 'Enter optional vendor prefix',
		default: this.options.VendorPrefix,
		store: false
	}
	];

	this.prompt(questions).then((answers) => {
		this.settings = answers;
		done();
	});
}

					

Multiple TDS projects (in my development world, at least) with different settings may exist in a single feature. I might need a "core" project and a "master" project to keep stuff from different database. And I might need a "content" project that keeps bits of data for the feature that aren't really "developer owned", but are necessary for the code to run. So we need some extra questions, and the easiest place to fit them in seemed to be the askForLayer() method – though maybe if this was a less hacky approach they'd get their own method...

So the code needs to ask two new questions: What database will the TDS project serialise (which is necessary to configure the TDS project file itself) and is this a project for content or for developer-owned items (which changes the naming). Those changes can get added into the flow as shown in the first highlight block below:

askForLayer() {
	const done = this.async();
	const questions = [{
		type: 'list',
		name: 'layer',
		message: 'What layer do you want to add the project too?',
		choices: [
			{
				name: 'Feature layer?',
				value: 'Feature'
			}, {
				name: 'Foundation layer?',
				value: 'Foundation'
			}, {
				name: 'Project layer?',
				value: 'Project'
			},
		]
	}, {
		type: 'list',
		name: 'database',
		message: 'What database are you serialising?',
		choices: [
			{
				name: 'Master database?',
				value: 'Master'
			},
			{
				name: 'Core database?',
				value: 'Core'
			}
		]
	}, {
		type: 'list',
		name: 'isContent',
		message: 'Is this content or developer items?' + chalk.blue(' (Content overrides database name when naming projects)'),
		choices: [
			{
				name: 'Developer?',
				value: false
			},
			{
				name: 'Content?',
				value: true
			}
		]
	}];

	this.prompt(questions).then((answers) => {
		this.layer = answers.layer;
		this.database = answers.database;
		this.isContent = answers.isContent;

		if (this.settings.VendorPrefix === '' || this.settings.VendorPrefix === undefined) {
			this.settings.LayerPrefixedProjectName = `${this.layer}.${this.settings.ProjectName}`;
		} else {
			this.settings.LayerPrefixedProjectName = `${this.settings.VendorPrefix}.${this.layer}.${this.settings.ProjectName}`;
		}

		if (this.isContent) {
			this.settings.LayerPrefixedProjectName += ".Content";
		} else {
			this.settings.LayerPrefixedProjectName += "." + this.database;
		}

		done();
	});
}

					

After the questions are asked, the answers need adding into the context data that's used for onward processing. That's done in the second and third highlights above. The two answers get saved first, and then the correct layer file name is computed based on the answers.

The solution I was working with was using "..Content.scproj" for content projects and "...scproj" for the developer-owned projects.

So with the options captured, on to generating the right output...

Doing the generation

The meat of the hacking is in getting all the files into the right places. And the first part of that is the contents of the Templates folder under the TDS folder that the index.js file lives in. What goes here is specific to the TDS project you want to template, but I needed a packages.config and a .scproj file. The package config file was a static file - with nothing complex required:

Template Files

But the TDS project file needs some template replacement done on it. This uses the same approach used for the C# projects. The filename includes the _project template string, and the contents of the file uses <%= %> blocks to fill in the relevant bits:

  <PropertyGroup>
    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
    <ProductVersion>9.0.21022</ProductVersion>
    <SchemaVersion>2.0</SchemaVersion>
    <ProjectGuid><%= projectguid %></ProjectGuid>
    <SourceWebPhysicalPath></SourceWebPhysicalPath>
    <SourceWebProject></SourceWebProject>
    <SourceWebVirtualPath></SourceWebVirtualPath>
    <TargetFrameworkVersion><%= target %></TargetFrameworkVersion>
    <CompactSitecoreItemsInProjectFile>True</CompactSitecoreItemsInProjectFile>
    <AssemblyName><%= layerprefixedprojectname %>.TDS</AssemblyName>
    <Name><%= layerprefixedprojectname %>.TDS</Name>
    <RootNamespace><%= layerprefixedprojectname %>.TDS</RootNamespace>
    <SitecoreDatabase><%= database %></SitecoreDatabase>
  </PropertyGroup>

					

That's similar to the C# project, but sets the Sitecore database too.

The first change to the index.js is to make sure the database is added to the template data:

_buildTemplateData() {
	this.templatedata.layerprefixedprojectname = this.settings.LayerPrefixedProjectName;
	this.templatedata.projectname = this.settings.ProjectName;
	this.templatedata.vendorprefix = this.settings.VendorPrefix;
	this.templatedata.projectguid = guid.v4();
	this.templatedata.layer = this.layer;
	this.templatedata.lowercasedlayer = this.layer.toLowerCase();
	this.templatedata.target = this.target;
	this.templatedata.modulegroup = this.modulegroup;
	this.templatedata.database = this.database.toLowerCase();
}

					

The code for copying project files gets simpler, as it's copying less stuff:

_copyProjectItems() {
	mkdir.sync(this.settings.ProjectPath);
	this.fs.copyTpl(
		this.templatePath('_project.scproj'),
		this.destinationPath(
			path.join(
				this.settings.ProjectPath,
				this.settings.LayerPrefixedProjectName + '.scproj')
		),
		this.templatedata);
	this.fs.copyTpl(
		this.templatePath('packages.config'),
		this.destinationPath(
			path.join(
				this.settings.ProjectPath,
				'packages.config')
		),
		this.templatedata);
}

					

And the "rename project" behaviour needs to change to understand TDS project files:

_renameProjectFile() {
	fs.renameSync(
		this.destinationPath(
			path.join(this.settings.ProjectPath, '_project.scproj')
		),
		this.destinationPath(
			path.join(
				this.settings.ProjectPath,
				this.settings.LayerPrefixedProjectName + '.scproj'
			)
		)
	);
}

					

The _copySerializationItems() method isn't needed any more, so can be removed. And then the rest of the changes are to update the writing() method to suit:

writing() {
	this.settings.ProjectPath = path.join(this.settings.sourceFolder, this.layer, this.modulegroup, this.settings.ProjectName, 'tds', this.settings.LayerPrefixedProjectName);
	this._copyProjectItems();

	const files = fs.readdirSync(this.destinationPath());
	const SolutionFile = files.find(file => file.toUpperCase().endsWith(".SLN"));
	const scriptParameters = '-Database \'' + this.database + '\' -SolutionFile \'' + this.destinationPath(SolutionFile) + '\' -Name ' + this.settings.LayerPrefixedProjectName + ' -Type ' + this.layer + ' -ProjectPath \'' + this.settings.ProjectPath + '\'' + ' -SolutionFolderName ' + this.templatedata.projectname;

	var pathToAddProjectScript = path.join(this._sourceRoot, '../add-tds.ps1');
	powershell.runAsync(pathToAddProjectScript, scriptParameters);
}

					

It needs to add in a "tds" folder into the deployment structure, the calls to deploy serialisation stuff for Unicorn are removed, and it needs to run some custom PowerShell with the relevant parameters.

That PowerShell script does the hard work of getting the project added to the Solution. Microsoft don't seem to provide a good API outisde of Visual Studio extensions for working with .sln files, hence the need for some custom script here. The basis of this file is the Add-Project.ps1 file in the generator-helix PowerShell folder. But to make deploying my hacks easier, I moved the new version inside my template folder.

param(
[Parameter(Mandatory=$true)]
[string]$Database,
[Parameter(Mandatory=$true)]
[ValidateSet("project", "feature", "foundation")]
[string]$Type,
[Parameter(Mandatory=$true)]
[string]$SolutionFile,
[Parameter(Mandatory=$true)]
[string]$Name,
[Parameter(Mandatory=$true)]
[string]$ProjectPath,
[Parameter(Mandatory=$true)]
[string]$SolutionFolderName)

. $PSScriptRoot\..\..\powershell\Add-Line.ps1
. $PSScriptRoot\..\..\powershell\Get-SolutionConfigurations.ps1
. $PSScriptRoot\..\..\powershell\Get-SolutionFolderId.ps1
. $PSScriptRoot\..\..\powershell\Get-ProjectConfigurationPlatformSection.ps1
. $PSScriptRoot\..\..\powershell\Add-BuildConfigurations.ps1

Write-Host "adding project $Name"

$configurations = Get-SolutionConfigurations -SolutionFile $SolutionFile
$solutionFolderId = Get-SolutionFolderId -SolutionFile $SolutionFile -Type $Type
$projectPath = "$ProjectPath\$name.scproj" 

$GuidSection = "GlobalSection(ProjectConfigurationPlatforms) = postSolution"
$ProjectSection = "MinimumVisualStudioVersion = 10.0.40219.1"
$NestedProjectSection = "GlobalSection(NestedProjects) = preSolution"
$projectGuid = [guid]::NewGuid();
$projectFolderGuid = Get-SolutionFolderId -SolutionFile $SolutionFile -Type $SolutionFolderName

$addProjectSection = @("Project(`{CAA73BB0-EF22-4D79-A57E-DF67B3BA9C80}`) = `$Name`, `$projectPath`, `{$projectGuid}`,"EndProject")
$addNestProjectSection = @(`t`t{$projectGuid} = $projectFolderGuid")

Add-BuildConfigurations -ProjectPath $projectPath -Configurations $configurations                
Add-Line -FileName $SolutionFile -Pattern $ProjectSection -LinesToAdd $addProjectSection
Add-Line -FileName $SolutionFile -Pattern $NestedProjectSection -LinesToAdd $addNestProjectSection

Add-Line -FileName $SolutionFile -Pattern $GuidSection -LinesToAdd (Get-ProjectConfigurationPlatformSection -Id $projectGuid -Configurations $configurations)

#Setting LastWriteTime to tell VS 2015 that solution has changed.
Set-ItemProperty -Path $SolutionFile -Name LastWriteTime -Value (get-date)

					

That adds a parameter for the database, and changes the include locations to match the fact that this file is in a different location to the previous version. The rest of the code gets the new project added to the solution.

Installing

Ideally, installing this would involve publishing an NPM package with the changes in it, so that it can be used in the same way as the original. But since I was just hacking about, I just chose to copy my changed files into the appropriate node_modules folder with a quick but of script. So I can put the files discussed above into a zip, and keep it with this script in my solution:

if(Test-Path "..\node_modules\generator-helix\generators") {
	Write-Host "Found local install"
	$installPath = Resolve-Path "..\..\node_modules\generator-helix\generators"
	
	Write-Host "Install to: $installPath"
	Expand-Archive "generators-tds.zip" "$installPath\tds" -Force
}

# check for global install
if(Test-Path "$env:userprofile\AppData\Roaming\npm\node_modules\generator-helix\generators\") {
	Write-Host "Found global install"
	$installPath = Resolve-Path "$env:userprofile\AppData\Roaming\npm\node_modules\generator-helix\generators\"
	
	Write-Host "Install to: $installPath"
	Expand-Archive "generators-tds.zip" "$installPath\tds" -Force
}

					

Not great - but it works – and it copes whether you did a global install of Generator-Helix or not...

So now you can run yo helix:tds and get:

Run TDS Add

And your solution will now magically include a TDS project:

TDS Add Result

Conclusions

Well, concept proved. Hacky – but good enough to save me some time...

↑ Back to top