src/application.ps1

class ApplicationState {
    [String] $Id = (New-Guid)
    [Bool] $Continue = $True
    [String] $Name = 'Application Name'
    $Data
}
function ConvertTo-PowershellSyntax {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'DataVariableName')]
    [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 -Id 'abc-def-ghi'
    .EXAMPLE
    $State = 'abc-def-ghi' | Get-State
    #>

    [CmdletBinding()]
    Param(
        [Parameter(Position = 0, ValueFromPipeline = $True)]
        [String] $Id,
        [AllowEmptyString()]
        [String] $Path
    )
    if ($Path.Length -gt 0 -and (Test-Path $Path)) {
        "==> Resolved $Path" | Write-Verbose
    } else {
        $TempRoot = if ($IsLinux) { '/tmp' } else { $Env:temp }
        $Path = Join-Path $TempRoot "state-$Id.xml"
    }
    "==> Loading state from $Path" | Write-Verbose
    Import-Clixml -Path $Path
}
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
    # Make a simple app
    # Initialize your app - $Init is only run once
    $Init = {
        '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
    .EXAMPLE
    # Make a simple app with state
    # Note: State is passed to Init, Loop, ShouldContinue, and BeforeNext
    New-ApplicationTemplate -Save
    .EXAMPLE
    # Applications trigger events throughout their lifecycle which can be listened to (most commonly within the Init scriptblock).
    { say 'Hello' } | on 'application:init'
    { say 'Wax on' } | on 'application:loop:before'
    { say 'Wax off' } | on 'application:loop:after'
    { say 'Goodbye' } | on 'application:exit'
    # 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] $Id,
        [ScriptBlock] $ShouldContinue,
        [ScriptBlock] $BeforeNext,
        [Switch] $ClearState,
        [Switch] $SingleRun,
        [Switch] $NoCleanup
    )
    if ($Id.Length -gt 0) {
        $TempRoot = if ($IsLinux) { '/tmp' } else { $Env:temp }
        $Path = Join-Path $TempRoot "state-$Id.xml"
        if ($ClearState -and (Test-Path $Path)) {
            Remove-Item $Path
        }
        if (Test-Path $Path) {
            "==> Resolved state with ID: $Id" | Write-Verbose
            try {
                [ApplicationState]$State = Get-State $Id
                $State.Id = $Id
            } catch {
                "==> Failed to get state with ID: $Id" | Write-Verbose
                $State = [ApplicationState]@{ Id = $Id }
            }
        } else {
            $State.Id = $Id
        }
    }
    if (-not $State) {
        $State = [ApplicationState]@{}
    }
    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: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 New-ApplicationTemplate {
    <#
    .SYNOPSIS
    Return boilerplate string of a "scrapp" ("script" + "app")
    .EXAMPLE
    New-ApplicationTemplate | Out-File 'my-app.ps1'
    #>

    [CmdletBinding()]
    Param()
    $Snippet = if (-not $IsLinux) {
        "{
            Invoke-Speak 'Goodbye'
            `$Id = `$Event.MessageData.State.Id
            `"``nApplication ID: `$Id``n`" | Write-Color -Magenta
        } | Invoke-ListenTo 'application:exit' | Out-Null"

    } else {
        ''
    }
    "
    #Requires -Modules Prelude
    [CmdletBinding()]
    Param(
        [String] `$Id = 'app',
        [Switch] `$Clear
    )
    $Empty
    `$InitialState = @{ Data = 0 }
    $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 My-App}}' | Write-Label -Color Gray -Indent 2 -NewLine
        $Snippet
        '' | Write-Color
        Start-Sleep 2
    }
    $Empty
    `$Loop = {
        Clear-Host
        `$State = `$Args[0]
        `$Count = `$State.Data
        `"Current count is {{#green `$Count}}`" | Write-Color -Cyan
        `$State.Data++
        Save-State `$State.Id `$State | Out-Null
        Start-Sleep 1
    }
    $Empty
    Invoke-RunApplication `$Init `$Loop `$InitialState -Id `$Id -ClearState:`$Clear
    "
 | Remove-Indent
}
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)
    .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 -Data parameter cause template to return formatted string instead of template function
    .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
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '')]
    [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 {
        $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 + ') | Write-Output)')
                    $Binding = $DefaultValues, $Binding | Invoke-ObjectMerge
                    try {
                        $Powershell = [Powershell]::Create()
                        $Powershell.AddScript($Renderer).AddParameter('Binding', $Binding).AddParameter('Script', $Block).Invoke()
                    } finally {
                        if ($Powershell) {
                            $Powershell.Dispose()
                        }
                    }
                }
                Default { "`${${Variable}}" }
            }
        }
    }
    Process {
        if ($File) {
            $Path = Resolve-Path $File
            $Template = Get-Content $Path -Raw
        }
        $TemplateScriptBlock = [ScriptBlock]::Create('$("' + [Regex]::Replace($Template, $Pattern, $Evaluator) + '" | Write-Output)')
        if (($Binding.Count -gt 0) -or $NoData) {
            if ($PassThru) {
                return $Template
                exit
            }
            $Binding = $DefaultValues, $Binding | Invoke-ObjectMerge
            try {
                $Powershell = [Powershell]::Create()
                $Powershell.AddScript($Renderer).AddParameter('Binding', $Binding).AddParameter('Script', $TemplateScriptBlock).Invoke()
            } finally {
                if ($Powershell) {
                    $Powershell.Dispose()
                }
            }
        } else {
            {
                Param(
                    [Parameter(Position = 0, ValueFromPipeline = $True)]
                    [Alias('Data')]
                    [Hashtable] $Binding = @{},
                    [Switch] $PassThru
                )
                if ($PassThru) {
                    return $Template
                    exit
                }
                $Binding = $DefaultValues, $Binding | Invoke-ObjectMerge
                try {
                    $Powershell = [Powershell]::Create()
                    $Powershell.AddScript($Renderer).AddParameter('Binding', $Binding).AddParameter('Script', $TemplateScriptBlock).Invoke()
                } finally {
                    if ($Powershell) {
                        $Powershell.Dispose()
                    }
                }
            }.GetNewClosure()
        }
    }
}
function Remove-Indent {
    <#
    .SYNOPSIS
    Remove indentation of multi-line (or single line) strings
    ==> Good for removing spaces added to template strings because of alignment with code.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Size')]
    [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 -Id 'my-app -State @{ Data = 42 }
    .EXAMPLE
    Set-State 'my-app' @{ Data = 42 }
    .EXAMPLE
    @{ Data = 42 } | Set-State 'my-app'
    #>

    [CmdletBinding(SupportsShouldProcess = $True)]
    Param(
        [Parameter(Mandatory = $True, Position = 0)]
        [String] $Id,
        [Parameter(Mandatory = $True, Position = 1, ValueFromPipeline = $True)]
        [PSObject] $State,
        [String] $Path
    )
    if (-not $Path) {
        $TempRoot = if ($IsLinux) { '/tmp' } else { $Env:temp }
        $Path = Join-Path $TempRoot "state-$Id.xml"
    }
    if ($PSCmdlet.ShouldProcess($Path)) {
        $State.Id = $Id
        $State | Export-Clixml -Path $Path
        "==> Saved state to $Path" | Write-Verbose
    } else {
        "==> Would have saved state to $Path" | Write-Verbose
    }
    $Path
}