Private/ConvertTo-InforcerHtml.ps1
|
function ConvertTo-HtmlAnchorId { <# .SYNOPSIS Converts a string to a valid HTML anchor ID (lowercase, hyphens, alphanumeric only). #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Text) $id = $Text.ToLowerInvariant() $id = $id -replace '[^a-z0-9\s-]', '' $id = $id -replace '\s+', '-' $id = $id -replace '-{2,}', '-' $id = $id.Trim('-') $id } function ConvertTo-InforcerHtml { <# .SYNOPSIS Renders a DocModel as a self-contained HTML document with embedded CSS. .DESCRIPTION Produces a single self-contained HTML file with modern 2025 admin dashboard styling: - Embedded CSS with CSS custom properties for dark/light theming - Glassmorphism-inspired cards and toolbar - Collapsible TOC per product and category - Collapsible product sections in content - Sticky toolbar with empty field filter and theme toggle - Smooth transitions and hover states - StringBuilder assembly (no string concatenation in loops) .PARAMETER DocModel Hashtable from ConvertTo-InforcerDocModel containing TenantName, TenantId, GeneratedAt, BaselineName, and Products (OrderedDictionary). .OUTPUTS System.String -- complete HTML document as a single string. .EXAMPLE $html = ConvertTo-InforcerHtml -DocModel $docModel Set-Content -Path '.\tenant-doc.html' -Value $html -Encoding UTF8 #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$DocModel ) $cssBlock = @' :root { --bg: #f8fafc; --bg-card: #ffffff; --bg-glass: rgba(255,255,255,0.7); --text: #0f172a; --text-secondary: #475569; --border: #e2e8f0; --border-subtle: #f1f5f9; --row-alt: rgba(248,250,252,0.6); --header-bg: rgba(241,245,249,0.8); --muted: #94a3b8; --accent: #2563eb; --accent-hover: #1d4ed8; --accent-soft: rgba(37,99,235,0.08); --badge-bg: #dbeafe; --badge-text: #1e40af; --summary-hover: rgba(241,245,249,0.8); --shadow-sm: 0 1px 2px rgba(0,0,0,0.04); --shadow-md: 0 4px 12px rgba(0,0,0,0.06); --shadow-lg: 0 8px 24px rgba(0,0,0,0.08); --radius: 12px; --radius-sm: 8px; --radius-xs: 6px; --transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); --success: #059669; --success-bg: #d1fae5; } @media (prefers-color-scheme: dark) { :root:not(.light) { --bg: #0c0f1a; --bg-card: #141825; --bg-glass: rgba(20,24,37,0.8); --text: #e2e8f0; --text-secondary: #94a3b8; --border: #1e293b; --border-subtle: #1e293b; --row-alt: rgba(30,41,59,0.4); --header-bg: rgba(30,41,59,0.6); --muted: #64748b; --accent: #3b82f6; --accent-hover: #60a5fa; --accent-soft: rgba(59,130,246,0.1); --badge-bg: rgba(59,130,246,0.15); --badge-text: #93c5fd; --summary-hover: rgba(30,41,59,0.6); --shadow-sm: 0 1px 2px rgba(0,0,0,0.2); --shadow-md: 0 4px 12px rgba(0,0,0,0.3); --shadow-lg: 0 8px 24px rgba(0,0,0,0.4); --success: #34d399; --success-bg: rgba(52,211,153,0.1); } } :root.dark { --bg: #0c0f1a; --bg-card: #141825; --bg-glass: rgba(20,24,37,0.8); --text: #e2e8f0; --text-secondary: #94a3b8; --border: #1e293b; --border-subtle: #1e293b; --row-alt: rgba(30,41,59,0.4); --header-bg: rgba(30,41,59,0.6); --muted: #64748b; --accent: #3b82f6; --accent-hover: #60a5fa; --accent-soft: rgba(59,130,246,0.1); --badge-bg: rgba(59,130,246,0.15); --badge-text: #93c5fd; --summary-hover: rgba(30,41,59,0.6); --shadow-sm: 0 1px 2px rgba(0,0,0,0.2); --shadow-md: 0 4px 12px rgba(0,0,0,0.3); --shadow-lg: 0 8px 24px rgba(0,0,0,0.4); --success: #34d399; --success-bg: rgba(52,211,153,0.1); } :root.light { --bg: #f8fafc; --bg-card: #ffffff; --bg-glass: rgba(255,255,255,0.7); --text: #0f172a; --text-secondary: #475569; --border: #e2e8f0; --border-subtle: #f1f5f9; --row-alt: rgba(248,250,252,0.6); --header-bg: rgba(241,245,249,0.8); --muted: #94a3b8; --accent: #2563eb; --accent-hover: #1d4ed8; --accent-soft: rgba(37,99,235,0.08); --badge-bg: #dbeafe; --badge-text: #1e40af; --summary-hover: rgba(241,245,249,0.8); --shadow-sm: 0 1px 2px rgba(0,0,0,0.04); --shadow-md: 0 4px 12px rgba(0,0,0,0.06); --shadow-lg: 0 8px 24px rgba(0,0,0,0.08); --success: #059669; --success-bg: #d1fae5; } @media (prefers-reduced-motion: reduce) { * { transition-duration: 0ms !important; } } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, sans-serif; background: var(--bg); color: var(--text); max-width: 1100px; margin: 0 auto; padding: 0 1.5rem 3rem; margin-left: 340px; line-height: 1.65; font-size: 0.9375rem; -webkit-font-smoothing: antialiased; } /* (toolbar removed -- controls are now in the sidebar panel) */ /* --- Header --- */ .header { padding: 2.5rem 0 1.5rem; } .header h1 { font-size: 1.75rem; font-weight: 700; letter-spacing: -0.025em; margin-bottom: 0.5rem; } .header-meta { display: flex; gap: 1.5rem; flex-wrap: wrap; font-size: 0.8125rem; color: var(--muted); } .header-meta span { display: flex; align-items: center; gap: 0.375rem; } /* --- Cards --- */ .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); padding: 1.25rem 1.5rem; margin-bottom: 1rem; transition: box-shadow var(--transition); } .card:hover { box-shadow: var(--shadow-md); } /* --- TOC --- */ .toc-section { margin-bottom: 1.5rem; } .toc-section .card { padding: 1rem 1.25rem; } .toc-title { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-bottom: 0.75rem; } .toc-l1 { list-style: none; } .toc-l1 > li { margin-bottom: 0.25rem; } .toc-l1 > li > details { border-radius: var(--radius-xs); } .toc-l1 > li > details > summary { padding: 0.5rem 0.625rem; border-radius: var(--radius-xs); font-weight: 600; font-size: 0.875rem; color: var(--text); display: flex; align-items: center; gap: 0.5rem; } .toc-l2 { list-style: none; padding-left: 1.25rem; margin: 0.125rem 0 0.25rem; } .toc-l2 > li > details > summary { padding: 0.3rem 0.5rem; border-radius: var(--radius-xs); font-size: 0.8125rem; color: var(--text-secondary); font-weight: 500; } .toc-l3 { list-style: none; padding-left: 1rem; margin: 0.125rem 0; } .toc-l3 > li { margin: 0.0625rem 0; } .toc-l3 > li a { display: block; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; color: var(--muted); transition: all var(--transition); } .toc-l3 > li a:hover { color: var(--accent); background: var(--accent-soft); } .toc-section a { color: inherit; text-decoration: none; } .toc-section a:hover { color: var(--accent); } /* --- Badges --- */ .badge { display: inline-flex; align-items: center; background: var(--badge-bg); color: var(--badge-text); padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.01em; white-space: nowrap; } .muted { color: var(--muted); font-style: italic; } .tag-badge { background: #dbeafe !important; color: #1e40af !important; border: 1px solid #93c5fd; } :root.dark .tag-badge { background: rgba(59,130,246,0.2) !important; color: #93c5fd !important; border-color: rgba(59,130,246,0.4); } /* --- Summary / Details (shared) --- */ details { border-radius: var(--radius-xs); } summary { cursor: pointer; padding: 0.5rem 0.625rem; border-radius: var(--radius-xs); user-select: none; list-style: none; transition: background var(--transition); } summary::-webkit-details-marker { display: none; } summary::before { content: ''; display: inline-block; width: 0.375rem; height: 0.375rem; border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted); transform: rotate(-45deg); margin-right: 0.625rem; transition: transform var(--transition); flex-shrink: 0; } details[open] > summary::before { transform: rotate(45deg); } summary:hover { background: var(--summary-hover); } /* --- Product sections --- */ .product-section { margin-bottom: 1rem; } .product-section > summary { font-size: 1rem; font-weight: 700; padding: 0.875rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } .product-section > summary:hover { box-shadow: var(--shadow-md); } .product-section[open] > summary { border-radius: var(--radius) var(--radius) 0 0; margin-bottom: 0; border-bottom-color: transparent; } .product-content { background: var(--bg-card); border: 1px solid var(--border); border-top: none; border-radius: 0 0 var(--radius) var(--radius); padding: 0.25rem 1.25rem 1rem; box-shadow: var(--shadow-sm); margin-bottom: 0.5rem; } .product-content > h3:first-child { margin-top: 0.375rem; } .product-title { flex: 1; } /* --- Category headings --- */ h3 { font-size: 0.8125rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 1.25rem 0 0.625rem; padding-bottom: 0.375rem; border-bottom: 1px solid var(--border-subtle); } /* --- Policy cards --- */ .policy-section { background: var(--bg); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 1rem; margin-bottom: 0.625rem; transition: border-color var(--transition), box-shadow var(--transition); } .policy-section:hover { border-color: var(--border); box-shadow: var(--shadow-sm); } h4 { font-size: 0.9375rem; font-weight: 600; margin: 0 0 0.75rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } /* --- Section labels --- */ .section-label { font-weight: 600; margin: 0.75rem 0 0.375rem; font-size: 0.6875rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; } /* --- Tables --- */ .table-wrap { overflow-x: auto; margin-bottom: 0.75rem; border-radius: var(--radius-xs); } table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; min-width: 400px; } /* 2-column tables (Basics, Settings): fixed layout with 40/60 split */ table.t2 { table-layout: fixed; } table.t2 th:first-child, table.t2 td:first-child { width: 40%; } table.t2 th:last-child, table.t2 td:last-child { width: 60%; } /* 4-column tables (Assignments): auto layout, even columns */ table.t4 { table-layout: auto; } table.t4 th, table.t4 td { white-space: nowrap; } table.t4 th:first-child, table.t4 td:first-child { white-space: normal; width: 35%; } th { background: var(--header-bg); text-align: left; padding: 0.5rem 0.75rem; font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary); border-bottom: 1px solid var(--border); } td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border-subtle); vertical-align: top; color: var(--text); word-break: break-word; max-width: 500px; } .long-val { display: block; max-height: 2.6em; overflow: hidden; position: relative; word-break: break-all; } .long-val.expanded { max-height: none; } .long-val-btn { display: inline-block; margin-top: 0.25rem; padding: 0.125rem 0.5rem; background: var(--accent-soft); color: var(--accent); border: 1px solid var(--accent); border-radius: var(--radius-xs); font-size: 0.6875rem; font-weight: 600; cursor: pointer; transition: all var(--transition); } .long-val-btn:hover { background: var(--accent); color: #fff; } tr:last-child td { border-bottom: none; } tr:nth-child(even) td { background: var(--row-alt); } tr:hover td { background: var(--accent-soft); } /* --- Metadata rows (hidden by default, shown via toggle) --- */ .metadata-row { display: none; } .show-metadata .metadata-row { display: table-row; } /* --- Tooltip icon --- */ .tooltip-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: var(--border); color: var(--text-secondary); font-size: 0.625rem; font-weight: 700; cursor: help; position: relative; margin-left: 0.375rem; flex-shrink: 0; } .tooltip-icon::after { content: attr(data-tip); position: absolute; left: 50%; bottom: 130%; transform: translateX(-50%); background: var(--bg-card); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0.375rem 0.625rem; font-size: 0.6875rem; font-weight: 400; white-space: nowrap; box-shadow: var(--shadow-md); pointer-events: none; opacity: 0; transition: opacity var(--transition); z-index: 10; } .tooltip-icon:hover::after { opacity: 1; } /* --- Footer --- */ .footer { margin-top: 2rem; padding: 1rem 0; text-align: center; color: var(--muted); font-size: 0.75rem; } /* --- Filter toggle (only hide rows where the VALUE column is empty, not any column) --- */ /* Only hide empty rows in 2-column tables (Basics/Settings), never in assignments (t4) */ .hide-empty table.t2 tr:has(td:last-child > .empty-val:only-child):not(:has(td:first-child > .empty-val)) { display: none; } /* --- Search --- */ .search-hidden { display: none !important; } .tag-hidden { display: none !important; } .search-highlight { background: rgba(250,204,21,0.3); border-radius: 2px; } /* --- Notch warning bar --- */ .notch-bar { position: fixed; top: 0; left: 50%; transform: translateX(-50%); z-index: 1000; background: var(--accent); color: #fff; font-size: 0.75rem; font-weight: 600; padding: 0.25rem 1.5rem 0.3rem; border-radius: 0 0 var(--radius) var(--radius); box-shadow: var(--shadow-md); white-space: nowrap; letter-spacing: 0.02em; pointer-events: none; } .notch-bar .notch-warn { font-weight: 400; opacity: 0.85; margin-left: 0.75rem; font-size: 0.6875rem; } /* --- Tag filter pills in sidebar --- */ .tag-filter { padding: 0 1.25rem 0.75rem; border-bottom: 1px solid var(--border); flex-shrink: 0; } .tag-filter-title { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-bottom: 0.375rem; } .tag-pills { display: flex; flex-wrap: wrap; gap: 0.25rem; } .tag-pill { background: var(--bg); border: 1px solid var(--border); color: var(--text-secondary); padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; cursor: pointer; transition: all var(--transition); user-select: none; font-weight: 500; } .tag-pill:hover { border-color: var(--accent); color: var(--accent); } .tag-pill.active { background: var(--accent); color: #fff; border-color: var(--accent); } /* --- Sidebar panel (always visible on desktop, slide-out on mobile) --- */ .sidebar-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 998; opacity: 0; pointer-events: none; transition: opacity var(--transition); display: none; } .sidebar-backdrop.open { opacity: 1; pointer-events: auto; } .sidebar { position: fixed; top: 0; left: 0; width: 320px; height: 100vh; background: var(--bg-card); border-right: 1px solid var(--border); z-index: 999; display: flex; flex-direction: column; overflow: hidden; } .sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); flex-shrink: 0; } .sidebar-header h2 { font-size: 0.875rem; font-weight: 700; } .sidebar-close { background: none; border: none; cursor: pointer; color: var(--muted); font-size: 1.25rem; line-height: 1; padding: 0.25rem; transition: color var(--transition); } .sidebar-close:hover { color: var(--text); } /* --- Sidebar controls --- */ .sidebar-controls { padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 0.5rem; flex-shrink: 0; } .toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0; font-size: 0.8125rem; color: var(--text-secondary); cursor: pointer; user-select: none; } .toggle-row:hover { color: var(--text); } .toggle-row > span:first-child { font-weight: 500; } .toggle-switch { position: relative; width: 40px; height: 22px; flex-shrink: 0; } .toggle-switch input { opacity: 0; width: 0; height: 0; } .toggle-slider { position: absolute; inset: 0; background: var(--border); border-radius: 11px; cursor: pointer; transition: background var(--transition); } .toggle-slider::before { content: ''; position: absolute; left: 3px; top: 3px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: transform var(--transition); box-shadow: 0 1px 3px rgba(0,0,0,0.2); } .toggle-switch input:checked + .toggle-slider { background: var(--accent); } .toggle-switch input:checked + .toggle-slider::before { transform: translateX(18px); } .toggle-switch input:focus-visible + .toggle-slider { outline: 2px solid var(--accent); outline-offset: 2px; } /* --- Sidebar TOC --- */ .sidebar-toc { flex: 1; overflow-y: auto; padding: 0.75rem 1rem; scrollbar-width: thin; scrollbar-color: var(--border) transparent; } .sidebar-toc::-webkit-scrollbar { width: 4px; } .sidebar-toc::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } /* --- Floating buttons --- */ .fab-group { position: fixed; bottom: 1.5rem; right: 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 200; } .fab { width: 44px; height: 44px; border-radius: 50%; background: var(--accent); color: #fff; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: var(--shadow-md); transition: all var(--transition); font-size: 1.125rem; } .fab:hover { background: var(--accent-hover); box-shadow: var(--shadow-lg); transform: scale(1.05); } .fab:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } .fab-top { opacity: 0; pointer-events: none; transform: translateY(8px); } .fab-top.visible { opacity: 1; pointer-events: auto; transform: translateY(0); } /* --- Responsive --- */ @media (max-width: 1024px) { body { margin-left: 0; } .sidebar { left: -320px; width: 300px; max-width: 85vw; box-shadow: var(--shadow-lg); transition: left 300ms cubic-bezier(0.4, 0, 0.2, 1); } .sidebar.open { left: 0; } .sidebar-backdrop { display: block; } .fab-sidebar { display: flex; } } @media (min-width: 1025px) { .fab-sidebar { display: none; } .sidebar-close { display: none; } } @media (max-width: 768px) { body { padding: 0 1rem 2rem; font-size: 0.875rem; } .header h1 { font-size: 1.375rem; } .product-section > summary { padding: 0.75rem; font-size: 0.9375rem; } .policy-section { padding: 0.75rem; } td, th { padding: 0.375rem 0.5rem; } .fab-group { bottom: 1rem; right: 1rem; } } '@ # ------------------------------------------------------------------------- # Helper: encode a value for HTML output (null/empty -> muted em dash) # ------------------------------------------------------------------------- function ConvertTo-SafeHtmlValue { param([Parameter()][object]$Value) if ($null -eq $Value -or ($Value -is [string] -and [string]::IsNullOrEmpty($Value))) { return '<span class="muted empty-val">—</span>' } $str = [System.Net.WebUtility]::HtmlEncode($Value.ToString()) # Wrap long values (>200 chars) in a collapsible block with ellipsis button if ($str.Length -gt 200) { return "<span class=`"long-val`" id=`"lv$(Get-Random)`">$str</span><span class=`"long-val-btn`" onclick=`"var v=this.previousElementSibling;v.classList.toggle('expanded');this.textContent=v.classList.contains('expanded')?'Collapse':'Expand'`">Expand</span>" } return $str } # ------------------------------------------------------------------------- # Helper: determine if a setting name is a metadata field # ------------------------------------------------------------------------- function Test-MetadataSetting { param([Parameter()][string]$Name) if ([string]::IsNullOrWhiteSpace($Name)) { return $false } if ($Name -match '@odata') { return $true } if ($Name -eq 'policyGuid') { return $true } return $false } # ------------------------------------------------------------------------- # StringBuilder assembly # ------------------------------------------------------------------------- $sb = [System.Text.StringBuilder]::new(65536) $tenantNameEsc = [System.Net.WebUtility]::HtmlEncode($DocModel.TenantName) $baselineNames = if ($DocModel.Baselines) { $DocModel.Baselines } else { @() } $generatedAt = if ($DocModel.GeneratedAt -is [datetime]) { $DocModel.GeneratedAt.ToString('yyyy-MM-dd HH:mm:ss') } else { [string]$DocModel.GeneratedAt } # Count totals for header $totalPolicies = 0 $totalProducts = $DocModel.Products.Count foreach ($prodVal in $DocModel.Products.Values) { foreach ($catPols in $prodVal.Categories.Values) { $totalPolicies += @($catPols).Count } } # --- HTML head --- [void]$sb.AppendLine('<!DOCTYPE html>') [void]$sb.AppendLine('<html lang="en">') [void]$sb.AppendLine('<head>') [void]$sb.AppendLine('<meta charset="UTF-8">') [void]$sb.AppendLine('<meta name="viewport" content="width=device-width, initial-scale=1.0">') [void]$sb.AppendLine("<title>$tenantNameEsc - Tenant Documentation</title>") [void]$sb.AppendLine('<style>') [void]$sb.AppendLine($cssBlock) [void]$sb.AppendLine('</style>') [void]$sb.AppendLine('</head>') [void]$sb.AppendLine('<body>') # --- Header --- [void]$sb.AppendLine('<div id="top"></div>') [void]$sb.AppendLine('<div class="header">') [void]$sb.AppendLine("<h1>$tenantNameEsc</h1>") [void]$sb.AppendLine('<div class="header-meta">') [void]$sb.AppendLine("<span>$generatedAt UTC</span>") [void]$sb.AppendLine("<span>$totalProducts products</span>") [void]$sb.AppendLine("<span>$totalPolicies policies</span>") if ($baselineNames.Count -gt 0) { [void]$sb.AppendLine("<span>$($baselineNames.Count) baselines</span>") } # Show active filters if ($DocModel.FilterBaseline) { $filterBaselineEsc = [System.Net.WebUtility]::HtmlEncode($DocModel.FilterBaseline) [void]$sb.AppendLine("<span style=`"color:var(--accent);font-weight:600`">Baseline: $filterBaselineEsc</span>") } if ($DocModel.FilterTag) { $filterTagEsc = [System.Net.WebUtility]::HtmlEncode($DocModel.FilterTag) [void]$sb.AppendLine("<span style=`"color:var(--accent);font-weight:600`">Tag: $filterTagEsc</span>") } [void]$sb.AppendLine('</div>') # --- Search bar --- [void]$sb.AppendLine('<div style="margin-top:0.75rem"><input type="text" id="search-input" placeholder="Search policies, settings, values..." style="width:100%;padding:0.5rem 0.75rem;border:1px solid var(--border);border-radius:var(--radius-xs);background:var(--bg-card);color:var(--text);font-size:0.875rem;font-family:inherit;outline:none" oninput="searchPolicies(this.value)"></div>') [void]$sb.AppendLine('</div>') # --- Notch warning bar --- $notchLabel = if ($DocModel.FilterBaseline) { [System.Net.WebUtility]::HtmlEncode($DocModel.FilterBaseline) } else { $tenantNameEsc } [void]$sb.AppendLine("<div class=`"notch-bar`">$notchLabel · $totalPolicies policies<span class=`"notch-warn`">API coverage may be limited for some policy types</span></div>") # --- Sidebar backdrop + panel --- [void]$sb.AppendLine('<div class="sidebar-backdrop" id="sidebar-backdrop" onclick="closeSidebar()"></div>') [void]$sb.AppendLine('<aside class="sidebar" id="sidebar">') # Sidebar header [void]$sb.AppendLine('<div class="sidebar-header">') [void]$sb.AppendLine('<h2>Navigation</h2>') [void]$sb.AppendLine('<button class="sidebar-close" onclick="closeSidebar()" aria-label="Close">×</button>') [void]$sb.AppendLine('</div>') # Sidebar controls [void]$sb.AppendLine('<div class="sidebar-controls">') [void]$sb.AppendLine('<label class="toggle-row"><span>Hide empty fields</span><span class="toggle-switch"><input type="checkbox" id="chk-empty" onchange="toggleEmpty()"><span class="toggle-slider"></span></span></label>') [void]$sb.AppendLine('<label class="toggle-row"><span>Expand all sections</span><span class="toggle-switch"><input type="checkbox" id="chk-expand" onchange="toggleExpand()"><span class="toggle-slider"></span></span></label>') [void]$sb.AppendLine('<label class="toggle-row"><span>Dark mode</span><span class="toggle-switch"><input type="checkbox" id="chk-theme" onchange="toggleTheme()"><span class="toggle-slider"></span></span></label>') [void]$sb.AppendLine('<label class="toggle-row"><span>Show metadata<span class="tooltip-icon" data-tip="Show @odata.type and other metadata properties in settings tables">i</span></span><span class="toggle-switch"><input type="checkbox" id="chk-meta" onchange="toggleMeta()"><span class="toggle-slider"></span></span></label>') [void]$sb.AppendLine('</div>') # Sidebar tag filter pills (collect all unique tags from policies) $allTags = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($prodVal in $DocModel.Products.Values) { foreach ($catPols in $prodVal.Categories.Values) { foreach ($pol in @($catPols)) { $t = $pol.Basics['Tags'] if (-not [string]::IsNullOrWhiteSpace($t)) { foreach ($tagName in ($t -split ',\s*')) { if (-not [string]::IsNullOrWhiteSpace($tagName)) { [void]$allTags.Add($tagName.Trim()) } } } } } } if ($allTags.Count -gt 0) { [void]$sb.AppendLine('<div class="tag-filter">') [void]$sb.AppendLine('<div class="tag-filter-title">Filter by Tag</div>') [void]$sb.AppendLine('<div class="tag-pills">') foreach ($tagName in $allTags) { $tagEsc = [System.Net.WebUtility]::HtmlEncode($tagName) $tagSafe = $tagName -replace '[^a-zA-Z0-9 \-]', '' [void]$sb.AppendLine("<span class=`"tag-pill`" data-tag=`"$tagSafe`" onclick=`"toggleTagFilter(this,this.getAttribute('data-tag'))`">$tagEsc</span>") } [void]$sb.AppendLine('</div>') [void]$sb.AppendLine('</div>') } # Sidebar TOC [void]$sb.AppendLine('<div class="sidebar-toc toc-section">') [void]$sb.AppendLine('<ul class="toc-l1">') foreach ($prodName in $DocModel.Products.Keys) { $prodEsc = [System.Net.WebUtility]::HtmlEncode($prodName) $prodAnchor = ConvertTo-HtmlAnchorId -Text $prodName $prodPolicies = 0 foreach ($cp in $DocModel.Products[$prodName].Categories.Values) { $prodPolicies += @($cp).Count } [void]$sb.AppendLine('<li>') [void]$sb.AppendLine('<details>') [void]$sb.AppendLine("<summary><a href=`"#$prodAnchor`" onclick=`"navClick(event,'$prodAnchor')`">$prodEsc</a> <span class=`"badge`">$prodPolicies</span></summary>") [void]$sb.AppendLine('<ul class="toc-l2">') # Group categories by their first part (before " / ") for nested display $catGroups = [ordered]@{} foreach ($catName in ($DocModel.Products[$prodName].Categories.Keys | Sort-Object)) { if ($catName -match '^(.+?) / (.+)$') { $groupName = $Matches[1] if ($groupName -eq 'All') { $groupName = $Matches[2]; $subName = $null } else { $subName = $Matches[2] } } else { $groupName = $catName; $subName = $null } if (-not $catGroups.Contains($groupName)) { $catGroups[$groupName] = [System.Collections.Generic.List[object]]::new() } [void]$catGroups[$groupName].Add(@{ CatName = $catName; SubName = $subName }) } foreach ($groupName in $catGroups.Keys) { $groupEntries = $catGroups[$groupName] $groupEsc = [System.Net.WebUtility]::HtmlEncode($groupName) if ($groupEntries.Count -eq 1 -and $null -eq $groupEntries[0].SubName) { # Single category with no subcategory — show directly with policies $catName = $groupEntries[0].CatName $catAnchor = ConvertTo-HtmlAnchorId -Text "$prodName-$catName" $catPolicies = @($DocModel.Products[$prodName].Categories[$catName]) [void]$sb.AppendLine('<li>') [void]$sb.AppendLine('<details>') [void]$sb.AppendLine("<summary><a href=`"#$catAnchor`" onclick=`"navClick(event,'$catAnchor')`" style=`"font-size:0.8rem;color:var(--muted)`">$groupEsc</a></summary>") [void]$sb.AppendLine('<ul class="toc-l3">') foreach ($pol in $catPolicies) { $polNameEsc = [System.Net.WebUtility]::HtmlEncode($pol.Basics.Name) $polAnchor = ConvertTo-HtmlAnchorId -Text "$prodName-$catName-$($pol.Basics.Name)" [void]$sb.AppendLine("<li><a href=`"#$polAnchor`" onclick=`"navClick(event,'$polAnchor')`">$polNameEsc</a></li>") } [void]$sb.AppendLine('</ul>') [void]$sb.AppendLine('</details>') [void]$sb.AppendLine('</li>') } else { # Group with subcategories — nest them [void]$sb.AppendLine('<li>') [void]$sb.AppendLine('<details>') $firstCatAnchor = ConvertTo-HtmlAnchorId -Text "$prodName-$($groupEntries[0].CatName)" [void]$sb.AppendLine("<summary><a href=`"#$firstCatAnchor`" onclick=`"navClick(event,'$firstCatAnchor')`" style=`"font-size:0.8rem;color:var(--muted)`">$groupEsc</a></summary>") [void]$sb.AppendLine('<ul class="toc-l3">') foreach ($entry in $groupEntries) { $catName = $entry.CatName $subDisplay = if ($entry.SubName) { $entry.SubName } else { $groupName } $subEsc = [System.Net.WebUtility]::HtmlEncode($subDisplay) $catAnchor = ConvertTo-HtmlAnchorId -Text "$prodName-$catName" [void]$sb.AppendLine("<li><a href=`"#$catAnchor`" onclick=`"navClick(event,'$catAnchor')`"><strong>$subEsc</strong></a></li>") # Show policies under each subcategory foreach ($pol in @($DocModel.Products[$prodName].Categories[$catName])) { $polNameEsc = [System.Net.WebUtility]::HtmlEncode($pol.Basics.Name) $polAnchor = ConvertTo-HtmlAnchorId -Text "$prodName-$catName-$($pol.Basics.Name)" [void]$sb.AppendLine("<li style=`"padding-left:0.75rem`"><a href=`"#$polAnchor`" onclick=`"navClick(event,'$polAnchor')`">$polNameEsc</a></li>") } } [void]$sb.AppendLine('</ul>') [void]$sb.AppendLine('</details>') [void]$sb.AppendLine('</li>') } } [void]$sb.AppendLine('</ul>') [void]$sb.AppendLine('</details>') [void]$sb.AppendLine('</li>') } [void]$sb.AppendLine('</ul>') [void]$sb.AppendLine('</div>') [void]$sb.AppendLine('</aside>') # --- Floating action buttons --- [void]$sb.AppendLine('<div class="fab-group">') [void]$sb.AppendLine('<button class="fab fab-top" id="btn-top" onclick="scrollToTop()" aria-label="Back to top">') # SVG arrow up (inline, no CDN) [void]$sb.AppendLine('<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15l-6-6-6 6"/></svg>') [void]$sb.AppendLine('</button>') [void]$sb.AppendLine('<button class="fab fab-sidebar" id="btn-sidebar" onclick="openSidebar()" aria-label="Open navigation">') # SVG menu/list icon [void]$sb.AppendLine('<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18M3 6h18M3 18h18"/></svg>') [void]$sb.AppendLine('</button>') [void]$sb.AppendLine('</div>') # --- Content sections --- foreach ($prodName in $DocModel.Products.Keys) { $prodEsc = [System.Net.WebUtility]::HtmlEncode($prodName) $prodAnchor = ConvertTo-HtmlAnchorId -Text $prodName $prodPolicyCount = 0 foreach ($catPolicies in $DocModel.Products[$prodName].Categories.Values) { $prodPolicyCount += @($catPolicies).Count } # Product-level collapsible section [void]$sb.AppendLine('<details class="product-section">') [void]$sb.AppendLine("<summary id=`"$prodAnchor`"><span class=`"product-title`">$prodEsc</span> <span class=`"badge`">$prodPolicyCount policies</span></summary>") [void]$sb.AppendLine('<div class="product-content">') foreach ($catName in ($DocModel.Products[$prodName].Categories.Keys | Sort-Object)) { $catDisplayName = $catName -replace '^All / ', '' $catEsc = [System.Net.WebUtility]::HtmlEncode($catDisplayName) $catAnchor = ConvertTo-HtmlAnchorId -Text "$prodName-$catName" $policies = $DocModel.Products[$prodName].Categories[$catName] [void]$sb.AppendLine("<h3 id=`"$catAnchor`">$catEsc</h3>") foreach ($policy in @($policies)) { $policyNameEsc = [System.Net.WebUtility]::HtmlEncode($policy.Basics.Name) $settingsCount = if ($policy.Settings) { @($policy.Settings).Count } else { 0 } $policyAnchor = ConvertTo-HtmlAnchorId -Text "$prodName-$catName-$($policy.Basics.Name)" $tagsAttr = '' $tagsVal = $policy.Basics['Tags'] if (-not [string]::IsNullOrWhiteSpace($tagsVal)) { $tagsAttr = " data-tags=`"$([System.Net.WebUtility]::HtmlEncode($tagsVal))`"" } [void]$sb.AppendLine("<div class=`"policy-section`"$tagsAttr>") # Build tag badges HTML inline with the policy title $tagBadgesHtml = '' if (-not [string]::IsNullOrWhiteSpace($tagsVal)) { foreach ($tagName in ($tagsVal -split ',\s*')) { $tagEsc = [System.Net.WebUtility]::HtmlEncode($tagName.Trim()) $tagBadgesHtml += " <span class=`"badge tag-badge`">$tagEsc</span>" } } [void]$sb.AppendLine("<h4 id=`"$policyAnchor`">$policyNameEsc <span class=`"badge`">$settingsCount settings</span>$tagBadgesHtml</h4>") # --- Basics table (only non-empty fields) --- $basicsProps = @('Description', 'ProfileType', 'Platform', 'Created', 'Modified', 'ScopeTags') $nonEmptyBasics = @($basicsProps | Where-Object { $val = $policy.Basics[$_] $null -ne $val -and ($val -isnot [string] -or -not [string]::IsNullOrWhiteSpace($val)) }) if ($nonEmptyBasics.Count -gt 0) { [void]$sb.AppendLine('<div class="section-label">Basics</div>') [void]$sb.AppendLine('<div class="table-wrap"><table class="t2">') [void]$sb.AppendLine('<tr><th>Property</th><th>Value</th></tr>') foreach ($propName in $nonEmptyBasics) { $propLabel = [System.Net.WebUtility]::HtmlEncode($propName) $propVal = ConvertTo-SafeHtmlValue -Value $policy.Basics[$propName] [void]$sb.AppendLine("<tr><td>$propLabel</td><td>$propVal</td></tr>") } [void]$sb.AppendLine('</table></div>') } # --- Settings table --- if ($settingsCount -gt 0) { [void]$sb.AppendLine('<div class="section-label">Settings</div>') [void]$sb.AppendLine('<div class="table-wrap"><table class="t2">') [void]$sb.AppendLine('<tr><th>Setting</th><th>Value</th></tr>') foreach ($setting in @($policy.Settings)) { $settingNameEsc = [System.Net.WebUtility]::HtmlEncode($setting.Name) $settingVal = ConvertTo-SafeHtmlValue -Value $setting.Value $indentLevel = if ($null -ne $setting.Indent) { [int]$setting.Indent } else { 0 } $paddingRem = $indentLevel * 1.5 # Determine if this is a metadata row $metaClass = '' if (Test-MetadataSetting -Name $setting.Name) { $metaClass = ' class="metadata-row"' } if ($indentLevel -gt 0) { $paddingStr = $paddingRem.ToString('0.#', [System.Globalization.CultureInfo]::InvariantCulture) [void]$sb.AppendLine("<tr$metaClass><td style=`"padding-left: $($paddingStr)rem`">$settingNameEsc</td><td>$settingVal</td></tr>") } else { [void]$sb.AppendLine("<tr$metaClass><td>$settingNameEsc</td><td>$settingVal</td></tr>") } } [void]$sb.AppendLine('</table></div>') } # --- Assignments --- $assignmentsCount = if ($policy.Assignments) { @($policy.Assignments).Count } else { 0 } [void]$sb.AppendLine('<div class="section-label">Assignments</div>') if ($assignmentsCount -gt 0) { [void]$sb.AppendLine('<div class="table-wrap"><table class="t4">') [void]$sb.AppendLine('<tr><th>Target</th><th>Type</th><th>Filter</th><th>Filter Mode</th></tr>') foreach ($assignment in @($policy.Assignments)) { $targetVal = ConvertTo-SafeHtmlValue -Value $assignment.Target $typeVal = ConvertTo-SafeHtmlValue -Value $assignment.Type $filterVal = ConvertTo-SafeHtmlValue -Value $assignment.Filter $filterModeVal = ConvertTo-SafeHtmlValue -Value $assignment.FilterMode [void]$sb.AppendLine("<tr><td>$targetVal</td><td>$typeVal</td><td>$filterVal</td><td>$filterModeVal</td></tr>") } [void]$sb.AppendLine('</table></div>') } else { [void]$sb.AppendLine('<p class="muted" style="font-size:0.8125rem;margin:0.25rem 0">None</p>') } [void]$sb.AppendLine('</div>') } } [void]$sb.AppendLine('</div>') [void]$sb.AppendLine('</details>') } # --- Footer --- [void]$sb.AppendLine('<div class="footer">') [void]$sb.AppendLine("<p>Generated $generatedAt UTC · $tenantNameEsc · $totalPolicies policies across $totalProducts products</p>") [void]$sb.AppendLine('</div>') # --- JavaScript --- $jsBlock = @' <script> function openSidebar(){document.getElementById('sidebar').classList.add('open');document.getElementById('sidebar-backdrop').classList.add('open')} function closeSidebar(){document.getElementById('sidebar').classList.remove('open');document.getElementById('sidebar-backdrop').classList.remove('open')} function scrollToTop(){window.scrollTo({top:0,behavior:'smooth'})} function toggleMeta(){document.body.classList.toggle('show-metadata')} var _activeTags=[]; function searchPolicies(q){var prods=document.querySelectorAll('.product-section');q=q.toLowerCase().trim();prods.forEach(function(pr){pr.querySelectorAll('mark.search-highlight').forEach(function(h){var p=h.parentNode;p.replaceChild(document.createTextNode(h.textContent),h);p.normalize()});var vis=0;pr.querySelectorAll('.policy-section').forEach(function(p){if(p.classList.contains('tag-hidden')){return}var txt=p.textContent.toLowerCase();if(!q||txt.indexOf(q)>=0){p.classList.remove('search-hidden');vis++}else{p.classList.add('search-hidden')}});if(q){if(vis>0){pr.open=true;pr.style.display=''}else{pr.open=false;pr.style.display='none'}}else{pr.style.display='';pr.open=false}});document.querySelectorAll('h3').forEach(function(h){if(!q){h.style.display='';return}var next=h.nextElementSibling;var hasVis=false;while(next&&!next.matches('h3')){if(next.classList.contains('policy-section')&&!next.classList.contains('search-hidden')&&!next.classList.contains('tag-hidden')){hasVis=true;break}next=next.nextElementSibling}h.style.display=hasVis?'':'none'});if(!q)return;var re=new RegExp('('+q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+')','gi');prods.forEach(function(pr){pr.querySelectorAll('.policy-section:not(.search-hidden):not(.tag-hidden) td, .policy-section:not(.search-hidden):not(.tag-hidden) h4').forEach(function(el){el.childNodes.forEach(function(n){if(n.nodeType===3&&re.test(n.textContent)){var s=document.createElement('span');s.innerHTML=n.textContent.replace(re,'<mark class="search-highlight">$1</mark>');n.parentNode.replaceChild(s,n)}})})})} function toggleTagFilter(el,tag){var isActive=!el.classList.contains('active');var i=_activeTags.indexOf(tag);if(isActive&&i<0)_activeTags.push(tag);else if(!isActive&&i>=0)_activeTags.splice(i,1);document.querySelectorAll('.tag-pill[data-tag="'+tag+'"]').forEach(function(p){if(isActive)p.classList.add('active');else p.classList.remove('active')});applyTagFilter()} function applyTagFilter(){document.querySelectorAll('.policy-section').forEach(function(p){if(_activeTags.length===0){p.classList.remove('tag-hidden');return}var t=p.getAttribute('data-tags')||'';var found=_activeTags.some(function(tag){return t.toLowerCase().indexOf(tag.toLowerCase())>=0});if(found)p.classList.remove('tag-hidden');else p.classList.add('tag-hidden')});document.querySelectorAll('.product-section').forEach(function(pr){var vis=pr.querySelectorAll('.policy-section:not(.tag-hidden)').length;if(_activeTags.length>0&&vis>0)pr.open=true});var si=document.getElementById('search-input');if(si.value)searchPolicies(si.value)} function navClick(e,targetId){if(e)e.preventDefault();document.getElementById('search-input').value='';searchPolicies('');closeSidebar();var el=document.getElementById(targetId);if(el){var prod=el.closest('details.product-section');if(prod)prod.open=true;el.scrollIntoView({behavior:'smooth',block:'start'})}} function toggleEmpty(){document.body.classList.toggle('hide-empty')} function toggleExpand(){var c=document.getElementById('chk-expand').checked;document.querySelectorAll('details.product-section').forEach(function(d){d.open=c});document.querySelectorAll('.sidebar-toc details').forEach(function(d){d.open=c})} function toggleTheme(){var r=document.documentElement,c=document.getElementById('chk-theme');if(c.checked){r.classList.remove('light');r.classList.add('dark');localStorage.setItem('theme','dark')}else{r.classList.remove('dark');r.classList.add('light');localStorage.setItem('theme','light')}} (function(){var s=localStorage.getItem('theme'),ct=document.getElementById('chk-theme');if(s==='dark'){document.documentElement.classList.add('dark');ct.checked=true}else if(s==='light'){document.documentElement.classList.add('light')}else if(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches){ct.checked=true;document.documentElement.classList.add('dark')} window.addEventListener('scroll',function(){var b=document.getElementById('btn-top');if(window.scrollY>400){b.classList.add('visible')}else{b.classList.remove('visible')}})})(); </script> '@ [void]$sb.AppendLine($jsBlock) [void]$sb.AppendLine('</body>') [void]$sb.Append('</html>') $sb.ToString() } |