Private/DhYamlWriter.ps1

# ---------------------------------------------------------------------------
# Minimal, self-contained YAML emitter for the DashHtml layout schema.
# Not a general YAML library — it emits the block-style subset that
# Read-DhYaml understands, so ConvertTo-DhYaml / ConvertFrom-DhYaml round-trip.
#
# Supported: block mappings, block sequences (for arrays of maps/arrays),
# flow sequences [a, b] (for arrays of scalars), scalars typed by value
# (string / number / bool / null). No anchors, tags, or block scalars.
# ---------------------------------------------------------------------------

function Test-DhYamlBareSafe {
    <# True if a string can be emitted unquoted without ambiguity. #>
    param([string] $s)
    if ([string]::IsNullOrEmpty($s)) { return $false }
    # Reserved words / number-looking / bool-looking must be quoted so they
    # round-trip back as STRINGS rather than being coerced.
    if ($s -match '^(true|false|null|yes|no|on|off|~)$') { return $false }
    if ($s -match '^-?\d+(\.\d+)?$') { return $false }
    # Safe set: starts alnum, then word / space / . - / @ % : (no leading/trailing space)
    if ($s -match '^[A-Za-z0-9][A-Za-z0-9 _.\-/@%]*$' -and $s -notmatch '\s$') { return $true }
    return $false
}

function ConvertTo-DhYamlScalar {
    <# Render a single scalar value with its type preserved. #>
    param([object] $v)
    if ($null -eq $v) { return 'null' }
    if ($v -is [bool] -or $v -is [System.Management.Automation.SwitchParameter]) { return ([bool]$v).ToString().ToLower() }
    if ($v -is [int] -or $v -is [long] -or $v -is [double] -or $v -is [single] -or $v -is [decimal]) {
        # InvariantCulture so decimals use a period, not a locale comma.
        return $v.ToString([System.Globalization.CultureInfo]::InvariantCulture)
    }
    $s = [string]$v
    if (Test-DhYamlBareSafe $s) { return $s }
    # Double-quote with JSON-ish escaping
    $esc = $s -replace '\\', '\\' -replace '"', '\"' -replace "`t", '\t' -replace "`r", '\r' -replace "`n", '\n'
    return '"' + $esc + '"'
}

function Test-DhYamlScalar {
    param([object] $v)
    return ($null -eq $v) -or ($v -is [string]) -or ($v -is [bool]) -or `
           ($v -is [System.Management.Automation.SwitchParameter]) -or `
           ($v -is [int]) -or ($v -is [long]) -or ($v -is [double]) -or `
           ($v -is [single]) -or ($v -is [decimal])
}

function Test-DhYamlMap {
    param([object] $v)
    return ($v -is [System.Collections.IDictionary])
}

function Test-DhYamlSeq {
    param([object] $v)
    return ($v -is [System.Collections.IEnumerable]) -and ($v -isnot [string]) -and (-not (Test-DhYamlMap $v))
}

function ConvertTo-DhYamlText {
    <#
    .SYNOPSIS Recursively emit an object as block-style YAML text.
    .PARAMETER InputObject Hashtable / array / scalar to emit.
    .PARAMETER Indent Current indent depth (spaces = Indent * 2).
    #>

    param(
        [object] $InputObject,
        [int]    $Indent = 0
    )
    $sb  = [System.Text.StringBuilder]::new()
    $pad = ' ' * $Indent

    if (Test-DhYamlMap $InputObject) {
        foreach ($key in $InputObject.Keys) {
            $val = $InputObject[$key]
            if (Test-DhYamlScalar $val) {
                [void]$sb.AppendLine("$pad${key}: $(ConvertTo-DhYamlScalar $val)")
            }
            elseif (Test-DhYamlSeq $val) {
                $arr = @($val)
                if ($arr.Count -eq 0) {
                    [void]$sb.AppendLine("$pad${key}: []")
                }
                elseif (@($arr | Where-Object { -not (Test-DhYamlScalar $_) }).Count -eq 0) {
                    # array of scalars -> flow sequence
                    $flow = ($arr | ForEach-Object { ConvertTo-DhYamlScalar $_ }) -join ', '
                    [void]$sb.AppendLine("$pad${key}: [$flow]")
                }
                else {
                    # array of maps/arrays -> block sequence, indented under the key
                    # (one level deeper so the reader's "child indent > key indent" holds)
                    [void]$sb.AppendLine("$pad${key}:")
                    [void]$sb.Append((ConvertTo-DhYamlSeqBlock -Items $arr -Indent ($Indent + 1)))
                }
            }
            else {
                # nested map
                [void]$sb.AppendLine("$pad${key}:")
                [void]$sb.Append((ConvertTo-DhYamlText -InputObject $val -Indent ($Indent + 1)))
            }
        }
    }
    elseif (Test-DhYamlSeq $InputObject) {
        [void]$sb.Append((ConvertTo-DhYamlSeqBlock -Items @($InputObject) -Indent $Indent))
    }
    else {
        [void]$sb.AppendLine("$pad$(ConvertTo-DhYamlScalar $InputObject)")
    }

    return $sb.ToString()
}

function ConvertTo-DhYamlSeqBlock {
    <# Emit an array as a block sequence at the given indent. #>
    param([object[]] $Items, [int] $Indent)
    $sb  = [System.Text.StringBuilder]::new()
    $pad = ' ' * $Indent
    foreach ($item in $Items) {
        if (Test-DhYamlScalar $item) {
            [void]$sb.AppendLine("$pad- $(ConvertTo-DhYamlScalar $item)")
        }
        elseif (Test-DhYamlMap $item) {
            # First key inline after "- ", the rest indented one level deeper.
            $keys = @($item.Keys)
            if ($keys.Count -eq 0) { [void]$sb.AppendLine("$pad- {}"); continue }
            $childPad = ' ' * ($Indent + 1)
            $first = $true
            foreach ($k in $keys) {
                $v = $item[$k]
                $prefix = if ($first) { "$pad- " } else { $childPad }
                if (Test-DhYamlScalar $v) {
                    [void]$sb.AppendLine("$prefix${k}: $(ConvertTo-DhYamlScalar $v)")
                }
                elseif (Test-DhYamlSeq $v) {
                    $arr = @($v)
                    if ($arr.Count -eq 0) {
                        [void]$sb.AppendLine("$prefix${k}: []")
                    }
                    elseif (@($arr | Where-Object { -not (Test-DhYamlScalar $_) }).Count -eq 0) {
                        $flow = ($arr | ForEach-Object { ConvertTo-DhYamlScalar $_ }) -join ', '
                        [void]$sb.AppendLine("$prefix${k}: [$flow]")
                    }
                    else {
                        [void]$sb.AppendLine("$prefix${k}:")
                        [void]$sb.Append((ConvertTo-DhYamlSeqBlock -Items $arr -Indent ($Indent + 2)))
                    }
                }
                else {
                    [void]$sb.AppendLine("$prefix${k}:")
                    [void]$sb.Append((ConvertTo-DhYamlText -InputObject $v -Indent ($Indent + 2)))
                }
                $first = $false
            }
        }
        else {
            # nested array element
            [void]$sb.AppendLine("$pad-")
            [void]$sb.Append((ConvertTo-DhYamlText -InputObject $item -Indent ($Indent + 1)))
        }
    }
    return $sb.ToString()
}