src/application.ps1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Scope = 'Function', Target = 'Invoke-NpmInstall')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Scope = 'Function', Target = 'New-Template')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Scope = 'Function', Target = 'ConvertTo-PowerShellSyntax')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Scope = 'Function', Target = 'New-WebApplication')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Scope = 'Function', Target = 'Remove-Indent')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Scope = 'Function', Target = 'Test-ApplicationContext')]
Param()

class ApplicationState {
    [String] $Id = (New-Guid)
    [Bool] $Continue = $True
    [String] $Name = 'Application Name'
    [String] $Parent = (Get-Location).Path
    [String] $Type = 'Terminal'
    $Data
}
function ConvertFrom-Base64 {
    <#
    .SYNOPSIS
    Deccode a Base64 string
    #>

    [CmdletBinding()]
    [OutputType([String])]
    Param(
        [Parameter(Position = 0, ValueFromPipeline = $True)]
        [String] $Value
    )
    Process {
        $String = [System.Convert]::FromBase64String($Value)
        [System.Text.Encoding]::Unicode.GetString($String)
    }
}
function ConvertTo-Base64 {
    <#
    .SYNOPSIS
    Encode string in Base64
    #>

    [CmdletBinding()]
    [OutputType([String])]
    Param(
        [Parameter(Position = 0, ValueFromPipeline = $True)]
        [String] $Value
    )
    Process {
        $Bytes = [System.Text.Encoding]::Unicode.GetBytes($Value)
        [Convert]::ToBase64String($Bytes)
    }
}
function ConvertTo-PowerShellSyntax {
    [OutputType([String])]
    Param(
        [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        [String] $Value,
        [String] $DataVariableName = 'Data'
    )
    Write-Output $Value |
        ForEach-Object { $_ -replace '(?<!(}}[\w\s]*))(?<!{{#[\w\s\-_]*)\s*}}', ')' } |
        ForEach-Object { $_ -replace '{{(?!#)\s*', "`$(`$$DataVariableName." }
}
function Get-State {
    <#
    .SYNOPSIS
    Load state from file
    .EXAMPLE
    $State = Get-State -Name 'abc-def-ghi'
    .EXAMPLE
    $State = 'abc-def-ghi' | Get-State
    #>

    [CmdletBinding()]
    Param(
        [Parameter(Position = 0, ValueFromPipeline = $True)]
        [String] $Name,
        [AllowEmptyString()]
        [String] $Path
    )
    if ($Path.Length -gt 0 -and (Test-Path $Path)) {
        "==> Resolved ${Path}" | Write-Verbose
    } else {
        $TempRoot = if ($IsLinux) { '/tmp' } else { $Env:temp }
        $Filename = $Name | Get-StateName
        $Path = Join-Path $TempRoot "${Filename}.xml"
    }
    "==> Loading state from ${Path}" | Write-Verbose
    Import-Clixml -Path $Path
}
function Get-StateName {
    <#
    .SYNOPSIS
    Create state name from input ID
    .EXAMPLE
    $Name = 'My-App' | Get-StateName
    #>

    [CmdletBinding()]
    [OutputType([String])]
    Param(
        [Parameter(Position = 0, ValueFromPipeline = $True)]
        [String] $Id
    )
    Process {
        "prelude-$($Id | ConvertTo-Base64)"
    }
}
function Format-Json {
    <#
    .SYNOPSIS
    Prettify JSON output
    .EXAMPLE
    Get-Content './foo.json' | Format-Json | Out-File './bar.json' -Encoding ascii
    .EXAMPLE
    './some.json' | Format-Json -InPlace
    #>

    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $True, ValueFromPipeline = $True)]
        [String] $Value,
        [ValidateSet(2, 4)]
        [Int] $Indentation = 2,
        [Switch] $InPlace
    )
    $Indent = 0
    $NewLine = '\r?\n'
    $Quoted = '(?=([^"]*"[^"]*")*[^"]*$)'
    $IsValidPath = Test-Path -Path $Value
    $Data = if ($InPlace -and $IsValidPath) {
        Get-Content -Path $Value -Raw
    } else {
        $Value
    }
    $Compressed = $Data -notmatch $NewLine
    if ($Compressed) {
        $Data = $Data | ConvertFrom-Json | ConvertTo-Json -Depth 100
    }
    $Lines = $Data -split $NewLine
    $Result = foreach ($Line in $Lines) {
        if ($Line -match "[}\]]${Quoted}") {
            $Indent = ($Indent - $Indentation), 0 | Get-Maximum
        }
        $Temp = (' ' * $Indent) + ($Line.TrimStart() -replace ":\s+${Quoted}", ': ')
        if ($Line -match "[\{\[]${Quoted}") {
            $Indent += $Indentation
        }
        $Temp
    }
    if ($InPlace -and $IsValidPath) {
        $Result | Set-Content -Path $Value | Out-Null
    } else {
        $Result -join [Environment]::NewLine
    }
}
function Invoke-FireEvent {
    <#
    .SYNOPSIS
    Create event
    .EXAMPLE
    'eventName' | Invoke-FireEvent
    #>

    [CmdletBinding()]
    [Alias('trigger')]
    Param(
        [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        [String] $Name,
        [PSObject] $Data
    )
    New-Event -SourceIdentifier $Name -MessageData $Data | Out-Null
}
function Invoke-NpmInstall {
    <#
    .SYNOPSIS
    "npm install"...but as a cmdlet
    .EXAMPLE
    Invoke-NpmInstall
    #>

    [CmdletBinding(SupportsShouldProcess = $True)]
    [OutputType([Bool])]
    Param(
        [ValidateScript( { Test-Path $_ })]
        [String] $Parent = (Get-Location).Path,
        [Switch] $Silent
    )
    Begin {
        $Command = 'npm install'
        $Context = Test-ApplicationContext -Parent $Parent
        $Location = Get-Location
        if ($PSCmdlet.ShouldProcess("Change location to ${Parent}")) {
            $Success = $True
            Set-Location -Path $Parent
        }
    }
    Process {
        if ($Context.Node.Ready) {
            try {
                if ($PSCmdlet.ShouldProcess('Install dependencies with "npm install"')) {
                    if (-not $Silent) {
                        '==> [INFO] Installing dependencies...' | Write-Color -Cyan
                    }
                    Invoke-Expression $Command | Out-Null
                }
            } catch {
                $Success = $False
            }
        } else {
            if (-not $Silent) {
                Write-Status 'fail'
            }
            switch ($Context.Node) {
                { -not $_.PackageManager } {
                    "Could not run `"${Command}.`" Is npm installed?`n" | Write-Color -White
                }
                { -not $_.Manifest } {
                    "Could not find package.json in ${Parent}...`n" | Write-Color -White
                }
                Default {
                    "{{#yellow (╯°□°)╯︵ ┻━┻ }}...maybe try again?`n" | Write-Color -White
                }
            }
            $Success = $False
        }
    }
    End {
        if ($PSCmdlet.ShouldProcess("Restore location to ${Location}")) {
            Set-Location -Path $Location
            if (-not $Success) {
                return $Null
            } else {
                return $Success
            }
        }
    }
}
function Invoke-RunApplication {
    <#
    .SYNOPSIS
    Entry point for PowerShell CLI application
    .PARAMETER Init
    Function to initialize application, executed when application is started.
    .PARAMETER Loop
    Code to execute during every application loop, executed when ShouldContinue returns True.
    .PARAMETER BeforeNext
    Code to execute at the end of each application loop. It should be used to update the return of ShouldContinue.
    .PARAMETER SingleRun
    As its name implies - use this flag to execute one loop of the application
    .PARAMETER NoCleanup
    Use this switch to disable removing the application event listeners when the application exits.
    Application event listeners can be removed manually with: 'application:' | Invoke-StopListen
    .EXAMPLE
    $Init = {
        # Initialize your app - $Init is only run once
        'Getting things ready...' | Write-Color -Green
    }
    # Define what your app should do every iteration - $Loop is executed until ShouldContinue returns False
    $Loop = {
        Clear-Host
        'Doing something super important...' | Write-Color -Gray
        Start-Sleep 5
    }
    # Start your app
    Invoke-RunApplication $Init $Loop
 
    # Make a simple app
    .EXAMPLE
    New-TerminalApplicationTemplate -Save
 
    # Make a simple app with state
    # Note: State is passed to Init, Loop, ShouldContinue, and BeforeNext
    .EXAMPLE
    { say 'Hello' } | on 'application:init'
    { say 'Wax on' } | on 'application:loop:before'
    { say 'Wax off' } | on 'application:loop:after'
    { say 'Goodbye' } | on 'application:exit'
 
    # Applications trigger events throughout their lifecycle which can be listened to (most commonly within the Init scriptblock).
    # The triggered event will include State as MessageData
 
    {
        $Id = $Event.MessageData.State.Id
        "`nApplication ID: $Id" | Write-Color -Green
    } | Invoke-ListenTo 'application:init'
    #>

    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $True, Position = 0)]
        [ScriptBlock] $Init,
        [Parameter(Mandatory = $True, Position = 1)]
        [ScriptBlock] $Loop,
        [Parameter(Position = 2)]
        [ApplicationState] $State = @{},
        [String] $Name,
        [ScriptBlock] $ShouldContinue,
        [ScriptBlock] $BeforeNext,
        [Switch] $ClearState,
        [Switch] $SingleRun,
        [Switch] $NoCleanup
    )
    if ($Name.Length -gt 0) {
        $TempRoot = if ($IsLinux) { '/tmp' } else { $Env:temp }
        $Filename = $Name | Get-StateName
        $Path = Join-Path $TempRoot "${Filename}.xml"
        if ($ClearState -and (Test-Path $Path)) {
            Remove-Item $Path
        }
        if (Test-Path $Path) {
            "==> Resolved state with name: ${Name}" | Write-Verbose
            try {
                [ApplicationState]$State = Get-State $Name
                $State.Name = $Name
            } catch {
                "==> Failed to get state with name: ${Name}" | Write-Verbose
                $State = [ApplicationState]@{ Name = $Name }
            }
        } else {
            $State.Name = $Name
        }
    }
    if (-not $State) {
        $State = [ApplicationState]@{ Name = $Name }
    }
    if (-not $ShouldContinue) {
        $ShouldContinue = { $State.Continue -eq $True }
    }
    if (-not $BeforeNext) {
        $BeforeNext = {
            "`n`nContinue?" | Write-Label -NewLine
            $State.Continue = ('yes', 'no' | Invoke-Menu) -eq 'yes'
        }
    }
    "Application ID: $($State.Id)" | Write-Verbose
    "Application Name: $($State.Name)" | Write-Verbose
    'application:init' | Invoke-FireEvent
    & $Init $State
    if ($SingleRun) {
        'application:loop:before' | Invoke-FireEvent -Data @{ State = $State }
        & $Loop $State
        'application:loop:after' | Invoke-FireEvent -Data @{ State = $State }
    } else {
        while (& $ShouldContinue $State) {
            'application:loop:before' | Invoke-FireEvent -Data @{ State = $State }
            & $Loop $State
            'application:loop:after' | Invoke-FireEvent -Data @{ State = $State }
            & $BeforeNext $State
        }
    }
    'application:exit' | Invoke-FireEvent -Data @{ State = $State }
    if (-not $NoCleanup) {
        'application:' | Invoke-StopListen
    }
    $State.Id
}
function Invoke-StopListen {
    <#
    .SYNOPSIS
    Remove event subscriber(s)
    .EXAMPLE
    $Callback | on 'SomeEvent'
    'SomeEvent' | Invoke-StopListen
 
    # Remove events using the event "source identifier" (Name)
    .EXAMPLE
    $Callback | on -Name 'Namespace:foo'
    $Callback | on -Name 'Namespace:bar'
    'Namespace:' | Invoke-StopListen
 
    # Remove multiple events using an event namespace
    .EXAMPLE
    $Listener = $Callback | on 'SomeEvent'
    Invoke-StopListen -EventData $Listener
 
    # Selectively remove a single event by passing its event data
    #>

    [CmdletBinding()]
    Param(
        [Parameter(ValueFromPipeline = $True)]
        [String] $Name,
        [PSObject] $EventData
    )
    if ($EventData) {
        Unregister-Event -SubscriptionId $EventData.Id
    } else {
        if ($Name) {
            $Events = Get-EventSubscriber | Where-Object { $_.SourceIdentifier -match "^$Name" }
        } else {
            $Events = Get-EventSubscriber
        }
        $Events | ForEach-Object { Unregister-Event -SubscriptionId $_.SubscriptionId }
    }
}
function New-TerminalApplicationTemplate {
    <#
    .SYNOPSIS
    Return boilerplate string of a Prelude terminal application
    .EXAMPLE
    New-TerminalApplicationTemplate | Out-File 'my-app.ps1'
    #>

    [CmdletBinding()]
    [OutputType([String])]
    Param()
    $Snippet = if (-not $IsLinux) {
        '
        {
            Invoke-Speak goodbye
            $Name = $Event.MessageData.State.Name
            "Application name: ${Name}" | Write-Color -Magenta
        } | Invoke-ListenTo ''application:exit'' | Out-Null
        '
 | Remove-Indent
    } else {
        ''
    }
    $Data = @{
        Empty = ''
        Snippet = $Snippet
    }
    ' #Requires -Modules Prelude
    [CmdletBinding()]
    Param(
        [String] $Name = ''My-Terminal-App'',
        [Switch] $Clear
    )
    {{ Empty }}
    $InitialState = @{ Data = 0; Type = ''Terminal''; Name = $Name }
    {{ Empty }}
    $Init = {
        Clear-Host
        $State = $Args[0]
        $Id = $State.Id
        ''Application Information:'' | Write-Color
        `"ID = {{#green $Id}}`" | Write-Label -Color Gray -Indent 2 -NewLine
        `"Name = {{#green ${Name}}}`" | Write-Label -Color Gray -Indent 2 -NewLine
        {{ Snippet }}
        Start-Sleep 2
    }
    {{ Empty }}
    $Loop = {
        Clear-Host
        $State = $Args[0]
        $Count = $State.Data
        `"Current count is {{#green $Count}}`" | Write-Color -Cyan
        $State.Data++
        $State | Save-State $State.Name -Force | Out-Null
        Start-Sleep 1
    }
    {{ Empty }}
    Invoke-RunApplication $Init $Loop $InitialState -Name $Name -ClearState:$Clear
    '
 | Remove-Indent | New-Template -Data $Data
}
function New-Template {
    <#
    .SYNOPSIS
    Create render function that interpolates passed object values
    .PARAMETER Data
    Pass template data to New-Template when using New-Template within pipe chain (see examples)
    .PARAMETER NoData
    For use in tandem with templates that ONLY use external data (e.g. $Env variables)
    .PARAMETER File
    Path to file containing template content
    .EXAMPLE
    $Function:render = New-Template '<div>Hello {{ name }}!</div>'
    render @{ name = 'World' }
    # '<div>Hello World!</div>'
 
    # Use mustache template syntax! Just like Handlebars.js!
    .EXAMPLE
    $Function:render = 'hello {{ name }}' | New-Template
    @{ name = 'world' } | render
    # 'hello world'
 
    # New-Template supports idiomatic PowerShell pipeline syntax
    .EXAMPLE
    $title = New-Template -Template '<h1>{{ text }}</h1>' -DefaultValues @{ text = 'Default' }
 
    & $title
    # '<h1>Default</h1>'
 
    & $title @{ text = 'Hello World' }
    # '<h1>Hello World</h1>'
 
    # Provide default values for your templates!
    .EXAMPLE
    $Function:Div = '<div>{{ v }}</div>' | New-Template
    $Function:Span = '<span>{{ v }}</span>' | New-Template
    Div @{ v = Span @{ v = 'Hello World' } } | Write-Output
    # "<div><span>Hello World</span></div>"
 
    # Templates can even be nested!
    .EXAMPLE
    '{{#green Hello}} {{ name }}' | tpl -Data @{ name = 'World' } | Write-Color
 
    # Use of the -Data parameter will cause New-Template to return a formatted string instead of template function
    .EXAMPLE
    New-Template -File path/to/file -Data $Data | Write-Color -Cyan
 
    # Load a template from a file
    .EXAMPLE
    $Function:Element = '<{{ tag }}>{{ text }}</{{ tag }}>' | New-Template
    $Function:Div = Element @{ tag = 'div' } -Partial | New-Template
    Div @{ text = 'Hello World' }
    # '<div>Hello World</div>'
 
    # Create partial templates using the -Partial parameter
    .EXAMPLE
    'The answer is {{= $Value + 2 }}' | tpl -Data @{ Value = 40 }
    # "The answer is 42"
 
    # Execute PowerShell code within your templates using the {{= ... }} syntax
    .EXAMPLE
    'The fox says {{= $Env:SomeRandomValue }}!!!' | New-Template -NoData
 
    # Even access environment variables. Use -NoData when no data needs to be passed.
    .EXAMPLE
    '{{- This is a comment }}Super important stuff' | tpl -NoData
 
    # Add comments to templates using {{- ... }} syntax
    #>

    [CmdletBinding()]
    [Alias('tpl')]
    [OutputType([String])]
    Param(
        [Parameter(ParameterSetName = 'string', Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        [String] $Template,
        [Parameter(ParameterSetName = 'file')]
        [String] $File,
        [Alias('Data')]
        [Hashtable] $Binding = @{},
        [Switch] $NoData,
        [Hashtable] $DefaultValues = @{},
        [Switch] $PassThru
    )
    Begin {
        $Script:TemplateKeyNamesNotPassed = @()
        $Pattern = '(?<expression>{{(?<indicator>(=|-|#))?\s+(?<variable>.*?)\s*}})'
        $Renderer = {
            Param(
                [ScriptBlock] $Script,
                [Hashtable] $Binding = @{}
            )
            $Binding.GetEnumerator() | ForEach-Object { New-Variable -Name $_.Key -Value $_.Value }
            try {
                $Script.Invoke()
            } catch {
                throw $_
            }
        }
        $Evaluator = {
            Param($Match)
            $Groups = $Match.Groups
            $Value = $Groups[1].Value
            $Indicator = $Groups | Where-Object { $_.Name -eq 'indicator' } | Get-Property 'Value'
            $Variable = $Groups | Where-Object { $_.Name -eq 'variable' } | Get-Property 'Value'
            switch ($Indicator) {
                '#' { $Value }
                '-' { '' }
                '=' {
                    $Block = [ScriptBlock]::Create('$($(' + ($Variable -replace '`(?=\$)', '') + ') | Write-Output)')
                    $Binding = $DefaultValues, $Binding | Invoke-ObjectMerge -Force
                    try {
                        $PowerShell = [PowerShell]::Create()
                        $PowerShell.AddScript($Renderer).AddParameter('Binding', $Binding).AddParameter('Script', $Block).Invoke()
                    } catch {
                        "==> [ERROR] Something went wrong within the Evaluator when rendering: {{#yellow ${Block} }}`n" | Write-Color -Red
                        "==> [INFO] `$Binding = $($Binding | ConvertTo-Json -Compress)`n" | Write-Color -DarkGray
                        $_ | Write-Error
                    } finally {
                        if ($PowerShell) {
                            $PowerShell.Dispose()
                        }
                    }
                }
                Default {
                    $Script:TemplateKeyNamesNotPassed += $Variable
                    "`${${Variable}}"
                }
            }
        }
    }
    Process {
        $PLACEHOLDER = '<<<DOUBLE QUOTES PRELUDE PLACEHOLDER>>>'
        if ($File) {
            $Path = Get-StringPath $File
            $Template = Get-Content $Path -Raw
        }
        $EvaluatedTemplate = [Regex]::Replace(($Template -replace '[$]', '`$'), $Pattern, $Evaluator)
        if ($File) {
            # $EvaluatedTemplate = ($EvaluatedTemplate -replace '"', '""')
        }
        $TemplateScriptBlock = [ScriptBlock]::Create('$("' + ($EvaluatedTemplate -replace '"', $PLACEHOLDER) + '" | Write-Output)')
        $NotPassed = $Script:TemplateKeyNamesNotPassed
        if (($Binding.Count -gt 0) -or $NoData) {
            if ($PassThru) {
                return $Template
                exit
            }
            $Binding = $DefaultValues, $Binding | Invoke-ObjectMerge -Force
            try {
                $PowerShell = [PowerShell]::Create()
                $PowerShell.AddScript($Renderer).AddParameter('Binding', $Binding).AddParameter('Script', $TemplateScriptBlock).Invoke() -replace $PLACEHOLDER, '"'
            } catch {
                "==> [ERROR] Something went wrong when rendering: {{#yellow ${TemplateScriptBlock} }}`n" | Write-Color -Red
                "==> [INFO] No data passed`n" | Write-Color -DarkGray
                $_ | Write-Error
            } finally {
                if ($PowerShell) {
                    $PowerShell.Dispose()
                }
            }
        } else {
            {
                Param(
                    [Parameter(Position = 0, ValueFromPipeline = $True)]
                    [Alias('Data')]
                    [Hashtable] $Binding = @{},
                    [Array] $NotPassed = $NotPassed,
                    [Switch] $Partial,
                    [Switch] $PassThru
                )
                if ($PassThru) {
                    return $Template
                    exit
                }
                $PartialValues = @{}
                if ($Partial) {
                    foreach ($Key in $NotPassed) {
                        if ([String]::IsNullOrEmpty($Binding[$Key])) {
                            $PartialValues[$Key] = "{{ ${Key} }}"
                        }
                    }
                }
                $Binding = $PartialValues, $DefaultValues, $Binding | Invoke-ObjectMerge -Force
                try {
                    $PowerShell = [PowerShell]::Create()
                    $PowerShell.AddScript($Renderer).AddParameter('Binding', $Binding).AddParameter('Script', $TemplateScriptBlock).Invoke() -replace $PLACEHOLDER, '"'
                } catch {
                    "==> [ERROR] Something went wrong when rendering: {{#yellow ${TemplateScriptBlock} }}`n" | Write-Color -Red
                    "==> [INFO] `$Binding = $($Binding | ConvertTo-Json -Compress)`n" | Write-Color -DarkGray
                    $_ | Write-Error
                } finally {
                    if ($PowerShell) {
                        $PowerShell.Dispose()
                    }
                }
            }.GetNewClosure()
        }
    }
}
function New-DesktopApplication {
    <#
    .SYNOPSIS
    Create a new desktop application.
    #>

    [CmdletBinding()]
    Param(
        [Parameter(ValueFromPipeline = $True)]
        [PSObject] $Configuration = @{}
    )
    Begin {}
    Process {}
    End {}
}
function New-WebApplication {
    <#
    .SYNOPSIS
    Create a new web application.
    .DESCRIPTION
    This function allows you to scaffold a bespoke web application and optionally install dependencies.
 
    When -NoInstall is not used, dependencies will be installed using npm.
 
    Before dependencies are installed, application state will be saved using Save-State under the passed application name (or the default, "webapp")
 
    Application data can be viewed and used using "Get-State <Name>"
 
    .PARAMETER Name
    Name of application folder
    .PARAMETER Parent
    Parent directory in which to create the application directory
    .EXAMPLE
    New-WebApplication
    .EXAMPLE
    New-WebApplication -Bundler Parcel -Library React -With Cesium
    .EXAMPLE
    New-WebApplication -Parcel -React -With Cesium
    .EXAMPLE
    @{
        Bundler = 'Parcel'
        Library = 'React'
        With = 'Cesium'
    } | New-WebApplication -Name 'My-App'
    #>

    [CmdletBinding(DefaultParameterSetName = 'parameter', SupportsShouldProcess = $True)]
    Param(
        [Parameter(ParameterSetName = 'pipeline', ValueFromPipeline = $True)]
        [PSObject] $Configuration = @{},
        [ApplicationState] $State = @{ Type = 'Web' },
        [Parameter(Position = 0, ParameterSetName = 'parameter')]
        [ValidateSet('Parcel', 'Rollup', 'Snowpack', 'Vite', 'Webpack')]
        [String] $Bundler,
        [Parameter(ParameterSetName = 'switch')]
        [Switch] $Webpack,
        [Parameter(ParameterSetName = 'switch')]
        [Switch] $Parcel,
        [Parameter(ParameterSetName = 'switch')]
        [Switch] $Rollup,
        [Parameter(ParameterSetName = 'switch')]
        [Switch] $Snowpack,
        [Parameter(ParameterSetName = 'switch')]
        [Switch] $Vite,
        [Parameter(Position = 1, ParameterSetName = 'parameter')]
        [AllowNull()]
        [AllowEmptyString()]
        [ValidateSet('', 'Vanilla', 'React', 'Solid')]
        [String] $Library,
        [Parameter(ParameterSetName = 'switch')]
        [Switch] $Vanilla,
        [Parameter(ParameterSetName = 'switch')]
        [Switch] $React,
        [Parameter(ParameterSetName = 'switch')]
        [Switch] $Solid,
        [ValidateSet('Cesium', 'Reason', 'Rust')]
        [String[]] $With,
        [String] $Name = 'webapp',
        [ValidateScript( { Test-Path $_ })]
        [String] $Parent = (Get-Location).Path,
        [Parameter(ParameterSetName = 'interactive')]
        [Switch] $Interactive,
        [Switch] $NoInstall,
        [Switch] $Silent,
        [Switch] $Force
    )
    Begin {
        $BundlerOptions = @(
            'Webpack'
            'Parcel'
            'Rollup'
            'Snowpack'
            'Vite'
        )
        $LibraryOptions = @(
            'Vanilla'
            'React'
            'Solid'
        )
        $WithOptions = @(
            'Cesium'
            'Reason'
            'Rust'
        )
        $Defaults = @{
            Bundler = 'Webpack'
            Library = 'Vanilla'
            With = @()
            SourceDirectory = 'src'
            AssetsDirectory = 'public'
            ProductionDirectory = 'dist'
            RustDirectory = 'rust-to-wasm'
            Legacy = $False
            ReactVersion = '^17'
            License = 'MIT'
        }
    }
    Process {
        $Data = if ($PsCmdlet.ParameterSetName -eq 'pipeline') {
            $Defaults, $Configuration | Invoke-ObjectMerge -Force
        } else {
            if ($Interactive) {
                'Build a Web Application' | Write-Title -Blue -TextColor White -SubText 'choose wisely'
                '' | Write-Label -NewLine

                'Choose your {{#cyan bundler}}:' | Write-Label -Color 'Gray' -NewLine
                $Bundler = Invoke-Menu $BundlerOptions -SingleSelect -SelectedMarker ' => ' -HighlightColor 'Cyan'
                '' | Write-Label -NewLine

                'Choose your {{#yellow library}}:' | Write-Label -Color 'Gray' -NewLine
                $Library = Invoke-Menu $LibraryOptions -SingleSelect -SelectedMarker ' => ' -HighlightColor 'Yellow'
                '' | Write-Label -NewLine

                'Enhance your application {{#magenta with}}:' | Write-Label -Color 'Gray' -NewLine
                $With = Invoke-Menu $WithOptions -MultiSelect -SelectedMarker ' => ' -HighlightColor 'Magenta'
                '' | Write-Label -NewLine
            } else {
                if (-not $Bundler) {
                    $Bundler = Find-FirstTrueVariable $BundlerOptions
                }
                if (-not $Library) {
                    $Library = Find-FirstTrueVariable $LibraryOptions
                }
            }
            $Defaults, @{
                Bundler = $Bundler
                Library = $Library
                With = $With
            } | Invoke-ObjectMerge -Force
        }
        $Data.Name = if ($Data.Name) { $Data.Name } else { $Name }
        $Data.Parent = if ($Data.Parent) { $Data.Parent } else { $Parent }
        $ApplicationDirectory = Join-Path $Data.Parent $Data.Name
        $TemplateDirectory = Join-Path $PSScriptRoot '../src/templates'
        $RustDirectory = Join-Path $ApplicationDirectory $Data.RustDirectory
        $PackageManifestData = @{
            name = $Data.Name
            version = '0.0.0'
            description = ''
            license = $Data.License
            keywords = @()
            main = "./$($Data.SourceDirectory)/main.js$(if ($Library -eq 'React') { 'x' })"
            scripts = @{}
            dependencies = @{}
            devDependencies = @{}
        }
        $ConfigurationFileData = @{
            Reason = @{
                'name' = $Data.Name
                'bs-dependencies' = @(
                    '@rescript/react'
                )
                'bsc-flags' = @(
                    '-bs-super-errors'
                )
                'namespace' = $True
                'package-specs' = @(
                    @{
                        'module' = 'es6'
                        'in-source' = $True
                    }
                )
                'ppx-flags' = @()
                'reason' = @{
                    'react-jsx' = 3
                }
                'refmt' = 3
                'sources' = @(
                    @{
                        'dir' = $Data.SourceDirectory
                        'subdirs' = $True
                    }
                )
                'suffix' = '.bs.js'
            }
            Webpack = @{
                SourceDirectory = $Data.SourceDirectory
                AssetsDirectory = $Data.AssetsDirectory
                ProductionDirectory = $Data.ProductionDirectory
                UseReact = ($Library -eq 'React')
                WithCesium = ($With -contains 'Cesium')
                WithRust = ($With -contains 'Rust')
                CesiumConfig = ("
                    new DefinePlugin({CESIUM_BASE_URL: JSON.stringify('/')}),
                    new CopyWebpackPlugin({
                        patterns: [
                            {from: join(source, 'Workers'), to: 'Workers'},
                            {from: join(source, 'ThirdParty'), to: 'ThirdParty'},
                            {from: join(source, 'Assets'), to: 'Assets'},
                            {from: join(source, 'Widgets'), to: 'Widgets'}
                        ]
                    })"
 | Remove-Indent -Size 12)
            }
        }
        $Dependencies = @{
            Cesium = @{
                'cesium' = '^1.93.0'
            }
            React = @{
                Core = @{
                    'prop-types' = '*'
                    'react' = $Data.ReactVersion
                    'react-dom' = $Data.ReactVersion
                    'wouter' = '*'
                }
                Cesium = @{
                    'resium' = '^1.14.3'
                }
            }
            Reason = @{
                '@rescript/react' = '*'
            }
            Solid = @{}
        }
        $DevelopmentDependencies = @{
            _workflow = @{
                'cpy-cli' = '*'
                'del-cli' = '*'
                'npm-run-all' = '*'
                'watch' = '*'
            }
            Cesium = @{}
            Parcel = @{
                'parcel' = '*'
                'parcel-plugin-purgecss' = '*'
            }
            Postcss = @{
                Core = @{
                    'cssnano' = '^5.1.9'
                    'postcss' = '^8.4.14'
                    'postcss-cli' = '^9.1.0'
                    'postcss-import' = '^14.1.0'
                    'postcss-preset-env' = '^7.6.0'
                    'postcss-reporter' = '^7.0.5'
                    'postcss-safe-parser' = '^6.0.0'
                }
                React = @{}
            }
            React = @{
                '@hot-loader/react-dom' = $Data.ReactVersion
                'react-hot-loader' = '*'
            }
            Reason = @{
                'rescript' = '*'
            }
            Rollup = @{
                'rollup' = '*'
                'rollup-plugin-babel' = '*'
                'rollup-plugin-commonjs' = '*'
                'rollup-plugin-node-resolve' = '*'
                'rollup-plugin-replace' = '*'
                'rollup-plugin-terser' = '*'
            }
            Rust = @{
                '@wasm-tool/wasm-pack-plugin' = '*'
            }
            Stylelint = @{
                'stylelint' = '^14.8.3'
                'stylelint-config-recommended' = '^7.0.0'
            }
            Snowpack = @{
                'snowpack' = '*'
                '@snowpack/app-scripts-react' = '*'
                '@snowpack/plugin-react-refresh' = '*'
                '@snowpack/plugin-postcss' = '*'
                '@snowpack/plugin-optimize' = '*'
            }
            Webpack = @{
                'webpack' = '*'
                'webpack-bundle-analyzer' = '*'
                'webpack-cli' = '*'
                'webpack-dashboard' = '*'
                'webpack-dev-server' = '*'
                'webpack-jarvis' = '*'
                'webpack-subresource-integrity' = '*'
                'babel-loader' = '*'
                'css-loader' = '*'
                'file-loader' = '*'
                'style-loader' = '*'
                'url-loader' = '*'
                'copy-webpack-plugin' = '*'
                'html-webpack-plugin' = '*'
                'terser-webpack-plugin' = '*'
            }
        }
        $NpmScripts = @{
            Parcel = @{}
            Postcss = @{}
            Rollup = @{}
            Webpack = @{
                'clean' = "del-cli $($Data.ProductionDirectory)"
                'copy' = 'npm-run-all --parallel copy:assets'
                'copy:assets' = "cpy \`"$($Data.AssetsDirectory)/!(css)/**/*.*\`" \`"$($Data.AssetsDirectory)/**/[.]*\`" $($Data.ProductionDirectory) --parents --recursive"
                'prebuild:es' = "del-cli $($Data.ProductionDirectory)/$($Data.AssetsDirectory)"
                'build:es' = 'webpack'
                'build:stats' = 'webpack --mode production --profile --json > stats.json'
                'build:analyze' = 'webpack-bundle-analyzer ./stats.json'
                'postbuild:es' = 'npm run copy'
                'watch:assets' = "watch \`"npm run copy\`" $($Data.AssetsDirectory)"
                'watch:es' = "watch \`"npm run build:es\`" $($Data.AssetsDirectory)"
                'dashboard' = 'webpack-dashboard -- webpack serve --config ./webpack.config.js'
                'predeploy' = 'npm-run-all clean "build:es -- --mode=production" build:css'
                'deploy' = 'echo \"Not yet implemented - now.sh or surge.sh are supported out of the box\" && exit 1'
            }
        }
        if ($PSCmdlet.ShouldProcess('Create application folder structure and common assets')) {
            $Source = $Data.SourceDirectory
            $Assets = $Data.AssetsDirectory
            @(
                ''
                $Source
                "${Source}/components"
                $Assets
                "${Assets}/css"
                "${Assets}/fonts"
                "${Assets}/images"
                "${Assets}/library"
                "${Assets}/workers"
                '__tests__'
            ) | ForEach-Object { New-Item -Type Directory -Path (Join-Path $ApplicationDirectory $_) -Force } | Out-Null
        }
        if ($PSCmdlet.ShouldProcess('Copy common assets')) {
            $Data, @{
                UseReact = ($Library -eq 'React')
                WithCesium = ($With -contains 'Cesium')
                NoJavaScriptEnglish = 'Please enable JavaScript in your browser for a better experience.'
                NoJavaScriptFrench = 'Veuillez activer JavaScript dans votre navigateur pour une meilleure expérience.'
                NoJavaScriptJapanese = 'より良い体験のため、ブラウザでJavaScriptを有効にして下さい'
                NoJavaScriptChinese = '请在你的浏览器中启用JavaScript以便享受最佳体验'
            } | Invoke-ObjectMerge -InPlace -Force
            $Assets = Join-Path $ApplicationDirectory $Data.AssetsDirectory
            @(
                @{
                    Filename = 'index.html'
                    Template = 'source/html_index'
                    Parent = $Assets
                }
                @{
                    Filename = 'style.css'
                    Template = 'source/css_style'
                    Parent = (Join-Path $Assets 'css')
                }
                @{
                    Filename = '.gitkeep'
                    Template = 'gitkeep'
                    Parent = (Join-Path $Assets 'fonts')
                }
                @{
                    Filename = '.gitkeep'
                    Template = 'gitkeep'
                    Parent = (Join-Path $Assets 'images')
                }
                @{
                    Filename = '.gitkeep'
                    Template = 'gitkeep'
                    Parent = (Join-Path $Assets 'library')
                }
                @{
                    Filename = '.gitkeep'
                    Template = 'gitkeep'
                    Parent = (Join-Path $Assets 'workers')
                }
            ) | ForEach-Object {
                $Parameters = $_
                $Common = @{
                    Data = $Data
                    Force = $Force
                    TemplateDirectory = $TemplateDirectory
                    Encoding = 'utf8'
                }
                Save-TemplateData @Parameters @Common
            }
        }
        switch ($Bundler) {
            Parcel {
                if ($PSCmdlet.ShouldProcess('Add Parcel dependencies to package.json')) {
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.Parcel
                }
                if ($PSCmdlet.ShouldProcess('Copy Parcel files')) {
                    # TODO: Add code for copying files
                }
            }
            Rollup {
                if ($PSCmdlet.ShouldProcess('Add Rollup dependencies to package.json')) {
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.Rollup
                }
                if ($PSCmdlet.ShouldProcess('Save Rollup configuration file')) {
                    # TODO: Add code for copying files
                }
            }
            Snowpack {
                if ($PSCmdlet.ShouldProcess('Add Snowpack dependencies to package.json')) {
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.Snowpack
                }
                if ($PSCmdlet.ShouldProcess('Save Snowpack configuration file')) {
                    # TODO: Add code for copying files
                }
            }
            Default {
                if ($PSCmdlet.ShouldProcess('Add Webpack dependencies and tasks to package.json')) {
                    $PackageManifestData.devDependencies += $DevelopmentDependencies._workflow
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.Webpack
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.Stylelint
                    $PackageManifestData.scripts += $NpmScripts.Webpack
                }
                if ($PSCmdlet.ShouldProcess('Save Webpack configuration file')) {
                    $Parameters = @{
                        Filename = 'webpack.config.js'
                        Template = 'config/webpack'
                        TemplateDirectory = $TemplateDirectory
                        Data = $ConfigurationFileData.Webpack
                        Parent = $ApplicationDirectory
                        Force = $Force
                    }
                    Save-TemplateData @Parameters
                }
            }
        }
        switch ($Library) {
            React {
                if ($PSCmdlet.ShouldProcess('Add React dependencies to package.json')) {
                    $PackageManifestData.dependencies += $Dependencies.React.Core
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.React
                }
                if ($PSCmdlet.ShouldProcess('Copy React files')) {
                    $Source = Join-Path $ApplicationDirectory 'src'
                    $Components = Join-Path $Source 'components'
                    @(
                        @{
                            Filename = 'main.jsx'
                            Template = 'source/react/main'
                            Parent = $Source
                        }
                        @{
                            Filename = 'App.jsx'
                            Template = 'source/react/app'
                            Parent = $Components
                        }
                        @{
                            Filename = 'Header.jsx'
                            Template = 'source/react/header'
                            Parent = $Components
                        }
                        @{
                            Filename = 'Body.jsx'
                            Template = 'source/react/body'
                            Parent = $Components
                        }
                        @{
                            Filename = 'Footer.jsx'
                            Template = 'source/react/footer'
                            Parent = $Components
                        }
                    ) | ForEach-Object {
                        $Parameters = $_
                        $Common = @{
                            Data = $Data
                            Force = $Force
                            TemplateDirectory = $TemplateDirectory
                        }
                        Save-TemplateData @Parameters @Common
                    }
                }
            }
            Solid {
                if ($PSCmdlet.ShouldProcess('Add Solid dependencies to package.json')) {
                    $PackageManifestData.dependencies += $Dependencies.Solid
                }
                if ($PSCmdlet.ShouldProcess('Copy Solid files')) {
                    # TODO: Add code for copying files
                }
            }
            Default {
                if ($PSCmdlet.ShouldProcess('Copy JavaScript files')) {
                    $Source = Join-Path $ApplicationDirectory 'src'
                    $Parameters = @{
                        Filename = 'main.js'
                        Template = 'source/vanilla_main'
                        TemplateDirectory = $TemplateDirectory
                        Data = $Data
                        Parent = $Source
                        Force = $Force
                    }
                    Save-TemplateData @Parameters
                }
            }
        }
        switch ($With) {
            Cesium {
                if ($PSCmdlet.ShouldProcess('Add Cesium dependencies to package.json')) {
                    $PackageManifestData.dependencies += $Dependencies.Cesium
                    if ($Library -eq 'React') {
                        $PackageManifestData.dependencies += $Dependencies.React.Cesium
                    }
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.Cesium
                }
            }
            Reason {
                if ($Library -ne 'React' -and (-not $Silent)) {
                    '==> ReasonML works best with React. You might consider using -React.' | Write-Warning
                }
                if ($PSCmdlet.ShouldProcess('Add ReasonML dependencies to package.json')) {
                    $PackageManifestData.dependencies += $Dependencies.Reason
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.Reason
                }
                if ($PSCmdlet.ShouldProcess('Save ReasonML configuration file; Add dependencies to package.json')) {
                    $Parameters = @{
                        Filename = 'bsconfig.json'
                        Data = $ConfigurationFileData.Reason
                        Parent = $ApplicationDirectory
                        Force = $Force
                    }
                    Save-JsonData @Parameters
                }
                if ($PSCmdlet.ShouldProcess('Copy ReasonML files')) {
                    $Components = Join-Path $ApplicationDirectory 'src/components'
                    @(
                        @{
                            Filename = 'App.re'
                            Template = 'source/reason/app'
                        }
                        @{
                            Filename = 'Example.re'
                            Template = 'source/reason/example'
                        }
                    ) | ForEach-Object {
                        $Parameters = $_
                        $Common = @{
                            Data = $Data
                            Parent = $Components
                            Force = $Force
                            TemplateDirectory = $TemplateDirectory
                        }
                        Save-TemplateData @Parameters @Common
                    }
                }
            }
            Rust {
                if ($PSCmdlet.ShouldProcess('Add Rust dependencies to package.json')) {
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.Rust
                }
                if ($PSCmdlet.ShouldProcess('Copy Rust files')) {
                    $Source = Join-Path $RustDirectory 'src'
                    $Tests = Join-Path $RustDirectory 'tests'
                    @(
                        $RustDirectory
                        $Source
                        $Tests
                    ) | Get-StringPath | ForEach-Object { New-Item -Type Directory -Path $_ -Force } | Out-Null
                    @(
                        @{
                            Filename = 'Cargo.toml'
                            Template = 'config/rust'
                            Parent = $ApplicationDirectory
                        }
                        @{
                            Filename = 'Cargo.toml'
                            Template = 'config/crate'
                            Parent = $RustDirectory
                        }
                        @{
                            Filename = 'lib.rs'
                            Template = 'source/rust/lib'
                            Parent = $Source
                        }
                        @{
                            Filename = 'utils.rs'
                            Template = 'source/rust/utils'
                            Parent = $Source
                        }
                        @{
                            Filename = 'app.rs'
                            Template = 'source/rust/app'
                            Parent = $Tests
                        }
                        @{
                            Filename = 'web.rs'
                            Template = 'source/rust/web'
                            Parent = $Tests
                        }
                    ) | ForEach-Object {
                        $Parameters = $_
                        $Common = @{
                            Data = $Data
                            Force = $Force
                            TemplateDirectory = $TemplateDirectory
                        }
                        Save-TemplateData @Parameters @Common
                    }
                }
            }
            Default {}
        }
        if ($PSCmdlet.ShouldProcess('Save EditorConfig configuration file')) {
            $Parameters = @{
                Filename = '.editorconfig'
                Template = 'config/editor'
                TemplateDirectory = $TemplateDirectory
                Data = @{}
                Parent = $ApplicationDirectory
                Force = $Force
            }
            Save-TemplateData @Parameters
        }
        if ($PSCmdlet.ShouldProcess('Save package.json to application directory')) {
            $PackageManifestData = $PackageManifestData | ConvertTo-OrderedDictionary
            $PackageManifestData.dependencies = $PackageManifestData.dependencies | ConvertTo-OrderedDictionary
            $PackageManifestData.devDependencies = $PackageManifestData.devDependencies | ConvertTo-OrderedDictionary
            $PackageManifestData.scripts = $PackageManifestData.scripts | ConvertTo-OrderedDictionary
            $Parameters = @{
                Filename = 'package.json'
                Data = $PackageManifestData
                Parent = $ApplicationDirectory
                Force = $Force
            }
            Save-JsonData @Parameters
        }
        $Context = Test-ApplicationContext $ApplicationDirectory
        $Data, @{
            Context = $Context
            PackageManifestData = $PackageManifestData
        } | Invoke-ObjectMerge -Force -InPlace
        $State, @{
            Data = $Data
            Name = $Data.Name
            Parent = $Data.Parent
        } | Invoke-ObjectMerge -Force -InPlace
        $Tools = @(
            'Babel'
            'ESLint'
            'PostCSS'
            'Jest'
        )
        if ($PSCmdlet.ShouldProcess("Add development tools - $($Tools | Join-StringsWithGrammar)")) {
            Update-Application -Add $Tools -Parent $ApplicationDirectory -State $State
        }
    }
    End {
        if ($PSCmdlet.ShouldProcess('Save application state')) {
            $State | Save-State -Name $State.Name -Verbose:(-not $Silent) -Force:$Force | Out-Null
        }
        if (-not $NoInstall) {
            if ($PSCmdlet.ShouldProcess('Install dependencies')) {
                $NoErrors = if ($Context.Node.Ready) {
                    $Parameters = @{
                        Parent = $ApplicationDirectory
                        Silent = $Silent
                    }
                    Invoke-NpmInstall @Parameters
                }
            }
            if (($NoErrors -and (-not $Silent)) -or $Interactive) {
                'done' | Write-Status
            }
        }
    }
}
function Remove-Indent {
    <#
    .SYNOPSIS
    Remove indentation of multi-line (or single line) strings
    .NOTES
    Good for removing spaces added to template strings because of alignment with code.
    #>

    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $True, ValueFromPipeline = $True)]
        [AllowEmptyString()]
        [String] $From,
        [Int] $Size = 4
    )
    Process {
        $Lines = $From -split '\n'
        $Delimiter = if ($Lines.Count -eq 1) { '' } else { "`n" }
        $Callback = { $Args[0], $Args[1] -join $Delimiter }
        $Lines |
            Where-Object { $_.Length -ge $Size } |
            ForEach-Object { $_.SubString($Size) } |
            Invoke-Reduce -Callback $Callback -InitialValue ''
    }
}
function Save-State {
    <#
    .SYNOPSIS
    Save state object as CliXml in temp directory
    .EXAMPLE
    Set-State -Name 'My-App' -State @{ Data = 42 }
    .EXAMPLE
    Set-State 'My-App' @{ Data = 42 }
    .EXAMPLE
    @{ Data = 42 } | Set-State 'My-App'
    #>

    [CmdletBinding(SupportsShouldProcess = $True)]
    [OutputType([String])]
    Param(
        [Parameter(Mandatory = $True, Position = 0)]
        [String] $Name,
        [Parameter(Mandatory = $True, Position = 1, ValueFromPipeline = $True)]
        [PSObject] $State,
        [String] $Path,
        [Switch] $Force
    )
    if (-not $Path) {
        $TempRoot = if ($IsLinux) { '/tmp' } else { $Env:temp }
        $Filename = $Name | Get-StateName
        $Path = Join-Path $TempRoot "${Filename}.xml"
    }
    if ($PSCmdlet.ShouldProcess($Path)) {
        if ((Test-Path -Path $Path) -and (-not $Force)) {
            "==> ${Path} already exists. To replace the existing state, use -Force" | Write-Warning
        } else {
            $State.Name = $Name
            $State | Export-Clixml -Path $Path
            "==> Saved state to ${Path}" | Write-Verbose
        }
    } else {
        "==> Would have saved state to ${Path}" | Write-Verbose
    }
    $Path
}
function Save-JsonData {
    <#
    .SYNOPSIS
    Utility function for saving JSON data.
    Provides warning message when file already exists and -Force is not used.
    #>

    [CmdletBinding()]
    Param(
        [PSObject] $Data,
        [String] $Parent,
        [String] $Filename,
        [Switch] $Force
    )
    $Path = Join-Path $Parent $Filename
    $Message = "==> [WARN] ${Filename} already exists. Please either delete ${Filename} or re-run this command with the -Force parameter."
    if (-not (Test-Path -Path $Path) -or $Force) {
        $Data |
            ConvertTo-Json -Depth 100 |
            ForEach-Object { $_ -replace '\\\\\\', '\' } |
            ForEach-Object { $_ -replace '\\u003c', '<' } |
            ForEach-Object { $_ -replace '\\u003e', '>' } |
            ForEach-Object { $_ -replace '\\u0026', '&' } |
            Format-Json |
            Out-File -FilePath $Path -Encoding ascii
    } else {
        $Message | Write-Warning
    }
}
function Save-TemplateData {
    <#
    .SYNOPSIS
    Utility function for copying template file to the application directory.
    Provides warning message when file already exists and -Force is not used.
    .PARAMETER Template
    Name of input template file (located within TemplateDirectory)
    .PARAMETER Parent
    Parent directory of where template file should be saved
    .PARAMETER Filename
    Name of output file
    #>

    [CmdletBinding()]
    Param(
        [PSObject] $Data,
        [ValidateScript( { Test-Path $_ })]
        [String] $TemplateDirectory = (Get-Location).Path,
        [String] $Template,
        [String] $Parent,
        [String] $Filename,
        [String] $Encoding = 'ascii',
        [Switch] $Force
    )
    $Path = Join-Path $Parent $Filename
    $Message = "==> [WARN] ${Filename} already exists. Please either delete ${Filename} or re-run this command with the -Force parameter."
    if (-not (Test-Path -Path $Path) -or $Force) {
        $Parameters = @{
            File = (Join-Path $TemplateDirectory $Template)
            Data = $Data
            NoData = ($Data.Count -eq 0)
        }
        New-Template @Parameters | Out-File -FilePath $Path -Encoding $Encoding
    } else {
        $Message | Write-Warning
    }
}
function Test-ApplicationContext {
    <#
    .SYNOPSIS
    Test various environment conditions and return an object with the results.
    .EXAMPLE
    $Results = Test-ApplicationContext
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    Param(
        [Parameter(Position = 0, ValueFromPipeline = $True)]
        [ValidateScript( { Test-Path $_ })]
        [String] $Parent = (Get-Location).Path
    )
    Begin {
        function Test-SomeExist {
            Param(
                [Parameter(Position = 0)]
                [String[]] $PathList
            )
            foreach ($Path in $PathList) {
                if (Test-Path -Path (Join-Path $Parent $Path)) {
                    "==> [INFO] Found ${Path}" | Write-Verbose
                    return $True
                }
            }
            $False
        }
        $BABEL_CONFIG_NAMES = @(
            'babel.config.json'
            'babel.config.js'
            'babel.config.cjs'
            'babel.config.mjs'
            '.babelrc'
            '.babelrc.json'
            '.babelrc.js'
            '.babelrc.cjs'
            '.babelrc.mjs'
        )
        $ESLINT_CONFIG_NAMES = @(
            '',
            '.js',
            '.cjs',
            '.yaml',
            '.yml',
            '.json'
        ) | ForEach-Object { ".eslintrc${_}" }
    }
    Process {
        $Installed = @{
            Cargo = (Test-Command 'cargo')
            Rustc = (Test-Command 'rustc')
            Npm = (Test-Command 'npm')
        }
        $FileExists = @{
            CargoToml = (Test-SomeExist 'Cargo.toml')
            PackageJson = (Test-SomeExist 'package.json')
            BabelConfig = (Test-SomeExist $BABEL_CONFIG_NAMES)
            EslintConfig = (Test-SomeExist $ESLINT_CONFIG_NAMES)
            PostcssConfig = (Test-SomeExist 'postcss.config.js')
            WebpackConfig = (Test-SomeExist 'webpack.config.js')
        }
    }
    End {
        @{
            Rust = @{
                Ready = ($Installed.Cargo -and $Installed.Rustc -and $FileExists.CargoToml)
                Manifest = $FileExists.CargoToml
                PackageManager = $Installed.Cargo
                Compiler = $Installed.Rustc
                Linter = $False
            }
            Node = @{
                Ready = ($Installed.Npm -and $FileExists.PackageJson)
                Manifest = $FileExists.PackageJson
                PackageManager = $Installed.Npm
                Compiler = $FileExists.BabelConfig
                Linter = $FileExists.EslintConfig
            }
            CSS = @{
                Ready = $FileExists.PostcssConfig
                Manifest = $False
                PackageManager = $False
                Compiler = $FileExists.PostcssConfig
                Linter = $False
            }
        }
    }
}
function Update-Application {
    <#
    .SYNOPSIS
    Update a dependency of a web or desktop application created using New-WebApplication or New-DesktopApplication, respectively.
    #>

    [CmdletBinding(SupportsShouldProcess = $True)]
    Param(
        [Switch] $Web,
        [Switch] $Desktop,
        [ValidateSet(
            'Babel',
            'ESLint',
            'Jest',
            'PostCSS'
        )]
        [String[]] $Add,
        [ValidateSet(
            'Babel',
            'ESLint'
        )]
        [String[]] $Remove,
        [ValidateScript( { Test-Path $_ })]
        [String] $Parent = (Get-Location).Path,
        [ApplicationState] $State,
        [Switch] $Force
    )
    Begin {
        $Data = if ($State) {
            $PackageManifestData = $State.Data.PackageManifestData
            $State.Data
        } else {
            $PackageManifestData = Get-Content -Path (Join-Path $Parent 'package.json') -Raw | ConvertFrom-Json
            ($PackageManifestData.name | Get-State).Data
        }
        $UseReact = $Data.Library -eq 'React'
        $ApplicationDirectory = Join-Path $Data.Parent $Data.Name
        $TemplateDirectory = Join-Path $PSScriptRoot '../src/templates'
        $PackageManifestAugment = @{
            Jest = @{
                jest = @{
                    testMatch = @(
                        '**/__tests__/**/*.(e2e|test).[jt]s?(x)'
                    )
                    setupFilesAfterEnv = @(
                        '<rootDir>/__tests__/setup.js'
                    )
                    watchPlugins = @(
                        'jest-watch-typeahead/filename'
                        'jest-watch-typeahead/testname'
                    )
                }
            }
            Postcss = @{}
        }
        $ConfigurationFileData = @{
            Babel = @{
                Core = @{
                    plugins = @(
                        '@babel/plugin-transform-runtime'
                        '@babel/plugin-proposal-class-properties'
                        '@babel/plugin-proposal-export-default-from'
                        '@babel/plugin-proposal-optional-chaining'
                    )
                    presets = @(
                        '@babel/preset-env'
                        'babel-preset-minify'
                    )
                }
                React = @{
                    plugins = @(
                        'react-hot-loader/babel'
                        '@babel/plugin-transform-runtime'
                        '@babel/plugin-proposal-class-properties'
                        '@babel/plugin-proposal-export-default-from'
                        '@babel/plugin-proposal-optional-chaining'
                    )
                    presets = @(
                        '@babel/preset-env'
                        'babel-preset-minify'
                        , @(
                            '@babel/preset-react'
                            @{
                                runtime = 'automatic'
                            }
                        )
                    )
                }
            }
            Eslint = @{
                Core = @{
                    env = @{
                        es6 = $True
                        jest = $True
                        browser = $True
                    }
                    extends = @(
                        'omaha-prime-grade'
                        'plugin:import/errors'
                        'plugin:import/warnings'
                        'plugin:promise/recommended'
                        'plugin:jsx-a11y/recommended'
                    )
                    parser = 'babel-eslint'
                }
                React = @{
                    env = @{
                        es6 = $True
                        jest = $True
                        browser = $True
                    }
                    extends = @(
                        'omaha-prime-grade'
                        'plugin:import/errors'
                        'plugin:import/warnings'
                        'plugin:promise/recommended'
                        'plugin:react/recommended'
                        'plugin:jsx-a11y/recommended'
                    )
                    parser = 'babel-eslint'
                    parserOptions = @{
                        ecmaFeatures = @{
                            jsx = $True
                        }
                    }
                    plugins = @(
                        'jsx-a11y'
                    )
                    rules = @{
                        'react/jsx-uses-react' = 'off'
                        'react/react-in-jsx-scope' = 'off'
                    }
                    settings = @{
                        react = @{
                            version = 'detect'
                        }
                    }
                }
            }
            Jest = @{
                Core = $Data
                React = @{}
            }
            Postcss = @{
                Core = @{
                    map = $True
                    parser = 'postcss-safe-parser'
                    plugins = @(
                        , @(
                            'stylelint'
                            @{
                                config = @{
                                    extends = 'stylelint-config-recommended'
                                }
                            }
                        )
                        'postcss-import'
                        'postcss-preset-env'
                        'cssnano'
                        'postcss-reporter'
                    )
                }
                React = @{}
            }
            Reason = @{
                Core = @{}
                React = @{
                    'name' = $Data.Name
                    'bs-dependencies' = @(
                        '@rescript/react'
                    )
                    'bsc-flags' = @(
                        '-bs-super-errors'
                    )
                    'namespace' = $True
                    'package-specs' = @(
                        @{
                            'module' = 'es6'
                            'in-source' = $True
                        }
                    )
                    'ppx-flags' = @()
                    'reason' = @{
                        'react-jsx' = 3
                    }
                    'refmt' = 3
                    'sources' = @(
                        @{
                            'dir' = $Data.SourceDirectory
                            'subdirs' = $True
                        }
                    )
                    'suffix' = '.bs.js'
                }
            }
        }
        $DevelopmentDependencies = @{
            _workflow = @{
                'cpy-cli' = '*'
                'del-cli' = '*'
                'npm-run-all' = '*'
                'watch' = '*'
            }
            Babel = @{
                Core = @{
                    '@babel/cli' = '^7.17.10'
                    '@babel/core' = '^7.18.0'
                    '@babel/plugin-proposal-class-properties' = '^7.17.12'
                    '@babel/plugin-proposal-export-default-from' = '^7.17.12'
                    '@babel/plugin-proposal-optional-chaining' = '^7.17.12'
                    '@babel/plugin-transform-runtime' = '^7.18.0'
                    '@babel/preset-env' = '^7.18.0'
                    '@babel/runtime' = '^7.18.0'
                    'babel-preset-minify' = '^0.5.2'
                }
                React = @{
                    '@babel/preset-react' = '^7.17.12'
                }
            }
            Cesium = @{
                Core = @{}
                React = @{}
            }
            Eslint = @{
                Core = @{
                    'eslint' = '^7.32.0'
                    'babel-eslint' = '^10.1.0'
                    'eslint-config-omaha-prime-grade' = '^14.0.1'
                    'eslint-plugin-import' = '^2.26.0'
                    'eslint-plugin-jsx-a11y' = '^6.5.1'
                    'eslint-plugin-promise' = '*'
                }
                React = @{
                    'eslint-plugin-react' = '^7.30.0'
                }
            }
            Jest = @{
                Core = @{
                    'jest' = '^28.1.0'
                    'babel-jest' = '^28.1.0'
                    'jest-watch-typeahead' = '^1.1.0'
                }
                React = @{}
            }
            Postcss = @{
                Core = @{
                    'cssnano' = '^5.1.9'
                    'postcss' = '^8.4.14'
                    'postcss-cli' = '^9.1.0'
                    'postcss-import' = '^14.1.0'
                    'postcss-preset-env' = '^7.6.0'
                    'postcss-reporter' = '^7.0.5'
                    'postcss-safe-parser' = '^6.0.0'
                }
                React = @{}
            }
            Reason = @{
                Core = @{}
                React = @{
                    'rescript' = '*'
                }
            }
            Stylelint = @{
                Core = @{
                    'style-loader' = '^3.3.1'
                    'stylelint' = '^14.8.3'
                    'stylelint-config-recommended' = '^7.0.0'
                }
                React = @{}
            }
        }
        $NpmScripts = @{
            Babel = @{}
            Eslint = @{
                'lint' = 'eslint . -c ./.eslintrc.json --ext .js,.jsx --fix'
                'lint:ing' = "watch `"npm run lint`" $($Data.SourceDirectory)"
                'lint:tests' = 'eslint __tests__/**/*.js -c ./.eslintrc.json --fix --no-ignore'
            }
            Jest = @{
                'test' = 'jest .*.test.js --coverage'
                'test:ing' = 'npm test -- --watchAll'
            }
            Postcss = @{}
        }
    }
    Process {
        switch ($Add) {
            Babel {
                $ToolName = $_
                $ConfigName = 'babel.config.json'
                if ($PSCmdlet.ShouldProcess("Save ${ToolName} configuration file; Add tasks and dependencies to package.json")) {
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.$ToolName.Core
                    $PackageManifestData.scripts += $NpmScripts.$ToolName
                    $Config = if ($UseReact) {
                        $PackageManifestData.devDependencies += $DevelopmentDependencies.$ToolName.React
                        $ConfigurationFileData.$ToolName.React
                    } else {
                        $ConfigurationFileData.$ToolName.Core
                    }
                    $Parameters = @{
                        Filename = $ConfigName
                        Data = $Config
                        Parent = $ApplicationDirectory
                        Force = $Force
                    }
                    Save-JsonData @Parameters
                }
            }
            ESLint {
                $ToolName = $_
                $ConfigName = '.eslintrc.json'
                if ($PSCmdlet.ShouldProcess("Save ${ToolName} configuration file; Add tasks and dependencies to package.json")) {
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.$ToolName.Core
                    $PackageManifestData.scripts += $NpmScripts.$ToolName
                    $Config = if ($UseReact) {
                        $PackageManifestData.devDependencies += $DevelopmentDependencies.$ToolName.React
                        $ConfigurationFileData.$ToolName.React
                    } else {
                        $ConfigurationFileData.$ToolName.Core
                    }
                    $Parameters = @{
                        Filename = $ConfigName
                        Data = $Config
                        Parent = $ApplicationDirectory
                        Force = $Force
                    }
                    Save-JsonData @Parameters
                }
            }
            Jest {
                $Toolname = $_
                if ($PSCmdlet.ShouldProcess('Copy Jest files; Add dependencies and tasks to package.json')) {
                    $Config = $ConfigurationFileData.$ToolName.Core
                    $PackageManifestData += $PackageManifestAugment.$ToolName
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.$ToolName.Core
                    $PackageManifestData.scripts += $NpmScripts.$ToolName
                    $Parent = Join-Path $ApplicationDirectory '__tests__'
                    if ($UseReact) {
                        # Do nothing
                    }
                    @(
                        @{
                            Filename = 'setup.js'
                            Template = 'source/jest_setup'
                        }
                        @{
                            Filename = 'example.test.js'
                            Template = 'source/jest_example'
                        }
                    ) | ForEach-Object {
                        $Parameters = $_
                        $Common = @{
                            TemplateDirectory = $TemplateDirectory
                            Data = $Config
                            Parent = $Parent
                            Force = $Force
                        }
                        Save-TemplateData @Parameters @Common
                    }
                }
            }
            PostCSS {
                $ToolName = 'PostCSS'
                $ConfigName = 'postcss.config.js'
                if ($PSCmdlet.ShouldProcess('Save PostCSS configuration file; Add dependencies to package.json')) {
                    $Config = $ConfigurationFileData.$ToolName.Core
                    $PackageManifestData += $PackageManifestAugment.$ToolName
                    $PackageManifestData.devDependencies += $DevelopmentDependencies.$ToolName.Core
                    $PackageManifestData.scripts += $NpmScripts.$ToolName
                    $Parent = $ApplicationDirectory
                    if ($UseReact) {
                        $Config, $ConfigurationFileData.$ToolName.React | Invoke-ObjectMerge -InPlace -Force
                    }
                    @(
                        @{
                            Filename = $ConfigName
                            Template = 'config/postcss'
                        }
                    ) | ForEach-Object {
                        $Parameters = $_
                        $Common = @{
                            TemplateDirectory = $TemplateDirectory
                            Data = $Config
                            Parent = $Parent
                            Force = $Force
                        }
                        Save-TemplateData @Parameters @Common
                    }
                }
            }
            Default {
                '==> [WARN] Unknown application update requested' | Write-Warning
            }
        }
        switch ($Remove) {
            Babel {
                "==> [WARN] Removing ${_} has not yet been implemented" | Write-Color -Yellow
            }
            ESLint {
                "==> [WARN] Removing ${_} has not yet been implemented" | Write-Color -Yellow
            }
        }
    }
    End {
        if ($PSCmdlet.ShouldProcess('Save package.json to application directory')) {
            $PackageManifestData = $PackageManifestData | ConvertTo-OrderedDictionary
            $PackageManifestData.dependencies = $PackageManifestData.dependencies | ConvertTo-OrderedDictionary
            $PackageManifestData.devDependencies = $PackageManifestData.devDependencies | ConvertTo-OrderedDictionary
            $PackageManifestData.scripts = $PackageManifestData.scripts | ConvertTo-OrderedDictionary
            $Parameters = @{
                Filename = 'package.json'
                Data = $PackageManifestData
                Parent = $ApplicationDirectory
                Force = $True
            }
            Save-JsonData @Parameters
        }
    }
}
function Write-Status {
    <#
    .SYNOPSIS
    Print ASCII status message
    .EXAMPLE
    'pass' | Write-Status
    #>

    [CmdletBinding()]
    [OutputType([String])]
    Param(
        [Parameter(Position = 0, ValueFromPipeline = $True)]
        [ValidateSet('done', 'fail', 'pass')]
        [String] $Status = 'done',
        [String] $Color,
        [Switch] $PassThru
    )
    if (-not $Color) {
        $Color = switch ($Status) {
            'done' { 'Gray' }
            'fail' { 'Red' }
            'pass' { 'Green' }
        }
    }
    $Message = switch ($Status) {
        'done' {
            @(
                '▄▀█ █░░ █░░   █▀▄ █▀█ █▄░█ █▀▀ █'
                '█▀█ █▄▄ █▄▄   █▄▀ █▄█ █░▀█ ██▄ ▄'
            )
        }
        'fail' {
            @(
                '█▀▀ ▄▀█ █ █░░'
                '█▀░ █▀█ █ █▄▄'
            )
        }
        'pass' {
            @(
                '█▀█ ▄▀█ █▀ █▀'
                '█▀▀ █▀█ ▄█ ▄█'
            )
        }
    }
    if ($PassThru) {
        $Message -join "`n"
    } else {
        '' | Write-Host
        $Message | ForEach-Object {
            $_ | Write-Host -ForegroundColor $Color
        }
        '' | Write-Host
    }
}