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) } } } function Convert-TemplateFilePlaceholders { <# .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. Now supports two parameter sets: 1) Input/Output mode (original): -InputFile + -OutputFile + -Replacements 2) Template mode (new): -TemplateFile + -Replacements In Template mode the output file path is derived in the same directory by removing a pre-extension token of ".template", ".tpl", or ".tlp" (case-insensitive). Examples: - "appsettings.template.json" → "appsettings.json" - "appsettings.tlp.json" → "appsettings.json" - "appsettings.tpl.json" → "appsettings.json" .PARAMETER InputFile Full path to the template file containing placeholders like {{Name}}. (Input/Output mode) .PARAMETER OutputFile Full path for the rendered output file. (Input/Output mode) .PARAMETER TemplateFile Full path to the template file. The output file path will be derived automatically as described above. (Template mode) .PARAMETER Replacements Hashtable where keys are placeholder names (without braces) and values are replacement strings. .EXAMPLE # Original mode (Windows paths) $map = @{ sourceCodeDirectory = 'C:\Projects\MyApp'; outputDirectory = 'C:\Out' } Convert-TemplateFilePlaceholders -InputFile 'C:\Tpl\appsettings.template.json' -OutputFile 'C:\Out\appsettings.json' -Replacements $map .EXAMPLE # New template mode (.template) $map = @{ imagePath = '/opt/app/images'; dataRoot = '/var/data/app' } Convert-TemplateFilePlaceholders -TemplateFile '/srv/tpl/config.template.json' -Replacements $map # -> writes '/srv/tpl/config.json' .EXAMPLE # New template mode (.tlp or .tpl) $map = @{ Foo = 'X'; Bar = 'Y' } Convert-TemplateFilePlaceholders -TemplateFile './appsettings.tlp.json' -Replacements $map # -> writes './appsettings.json' .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(DefaultParameterSetName = 'InOut')] param( # ------- Original parameter set ------- [Parameter(Mandatory = $true, ParameterSetName = 'InOut')] [ValidateNotNullOrEmpty()] [string] $InputFile, [Parameter(Mandatory = $true, ParameterSetName = 'InOut')] [ValidateNotNullOrEmpty()] [string] $OutputFile, # ------- New template parameter set ------- [Parameter(Mandatory = $true, ParameterSetName = 'Tpl')] [ValidateNotNullOrEmpty()] [string] $TemplateFile, # ------- Common ------- [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) } } function _Derive-OutputPathFromTemplate { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([Parameter(Mandatory=$true)][string] $Path) # NOTE: Reviewer: Make sure we only manipulate the file name, not directories. $dir = [System.IO.Path]::GetDirectoryName($Path) $file = [System.IO.Path]::GetFileName($Path) $ext = [System.IO.Path]::GetExtension($file) # e.g. ".json" $baseName = [System.IO.Path]::GetFileNameWithoutExtension($file) # e.g. "appsettings.template" # Remove a single trailing token immediately before the last extension: # ".template" (preferred), ".tpl", or ".tlp" — case-insensitive. if ($baseName.EndsWith('.template', [System.StringComparison]::OrdinalIgnoreCase)) { $baseName = $baseName.Substring(0, $baseName.Length - 9) } elseif ($baseName.EndsWith('.tpl', [System.StringComparison]::OrdinalIgnoreCase)) { $baseName = $baseName.Substring(0, $baseName.Length - 4) } elseif ($baseName.EndsWith('.tlp', [System.StringComparison]::OrdinalIgnoreCase)) { $baseName = $baseName.Substring(0, $baseName.Length - 4) } $derivedName = $baseName + $ext if ([string]::IsNullOrWhiteSpace($dir)) { return $derivedName } return [System.IO.Path]::Combine($dir, $derivedName) } # ------- Template parameter set handling (derive paths before validation) ------- if ($PSCmdlet.ParameterSetName -eq 'Tpl') { $InputFile = $TemplateFile $OutputFile = _Derive-OutputPathFromTemplate -Path $TemplateFile _Write-StandardMessage -Message ("Derived output from template: {0} -> {1}" -f $TemplateFile, $OutputFile) -Level DBG } # 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) } } } |