Skip to content

Instantly share code, notes, and snippets.

@keyan1603
Created March 18, 2026 15:09
Show Gist options
  • Select an option

  • Save keyan1603/cb53e052404b06fb80149e4950beedf8 to your computer and use it in GitHub Desktop.

Select an option

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
# ================================================================
# 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) &nbsp;|&nbsp; <strong>UAT</strong> extracted: $($data.uatExtractedAt)</div>
<div>Compared at: $($data.comparedAt) &nbsp;|&nbsp; 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