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...
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...
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...
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.
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: