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...
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...
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:
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:
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 "
So with the options captured, on to generating the right output...
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:
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.
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:
And your solution will now magically include a TDS project:
Well, concept proved. Hacky – but good enough to save me some time...
↑ Back to top