pode-pug-engine.ps1

<#
.SYNOPSIS
    PugPS - A Pug to HTML converter for PowerShell and Pode.
     
.DESCRIPTION
    Copyright (c) 2026 Nabil Redmann
    Licensed under the MIT License.
    Permission is hereby granted, free of charge, to any person obtaining a copy
    of this software and associated documentation files.
#>


# @{filename = @{sb = scriptblock; LastWriteTime = [datetime]}}
$cache = [Hashtable]@{}

# ADD a view engine for Pug, and SET as default and only view engine for files without extension
# inbuild engines can still be used, if `Write-PodeViewResponse -Path 'index.EXTENSION'` is used
Function Set-PodeViewEnginePug {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Extension,

        [Parameter(Mandatory=$false, HelpMessage="The root directory used to resolve absolute include/extend paths (those starting with / or \). If empty, absolute paths are resolved relative to the current file.")]
        [string]$BaseDir = "",

        [Parameter(Mandatory=$false, HelpMessage="The path to the filters file (ps1) to be imported or a scriptblock with the filter functions.")]
        [AllowNull()]
        $Filters = $Null,

        [Parameter(Mandatory=$false, HelpMessage="When true (default), boolean attributes are rendered as 'attr'. When false, they are rendered as 'attr=''attr'''.")]
        [bool]$Properties = $true,

        [Parameter(Mandatory=$false, HelpMessage="When true, standard void tags (like img, br) are rendered with a self-closing slash (e.g., <img />). Default is false.")]
        [bool]$VoidTagsSelfClosing = $false,

        [Parameter(Mandatory=$false, HelpMessage="When true, empty container tags (like div, span) with no content or children are rendered as self-closing (e.g., <div />). Default is false.")]
        [bool]$ContainerTagsSelfClosing = $false,

        [Parameter(Mandatory=$false, HelpMessage="When true, CamelCase in PUG is converted to kebab-case. Default is true.")]
        [bool]$KebabCaseHTML = $true,

        [Parameter(Mandatory=$false, HelpMessage="When text, an empty page with ony the error is generated, if `"rethrow`" is used, the Pode errorpage for 422 is triggered. Default is `"text`".")]
        [ValidateSet("text", "rethrow")]
        [string]$ErrorOutput = "rethrow",

        [Parameter(Mandatory=$false, HelpMessage="Number of context lines to show before and after the error line.")]
        [int]$ErrorContextRange = 2
    )

    Set-PodeViewEngine -Type 'Pug' -Extension $Extension -ScriptBlock {
        param($path, $data)

        # Ensure parser functions are available
        . $using:PSScriptRoot\parser.ps1

        if ([string]::IsNullOrWhiteSpace($using:Filters)) {
            # param not used.
        }
        # 1. Import filter from scriptblock
        # elseif (($using:Filters).getType().Name -eq 'ScriptBlock') {
        # . ($using:Filters)

        # write-host "Filters imported : " ($using:Filters).ToString()
        # #! WHY DOES THIS NOT WORK ??
        # write-host "Filters fn exists : ", ((Get-Command "TestFN2") ? "True" : "False")
        # }
        # 1. Import filters file if it exists
        elseif (($using:Filters).getType().Name -eq 'String' -and (Test-Path (Join-Path $PWD $using:Filters) -PathType Leaf)) {
            . (Join-Path $PWD $using:Filters)
        }
        else {
            $exFn = New-Object System.Exception("Filters not found: " + $using:Filters)
            throw $exFn
        }

        try {
            if (($using:cache).ContainsKey($path) -and `
                ($using:cache)[$path].LastWriteTime -eq (Get-Item $path).LastWriteTime)
            {
                # 1. get from cache
                # 2. is a created scriptblock
                Write-Host "[PUG:CHACHE] Use for $path"
                $sb = ($using:cache)[$path].sb
            }
            else {
                # 1. Transpile
                $psCode = Convert-PugToPowerShell `
                    -Path $path `
                    -Extension $using:Extension `
                    -BaseDir $using:BaseDir `
                    -Properties $using:Properties `
                    -VoidTagsSelfClosing $using:VoidTagsSelfClosing `
                    -ContainerTagsSelfClosing $using:ContainerTagsSelfClosing `
                    -KebabCaseHTML $using:KebabCaseHTML `
                    -ErrorContextRange $using:ErrorContextRange
                
                # debug
                #$psCode | Out-File "_generated_template_.ps1"
                
                # 2. Create ScriptBlock
                $sb = [scriptblock]::Create($psCode)
                Write-Host "[PUG:CACHE] Created for $path"
                ($using:cache)[$path] = @{sb = $sb; LastWriteTime = (Get-Item $path).LastWriteTime}
            }

            # 3. Execute, passing $data into the scope
            $html = (& $sb $data)

            return $html

        }
        catch {
            # remove faulty scriptblock from cache
            Write-Host "[PUG:CACHE] Error -> Remove $path"
            ($using:cache).Remove($path)

            $ex = $_.Exception
            $niceMsg = ""

            # Check if this is a Parser-thrown error (already has custom data fields)
            if ($ex.Data.Contains('Line')) {
                # Just re-use the formatted message if it was a New-PugError
                $niceMsg = $ex.Message
            } 
            # Check if this is a Runtime-thrown error (caught inside the generated script)
            elseif ($ex.Data.Contains('PugLine') -and $ex.Data['PugLine'] -gt 0) {
                $runtimeLine = $ex.Data['PugLine']
                $runtimePath = $ex.Data['PugPath']
                
                # Use the parser helper to generate the nice message
                $niceMsg = Get-PugErrorContext `
                    -Path $runtimePath `
                    -LineNumber $runtimeLine `
                    -Detail $ex.Message `
                    -ContextRange $using:ErrorContextRange
            }
            else {
                # Fallback for errors that didn't get tracked
                $niceMsg = "An unexpected error occurred:`n$($ex.Message)`n$($ex.StackTrace)"
            }

            if ($using:ErrorOutput -eq "text") {
                # Set-PodeResponseStatus -Code 400+ --- pode will block output ... -NoErrorPage lets us output what we want
                Set-PodeResponseStatus -Code 422 -NoErrorPage
                # Set-PodeHeader -Name "Content-Type" -Value "text/plain" ... BROKEN ...
                # Return the clean message to be rendered as plain text
                $escapedMsg = [System.Net.WebUtility]::HtmlEncode($niceMsg)
                return "<pre>$escapedMsg</pre>"
            }
            else {
                # Rethrow specifically
                $newEx = New-Object System.Exception($niceMsg, $ex)
                throw $newEx
            }
        }
    }
}



#Export-ModuleMember -Function Set-PodeViewEnginePug