Private/ConvertTo-DhSectionsJson.ps1

function ConvertTo-DhSectionsJson {
    <#
    .SYNOPSIS Serialise normalised detail sections (see ConvertTo-DhSections) to a JSON array (v2.3.0).
    .DESCRIPTION
        Keys are lower-camel, in a fixed order, so the same input always gives the
        same bytes. Numbers use the invariant culture. Every string goes through
        ConvertTo-DhJsonString, so '</script' cannot break out of the page.
    #>

    [OutputType([string])]
    param([AllowNull()] [object[]] $Sections)

    $inv = [cultureinfo]::InvariantCulture
    $str = { param($s) ConvertTo-DhJsonString ([string]$s) }
    $parts = foreach ($s in @($Sections)) {
        if ($null -eq $s) { continue }
        $kind = [string]$s.Kind
        $head = "`"kind`":$(& $str $kind),`"title`":$(& $str $s.Title)"
        switch ($kind) {
            'kv' {
                $items = (@($s.Items) | ForEach-Object {
                    "{`"label`":$(& $str $_.Label),`"value`":$(& $str $_.Value),`"status`":$(& $str $_.Status)}"
                }) -join ','
                "{$head,`"columns`":$([int]$s.Columns),`"items`":[$items]}"
            }
            'bars' {
                $items = (@($s.Items) | ForEach-Object {
                    $v = if ($null -eq $_.Value) { 'null' } else { ([double]$_.Value).ToString('R', $inv) }
                    $d = if ($null -eq $_.Display) { 'null' } else { & $str $_.Display }
                    "{`"label`":$(& $str $_.Label),`"value`":$v,`"display`":$d,`"status`":$(& $str $_.Status)}"
                }) -join ','
                "{$head,`"max`":$(([double]$s.Max).ToString('R', $inv)),`"unit`":$(& $str $s.Unit),`"items`":[$items]}"
            }
            'statuslist' {
                $items = (@($s.Items) | ForEach-Object {
                    $t = if ($null -eq $_.Text) { 'null' } else { & $str $_.Text }
                    "{`"label`":$(& $str $_.Label),`"status`":$(& $str $_.Status),`"text`":$t}"
                }) -join ','
                "{$head,`"items`":[$items]}"
            }
            'checks' {
                $items = (@($s.Items) | ForEach-Object {
                    $ok = if ($null -eq $_.Ok) { 'null' } else { ([bool]$_.Ok).ToString().ToLowerInvariant() }
                    "{`"label`":$(& $str $_.Label),`"ok`":$ok,`"status`":$(& $str $_.Status),`"note`":$(& $str $_.Note)}"
                }) -join ','
                "{$head,`"items`":[$items]}"
            }
            'table' {
                $cols = (@($s.Columns) | ForEach-Object { & $str $_ }) -join ','
                $rows = (@($s.Rows) | ForEach-Object {
                    '[' + ((@($_) | ForEach-Object {
                        if ($_ -is [System.Collections.IDictionary]) { "{`"text`":$(& $str $_.Text),`"status`":$(& $str $_.Status)}" }
                        else { & $str $_ }
                    }) -join ',') + ']'
                }) -join ','
                "{$head,`"columns`":[$cols],`"maxRows`":$([int]$s.MaxRows),`"rows`":[$rows]}"
            }
            'links' {
                $items = (@($s.Items) | ForEach-Object { "{`"label`":$(& $str $_.Label),`"url`":$(& $str $_.Url)}" }) -join ','
                "{$head,`"items`":[$items]}"
            }
            default {
                "{$head,`"text`":$(& $str $s.Text)}"
            }
        }
    }
    return '[' + (@($parts) -join ',') + ']'
}

function ConvertTo-DhDetailJson {
    <#
    .SYNOPSIS Serialise a normalised Detail spec (see ConvertTo-DhDetailSpec) to a JSON object, or 'null' (v2.3.0).
    #>

    [OutputType([string])]
    param([AllowNull()] $Detail)
    if ($null -eq $Detail) { return 'null' }
    return "{`"title`":$(ConvertTo-DhJsonString ([string]$Detail.Title)),`"subtitle`":$(ConvertTo-DhJsonString ([string]$Detail.Subtitle)),`"sections`":$(ConvertTo-DhSectionsJson $Detail.Sections)}"
}