Private/ConvertTo-InforcerMarkdown.ps1

function ConvertTo-MarkdownAnchor {
    <#
    .SYNOPSIS
        Converts a display name string to a GitHub Flavored Markdown anchor fragment.
    .PARAMETER Text
        The heading text to convert.
    .OUTPUTS
        [string] Lowercase anchor fragment (e.g. 'Device Configuration' -> 'device-configuration').
    #>

    param([Parameter(Mandatory)][string]$Text)
    ($Text.ToLower() -replace '[^a-z0-9\s-]', '' -replace '\s+', '-').Trim('-')
}

function ConvertTo-MarkdownTable {
    <#
    .SYNOPSIS
        Builds a GFM pipe-delimited Markdown table from headers and rows.
    .PARAMETER Headers
        Array of column header strings.
    .PARAMETER Rows
        Array of string arrays, one per data row. Each inner array must have the same
        number of elements as Headers.
    .OUTPUTS
        [string] Complete GFM table block (header row + separator + data rows).
    #>

    param(
        [Parameter(Mandatory)][string[]]$Headers,
        [Parameter(Mandatory)][object[]]$Rows
    )

    $sb = [System.Text.StringBuilder]::new()

    # Header row
    $headerCells = $Headers | ForEach-Object { " $_ " }
    [void]$sb.AppendLine("|$($headerCells -join '|')|")

    # Separator row
    $sepCells = $Headers | ForEach-Object { ' --- ' }
    [void]$sb.AppendLine("|$($sepCells -join '|')|")

    # Data rows
    foreach ($row in $Rows) {
        $cells = @($row) | ForEach-Object {
            $val = if ($null -eq $_ -or "$_" -eq '') { [char]0x2014 } else { "$_" }
            # Escape pipe characters (D-20)
            $val = $val -replace '\|', '\|'
            " $val "
        }
        [void]$sb.AppendLine("|$($cells -join '|')|")
    }

    $sb.ToString().TrimEnd()
}

function ConvertTo-InforcerMarkdown {
    <#
    .SYNOPSIS
        Renders a DocModel as a GitHub Flavored Markdown document.
    .DESCRIPTION
        Consumes the format-agnostic DocModel produced by ConvertTo-InforcerDocModel and returns
        a complete GFM Markdown string with:
        - Document header (tenant name, generation timestamp, baseline)
        - Anchor-based Table of Contents (Products -> Categories, two-level)
        - Per-product (##), per-category (###), per-policy (####) headings
        - Basics table (Property | Value), Settings table (Setting | Value), Assignments table
        - Pipe characters in cell values escaped as \| (per D-20)
        - Null/empty values rendered as em dash (U+2014) (per D-10)
        - Child settings (Indent > 0) prefixed with arrow markers (U+21B3) (per D-08)

        No file I/O, no API calls. Returns the Markdown string only.
    .PARAMETER DocModel
        Hashtable from ConvertTo-InforcerDocModel containing TenantName, TenantId,
        GeneratedAt, BaselineName, and Products (OrderedDictionary).
    .OUTPUTS
        [string] Complete GFM Markdown document.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$DocModel
    )

    $sb = [System.Text.StringBuilder]::new()

    # -------------------------------------------------------------------------
    # Document header
    # -------------------------------------------------------------------------
    [void]$sb.AppendLine("# Tenant Documentation: $($DocModel.TenantName)")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("*Generated: $($DocModel.GeneratedAt.ToString('yyyy-MM-dd HH:mm:ss')) UTC*")
    [void]$sb.AppendLine()

    if (-not [string]::IsNullOrWhiteSpace($DocModel.BaselineName)) {
        [void]$sb.AppendLine("*Baseline: $($DocModel.BaselineName)*")
        [void]$sb.AppendLine()
    }

    # -------------------------------------------------------------------------
    # Table of Contents
    # -------------------------------------------------------------------------
    [void]$sb.AppendLine('## Table of Contents')
    [void]$sb.AppendLine()

    foreach ($prodName in $DocModel.Products.Keys) {
        $prodAnchor = ConvertTo-MarkdownAnchor -Text $prodName
        [void]$sb.AppendLine("- [$prodName](#$prodAnchor)")
        $product = $DocModel.Products[$prodName]
        foreach ($catName in $product.Categories.Keys) {
            $catAnchor = ConvertTo-MarkdownAnchor -Text $catName
            [void]$sb.AppendLine(" - [$catName](#$catAnchor)")
        }
    }
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('---')
    [void]$sb.AppendLine()

    # -------------------------------------------------------------------------
    # Content sections
    # -------------------------------------------------------------------------
    foreach ($prodName in $DocModel.Products.Keys) {
        [void]$sb.AppendLine("## $prodName")
        [void]$sb.AppendLine()

        $product = $DocModel.Products[$prodName]

        foreach ($catName in $product.Categories.Keys) {
            [void]$sb.AppendLine("### $catName")
            [void]$sb.AppendLine()

            $policies = $product.Categories[$catName]

            foreach ($policy in @($policies)) {
                $policyName = if ($policy.Basics -and $policy.Basics.Name) { $policy.Basics.Name } else { 'Unknown Policy' }
                [void]$sb.AppendLine("#### $policyName")
                [void]$sb.AppendLine()

                # Basics table - skip Name row (it is the heading)
                $basicsRows = @()
                $basics = $policy.Basics
                $basicsProps = @('Description', 'ProfileType', 'Platform', 'Created', 'Modified', 'ScopeTags')
                foreach ($prop in $basicsProps) {
                    $basicsRows += ,@($prop, $basics[$prop])
                }
                [void]$sb.AppendLine((ConvertTo-MarkdownTable -Headers @('Property', 'Value') -Rows $basicsRows))
                [void]$sb.AppendLine()

                # Settings table (only if there are settings)
                if ($policy.Settings -and $policy.Settings.Count -gt 0) {
                    $settingsRows = @()
                    foreach ($setting in @($policy.Settings)) {
                        $settingName = if ($setting.Indent -gt 0) {
                            "$(' ' * $setting.Indent)" + [char]0x21B3 + " $($setting.Name)"
                        } else {
                            $setting.Name
                        }
                        $settingsRows += ,@($settingName, $setting.Value)
                    }
                    [void]$sb.AppendLine((ConvertTo-MarkdownTable -Headers @('Setting', 'Value') -Rows $settingsRows))
                    [void]$sb.AppendLine()
                }

                # Assignments table (only if there are assignments)
                if ($policy.Assignments -and $policy.Assignments.Count -gt 0) {
                    $assignmentRows = @()
                    foreach ($assignment in @($policy.Assignments)) {
                        $assignmentRows += ,@($assignment.Target, $assignment.Type, $assignment.Filter, $assignment.FilterMode)
                    }
                    [void]$sb.AppendLine((ConvertTo-MarkdownTable -Headers @('Target', 'Type', 'Filter', 'Filter Mode') -Rows $assignmentRows))
                    [void]$sb.AppendLine()
                }
            }
        }
    }

    $sb.ToString()
}