Public/New-FslReport.ps1

function New-FslReport {
    <#
    .SYNOPSIS
        Renders FSLogixDoctor findings into a single self-contained HTML report.
    .DESCRIPTION
        Produces one HTML file with an at-a-glance severity summary and the detailed
        findings grouped by category. No external assets, no JavaScript frameworks,
        no telemetry - the file can be mailed to a customer or attached to a ticket.
    .PARAMETER Finding
        FSLogixDoctor.Finding objects, typically from Invoke-FslDiagnostic or
        Test-FslConfiguration. Accepts pipeline input.
    .PARAMETER Path
        Output path of the HTML file.
    .PARAMETER Title
        Report title. Defaults to 'FSLogix Health Report - <computer>'.
    .EXAMPLE
        Test-FslConfiguration | New-FslReport -Path .\fslogix-report.html
    .EXAMPLE
        Invoke-FslDiagnostic -ReportPath .\report.html

        Invoke-FslDiagnostic calls New-FslReport internally.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [pscustomobject[]]$Finding,

        [Parameter(Mandatory)]
        [string]$Path,

        [string]$Title = ''
    )

    begin {
        $all = New-Object System.Collections.Generic.List[object]
    }

    process {
        foreach ($item in $Finding) { $all.Add($item) }
    }

    end {
        if (-not $Title) {
            $Title = 'FSLogix Health Report - {0}' -f $env:COMPUTERNAME
        }
        if (-not $PSCmdlet.ShouldProcess($Path, 'Write HTML report')) { return }

        $encode = { param([object]$text) [System.Net.WebUtility]::HtmlEncode([string]$text) }

        $severityOrder = @{ 'Critical' = 0; 'Warning' = 1; 'Info' = 2; 'Pass' = 3 }
        $counts = @{ 'Critical' = 0; 'Warning' = 0; 'Info' = 0; 'Pass' = 0 }
        foreach ($f in $all) { $counts[$f.Severity]++ }

        $verdict = 'Healthy'
        $verdictClass = 'pass'
        if ($counts['Critical'] -gt 0) { $verdict = 'Critical issues found'; $verdictClass = 'critical' }
        elseif ($counts['Warning'] -gt 0) { $verdict = 'Warnings found'; $verdictClass = 'warning' }

        $sections = New-Object System.Text.StringBuilder
        $categories = @($all | Group-Object Category | Sort-Object Name)
        foreach ($category in $categories) {
            $null = $sections.AppendLine(('<h2>{0}</h2>' -f (& $encode $category.Name)))
            $null = $sections.AppendLine('<table><thead><tr><th>Severity</th><th>Check</th><th>Target</th><th>Details</th></tr></thead><tbody>')
            $ordered = $category.Group | Sort-Object -Property @{ Expression = { $severityOrder[$_.Severity] } }, Check
            foreach ($f in $ordered) {
                $details = '<div class="msg">{0}</div>' -f (& $encode $f.Message)
                if ($f.Evidence) { $details += '<div class="evidence">{0}</div>' -f (& $encode $f.Evidence) }
                if ($f.Recommendation) { $details += '<div class="fix">Fix: {0}</div>' -f (& $encode $f.Recommendation) }
                if ($f.HelpUri) { $details += '<div class="link"><a href="{0}">Documentation</a></div>' -f (& $encode $f.HelpUri) }
                $null = $sections.AppendLine(('<tr class="{0}"><td><span class="badge {0}">{1}</span></td><td>{2}</td><td>{3}</td><td>{4}</td></tr>' -f `
                            $f.Severity.ToLowerInvariant(), (& $encode $f.Severity), (& $encode $f.Check), (& $encode $f.Target), $details))
            }
            $null = $sections.AppendLine('</tbody></table>')
        }

        $moduleVersion = '0.0.0'
        $manifest = Get-Module -Name FSLogixDoctor
        if ($manifest) { $moduleVersion = [string]$manifest.Version }

        $html = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>$(& $encode $Title)</title>
<style>
  :root { color-scheme: light; }
  * { box-sizing: border-box; }
  body { font-family: 'Segoe UI', system-ui, sans-serif; margin: 0; background: #f4f6f8; color: #1f2933; }
  header { background: #102a43; color: #fff; padding: 24px 32px; }
  header h1 { margin: 0 0 4px 0; font-size: 22px; }
  header .meta { color: #9fb3c8; font-size: 13px; }
  main { max-width: 1100px; margin: 0 auto; padding: 24px 32px 48px; }
  .tiles { display: flex; gap: 12px; flex-wrap: wrap; margin: 0 0 8px; }
  .tile { flex: 1 1 120px; background: #fff; border-radius: 8px; padding: 14px 18px; box-shadow: 0 1px 3px rgba(16,42,67,.12); border-top: 4px solid #829ab1; }
  .tile .num { font-size: 28px; font-weight: 600; }
  .tile .label { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: #627d98; }
  .tile.critical { border-top-color: #c62828; } .tile.critical .num { color: #c62828; }
  .tile.warning { border-top-color: #ef6c00; } .tile.warning .num { color: #ef6c00; }
  .tile.info { border-top-color: #0277bd; } .tile.info .num { color: #0277bd; }
  .tile.pass { border-top-color: #2e7d32; } .tile.pass .num { color: #2e7d32; }
  .verdict { display: inline-block; margin: 12px 0 4px; padding: 6px 14px; border-radius: 999px; font-weight: 600; font-size: 14px; }
  .verdict.pass { background: #e8f5e9; color: #2e7d32; }
  .verdict.warning { background: #fff3e0; color: #ef6c00; }
  .verdict.critical { background: #ffebee; color: #c62828; }
  h2 { margin: 28px 0 10px; font-size: 17px; color: #102a43; }
  table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(16,42,67,.12); }
  th { text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: #627d98; padding: 10px 14px; border-bottom: 2px solid #d9e2ec; }
  td { padding: 10px 14px; border-bottom: 1px solid #e4ebf1; vertical-align: top; font-size: 14px; }
  tr:last-child td { border-bottom: none; }
  .badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
  .badge.critical { background: #ffebee; color: #c62828; }
  .badge.warning { background: #fff3e0; color: #ef6c00; }
  .badge.info { background: #e1f5fe; color: #0277bd; }
  .badge.pass { background: #e8f5e9; color: #2e7d32; }
  .msg { margin-bottom: 4px; }
  .evidence { color: #627d98; font-size: 13px; margin-bottom: 4px; }
  .fix { font-size: 13px; color: #334e68; }
  .link { font-size: 13px; } .link a { color: #0277bd; }
  footer { text-align: center; color: #829ab1; font-size: 12px; padding: 16px; }
</style>
</head>
<body>
<header>
  <h1>$(& $encode $Title)</h1>
  <div class="meta">Generated $(Get-Date -Format 'yyyy-MM-dd HH:mm') by FSLogixDoctor v$moduleVersion &middot; read-only diagnostics &middot; no telemetry</div>
</header>
<main>
  <div class="verdict $verdictClass">$(& $encode $verdict)</div>
  <div class="tiles">
    <div class="tile critical"><div class="num">$($counts['Critical'])</div><div class="label">Critical</div></div>
    <div class="tile warning"><div class="num">$($counts['Warning'])</div><div class="label">Warnings</div></div>
    <div class="tile info"><div class="num">$($counts['Info'])</div><div class="label">Info</div></div>
    <div class="tile pass"><div class="num">$($counts['Pass'])</div><div class="label">Passed</div></div>
  </div>
$($sections.ToString())
</main>
<footer>FSLogixDoctor &middot; https://github.com/xGreeny/fslogix-doctor</footer>
</body>
</html>
"@


        # Resolve via the provider so relative paths work even when the caller
        # sits on a registry drive (realistic while poking HKLM:\SOFTWARE\FSLogix).
        $resolvedPath = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
        [System.IO.File]::WriteAllText($resolvedPath, $html, (New-Object System.Text.UTF8Encoding($false)))
        Get-Item -LiteralPath $resolvedPath
    }
}