functions/templating/New-PSMDTemplate.ps1

function New-PSMDTemplate
{
<#
    .SYNOPSIS
        Creates a template from a reference file / folder.
     
    .DESCRIPTION
        This function creates a template based on an existing folder or file.
        It automatically detects parameters that should be filled in one creation time.
         
        # Template reference: #
        #---------------------#
        Project templates can be preconfigured by a special reference file in the folder root.
        This file must be named "PSMDTemplate.ps1" and will not be part of the template.
        It must emit a single hashtable with various pieces of information.
         
        This hashtable can have any number of the following values, in any desired combination:
        - Scripts: A Hashtable, of scriptblocks. These are scripts used for replacement parameters, the key is the name used on insertions.
        - TemplateName: Name of the template
        - Version: The version number for the template (See AutoIncrementVersion property)
        - AutoIncrementVersion: Whether the version number should be incremented
        - Tags: Tags to add to a template - makes searching and finding templates easier
        - Author: Name of the author of the template
        - Description: Description of the template
        - Exclusions: List of relative file/folder names to not process / skip.
        Each of those entries can also be overridden by specifying the corresponding parameter of this function.
         
        # Parameterizing templates: #
        #---------------------------#
        The script will pick up any parameter found in the files and folders (including the file/folder name itself).
        There are three ways to do this:
        - Named text replacement: The user will need to specify what to insert into this when creating a new project from this template.
        - Scriptblock replacement: The included scriptblock will be executed on initialization, in order to provide a text to insert. Duplicate scriptblocks will be merged.
        - Named scriptblock replacement: The template reference file can define scriptblocks, their value will be inserted here.
        The same name can be reused any number of times across the entire project, it will always receive the same input.
         
        Naming Rules:
        - Parameter names cannot include the characters '!', '{', or '}'
        - Parameter names cannot include the parameter identifier. This is by default 'þ'.
        This identifier can be changed by updating the 'psmoduledevelopment.template.identifier' configuration setting.
        - Names are not case sensitive.
         
        Examples:
        ° Named for replacement:
        "Test þnameþ" --> "Test <inserted text of parameter>"
         
        ° Scriptblock replacement:
        "Test þ{ $env:COMPUTERNAME }þ" --> "Test <Name of invoking computer>"
        - Important: No space between identifier and curly braces!
        - Scriptblock can have multiple lines.
         
        ° Named Scriptblock replacement:
        "Test þ!ClosestDomainController!þ" --> "Test <Result of script ClosestDomainController>"
        - Named Scriptblocks are created by using a template reference file (see section above)
     
    .PARAMETER ReferencePath
        Root path in which all files are selected for creating a template project.
        The folder will not be part of the template, only its content.
     
    .PARAMETER FilePath
        Path to a single file.
        Used to create a template for that single file, instead of a full-blown project.
        Note: Does not support template reference files.
     
    .PARAMETER TemplateName
        Name of the template.
     
    .PARAMETER Filter
        Only files matching this filter will be included in the template.
     
    .PARAMETER OutStore
        Where the template will be stored at.
        By default, it will push the template to the default store (A folder in appdata unless configuration was changed).
     
    .PARAMETER OutPath
        If the template should be written to a specific path instead.
        Specify a folder.
     
    .PARAMETER Exclusions
        The relative path of the files or folders to ignore.
        Ignoring folders will also ignore all items in the folder.
     
    .PARAMETER Version
        The version of the template.
     
    .PARAMETER Author
        The author of the template.
     
    .PARAMETER Description
        A description text for the template itself.
        This will be visible to the user before invoking the template and should describe what this template is for.
     
    .PARAMETER Tags
        Tags to apply to the template, making it easier to filter & search.
     
    .PARAMETER Force
        If the template in the specified version in the specified destination already exists, this will fail unless the Force parameter is used.
     
    .PARAMETER EnableException
        Replaces user friendly yellow warnings with bloody red exceptions of doom!
        Use this if you want the function to throw terminating errors you want to catch.
     
    .EXAMPLE
        PS C:\> New-PSMDTemplate -FilePath .\þnameþ.Test.ps1 -TemplateName functiontest
     
        Creates a new template named 'functiontest', based on the content of '.\þnameþ.Test.ps1'
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding(DefaultParameterSetName = 'Project')]
    param (
        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'Project')]
        [string]
        $ReferencePath,
        
        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'File')]
        [string]
        $FilePath,
        
        [Parameter(Position = 1, ParameterSetName = 'Project')]
        [Parameter(Position = 1, ParameterSetName = 'File', Mandatory = $true)]
        [string]
        $TemplateName,
        
        [string]
        $Filter = "*",
        
        [string]
        $OutStore = "Default",
        
        [string]
        $OutPath,
        
        [string[]]
        $Exclusions,
        
        [version]
        $Version = "1.0.0.0",
        
        [string]
        $Description,
        
        [string]
        $Author = (Get-PSFConfigValue -FullName 'PSModuleDevelopment.Template.ParameterDefault.Author' -Fallback $env:USERNAME),
        
        [string[]]
        $Tags,
        
        [switch]
        $Force,
        
        [switch]
        $EnableException
    )
    
    begin
    {
        #region Insert basic meta-data
        $identifier = [regex]::Escape(( Get-PSFConfigValue -FullName 'psmoduledevelopment.template.identifier' -Fallback 'þ' ))
        $binaryExtensions = Get-PSFConfigValue -FullName 'PSModuleDevelopment.Template.BinaryExtensions' -Fallback @('.dll', '.exe', '.pdf', '.doc', '.docx', '.xls', '.xlsx')
        
        $template = New-Object PSModuleDevelopment.Template.Template
        $template.Name = $TemplateName
        $template.Version = $Version
        $template.Tags = $Tags
        $template.Description = $Description
        $template.Author = $Author
        
        if ($PSCmdlet.ParameterSetName -eq 'File')
        {
            $template.Type = 'File'
        }
        else
        {
            $template.Type = 'Project'
            
            $processedReferencePath = Resolve-Path $ReferencePath
            
            if (Test-Path (Join-Path $processedReferencePath "PSMDTemplate.ps1"))
            {
                $templateData = & (Join-Path $processedReferencePath "PSMDTemplate.ps1")
                foreach ($key in $templateData.Scripts.Keys)
                {
                    $template.Scripts[$key] = New-Object PSModuleDevelopment.Template.ParameterScript($key, $templateData.Scripts[$key])
                }
                if ($templateData.TemplateName -and (Test-PSFParameterBinding -ParameterName TemplateName -Not)) { $template.Name = $templateData.TemplateName }
                if ($templateData.Version -and (Test-PSFParameterBinding -ParameterName Version -Not)) { $template.Version = $templateData.Version }
                if ($templateData.Tags -and (Test-PSFParameterBinding -ParameterName Tags -Not)) { $template.Tags = $templateData.Tags }
                if ($templateData.Description -and (Test-PSFParameterBinding -ParameterName Description -Not)) { $template.Description = $templateData.Description }
                if ($templateData.Author -and (Test-PSFParameterBinding -ParameterName Author -Not)) { $template.Author = $templateData.Author }
                
                if (-not $template.Name)
                {
                    Stop-PSFFunction -Message "No template name detected: Make sure to specify it as parameter or include it in the 'PSMDTemplate.ps1' definition file!" -EnableException $EnableException
                    return
                }
                
                if ($templateData.AutoIncrementVersion)
                {
                    $oldTemplate = Get-PSMDTemplate -TemplateName $template.Name -WarningAction SilentlyContinue | Sort-Object Version | Select-Object -First 1
                    if (($oldTemplate) -and ($oldTemplate.Version -ge $template.Version))
                    {
                        $major = $oldTemplate.Version.Major
                        $minor = $oldTemplate.Version.Minor
                        $revision = $oldTemplate.Version.Revision
                        $build = $oldTemplate.Version.Build
                        
                        # Increment lowest element
                        if ($build -ge 0) { $build++ }
                        elseif ($revision -ge 0) { $revision++ }
                        elseif ($minor -ge 0) { $minor++ }
                        else { $major++ }
                        $template.Version = "$($major).$($minor).$($revision).$($build)" -replace "\.-1",''
                    }
                }
                
                if ($templateData.Exclusions -and (Test-PSFParameterBinding -ParameterName Exclusions -Not)) { $Exclusions = $templateData.Exclusions }
            }
            
            if ($Exclusions)
            {
                $oldExclusions = $Exclusions
                $Exclusions = @()
                foreach ($exclusion in $oldExclusions)
                {
                    $Exclusions += Join-Path $processedReferencePath $exclusion
                }
            }
        }
        #endregion Insert basic meta-data
        
        #region Validation
        #region Validate FilePath
        if ($FilePath)
        {
            if (-not (Test-Path $FilePath -PathType Leaf))
            {
                Stop-PSFFunction -Message "Filepath $FilePath is invalid. Ensure it exists and is a file" -EnableException $EnableException -Category InvalidArgument -Tag 'fail', 'argument', 'path'
                return
            }
        }
        #endregion Validate FilePath
        
        #region Validate & ensure output folder
        $fileName = "$($template.Name)-$($template.Version).xml"
        $infoFileName = "$($template.Name)-$($template.Version)-Info.xml"
        if ($OutPath) { $exportFolder = $OutPath }
        else { $exportFolder = Get-PsmdTemplateStore -Filter $OutStore | Select-Object -ExpandProperty Path -First 1 }
        
        if (-not $exportFolder)
        {
            Stop-PSFFunction -Message "Unable to resolve a path to create the template in. Verify a valid template store or path were specified." -Category InvalidArgument -EnableException $EnableException -Tag 'fail', 'argument', 'path'
            return
        }
        
        if (-not (Test-Path $exportFolder))
        {
            if ($Force)
            {
                try { $null = New-Item -Path $exportFolder -ItemType Directory -Force -ErrorAction Stop }
                catch
                {
                    Stop-PSFFunction -Message "Failed to create output path: $exportFolder" -ErrorRecord $_ -Tag 'fail', 'folder', 'create' -EnableException $EnableException
                    return
                }
            }
            else
            {
                Stop-PSFFunction -Message "Output folder does not exist. Use '-Force' to have this function automatically create it: $exportFolder" -Category InvalidArgument -EnableException $EnableException -Tag 'fail', 'argument', 'path'
                return
            }
        }
        
        if ((Test-Path (Join-Path $exportFolder $fileName)) -and (-not $Force))
        {
            Stop-PSFFunction -Message "Template already exists in the current version. Use '-Force' if you want to overwrite it!" -Category InvalidArgument -EnableException $EnableException -Tag 'fail', 'argument', 'path'
            return
        }
        #endregion Validate & ensure output folder
        #endregion Validation
        
        #region Utility functions
        function Convert-Item
        {
            [CmdletBinding()]
            param (
                [System.IO.FileSystemInfo]
                $Item,
                
                [PSModuleDevelopment.Template.TemplateItemBase]
                $Parent,
                
                [string]
                $Filter,
                
                [string[]]
                $Exclusions,
                
                [PSModuleDevelopment.Template.Template]
                $Template,
                
                [string]
                $ReferencePath,
                
                [string]
                $Identifier,
                
                [string[]]
                $BinaryExtensions
            )
            
            if ($Item.FullName -in $Exclusions) { return }
            
            #region Regex
            <#
                Fixed string Replacement pattern:
                "$($Identifier)([^{}!]+?)$($Identifier)"
             
                Named script replacement pattern:
                "$($Identifier)!([^{}!]+?)!$($Identifier)"
             
                Live script replacement pattern:
                "$($Identifier){(.+?)}$($Identifier)"
             
                Chained together in a logical or, in order to avoid combination issues.
            #>

            $pattern = "$($Identifier)([^{}!]+?)$($Identifier)|$($Identifier)!([^{}!]+?)!$($Identifier)|(?ms)$($Identifier){(.+?)}$($Identifier)"
            #endregion Regex
            
            $name = $Item.Name
            $relativePath = ""
            if ($ReferencePath)
            {
                $relativePath = ($Item.FullName -replace "^$([regex]::Escape($ReferencePath))","").Trim("\")
            }
            
            #region Folder
            if ($Item.GetType().Name -eq "DirectoryInfo")
            {
                $object = New-Object PSModuleDevelopment.Template.TemplateItemFolder
                $object.Name = $name
                $object.RelativePath = $relativePath
                
                foreach ($find in ([regex]::Matches($name, $pattern, 'IgnoreCase')))
                {
                    #region Fixed string replacement
                    if ($find.Groups[1].Success)
                    {
                        if ($object.FileSystemParameterFlat -notcontains $find.Groups[1].Value)
                        {
                            $null = $object.FileSystemParameterFlat.Add($find.Groups[1].Value)
                        }
                        if ($Template.Parameters -notcontains $find.Groups[1].Value)
                        {
                            $null = $Template.Parameters.Add($find.Groups[1].Value)
                        }
                    }
                    #endregion Fixed string replacement
                    
                    #region Named Scriptblock replacement
                    if ($find.Groups[2].Success)
                    {
                        $scriptName = $find.Groups[2].Value
                        if ($Template.Scripts.Keys -eq $scriptName)
                        {
                            $object.FileSystemParameterScript($scriptName)
                        }
                        else
                        {
                            throw "Unknown named scriptblock '$($scriptName)' in name of '$($Item.FullName)'. Make sure the named scriptblock exists in the configuration file."
                        }
                    }
                    #endregion Named Scriptblock replacement
                }
                
                foreach ($child in (Get-ChildItem -Path $Item.FullName -Filter $Filter))
                {
                    $paramConvertItem = @{
                        Item               = $child
                        Filter               = $Filter
                        Exclusions           = $Exclusions
                        Template           = $Template
                        ReferencePath       = $ReferencePath
                        Identifier           = $Identifier
                        BinaryExtensions   = $BinaryExtensions
                        Parent               = $object
                    }
                    
                    Convert-Item @paramConvertItem
                }
            }
            #endregion Folder
            
            #region File
            else
            {
                $object = New-Object PSModuleDevelopment.Template.TemplateItemFile
                $object.Name = $name
                $object.RelativePath = $relativePath
                
                #region File Name
                foreach ($find in ([regex]::Matches($name, $pattern, 'IgnoreCase')))
                {
                    #region Fixed string replacement
                    if ($find.Groups[1].Success)
                    {
                        if ($object.FileSystemParameterFlat -notcontains $find.Groups[1].Value)
                        {
                            $null = $object.FileSystemParameterFlat.Add($find.Groups[1].Value)
                        }
                        if ($Template.Parameters -notcontains $find.Groups[1].Value)
                        {
                            $null = $Template.Parameters.Add($find.Groups[1].Value)
                        }
                    }
                    #endregion Fixed string replacement
                    
                    #region Named Scriptblock replacement
                    if ($find.Groups[2].Success)
                    {
                        $scriptName = $find.Groups[2].Value
                        if ($Template.Scripts.Keys -eq $scriptName)
                        {
                            $null = $object.FileSystemParameterScript.Add($scriptName)
                        }
                        else
                        {
                            throw "Unknown named scriptblock '$($scriptName)' in name of '$($Item.FullName)'. Make sure the named scriptblock exists in the configuration file."
                        }
                    }
                    #endregion Named Scriptblock replacement
                }
                #endregion File Name
                
                #region File Content
                if (-not ($Item.Extension -in $BinaryExtensions))
                {
                    $text = [System.IO.File]::ReadAllText($Item.FullName)
                    foreach ($find in ([regex]::Matches($text, $pattern, 'IgnoreCase, Multiline')))
                    {
                        #region Fixed string replacement
                        if ($find.Groups[1].Success)
                        {
                            if ($object.ContentParameterFlat -notcontains $find.Groups[1].Value)
                            {
                                $null = $object.ContentParameterFlat.Add($find.Groups[1].Value)
                            }
                            if ($Template.Parameters -notcontains $find.Groups[1].Value)
                            {
                                $null = $Template.Parameters.Add($find.Groups[1].Value)
                            }
                        }
                        #endregion Fixed string replacement
                        
                        #region Named Scriptblock replacement
                        if ($find.Groups[2].Success)
                        {
                            $scriptName = $find.Groups[2].Value
                            if ($Template.Scripts.Keys -eq $scriptName)
                            {
                                $null = $object.ContentParameterScript.Add($scriptName)
                            }
                            else
                            {
                                throw "Unknown named scriptblock '$($scriptName)' in name of '$($Item.FullName)'. Make sure the named scriptblock exists in the configuration file."
                            }
                        }
                        #endregion Named Scriptblock replacement
                        
                        #region Live Scriptblock replacement
                        if ($find.Groups[3].Success)
                        {
                            $scriptCode = $find.Groups[3].Value
                            $scriptBlock = [ScriptBlock]::Create($scriptCode)
                            
                            if ($scriptBlock.ToString() -in $Template.Scripts.Values.StringScript)
                            {
                                $scriptName = ($Template.Scripts.Values | Where-Object StringScript -EQ $scriptBlock.ToString() | Select-Object -First 1).Name
                                if ($object.ContentParameterScript -notcontains $scriptName)
                                {
                                    $null = $object.ContentParameterScript.Add($scriptName)
                                }
                                $text = $text -replace ([regex]::Escape("$($Identifier){$($scriptCode)}$($Identifier)")), "$($Identifier)!$($scriptName)!$($Identifier)"
                            }
                            
                            else
                            {
                                do
                                {
                                    $scriptName = "dynamicscript_$(Get-Random -Minimum 100000 -Maximum 999999)"
                                }
                                until ($Template.Scripts.Keys -notcontains $scriptName)
                                
                                $parameter = New-Object PSModuleDevelopment.Template.ParameterScript($scriptName, ([System.Management.Automation.ScriptBlock]::Create($scriptCode)))
                                $Template.Scripts[$scriptName] = $parameter
                                $null = $object.ContentParameterScript.Add($scriptName)
                                $text = $text -replace ([regex]::Escape("$($Identifier){$($scriptCode)}$($Identifier)")), "$($Identifier)!$($scriptName)!$($Identifier)"
                            }
                        }
                        #endregion Live Scriptblock replacement
                    }
                    $object.Value = $text
                }
                else
                {
                    $bytes = [System.IO.File]::ReadAllBytes($Item.FullName)
                    $object.Value = [System.Convert]::ToBase64String($bytes)
                    $object.PlainText = $false
                }
                #endregion File Content
            }
            #endregion File
            
            # Set identifier, so that Invoke-PSMDTemplate knows what to use when creating the item
            # Needed for sharing templates between users with different identifiers
            $object.Identifier = $Identifier
            
            if ($Parent)
            {
                $null = $Parent.Children.Add($object)
            }
            else
            {
                $null = $Template.Children.Add($object)
            }
        }
        #endregion Utility functions
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        #region Parse content and produce template
        if ($ReferencePath)
        {
            foreach ($item in (Get-ChildItem -Path $processedReferencePath -Filter $Filter))
            {
                if ($item.FullName -in $Exclusions) { continue }
                if ($item.Name -eq "PSMDTemplate.ps1") { continue }
                Convert-Item -Item $item -Filter $Filter -Exclusions $Exclusions -Template $template -ReferencePath $processedReferencePath -Identifier $identifier -BinaryExtensions $binaryExtensions
            }
        }
        else
        {
            $item = Get-Item -Path $FilePath
            Convert-Item -Item $item -Template $template -Identifier $identifier -BinaryExtensions $binaryExtensions
        }
        #endregion Parse content and produce template
    }
    end
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        $template.CreatedOn = (Get-Date).Date
        
        $template | Export-Clixml -Path (Join-Path $exportFolder $fileName)
        $template.ToTemplateInfo() | Export-Clixml -Path (Join-Path $exportFolder $infoFileName)
    }
}