Public/Invoke-LiquidTemplate.ps1

<#
.SYNOPSIS
Renders a Liquid template.
.DESCRIPTION
Parses and renders a Liquid template against a supplied context hashtable.
PowerLiquid supports multiple dialects and host-provided extension registries
for custom tags and filters. Built-in filters include string, numeric, date,
URL, and collection helpers such as sort, sort_natural, uniq, map, where, slice, strip_html,
url_encode, and url_decode.

Before rendering, the supplied context is reduced to inert Liquid-safe data
structures. That means templates can read scalars, arrays, hashtables, and
note-property objects, but they do not execute arbitrary PowerShell script
properties or reflective object getters from untrusted input data.
.PARAMETER Template
The Liquid template source to render.
.PARAMETER Context
The root variable scope used during rendering.
.PARAMETER Dialect
The Liquid dialect to render with.
.PARAMETER IncludeRoot
The base path used when resolving include files.
.PARAMETER CurrentFilePath
The current template file path used for tags such as `include_relative`.
.PARAMETER RelativeIncludeRoot
The allowed root for `include_relative` resolution.
.PARAMETER IncludeStack
The current include stack, primarily used internally for recursion detection.
.PARAMETER Registry
The extension registry containing custom tags and filters.
.NOTES
Custom tags and filters registered through the extension registry are trusted
host code by design. The template language itself does not compile or execute
PowerShell from template text or context data.
.OUTPUTS
System.String
.EXAMPLE
Invoke-LiquidTemplate -Template 'Hello {{ user.name }}' -Context @{ user = @{ name = 'Paul' } }
.EXAMPLE
Invoke-LiquidTemplate -Template '{% include card.html %}' -Context @{} -Dialect JekyllLiquid -IncludeRoot .\_includes
.EXAMPLE
Invoke-LiquidTemplate -Template '{% include_relative snippet.md %}' -Context @{} -Dialect JekyllLiquid -CurrentFilePath .\_posts\2026-03-29-example.md -RelativeIncludeRoot .\_posts
#>

function Invoke-LiquidTemplate {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Template,

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

        [ValidateSet('Liquid', 'JekyllLiquid')]
        [string]$Dialect = 'Liquid',

        [string]$IncludeRoot,

        [string]$CurrentFilePath,

        [string]$RelativeIncludeRoot,

        [string[]]$IncludeStack = @(),

        [hashtable]$Registry = (New-LiquidExtensionRegistry),

        [int]$RenderDepth = 0
    )

    try {
        # Validate the requested dialect before we build any runtime state.
        AssertLiquidDialect -Dialect $Dialect

        Write-Verbose "Rendering template with dialect '$Dialect'"

        # Build the isolated runtime that carries sanitized context, include settings, and host extensions.
        $runtime = newLiquidRuntime -Context $Context -Dialect $Dialect -IncludeRoot $IncludeRoot -CurrentFilePath $CurrentFilePath -RelativeIncludeRoot $RelativeIncludeRoot -IncludeStack $IncludeStack -Registry $Registry
        $runtime.RenderDepth = $RenderDepth
        Write-Verbose "Created runtime with $($Context.Count) context variables"

        # Parse the template first so render-time evaluation always works from a consistent AST shape.
        $ast = ConvertTo-LiquidAst -Template $Template -Dialect $Dialect -Registry $Registry
        Write-Verbose "Parsed AST with $($ast.Nodes.Count) nodes"

        # Render the AST against the prepared runtime and return the resulting text.
        $result = ConvertFrom-LiquidNode -Nodes $ast.Nodes -Runtime $runtime
        Write-Verbose "Rendered template successfully"

        return $result
    } catch {
        throw "Invoke-LiquidTemplate failed: $($_.Exception.Message)"
    }
}