Private/Get-DhNavModel.ps1

<#
    Nav model — builds the menu tree from the declared items, decides the initial
    view, and emits the per-section attributes the runtime matches on.

    Everything here is depth-agnostic: no function mentions a number of levels.
    The tree is built from the paths the items declare, so adding a 4th or 5th
    level is data, not code (plan-v2.md D6).
#>


function New-DhNavNode {
    param([string[]] $Path = @(), [string] $Segment = '')
    return [ordered]@{
        Segment    = $Segment
        Path       = @($Path)
        Label      = $Segment      # overridden by the first -NavLabel seen
        Order      = 0             # from -NavOrder; 0 = declaration order
        Seq        = 0             # declaration order tiebreak
        HasContent = $false        # an item sits exactly here
        Children   = [ordered]@{}
    }
}

function Get-DhNavItem {
    <# Flatten every nav-declaring item in the report into a uniform shape. #>
    param([System.Collections.Specialized.OrderedDictionary] $Report)

    $items = [System.Collections.Generic.List[hashtable]]::new()
    $seq = 0

    foreach ($t in @($Report.Tables)) {
        if (-not $t) { continue }
        $items.Add(@{
            Kind  = 'Table'; Id = $t.Id
            Path  = @(if ($t.Contains('NavSegments')) { $t.NavSegments } else { @() })
            Scope = if ($t.Contains('NavScope') -and $t.NavScope) { $t.NavScope } else { 'Global' }
            Label = if ($t.Contains('NavLabel') -and $t.NavLabel) { $t.NavLabel } else { $t.Title }
            Order = if ($t.Contains('NavOrder')) { [int]$t.NavOrder } else { 0 }
            Seq   = if ($t.Contains('NavSeqNo')) { [int]$t.NavSeqNo } else { $seq }
        })
        $seq++
    }
    if ($Report.Contains('Blocks')) {
        foreach ($b in @($Report.Blocks)) {
            if (-not $b) { continue }
            $items.Add(@{
                Kind  = 'Block'; Id = $b.Id
                Path  = @(if ($b.Contains('NavSegments')) { $b.NavSegments } else { @() })
                Scope = if ($b.Contains('NavScope') -and $b.NavScope) { $b.NavScope } else { 'Global' }
                Label = if ($b.Contains('NavLabel') -and $b.NavLabel) { $b.NavLabel } else { $b.Id }
                Order = if ($b.Contains('NavOrder')) { [int]$b.NavOrder } else { 0 }
                Seq   = if ($b.Contains('NavSeqNo')) { [int]$b.NavSeqNo } else { $seq }
            })
            $seq++
        }
    }
    return $items
}

function Get-DhNavTree {
    <#
    .SYNOPSIS Build the menu tree from every declared item.
    .OUTPUTS The root node; children keyed by segment, in render order.
    #>

    param([System.Collections.Specialized.OrderedDictionary] $Report)

    $root  = New-DhNavNode
    $items = Get-DhNavItem -Report $Report

    foreach ($it in $items) {
        $path = @($it.Path)
        if ($path.Count -eq 0) { continue }   # page-global: no menu node

        $node = $root
        for ($i = 0; $i -lt $path.Count; $i++) {
            $seg = $path[$i]
            if (-not $node.Children.Contains($seg)) {
                $node.Children[$seg] = New-DhNavNode -Path @($path[0..$i]) -Segment $seg
                $node.Children[$seg].Seq = $it.Seq
            }
            $node = $node.Children[$seg]
        }
        # The leaf of an Exact item is a view that holds content of its own.
        if ($it.Scope -eq 'Exact') { $node.HasContent = $true }
        if ($it.Label -and $node.Label -eq $node.Segment) { $node.Label = $it.Label }
        if ($it.Order -ne 0 -and $node.Order -eq 0)       { $node.Order = $it.Order }
    }
    return $root
}

function Get-DhNavChildOrder {
    <# Children in render order: explicit -NavOrder first, then declaration order. #>
    param($Node)
    return @($Node.Children.Values |
        Sort-Object @{ Expression = { if ($_.Order -ne 0) { 0 } else { 1 } } },
                    @{ Expression = { $_.Order } },
                    @{ Expression = { $_.Seq } })
}

function Get-DhNavInitialPath {
    <#
    .SYNOPSIS
        The view the report opens on.
    .DESCRIPTION
        Descends from the root to the first node that holds content of its own,
        then STOPS. At depth <= 3 this is identical to 1.x descend-to-first-leaf;
        at depth 4+ it stops a single click teleporting the reader four levels
        down (plan-v2.md WP3.3).
        Returns an empty array when the report declares no nav at all.
    #>

    param($Tree)

    $path = [System.Collections.Generic.List[string]]::new()
    $node = $Tree
    while ($true) {
        # @(...) is load-bearing: a single-child node makes Get-DhNavChildOrder
        # return one object, PowerShell unrolls it on return, and $children[0]
        # would then index the OrderedDictionary by the KEY 0 and yield $null.
        $children = @(Get-DhNavChildOrder -Node $node)
        if ($children.Count -eq 0) { break }
        $node = $children[0]
        [void]$path.Add([string]$node.Segment)
        if ($node.HasContent) { break }
    }
    return @($path)
}

function Get-DhNavDepth {
    <# Deepest declared path in the tree. #>
    param($Node, [int] $Depth = 0)
    $max = $Depth
    foreach ($c in $Node.Children.Values) {
        $d = Get-DhNavDepth -Node $c -Depth ($Depth + 1)
        if ($d -gt $max) { $max = $d }
    }
    return $max
}

function ConvertTo-DhNavAttr {
    <#
    .SYNOPSIS
        The data-navpath / data-navscope attributes plus the initial panel-active
        class for one section.
    .DESCRIPTION
        data-navpath carries the path as a JSON array, so there is no separator
        to escape at runtime. The initial active state is stamped server-side
        using the same predicate the runtime uses (Test-DhNavMatch), which is
        what keeps the first paint and the first applyNav call in agreement.
    .OUTPUTS
        [hashtable] @{ Attr = ' data-navpath="..." data-navscope="..."'; Active = ' panel-active' | '' }
    #>

    param(
        [string[]] $Path        = @(),
        [string]   $Scope       = 'Global',
        [string[]] $InitialPath = @(),
        # Declaration order. The DOM emits ALL blocks before ALL tables, so without
        # this the menu would order nodes by section type rather than by the order
        # the author wrote them - which is not what anyone means.
        [int]      $Seq         = 0,
        [int]      $Order       = 0,
        [string]   $Label       = ''
    )

    # -InputObject, not a pipe: piping an EMPTY array sends nothing down the
    # pipeline, so ConvertTo-Json would emit nothing and the attribute would be
    # blank rather than '[]'.
    $json = ConvertTo-Json -InputObject @($Path) -Compress
    $attr = " data-navpath=`"$([System.Web.HttpUtility]::HtmlEncode($json))`"" +
            " data-navscope=`"$([System.Web.HttpUtility]::HtmlEncode($Scope.ToLowerInvariant()))`"" +
            " data-navseq=`"$Seq`""
    if ($Order -ne 0) { $attr += " data-navorder=`"$Order`"" }
    if ($Label)       { $attr += " data-navlabel=`"$([System.Web.HttpUtility]::HtmlEncode($Label))`"" }

    $active = if (Test-DhNavMatch -DeclaredPath $Path -SelectedPath $InitialPath -Scope $Scope) { ' panel-active' } else { '' }
    return @{ Attr = $attr; Active = $active }
}