Get-MarkdownHelp.ps1

<#PSScriptInfo
.VERSION 1.1.7
.GUID 19631007-c07a-48b9-8774-fcea5498ddb9
.AUTHOR iRon
.COMPANYNAME
.COPYRIGHT
.TAGS Help MarkDown ReadMe
.LICENSE https://github.com/iRon7/Get-MarkdownHelp/LICENSE
.PROJECTURI https://github.com/iRon7/Get-MarkdownHelp
.ICON
.EXTERNALMODULEDEPENDENCIES
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES
.PRIVATEDATA
#>


<#
.SYNOPSIS
    Creates a markdown Readme string from the comment based help of a command
 
.DESCRIPTION
    The [Get-MarkdownHelp][1] cmdlet retrieves the [comment-based help][2] and converts it to a Markdown page
    similar to the general online PowerShell help pages (as e.g. [Get-Content]).\
    Note that this cmdlet *doesn't* support `XML`-based help files, but has a few extra features for the comment-based
    help as opposed to the native [platyPS][3] [New-MarkdownHelp]:
 
    * **Code Blocks**
 
    To create code blocks, indent every line of the block by at least four spaces or one tab relative the **text indent**.
    The **text indent** is defined by the smallest indent of the current - and the `.SYNOPSIS` section.\
    Code blocks are automatically [fenced][4] for default PowerShell color coding.\
    The usual comment-based help prefix for code (`PS. \>`) might also be used to define a code lines.
    For more details, see the [-PSCodePattern parameter].
 
    As defined by the standard help interpreter, code blocks (including fenced code blocks) can't include help keywords.
    Meaning (fenced) code blocks will end at the next section defined by `.<help keyword>`.
 
    * **Titled Examples**
 
    Examples can be titled by adding an (extra) hash (`#`) in front of the first line in the section.
    This line will be removed from the section and added to the header of the example.
 
    * **Links**
 
    > As Per markdown definition, The first part of a [reference-style link][5] is formatted with two sets of brackets.
    > The first set of brackets surrounds the text that should appear linked. The second set of brackets displays
    > a label used to point to the link you're storing elsewhere in your document, e.g.: `[rabbit-hole][1]`.
    > The second part of a reference-style link is formatted with the following attributes:
 
    > * The label, in brackets, followed immediately by a colon and at least one space (e.g., `[label]:` ).
    > * The URL for the link, which you can optionally enclose in angle brackets.
    > * The optional title for the link, which you can enclose in double quotes, single quotes, or parentheses.
 
    For the comment-base help implementation, the second part should be placed in the `.LINK` section to automatically
    listed in the end of the document. The reference will be hidden if the label is an explicit empty string(`""`).
 
    * **Quick Links**
 
    Any phrase existing of a combination alphanumeric characters, spaces, underscores and dashes between squared brackets
    (e.g. `[my link]`) will be linked to the (automatic) anchor id in the document, e.g.: `[my link](#my-link)`.
 
    > **Note:** There is no confirmation if the internal anchor really exists.
 
    * **Parameter Links**
 
    **Parameter links** are similar to **Quick Links** but start with a dash and contain an existing parameter name possibly
    followed by the word "parameter". E.g.: `[-AlternateEOL]` or `[-AlternateEOL parameter]`.
    In this example, the parameter link will refer to the internal [-AlternateEOL parameter].
 
    * **Cmdlet Links**
 
    **Cmdlet links** are similar to **Quick Links** but contain a cmdlet name where the online help is known. E.g.: `[Get-Content]`.
    In this example, the cmdlet link will refer to the online help of the related [Get-Content] cmdlet.
 
.INPUTS
    A (reference to a) command or module
 
.OUTPUTS
    `String[]`
 
.PARAMETER Source
    The source of the commented help.
    This might a command or module by it name or file location.
 
.PARAMETER Command
    An embedded command that contains the parameters or actual commented help.
 
.PARAMETER PSCodePattern
    Specifies the PowerShell code pattern used by the get-help cmdlet.
    The native [`Get-Help`] cmdlet automatically adds a PowerShell prompt (`PS \>`) to the first line of an example if not yet exist.
    To be consistent with the first line you might manually add a PowerShell prompt to each line of code which will be converted to
    a code block by this `Get-MarkdownHelp` cmdlet.
 
.PARAMETER AlternateEOL
    The recommended way to force a line break or new line (`<br>`) in markdown is to end a line with two or more spaces but as that
    might cause an *[Avoid Trailing Whitespace][7]* warning, you might also consider to use an alternate EOL marker.\
    Any alternate EOL marker (at the end of the line) will be replaced by two spaces by this `Get-MarkdownHelp` cmdlet.
 
.EXAMPLE
    # Display markdown help
    This example generates a markdown format help page from itself and shows it in the default browser
 
        .\Get-MarkdownHelp.ps1 .\Show-MarkDown.ps1 |Out-String |Show-Markdown -UseBrowser
 
.EXAMPLE
    # Copy markdown help to a website
    This command creates a markdown readme string for the `Join-Object` cmdlet and puts it on the clipboard
    so that it might be pasted into e.g. a GitHub readme file.
 
        Get-MarkdownHelp Join-Object |Clip
 
.EXAMPLE
    # Save markdown help to file
    This command creates a markdown readme string for the `.\MyScript.ps1` script and saves it in `Readme.md`.
 
        Get-MarkdownHelp .\MyScript.ps1 |Set-Content .\Readme.md
 
.LINK
    [1]: https://github.com/iRon7/Get-MarkdownHelp "Online Help"
    [2]: https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_comment_based_help "About comment based help"
    [3]: https://github.com/PowerShell/platyPS "PlatyPS MALM renderer"
    [4]: https://www.markdownguide.org/extended-syntax/#fenced-code-blocks "Fenced Code Blocks"
    [5]: https://www.markdownguide.org/basic-syntax/#reference-style-links "Reference-style Links"
    [7]: https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/rules/avoidtrailingwhitespace ""
 
    https://www.markdownguide.org/basic-syntax/ "Markdown guide"
#>


using NameSpace System.Management.Automation
using NameSpace System.Management.Automation.Language

[CmdletBinding()][OutputType([String[]])] param(
    [Parameter(ValueFromPipeLine = $True, ValueFromPipelineByPropertyName = $True)]
    $Source,

    [Parameter(ValueFromPipelineByPropertyName = $True)]
    $Command,

    [String]$PSCodePattern = 'PS.*\>',

    [String]$AlternateEOL = '\'
)

begin {
    enum MDBlock {
        None
        Text
        Code
        Fenced
    }
    $TabSize = 4
    $Tab = ' ' * $TabSize
    $CodePrefix = "(?<=^\s*)$PSCodePattern"

    $UriLabelPattern  = '\[(?<Label>.+)\]\:'
    $UriPattern       = '\<?(?<Uri>\w+://\S+)\>?'
    $UriTitlePattern  = '("(?<Title>.*)"|''(?<Title>.*)''|\((?<Title>.?)\))'
    $ReferencePattern = "^($UriLabelPattern\s+)?$UriPattern(\s+$UriTitlePattern)?$"
    $AlternateEOL     = [regex]::Escape($AlternateEOL) + '\s*$'

    Class Sentence {
        [Int]$Offset
        [string]$Text
        static [Int]$TabSize = $TabSize
        Sentence([String]$String) {
            $This.Text = $String.Trim()
            if ($This.Text) {
                for ($i = 0; $i -lt $String.Length; $i++) {
                    switch ($String[$i]) {
                        ' '     { $This.Offset++ }
                        "`t"    { $This.Offset = $This.Offset - $This.Offset % [Sentence]::TabSize + [Sentence]::TabSize }
                        Default { $i = $String.Length }
                    }
                }
            }
        }
        [string]Indent([Int]$Offset) {
            $Spaces = [Math]::Max(0, ($This.Offset + $Offset))
            return ' ' * $Spaces + $This.Text
        }
    }

    function StopError($Exception, $Id = 'IncorrectArgument', $Group = [Management.Automation.ErrorCategory]::SyntaxError, $Object){
        if ($Exception -isnot [Exception]) { $Exception = [ArgumentException]$Exception }
        $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new($Exception, $Id, $Group, $Object))
    }

    function GetHelpItems([String[]]$Lines) {
        $Key, $Item = $Null
        $Help = @{}
        foreach ($Line in $Lines) {
            $Sentence = [Sentence]($Line -Replace $CodePrefix, $Tab)
            switch ($Sentence.Text) {
                { $_.StartsWith('<#') } {}
                { $_.EndsWith('#>')   } {}
                { '.Synopsis', '.Description', '.Inputs', '.Outputs', '.Notes', '.Link' -eq $_ } {
                    $Key = $_.SubString(1)
                    $Item = $Help[$Key] = [Collections.Generic.List[Sentence]]::new()
                }
                '.Example' {
                    if (!$Help.Contains('Example')) { $Help['Example'] = [Collections.Generic.List[object]]::new() }
                    $Help['Example'].Add([Collections.Generic.List[Sentence]]::new())
                    $Item = $Help['Example'][-1]
                }
                { $_ -Like '.Parameter *' } {
                    if (!$Help.Contains('Parameter')) { $Help['Parameter'] = @{} }
                    $Name = ($_ -Split '\.Parameter\s+', 2)[1]
                    $Item = $Help['Parameter'][$Name] = [Collections.Generic.List[Sentence]]::new()
                }
                Default {
                    if ($Null -ne $Item) { if ($Item.Count -Or $Sentence.Text) { $Item.add($Sentence) } }
                    elseif (!$_) { break }
                }
            }
        }
        $Help
    }

    function GetHelp([String]$Content) {
        $Help = $Null
        $Lines = [Collections.Generic.List[String]]::new()
        foreach ($Token in [PSParser]::Tokenize($Content, [Ref]$Null)) {
            if ($Token.Type -eq 'Comment') {
                if ($Token.Content.StartsWith('#') ) {
                    $Lines.Add($Token.Content.SubString(1))
                }
                else { #Block Comment
                    $Help = GetHelpItems ($Token.Content -split '\r?\n')
                    $Lines.Clear()
                }

            }
            elseif ($Token.Type -ne 'NewLine') {
                $Help = GetHelpItems $Lines
                $Lines.Clear()
            }
            if ($Help -and $Help.Count -and $Help.Contains('Synopsis')) { return $Help }
        }
        if ($Lines.Count -and !$Help) { GetHelpItems $Lines } # Only line commented help
    }

    function SplitInLineCode ([String]$Markdown) {
        $Left = ''
        While ($Markdown -Match '^([^`]*)(`+)([\s\S]*)$') {
            $Code, $Right = $Matches[3] -Split "(?<!``)$($Matches[2])(?!``)", 2
            if ($Null -ne $Right) {
                $Left + $Matches[1]
                $Matches[2] + $Code + $Matches[2]
                $Markdown = $Right
                $Left = ''
            }
            else {
                $Left += $Matches[1] + $Matches[2]
                $Markdown = $Matches[3]
            }
        }
        $Left + $MarkDown
    }

    function QuickLinks($Markdown) {
        $CallBack = {
            $Label = $Args[0].Value.TrimStart('[').TrimEnd(']')
            if ( $Label -Match '^(-\w+)(\s+parameter)?$' -and $ParamNames -eq $Matches[1].TrimStart('-') ) {
                "[``$($Matches[1])``$($Matches[2])](#$($Matches[1].ToLower()))"
            }
            else {
                $Command = Get-Command $Label -ErrorAction SilentlyContinue
                if ($Command.HelpUri) { "[``$Label``]($($Command.HelpUri))" }
                else { "[$Label](#$($Label.ToLower() -Replace '\W+', '-'))" }
            }
        }
        $Index = 0
        -Join @(
            foreach ($String in @(SplitInLineCode $Markdown)) {
                if ($Index++ -band 1) { $String } # Inline code
                else { ([regex]'(?<!\])\[[\w\- ]+\](?![\[\(])').Replace($String, $CallBack) }
            }
        )
    }

    function GetMarkDown([Sentence[]]$Sentences) {
        $CodeOffset = $TextOffset = 99
        foreach ($Sentence in $Sentences) { # determine general offset
            if ($Sentence.Text -and $Sentence.Offset -lt $TextOffset) { $TextOffset = $Sentence.Offset }
        }
        if ($Null -eq $Script:Indent) { $Script:Indent = $TextOffset }
        elseif ($TextOffset -gt $Script:Indent) { $TextOffset = $Script:Indent }

        foreach ($Sentence in $Sentences) { # determine code offset
            if ($Sentence.Text -and $Sentence.Offset -ge $TextOffset + $TabSize) {
                if ($Sentence.Offset -lt $CodeOffset) { $CodeOffset = $Sentence.Offset }
            }
        }

        $SkipLines = 0
        [MDBlock]$MDBlock = 'None'

        foreach ($Sentence in $Sentences) {
            if ($MDBlock -eq 'Fenced') {
                $Sentence.Indent(-$TextOffset)
                if ($Sentence.Text -Match $Fence ) {
                    $SkipLines = 1
                    $MDBlock = 'None'
                }
            }
            elseif ($Sentence.Text -Match '^`{3,4}') { # Either: ``` or: ````
                if ($SkipLines) { '' }
                $Sentence.Indent(-$TextOffset)
                $SkipLines = 0
                $MDBlock = 'Fenced'
                $Fence = $Matches[0]
            }
            elseif (!$Sentence.Text) { $SkipLines++ }
            elseif ($Sentence.Offset -lt $TextOffset + $TabSize) { # Text block
                if ($MDBlock -eq 'Text') {
                    if ($SkipLines) { '' }
                }
                elseif ($MDBlock -eq 'Code') {
                    '```'
                    ''
                }
                QuickLinks ($Sentence.Text -Replace $AlternateEOL, ' ')
                $SkipLines = 0
                $MDBlock = 'Text'
            }
            else { # if ($Sentence.Offset -ge $TextOffset + $TabSize) { # Code block
                if ($MDBlock -eq 'Code') {
                    if ($SkipLines) { @('') * $SkipLines }
                }
                elseif ($MDBlock -eq 'Text') {
                    ''
                    '```PowerShell'
                }
                $Sentence.Indent(-$TextOffset - $TabSize)
                $SkipLines = 0
                $MDBlock = 'Code'
            }
        }
        if ('Code', 'Fenced' -eq $MDBlock) { '```' }
    }

    function GetTypeLink($TypeName) {
        $Type = $TypeName -as [Type]
        if ($Type) {
            $TypeUri = 'https://docs.microsoft.com/en-us/dotnet/api/' + $Type.FullName
            "<a href=""$TypeUri"">$($Type.Name)</a>"
        }
        else {
            $TypeName
        }
    }
}

process {
    $File = try { Get-Item $Source } catch { $Null }
    if (-not $File) { StopError "Cannot find file '$Source'" }
    $Ast = [Parser]::ParseFile($File.FullName, [ref]$Null, [ref]$Null)
    $Body =
        if ($Command) { $Ast.EndBlock.Statements.where{$_.Name -eq $Command}.Body }
        elseif ($Ast.ParamBlock) { $Ast }
        else {
            $Function = $Ast.EndBlock.Statements.where{ $_ -is [FunctionDefinitionAst] }
            if ($Function.Count -eq 1) { $Function.Body }
            else { $Ast.EndBlock.Statements.where{ $_.name -is $File.BaseName }.Body }
        }
    if (-not $Body) { StopError "Cannot find parameters in '$Source'" }
    $Help = GetHelp $Body
    if (-not $Help -and $Body.Parent) { $Help = GetHelp $Ast }
    if (-not $Help) { StopError "Cannot find comment base help in '$Source'" }

    $Parameters = $Body.ParamBlock.Parameters
    $ParameterSets = [Ordered]@{}
    $Parameters.foreach{
        $Name =  $_.Name.VariablePath.UserPath
        $Sets = $_.Attributes.NamedArguments.where{ $_.ArgumentName -eq 'ParameterSetName' }
        foreach ($Value in @($Sets.Argument.Value)) {
            $SetName = if ($Value) { "$Value" } else { '__AllParameterSets' }
             if (!$ParameterSets.Contains($SetName)) { $ParameterSets[$SetName] = [Ordered]@{} }
            $ParameterSets[$SetName][$Name] = $_
        }
    }
    foreach ($SetName in $ParameterSets.get_Keys()) {
        $ParameterSets[$SetName]['<CommonParameters>'] = $Null
        if ($Ast.DynamicParamBlock) { $ParameterSets[$SetName]['<DynamicParameters>'] = $Null }
    }

# Start exporting markdown

    '<!-- markdownlint-disable MD033 -->'

    $Name = if ($Body.Parent.Name) { $Body.Parent.Name } else { $File.BaseName }
    "# $Name"
    ''
    GetMarkDown $Help.Synopsis

    if ($ParameterSets.get_Count()) {
        ''
        '## Syntax'

        foreach ($ParameterSet in $ParameterSets.Values) {
            ''
            '```PowerShell'
            $Name
            foreach ($Key in $ParameterSet.get_Keys()) {
                $Parameter = $ParameterSet[$Key]
                $Type, $Default, $Positional, $Optional = $Null
                if ($ParameterSet[$Key] -is [ParameterAst]) {
                    $Key       = "-$Key"
                    $Type       = $Parameter.StaticType.Name
                    $Default    = $Parameter.DefaultValue
                    $Positional = $Parameter.Attributes.Position -lt 0
                    $Optional   = !$Parameter.Attributes.NamedArguments.where{ $_.ArgumentName -eq 'Mandatory' }
                }
                if (!$Positional -and !$Optional) { $Key = "[$Key]" }
                if ($Type -and $Type -ne 'SwitchParameter') { $Key += " <$Type>" }
                if ($Default) { $Key += " = $Default" }
                if ($Optional) { $Key = "[$Key]" }
                $Tab + $Key
            }
            '```'
        }
    }

    if ($Help.Contains('Description')) {
        ''
        '## Description'
        ''
        GetMarkDown $Help.Description
    }

    if ($Help.Contains('Example')) {
        ''
        '## Examples'

        for ($i = 0; $i -lt $Help.Example.Count; $i++) {
            $MarkDown = GetMarkDown $Help.Example[$i]
            if ($Help.Example[$i].Count -and $Help.Example[$i][0].Text.StartsWith('#')) {
                ''
                "### Example $($i + 1): " + $Help.Example[$i][0].Text.SubString(1).Trim()
                ''
                GetMarkDown $Help.Example[$i] | Select-Object -Skip 1
            }
            else {
                "### Example $($i + 1):"
                ''
                GetMarkDown $Help.Example[$i]
                ''
            }
        }
    }

    if ($Parameters) {
        ''
        '## Parameter'
        $Parameters.foreach{
            $Name = $_.Name.VariablePath.UserPath
            $Type = if ($_.StaticType.Name -ne 'SwitchParameter') { " <$($_.StaticType.Name)>" }
            ''
            "### <a id=""-$($Name.ToLower())"">**``-$Name$Type``**</a>"
            if ($Help.Contains('Parameter') -and $Help.Parameter.Contains($Name)) {
                ""
                GetMarkDown $Help.Parameter[$Name]
            }
            ''
            $Dictionary = [Ordered]@{}
            $Attributes = $_.Attributes
            if ($Null -ne $Attributes.MinLength -and $Null -ne $Attributes.MaxLength) { $Dictionary['Accepted length']           = $Attributes.MinLength - $Attributes.MaxLength }
            elseif ($Null -ne $Attributes.MinLength)                                  { $Dictionary['Minimal length']            = $Attributes.MinLemgth }
            elseif ($Null -ne $Attributes.MaxLength)                                  { $Dictionary['Maximal lemgth']            = $Attributes.MaxLength }
            if ($Null -ne $Attributes.RegexPattern)                                   { $Dictionary['Accepted pattern']          = "<code>$($Attributes.RegexPattern)</code>" }
            if ($Null -ne $Attributes.MinRange -and $Null -ne $Attributes.MaxRange)   { $Dictionary['Accepted range']            =  $Attributes.MinRange - $Attributes.MaxRange }
            elseif ($Null -ne $Attributes.MinRange)                                   { $Dictionary['Minimal value']             =  $Attributes.MinRange }
            elseif ($Null -ne $Attributes.MaxRange)                                   { $Dictionary['Maximal value']             =  $Attributes.MaxRange }
            if ($Null -ne $Attributes.ScriptBlock)                                    { $Dictionary['Accepted script condition'] =  "<code>$($Attributes.ScriptBlock.ToString().Trim() -Split '\s*[\r?\n]\s*' -Join '; ')</code>" }
            if ($Null -ne $Attributes.ValidValues)                                    { $Dictionary['Accepted values']           =  $Attributes.ValidValues -Join ', ' }
            $Dictionary['Type'] = GetTypeLink $_.StaticType.Name
            $Dictionary['Mandatory'] = [bool]$Attributes.NamedArguments.where{ $_.ArgumentName -eq 'Mandatory' }
            if ($_.Aliases) { $Dictionary['Aliases'] = $_.Aliases -Join ', ' }
            $Position = if ($Attributes.Position -ge 0) {$Attributes.Position } else { 'Named' }
            $Position = if ($Attributes.Position -lt 0) { 'Named' }
                        elseif ($Attributes.Position -ne $Attributes.Position[0]) { $Attributes.Position -Join ', ' }
                        else { $Attributes.Position[0] }
            $Dictionary['Position']                   = $Position
            $DefaultValue                             = if ($_.DefaultValue) { "<code>$($_.DefaultValue)</code>" } # https://stackoverflow.com/a/64358608/1701026
            $Dictionary['Default value']              = $DefaultValue
            $Dictionary['Accept pipeline input']      = [Bool]$Attributes.ValueFromPipelineByPropertyName
            $Globbing = ($_.Attributes.where{$_.TypeName.Name -eq 'SupportsWildcards'}).Count -gt 0
            $Dictionary['Accept wildcard characters'] = $Globbing
            '<table>'
            $Dictionary.get_Keys().ForEach{ "<tr><td>$($_):</td><td>$($Dictionary[$_])</td></tr>"}
            '</table>'
        }
    }

    if ($Help.Contains('Inputs')) {
        ''
        '## Inputs'
        ''
        GetMarkDown $Help.Inputs
    }

    if ($Help.Contains('Outputs')) {
        ''
        '## Outputs'
        ''
        GetMarkDown $Help.Outputs
    }

    if ($Help.Contains('Link')) {
        ''
        '## Related Links'
        ''
        $LinkRefences = [Collections.Generic.List[String]]::new()
        ForEach ($Sentence in $Help.Link) {
            $Text = $Sentence.Text
            $Link = if ($Text -Match $ReferencePattern) {
                if ($Matches.Contains('Label')) {
                    $LinkRefences.Add($Text)
                    if ($Matches.Contains('Title')) {
                        if ($Matches['Title']) { "$($Matches['Label']): [$($Matches['Title'])][$($Matches['Label'])]" }
                    }
                    else {
                        "$($Matches['Label']): $($Matches['Uri'])"
                    }
                }
                elseif ($Matches.Contains('Title')) {
                    "[$($Matches['Title'])]($($Matches['Uri']))"
                }
                else { $Matches['Uri'] }
            }
            if ($Link) { "* $Link" }
        }
        if ($LinkRefences) {
            ''
            @($LinkRefences).ForEach{ $_ }
        }
    }
    ''
    '[comment]: <> (Created with Get-MarkdownHelp: Install-Script -Name Get-MarkdownHelp)'
}