private/Controls/ConvertTo-UiDefinitionArray.ps1

function ConvertTo-UiDefinitionArray {
    <#
    .SYNOPSIS
        Normalizes standin scriptblock, array, or hashtable input into a plain hashtable array.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]
        [object]$InputObject,

        [Parameter(Mandatory)]
        [string]$ParameterName,

        [Parameter(Mandatory)]
        [string]$CallerName,

        [switch]$PassThruDictionary,

        [switch]$AllowString,

        [switch]$AllowObject
    )

    if ($null -eq $InputObject) { return $null }

    # Legacy dictionary form (RowContextMenu's [ordered]@{} keyed by label) passes through whole, order and identity intact. A bare standin item is also a dictionary, but a string Text plus a scriptblock Action can't be the legacy form (legacy values are scriptblocks or nested dicts), so that one falls through to normalization instead of rendering as two bogus menu entries.
    if ($PassThruDictionary -and $InputObject -is [System.Collections.IDictionary]) {
        if (!($InputObject['Text'] -is [string] -and $InputObject['Action'] -is [scriptblock])) { return $InputObject }
    }

    if ($InputObject -is [scriptblock]) {
        # Definition blocks emit data, nothing else. CurrentParent goes null for the duration so a stray New-UiLabel in there throws via Assert-UiSession instead of quietly landing in the enclosing panel.
        $session = $null
        try { $session = Get-UiSession } catch { }
        $oldParent = if ($session) { $session.CurrentParent } else { $null }
        if ($session) { $session.CurrentParent = $null }

        # Restore outside try/finally for PS 5.1 closure compatibility - same dance as the layout containers.
        try { $definitions = @(& $InputObject) }
        catch {
            if ($session) { $session.CurrentParent = $oldParent }
            throw
        }
        if ($session) { $session.CurrentParent = $oldParent }
    }
    else {
        # Covers the legacy hashtable[] form, a mixed array, and a single bare hashtable (hashtables don't unroll).
        $definitions = @($InputObject)
    }

    $normalized = [System.Collections.Generic.List[object]]::new()
    foreach ($definition in $definitions) {
        if ($null -eq $definition) { continue }
        if ($AllowString -and $definition -is [string]) { $normalized.Add($definition); continue }
        if ($definition -is [hashtable]) { $normalized.Add($definition); continue }

        # An [ordered]@{} emitted out of legacy habit still works. Copied into a plain table so the downstream [hashtable[]] casts and -is [hashtable] gates hold.
        if ($definition -is [System.Collections.IDictionary]) {
            $copy = @{}
            foreach ($key in $definition.Keys) { $copy[$key] = $definition[$key] }
            $normalized.Add($copy)
            continue
        }

        # Full type name since [pscustomobject] is PSObject and matches a wrapped string too
        if ($AllowObject -and $definition -is [System.Management.Automation.PSCustomObject]) {
            $copy = @{}
            foreach ($prop in $definition.PSObject.Properties) { $copy[$prop.Name] = $prop.Value }
            $normalized.Add($copy)
            continue
        }

        throw "$CallerName`: $ParameterName entries must be hashtables (from the New-Ui standin functions or written by hand). Got [$($definition.GetType().Name)]."
    }

    # Comma here so a one-item result survives the return as an array. A bare hashtable's .Count is its key count, which silently missizes anything counting entries.
    return , $normalized.ToArray()
}