Documentarian.ModuleAuthor.psm1

# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

using module .\Documentarian.ModuleAuthor.Private.psm1

#region Enums.Public

[Flags()] enum ProviderFlags {
    Registry    = 0x01
    Alias       = 0x02
    Environment = 0x04
    FileSystem  = 0x08
    Function    = 0x10
    Variable    = 0x20
    Certificate = 0x40
    WSMan       = 0x80
}

enum ParameterAttributeKind {
    DontShow
    Experimental
    HasValidation
    SupportsWildcards
    ValueFromPipeline
    ValueFromRemaining
    DefaultValue
    IsCredential
    IsObsolete
}

#endregion Enums.Public

#region Classes.Public

class ParameterInfo {
  # The name of the parameter
  [string] $Name
  # The parameter's help description.
  [string] $HelpText
  # The parameter's full type name.
  [string] $Type
  # The comma-separated list of parameter sets the parameter belongs to.
  [string] $ParameterSet
  # The comma-separated list of aliases for the parameter.
  [string] $Aliases
  # Whether the parameter is mandatory.
  [bool[]] $Required
  # The position of the parameter in each parameter set.
  [string[]] $Position
  # Whether and how the parameter accepts pipeline input by parameter set.
  [string[]] $Pipeline
  # Whether the parameter supports wildcard characters.
  [bool] $Wildcard
  # Whether the parameter is a dynamic parameter.
  [bool] $Dynamic
  # Whether the parameter accepts values from remaining arguments.
  [bool[]] $FromRemaining
  # Whether the parameter should be hidden from the user.
  [bool[]] $DontShow
  # The default value of the parameter by parameter set.
  [string[]] $DefaultValue
  # Whether the parameter is a credential parameter.
  [bool[]] $IsCredential
  # Whether the parameter is obsolete.
  [psobject] $IsObsolete
  # The provider flags for the parameter, indicating which providers it's
  # valid for.
  [ProviderFlags] $ProviderFlags

  ParameterInfo(
    [System.Management.Automation.ParameterMetadata]$param,
    [ProviderFlags]$ProviderFlags
  ) {
    $this.Name     = $param.Name
    $this.HelpText = $param.Attributes.HelpMessage | Select-Object -First 1
    if ($this.HelpText.Length -eq 0) {
      $this.HelpText = '{{Placeholder}}'
    }
    $this.Type         = $param.ParameterType.FullName
    $this.ParameterSet = if ($param.Attributes.ParameterSetName -eq '__AllParameterSets') {
      '(All)'
    } else {
      $param.Attributes.ParameterSetName -join ', '
    }
    $this.Aliases  = $param.Aliases -join ', '
    $this.Required = $param.Attributes.Mandatory
    $this.Position = if ($param.Attributes.Position -lt 0) {
      'Named'
    } else {
      $param.Attributes.Position
    }
    $this.Pipeline = 'ByValue ({0}), ByName ({1})' -f @(
            ($param.Attributes.ValueFromPipeline -join ','),
            ($param.Attributes.ValueFromPipelineByPropertyName -join ',')
    )
    $this.Wildcard      = $param.Attributes.TypeId.Name -contains 'SupportsWildcardsAttribute'
    $this.Dynamic       = $param.IsDynamic
    $this.FromRemaining = $param.Attributes.ValueFromRemainingArguments
    $this.DontShow      = $param.Attributes.DontShow
    if ($param.Attributes.TypeId.Name -contains 'PSDefaultValueAttribute') {
      $this.DefaultValue = $param.Attributes.Help -join ', '
    } else {
      $this.DefaultValue = 'None'
    }
    if ($param.Attributes.TypeId.Name -contains 'CredentialAttribute') {
      $this.IsCredential = $true
    } else {
      $this.IsCredential = $false
    }
    if ($param.Attributes.TypeId.Name -contains 'ObsoleteAttribute') {
      $this.IsObsolete = [pscustomobject]@{
        Message = $param.Attributes.Message
        IsError = $param.Attributes.IsError
      }
    } else {
      $this.IsObsolete = $false
    }
    $this.ProviderFlags = $ProviderFlags
  }

  [string]ToMarkdown([bool]$showAll) {
    <#
            .SYNOPSIS
                Converts the parameter info to a Markdown section.
            .DESCRIPTION
                Converts the parameter info to a Markdown section as expected by the
                **PlatyPS** module. It includes an H3 for the parameter (with the
                leading hyphen), the parameter's help text, and a YAML code block
                containing the parameter's metadata.

                When the $showAll parameter is $true, the parameter's non-PlatyPS
                compliant metadata is included.

            .PARAMETER showAll
                Whether to include the parameter's non-PlatyPS compliant metadata.
        #>


    $builder = New-Builder
    $Builder | Add-Heading -Level 3 -Content $this.Name
    $Builder | Add-Line -Content $this.HelpText
    $Builder | Start-CodeFence -Language yaml
    $Builder | Add-Line -Content "Type: $($this.Type)"
    $Builder | Add-Line -Content "Parameter Sets: $($this.ParameterSet)"
    $Builder | Add-Line -Content "Aliases: $($this.Aliases)"
    $Builder | Add-Line
    $Builder | Add-Line -Content "Required: $($this.Required -join ', ')"
    $Builder | Add-Line -Content "Position: $($this.Position -join ', ')"
    if ($this.Type -is [System.Management.Automation.SwitchParameter]) {
      $Builder | Add-Line -Content 'Default value: False'
    } else {
      $Builder | Add-Line -Content "Default value: $($this.DefaultValue)"
    }
    $Builder | Add-Line -Content "Accept pipeline input: $($this.Pipeline)"
    $Builder | Add-Line -Content "Accept wildcard characters: $($this.Wildcard)"
    if ($showAll) {
      $Builder | Add-Line -Content "Dynamic: $($this.Dynamic)"
      if ($this.Dynamic -and $this.ProviderFlags) {
        $ProviderName = if ($this.ProviderFlags -eq 0xFF) {
          'All'
        } else {
          $this.ProviderFlags.ToString()
        }
        $Builder | Add-Line -Content "Providers: $ProviderName"
      }
      $Builder | Add-Line -Content "Values from remaining args: $($this.FromRemaining -join ', ')"
      $Builder | Add-Line -Content "Do not show: $($this.DontShow -join ', ')"
      $Builder | Add-Line -Content "Is credential: $($this.IsCredential)"
      if ($this.IsObsolete -ne $false) {
        $Builder | Add-Line -Content 'Is obsolete: True'
        $Builder | Add-Line -Content " - Message: $($this.IsObsolete.Message)"
        $Builder | Add-Line -Content " - IsError: $($this.IsObsolete.IsError)"
      } else {
        $Builder | Add-Line -Content 'Is obsolete: False'
      }
    }
    $Builder | Stop-CodeFence
    $Builder | Add-Line

    return $Builder.ToString()
  }
}

class CredentialAttributeInfo {
    [string]$Cmdlet
    [string]$Parameter
    [string]$ParameterType
    [bool]$IsCredential
    [string]$ParameterSetName
    [string]$Module

    [string] ToString () {
        return @(
            "$($this.Module)/"
            "$($this.Cmdlet), "
            "Parameter: $($this.Parameter) <"
            "$($this.ParameterType)>"
            "IsCredential: $($this.IsCredential)"
        ) -join ''
    }
}

class DontShowAttributeInfo {
    [string] $Cmdlet
    [string] $Parameter
    [string] $ParameterType
    [bool]   $DontShow
    [string] $ParameterSetName
    [string] $Module

    [string] ToString () {
        return @(
            "$($this.Module)/"
            "$($this.Cmdlet), "
            "Parameter: $($this.Parameter) <"
            "$($this.ParameterType)>, "
            "DontShow: $($this.DontShow)"
        ) -join ''
    }
}

class ExperimentalAttributeInfo {
    [string]$Cmdlet
    [string]$Parameter
    [string]$ParameterType
    [string]$ExperimentName
    [string]$ParameterSetName
    [string]$Module

    [string] ToString () {
        return @(
            "$($this.Module)/"
            "$($this.Cmdlet), "
            "Parameter: $($this.Parameter) <"
            "$($this.ParameterType)>, "
            "Experiment: $($this.ExperimentName)"
        ) -join ''
    }
}

class HasValidationAttributeInfo {
    [string]$Cmdlet
    [string]$Parameter
    [string]$ParameterType
    [string]$ValidationAttribute
    [string]$ParameterSetName
    [string]$Module

    [string] ToString () {
        return @(
            "$($this.Module)/"
            "$($this.Cmdlet), "
            "Parameter: $($this.Parameter) <"
            "$($this.ParameterType)>, "
            "ValidationAttribute: $($this.ValidationAttribute)"
        ) -join ''
    }
}

class ObsoleteAttributeInfo {
    [string]$Cmdlet
    [string]$Parameter
    [string]$ParameterType
    [bool]$IsObsolete
    [string]$ParameterSetName
    [string]$Module

    [string] ToString () {
        return @(
            "$($this.Module)/"
            "$($this.Cmdlet), "
            "Parameter: $($this.Parameter) <"
            "$($this.ParameterType), >"
            "IsObsolete: $($this.IsObsolete)"
        ) -join ''
    }
}

class PSDefaultValueAttributeInfo {
    [string]$Cmdlet
    [string]$Parameter
    [string]$ParameterType
    [string]$DefaultValue
    [string]$ParameterSetName
    [string]$Module

    [string] ToString () {
        return @(
            "$($this.Module)/"
            "$($this.Cmdlet), "
            "Parameter: $($this.Parameter) <"
            "$($this.ParameterType), >"
            "DefaultValue: $($this.DefaultValue)"
        ) -join ''
    }
}

class SupportsWildcardsAttributeInfo {
    [string]$Cmdlet
    [string]$Parameter
    [string]$ParameterType
    [bool]$SupportsWildcards
    [string]$ParameterSetName
    [string]$Module

    [string] ToString () {
        return @(
            "$($this.Module)/"
            "$($this.Cmdlet), "
            "Parameter: $($this.Parameter) <"
            "$($this.ParameterType)>"
            "SupportsWildcards: $($this.SupportsWildcards)"
        ) -join ''
    }
}

class ValueFromPipelineAttributeInfo {
    [string]$Cmdlet
    [string]$Parameter
    [string]$ParameterType
    [string]$ValueFromPipeline
    [string]$ParameterSetName
    [string]$Module

    [string] ToString () {
        return @(
            "$($this.Module)/"
            "$($this.Cmdlet), "
            "Parameter: $($this.Parameter) <"
            "$($this.ParameterType)>, "
            "ValueFromPipeline: $($this.ValueFromPipeline)"
        ) -join ''
    }
}

class ValueFromRemainingAttributeInfo {
    [string]$Cmdlet
    [string]$Parameter
    [string]$ParameterType
    [bool]$ValueFromRemaining
    [string]$ParameterSetName
    [string]$Module

    [string] ToString () {
        return @(
            "$($this.Module)/"
            "$($this.Cmdlet), "
            "Parameter: $($this.Parameter) <"
            "$($this.ParameterType)>, "
            "ValueFromRemaining: $($this.ValueFromRemaining)"
        ) -join ''
    }
}

#endregion Classes.Public

#region Functions.Private

<#
.SYNOPSIS
Returns a list of parameter headers from a cmdlet markdown file.

.DESCRIPTION
Returns a list of parameter headers from a cmdlet markdown file.

.PARAMETER mdheaders
An array of objects returned by `Select-String -Pattern '^#' -Path $file`

.NOTES
Used by `Update-ParameterOrder` to sort the parameters in a cmdlet markdown file.
#>

function Get-ParameterMdHeaders {
    param($mdheaders)

    $paramlist = @()

    $inParams = $false
    foreach ($hdr in $mdheaders) {
        # Find the start of the parameters section
        if ($hdr.Line -eq '## Parameters') {
            $inParams = $true
        }
        if ($inParams) {
            # Find the start of each parameter
            if ($hdr.Line -match '^### -') {
                $param = [PSCustomObject]@{
                    Line      = $hdr.Line.Trim()
                    StartLine = $hdr.LineNumber - 1
                    EndLine   = -1
                }
                $paramlist += $param
            }
            # Find the end of the last parameter
            if ((($hdr.Line -match '^## ' -and $hdr.Line -ne '## Parameters') -or
                 ($hdr.Line -eq '### CommonParameters')) -and
                ($paramlist.Count -gt 0)) {
                $inParams = $false
                $paramlist[-1].EndLine = $hdr.LineNumber - 2
            }
        }
    }
    # Find the end each last parameter
    if ($paramlist.Count -gt 0) {
        for ($x = 0; $x -lt $paramlist.Count; $x++) {
            if ($paramlist[$x].EndLine -eq -1) {
                $paramlist[$x].EndLine = $paramlist[($x + 1)].StartLine - 1
            }
        }
    }
    $paramlist
}

<#
.SYNOPSIS
Wraps text to a specified line length.

.DESCRIPTION
Wraps text to a specified line length. Useful for formatting descriptions in
markdown files.

.PARAMETER line
The line of text to wrap.

.PARAMETER pad
The number of spaces to indent wrapped lines. Default is 0.

.PARAMETER max
The maximum line length. Default is 79.
#>

function WrapText {
    param(
        [string]$line,
        [uint]$pad = 0,
        [uint]$max = 79
    )
    $lines = @()
    [string[]]$parts = $line -split ' '
    [string]$newLine = ' ' * $pad
    foreach ($p in $parts) {
        if ($newLine.Length + $p.Length + 1 -lt $max) {
            $newLine += "$p "
        } else {
            $lines += $newLine.TrimEnd(' ')
            $newLine = ' ' * $pad + "$p "
        }
    }
    if ($newLine.Trim() -ne '') {
        $lines += $newLine.TrimEnd(' ')
    }
    $lines
}

#endregion Functions.Private

#region Functions.Public

function Find-ParameterWithAttribute {
    [CmdletBinding()]
    [OutputType(
        [DontShowAttributeInfo],
        [ExperimentalAttributeInfo],
        [HasValidationAttributeInfo],
        [SupportsWildcardsAttributeInfo],
        [ValueFromPipelineAttributeInfo],
        [ValueFromRemainingAttributeInfo],
        [PSDefaultValueAttributeInfo],
        [CredentialAttributeInfo],
        [ObsoleteAttributeInfo]
    )]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ParameterAttributeKind]$AttributeKind,

        [Parameter(Position = 1)]
        [SupportsWildcards()]
        [PSDefaultValue(Help='*', Value='*')]
        [string[]]$CommandName = '*',

        [ValidateSet('Cmdlet', 'Module', 'None')]
        [PSDefaultValue(Help='None', Value='None')]
        [string]$GroupBy = 'None'
    )
    begin {
        $cmdlets = Get-Command $CommandName -Type Cmdlet, ExternalScript, Filter, Function, Script
    }
    process {
        foreach ($cmd in $cmdlets) {
            foreach ($param in $cmd.Parameters.Values) {
                $result = $null
                foreach ($attr in $param.Attributes) {
                    if ($attr.TypeId.ToString() -eq 'System.Management.Automation.ParameterAttribute' -and
                        $AttributeKind -in 'DontShow', 'Experimental', 'ValueFromPipeline', 'ValueFromRemaining') {
                        switch ($AttributeKind) {
                            DontShow {
                                if ($attr.DontShow) {
                                    $result = [DontShowAttributeInfo]@{
                                        Cmdlet           = $cmd.Name
                                        Parameter        = $param.Name
                                        ParameterType    = $param.ParameterType.Name
                                        DontShow         = $attr.DontShow
                                        ParameterSetName = $param.ParameterSets.Keys -join ', '
                                        Module           = $cmd.Source
                                    }
                                }
                                break
                            }
                            Experimental {
                                if ($attr.ExperimentName) {
                                    $result = [ExperimentalAttributeInfo]@{
                                        Cmdlet           = $cmd.Name
                                        Parameter        = $param.Name
                                        ParameterType    = $param.ParameterType.Name
                                        DontShow         = $attr.ExperimentName
                                        ParameterSetName = $param.ParameterSets.Keys -join ', '
                                        Module           = $cmd.Source
                                    }
                                }
                                break
                            }
                            ValueFromPipeline {
                                if ($attr.ValueFromPipeline -or $attr.ValueFromPipelineByPropertyName) {
                                    $result = [ValueFromPipelineAttributeInfo]@{
                                        Cmdlet            = $cmd.Name
                                        Parameter         = $param.Name
                                        ParameterType     = $param.ParameterType.Name
                                        ValueFromPipeline = ('ByValue({0}), ByName({1})' -f $attr.ValueFromPipeline, $attr.ValueFromPipelineByPropertyName)
                                        ParameterSetName  = $param.ParameterSets.Keys -join ', '
                                        Module            = $cmd.Source
                                    }
                                }
                                break
                            }
                            ValueFromRemaining {
                                if ($attr.ValueFromRemainingArguments) {
                                    $result = [ValueFromRemainingAttributeInfo]@{
                                        Cmdlet             = $cmd.Name
                                        Parameter          = $param.Name
                                        ParameterType      = $param.ParameterType.Name
                                        ValueFromRemaining = $attr.ValueFromRemainingArguments
                                        ParameterSetName   = $param.ParameterSets.Keys -join ', '
                                        Module             = $cmd.Source
                                    }
                                }
                                break
                            }
                        }
                    } elseif ($attr.TypeId.ToString() -like 'System.Management.Automation.Validate*Attribute' -and
                        $AttributeKind -eq 'HasValidation') {
                        $result = [HasValidationAttributeInfo]@{
                            Cmdlet              = $cmd.Name
                            Parameter           = $param.Name
                            ParameterType       = $param.ParameterType.Name
                            ValidationAttribute = $attr.TypeId.ToString().Split('.')[ - 1].Replace('Attribute', '')
                            ParameterSetName    = $param.ParameterSets.Keys -join ', '
                            Module              = $cmd.Source
                        }
                    } elseif ($attr.TypeId.ToString() -eq 'System.Management.Automation.SupportsWildcardsAttribute' -and
                        $AttributeKind -eq 'SupportsWildcards') {
                        $result = [SupportsWildcardsAttributeInfo]@{
                            Cmdlet            = $cmd.Name
                            Parameter         = $param.Name
                            ParameterType     = $param.ParameterType.Name
                            SupportsWildcards = $true
                            ParameterSetName  = $param.ParameterSets.Keys -join ', '
                            Module            = $cmd.Source
                        }
                    } elseif ($attr.TypeId.ToString() -eq 'System.Management.Automation.CredentialAttribute' -and
                        $AttributeKind -eq 'IsCredential') {
                        $result = [CredentialAttributeInfo]@{
                            Cmdlet           = $cmd.Name
                            Parameter        = $param.Name
                            ParameterType    = $param.ParameterType.Name
                            IsCredential     = $true
                            ParameterSetName = $param.ParameterSets.Keys -join ', '
                            Module           = $cmd.Source
                        }
                    } elseif ($attr.TypeId.ToString() -eq 'System.Management.Automation.PSDefaultValueAttribute' -and
                        $AttributeKind -eq 'DefaultValue') {
                        $result = [PSDefaultValueAttributeInfo]@{
                            Cmdlet           = $cmd.Name
                            Parameter        = $param.Name
                            ParameterType    = $param.ParameterType.Name
                            DefaultValue     = $attr.Help
                            ParameterSetName = $param.ParameterSets.Keys -join ', '
                            Module           = $cmd.Source
                        }
                    } elseif ($attr.TypeId.ToString() -eq 'System.ObsoleteAttribute' -and
                        $AttributeKind -eq 'IsObsolete') {
                        $result = [ObsoleteAttributeInfo]@{
                            Cmdlet           = $cmd.Name
                            Parameter        = $param.Name
                            ParameterType    = $param.ParameterType.Name
                            IsObsolete       = if ($param.IsObsolete -eq $false) {
                                $false
                            } else {
                                $true
                            }
                            ParameterSetName = $param.ParameterSets.Keys -join ', '
                            Module           = $cmd.Source
                        }
                    }
                }
                if ($result) {
                    # Add a type name to the object so that the correct format gets chosen
                    switch ($GroupBy) {
                        'Cmdlet' {
                            $typename = $result.GetType().Name + '#ByCmdlet'
                            $result.psobject.TypeNames.Insert(0, $typename)
                            break
                        }
                        'Module' {
                            $typename = $result.GetType().Name + '#ByModule'
                            $result.psobject.TypeNames.Insert(0, $typename)
                            break
                        }
                    }
                    $result
                }
            }
        }
    }
}

function Get-ParameterInfo {
    [CmdletBinding(DefaultParameterSetName = 'AsMarkdown')]
    [OutputType([ParameterInfo])]
    [OutputType([System.String])]
    param(
        [Parameter(Mandatory, Position = 0, ParameterSetName = 'AsMarkdown')]
        [Parameter(Mandatory, Position = 0, ParameterSetName = 'AsObject')]
        [string[]]$ParameterName,

        [Parameter(Mandatory, Position = 1, ParameterSetName = 'AsMarkdown')]
        [Parameter(Mandatory, Position = 1, ParameterSetName = 'AsObject')]
        [string]$CmdletName,

        [Parameter(ParameterSetName = 'AsMarkdown')]
        [switch]$ShowAll,

        [Parameter(Mandatory, ParameterSetName = 'AsObject')]
        [switch]$AsObject
    )

    $cmdlet = Get-Command -Name $CmdletName -ErrorAction Stop
    $null = Get-PSDrive # Load all providers
    $providerList = Get-PSProvider

    foreach ($pname in $ParameterName) {
        try {
            $paraminfo = $null
            $param = $null
            foreach ($provider in $providerList) {
                Push-Location $($provider.Drives[0].Name + ':')
                $param = $cmdlet.Parameters.Values | Where-Object Name -EQ $pname
                if ($param) {
                    if ($paraminfo) {
                        $paraminfo.ProviderFlags = $paraminfo.ProviderFlags -bor [ProviderFlags]($provider.Name)
                    } else {
                        $paraminfo = [ParameterInfo]::new(
                            $param,
                            [ProviderFlags]($provider.Name)
                        )
                    }
                }
                Pop-Location
            }
        } catch {
            Write-Error "Cmdlet $CmdletName not found."
            return
        }

        if ($paraminfo) {
            if ($AsObject) {
                $paraminfo
            } else {
                $paraminfo.ToMarkdown($ShowAll)
            }
        } else {
            Write-Error "Parameter $pname not found."
        }
    }
}

function Get-ShortDescription {

    $crlf = "`r`n"
    Get-ChildItem *.md | ForEach-Object {
        if ($_.directory.basename -ne $_.basename) {
            $filename = $_.Name
            $name = $_.BaseName
            $headers = Select-String -Path $filename -Pattern '^## \w*' -AllMatches
            $mdtext = Get-Content $filename
            $start = $headers[0].LineNumber
            $end = $headers[1].LineNumber - 2
            $short = $mdtext[($start)..($end)] -join ' '
            if ($short -eq '') { $short = '{{Placeholder}}' }

            '### [{0}]({1}){3}{2}{3}' -f $name, $filename, $short.Trim(), $crlf
        }
    }

}

function Get-Syntax {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]
        $CmdletName,

        [switch]
        $Markdown
    )

    function formatString {
        param(
            $cmd,
            $pstring
        )

        $parts      = $pstring -split ' '
        $parameters = @()
        for ($x = 0; $x -lt $parts.Count; $x++) {
            $p = $parts[$x]
            if ($x -lt $parts.Count - 1) {
                if (!$parts[$x + 1].StartsWith('[')) {
                    $p += ' ' + $parts[$x + 1]
                    $x++
                }
                $parameters += , $p
            } else {
                $parameters += , $p
            }
        }

        $line = $cmd + ' '
        $temp = ''
        for ($x = 0; $x -lt $parameters.Count; $x++) {
            if ($line.Length + $parameters[$x].Length + 1 -lt 100) {
                $line += $parameters[$x] + ' '
            } else {
                $temp += $line + "`r`n"
                $line  = ' ' + $parameters[$x] + ' '
            }
        }
        $temp + $line.TrimEnd()
    }

    try {
        $cmdlet = Get-Command $cmdletname -ea Stop
        if ($cmdlet.CommandType -eq 'Alias') { $cmdlet = Get-Command $cmdlet.Definition }
        if ($cmdlet.CommandType -eq 'ExternalScript') {
            $name = $CmdletName
        } else {
            $name = $cmdlet.Name
        }

        $syntax = (Get-Command $name).ParameterSets |
            Select-Object -Property @(
                @{Name = 'Cmdlet'           ; Expression = { $cmdlet.Name } },
                @{Name = 'ParameterSetName' ; Expression = { $_.name } },
                'IsDefault',
                @{Name = 'Parameters'       ; Expression = { $_.ToString() } }
            )
    } catch [System.Management.Automation.CommandNotFoundException] {
        $_.Exception.Message
    }

    $mdHere = @'
### {0}{1}

```
{2}
```

'@


    if ($Markdown) {
        foreach ($s in $syntax) {
            $string = $s.Cmdlet, $s.Parameters -join ' '
            if ($s.IsDefault) { $default = ' (Default)' } else { $default = '' }
            if ($string.Length -gt 100) {
                $string = formatString $s.Cmdlet $s.Parameters
            }
            $mdHere -f $s.ParameterSetName, $default, $string
        }
    } else {
        $syntax
    }

}

function Invoke-NewMDHelp {

    ### Runs New-MarkdownHelp with the parameters we use most often.

    param(
        [Parameter(Mandatory)]
        [string]$Module,

        [Parameter(Mandatory)]
        [string]$OutPath
    )
    $parameters = @{
        Module                = $Module
        OutputFolder          = $OutPath
        AlphabeticParamsOrder = $true
        UseFullTypeName       = $true
        WithModulePage        = $true
        ExcludeDontShow       = $false
        Encoding              = [System.Text.Encoding]::UTF8
    }
    New-MarkdownHelp @parameters

}

function Invoke-Pandoc {
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [string[]]$Path,
        [string]$OutputPath = '.',
        [switch]$Recurse
    )

    # Initialize recurse parameter if not specified
    if (-not $PSBoundParameters.ContainsKey('Recurse')) {
        $Recurse = $false
    }

    # Ensure pandoc is available
    $pandocExe = Get-Command pandoc.exe
    if ($null -eq $pandocExe) {
        Write-Error 'pandoc.exe not found in PATH. See https://pandoc.org/installing.html'
        return
    }

    # Define Pandoc template, filter, and arguments
    $aboutTemplate = @(
        'TOPIC'
        ' $title$'
        ''
        'SYNOPSIS'
        ' $description$'
        ''
        '$body$'
    ) -join [Environment]::NewLine

    $luaFilter = @(
        'function Header(elem)'
        ' local text = pandoc.utils.stringify(elem.content)'
        ' local underline_char = { "=", "=" }'
        ' local level = elem.level'
        ' local underline = string.rep(underline_char[level] or "-", #text)'
        ' return pandoc.Para{pandoc.Str(text), pandoc.LineBreak(), pandoc.Str(underline)}'
        'end'
    ) -join [Environment]::NewLine

    $templatePath = "$env:TEMP\pandoc-template.txt"
    $luaFilterPath = "$env:TEMP\pandoc-filter.lua"
    $pandocArgs = @(
        '--from=gfm',
        '--to=plain+multiline_tables-yaml_metadata_block',
        "--lua-filter=$luaFilterPath",
        "--template=$templatePath",
        '--columns=79',
        '--quiet'
    )
    Write-Verbose "Pandoc: $($pandocExe.Source)"
    Write-Verbose "Args: $($pandocArgs -join ' ')"

    # Create Pandoc assets
    $aboutTemplate | Out-File $templatePath -Encoding utf8
    $luaFilter | Out-File $luaFilterPath -Encoding utf8

    # Process files
    $files = Get-ChildItem $Path -Recurse:$Recurse
    foreach ($f in $files) {
        $outfile = Join-Path $OutputPath "$($f.BaseName).help.txt"
        Write-Verbose "Converting $($f.Name) to $outfile"

        $metadata = Get-Metadata -Path $f
        if ($null -eq $metadata) {
            Write-Error "No metadata found in $($f.Name)"
            continue # Skip to next file
        }
        if (-not $metadata.Contains('title') -or $metadata.title -eq '') {
            Write-Error "Missing metadata.title in $($f.Name)"
            continue # Skip to next file
        }
        if (-not $metadata.Contains('description') -or $metadata.description -eq '') {
            Write-Error "Missing metadata.description in $($f.Name)"
            continue # Skip to next file
        }

        # Remove H1 from markdown content
        $markdown = (Get-Content $f -Raw) -replace "# $($metadata.title)\r?\n", ''

        $markdown | & $pandocExe @pandocArgs | Out-File $outfile -Encoding utf8
    }
}

function Test-HelpInfoUri {
    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    param(
        [Parameter(Mandatory, Position = 0, ParameterSetName = 'ByName')]
        [string[]]$Module,

        [Parameter(ValueFromPipeline, ParameterSetName = 'ByObject')]
        [object]$InputObject,

        [Parameter(Position = 1)]
        [uri]$HelpInfoUri,

        [string]$OutPath
    )

    begin {
        if ($PSVersionTable.PSVersion.Major -lt 7) {
            throw 'This function requires PowerShell 7 or higher'
        }
        # Used for short-circuiting checks against already-checked modules
        $TestedModules = @()
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ByObject') {
            # We can infer the Module from the input object. If it's a
            # CommandInfo, it might have the module info or name. If it's
            # a PSModuleInfo, use it. Otherwise, cast to string.
            foreach ($Object in $InputObject) {
                switch ($Object) {
                    { $_ -is [System.Management.Automation.CommandInfo] } {
                        if ($Object.Module) {
                            $Module = $Object.Module
                        } elseif ($Object.ModuleName) {
                            $Module = $Object.ModuleName
                        } else {
                            $Module = $Object.ToString()
                        }
                    }

                    { $_ -is [System.Management.Automation.PSModuleInfo] } {
                        $Module = $Object.Name
                    }

                    default {
                        $Module = $Object.ToString()
                    }
                }
            }
        }

        # Don't bother searching the same module more than
        # once since we always check latest version
        $Module = $Module | Select-Object -Unique

        foreach ($modname in $Module) {
            # To support pipeline, skip if a module has already
            # been tested, otherwise add to the test list.
            if ($modname -in $TestedModules) {
                continue
            } else {
                $TestedModules += $modname
            }

            $output = [pscustomobject]@{
                pstypename = 'TestHelpInfoUriResult'
                Module     = $modname
                Code       = $null
                Message    = $null
            }

            $mod = Get-Module -Name $modname -ListAvailable | Select-Object -First 1

            # If the input wasn't a module, return the failed result immediately
            if ($null -eq $mod) {
                $output.Message = 'Module not found'
                $output.Code    = 0x2
                $output
                continue
            }

            # else we have a module object
            $output.Module = $mod.Name

            # The HelpInfoUri parameter overrides $mod.HelpInfoUri, allowing you to test a new URI
            # before updating the module. If the parameter is empty, $mod.HelpInfoUri.
            if ( $PSBoundParameters.Keys -notcontains 'HelpInfoUri') {
                $HelpInfoUri = $mod.HelpInfoUri
            }

            # If the input was a module, we have the component parts needed to check it
            Write-Verbose "$($mod.Name) - $($mod.Guid) - $HelpInfoUri"

            # If $HelpInfoUri is empty, we know it can't be reached
            if ($null -eq $HelpInfoUri) {
                $output.Message = 'HelpInfoUri is null or empty'
                $output.Code    = 0x00002ef8 # ERROR_INTERNET_NO_CONTEXT
                $output
                continue
            }

            # Resolve the URI from the manifest, which may include redirection.
            try {
                # If successful, then the URI probably points to a browseable directory
                $response = Invoke-WebRequest -Uri $HelpInfoUri -ErrorAction Stop
                continue
            } catch {
                if ($_.Exception.Response.StatusCode.value__ -eq 404) {
                    # If the response is 404, then the URI is probably valid, especially when
                    # hosted in an Azure blobstore. You can't browse to the URI, but you can
                    # download the file using a fully qualified URI to the file. A true 404 problem
                    # will be caught later.
                    $baseUri = $_.TargetObject.RequestUri.AbsoluteUri
                } else {
                    # If the response is anything other than 404, then the URI is probably invalid
                    $output.Message = $_.Exception.Response.StatusCode
                    $output.Code    = $_.Exception.Response.StatusCode.value__
                    $output
                    continue
                }
            }

            # Construct the URI: the last segment is always <Name>_<Guid>_HelpInfo.xml
            $HelpInfoUri = [uri]($baseUri, $mod.Name, '_', $mod.Guid, '_HelpInfo.xml' -join '')
            Write-Verbose "HelpInfoUri: $HelpInfoUri"

            # Try to get the HelpInfo.xml to determine validity
            try {
                $params = @{
                    Uri         = $HelpInfoUri
                    Method      = 'Get'
                    ErrorAction = 'Stop'
                }
                if ($OutPath) {
                    # Add the OutFile parameter to Invoke-WebRequest if an output path is specified
                    $params.Add(
                        'OutFile',
                        (Join-Path -Path $OutPath -ChildPath $HelpInfoUri.Segments[-1])
                    )
                }
                $response = Invoke-WebRequest @params
                $output.Code = $response.StatusCode
                $output.Message = 'HelpInfoUri is valid'
                $output
            } catch {
                $output.Code = $_.Exception.Response.StatusCode.value__
                $output.Message = $_.Exception.Response.StatusCode
                $output
            }
        }
    }
}

function Update-Headings {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [SupportsWildcards()]
        [string]
        $Path,

        [switch]
        $Recurse
    )

    $headings = @(
        '## Synopsis'
        '## Syntax'
        '## Description'
        '## Examples'
        '## Parameters'
        '### CommonParameters'
        '## Inputs'
        '## Outputs'
        '## Notes'
        '## Related links'
        '## Short description'
        '## Long description'
        '## See also'
    )

    Get-ChildItem $Path -Recurse:$Recurse | ForEach-Object {
        $_.name
        $md = Get-Content -Encoding utf8 -Path $_
        foreach ($h in $headings) {
            $md = $md -replace "^$h$", $h
        }
        Set-Content -Encoding utf8 -Value $md -Path $_ -Force
    }

}

function Update-ParameterOrder {
    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param (
        [Parameter(Mandatory, Position = 0)]
        [SupportsWildcards()]
        [string[]]
        $Path
    )

    $mdfiles = Get-ChildItem $path

    foreach ($file in $mdfiles) {
        $mdtext    = Get-Content $file -Encoding utf8
        $mdheaders = Select-String -Pattern '^#' -Path $file

        $unsorted = Get-ParameterMdHeaders $mdheaders
        if ($unsorted.Count -gt 0) {
            $sorted        = $unsorted | Sort-Object Line
            $newtext       = $mdtext[0..($unsorted[0].StartLine - 1)]
            $confirmWhatIf = @()
            foreach ($paramblock in $sorted) {
                if ( '### -Confirm', '### -WhatIf' -notcontains $paramblock.Line) {
                    $newtext += $mdtext[$paramblock.StartLine..$paramblock.EndLine]
                } else {
                    $confirmWhatIf += $paramblock
                }
            }
            foreach ($paramblock in $confirmWhatIf) {
                $newtext += $mdtext[$paramblock.StartLine..$paramblock.EndLine]
            }
            $newtext += $mdtext[($unsorted[-1].EndLine + 1)..($mdtext.Count - 1)]

            Set-Content -Value $newtext -Path $file.FullName -Encoding utf8 -Force
            $file
        }
    }

}

#endregion Functions.Public

# Define the types to export with type accelerators.
$ExportableTypes =@(
    [ProviderFlags]
    [ParameterAttributeKind]
    [ParameterInfo]
    [CredentialAttributeInfo]
    [DontShowAttributeInfo]
    [ExperimentalAttributeInfo]
    [HasValidationAttributeInfo]
    [ObsoleteAttributeInfo]
    [PSDefaultValueAttributeInfo]
    [SupportsWildcardsAttributeInfo]
    [ValueFromPipelineAttributeInfo]
    [ValueFromRemainingAttributeInfo]
)

# Get the internal TypeAccelerators class to use its static methods.
$TypeAcceleratorsClass = [psobject].Assembly.GetType(
'System.Management.Automation.TypeAccelerators'
)
# Ensure none of the types would clobber an existing type accelerator.
# If a type accelerator with the same name exists, throw an exception.
$ExistingTypeAccelerators = $TypeAcceleratorsClass::Get
foreach ($Type in $ExportableTypes) {
    if ($Type -in $ExistingTypeAccelerators.Keys) {
        $Message = @(
            "Unable to register type accelerator '$($Type.FullName)'"
            'Accelerator already exists'
        ) -join ' '

        throw [System.Management.Automation.ErrorRecord]::new(
            [System.InvalidOperationException]::new($Message),
            'TypeAcceleratorAlreadyExists',
            [System.Management.Automation.ErrorCategory]::InvalidOperation,
            $Type.FullName
        )
    }
}

# Add the type accelerators for every exportable type.
foreach ($Type in $ExportableTypes) {
    Write-Verbose "Registering type accelerator for [$Type]"
    $TypeAcceleratorsClass::Add($Type.FullName, $Type)
}
# Remove type accelerators when the module is removed.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
    foreach ($Type in $ExportableTypes) {
        Write-Verbose "Unregistering type accelerator for [$Type]"
        $null = $TypeAcceleratorsClass::Remove($Type.FullName)
    }
}.GetNewClosure()

$ExportableFunctions = @(
  'Find-ParameterWithAttribute'
  'Get-ParameterInfo'
  'Get-ShortDescription'
  'Get-Syntax'
  'Invoke-NewMDHelp'
  'Invoke-Pandoc'
  'Test-HelpInfoUri'
  'Update-Headings'
  'Update-ParameterOrder'
)

Export-ModuleMember -Alias * -Function $ExportableFunctions