Pentia.Publish-WebSolution.psm1

<#
.SYNOPSIS
Used to publish a web solution to disk for a specific build configuration.
 
.DESCRIPTION
Used to publish a web solution to disk, applying and subsequently removing all relevant XDTs.
The steps it runs through are:
 
1. Delete $WebrootOutputPath.
2. Publish runtime dependencies to $WebrootOutputPath.
3. Publish all web projects to $WebrootOutputPath, on top of the published runtime dependencies.
4. Apply all XML Document Transform files found in $WebrootOutputPath.
5. Delete all XML Document Transform files found in $WebrootOutputPath.
 
.PARAMETER SolutionRootPath
This is the absolute path to the root of your solution, usually the same directory as your ".sln"-file is placed.
Uses the current working directory ($PWD) as a fallback.
 
.PARAMETER WebrootOutputPath
The path to where you want your webroot to be published. E.g. "D:\Websites\SolutionSite\www".
 
.PARAMETER DataOutputPath
This is where the Sitecore data folder will be placed. E.g. "D:\Websites\SolutionSite\Data".
 
.PARAMETER BuildConfiguration
The build configuration that will be passed to "MSBuild.exe".
 
.PARAMETER WebProjects
The list of webprojects to publish - will call Get-WebProject if empty
 
.PARAMETER MSBuildExecutablePath
Absolute or relative path of MSBuild.exe. If null or empty, the script will attempt to find the latest MSBuild.exe installed with Visual Studio 2017 or later.
 
.PARAMETER PublishParallelly
If set, MSBuild will use all available nodes for publishing multiple projects in parallel; otherwise, MSBuild will only use one node for publishing.
 
.PARAMETER KeepWebrootOutputPath
If set, the WebrootOutputPath will not be removed before publishing dependencies and projects
 
.EXAMPLE
Publish-ConfiguredWebSolution -SolutionRootPath "D:\Project\Solution" -WebrootOutputPath "D:\Websites\SolutionSite\www" -DataOutputPath "D:\Websites\SolutionSite\Data" -BuildConfiguration "Debug"
Publishes the solution placed at "D:\Project\Solution" to "D:\Websites\SolutionSite\www" using the "Debug" build configuration, and saves the provided parameters to "D:\Project\Solution\.pentia\user-settings.json" for future use.
 
Publish-ConfiguredWebSolution
Publishes the solution using the saved user settings found in "<current directory>\.pentia\user-settings.json", and prompts the user for any missing settings.
 
.NOTES
Most large scale solutions will end up with file lock issues when using the "-PublishParallelly" switch, which is why it's off by default.
 
In order to enable verbose or debug output for the entire command, run the following in your current PowerShell session (your "PowerShell command prompt"):
    $VerbosePreference = "Continue"
    $DebugPreference = "Continue"
#>

function Publish-ConfiguredWebSolution {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [string]$SolutionRootPath,

        [Parameter(Mandatory = $false)]
        [string]$WebrootOutputPath,

        [Parameter(Mandatory = $false)]
        [string]$DataOutputPath,

        [Parameter(Mandatory = $false)]
        [string]$BuildConfiguration,

        [Parameter(Mandatory = $false)]
        [string[]]$WebProjects,

        [Parameter(Mandatory = $false)]
        [string]$MSBuildExecutablePath,

        [switch]$PublishParallelly,

        [switch]$KeepWebrootOutputPath
    )

    $SolutionRootPath = Get-SolutionRootPath -SolutionRootPath $SolutionRootPath
    $parameters = Get-MergedParametersAndUserSettings -SolutionRootPath $SolutionRootPath -WebrootOutputPath $WebrootOutputPath -DataOutputPath $DataOutputPath -BuildConfiguration $BuildConfiguration
    $WebrootOutputPath = $parameters.webrootOutputPath
    $DataOutputPath = $parameters.dataOutputPath
    $BuildConfiguration = $parameters.buildConfiguration

    Publish-UnconfiguredWebSolution -SolutionRootPath $SolutionRootPath -WebrootOutputPath $WebrootOutputPath -DataOutputPath $DataOutputPath -WebProjects $WebProjects -MSBuildExecutablePath $MSBuildExecutablePath -PublishParallelly:$PublishParallelly -KeepWebrootOutputPath:$KeepWebrootOutputPath
    if (Test-Path $WebrootOutputPath) {
        Set-WebSolutionConfiguration -WebrootOutputPath $WebrootOutputPath -BuildConfiguration $BuildConfiguration
    }
    else {
        Write-Warning "'$WebrootOutputPath' not found. Skipping solution configuration."
    }
}

function Get-SolutionRootPath {
    param (
        [Parameter(Mandatory = $false)]
        [string]$SolutionRootPath
    )

    if ([string]::IsNullOrWhiteSpace($SolutionRootPath)) {
        Write-Verbose "`$SolutionRootPath not set. Using '$PWD'."
        $SolutionRootPath = "$PWD"
    }
    if (-not ([System.IO.Path]::IsPathRooted($SolutionRootPath))) {
        $SolutionRootPath = [System.IO.Path]::Combine($PWD, $SolutionRootPath)
        Write-Verbose "`$SolutionRootPath not rooted. Using '$SolutionRootPath'."
    }
    $SolutionRootPath
}

<#
.SYNOPSIS
Publishes a web solution, without applying any XDTs.
 
.DESCRIPTION
Used to publish a Sitecore web solution to disk, without applying or removing any XDTs.
The steps it runs through are:
 
1. Delete $WebrootOutputPath.
2. Publish runtime dependencies to $WebrootOutputPath.
3. Publish all web projects to $WebrootOutputPath, on top of the published runtime dependencies.
 
.PARAMETER SolutionRootPath
This is the absolute path to the root of your solution, usually the same directory as your ".sln"-file is placed.
Uses the current working directory ($PWD) as a fallback.
 
.PARAMETER WebrootOutputPath
The path to where you want your webroot to be published. E.g. "D:\Websites\SolutionSite\www".
 
.PARAMETER DataOutputPath
This is where the Sitecore data folder will be placed. E.g. "D:\Websites\SolutionSite\Data".
 
.PARAMETER WebProjects
The list of webprojects to publish - will call Get-WebProject if empty
 
.PARAMETER MSBuildExecutablePath
Absolute or relative path of MSBuild.exe. If null or empty, the script will attempt to find the latest MSBuild.exe installed with Visual Studio 2017 or later.
 
.PARAMETER PublishParallelly
If set, MSBuild will use all available nodes for publishing multiple projects in parallel; otherwise, MSBuild will only use one node for publishing.
 
.PARAMETER KeepWebrootOutputPath
If set, the WebrootOutputPath will not be removed before publishing dependencies and projects
 
.EXAMPLE
Publish-UnconfiguredWebSolution -SolutionRootPath "D:\Project\Solution" -WebrootOutputPath "D:\Websites\SolutionSite\www" -DataOutputPath "D:\Websites\SolutionSite\Data"
Publishes the solution placed at "D:\Project\Solution" to "D:\Websites\SolutionSite\www".
 
.NOTES
Most large scale solutions will end up with file lock issues when using the "-PublishParallelly" switch, which is why it's off by default.
 
In order to enable verbose or debug output for the entire command, run the following in your current PowerShell session (your "PowerShell command prompt"):
    $VerbosePreference = "Continue"
    $DebugPreference = "Continue"
#>

function Publish-UnconfiguredWebSolution {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(Mandatory = $false)]
        [string]$SolutionRootPath,

        [Parameter(Mandatory = $true)]
        [string]$WebrootOutputPath,

        [Parameter(Mandatory = $true)]
        [string]$DataOutputPath,

        [Parameter(Mandatory = $false)]
        [string[]]$WebProjects,

        [Parameter(Mandatory = $false)]
        [string]$MSBuildExecutablePath,

        [switch]$PublishParallelly,

        [switch]$KeepWebrootOutputPath
    )

    if (-not ([System.IO.Path]::IsPathRooted($WebrootOutputPath))) {
        $WebrootOutputPath = [System.IO.Path]::Combine($PWD, $WebrootOutputPath)
    }

    if (-not ([System.IO.Path]::IsPathRooted($DataOutputPath))) {
        $DataOutputPath = [System.IO.Path]::Combine($PWD, $DataOutputPath)
    }

    $SolutionRootPath = Get-SolutionRootPath -SolutionRootPath $SolutionRootPath

    if (-not $KeepWebrootOutputPath) {
        Write-Progress -Activity "Publishing web solution" -Status "Cleaning webroot output path"
        Remove-WebrootOutputPath -WebrootOutputPath $WebrootOutputPath
    }

    Write-Progress -Activity "Publishing web solution" -Status "Publishing runtime dependency packages"
    Publish-AllRuntimeDependencies -SolutionRootPath $SolutionRootPath -WebrootOutputPath $WebrootOutputPath -DataOutputPath $DataOutputPath

    Write-Progress -Activity "Publishing web solution" -Status "Publishing web projects"
    Publish-MultipleWebProjects -SolutionRootPath $SolutionRootPath -WebrootOutputPath $WebrootOutputPath -WebProjects $WebProjects -MSBuildExecutablePath $MSBuildExecutablePath -PublishParallelly:$PublishParallelly

    Write-Progress -Activity "Publishing web solution" -Completed -Status "Done."
}

function Remove-WebrootOutputPath {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(Mandatory = $true)]
        [string]$WebrootOutputPath
    )
    if (-not $pscmdlet.ShouldProcess($WebrootOutputPath, "Delete the directory and all contents")) {
        return
    }
    if (Test-Path $WebrootOutputPath -PathType Container) {
        Write-Verbose "Deleting '$WebrootOutputPath' and all contents."
        Remove-Item -Path $WebrootOutputPath -Recurse -Force
    }
}

<#
.SYNOPSIS
Publishes the contents of all runtime dependency packages to the specified directores.
 
.DESCRIPTION
Publishes the contents of all runtime dependency packages to the specified directores, by looking for a "packages.config" to install packages using NuGet,
or a "runtime-dependencies.config" to install packages using the PowerShell Package Management framework (deprecated).
 
.PARAMETER SolutionRootPath
This is the absolute path to the root of your solution, usually the same directory as your ".sln"-file is placed.
Uses the current working directory ($PWD) as a fallback.
 
.PARAMETER WebrootOutputPath
The path to where you want your webroot to be published. E.g. "D:\Websites\SolutionSite\www".
 
.PARAMETER DataOutputPath
This is where the Sitecore data folder will be placed. E.g. "D:\Websites\SolutionSite\Data".
 
.EXAMPLE
Publish-AllRuntimeDependencies -SolutionRootPath "D:\Projects\Abbr\Solution\" -WebrootOutputPath "D:\Websites\SolutionSite\www". -DataOutputPath "D:\Websites\SolutionSite\Data"
Publishes all runtime packages defined in "D:\Projects\Abbr\Solution\packages.config" and "D:\Projects\Abbr\Solution\runtime-dependencies.config" to the specified output paths.
#>

function Publish-AllRuntimeDependencies {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$SolutionRootPath,

        [Parameter(Mandatory = $true)]
        [string]$WebrootOutputPath,

        [Parameter(Mandatory = $true)]
        [string]$DataOutputPath
    )
    if (-not [System.IO.Path]::IsPathRooted($SolutionRootPath)) {
        $SolutionRootPath = [System.IO.Path]::Combine($PWD, $SolutionRootPath)
    }
    Publish-PackagesUsingPackageManagement -SolutionRootPath $SolutionRootPath -WebrootOutputPath $WebrootOutputPath -DataOutputPath $DataOutputPath
    Publish-PackagesUsingNuGet -SolutionRootPath $SolutionRootPath -WebrootOutputPath $WebrootOutputPath -DataOutputPath $DataOutputPath
}

function Publish-PackagesUsingPackageManagement {
    param (
        [Parameter(Mandatory = $true)]
        [string]$SolutionRootPath,

        [Parameter(Mandatory = $true)]
        [string]$WebrootOutputPath,

        [Parameter(Mandatory = $true)]
        [string]$DataOutputPath
    )
    $runtimeDependencyConfigurationFileName = "runtime-dependencies.config"
    $runtimeDependencyConfigurationFilePath = [System.IO.Path]::Combine($SolutionRootPath, $runtimeDependencyConfigurationFileName)
    if (-not (Test-Path $runtimeDependencyConfigurationFilePath -PathType Leaf)) {
        Write-Verbose "'$runtimeDependencyConfigurationFilePath' not found - skipping runtime package installation using Package Management."
        return
    }
    Write-Warning "Usage of 'runtime-dependencies.config' is deprecated. Use regular 'packages.config' and 'NuGet.config' files instead."
    $runtimeDependencies = Get-RuntimeDependencyPackage -ConfigurationFilePath $RuntimeDependencyConfigurationFilePath
    for ($i = 0; $i -lt $runtimeDependencies.Count; $i++) {
        $runtimeDependency = $runtimeDependencies[$i]
        Write-Progress -Activity "Publishing web solution" -PercentComplete ($i / $runtimeDependencies.Count * 100) -Status "Publishing runtime dependency packages" -CurrentOperation "$($runtimeDependency.id) $($runtimeDependency.version)"
        Publish-RuntimeDependencyPackage -WebrootOutputPath $WebrootOutputPath -DataOutputPath $DataOutputPath -PackageName $runtimeDependency.id -PackageVersion $runtimeDependency.version -PackageSource $runtimeDependency.source
    }
}

function Publish-PackagesUsingNuGet {
    param (
        [Parameter(Mandatory = $true)]
        [string]$SolutionRootPath,

        [Parameter(Mandatory = $true)]
        [string]$WebrootOutputPath,

        [Parameter(Mandatory = $true)]
        [string]$DataOutputPath
    )
    $nugetPackageFileName = "packages.config"
    $nugetPackageFilePath = [System.IO.Path]::Combine($SolutionRootPath, $nugetPackageFileName)
    if (-not(Test-Path $nugetPackageFilePath -PathType Leaf)) {
        Write-Verbose "'$nugetPackageFileName' not found - skipping runtime package installation using NuGet."
        return
    }
    Write-Progress -Activity "Publishing web solution" -Status "Publishing runtime dependency packages" -CurrentOperation "Installing packages in parallel"
    Install-NuGetExe
    $packageOutputDirectory = [System.IO.Path]::Combine($env:APPDATA, ".pentia")
    Install-NuGetPackage -PackageConfigFile $nugetPackageFilePath -SolutionDirectory $SolutionRootPath -OutputDirectory $packageOutputDirectory
    [xml]$nugetPackageFileXml = Get-Content $nugetPackageFilePath
    $runtimeDependencies = @($nugetPackageFileXml | Select-Xml -XPath "/packages/package" | Select-Object -ExpandProperty "Node")
    for ($i = 0; $i -lt $runtimeDependencies.Count; $i++) {
        $runtimeDependency = $runtimeDependencies[$i]
        Write-Progress -Activity "Publishing web solution" -PercentComplete ($i / $runtimeDependencies.Count * 100) -Status "Publishing runtime dependency packages" -CurrentOperation "Copying package contents sequentially"
        Publish-NuGetPackage -PackageName $runtimeDependency.id -PackageVersion $runtimeDependency.version -PackageOutputPath $packageOutputDirectory -WebrootOutputPath $WebrootOutputPath -DataOutputPath $DataOutputPath
    }
}

function Publish-MultipleWebProjects {
    param (
        [Parameter(Mandatory = $true)]
        [string]$SolutionRootPath,

        [Parameter(Mandatory = $true)]
        [string]$WebrootOutputPath,

        [Parameter(Mandatory = $false)]
        [string[]]$WebProjects,

        [Parameter(Mandatory = $false)]
        [string]$MSBuildExecutablePath,

        [switch]$PublishParallelly
    )

    if ($WebProjects.Count -lt 1) {
        $WebProjects = Get-WebProject -SolutionRootPath $SolutionRootPath
    }
    if ($WebProjects.Count -lt 1) {
        Write-Verbose "No web projects found - skipping web project publishing."
        return
    }

    if ([string]::IsNullOrWhiteSpace($MSBuildExecutablePath)) {
        $MSBuildExecutablePath = Get-MSBuild
    }

    Write-Progress -Activity "Publishing web solution" -Status "Publishing web projects" -CurrentOperation "Creating web publish project"
    $projectFilePath = New-WebPublishProject -SolutionRootPath $SolutionRootPath -WebProjects $WebProjects -PublishParallelly:$PublishParallelly
    Write-Progress -Activity "Publishing web solution" -Status "Publishing web projects" -CurrentOperation "Publishing all web projects referenced by '$projectFilePath'"
    Publish-WebProject -WebProjectFilePath $projectFilePath  -OutputPath $WebrootOutputPath -MSBuildExecutablePath $MSBuildExecutablePath
}

<#
.SYNOPSIS
Creates a .csproj-file which references all projects in "WebProjects".
 
.DESCRIPTION
Creates a .csproj-file which references all projects in "WebProjects".
Publishing this project will trigger the WebPublish target for all referenced projects.
This gives a significant performance boost compared to publishing individual projects sequentially.
 
.PARAMETER SolutionRootPath
The solution root path.
 
.PARAMETER WebProjects
A list of web projects to publish.
 
.PARAMETER PublishParallelly
If set, MSBuild will use all available nodes for publishing multiple projects in parallel; otherwise, MSBuild will only use one node for publishing.
#>

function New-WebPublishProject {
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([string])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$SolutionRootPath,

        [Parameter(Mandatory = $true)]
        [string[]]$WebProjects,

        [switch]$PublishParallelly
    )
    $webProjectFilePaths = @()
    foreach ($webProject in $WebProjects) {
        if ([System.IO.Path]::IsPathRooted($webProject)) {
            $webProjectFilePaths += $webProject
        }
        else {
            $webProjectFilePaths += [System.IO.Path]::Combine($SolutionRootPath, $webProject)
        }
    }
    $formattedWebProjectPaths = $webProjectFilePaths -join ";"
    [xml]$webPublishProject = "<!-- This file is (re-)generated automatically --><Project xmlns=""http://schemas.microsoft.com/developer/msbuild/2003""><ItemGroup><WebProjects Include=""$formattedWebProjectPaths"" /></ItemGroup><Target Name=""WebPublish""><MSBuild Projects=""@(WebProjects)"" Targets=""WebPublish"" BuildInParallel=""$PublishParallelly"" /></Target></Project>"
    $webPublishProjectDirectory = [System.IO.Path]::Combine($SolutionRootPath, ".pentia")
    if (-not (Test-Path $webPublishProjectDirectory) -and $pscmdlet.ShouldProcess($webPublishProjectDirectory, "Create misc. directory")) {
        $webPublishProjectDirectory = New-Item $webPublishProjectDirectory -ItemType Directory
    }
    $projectFilePath = [System.IO.Path]::Combine($webPublishProjectDirectory, "WebPublish.csproj")
    if ($pscmdlet.ShouldProcess($projectFilePath, "Create .csproj-file")) {
        $webPublishProject.Save($projectFilePath)
    }
    $projectFilePath
}

<#
.SYNOPSIS
Applies XDTs to a set of configuration files, then deletes the XDTs.
 
.DESCRIPTION
Applies all XML Document Transforms found in $WebrootOutputPath to their configuration file counterparts.
 
.PARAMETER WebrootOutputPath
The path to the webroot. E.g. "D:\Websites\SolutionSite\www".
 
.PARAMETER BuildConfiguration
The build configuration that will be used to select which transforms to apply.
 
.EXAMPLE
Set-WebSolutionConfiguration -WebrootOutputPath "D:\Websites\SolutionSite\www" -BuildConfiguration "Debug"
Searchse for all "*.Debug.config" XDTs in the "D:\Websites\SolutionSite\www" directory, and applies them to their configuration file counterparts.
 
.NOTES
In order to enable verbose or debug output for the entire command, run the following in your current PowerShell session (your "PowerShell command prompt"):
    $VerbosePreference = "Continue"
    $DebugPreference = "Continue"
 
We'd like to call this function "Configure-WebSolution", but according
to https://msdn.microsoft.com/en-us/library/ms714428(v=vs.85).aspx the "Set" verb should be used instead.
#>

function Set-WebSolutionConfiguration {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(Mandatory = $true)]
        [string]$WebrootOutputPath,

        [Parameter(Mandatory = $true)]
        [string]$BuildConfiguration
    )

    if (-not (Test-Path $WebrootOutputPath)) {
        throw "Path '$WebrootOutputPath' not found."
    }

    $WebrootOutputPath = Resolve-Path $WebrootOutputPath

    Write-Progress -Activity "Configuring web solution" -Status "Applying XML Document Transforms"
    if ($pscmdlet.ShouldProcess($WebrootOutputPath, "Apply XML Document Transforms")) {
        Invoke-AllConfigurationTransforms -SolutionOrProjectRootPath $WebrootOutputPath -WebrootOutputPath $WebrootOutputPath -BuildConfiguration $BuildConfiguration
    }

    Write-Progress -Activity "Configuring web solution" -Status "Removing XML Document Transform files"
    if ($pscmdlet.ShouldProcess($WebrootOutputPath, "Remove XML Document Transform files")) {
        Get-ConfigurationTransformFile -SolutionRootPath $WebrootOutputPath | ForEach-Object { Remove-Item -Path $_ }
    }

    Write-Progress -Activity "Configuring web solution" -Status "Done." -Completed
}

Export-ModuleMember -Function Publish-ConfiguredWebSolution, Publish-UnconfiguredWebSolution, Set-WebSolutionConfiguration, Publish-AllRuntimeDependencies