Eigenverft.Manifested.Drydock.Convert.ps1
|
function Convert-FilePlaceholders { <# .SYNOPSIS Transforms a template file by replacing {{Placeholders}} with provided values and writes the result to an output file. .DESCRIPTION Reads all text from an input file, replaces placeholders of the form {{Name}} using values from a hashtable, and writes the rendered content to the output file. The function: - Preserves the input file's encoding (BOM-aware) when writing the output. - Creates the output directory if missing. - Is idempotent: skips writing when no content change is detected. - Emits minimal, standardized messages via _Write-StandardMessage for key actions only. .PARAMETER InputFile Full path to the template file containing placeholders like {{Name}}. .PARAMETER OutputFile Full path for the rendered output file. .PARAMETER Replacements Hashtable where keys are placeholder names (without braces) and values are replacement strings. .EXAMPLE # Basic usage (Windows paths) $map = @{ sourceCodeDirectory = 'C:\Projects\MyApp'; outputDirectory = 'C:\Out' } Convert-FilePlaceholders -InputFile 'C:\Tpl\appsettings.template.json' -OutputFile 'C:\Out\appsettings.json' -Replacements $map .EXAMPLE # Cross-platform usage (macOS/Linux paths) $map = @{ imagePath = '/opt/app/images'; dataRoot = '/var/data/app' } Convert-FilePlaceholders -InputFile '/srv/tpl/config.tpl' -OutputFile '/srv/app/config.json' -Replacements $map .EXAMPLE # Warns about unused keys or unresolved placeholders (default behavior) $map = @{ Foo = 'X'; Bar = 'Y' } Convert-FilePlaceholders -InputFile './in.tpl' -OutputFile './out.txt' -Replacements $map .NOTES - Compatibility: Windows PowerShell 5/5.1 and PowerShell 7+ on Windows/macOS/Linux. - No SupportsShouldProcess, no pipeline binding, StrictMode 3 safe. - Keys must match: letters, digits, underscore, hyphen, or dot. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $InputFile, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $OutputFile, [Parameter(Mandatory = $true)] [ValidateNotNull()] [hashtable] $Replacements ) # Inline helpers (local scope, deterministic, no pipeline writes) function _Write-StandardMessage { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Message, [Parameter(Mandatory=$false)] [ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')] [string]$Level = 'INF', [Parameter(Mandatory=$false)] [ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')] [string]$MinLevel ) if (-not $PSBoundParameters.ContainsKey('MinLevel')) { if ($Global:ConsoleLogMinLevel) { $MinLevel = $Global:ConsoleLogMinLevel } else { $MinLevel = 'INF' } } $sevMap = @{ TRC=0; DBG=1; INF=2; WRN=3; ERR=4; FTL=5 } $lvl = $Level.ToUpperInvariant() $min = $MinLevel.ToUpperInvariant() $sev = $sevMap[$lvl] $gate = $sevMap[$min] if ($sev -ge 4 -and $sev -lt $gate -and $gate -ge 4) { $lvl = $min $sev = $gate } if ($sev -lt $gate) { return } $ts = ([DateTime]::UtcNow).ToString('yyyy-MM-dd HH:mm:ss:fff') $stack = Get-PSCallStack $helperName = $MyInvocation.MyCommand.Name $orgFunc = $null $caller = $null if ($stack) { $orgIdx = -1 for ($i = 0; $i -lt $stack.Count; $i++) { if ($stack[$i].FunctionName -ne $helperName) { $orgFunc = $stack[$i]; $orgIdx = $i; break } } if ($orgIdx -ge 0) { $callerIdx = $orgIdx + 1 if ($stack.Count -gt $callerIdx) { $caller = $stack[$callerIdx] } else { $caller = $orgFunc } } } if (-not $caller) { $caller = [pscustomobject]@{ ScriptName = $PSCommandPath; FunctionName = '<scriptblock>' } } $file = if ($caller.ScriptName) { Split-Path -Leaf $caller.ScriptName } else { 'console' } $func = if ($caller.FunctionName) { $caller.FunctionName } else { '<scriptblock>' } $line = ("[{0} {1}] [{2}] [{3}] {4}" -f $ts, $lvl, $file, $func, $Message) if ($sev -ge 4) { if ($ErrorActionPreference -eq 'Stop') { Write-Error -Message $line -ErrorId ("ConsoleLog.{0}" -f $lvl) -Category NotSpecified -ErrorAction Stop } else { Write-Error -Message $line -ErrorId ("ConsoleLog.{0}" -f $lvl) -Category NotSpecified } } else { Write-Information -MessageData $line -InformationAction Continue } } function _Validate-Keys { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [hashtable] $Map ) $pattern = '^[A-Za-z0-9_\.\-]+$' foreach ($k in $Map.Keys) { if ($null -eq $k) { _Write-StandardMessage -Message "Null key detected in Replacements." -Level ERR throw "Replacement keys must be non-null." } $keyText = [string]$k if (-not ([System.Text.RegularExpressions.Regex]::IsMatch($keyText, $pattern, [System.Text.RegularExpressions.RegexOptions]::CultureInvariant))) { _Write-StandardMessage -Message ("Invalid key '{0}'. Allowed: letters, digits, underscore, hyphen, dot." -f $keyText) -Level ERR throw ("Invalid replacement key '{0}'." -f $keyText) } } } function _Read-AllTextWithEncoding { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([string] $Path) $encoding = $null $content = $null $fs = $null $sr = $null try { $fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) $sr = New-Object System.IO.StreamReader($fs, $true) $content = $sr.ReadToEnd() $encoding = $sr.CurrentEncoding } finally { if ($null -ne $sr) { $sr.Dispose() } if ($null -ne $fs) { $fs.Dispose() } } [pscustomobject]@{ Content = $content; Encoding = $encoding } } function _Write-AllTextWithEncoding { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [string] $Path, [string] $Text, [System.Text.Encoding] $Encoding ) [System.IO.File]::WriteAllText($Path, $Text, $Encoding) } function _Replace-Placeholders { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [string] $Text, [hashtable] $Map ) $pattern = '\{\{(?<name>[A-Za-z0-9_\.\-]+)\}\}' $regexOptions = [System.Text.RegularExpressions.RegexOptions]::CultureInvariant $regex = New-Object System.Text.RegularExpressions.Regex($pattern, $regexOptions) $used = @{} $missing = @{} $evaluator = [System.Text.RegularExpressions.MatchEvaluator]{ param([System.Text.RegularExpressions.Match] $m) $n = $m.Groups['name'].Value if ($Map.ContainsKey($n)) { $used[$n] = $true return [string]$Map[$n] } else { $missing[$n] = $true return $m.Value } } $out = $regex.Replace($Text, $evaluator) [pscustomobject]@{ Text = $out UsedKeys = @($used.Keys) MissingNames = @($missing.Keys) } } # Validate input existence and map keys if (-not [System.IO.File]::Exists($InputFile)) { _Write-StandardMessage -Message ("Input file not found: {0}" -f $InputFile) -Level ERR throw ("Input file not found: {0}" -f $InputFile) } if ($Replacements.Count -eq 0) { _Write-StandardMessage -Message "No replacements provided; nothing to do." -Level WRN return } _Validate-Keys -Map $Replacements # Read input (preserve encoding) $readIn = $null try { $readIn = _Read-AllTextWithEncoding -Path $InputFile } catch { _Write-StandardMessage -Message ("Failed to read input file: {0}" -f $InputFile) -Level ERR throw ("Failed to read input file: {0}" -f $InputFile) } $inputText = $readIn.Content $inputEncoding = $readIn.Encoding # Replace placeholders $rep = _Replace-Placeholders -Text $inputText -Map $Replacements $rendered = $rep.Text # Warn about unresolved placeholders and unused keys if ($rep.MissingNames.Count -gt 0) { _Write-StandardMessage -Message ("Unresolved placeholders in content: {0}" -f ([string]::Join(', ', $rep.MissingNames))) -Level WRN } $unused = @() foreach ($k in $Replacements.Keys) { if (-not ($rep.UsedKeys -contains $k)) { $unused += [string]$k } } if ($unused.Count -gt 0) { _Write-StandardMessage -Message ("Provided keys not found in content: {0}" -f ([string]::Join(', ', $unused))) -Level WRN } # Ensure output directory exists $outDir = [System.IO.Path]::GetDirectoryName($OutputFile) if ($null -ne $outDir -and $outDir.Length -gt 0) { if (-not [System.IO.Directory]::Exists($outDir)) { try { [System.IO.Directory]::CreateDirectory($outDir) | Out-Null _Write-StandardMessage -Message ("Created directory: {0}" -f $outDir) -Level INF } catch { _Write-StandardMessage -Message ("Failed to create directory: {0}" -f $outDir) -Level ERR throw ("Failed to create directory: {0}" -f $outDir) } } } # Idempotent write: only write when content differs or file missing; preserve input encoding $shouldWrite = $true if ([System.IO.File]::Exists($OutputFile)) { $readOut = $null try { $readOut = _Read-AllTextWithEncoding -Path $OutputFile } catch { _Write-StandardMessage -Message ("Failed to read output file for comparison: {0}" -f $OutputFile) -Level ERR throw ("Failed to read output file for comparison: {0}" -f $OutputFile) } if ([string]::Equals($rendered, $readOut.Content, [System.StringComparison]::Ordinal)) { $shouldWrite = $false _Write-StandardMessage -Message ("No changes for: {0}" -f $OutputFile) -Level INF } } if ($shouldWrite) { try { _Write-AllTextWithEncoding -Path $OutputFile -Text $rendered -Encoding $inputEncoding if ([System.IO.File]::Exists($OutputFile)) { _Write-StandardMessage -Message ("Updated file: {0}" -f $OutputFile) -Level INF } else { _Write-StandardMessage -Message ("Created file: {0}" -f $OutputFile) -Level INF } } catch { _Write-StandardMessage -Message ("Failed to write output file: {0}" -f $OutputFile) -Level ERR throw ("Failed to write output file: {0}" -f $OutputFile) } } } |