PSJinja.psm1

#Requires -Version 5.1
Set-StrictMode -Version Latest

#region --- Tokenizer ---

function ConvertTo-JinjaTokenList {
    <#
    .SYNOPSIS
        Tokenizes a Jinja2 template string into a flat list of typed tokens.
    .DESCRIPTION
        Scans the template for Jinja2 delimiters and splits it into TEXT, VAR,
        BLOCK, and COMMENT tokens that can be consumed by the parser.
    .PARAMETER Template
        The raw template string to tokenize.
    .OUTPUTS
        System.Collections.Generic.List[hashtable]
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Generic.List[hashtable]])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Template
    )

    $list = [System.Collections.Generic.List[hashtable]]::new()
    if ([string]::IsNullOrEmpty($Template)) {
        return , $list
    }

    $re   = [System.Text.RegularExpressions.Regex]::new(
        '(?s)\{\{.*?\}\}|\{%.*?%\}|\{#.*?#\}',
        [System.Text.RegularExpressions.RegexOptions]::None
    )
    $last = 0

    foreach ($m in $re.Matches($Template)) {
        if ($m.Index -gt $last) {
            [void]$list.Add(@{ Type = 'TEXT'; Value = $Template.Substring($last, $m.Index - $last) })
        }

        $raw = $m.Value
        if ($raw.StartsWith('{{')) {
            [void]$list.Add(@{ Type = 'VAR'; Value = $raw.Substring(2, $raw.Length - 4).Trim() })
        }
        elseif ($raw.StartsWith('{%')) {
            # Strip optional whitespace-control dashes and re-trim
            $blockVal = $raw.Substring(2, $raw.Length - 4).Trim()
            $blockVal = $blockVal.TrimStart('-').TrimEnd('-').Trim()
            [void]$list.Add(@{ Type = 'BLOCK'; Value = $blockVal })
        }
        elseif ($raw.StartsWith('{#')) {
            [void]$list.Add(@{ Type = 'COMMENT' })
        }

        $last = $m.Index + $m.Length
    }

    if ($last -lt $Template.Length) {
        [void]$list.Add(@{ Type = 'TEXT'; Value = $Template.Substring($last) })
    }

    return , $list
}

#endregion

#region --- Value / Condition Resolution ---

function Get-JinjaVariableValue {
    <#
    .SYNOPSIS
        Resolves a dotted-path / array-index expression from the data context.
    .DESCRIPTION
        Walks segment-by-segment through the expression, supporting hashtable
        key access, PSCustomObject property access, and integer array indexes
        in the form name[0].
    .PARAMETER Expression
        The variable path to resolve (e.g. "user.address.city" or "items[2]").
    .PARAMETER Context
        The current data context hashtable.
    .OUTPUTS
        System.Object
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Expression,

        [Parameter(Mandatory = $true)]
        [hashtable]$Context
    )

    $current = $Context
    $parts   = $Expression.Trim() -split '\.'

    foreach ($part in $parts) {
        if ($null -eq $current) { return $null }

        # Handle array index notation: segment[N]
        if ($part -match '^(.+)\[(\d+)\]$') {
            $propName    = $Matches[1]
            $arrayIndex  = [int]$Matches[2]

            if ($current -is [hashtable]) {
                if (-not $current.ContainsKey($propName)) { return $null }
                $current = $current[$propName]
            }
            elseif ($current -is [System.Management.Automation.PSCustomObject]) {
                $prop = $current.PSObject.Properties[$propName]
                $current = if ($null -ne $prop) { $prop.Value } else { return $null }
            }
            else {
                try { $current = $current.$propName } catch { return $null }
            }

            if ($null -eq $current) { return $null }
            $arr = @($current)
            if ($arrayIndex -lt $arr.Count) { return $arr[$arrayIndex] } else { return $null }
        }
        else {
            if ($current -is [hashtable]) {
                if (-not $current.ContainsKey($part)) { return $null }
                $current = $current[$part]
            }
            elseif ($current -is [System.Management.Automation.PSCustomObject]) {
                $prop = $current.PSObject.Properties[$part]
                $current = if ($null -ne $prop) { $prop.Value } else { return $null }
            }
            else {
                try { $current = $current.$part } catch { return $null }
            }
        }
    }

    return $current
}

function Invoke-JinjaFilter {
    <#
    .SYNOPSIS
        Applies a single Jinja2 filter to a value.
    .DESCRIPTION
        Supported filters: upper, lower, title, trim, length, default, join,
        first, last, sort, reverse, capitalize, replace, int, float, string.
    .PARAMETER Value
        The value to filter.
    .PARAMETER Filter
        The filter name, optionally with arguments: name or name('arg').
    .OUTPUTS
        System.Object
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object]$Value,

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

    if (-not ($Filter -match '^(\w+)(?:\((.+)\))?$')) {
        return $Value
    }

    $filterName = $Matches[1].ToLower()
    $filterArg  = if ($Matches.Count -gt 2 -and $null -ne $Matches[2]) { $Matches[2] } else { $null }

    switch ($filterName) {
        'upper'      {
            if ($null -eq $Value) { return '' } else { return $Value.ToString().ToUpper() }
        }
        'lower'      {
            if ($null -eq $Value) { return '' } else { return $Value.ToString().ToLower() }
        }
        'capitalize' {
            if ($null -eq $Value) { return '' }
            $s = $Value.ToString()
            if ($s.Length -eq 0) { return $s } else { return $s[0].ToString().ToUpper() + $s.Substring(1).ToLower() }
        }
        'title'      {
            if ($null -eq $Value) { return '' }
            return (($Value.ToString() -split '\s+') | ForEach-Object {
                if ($_.Length -gt 0) { $_.Substring(0,1).ToUpper() + $_.Substring(1).ToLower() } else { $_ }
            }) -join ' '
        }
        'trim'       {
            if ($null -eq $Value) { return '' } else { return $Value.ToString().Trim() }
        }
        'length'     {
            if ($null -eq $Value) { return 0 } else { return @($Value).Count }
        }
        'default'    {
            if ($null -eq $Value -or $Value -eq '') {
                if ($null -ne $filterArg) {
                    $stripped = $filterArg.Trim().Trim('"').Trim("'")
                    return $stripped
                }
                return ''
            }
            return $Value
        }
        'join'       {
            if ($null -eq $Value) { return '' }
            $sep = if ($null -ne $filterArg) { $filterArg.Trim().Trim('"').Trim("'") } else { '' }
            return (@($Value) -join $sep)
        }
        'first'      {
            if ($null -eq $Value) { return $null }
            $arr = @($Value)
            if ($arr.Count -gt 0) { return $arr[0] } else { return $null }
        }
        'last'       {
            if ($null -eq $Value) { return $null }
            $arr = @($Value)
            if ($arr.Count -gt 0) { return $arr[-1] } else { return $null }
        }
        'sort'       {
            if ($null -eq $Value) { return @() }
            return @($Value | Sort-Object)
        }
        'reverse'    {
            if ($null -eq $Value) { return @() }
            $arr = @($Value)
            [array]::Reverse($arr)
            return $arr
        }
        'replace'    {
            if ($null -eq $Value) { return '' }
            if ($null -ne $filterArg -and $filterArg -match "^['""](.+)['""],\s*['""](.+)['""]$") {
                return $Value.ToString().Replace($Matches[1], $Matches[2])
            }
            return $Value
        }
        'int'        {
            try { return [int]$Value } catch { return 0 }
        }
        'float'      {
            try { return [double]$Value } catch { return 0.0 }
        }
        'string'     {
            if ($null -eq $Value) { return '' } else { return $Value.ToString() }
        }
        default      {
            Write-Warning "PSJinja: Unknown filter '$filterName'"
            return $Value
        }
    }
}

function Get-JinjaValue {
    <#
    .SYNOPSIS
        Evaluates a Jinja2 expression (variable, literal, or filtered expression).
    .DESCRIPTION
        Handles string/int/float/bool/null literals, variable lookups, and
        pipe-separated filter chains such as name | upper | trim.
    .PARAMETER Expr
        The expression string.
    .PARAMETER Context
        The current data context hashtable.
    .OUTPUTS
        System.Object
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Expr,

        [Parameter(Mandatory = $true)]
        [hashtable]$Context
    )

    # Split on pipe to separate base expression from filter chain
    $segments  = [regex]::Split($Expr.Trim(), '\s*\|\s*')
    $baseExpr  = $segments[0].Trim()
    $filters   = if ($segments.Count -gt 1) { $segments[1..($segments.Count - 1)] } else { @() }

    # Resolve base value
    $val = Resolve-JinjaLiteral -Expr $baseExpr -Context $Context

    # Apply filters in order
    foreach ($f in $filters) {
        $fTrimmed = $f.Trim()
        if (-not [string]::IsNullOrEmpty($fTrimmed)) {
            $val = Invoke-JinjaFilter -Value $val -Filter $fTrimmed
        }
    }

    return $val
}

function Resolve-JinjaLiteral {
    <#
    .SYNOPSIS
        Resolves a base expression to a literal value or context variable.
    .PARAMETER Expr
        A single, un-filtered expression.
    .PARAMETER Context
        The current data context hashtable.
    .OUTPUTS
        System.Object
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Expr,

        [Parameter(Mandatory = $true)]
        [hashtable]$Context
    )

    $e = $Expr.Trim()

    # String literals (single or double quoted)
    if ($e -match '^"(.*)"$' -or $e -match "^'(.*)'$") {
        return $Matches[1]
    }

    # Integer literal
    if ($e -match '^-?\d+$') {
        return [int]$e
    }

    # Float literal
    if ($e -match '^-?\d+\.\d+$') {
        return [double]$e
    }

    # Boolean literals
    if ($e -eq 'true'  -or $e -eq '$true')  { return $true  }
    if ($e -eq 'false' -or $e -eq '$false') { return $false }

    # None / null
    if ($e -eq 'none' -or $e -eq 'null' -or $e -eq '$null') { return $null }

    # Variable lookup
    return Get-JinjaVariableValue -Expression $e -Context $Context
}

function Test-JinjaCondition {
    <#
    .SYNOPSIS
        Evaluates a Jinja2 condition string to a boolean result.
    .DESCRIPTION
        Supports: PowerShell comparison operators (-eq, -ne, -gt, -ge, -lt, -le,
        -like, -notlike, -match, -notmatch, -in, -notin, -contains, -notcontains),
        the "not" prefix, and simple truthy/falsy evaluation.
    .PARAMETER Condition
        The condition expression string.
    .PARAMETER Context
        The current data context hashtable.
    .OUTPUTS
        System.Boolean
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Condition,

        [Parameter(Mandatory = $true)]
        [hashtable]$Context
    )

    $cond = $Condition.Trim()

    # Parenthesised expression — unwrap and recurse
    if ($cond -match '^\((.+)\)$') {
        return Test-JinjaCondition -Condition $Matches[1] -Context $Context
    }

    # "not ..." negation
    if ($cond -match '^not\s+(.+)$') {
        return -not (Test-JinjaCondition -Condition $Matches[1] -Context $Context)
    }

    # Comparison operators
    $compPattern = '^(.+?)\s+(-eq|-ne|-gt|-ge|-lt|-le|-like|-notlike|-match|-notmatch|-in|-notin|-contains|-notcontains)\s+(.+)$'
    if ($cond -match $compPattern) {
        $left  = Get-JinjaValue -Expr $Matches[1].Trim() -Context $Context
        $op    = $Matches[2]
        $right = Get-JinjaValue -Expr $Matches[3].Trim() -Context $Context

        switch ($op) {
            '-eq'          { return $left -eq $right          }
            '-ne'          { return $left -ne $right          }
            '-gt'          { return $left -gt $right          }
            '-ge'          { return $left -ge $right          }
            '-lt'          { return $left -lt $right          }
            '-le'          { return $left -le $right          }
            '-like'        { return $left -like $right        }
            '-notlike'     { return $left -notlike $right     }
            '-match'       { return $left -match $right       }
            '-notmatch'    { return $left -notmatch $right    }
            '-in'          { return $left -in $right          }
            '-notin'       { return $left -notin $right       }
            '-contains'    { return $left -contains $right    }
            '-notcontains' { return $left -notcontains $right }
            default        { return $false }
        }
    }

    # Simple truthy / falsy evaluation
    $val = Get-JinjaValue -Expr $cond -Context $Context
    if ($null -eq $val) { return $false }
    return [bool]$val
}

#endregion

#region --- Parser (Token List → AST) ---

function Invoke-JinjaParseBlock {
    <#
    .SYNOPSIS
        Parses a sequence of tokens into a 'template' AST node.
    .DESCRIPTION
        Reads tokens from the list (advancing $Index) until either the list is
        exhausted or a BLOCK token whose leading keyword is listed in $StopAt is
        encountered. The stop token is NOT consumed — it is left for the caller.
    .PARAMETER Tokens
        The flat token list produced by ConvertTo-JinjaTokenList.
    .PARAMETER Index
        A [ref] integer cursor into the token list.
    .PARAMETER StopAt
        A list of BLOCK keywords that should halt this parse level.
    .OUTPUTS
        hashtable — AST node of type 'template'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.Generic.List[hashtable]]$Tokens,

        [Parameter(Mandatory = $true)]
        [ref]$Index,

        [Parameter(Mandatory = $false)]
        [string[]]$StopAt = @()
    )

    $children = [System.Collections.Generic.List[hashtable]]::new()

    while ($Index.Value -lt $Tokens.Count) {
        $token = $Tokens[$Index.Value]

        if ($token.Type -eq 'TEXT') {
            [void]$children.Add(@{ Type = 'text'; Value = $token.Value })
            $Index.Value++
        }
        elseif ($token.Type -eq 'COMMENT') {
            $Index.Value++
        }
        elseif ($token.Type -eq 'VAR') {
            [void]$children.Add(@{ Type = 'var'; Expr = $token.Value })
            $Index.Value++
        }
        elseif ($token.Type -eq 'BLOCK') {
            $keyword = ($token.Value -split '\s+')[0].ToLower()

            # Stop (but do NOT consume) when the caller owns this token
            if ($StopAt -contains $keyword) {
                break
            }

            if ($keyword -eq 'if') {
                $condition = $token.Value -replace '^if\s+', ''
                $Index.Value++
                $ifNode = Invoke-JinjaParseIf -Tokens $Tokens -Index $Index -Condition $condition
                [void]$children.Add($ifNode)
            }
            elseif ($keyword -eq 'for') {
                $header = $token.Value
                $Index.Value++
                $forNode = Invoke-JinjaParseFor -Tokens $Tokens -Index $Index -Header $header
                [void]$children.Add($forNode)
            }
            elseif ($keyword -eq 'set') {
                if ($token.Value -match '^set\s+(\w+)\s*=\s*(.+)$') {
                    [void]$children.Add(@{ Type = 'set'; VarName = $Matches[1]; ValueExpr = $Matches[2].Trim() })
                }
                $Index.Value++
            }
            else {
                Write-Warning "PSJinja: Unrecognized block tag '$($token.Value)'"
                $Index.Value++
            }
        }
        else {
            $Index.Value++
        }
    }

    return @{ Type = 'template'; Children = $children.ToArray() }
}

function Invoke-JinjaParseIf {
    <#
    .SYNOPSIS
        Parses an if/elif/else/endif construct into an 'if' AST node.
    .DESCRIPTION
        Called after the opening {% if condition %} token has been consumed.
        Recursively collects elif and else branches until {% endif %} is found.
    .PARAMETER Tokens
        The flat token list.
    .PARAMETER Index
        A [ref] integer cursor.
    .PARAMETER Condition
        The condition text stripped from the opening if/elif token.
    .OUTPUTS
        hashtable — AST node of type 'if'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.Generic.List[hashtable]]$Tokens,

        [Parameter(Mandatory = $true)]
        [ref]$Index,

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

    $ifBody       = Invoke-JinjaParseBlock -Tokens $Tokens -Index $Index -StopAt @('elif', 'else', 'endif')
    $elifBranches = [System.Collections.Generic.List[hashtable]]::new()
    $elseBody     = $null

    while ($Index.Value -lt $Tokens.Count) {
        $token   = $Tokens[$Index.Value]
        if ($token.Type -ne 'BLOCK') { break }
        $keyword = ($token.Value -split '\s+')[0].ToLower()

        if ($keyword -eq 'endif') {
            $Index.Value++
            break
        }
        elseif ($keyword -eq 'else') {
            $Index.Value++
            $elseBody = Invoke-JinjaParseBlock -Tokens $Tokens -Index $Index -StopAt @('endif')
            if ($Index.Value -lt $Tokens.Count -and
                $Tokens[$Index.Value].Type -eq 'BLOCK' -and
                ($Tokens[$Index.Value].Value -split '\s+')[0].ToLower() -eq 'endif') {
                $Index.Value++
            }
            break
        }
        elseif ($keyword -eq 'elif') {
            $elifCond = $token.Value -replace '^elif\s+', ''
            $Index.Value++
            $elifBody = Invoke-JinjaParseBlock -Tokens $Tokens -Index $Index -StopAt @('elif', 'else', 'endif')
            [void]$elifBranches.Add(@{ Condition = $elifCond; Body = $elifBody })
        }
        else {
            break
        }
    }

    return @{
        Type          = 'if'
        Condition     = $Condition
        IfBody        = $ifBody
        ElifBranches  = $elifBranches.ToArray()
        ElseBody      = $elseBody
    }
}

function Invoke-JinjaParseFor {
    <#
    .SYNOPSIS
        Parses a for/endfor construct into a 'for' AST node.
    .DESCRIPTION
        Called after the opening {% for item in list %} token has been consumed.
    .PARAMETER Tokens
        The flat token list.
    .PARAMETER Index
        A [ref] integer cursor.
    .PARAMETER Header
        The full block value from the opening for token (e.g. "for x in items").
    .OUTPUTS
        hashtable — AST node of type 'for'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.Generic.List[hashtable]]$Tokens,

        [Parameter(Mandatory = $true)]
        [ref]$Index,

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

    if (-not ($Header -match '^for\s+(\w+)\s+in\s+(.+)$')) {
        Write-Warning "PSJinja: Invalid for-loop syntax: $Header"
        return @{ Type = 'for'; ItemVar = ''; ListExpr = ''; Body = @{ Type = 'template'; Children = @() } }
    }

    $itemVar  = $Matches[1]
    $listExpr = $Matches[2].Trim()

    $body = Invoke-JinjaParseBlock -Tokens $Tokens -Index $Index -StopAt @('endfor')

    if ($Index.Value -lt $Tokens.Count -and
        $Tokens[$Index.Value].Type -eq 'BLOCK' -and
        ($Tokens[$Index.Value].Value -split '\s+')[0].ToLower() -eq 'endfor') {
        $Index.Value++
    }

    return @{
        Type     = 'for'
        ItemVar  = $itemVar
        ListExpr = $listExpr
        Body     = $body
    }
}

#endregion

#region --- Evaluator (AST → string) ---

function Invoke-JinjaNode {
    <#
    .SYNOPSIS
        Recursively evaluates an AST node against a data context.
    .DESCRIPTION
        Dispatches on the node Type field and returns the rendered string
        fragment for that node.
    .PARAMETER Node
        The AST node hashtable to evaluate.
    .PARAMETER Context
        The current data context hashtable.
    .OUTPUTS
        System.String
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Node,

        [Parameter(Mandatory = $true)]
        [hashtable]$Context
    )

    switch ($Node.Type) {
        'template' {
            $sb = [System.Text.StringBuilder]::new()
            foreach ($child in $Node.Children) {
                [void]$sb.Append((Invoke-JinjaNode -Node $child -Context $Context))
            }
            return $sb.ToString()
        }

        'text' {
            return $Node.Value
        }

        'var' {
            $val = Get-JinjaValue -Expr $Node.Expr -Context $Context
            if ($null -eq $val) { return '' } else { return $val.ToString() }
        }

        'if' {
            if (Test-JinjaCondition -Condition $Node.Condition -Context $Context) {
                return Invoke-JinjaNode -Node $Node.IfBody -Context $Context
            }
            foreach ($branch in $Node.ElifBranches) {
                if (Test-JinjaCondition -Condition $branch.Condition -Context $Context) {
                    return Invoke-JinjaNode -Node $branch.Body -Context $Context
                }
            }
            if ($null -ne $Node.ElseBody) {
                return Invoke-JinjaNode -Node $Node.ElseBody -Context $Context
            }
            return ''
        }

        'for' {
            if ([string]::IsNullOrEmpty($Node.ListExpr)) { return '' }
            $listVal = Get-JinjaValue -Expr $Node.ListExpr -Context $Context
            if ($null -eq $listVal) { return '' }
            $items = @($listVal)
            $count = $items.Count
            if ($count -eq 0) { return '' }

            $sb = [System.Text.StringBuilder]::new()
            for ($i = 0; $i -lt $count; $i++) {
                $loopContext               = [hashtable]$Context.Clone()
                $loopContext[$Node.ItemVar] = $items[$i]
                $loopContext['loop']        = @{
                    index  = $i + 1
                    index0 = $i
                    first  = ($i -eq 0)
                    last   = ($i -eq $count - 1)
                    length = $count
                }
                [void]$sb.Append((Invoke-JinjaNode -Node $Node.Body -Context $loopContext))
            }
            return $sb.ToString()
        }

        'set' {
            $Context[$Node.VarName] = Get-JinjaValue -Expr $Node.ValueExpr -Context $Context
            return ''
        }

        default {
            return ''
        }
    }
}

#endregion

#region --- Public API ---

function Invoke-Jinja {
    <#
    .SYNOPSIS
        Renders a Jinja2-style template string with a provided data context.
 
    .DESCRIPTION
        Invoke-Jinja accepts a template string containing Jinja2-style
        delimiters and a data context, and returns the fully rendered string.
 
        Supported syntax:
          Variables : {{ variable }} {{ obj.prop }} {{ arr[0] }}
          Filters : {{ name | upper }} {{ list | join(', ') }}
          If / elif / else / endif
          For / endfor (with loop.index, loop.first, loop.last, loop.length)
          Set : {% set x = value %}
          Comments : {# this is ignored #}
 
    .PARAMETER Template
        The Jinja2-style template string to render.
 
    .PARAMETER Data
        A hashtable or PSCustomObject containing the variables available
        inside the template. May be $null or omitted for templates that
        contain no variable references.
 
    .EXAMPLE
        Invoke-Jinja -Template 'Hello, {{ Name }}!' -Data @{ Name = 'World' }
        # Returns: Hello, World!
 
    .EXAMPLE
        $tmpl = '{% if admin %}Welcome, admin.{% else %}Access denied.{% endif %}'
        Invoke-Jinja -Template $tmpl -Data @{ admin = $true }
        # Returns: Welcome, admin.
 
    .EXAMPLE
        $tmpl = '{% for item in items %}{{ item }} {% endfor %}'
        Invoke-Jinja -Template $tmpl -Data @{ items = @('a','b','c') }
        # Returns: a b c
 
    .INPUTS
        System.String
 
    .OUTPUTS
        System.String
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [AllowEmptyString()]
        [string]$Template,

        [Parameter(Mandatory = $false, Position = 1)]
        [AllowNull()]
        [object]$Data
    )

    process {
        if ([string]::IsNullOrEmpty($Template)) {
            return $Template
        }

        # Normalize data to a hashtable for uniform key access
        if ($null -eq $Data) {
            $context = [hashtable]@{}
        }
        elseif ($Data -is [hashtable]) {
            $context = $Data
        }
        elseif ($Data -is [System.Management.Automation.PSCustomObject]) {
            $context = [hashtable]@{}
            foreach ($prop in $Data.PSObject.Properties) {
                $context[$prop.Name] = $prop.Value
            }
        }
        else {
            $context = [hashtable]@{}
        }

        try {
            $tokens   = ConvertTo-JinjaTokenList -Template $Template
            $idx      = 0
            $ast      = Invoke-JinjaParseBlock -Tokens $tokens -Index ([ref]$idx) -StopAt @()
            return Invoke-JinjaNode -Node $ast -Context $context
        }
        catch {
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    $_.Exception,
                    'JinjaRenderError',
                    [System.Management.Automation.ErrorCategory]::InvalidOperation,
                    $Template
                )
            )
        }
    }
}

#endregion

Export-ModuleMember -Function 'Invoke-Jinja'