Private/Sites/Export.ps1

function Write-PSISitesLog([string]$Path,[string]$RunId,[string]$Phase,[string]$Operation,[string]$Outcome,[string]$Target,[string]$Message,[long]$DurationMs=0,[string]$ErrorId=''){
    [pscustomobject][ordered]@{Timestamp=[datetimeoffset]::Now.ToString('o');Level=if($Outcome -in @('Failed','TimedOut')){'Warning'}else{'Information'};RunId=$RunId;Phase=$Phase;Target=$Target;Operation=$Operation;Outcome=$Outcome;DurationMs=$DurationMs;Message=$Message;ErrorId=$ErrorId}|
        ConvertTo-Json -Compress|Add-Content -LiteralPath $Path -Encoding UTF8
}
function Export-PSISitesReport($Model,$Meta,[string]$ModuleRoot,[string]$ReportFolder,[string]$TemplateName='SitesAndServices.html',[string]$ReportBaseName='Sites-And-Services'){
    $schema=$Model.Schema;$data=$Model.Data
    $csvFolder=Join-Path $ReportFolder 'CSV';$null=New-Item -ItemType Directory -Path $csvFolder -Force
    $payload=[ordered]@{meta=$Meta;sections=$schema;data=$data;coverage=$Model.Coverage;raw=$Model.Raw}
    $json=$payload|ConvertTo-Json -Depth 30
    $html=[IO.File]::ReadAllText((Join-Path $ModuleRoot ('Templates/'+$TemplateName)))
    $html=$html.Replace('/*JP_BRAND_CSS*/',[IO.File]::ReadAllText((Join-Path $ModuleRoot 'Assets/JP.brand.css')))
    $about=[IO.File]::ReadAllText((Join-Path $ModuleRoot 'Assets/HelpAbout.html')).Replace('{{MODULE_VERSION}}',[net.webutility]::HtmlEncode([string]$Meta.version))
    $html=$html.Replace('</body>',$about+'</body>')
    $fallback=New-Object text.StringBuilder
    [void]$fallback.Append('<h1>JP | PSInsightInventory - '+[net.webutility]::HtmlEncode($Meta.title)+'</h1><p>'+[net.webutility]::HtmlEncode($Meta.notice)+'</p><p>Scope: '+[net.webutility]::HtmlEncode($Meta.scope)+'</p>')
    foreach($key in $schema.Keys){
        $cols=@($schema[$key][1]);$rows=@($data[$key]);$path=Join-Path $csvFolder ($key+'.csv')
        if($rows.Count){
            $rows | ForEach-Object{
                $safe=[ordered]@{};foreach($c in $cols){$v=Get-PSISiteValue $_ $c;if($v -is [string] -and $v -match '^[=+@\-\t\r\n]'){$v="'"+$v};$safe[$c]=$v};[pscustomobject]$safe
            }|Select-Object -Property $cols|Export-Csv -LiteralPath $path -Encoding UTF8 -NoTypeInformation
        }else{('"'+($cols -join '","')+'"'+[environment]::NewLine)|Set-Content -LiteralPath $path -Encoding UTF8 -NoNewline}
        [void]$fallback.Append('<h2>'+[net.webutility]::HtmlEncode($schema[$key][0])+'</h2><div style="overflow:auto"><table><thead><tr>')
        foreach($c in $cols){[void]$fallback.Append('<th>'+[net.webutility]::HtmlEncode($c)+'</th>')};[void]$fallback.Append('</tr></thead><tbody>')
        foreach($row in $rows){[void]$fallback.Append('<tr>');foreach($c in $cols){[void]$fallback.Append('<td>'+[net.webutility]::HtmlEncode([string](Get-PSISiteValue $row $c))+'</td>')};[void]$fallback.Append('</tr>')}
        [void]$fallback.Append('</tbody></table></div>')
    }
    $html=$html.Replace('/*DATA*/',(ConvertTo-PSISafeJson $payload)).Replace('<!--FALLBACK-->',$fallback.ToString())
    foreach($placeholder in @('/*DATA*/','/*JP_BRAND_CSS*/','<!--FALLBACK-->','{{MODULE_VERSION}}')){if($html.Contains($placeholder)){throw "Unresolved report placeholder: $placeholder"}}
    $htmlPath=Join-Path $ReportFolder ($ReportBaseName+'.html');$jsonPath=Join-Path $ReportFolder ($ReportBaseName+'.json')
    $utf8=New-Object text.UTF8Encoding($true)
    [IO.File]::WriteAllText($htmlPath,$html,$utf8);[IO.File]::WriteAllText($jsonPath,$json,$utf8)
    [pscustomobject]@{HtmlReport=$htmlPath;JsonSnapshot=$jsonPath;CsvFolder=$csvFolder}
}