Hands-on with Sitecore Helix: Anatomy of the Add-HelixModule.ps1 PowerShell script
In my previous post I showed how I got to a solution which allows the developers in my team to create new Feature and Foundation modules with ease.
I showed the moving parts of the solution but I did not go into much detail of the most important part so that's what I'll do in this post. This would be particularly useful if you want to change the script yourself to match it to your needs.
A detailed look at add-helixmodule.ps1
The add-helixmodule.ps1
script is where all the action happens. The file is included in my Habitat fork and is also available as a gist which is shown inline below.
I've added loads of comments to it today, so I think should give you enough to work with.
The function which handles the addition of projects to the solution through the DTE interface is called Add-Projects
(how surprising!) and starts at line 283.
Please do let me know if you have comments or suggestions for improvements!
add-helixmodule.ps1
<#
.SYNOPSIS
This script contains the Add-Feature and Add-Foundation methods which can be used to add a new module to a Sitecore Helix based Visual Studio solution.
The Visual Studio solution should contain a add-helix-module-configuration.json file containing variables which this script will use.
The Add-Feature and Add-Foundation methods can be run from the Pacakge Console Manager as long as this script is loaded in the relevant PowerShell profile.
Run $profile in the Pacakge Manager Console to verify the which profile is used.
#>
# Some hardcoded values
$featureModuleType = "Feature" # Used in Add-Feature and Create-Config.
$foundationModuleType = "Foundation" # Used in Add-Foundation and Create-Config.
$addHelixModuleConfigFile = "add-helix-module-configuration.json" # Used in Add-Module.
$csprojExtension = ".csproj" # Used in Add-Projects
<#
.SYNOPSIS
Creates a config object which is used in the other functions in this script file.
.DESCRIPTION
This function should be considered private and is called from the Add-Module function.
.Parameter JsonConfigFilePath
The path of the json based configuration file which contains the path to the module-template folder,
namespaces and tokens to replace.
.Parameter ModuleType
The type of the new module, either 'Feature' or 'Foundation'.
.Parameter ModuleName
The name of the new module, excluding namespaces since these are retreived from the config object.
.Parameter SolutionRootFolder
The path to the folder which contains the Visual Studio solution (sln) file.
#>
function Create-Config
{
Param(
[Parameter(Position=0, Mandatory=$True)]
[string]$JsonConfigFilePath,
[Parameter(Position=1, Mandatory=$True)]
[string]$ModuleType,
[Parameter(Position=2, Mandatory=$True)]
[string]$ModuleName,
[Parameter(Position=3, Mandatory=$True)]
[string]$SolutionRootFolder
)
$jsonFile = Get-Content -Raw -Path "$JsonConfigFilePath" | ConvertFrom-Json
if ($jsonFile)
{
$config = New-Object psobject
Add-Member -InputObject $config -Name ModuleTemplatePath -Value $jsonFile.config.moduleTemplatePath -MemberType NoteProperty
Add-Member -InputObject $config -Name SourceFolderName -Value $jsonFile.config.sourceFolderName -MemberType NoteProperty
Add-Member -InputObject $config -Name TemplateNamespacePrefix -Value $jsonFile.config.templateNamespacePrefix -MemberType NoteProperty
Add-Member -InputObject $config -Name TemplateModuleType -Value $jsonFile.config.templateModuleType -MemberType NoteProperty
Add-Member -InputObject $config -Name TemplateModuleName -Value $jsonFile.config.templateModuleName -MemberType NoteProperty
Add-Member -InputObject $config -Name TemplateProjectGuid -Value $jsonFile.config.templateProjectGuid -MemberType NoteProperty
Add-Member -InputObject $config -Name TemplateTestProjectGuid -Value $jsonFile.config.templateTestProjectGuid -MemberType NoteProperty
Add-Member -InputObject $config -Name FileExtensionsToUpdateContentRegex -Value $jsonFile.config.fileExtensionsToUpdateContentRegex -MemberType NoteProperty
Add-Member -InputObject $config -Name FileExtensionsToUpdateProjectGuidsRegex -Value $jsonFile.config.fileExtensionsToUpdateProjectGuidsRegex -MemberType NoteProperty
Add-Member -InputObject $config -Name ModuleType -Value $ModuleType -MemberType NoteProperty
Add-Member -InputObject $config -Name ModuleName -Value $ModuleName -MemberType NoteProperty
# GUIDs are needed for the VS projects
$projectGuid = [guid]::NewGuid().toString().toUpper()
Add-Member -InputObject $config -Name ProjectGuid -Value $projectGuid -MemberType NoteProperty
$testProjectGuid = [guid]::NewGuid().toString().toUpper()
Add-Member -InputObject $config -Name TestProjectGuid -Value $testProjectGuid -MemberType NoteProperty
# The json config file contains two namespace prefixes. One for Foundation modules and one for Feature modules.
# This seperation is done to allow namespace differentiation between those module types.
# Foundation modules could be reusable across development projects while Feature module most likely will not.
$newNamespacePrefix = ""
if ($ModuleType -eq $featureModuleType)
{
$newNamespacePrefix = $jsonFile.config.featureNamespacePrefix
}
if ($ModuleType -eq $foundationModuleType)
{
$newNamespacePrefix = $jsonFile.config.foundationNamespacePrefix
}
Add-Member -InputObject $config -Name NamespacePrefix -Value $newNamespacePrefix -MemberType NoteProperty
Add-Member -InputObject $config -Name SolutionRootFolder -Value $SolutionRootFolder -MemberType NoteProperty
return $config
}
}
<#
.SYNOPSIS
The main function that calls the other rename* functions.
.DESCRIPTION
This function should be considered private and is called from the Add-Module function.
.PARAMETER StartPath
The full path of the new module folder. This is used as a path to start folder and file searches.
#>
function Rename-Module
{
Param(
[Parameter(Position=0, Mandatory=$True)]
[string]$StartPath
)
# Rename all the folders from the copied module-template.
Rename-Folders -StartPath "$StartPath" -OldValue $config.TemplateModuleType -NewValue $config.ModuleType
Rename-Folders -StartPath "$StartPath" -OldValue $config.TemplateModuleName -NewValue $config.ModuleName
# Rename all the files from the copied module-template.
Rename-Files -StartPath "$StartPath" -OldValue $config.TemplateNamespacePrefix -NewValue $config.NamespacePrefix
Rename-Files -StartPath "$StartPath" -OldValue $config.TemplateModuleType -NewValue $config.ModuleType
Rename-Files -StartPath "$StartPath" -OldValue $config.TemplateModuleName -NewValue $config.ModuleName
# Update file content for GUIDs.
Update-FileContent -StartPath "$StartPath" -OldValue $config.TemplateProjectGuid -NewValue $config.ProjectGuid -FileExtensionsRegex $config.fileExtensionsToUpdateProjectGuidsRegex
Update-FileContent -StartPath "$StartPath" -OldValue $config.TemplateTestProjectGuid -NewValue $config.TestProjectGuid -FileExtensionsRegex $config.fileExtensionsToUpdateProjectGuidsRegex
# Update file content for namespaces, module tpyes and module name.
Update-FileContent -StartPath "$StartPath" -OldValue $config.TemplateNamespacePrefix -NewValue $config.NamespacePrefix -FileExtensionsRegex $config.FileExtensionsToUpdateContentRegex
Update-FileContent -StartPath "$StartPath" -OldValue $config.TemplateModuleType -NewValue $config.ModuleType -FileExtensionsRegex $config.FileExtensionsToUpdateContentRegex
Update-FileContent -StartPath "$StartPath" -OldValue $config.TemplateModuleName -NewValue $config.ModuleName -FileExtensionsRegex $config.FileExtensionsToUpdateContentRegex
}
<#
.SYNOPSIS
Renames files, replaces OldValue with NewValue in the filename.
.DESCRIPTION
This function should be considered private and is called from the Rename-Module function.
.PARAMETER StartPath
The full path of the new module folder. This is used as a path to start folder and file searches.
.PARAMETER OldValue
The part of the filename which is used to search and is replaced with NewValue.
.PARAMETER NewValue
The value which is used in the replacement of OldValue.
#>
function Rename-Files
{
Param(
[Parameter(Position=0, Mandatory=$true)]
[string]$StartPath,
[Parameter(Position=1, Mandatory=$true)]
[string]$OldValue,
[Parameter(Position=2, Mandatory=$true)]
[string]$NewValue
)
$pattern = "*$OldValue*"
Write-Output "Renaming $pattern files located in $StartPath."
$fileItems = Get-ChildItem -File -Path "$StartPath" -Filter $pattern -Recurse -Force | Where-Object { $_.FullName -notmatch "\\(obj|bin)\\?" }
$fileItems | Rename-Item -NewName { $_.Name -replace $OldValue, $NewValue } -Force
}
<#
.SYNOPSIS
Renames folders, replaces OldValue with NewValue in the folder name.
.DESCRIPTION
This function should be considered private and is called from the Rename-Module function.
.PARAMETER StartPath
The full path of the new module folder. This is used as a path to start folder and file searches.
.PARAMETER OldValue
The part of the folder name which is used to search and is replaced with NewValue.
.PARAMETER NewValue
The value which is used in the replacement of OldValue.
#>
function Rename-Folders
{
Param(
[Parameter(Position=0, Mandatory=$true)]
[string]$StartPath,
[Parameter(Position=1, Mandatory=$true)]
[string]$OldValue,
[Parameter(Position=2, Mandatory=$true)]
[string]$NewValue
)
$pattern = "*$OldValue*"
Write-Output "Renaming $pattern folders located in $StartPath."
# Note the usage of Sort-Object { $_.FullName.Length } -Descending.
# This is done to prevent exceptions with nested folders that need to be renamed.
# Folders are renamed from lowest level to highest level.
$folderItems = Get-ChildItem -Directory -Path "$StartPath" -Recurse -Filter $pattern -Force | Where-Object { $_.FullName -notmatch "\\(obj|bin)\\?" } | Sort-Object { $_.FullName.Length } -Descending
$folderItems | Rename-Item -NewName { $_.Name -replace $OldValue, $NewValue } -Force
}
<#
.SYNOPSIS
Updates the content of files, replaces OldValue with NewValue.
.DESCRIPTION
This function should be considered private and is called from the Rename-Module function.
.PARAMETER StartPath
The full path of the new module folder. This is used as a path to start folder and file searches.
.PARAMETER OldValue
The part of the filename which is used to search and is replaced with NewValue.
.PARAMETER NewValue
The value which is used in the replacement of OldValue.
.PARAMETER FileExtensionsRegex
A regular expression that describes which file extensions are searched for.
#>
function Update-FileContent
{
Param(
[Parameter(Position=0, Mandatory=$true)]
[string]$StartPath,
[Parameter(Position=1, Mandatory=$true)]
[string]$OldValue,
[Parameter(Position=2, Mandatory=$true)]
[string]$NewValue,
[Parameter(Position=3, Mandatory=$true)]
[string]$FileExtensionsRegex
)
Write-Output "Renaming $OldValue to $NewValue in files matching $FileExtensionsRegex located in $StartPath."
$filesToUpdate = Get-ChildItem -File -Path "$StartPath" -Recurse -Force | Where-Object { ( $_.FullName -notmatch "\\(obj|bin)\\?") -and ($_.Name -match $FileExtensionsRegex) } | Select-String -Pattern $OldValue | Group-Object Path | Select-Object -ExpandProperty Name
# -ireplace: case insensitive replacement
$filesToUpdate | ForEach-Object { (Get-Content $_ ) -ireplace [regex]::Escape($OldValue), $NewValue | Set-Content $_ -Force }
}
<#
.SYNOPSIS
Returns the path of the new module.
.DESCRIPTION
The path is constructed as follows: SolutionRootFolder\SourceFolderName\ModuleType\ModuleName.
This function should be considered private and is called from the Add-Module function.
#>
function Get-ModulePath
{
$sourceFolderPath = Join-Path -Path $config.SolutionRootFolder -ChildPath $config.SourceFolderName
$moduleTypePath = Join-Path -Path "$sourceFolderPath" -ChildPath $config.ModuleType
$modulePath = Join-Path -Path "$moduleTypePath" -ChildPath $config.ModuleName
if (Test-Path $modulePath)
{
throw [System.ArgumentException] "$modulePath already exists."
}
return $modulePath
}
<#
.SYNOPSIS
Helper function to retrieve the literal 'Feature' or 'Foundation' solution folder.
.DESCRIPTION
This function should be considered private and is called from the Add-Projects function.
#>
function Get-ModuleTypeSolutionFolder
{
return $dte.Solution.Projects | Where-Object { $_.Name -eq $config.ModuleType -and $_.Kind -eq [EnvDTE80.ProjectKinds]::vsProjectKindSolutionFolder } | Select-Object -First 1
}
<#
.SYNOPSIS
Adds new module project(s) to the solution.
.DESCRIPTION
Searches for csproj files in the new module folder and uses EnvDTE80 interfaces to add these to the solution.
This function should be considered private and is called from the Add-Module function.
#>
function Add-Projects
{
Param(
[Parameter(Position=0, Mandatory=$True)]
[string]$ModulePath
)
Write-Output "Adding project(s)..."
$moduleTypeFolder = Get-ModuleTypeSolutionFolder
Write-Output $moduleTypeFolder
# When the literal 'Feature' or 'Foundation' solution folder does not exist in the solution it will be created.
if (-not $moduleTypeFolder)
{
$dte.Solution.AddSolutionFolder($config.ModuleType)
$moduleTypeFolder = Get-ModuleTypeSolutionFolder
}
$folderInterface = Get-Interface $moduleTypeFolder.Object ([EnvDTE80.SolutionFolder])
$moduleNameFolder = $folderInterface.AddSolutionFolder($config.ModuleName)
$moduleNameInterface = Get-Interface $moduleNameFolder.Object ([EnvDTE80.SolutionFolder])
# Search in the new module folder for csproj files and add those to the solution.
Get-ChildItem -File -Path $ModulePath -Filter "*$csprojExtension" -Recurse | ForEach-Object { $moduleNameInterface.AddFromFile("$($_.FullName)")}
Write-Output "Saving solution..."
# Strangely enough the Solution interface does not contain a simple Save() method so a call to SaveAs(fileName) with the filename needs to be done.
$dte.Solution.SaveAs($dte.Solution.FullName)
}
<#
.SYNOPSIS
Main function to add a new module.
.DESCRIPTION
This function should be considered private and is called from the Add-Feature or Add-Foundation function.
.PARAMETER ModuleName
The name of the new module.
.PARAMETER ModuleType
The type of the new module, either 'Feature' or 'Foundation'.
#>
function Add-Module
{
Param(
[Parameter(Position=0, Mandatory=$True)]
[string]$ModuleName,
[Parameter(Position=1, Mandatory=$True)]
[string]$ModuleType
)
try
{
# Do a check if there is a solution active in Visual Studio.
# If there is no active solution the Add-Projects function would fail.
if (-not $dte.Solution.FullName)
{
throw [System.ArgumentException] "There is no active solution. Load a Sitecore Helix solution first which contains an $addHelixModuleConfigFile file."
}
# The only reason I do this check is because I need a path to start searching for the json based config file.
$solutionRootFolder = [System.IO.Path]::GetDirectoryName($dte.Solution.FullName)
if (-not (Test-Path "$solutionRootFolder"))
{
throw [System.IO.DirectoryNotFoundException] "$solutionRootFolder folder not found."
}
$configJsonFile = Get-ChildItem -Path "$solutionRootFolder" -File -Filter "$addHelixModuleConfigFile" -Recurse | Select-Object -First 1 | Select-Object -ExpandProperty FullName
if (-not (Test-Path $configJsonFile))
{
throw [System.IO.DirectoryNotFoundException] "$configJsonFile not found."
}
# Create a config object we can use throughout the other functions.
$config = Create-Config -JsonConfigFilePath "$configJsonFile" -ModuleType $ModuleType -ModuleName $ModuleName -SolutionRootFolder $solutionRootFolder
# Get the path to the module-template folder and verify that is exists on disk.
$copyModuleFromLocation = Join-Path -Path $config.ModuleTemplatePath -ChildPath $config.TemplateModuleName
if (-not (Test-Path $copyModuleFromLocation))
{
throw [System.IO.DirectoryNotFoundException] "$copyModuleFromLocation folder not found."
}
$modulePath = Get-ModulePath
Write-Output "Copying module template to $modulePath."
Copy-Item -Path "$copyModuleFromLocation" -Destination "$modulePath" -Recurse
Rename-Module -StartPath "$modulePath"
Add-Projects -ModulePath "$modulePath"
Write-Output "Completed adding $($config.NamespacePrefix).$moduleType.$moduleName."
}
catch
{
Write-Error $error[0]
exit
}
}
<#
.SYNOPSIS
Adds a Sitecore Helix Feature module to the current solution.
.DESCRIPTION
The solution should contain an add-helix-module-configuration.json file containing
paths to the module template folder and namespace settings for the new module.
.PARAMETER Name
The name of the new Feature, excluding the namespace prefix since that comes from the json config file.
.EXAMPLE
Add-Feature Navigation
#>
function Add-Feature
{
Param(
[Parameter(Position=0, Mandatory=$True)]
[string]$Name
)
Add-Module -ModuleName $Name -ModuleType $featureModuleType
}
<#
.SYNOPSIS
Adds a Sitecore Helix Foundation module to the current solution.
.DESCRIPTION
The solution should contain an add-helix-module-configuration.json file containing
paths to the module template folder and namespace settings for the new module.
.PARAMETER Name
The name of the new Foundation module, excluding the namespace prefix since that comes from the json config file.
.EXAMPLE
Add-Foundation Dictionary
#>
function Add-Foundation
{
Param(
[Parameter(Position=0, Mandatory=$True)]
[string]$Name
)
Add-Module -ModuleName $Name -ModuleType $foundationModuleType
}