Created
March 18, 2026 15:09
-
-
Save keyan1603/cb53e052404b06fb80149e4950beedf8 to your computer and use it in GitHub Desktop.
SXA content extractor, Comparer and HTML report generator - input scope-config.json from Discovery.ps1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ================================================================ | |
| # SCRIPT 1 — SXA CONTENT EXTRACTOR | |
| # Tool : Sitecore PowerShell Extensions (SPE) | |
| # Run on : DEV first → produces dev-extract.json | |
| # Then UAT → produces uat-extract.json | |
| # | |
| # INPUT : scope-config.json (exported by Discovery script) | |
| # OUTPUT : dev-extract.json / uat-extract.json | |
| # | |
| # Only two values need changing between DEV and UAT runs: | |
| # Environment — label embedded in the JSON | |
| # OutputPath — where to write the extract file | |
| # ================================================================ | |
| # ================================================================ | |
| # SECTION 0 — MINIMAL CONFIG (only these change per run) | |
| # ================================================================ | |
| $runConfig = @{ | |
| # Change to "UAT" when running on UAT | |
| Environment = "DEV" | |
| # Path to the scope-config.json exported by the Discovery script | |
| # Copy this file to this machine before running | |
| ScopeConfigPath = "C:\temp\scope-config.json" # <-- CHANGE IF NEEDED | |
| # Where to write the extract output | |
| # Leave blank "" to print JSON to console only | |
| OutputPath = "C:\temp\dev-extract.json" # <-- CHANGE TO uat-extract.json on UAT | |
| # Safety limit per sub-node (0 = unlimited, use >0 for test runs e.g. 50) | |
| MaxItemsPerNode = 0 | |
| } | |
| # ================================================================ | |
| # LOAD SCOPE CONFIG | |
| # ================================================================ | |
| Add-Type -AssemblyName System.Web | |
| Write-Host "`n=====================================================" -ForegroundColor Cyan | |
| Write-Host " SXA EXTRACTOR — Environment: $($runConfig.Environment)" -ForegroundColor Cyan | |
| Write-Host "=====================================================" -ForegroundColor Cyan | |
| if (-not (Test-Path $runConfig.ScopeConfigPath)) { | |
| Write-Host "`n [ERROR] scope-config.json not found at: $($runConfig.ScopeConfigPath)" -ForegroundColor Red | |
| Write-Host " Run the Discovery script first and copy scope-config.json to this machine." -ForegroundColor Yellow | |
| return | |
| } | |
| Write-Host "`n Loading scope config: $($runConfig.ScopeConfigPath)" -ForegroundColor Yellow | |
| $scope = Get-Content $runConfig.ScopeConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json | |
| Write-Host " Site Root : $($scope.siteRoot)" | |
| Write-Host " Sub-nodes : $($scope.subNodes -join ', ')" | |
| Write-Host " Templates : $($scope.includedTemplates.Count) included" | |
| Write-Host " Languages : $($scope.languages -join ', ')" | |
| Write-Host " Version Mode : $($scope.versionMode)" | |
| Write-Host " Language Mode : $($scope.languageMode)" | |
| Write-Host " Scope created : $($scope.discoveredAt)" | |
| Write-Host "" | |
| # ================================================================ | |
| # HELPERS | |
| # ================================================================ | |
| function Should-Include($templateName) { | |
| if ($scope.excludedTemplates -contains $templateName) { return $false } | |
| if ($scope.includedTemplates.Count -gt 0) { | |
| return $scope.includedTemplates -contains $templateName | |
| } | |
| return $true | |
| } | |
| function Get-Languages($db) { | |
| if ($scope.languageMode -eq "Specific") { return $scope.targetLanguages } | |
| return [Sitecore.Data.Managers.LanguageManager]::GetLanguages($db) | | |
| ForEach-Object { $_.Name } | |
| } | |
| function Get-VersionsForItem($path, $lang) { | |
| $item = Get-Item -Path $path -Language $lang -ErrorAction SilentlyContinue | |
| if (-not $item -or $item.Versions.Count -eq 0) { return @() } | |
| if ($scope.versionMode -eq "Latest") { | |
| return @($item) | |
| } | |
| return $item.Versions.GetVersions() | ForEach-Object { | |
| Get-Item -Path $path -Language $lang -Version $_.Number -ErrorAction SilentlyContinue | |
| } | Where-Object { $_ -ne $null } | |
| } | |
| function Normalize-RichText($html) { | |
| if ([string]::IsNullOrWhiteSpace($html)) { return "" } | |
| $plain = [System.Text.RegularExpressions.Regex]::Replace($html, "<[^>]+>", " ") | |
| $plain = [System.Web.HttpUtility]::HtmlDecode($plain) | |
| $plain = [System.Text.RegularExpressions.Regex]::Replace($plain, "\s+", " ").Trim() | |
| return $plain | |
| } | |
| function Normalize-Multilist($value) { | |
| if ([string]::IsNullOrWhiteSpace($value)) { return @() } | |
| return ($value -split "\|" | | |
| ForEach-Object { $_.Trim() } | | |
| Where-Object { $_ -ne "" } | | |
| Sort-Object) | |
| } | |
| function Get-FieldClass($fieldType) { | |
| if ($scope.richTextTypes -contains $fieldType) { return "RichText" } | |
| if ($scope.multilistTypes -contains $fieldType) { return "Multilist" } | |
| return "Scalar" | |
| } | |
| function Extract-FieldValue($field) { | |
| $raw = $field.Value | |
| $class = Get-FieldClass $field.Type | |
| switch ($class) { | |
| "RichText" { | |
| return @{ | |
| raw = $raw | |
| normalized = Normalize-RichText $raw | |
| fieldClass = "RichText" | |
| } | |
| } | |
| "Multilist" { | |
| return @{ | |
| raw = $raw | |
| normalized = Normalize-Multilist $raw | |
| fieldClass = "Multilist" | |
| } | |
| } | |
| default { | |
| $norm = if ([string]::IsNullOrWhiteSpace($raw)) { "" } else { $raw.Trim() } | |
| return @{ | |
| raw = $raw | |
| normalized = $norm | |
| fieldClass = "Scalar" | |
| } | |
| } | |
| } | |
| } | |
| # ================================================================ | |
| # MAIN EXTRACTION | |
| # ================================================================ | |
| $db = [Sitecore.Configuration.Factory]::GetDatabase("master") | |
| $langs = Get-Languages $db | |
| $extractedAt = Get-Date -Format "yyyy-MM-ddTHH:mm:ss" | |
| $itemsOutput = [System.Collections.ArrayList]::new() | |
| $stats = @{ total = 0; skipped = 0; errors = 0 } | |
| foreach ($subNode in $scope.subNodes) { | |
| $nodePath = "$($scope.siteRoot)/$subNode" | |
| $nodeItem = Get-Item -Path $nodePath -ErrorAction SilentlyContinue | |
| if (-not $nodeItem) { | |
| Write-Host " [SKIP] Not found: $nodePath" -ForegroundColor DarkGray | |
| continue | |
| } | |
| Write-Host " Scanning: /$subNode ..." -ForegroundColor Yellow | |
| $children = Get-ChildItem -Path $nodePath -Recurse -ErrorAction SilentlyContinue | |
| if ($runConfig.MaxItemsPerNode -gt 0) { | |
| $children = $children | Select-Object -First $runConfig.MaxItemsPerNode | |
| } | |
| foreach ($item in $children) { | |
| if (-not (Should-Include $item.TemplateName)) { | |
| $stats.skipped++ | |
| continue | |
| } | |
| foreach ($lang in $langs) { | |
| $versions = Get-VersionsForItem $item.Paths.FullPath $lang | |
| foreach ($vItem in $versions) { | |
| try { | |
| $vItem.Fields.ReadAll() | |
| $fields = @{} | |
| foreach ($field in $vItem.Fields) { | |
| $skip = $false | |
| foreach ($prefix in $scope.excludeFieldPrefixes) { | |
| if ($field.Name.StartsWith($prefix)) { $skip = $true; break } | |
| } | |
| if ($skip -or $field.Name -eq "") { continue } | |
| $fields[$field.Name] = Extract-FieldValue $field | |
| } | |
| $record = @{ | |
| itemId = $vItem.ID.ToString() | |
| itemName = $vItem.Name | |
| itemPath = $vItem.Paths.FullPath | |
| subNode = $subNode | |
| templateName = $vItem.TemplateName | |
| templateId = $vItem.TemplateID.ToString() | |
| language = $lang | |
| version = $vItem.Version.Number | |
| fields = $fields | |
| } | |
| [void]$itemsOutput.Add($record) | |
| $stats.total++ | |
| } | |
| catch { | |
| $stats.errors++ | |
| Write-Host " [ERROR] $($item.Paths.FullPath) | $lang | v$($vItem.Version.Number): $_" -ForegroundColor Red | |
| } | |
| } | |
| } | |
| } | |
| Write-Host " Done /$subNode — running total: $($stats.total) records" -ForegroundColor DarkGreen | |
| } | |
| # ================================================================ | |
| # BUILD & WRITE OUTPUT JSON | |
| # ================================================================ | |
| $output = @{ | |
| environment = $runConfig.Environment | |
| siteRoot = $scope.siteRoot | |
| extractedAt = $extractedAt | |
| versionMode = $scope.versionMode | |
| languageMode = $scope.languageMode | |
| languages = $langs | |
| scopeFile = $runConfig.ScopeConfigPath | |
| stats = @{ | |
| totalExtracted = $stats.total | |
| skipped = $stats.skipped | |
| errors = $stats.errors | |
| } | |
| items = $itemsOutput | |
| } | |
| $json = $output | ConvertTo-Json -Depth 20 | |
| if ([string]::IsNullOrWhiteSpace($runConfig.OutputPath)) { | |
| Write-Host "`n===== JSON OUTPUT (copy below) =====" -ForegroundColor Cyan | |
| Write-Host $json | |
| } | |
| else { | |
| try { | |
| [System.IO.File]::WriteAllText($runConfig.OutputPath, $json, [System.Text.Encoding]::UTF8) | |
| Write-Host "`n [OK] Extract written to: $($runConfig.OutputPath)" -ForegroundColor Green | |
| } | |
| catch { | |
| Write-Host "`n [ERROR] Could not write file: $_" -ForegroundColor Red | |
| Write-Host " Printing to console instead..." -ForegroundColor Yellow | |
| Write-Host $json | |
| } | |
| } | |
| Write-Host "`n===== EXTRACTION COMPLETE — $($runConfig.Environment) =====" -ForegroundColor Cyan | |
| Write-Host " Extracted : $($stats.total) item-language-version records" | |
| Write-Host " Skipped : $($stats.skipped) (template filter)" | |
| Write-Host " Errors : $($stats.errors)" | |
| Write-Host "" | |
| Write-Host " Next: Run this script on $(if ($runConfig.Environment -eq 'DEV') {'UAT'} else {'DEV'}) — change Environment + OutputPath only." | |
| Write-Host "" | |
| # ================================================================ | |
| # SCRIPT 2 — SXA CONTENT COMPARATOR | |
| # Tool : Plain PowerShell 5.1+ (NO Sitecore needed) | |
| # Run on : Any machine with all three files present | |
| # | |
| # INPUT : scope-config.json — comparison rules (from Discovery) | |
| # dev-extract.json — DEV extract (from Script 1) | |
| # uat-extract.json — UAT extract (from Script 1) | |
| # OUTPUT : comparison-result.json | |
| # ================================================================ | |
| # ================================================================ | |
| # SECTION 0 — FILE PATHS (only thing to configure here) | |
| # ================================================================ | |
| $runConfig = @{ | |
| ScopeConfigPath = "C:\temp\scope-config.json" # <-- CHANGE IF NEEDED | |
| DevJsonPath = "C:\temp\dev-extract.json" # <-- CHANGE IF NEEDED | |
| UatJsonPath = "C:\temp\uat-extract.json" # <-- CHANGE IF NEEDED | |
| OutputPath = "C:\temp\comparison-result.json" # <-- CHANGE IF NEEDED | |
| # Only write mismatches/missing items to output (set $false for full output incl. matches) | |
| MismatchesOnly = $true | |
| } | |
| # NOTE: Comparison rules (NullEmptyRule, RichTextMode, MultilistMode, ExcludeFromCompare) | |
| # are all read from scope-config.json — edit them there, not here. | |
| # ================================================================ | |
| # LOAD FILES | |
| # ================================================================ | |
| Write-Host "`n=====================================================" -ForegroundColor Cyan | |
| Write-Host " SXA COMPARATOR" -ForegroundColor Cyan | |
| Write-Host "=====================================================" -ForegroundColor Cyan | |
| foreach ($label in @("ScopeConfigPath","DevJsonPath","UatJsonPath")) { | |
| if (-not (Test-Path $runConfig[$label])) { | |
| Write-Host "`n [ERROR] File not found — $label : $($runConfig[$label])" -ForegroundColor Red | |
| return | |
| } | |
| } | |
| Write-Host "`n Loading scope config : $($runConfig.ScopeConfigPath)" -ForegroundColor Yellow | |
| $scope = Get-Content $runConfig.ScopeConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json | |
| Write-Host " Loading DEV extract : $($runConfig.DevJsonPath)" -ForegroundColor Yellow | |
| $devData = Get-Content $runConfig.DevJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json | |
| Write-Host " Loading UAT extract : $($runConfig.UatJsonPath)" -ForegroundColor Yellow | |
| $uatData = Get-Content $runConfig.UatJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json | |
| # Surface the rules being used | |
| Write-Host "" | |
| Write-Host " Comparison rules (from scope-config.json):" -ForegroundColor White | |
| Write-Host " NullEmptyRule : $($scope.nullEmptyRule)" | |
| Write-Host " RichTextMode : $($scope.richTextMode)" | |
| Write-Host " MultilistMode : $($scope.multilistMode)" | |
| Write-Host " Exclude fields : $($scope.excludeFromCompare -join ', ')" | |
| Write-Host "" | |
| Write-Host " DEV records : $($devData.items.Count)" | |
| Write-Host " UAT records : $($uatData.items.Count)" | |
| # ================================================================ | |
| # HELPERS | |
| # ================================================================ | |
| function Normalize-ForCompare($fieldObj) { | |
| if (-not $fieldObj) { return @{ value = ""; class = "Scalar" } } | |
| $class = $fieldObj.fieldClass | |
| switch ($class) { | |
| "RichText" { | |
| $val = if ($scope.richTextMode -eq "Normalized") { | |
| $fieldObj.normalized | |
| } else { | |
| $fieldObj.raw | |
| } | |
| if ($scope.nullEmptyRule -eq "TreatAsEqual") { | |
| $val = if ([string]::IsNullOrWhiteSpace($val)) { "" } else { $val } | |
| } | |
| return @{ value = $val; class = "RichText" } | |
| } | |
| "Multilist" { | |
| # normalized is already a sorted array from extraction | |
| $items = @() | |
| if ($fieldObj.normalized -is [array]) { | |
| $items = $fieldObj.normalized | Where-Object { $_ -ne $null -and $_ -ne "" } | |
| } | |
| elseif (-not [string]::IsNullOrWhiteSpace($fieldObj.normalized)) { | |
| $items = @($fieldObj.normalized) | |
| } | |
| if ($scope.multilistMode -eq "SetBased") { $items = $items | Sort-Object } | |
| return @{ value = ($items -join "|"); class = "Multilist" } | |
| } | |
| default { | |
| $val = $fieldObj.normalized | |
| if ($scope.nullEmptyRule -eq "TreatAsEqual") { | |
| $val = if ([string]::IsNullOrWhiteSpace($val)) { "" } else { $val } | |
| } | |
| return @{ value = $val; class = "Scalar" } | |
| } | |
| } | |
| } | |
| function Compare-Fields($devFields, $uatFields) { | |
| $results = @() | |
| $allFieldNames = @() | |
| if ($devFields) { $allFieldNames += $devFields.PSObject.Properties.Name } | |
| if ($uatFields) { $allFieldNames += $uatFields.PSObject.Properties.Name } | |
| $allFieldNames = $allFieldNames | Sort-Object -Unique | |
| foreach ($fieldName in $allFieldNames) { | |
| if ($scope.excludeFromCompare -contains $fieldName) { continue } | |
| $devField = if ($devFields -and $devFields.PSObject.Properties[$fieldName]) { | |
| $devFields.PSObject.Properties[$fieldName].Value } else { $null } | |
| $uatField = if ($uatFields -and $uatFields.PSObject.Properties[$fieldName]) { | |
| $uatFields.PSObject.Properties[$fieldName].Value } else { $null } | |
| $devNorm = Normalize-ForCompare $devField | |
| $uatNorm = Normalize-ForCompare $uatField | |
| $isMatch = ($devNorm.value -eq $uatNorm.value) | |
| if ($runConfig.MismatchesOnly -and $isMatch) { continue } | |
| $results += @{ | |
| fieldName = $fieldName | |
| fieldClass = if ($devNorm.class -ne "Scalar") { $devNorm.class } else { $uatNorm.class } | |
| match = $isMatch | |
| devValue = $devNorm.value | |
| uatValue = $uatNorm.value | |
| devRaw = if ($devField) { $devField.raw } else { $null } | |
| uatRaw = if ($uatField) { $uatField.raw } else { $null } | |
| } | |
| } | |
| return $results | |
| } | |
| # ================================================================ | |
| # MAIN COMPARISON | |
| # ================================================================ | |
| Write-Host "`n Building UAT lookup index..." -ForegroundColor Yellow | |
| $uatIndex = @{} | |
| foreach ($item in $uatData.items) { | |
| $key = "$($item.itemId)|$($item.language)|$($item.version)" | |
| $uatIndex[$key] = $item | |
| } | |
| $matchedUatKeys = @{} | |
| $results = [System.Collections.ArrayList]::new() | |
| $stats = @{ | |
| totalDev = $devData.items.Count | |
| totalUat = $uatData.items.Count | |
| matched = 0 | |
| devOnly = 0 | |
| uatOnly = 0 | |
| fieldMatches = 0 | |
| fieldMismatches = 0 | |
| } | |
| Write-Host " Comparing $($devData.items.Count) DEV records..." -ForegroundColor Yellow | |
| $i = 0 | |
| foreach ($devItem in $devData.items) { | |
| $i++ | |
| if ($i % 500 -eq 0) { Write-Host " ... $i / $($devData.items.Count)" -ForegroundColor DarkGray } | |
| $key = "$($devItem.itemId)|$($devItem.language)|$($devItem.version)" | |
| $uatItem = $uatIndex[$key] | |
| if (-not $uatItem) { | |
| $stats.devOnly++ | |
| [void]$results.Add(@{ | |
| status = "DEV_ONLY" | |
| itemId = $devItem.itemId | |
| itemName = $devItem.itemName | |
| itemPath = $devItem.itemPath | |
| subNode = $devItem.subNode | |
| templateName = $devItem.templateName | |
| language = $devItem.language | |
| version = $devItem.version | |
| mismatchCount= 0 | |
| fieldDiffs = @() | |
| }) | |
| continue | |
| } | |
| $matchedUatKeys[$key] = $true | |
| $stats.matched++ | |
| $fieldDiffs = Compare-Fields $devItem.fields $uatItem.fields | |
| $mismatchCount = ($fieldDiffs | Where-Object { -not $_.match }).Count | |
| $matchCount = ($fieldDiffs | Where-Object { $_.match }).Count | |
| $stats.fieldMismatches += $mismatchCount | |
| $stats.fieldMatches += $matchCount | |
| $overallStatus = if ($mismatchCount -gt 0) { "MISMATCH" } else { "MATCH" } | |
| if ($runConfig.MismatchesOnly -and $overallStatus -eq "MATCH") { continue } | |
| [void]$results.Add(@{ | |
| status = $overallStatus | |
| itemId = $devItem.itemId | |
| itemName = $devItem.itemName | |
| itemPath = $devItem.itemPath | |
| subNode = $devItem.subNode | |
| templateName = $devItem.templateName | |
| language = $devItem.language | |
| version = $devItem.version | |
| mismatchCount = $mismatchCount | |
| fieldDiffs = $fieldDiffs | |
| }) | |
| } | |
| # UAT-only items | |
| foreach ($uatItem in $uatData.items) { | |
| $key = "$($uatItem.itemId)|$($uatItem.language)|$($uatItem.version)" | |
| if (-not $matchedUatKeys.ContainsKey($key)) { | |
| $stats.uatOnly++ | |
| [void]$results.Add(@{ | |
| status = "UAT_ONLY" | |
| itemId = $uatItem.itemId | |
| itemName = $uatItem.itemName | |
| itemPath = $uatItem.itemPath | |
| subNode = $uatItem.subNode | |
| templateName = $uatItem.templateName | |
| language = $uatItem.language | |
| version = $uatItem.version | |
| mismatchCount= 0 | |
| fieldDiffs = @() | |
| }) | |
| } | |
| } | |
| # ================================================================ | |
| # BUILD & WRITE OUTPUT | |
| # ================================================================ | |
| $comparisonOutput = @{ | |
| comparedAt = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss") | |
| devEnvironment = $devData.environment | |
| uatEnvironment = $uatData.environment | |
| devExtractedAt = $devData.extractedAt | |
| uatExtractedAt = $uatData.extractedAt | |
| scopeFile = $runConfig.ScopeConfigPath | |
| config = @{ | |
| nullEmptyRule = $scope.nullEmptyRule | |
| richTextMode = $scope.richTextMode | |
| multilistMode = $scope.multilistMode | |
| mismatchesOnly = $runConfig.MismatchesOnly | |
| } | |
| stats = @{ | |
| totalDevRecords = $stats.totalDev | |
| totalUatRecords = $stats.totalUat | |
| matchedItems = $stats.matched | |
| devOnlyItems = $stats.devOnly | |
| uatOnlyItems = $stats.uatOnly | |
| totalFieldMismatches = $stats.fieldMismatches | |
| totalFieldMatches = $stats.fieldMatches | |
| } | |
| results = $results | |
| } | |
| $json = $comparisonOutput | ConvertTo-Json -Depth 20 | |
| [System.IO.File]::WriteAllText($runConfig.OutputPath, $json, [System.Text.Encoding]::UTF8) | |
| Write-Host "`n===== COMPARISON COMPLETE =====" -ForegroundColor Cyan | |
| Write-Host " DEV records : $($stats.totalDev)" | |
| Write-Host " UAT records : $($stats.totalUat)" | |
| Write-Host " Matched items : $($stats.matched)" | |
| Write-Host " DEV-only items : $($stats.devOnly)" -ForegroundColor $(if ($stats.devOnly -gt 0) {"Red"} else {"Green"}) | |
| Write-Host " UAT-only items : $($stats.uatOnly)" -ForegroundColor $(if ($stats.uatOnly -gt 0) {"Red"} else {"Green"}) | |
| Write-Host " Field mismatches : $($stats.fieldMismatches)" -ForegroundColor $(if ($stats.fieldMismatches -gt 0) {"Yellow"} else {"Green"}) | |
| Write-Host " Output written : $($runConfig.OutputPath)" | |
| Write-Host "" | |
| Write-Host " Next: Run Script 3 (Reporter) pointing at $($runConfig.OutputPath)" | |
| Write-Host "" | |
| # ================================================================ | |
| # SCRIPT 3 — SXA COMPARISON HTML REPORTER | |
| # Tool : Plain PowerShell 5.1+ (NO Sitecore needed) | |
| # Run on : Any machine | |
| # Input : comparison-result.json (output of Script 2) | |
| # Output : comparison-report.html (self-contained, open in browser) | |
| # ================================================================ | |
| # ================================================================ | |
| # SECTION 0 — CONFIGURATION | |
| # ================================================================ | |
| $config = @{ | |
| InputPath = "C:\temp\comparison-result.json" # <-- CHANGE | |
| OutputPath = "C:\temp\comparison-report.html" # <-- CHANGE | |
| # Max field value length to show inline before truncating | |
| MaxValueDisplay = 300 | |
| } | |
| # ================================================================ | |
| # LOAD DATA | |
| # ================================================================ | |
| Write-Host "`n Loading: $($config.InputPath)" -ForegroundColor Yellow | |
| $data = Get-Content $config.InputPath -Raw -Encoding UTF8 | ConvertFrom-Json | |
| $results = $data.results | |
| $stats = $data.stats | |
| function Truncate($str, $len) { | |
| if ([string]::IsNullOrEmpty($str)) { return "<em class='empty'>(empty)</em>" } | |
| $escaped = [System.Web.HttpUtility]::HtmlEncode($str) | |
| if ($escaped.Length -gt $len) { return "$($escaped.Substring(0,$len))<span class='trunc'>…</span>" } | |
| return $escaped | |
| } | |
| Add-Type -AssemblyName System.Web | |
| # Group results | |
| $devOnly = $results | Where-Object { $_.status -eq "DEV_ONLY" } | |
| $uatOnly = $results | Where-Object { $_.status -eq "UAT_ONLY" } | |
| $mismatches= $results | Where-Object { $_.status -eq "MISMATCH" } | |
| $matches = $results | Where-Object { $_.status -eq "MATCH" } | |
| # ================================================================ | |
| # BUILD HTML | |
| # ================================================================ | |
| function Build-SummaryCards { | |
| return @" | |
| <div class="cards"> | |
| <div class="card card-blue"> | |
| <div class="card-num">$($stats.totalDevRecords)</div> | |
| <div class="card-label">DEV Records</div> | |
| </div> | |
| <div class="card card-blue"> | |
| <div class="card-num">$($stats.totalUatRecords)</div> | |
| <div class="card-label">UAT Records</div> | |
| </div> | |
| <div class="card card-green"> | |
| <div class="card-num">$($stats.matchedItems)</div> | |
| <div class="card-label">Items Matched</div> | |
| </div> | |
| <div class="card $(if ($stats.devOnlyItems -gt 0) {'card-red'} else {'card-green'})"> | |
| <div class="card-num">$($stats.devOnlyItems)</div> | |
| <div class="card-label">DEV Only</div> | |
| </div> | |
| <div class="card $(if ($stats.uatOnlyItems -gt 0) {'card-red'} else {'card-green'})"> | |
| <div class="card-num">$($stats.uatOnlyItems)</div> | |
| <div class="card-label">UAT Only</div> | |
| </div> | |
| <div class="card $(if ($stats.totalFieldMismatches -gt 0) {'card-yellow'} else {'card-green'})"> | |
| <div class="card-num">$($stats.totalFieldMismatches)</div> | |
| <div class="card-label">Field Mismatches</div> | |
| </div> | |
| </div> | |
| "@ | |
| } | |
| function Build-MissingTable($items, $label, $badgeClass) { | |
| if ($items.Count -eq 0) { | |
| return "<p class='none-msg'>✓ No $label items found.</p>" | |
| } | |
| $rows = "" | |
| foreach ($item in ($items | Sort-Object itemPath)) { | |
| $rows += @" | |
| <tr> | |
| <td><span class='badge $badgeClass'>$label</span></td> | |
| <td class='mono'>$([System.Web.HttpUtility]::HtmlEncode($item.itemPath))</td> | |
| <td>$([System.Web.HttpUtility]::HtmlEncode($item.templateName))</td> | |
| <td>$([System.Web.HttpUtility]::HtmlEncode($item.language))</td> | |
| <td>$($item.version)</td> | |
| </tr> | |
| "@ | |
| } | |
| return @" | |
| <table> | |
| <thead><tr><th>Status</th><th>Item Path</th><th>Template</th><th>Language</th><th>Version</th></tr></thead> | |
| <tbody>$rows</tbody> | |
| </table> | |
| "@ | |
| } | |
| function Build-MismatchSection($mismatches) { | |
| if ($mismatches.Count -eq 0) { | |
| return "<p class='none-msg'>✓ No field mismatches found.</p>" | |
| } | |
| # Group by template for navigation | |
| $byTemplate = $mismatches | Group-Object templateName | Sort-Object Name | |
| $navLinks = "" | |
| foreach ($tGroup in $byTemplate) { | |
| $tid = "tmpl_" + ($tGroup.Name -replace "[^a-zA-Z0-9]","_") | |
| $navLinks += "<a href='#$tid' class='nav-chip'>$([System.Web.HttpUtility]::HtmlEncode($tGroup.Name)) ($($tGroup.Count))</a>`n" | |
| } | |
| $sections = "" | |
| foreach ($tGroup in $byTemplate) { | |
| $tid = "tmpl_" + ($tGroup.Name -replace "[^a-zA-Z0-9]","_") | |
| $itemBlocks = "" | |
| foreach ($item in ($tGroup.Group | Sort-Object itemPath)) { | |
| $fieldRows = "" | |
| $diffs = $item.fieldDiffs | Where-Object { -not $_.match } | |
| foreach ($diff in ($diffs | Sort-Object fieldName)) { | |
| $devVal = Truncate $diff.devValue $config.MaxValueDisplay | |
| $uatVal = Truncate $diff.uatValue $config.MaxValueDisplay | |
| $classBadge = switch ($diff.fieldClass) { | |
| "RichText" { "badge-rte" } | |
| "Multilist" { "badge-multi" } | |
| default { "badge-scalar" } | |
| } | |
| $fieldRows += @" | |
| <tr> | |
| <td>$([System.Web.HttpUtility]::HtmlEncode($diff.fieldName))</td> | |
| <td><span class='ftype $classBadge'>$($diff.fieldClass)</span></td> | |
| <td class='val-cell dev-val'>$devVal</td> | |
| <td class='val-cell uat-val'>$uatVal</td> | |
| </tr> | |
| "@ | |
| } | |
| $itemBlocks += @" | |
| <div class="item-block"> | |
| <div class="item-header"> | |
| <span class="item-path mono">$([System.Web.HttpUtility]::HtmlEncode($item.itemPath))</span> | |
| <span class="item-meta">$([System.Web.HttpUtility]::HtmlEncode($item.language)) · v$($item.version) · <strong>$($diffs.Count) mismatch$(if($diffs.Count -ne 1){'es'})</strong></span> | |
| </div> | |
| <table class="field-table"> | |
| <thead><tr><th>Field</th><th>Type</th><th>DEV Value</th><th>UAT Value</th></tr></thead> | |
| <tbody>$fieldRows</tbody> | |
| </table> | |
| </div> | |
| "@ | |
| } | |
| $sections += @" | |
| <div class="template-section" id="$tid"> | |
| <h3 class="template-title">$([System.Web.HttpUtility]::HtmlEncode($tGroup.Name)) <span class="template-count">$($tGroup.Count) items</span></h3> | |
| $itemBlocks | |
| </div> | |
| "@ | |
| } | |
| return "<div class='template-nav'>$navLinks</div>$sections" | |
| } | |
| # ================================================================ | |
| # ASSEMBLE FULL HTML | |
| # ================================================================ | |
| $summaryCards = Build-SummaryCards | |
| $devOnlyTable = Build-MissingTable $devOnly "DEV ONLY" "badge-dev" | |
| $uatOnlyTable = Build-MissingTable $uatOnly "UAT ONLY" "badge-uat" | |
| $mismatchSection = Build-MismatchSection $mismatches | |
| $html = @" | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>SXA Content Comparison Report</title> | |
| <style> | |
| :root { | |
| --bg: #0f1117; --surface: #1a1d27; --surface2: #22263a; | |
| --border: #2e3350; --text: #e2e8f0; --muted: #8892b0; | |
| --green: #4ade80; --red: #f87171; --yellow: #fbbf24; | |
| --blue: #60a5fa; --purple: #a78bfa; --cyan: #22d3ee; | |
| --font-mono: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; | |
| font-size: 14px; line-height: 1.6; } | |
| a { color: var(--blue); text-decoration: none; } | |
| a:hover { text-decoration: underline; } | |
| /* Layout */ | |
| .topbar { background: var(--surface); border-bottom: 1px solid var(--border); | |
| padding: 18px 32px; display: flex; align-items: center; gap: 16px; } | |
| .topbar h1 { font-size: 18px; font-weight: 700; color: var(--text); } | |
| .topbar .meta { font-size: 12px; color: var(--muted); margin-left: auto; text-align: right; } | |
| .topbar .meta strong { color: var(--text); } | |
| .container { max-width: 1400px; margin: 0 auto; padding: 32px; } | |
| /* Tabs */ | |
| .tabs { display: flex; gap: 4px; margin-bottom: 24px; border-bottom: 1px solid var(--border); } | |
| .tab { padding: 10px 20px; cursor: pointer; color: var(--muted); font-weight: 500; | |
| border-bottom: 2px solid transparent; transition: all .2s; } | |
| .tab:hover { color: var(--text); } | |
| .tab.active { color: var(--blue); border-bottom-color: var(--blue); } | |
| .tab-panel { display: none; } | |
| .tab-panel.active { display: block; } | |
| /* Summary cards */ | |
| .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 32px; } | |
| .card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; | |
| padding: 20px; text-align: center; } | |
| .card-num { font-size: 36px; font-weight: 700; line-height: 1; margin-bottom: 6px; } | |
| .card-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; } | |
| .card-green .card-num { color: var(--green); } | |
| .card-red .card-num { color: var(--red); } | |
| .card-yellow .card-num { color: var(--yellow); } | |
| .card-blue .card-num { color: var(--blue); } | |
| /* Config box */ | |
| .config-box { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; | |
| padding: 20px; margin-bottom: 24px; display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; } | |
| .config-item label { display: block; font-size: 11px; color: var(--muted); | |
| text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; } | |
| .config-item span { font-weight: 600; color: var(--cyan); } | |
| /* Tables */ | |
| table { width: 100%; border-collapse: collapse; margin-bottom: 8px; } | |
| thead { background: var(--surface2); } | |
| th { padding: 10px 14px; text-align: left; font-size: 11px; text-transform: uppercase; | |
| letter-spacing: .05em; color: var(--muted); border-bottom: 1px solid var(--border); } | |
| td { padding: 10px 14px; border-bottom: 1px solid var(--border); vertical-align: top; } | |
| tr:last-child td { border-bottom: none; } | |
| tr:hover td { background: var(--surface2); } | |
| /* Field diff table */ | |
| .field-table { font-size: 13px; } | |
| .val-cell { max-width: 380px; word-break: break-word; } | |
| .dev-val { background: rgba(248,113,113,.06); } | |
| .uat-val { background: rgba(74,222,128,.06); } | |
| /* Badges */ | |
| .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; | |
| font-size: 11px; font-weight: 600; } | |
| .badge-dev { background: rgba(248,113,113,.15); color: var(--red); } | |
| .badge-uat { background: rgba(74,222,128,.15); color: var(--green); } | |
| .ftype { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; } | |
| .badge-rte { background: rgba(167,139,250,.15); color: var(--purple); } | |
| .badge-multi { background: rgba(251,191,36,.15); color: var(--yellow); } | |
| .badge-scalar { background: rgba(96,165,250,.12); color: var(--blue); } | |
| /* Template sections */ | |
| .template-nav { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 24px; } | |
| .nav-chip { background: var(--surface); border: 1px solid var(--border); border-radius: 20px; | |
| padding: 4px 14px; font-size: 12px; color: var(--muted); transition: all .2s; } | |
| .nav-chip:hover { border-color: var(--blue); color: var(--blue); text-decoration: none; } | |
| .template-section { margin-bottom: 40px; } | |
| .template-title { font-size: 15px; font-weight: 700; color: var(--text); margin-bottom: 16px; | |
| padding-bottom: 8px; border-bottom: 1px solid var(--border); } | |
| .template-count { font-size: 12px; font-weight: 400; color: var(--muted); margin-left: 8px; } | |
| .item-block { background: var(--surface); border: 1px solid var(--border); | |
| border-radius: 6px; margin-bottom: 16px; overflow: hidden; } | |
| .item-header { display: flex; justify-content: space-between; align-items: center; | |
| padding: 10px 16px; background: var(--surface2); | |
| border-bottom: 1px solid var(--border); flex-wrap: wrap; gap: 8px; } | |
| .item-path { font-size: 12px; color: var(--cyan); } | |
| .item-meta { font-size: 12px; color: var(--muted); } | |
| .item-meta strong { color: var(--yellow); } | |
| /* Misc */ | |
| .mono { font-family: var(--font-mono); font-size: 12px; } | |
| .empty { color: var(--muted); font-style: italic; } | |
| .trunc { color: var(--muted); } | |
| .none-msg { color: var(--green); padding: 16px 0; } | |
| .section-title { font-size: 16px; font-weight: 700; margin-bottom: 16px; | |
| padding-bottom: 8px; border-bottom: 1px solid var(--border); } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="topbar"> | |
| <div> | |
| <h1>🔍 SXA Content Comparison Report</h1> | |
| </div> | |
| <div class="meta"> | |
| <div><strong>DEV</strong> extracted: $($data.devExtractedAt) | <strong>UAT</strong> extracted: $($data.uatExtractedAt)</div> | |
| <div>Compared at: $($data.comparedAt) | Site: $([System.Web.HttpUtility]::HtmlEncode($data.stats.ToString()))</div> | |
| </div> | |
| </div> | |
| <div class="container"> | |
| <!-- Comparison Config --> | |
| <div class="config-box"> | |
| <div class="config-item"><label>Null/Empty Rule</label><span>$($data.config.nullEmptyRule)</span></div> | |
| <div class="config-item"><label>Rich Text Mode</label><span>$($data.config.richTextMode)</span></div> | |
| <div class="config-item"><label>Multilist Mode</label><span>$($data.config.multilistMode)</span></div> | |
| <div class="config-item"><label>Showing</label><span>$(if($data.config.mismatchesOnly){"Mismatches Only"}else{"All Items"})</span></div> | |
| </div> | |
| <!-- Summary Cards --> | |
| $summaryCards | |
| <!-- Tabs --> | |
| <div class="tabs"> | |
| <div class="tab active" onclick="showTab('mismatches', this)"> | |
| Field Mismatches <span style="color:var(--yellow)">($($mismatches.Count))</span> | |
| </div> | |
| <div class="tab" onclick="showTab('devonly', this)"> | |
| DEV Only <span style="color:var(--red)">($($devOnly.Count))</span> | |
| </div> | |
| <div class="tab" onclick="showTab('uatonly', this)"> | |
| UAT Only <span style="color:var(--red)">($($uatOnly.Count))</span> | |
| </div> | |
| <div class="tab" onclick="showTab('matches', this)"> | |
| Matched Items <span style="color:var(--green)">($($matches.Count))</span> | |
| </div> | |
| </div> | |
| <!-- Mismatches Tab --> | |
| <div id="tab-mismatches" class="tab-panel active"> | |
| <p class="section-title">Field-Level Mismatches</p> | |
| $mismatchSection | |
| </div> | |
| <!-- DEV Only Tab --> | |
| <div id="tab-devonly" class="tab-panel"> | |
| <p class="section-title">Items present in DEV but missing in UAT</p> | |
| $devOnlyTable | |
| </div> | |
| <!-- UAT Only Tab --> | |
| <div id="tab-uatonly" class="tab-panel"> | |
| <p class="section-title">Items present in UAT but missing in DEV</p> | |
| $uatOnlyTable | |
| </div> | |
| <!-- Matches Tab --> | |
| <div id="tab-matches" class="tab-panel"> | |
| <p class="section-title">Fully Matched Items</p> | |
| $(if ($matches.Count -eq 0) { | |
| "<p class='none-msg'>No fully-matched items in output (MismatchesOnly mode may be on).</p>" | |
| } else { | |
| "<table><thead><tr><th>Item Path</th><th>Template</th><th>Language</th><th>Version</th></tr></thead><tbody>" + | |
| (($matches | Sort-Object itemPath | ForEach-Object { | |
| "<tr><td class='mono'>$([System.Web.HttpUtility]::HtmlEncode($_.itemPath))</td><td>$([System.Web.HttpUtility]::HtmlEncode($_.templateName))</td><td>$($_.language)</td><td>$($_.version)</td></tr>" | |
| }) -join "") + | |
| "</tbody></table>" | |
| }) | |
| </div> | |
| </div> | |
| <script> | |
| function showTab(name, el) { | |
| document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); | |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| document.getElementById('tab-' + name).classList.add('active'); | |
| el.classList.add('active'); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| "@ | |
| [System.IO.File]::WriteAllText($config.OutputPath, $html, [System.Text.Encoding]::UTF8) | |
| Write-Host " [OK] Report written to: $($config.OutputPath)" -ForegroundColor Green | |
| Write-Host " Open in any browser — no server required.`n" -ForegroundColor Cyan |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment