PSDocBuilder.psm1

# Copyright: (c) 2019, Jordan Borean (@jborean93) <jborean93@gmail.com>
# MIT License (see LICENSE or https://opensource.org/licenses/MIT)

#Requires -Module powershell-yaml

Set-Variable -Name PSDocBuilderSchema -Scope Script -Option Constant -Force -Value @(
    @{
        Name = 'synopsis'
        Required = $true
        Type = [System.String]
        IsArray = $false
    },
    @{
        Name = 'description'
        Required = $true
        Type = [System.String]
        IsArray = $true
    },
    @{
        Name = 'parameters'
        Required = $false
        Type = [System.Collections.Hashtable]
        IsArray = $true
        Schema = @(
            @{
                Name = 'name'
                Required = $true
                Type = [System.String]
                IsArray = $false
            },
            @{
                Name = 'description'
                Required = $true
                Type = [System.String]
                IsArray = $true
            }
        )
    }
    @{
        Name = 'examples'
        Required = $false
        Type = [System.Collections.Hashtable]
        IsArray = $true
        Schema = @(
            @{
                Name = 'name'
                Required = $true
                Type = [System.String]
                IsArray = $false
            },
            @{
                Name = 'description'
                Required = $true
                Type = [System.String]
                IsArray = $true
            },
            @{
                Name = 'code'
                Required = $true
                Type = [System.String]
                IsArray = $false
            }
        )
    },
    @{
        Name = 'inputs'
        Required = $false
        Type = [System.Collections.Hashtable]
        IsArray = $true
        Schema = @(
            @{
                Name = 'name'
                Required = $true
                Type = [System.String]
                IsArray = $false
            },
            @{
                Name = 'description'
                Required = $true
                Type = [System.String]
                IsArray = $true
            }
        )
    },
    @{
        Name = 'outputs'
        Required = $false
        Type = [System.Collections.Hashtable]
        IsArray = $true
        Schema = @(
            @{
                Name = 'description'
                Required = $false  # Required if structure_fragment is not set
                Type = [System.String]
                IsArray = $true
            },
            @{
                Name = 'structure_fragment'
                Required = $false
                Type = [System.String]
                IsArray = $false
            },
            @{
                Name = 'structure'
                Required = $false
                Type = [System.Collections.Hashtable]
                IsArray = $true
                Schema = @(
                    @{
                        Name = 'name'
                        Required = $true
                        Type = [System.String]
                        IsArray = $false
                    },
                    @{
                        Name = 'description'
                        Required = $true
                        Type = [System.String]
                        IsArray = $false
                    },
                    @{
                        Name = 'type'
                        Required = $false
                        Type = [System.String]
                        IsArray = $false
                    },
                    @{
                        Name = 'when'
                        Required = $false
                        Type = [System.String]
                        IsArray = $false
                    }
                )
            }
        )
    },
    @{
        Name = 'notes'
        Required = $false
        Type = [System.String]
        IsArray = $true
    },
    @{
        Name = 'links'
        Required = $false
        Type = [System.Collections.Hashtable]
        IsArray = $true
        Schema = @(
            @{
                Name = 'link'
                Required = $true
                Type = [System.String]
                IsArray = $false
            },
            @{
                Name = 'text'
                Required = $false
                Type = [System.String]
                IsArray = $false
            }
        )
    },
    @{
        Name = 'extended_doc_fragments'
        Required = $false
        Type = [System.String]
        IsArray = $true
    }
)

Function Format-FunctionWithDoc {
    <#
    .SYNOPSIS
    Generate PowerShell and Markdown docs from cmdlet.
 
    .DESCRIPTION
    The `Format-FunctionWithDoc` cmdlet takes in an existing cmdlet and generates the PowerShell and Markdown
    documentation based on common schema set by `PSDocBuilder` and the actual cmdlet's metadata. The advantage of using
    a common documentation schema and build tools is that it guarantees the output docs to follow a common format and
    add extra functionality like sharing common doc snippets in multiple modules.
 
    .PARAMETER Path
    [System.String[]]
    Specifies the path to one ore more locations to a PowerShell script that contains one or more cmdlets. These
    cmdlets are then parsed and used to generate both PowerShell and Markdown documents from the existing metadata.
    Wildcard characters are permitted.
 
    Use a dot (`.`) to specify the current location. Use the wildcard character (`*`) to specify all items in that
    location.
 
    .PARAMETER LiteralPath
    [System.String[]]
    Specifies the path to one or more locations to a PowerShell script that contains one or more cmdlet. These cmdlets
    are then parsed and used to generate both PowerShell and Markdown documents from the existing metadata.
 
    The value for `LiteralPath` is used exactly as it is typed, use `Path` if you wish to use wildcard characters
    instead.
 
    .PARAMETER FragmentPath
    [System.String]
    The path to a directory that contains extra document fragments to use during the metadata parsing. This directory
    should contain one or more `*.yml` files which contains common keys and values to be merged into the cmdlet
    metadata. This is referenced by the `extended_doc_fragments` key in the cmdlet metadata.
 
    .EXAMPLE Generate a single module file from a module.
    Uses the cmdlet to format an existing module that contains scripts in the `Private` and `Public` directory. The
    formatted functions are placed into single module file in the `Build` directory.
 
        $public_script_path = ".\Module\Public\*.ps1"
        $private_script_path = ".\Module\Private\*.ps1"
        $doc_path = ".\Docs"
        $module_file = ".\Build\Module.psm1"
 
        Set-Content -Path $module -Value "# Copyright 2019 - Author Name"
 
        $public_cmdlets = [System.Collections.Generic.List`1[System.String]]@()
        Format-FunctionWithDoc -Path $public_script_path, $private_script_path | For-EachObject -Process {
        $parent = Split-Path -Path (Split-Path -Path $_.Source -Parent) -Leaf
 
        if ($parent -eq 'Public') {
        $public_cmdlets.Add($_.Name)
        Set-Content -Path (Join-Path -Path $doc_path -Child Path "$($_.Name).md") -Value $_.Markdown
        }
 
        Add-Content -Path $module -Value $_.Function
        }
 
        $module_footer = @"
        $public_functions = @(
        '$($public_cmdlets -join "',`r`n'")'
        )
 
        Export-ModuleMember -Functions $public_functions
        "@
 
        Add-Content -Path $module -Value $module_footer
 
    .INPUTS
    [System.String[]]$Path - ByValue, ByPropertyName
    You can pipe a string or property with the name of `Path` to this cmdlet.
 
    .INPUTS
    [System.String[]]$LiteralPath - ByPropertyName
    You can pipe a property with the name of `LiteralPath` to this cmdlet.
 
    .OUTPUTS
    ([PSDocBuilder.FunctionDoc]) - Parameter Sets: (All)
    An object for each cmdlet inside the script(s) specified by `Path` or `LiteralPath`. The object has the name of the
    cmdlet as well as the formatted function with the PS and Markdown documentation.
 
    Contains:
    Name - [System.String]
        The name of the cmdlet.
    Source - [System.String]
        The full path to the source file the cmdlet was extracted from.
    Function - [System.String]
        The full PowerShell function with the embedded PowerShell document. This value can then be used to populate the
        final build artifact the caller is creating.
    Markdown - [System.String]
        The full Markdown document of the function. This value can be placed in a file in the output directory of the
        callers choice.
 
    .NOTES
    Each function found in the path will be dot sourced so the cmdlet can generate the Markdown syntax documentation.
    Any special types used by the cmdlet will need to be loaded before this will work.
    #>

    [OutputType('PSDocBuilder.FunctionDoc')]
    [CmdletBinding(DefaultParameterSetName='Path')]
    Param (
        [Parameter(Mandatory=$true, Position=0,
            ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true,
            ParameterSetName='Path')]
        [SupportsWildcards()]
        [System.String[]]
        $Path,

        [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true,
            ParameterSetName='LiteralPath')]
        [System.String[]]
        $LiteralPath,

        [System.String]
        $FragmentPath
    )

    Begin {
        $nl = [System.Environment]::NewLine
        $doc_fragments = @{}
        if ($FragmentPath) {
            Write-Verbose -Message "Getting all .yml fragments in '$FragmentPath'."
            Get-ChildItem -LiteralPath $FragmentPath -File -Filter "*.yml" | ForEach-Object -Process {
                $doc_fragment = Get-Content -LiteralPath $_.FullName -Raw

                Write-Verbose -Message "Attempting to convert fragment '$($_.FullName)' to yaml."
                $doc_fragment = ConvertFrom-Yaml -Yaml $doc_fragment

                $assert_params = @{
                    Schema = $script:PSDocBuilderSchema
                    Documentation = $doc_fragment
                    Name = $_.BaseName
                    IsFragment = $true
                }
                Assert-DocumentationStructure @assert_params

                $doc_fragments."$($_.BaseName)" = $doc_fragment
            }
        }
    }

    Process {
        $path_params = @{}
        if ($PSCmdlet.ParameterSetName -eq 'Path') {
            Write-Verbose -Message "Using -Path value '$Path' for getting cmdlets."
            $path_params.Path = $Path
            $path_value = $Path
        } else {
            Write-Verbose -Message "Using -LiteralPath value '$LiteralPath' for getting cmdlets."
            $path_params.LiteralPath = $LiteralPath
            $path_value = $LiteralPath
        }

        try {
            if (-not (Test-Path @path_params -PathType Leaf)) {
                Write-Error -Message "Fail to find a file at '$path_value'" -ErrorAction Stop
            }

            Get-Item @path_params -Force | ForEach-Object -Process {
                Write-Verbose -Message "Getting cmdlets from '$($_.FullName)'."
                $cmdlets = @(Get-CmdletFromPath -Path $_.FullName)

                foreach ($cmdlet in $cmdlets) {
                    Write-Verbose -Message "Extracting cmdlet documentation for '$($cmdlet.Name)' in '$($_.FullName)'."
                    $cmdlet_doc = Get-CmdletDocumentation -Cmdlet $cmdlet

                    # Get the indexes for the existing function block inside the comments. We also calculate the indent
                    # they are at when we insert the new docs later on.
                    $cmdlet_string = $Cmdlet.ToString()
                    $ignore_case = [System.StringComparison]::OrdinalIgnoreCase
                    $start_comment_idx = $cmdlet_string.IndexOf('<#', 0, $ignore_case)
                    $end_comment_idx = $cmdlet_string.IndexOf('#>', $start_comment_idx, $ignore_case)
                    $newline_idx = $cmdlet_string.Substring(0, $start_comment_idx).LastIndexOf($nl)
                    $indent = $start_comment_idx - $newline_idx - 2

                    # Load the cmdlet so Get-Help works properly
                    .([ScriptBlock]::Create($cmdlet_string))

                    $cmdlet_meta_params = @{
                        Cmdlet = $cmdlet
                        Documentation = $cmdlet_doc
                        DocumentationFragments = $doc_fragments
                    }
                    $cmdlet_meta = Get-CmdletMetadata @cmdlet_meta_params

                    Write-Verbose -Message "Generating PowerShell documentation for '$($cmdlet.Name)' in '$($_.FullName)'."
                    $ps_doc = New-PowerShellDoc -Documentation $cmdlet_meta -Indent $indent

                    Write-Verbose -Message "Generating Markdown documentation for '$($cmdlet.Name)' in '$($_.FullName)'."
                    $md_doc = New-MarkdownDoc -Documentation $cmdlet_meta

                    # Add the doc string to the actual function
                    $function_string = (
                        "{0}{1}{2}{1}{3}{4}" -f (
                            $cmdlet_string.Substring(0, $start_comment_idx + 2),
                            $nl,
                            $ps_doc,
                            (" " * $indent),
                            $cmdlet_string.Substring($end_comment_idx, $cmdlet_string.Length - $end_comment_idx)
                        )
                    )

                    Write-Output -InputObject ([PSCustomObject]@{
                        PSTypeName = 'PSDocBuilder.FunctionDoc'
                        Name = $cmdlet.Name
                        Source = $_.FullName
                        Function = $function_string
                        Markdown = $md_doc
                    })
                }
            }
        } catch {
            $PSCmdlet.ThrowTerminatingError($PSItem)
        }
    }
}

Function Assert-DocumentationStructure {
    <#
    .SYNOPSIS
    Validates the doc structure.
 
    .DESCRIPTION
    Validates the documentation structure passed from a cmdlet.
 
    .PARAMETER Schema
    [System.Collections.Hashtable[]]
    The schema object to validate against, if documenting against the root document element, this value should be
    `$script:PSDocBuilderSchema`.
 
    .PARAMETER Documentation
    [System.Collections.Hashtable]
    The actual documentation hashtable to validate against the schema.
 
    .PARAMETER Name
    [System.String]
    A human friendly name to describe where the doc was derived from. This is used for error reporting.
 
    .PARAMETER FoundIn
    [System.String[]]
    A list that contains the keys the current `Documentation` element was found in. This is used for error reporting.
 
    .PARAMETER IsFragment
    [System.Management.Automation.SwitchParameter]
    States the `Documentation` value is a fragment which relaxes the required key rules in the schema.
 
    .EXAMPLE Validate schema of PS metadata doc.
    Validates the structure of the yaml doc located in a PowerShell function.
 
        Assert-DocumentationStructure -Schema $script:PSDocBuilderSchema -Documentation $cmdlet_doc -Name 'Test-Function'
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory=$true)]
        [System.Collections.Hashtable[]]
        $Schema,

        [Parameter(Mandatory=$true)]
        [System.Collections.Hashtable]
        $Documentation,

        [System.String]
        $Name = 'Unspecified',

        [System.String[]]
        $FoundIn = @(),

        [Switch]
        $IsFragment
    )

    Function Add-FoundInError {
        Param (
            [Parameter(ValueFromPipeline=$true)]
            [System.String]
            $Message,

            [AllowEmptyCollection()]
            [System.String[]]
            $FoundIn
        )

        if ($FoundIn.Length -gt 0) {
            $Message += " Found in $($FoundIn -join " -> ")."
        }

        return $Message
    }

    # Loop through the keys to make sure they exist in the schema.
    foreach ($kvp in $Documentation.GetEnumerator()) {
        if ($kvp.Key -notin $Schema.Name) {
            $msg = "Cmdlet doc entry for '$Name' contains an invalid key '$($kvp.Key)', valid keys are: '$($Schema.Name -join "', '")'."
            $msg = $msg | Add-FoundInError -FoundIn $FoundIn
            throw $msg
        }
    }

    # Loop through the schema to make sure the values are the correct type and required ones are present.
    foreach ($schema_entry in $Schema) {
        # Raise an error if a required key is not set and the current doc is not a fragment.
        if ((-not $Documentation.ContainsKey($schema_entry.Name)) -and $schema_entry.Required -and -not $IsFragment) {
            $msg = "Cmdlet doc entry for '$Name' does not contain the required key '$($schema_entry.Name)'."
            $msg = $msg | Add-FoundInError -FoundIn $FoundIn
            throw $msg
        } elseif (-not $Documentation.ContainsKey($schema_entry.Name)) {
            # Set the default value and continue if the key is not set
            $value = $null
            if ($schema_entry.IsArray) {
                $value_type = 'System.Collections.Generic.List`1[System.Object]'
                $value = New-Object -TypeName $value_type
            } elseif ($schema_entry.Type -eq [System.String]) {
                $value = ""
            }
            $Documentation."$($schema_entry.Name)" = $value
            continue
        }

        # Verify the type
        $doc_value = $Documentation."$($schema_entry.Name)"
        if ($schema_entry.IsArray) {
            if ($doc_value -isnot [System.Collections.Generic.List`1[Object]]) {
                if ($doc_value -is $schema_entry.Type) {
                    $doc_value = [System.Collections.Generic.List`1[System.Object]]@($doc_value)
                    $Documentation."$($schema_entry.Name)" = $doc_value
                } else {
                    $msg = "Expecting a list for doc entry '$($schema_entry.Name)' for '$Name'."
                    $msg = $msg | Add-FoundInError -FoundIn $FoundIn
                    throw $msg
                }
            }
        } else {
            $doc_value = @($doc_value)
        }
        foreach ($val in $doc_value) {
            if ($val -isnot $schema_entry.Type) {
                $msg = "Expecting entry of type '$($schema_entry.Type)' for doc entry '$($schema_entry.Name)' of '$Name' but got '$($val.GetType().Name)'."
                $msg = $msg | Add-FoundInError -FoundIn $FoundIn
                throw $msg
            }

            # Validate the sub schema.
            if ($schema_entry.ContainsKey('Schema')) {
                $assert_params = @{
                    Schema = $schema_entry.Schema
                    Documentation = $val
                    Name = $Name
                    FoundIn = ($FoundIn + $schema_entry.Name)
                    IsFragment = $IsFragment.IsPresent
                }
                Assert-DocumentationStructure @assert_params
            }
        }
    }
}

Function Format-IndentAndWrapping {
    <#
    .SYNOPSIS
    Format a string to the set indentation and wrapping rules.
 
    .DESCRIPTION
    Takes in a string or array of strings that are then indented and wrapped at a character line length based on the
    input parameters.
 
    .PARAMETER Value
    [System.String[]]
    The string or array of strings to wrap. Each entry in an array will be placed in a new paragraph.
 
    .PARAMETER Indent
    [System.Int32]
    The number of spaces to indent each line.
 
    .PARAMETER MaxLength
    [System.Int32]
    The maximum characters in a line before the remaining values are placed in a new line. The value includes the
    length of the indentation added by the cmdlet. Set to `0` for no maximum line length.
 
    .EXAMPLE Format a string with no indentation and wrapping.
    Will format the input value with no indentation and wrapping.
 
        $input_string = @"
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam imperdiet eu lacus at iaculis.
        Quisque tempus erat sit amet vulputate iaculis. Morbi felis dui, scelerisque vel purus eu,
        posuere fermentum risus
        "@
 
        Format-IndentAndWrapper -Value $input_string
 
    .EXAMPLE Format a string up to 120 characters and indent with 4 spaces.
    Will format the input string with 4 spaces as an indentation and cut it off at 120 characters long.
 
        $input_string = @"
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam imperdiet eu lacus at iaculis.
        Quisque tempus erat sit amet vulputate iaculis. Morbi felis dui, scelerisque vel purus eu,
        posuere fermentum risus
        "@
 
        Format-IndentAndWrapper -Value $input_string -Indent 4 -MaxLength 120
 
    .INPUTS
    [System.String[]]$Value - ByValue
    The input string can be passed in as a value.
 
    .OUTPUTS
    ([System.String]) - Parameter Sets: (All)
    The indented and wrapped string based on the input parametes.
    #>

    [CmdletBinding()]
    [OutputType([System.String])]
    Param (
        [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
        [AllowEmptyString()]
        [System.String[]]
        $Value,

        [System.Int32]
        $Indent = 0,

        [System.Int32]
        $MaxLength = 0
    )

    Begin {
        $lines = [System.Collections.Generic.List`1[System.String]]@()
    }

    Process {
        foreach ($entry in $Value) {
            $entry = $entry.Trim()
            foreach ($line in $entry.Split([System.Char[]]@("`r", "`n"))) {
                $words = $line.Split([System.Char[]]@(' '))

                $new_lines = [System.Collections.Generic.List`1[System.String]]@()
                $new_line = New-Object -TypeName System.Text.StringBuilder -ArgumentList @((" " * $Indent), $MaxLength)

                foreach ($word in $words) {
                    if ($new_line.Length -eq $Indent) {
                        # Started on a newline, don't add a space.
                        $new_line.Append($word) > $null
                    } elseif ($MaxLength -gt 0 -and ($word.Length + $new_line.Length + 2) -gt $MaxLength) {
                        # Won't fit in the line, finish off the line and start a new one.
                        $new_lines.Add($new_line.ToString()) > $null
                        $new_line = New-Object -TypeName System.Text.StringBuilder -ArgumentList @(((" " * $Indent) + $word), $MaxLength)
                    } else {
                        # Just a normal work, add a space then the word.
                        $new_line.Append(" $word") > $null
                    }
                }

                # Finally add the remaining chars in the line.
                if ($new_line.Length -gt 0) {
                    $new_lines.Add($new_line.ToString())
                }

                # Loop through the lines and make sure the ends have been trimmed.
                foreach ($new_line in $new_lines) {
                    $new_line = $new_line.TrimEnd()
                    $lines.Add($new_line)
                }
            }

            $lines.Add("")  # Add an empty line for each new entry.
        }
    }

    End {
        return ($lines -join [System.Environment]::NewLine).TrimEnd()
    }
}

Function Get-CmdletDocumentation {
    <#
    .SYNOPSIS
    Parses a cmdlet and extracts the yaml documentation string.
 
    .DESCRIPTION
    Parses the cmdlet yaml documentation string and validates the structure before returning the Hashtable of that yaml
    representation.
 
    .PARAMETER Cmdlet
    [System.Management.Automation.Language.FunctionDefinitionAst]
    The cmdlet to parse. This cmdlet will report an error if it does not contain the proper yaml doc string.
 
    .EXAMPLE Get the cmdlet documentation.
    Parses a cmdlet generated by 'Get-CmdletFromPath' and returns the hashtable representation of it's doc string.
 
        $cmdlet = Get-CmdletFromPath -Path 'C:\PowerShell\test.ps1'
        $cmdlet_doc = Get-CmdletDocumentation -Cmdlet $cmdlet
 
    .OUTPUTS
    ([System.Collections.Hashtable]) - Parameter Sets: (All)
    A hashtable that is the parsed yaml documentation string of the cmdlet.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    Param (
        [Parameter(Mandatory=$true)]
        [System.Management.Automation.Language.FunctionDefinitionAst]
        $Cmdlet
    )

    $cmdlet_string = $Cmdlet.ToString()
    $start_comment_idx = $cmdlet_string.IndexOf('<#', 0, [System.StringComparison]::OrdinalIgnoreCase)
    if ($start_comment_idx -eq -1) {
        throw "Failed to find any comment block in cmdlet '$($Cmdlet.Name)'."
    }

    $end_comment_idx = $cmdlet_string.IndexOf('#>', $start_comment_idx, [System.StringComparison]::OrdinalIgnoreCase)
    $cmdlet_comment = $cmdlet_string.Substring($start_comment_idx + 2, $end_comment_idx - $start_comment_idx - 2).Trim()

    try {
        $cmdlet_doc = ConvertFrom-Yaml -Yaml $cmdlet_comment
    } catch [YamlDotNet.Core.SyntaxErrorException] {
        $err = @{
            Message = "Failed to convert the first comment block in '$($Cmdlet.Name)' from yaml: $($_.Exception.InnerException.Message)"
            ErrorAction = 'Stop'
        }
        Write-Error @err
    }

    if ($cmdlet_doc -isnot [Hashtable]) {
        throw "Expecting cmdlet documentation to be a dictionary not '$($cmdlet_doc.GetType().Name)'"
    }

    Assert-DocumentationStructure -Schema $script:PSDocBuilderSchema -Documentation $cmdlet_doc -Name $Cmdlet.Name

    return $cmdlet_doc
}

Function Get-CmdletFromPath {
    <#
    .SYNOPSIS
    Extracts all the cmdlets in a script.
 
    .DESCRIPTION
    Parses a script and extracts the cmdlets in the script.
 
    .PARAMETER Path
    [System.String]
    The path to the script to parse.
 
    .EXAMPLE Parse cmdlets in a script
    Parses all the cmdlets in the script `C:\PowerShell\test.ps1`.
 
        Get-CmdletFromPath -Path 'C:\PowerShell\test.ps1'
 
    .OUTPUTS
    ([System.Management.Automation.Language.FunctionDefinitionAst]) - Parameter Sets: (All)
    The FunctionDefinitionAst for each cmdlet found in `Path`.
    #>

    [OutputType([System.Management.Automation.Language.FunctionDefinitionAst])]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory=$true)]
        [System.String]
        $Path
    )

    $script_data = Get-Content -LiteralPath $Path -Raw
    $script_block = [ScriptBlock]::Create($script_data)

    $function_predicate = {
        Param ([System.Management.Automation.Language.Ast]$Ast)
        $Ast -is [System.Management.Automation.Language.FunctionDefinitionAst]
    }
    Write-Output -InputObject ($script_block.Ast.FindAll($function_predicate, $false))
}

Function Get-CmdletMetadata {
    <#
    .SYNOPSIS
    Get the full metadata of a cmdlet.
 
    .DESCRIPTION
    Merges the cmdlet documentation structure with the actual metadata of the cmdlet into one object. The metadata can
    then be used to build the proper PowerShell and Markdown documentation.
 
    .PARAMETER Cmdlet
    [System.Management.Automation.Language.FunctionDefinitionAst]
    The FunctionDefinitionAst of the cmdlet.
 
    .PARAMETER Documentation
    [System.Collections.Hashtable]
    A hashtable that represents the cmdlet's documentation YAML string.
 
    .PARAMETER DocumentationFragments
    [System.Collections.Hashtable]
    A hashtable that contains all the loaded documentation fragments.
 
    .EXAMPLE Get cmdlet metadata from file.
    This will get the cmdlet metadata from the cmdlet at the path `C:\powershell\My-Function.ps1`.
 
        $cmdlet = Get-CmdletFromPath -Path C:\powershell\My-Function.ps1
        $cmdlet_doc = Get-CmdletDocumentation -Cmdlet $cmdlet
        $cmdlet_meta = Get-CmdletMetadata -Cmdlet $cmdlet -Documentation $cmdlet_doc
 
    .OUTPUTS
    ([System.Collections.Hashtable]) - Parameter Sets: (All)
    A hashtable that contains prepopulated and known keys that can be used by 'New-MarkdownDoc' and
    'New-PowerShellDoc' to generate the doc entries.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    Param (
        [Parameter(Mandatory=$true)]
        [System.Management.Automation.Language.FunctionDefinitionAst]
        $Cmdlet,

        [Parameter(Mandatory=$true)]
        [System.Collections.Hashtable]
        $Documentation,

        [System.Collections.Hashtable]
        $DocumentationFragments = @{}
    )

    $Documentation.name = $Cmdlet.Name
    $Documentation.dynamic_params = ($null -ne $Cmdlet.Body.DynamicParamBlock -and $Cmdlet.Body.DynamicParamBlock.Statements.Count -gt 0)

    # Add an extended doc fragments to the documentation before verifying the rest of the inputs.
    foreach ($fragment in $Documentation.extended_doc_fragments) {
        if (-not $DocumentationFragments.ContainsKey($fragment)) {
            throw "Referenced documentation fragment '{0}' in '{1}' does not exist." -f ($fragment, $Cmdlet.Name)
        }
        Merge-Hashtable -InputObject $Documentation -Hashtable $DocumentationFragments.$fragment
    }

    # Verify the doc parameters match the actual cmdlet parameters.
    $param_block = $false
    $actual_params = [System.String[]]@()
    if ($null -ne $Cmdlet.Body.ParamBlock) {
        $param_block = $true
        $actual_params = [System.String[]]@($Cmdlet.Body.ParamBlock.Parameters | ForEach-Object -Process { $_.Name.VariablePath.UserPath })
    } elseif ($null -ne $Cmdlet.Parameters) {
        $actual_params = [System.String[]]@($Cmdlet.Parameters.Name.VariablePath.UserPath)
    }
    $documented_params = [System.String[]]@($Documentation.parameters | ForEach-Object -Process { $_.name })
    $missing_params = [System.String[]][System.Linq.Enumerable]::Except($actual_params, $documented_params)
    $extra_params = [System.String[]][System.Linq.Enumerable]::Except($documented_params, $actual_params)
    if ($missing_params.Length -gt 0) {
        throw "Parameter(s) '{0}' for {1} have not been documented." -f (($missing_params -join "', '"), $module.Name)
    }

    $dynamic_params = [System.Collections.Generic.List`1[System.Object]]@()
    if ($extra_params.Length -gt 0) {
        if ($Documentation.dynamic_params) {
            # Add the missing fields for each dynamic param doc entry.
            foreach ($doc_param in $extra_params) {
                $param_info = $Documentation.parameters | Where-Object { $_.name -eq $doc_param }
                if (-not $param_info.ContainsKey('accepts_wildcard')) {
                    $param_info.accepts_wildcard = $false
                }
                if (-not $param_info.ContainsKey('aliases')) {
                    $param_info.aliases = [System.Collections.Generic.List`1[System.String]]@()
                }
                if (-not $param_info.ContainsKey('default')) {
                    $param_info.default = $null
                }
                if (-not $param_info.ContainsKey('parameter_sets')) {
                    $param_info.parameter_sets = @{}
                }
                if (-not $param_info.ContainsKey('type')) {
                    $param_info.type = 'System.Object'
                }
                $param_info.is_dynamic = $true

                $dynamic_params.Add($param_info)
            }
        } else {
            throw "Parameter(s) '{0}' for {1} have been documented but not implemented." -f (($extra_params -join "', '"), $module.Name)
        }
    }

    # Store a state for whether common params are supported. They are if [CmdletBinding()] or [Parameter] is used.
    $common_params = $false
    if ($Cmdlet.Body.ParamBlock.Attributes | Where-Object { $_.TypeName.FullName -eq 'CmdletBinding' }) {
        $common_params = $true
    }

    # Add the cmdlet parameter info the documentation.
    $pipeline_by_value = [System.Collections.Generic.List`1[System.String]]@()
    $pipeline_by_prop = [System.Collections.Generic.List`1[System.String]]@()

    # Store parameters in the order defined by the Cmdlet not the doc block
    $parameters = [System.Collections.Generic.List`1[System.Object]]@()
    if ($param_block) {
        $cmdlet_params = $Cmdlet.Body.ParamBlock.Parameters
    } else {
        $cmdlet_params = $Cmdlet.Parameters
    }
    foreach ($param_info in $cmdlet_params) {
        $param = $Documentation.parameters | Where-Object { $_.name -eq $param_info.Name.VariablePath.UserPath }

        $default_value = $null
        if ($null -ne $param_info.DefaultValue) {
            $default_value = $param_info.DefaultValue.ToString()
            if (($default_value.StartsWith('"') -and $default_value.EndsWith('"')) -or
                ($default_value.StartsWith("'") -and $default_value.EndsWith("'"))) {

                $default_value = $default_value.Substring(1, $default_value.Length - 2)
            }
        }

        $param.accepts_wildcard = $false
        $param.aliases = [System.Collections.Generic.List`1[System.String]]@()
        $param.default = $default_value
        $param.parameter_sets = @{}
        $param.type = $param_info.StaticType.FullName

        foreach ($attr in $param_info.Attributes) {
            if ($attr.TypeName.FullName -eq 'Parameter') {
                $common_params = $true

                # First check if an explicit ParameterSetName was set
                $param_set_arg = $attr.NamedArguments | Where-Object { $_.ArgumentName -eq 'ParameterSetName' }
                if ($null -ne $param_set_arg) {
                    if ($param_set_arg.Argument -is [System.Management.Automation.Language.ParenExpressionAst]) {
                        $param_sets = [System.String[]]$param_set_arg.Argument.Pipeline.PipelineElements[0].Expression.Elements.Value
                        $current_param_set = $param_sets -join ", "
                    } else {
                        $current_param_set = $param_set_arg.Argument.Value
                    }
                } else {
                    $current_param_set = '(All)'
                }
                $param_set_values = [Ordered]@{
                    required = $false
                    position = $null
                    pipeline_inputs = [System.Collections.Generic.List`1[System.String]]@()
                }

                foreach ($param_arg in $attr.NamedArguments) {
                    $is_true = $param_arg.ExpressionOmitted -or $param_arg.Argument.VariablePath.UserPath -eq 'true'

                    if ($param_arg.ArgumentName -eq 'Mandatory' -and $is_true) {
                        $param_set_values.required = $true
                    } elseif ($param_arg.ArgumentName -eq 'Position') {
                        $param_set_values.position = $param_arg.Argument.Value
                    } elseif ($param_arg.ArgumentName -eq 'ValueFromPipeline' -and $is_true) {
                        $pipeline_by_value.Add($param.name)
                        $param_set_values.pipeline_inputs.Add('ByValue')
                    } elseif ($param_arg.ArgumentName -eq 'ValueFromPipelineByPropertyName' -and $is_true) {
                        $pipeline_by_prop.Add($param.name)
                        $param_set_values.pipeline_inputs.Add('ByPropertyName')
                    }
                }

                # Finally set the parameter set values to the metadata.
                $param.parameter_sets.$current_param_set = $param_set_values
            } elseif ($attr.TypeName.FullName -eq 'Alias') {
                $param.aliases.AddRange([System.String[]]$attr.PositionalArguments.Value)
            } elseif ($attr.TypeName.FullName -eq 'SupportsWildcards') {
                $param.accepts_wildcard = $true
            }
        }

        if ($param.parameter_sets.Count -eq 0) {
            # No [Parameter()] block was set for the parameter, add the default attributes for (All)
            $param.parameter_sets."(All)" = @{
                required = $false
                position = $null
                pipeline_inputs = [System.Collections.Generic.List`1[System.String]]@()
            }
        }

        $param.is_dynamic = $false
        $parameters.Add($param)
    }
    $parameters.AddRange($dynamic_params)
    $Documentation.parameters = $parameters

    # Add a flag that states the parameter supports the default CmdletBinding parameters
    $Documentation.cmdlet_binding = $common_params

    # Verify the doc inputs match the actual input parameters.
    $actual_pipeline_params = [System.String[]]@($pipeline_by_value + $pipeline_by_prop | Select-Object -Unique)
    $documented_input_params = [System.String[]]@($Documentation.inputs | ForEach-Object -Process { $_.name })
    $missing_params = [System.String[]][System.Linq.Enumerable]::Except($actual_pipeline_params, $documented_input_params)
    $extra_params = [System.String[]][System.Linq.Enumerable]::Except($documented_input_params, $actual_pipeline_params)
    if ($missing_params.Length -gt 0) {
        throw "Input parameter(s) '{0}' for {1} have not been documented." -f (($missing_params -join "', '"), $Cmdlet.Name)
    }
    if ($extra_params.Length -gt 0) {
        throw "Input parameter(s) '{0}' for {1} have been documented but not implemented." -f (($extra_params -join "', '"), $Cmdlet.Name)
    }

    # Add the extra input information.
    for ($i = 0; $i -lt $Documentation.inputs.Count; $i++) {
        $doc_input = $Documentation.inputs[$i]

        $param = $Documentation.parameters | Where-Object { $_.Name -eq $doc_input.name }
        $doc_input.type = $param.type
        $doc_input.pipeline_types = [System.Collections.Generic.List`1[System.String]]@()
        if ($doc_input.name -in $pipeline_by_value) {
            $doc_input.pipeline_types.Add('ByValue')
        }
        if ($doc_input.name -in $pipeline_by_prop) {
            $doc_input.pipeline_types.Add('ByPropertyName')
        }

        $Documentation.inputs[$i] = $doc_input
    }

    # Verify the doc outputs match the actual cmdlet output types.
    $actual_output_types = [System.Collections.Generic.List`1[System.Object]]@()
    foreach ($output_type in $Cmdlet.Body.ParamBlock.Attributes | Where-Object { $_.TypeName.FullName -eq 'OutputType' }) {
        $parameter_set_names = [System.Collections.Generic.List`1[System.String]]@()
        $param_set_arg = $output_type.NamedArguments | Where-Object { $_.ArgumentName -eq 'ParameterSetName' }
        if ($null -ne $param_set_arg) {
            if ($param_set_arg.Argument -is [System.Management.Automation.Language.ParenExpressionAst]) {
                $parameter_set_names.AddRange(
                    [System.String[]]$param_set_arg.Argument.Pipeline.PipelineElements[0].Expression.Elements.Value
                )
            } else {
                $parameter_set_names.Add($param_set_arg.Argument.Value)
            }
        }

        if ($parameter_set_names.Count -eq 0) {
            $parameter_set_names.Add('(All)')
        }

        $types = [System.Collections.Generic.List`1[System.String]]@()
        foreach ($type in $output_type.PositionalArguments) {
            if ($type -is [System.Management.Automation.Language.TypeExpressionAst]) {
                $types.Add($type.TypeName.FullName)
            } else {
                $type_as_type = $type.Value -as [Type]
                if ($null -ne $type_as_type) {
                    $types.Add($type_as_type.FullName)
                } else {
                    $types.Add($type.Value)
                }
            }
        }

        $actual_output_types.Add([PSCustomObject]@{
            types = $types
            parameter_sets = $parameter_set_names
        })
    }
    if ($actual_output_types.Count -ne $Documentation.outputs.Count) {
        throw ("Output type(s) count mismatch for {0}, expecting {1} documented outputs but got {2}." -f
            ($Cmdlet.Name, $actual_output_types.Count, $Documentation.outputs.Count))
    }

     # Add the extra outputs information.
    for ($i = 0; $i -lt $actual_output_types.Count; $i++) {
        # Expand the structure fragment
        $fragment_name = $Documentation.outputs[$i].structure_fragment
        if (-not [System.String]::IsNullOrEmpty($fragment_name)) {
            if (-not $DocumentationFragments.ContainsKey($fragment_name)) {
                throw "Referenced documentation fragment '{0}' in '{1}' does not exist." -f ($fragment_name, $Cmdlet.Name)
            }

            Merge-Hashtable -InputObject $Documentation.outputs[$i] -Hashtable $DocumentationFragments.$fragment_name.outputs[0]
        }

        $actual_output_type = $actual_output_types[$i]
        $Documentation.outputs[$i].types = $actual_output_type.types
        $Documentation.outputs[$i].parameter_sets = $actual_output_type.parameter_sets
    }

    return $Documentation
}

Function Merge-Hashtable {
    <#
    .SYNOPSIS
    Merge 2 hashtable together.
 
    .DESCRIPTION
    Merges two hashtable into the original input object.
 
    .PARAMETER InputObject
    [System.Collections.Hashtable]
    The original hashtable that will be used as the merge destination.
 
    .PARAMETER Hashtable
    [System.Collections.Hashtable]
    The hashtable to merge into `InputObject`.
 
    .EXAMPLE Merge a hashtable
    Merges the hashtable `$b` into the hashtable `$a`.
 
        $a = @{
        Key = "key"
        Value = "value"
        }
        $b = @{
        Value = "value2"
        Extra = "extra value"
        }
 
        Merge-Hashtable -InputObject $a -Hashtable $b
 
        Write-Output -InputObject $a
        # Will result in
        # Key = "key"
        # Value = "extra value"
        # Extra = "extra value"
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory=$true)]
        [System.Collections.Hashtable]
        $InputObject,

        [Parameter(Mandatory=$true)]
        [System.Collections.Hashtable]
        $Hashtable
    )

    foreach ($kvp in $Hashtable.GetEnumerator()) {
        $InputObject."$($kvp.Key)" += $kvp.Value
    }
}

Function New-MarkdownDoc {
    <#
    .SYNOPSIS
    Generate a PowerShell markdown doc string for a cmdlet.
 
    .DESCRIPTION
    Generate a markdown documentation string based on the cmdlet metadata. This takes in the metadata as parsed by
    `PSDocHelper`.
 
    .PARAMETER Documentation
    [System.Collections.Hashtable]
    A hashtable that contains the cmdlet/function metadata which is translated into the markdown string. This hashtable
    is generated by `PSDocHelper`.
 
    .EXAMPLE Generate PowerShell markdown string.
    Generate the PowerShell function markdown doc based on the path to the cmdlet.
 
        $cmdlet = Get-CmdletFromPath -Path C:\ps_cmdlet.ps1
        $cmdlet_doc = Get-CmdletDocumentation -Cmdlet $cmdlet
        $cmdlet_meta = Get-CmdletMetadata -Cmdlet $cmdlet -Documenation $cmdlet_doc
        $md_doc = New-MarkdownDoc -Documentation $cmdlet_meta
 
    .OUTPUTS
    ([System.String]) - Parameter Sets: (All)
    The PowerShell doc string generated from the metadata.
    #>

    [CmdletBinding(SupportsShouldProcess=$false)]
    [OutputType([System.String])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification='Not affecting system state, just outputting a string.'
    )]
    Param (
        [Parameter(Mandatory=$true)]
        [Hashtable]
        $Documentation
    )

    $nl = [System.Environment]::NewLine
    $syntax_lines = [System.Collections.Generic.List`1[System.String]]@()
    $cmdlet_syntax = (Get-Help -Name $Documentation.name).Synopsis
    foreach ($syntax in $cmdlet_syntax.Split($nl, [System.StringSplitOptions]::RemoveEmptyEntries)) {
        $syntax = $syntax | Format-IndentAndWrapping -Indent 4 -MaxLength 120
        $syntax_lines.Add($syntax.Substring(4))
    }

    $example_idx = 1
    $example_lines = [System.Collections.Generic.List`1[System.String]]@()
    foreach ($example in $Documentation.examples) {
        $description = $example.description | Format-IndentAndWrapping -MaxLength 120
        $code = $example.code.Split([System.Char[]]@("`r", "`n")) -join $nl  # Ensures newlines are based on the [System.Environment]::NewLine
        $example_lines.Add(
            "{0}### EXAMPLE {1}: {2}{0}{0}``````powershell{0}{3}{0}``````{0}{0}{4}" -f
                ($nl, $example_idx, $example.name, $code, $description)
        )
        $example_idx += 1
    }
    if ($example_lines.Count -eq 0) {
        $example_lines.Add('{0}None' -f $nl)
    }

    $parameter_lines = [System.Collections.Generic.List`1[System.String]]@()
    foreach ($parameter in $Documentation.parameters) {
        $description = $parameter.description | Format-IndentAndWrapping
        $aliases = "None"
        if ($parameter.aliases.Count -gt 0) {
            $aliases = $parameter.aliases -join ", "
        }

        # The metadata values don't match what we actually display.
        $parameter_sets = [Ordered]@{}
        $ps_keys = $parameter.parameter_sets.Keys | Sort-Object
        foreach ($key in $ps_keys) {
            $set = $parameter.parameter_sets.$key
            $pipeline_input = "False"
            if ($set.pipeline_inputs.Count -gt 0) {
                $pipeline_input = "True ({0})" -f ($set.pipeline_inputs -join ", ")
            }

            $set_info = [Ordered]@{
                Required = if ($set.required) { "True" } else { "False" }
                Position = if ($null -eq $set.position) { "Named" } else { $set.position }
                "Accept pipeline input" = $pipeline_input
            }
            $parameter_sets.Add($key, $set_info)
        }

        $extra_info = [Ordered]@{
            Type = $parameter.type
            Aliases = $aliases
            "Default value" = if ($null -eq $parameter.default) { "None" } else { $parameter.default }
            "Accept wildcard characters" = if ($parameter.accepts_wildcard) { "True" } else { "False" }
            "Parameter Sets" = $parameter_sets
        } | ConvertTo-Yaml

        $dynamic_str = ''
        if ($parameter.is_dynamic) {
            $dynamic_str = ' (Dynamic)'
        }

        $parameter_lines.Add(
            "{0}### -{1}{2}{0}{0}{3}{0}{0}``````{0}{4}``````" -f
                ($nl, $parameter.name, $dynamic_str, $description, $extra_info)
        )
    }
    if ($Documentation.cmdlet_binding) {
        $common_param = "{0}### CommonParameters{0}{0}This cmdlet supports the common parameters: -Debug, " -f $nl
        $common_param += "-ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, "
        $common_param += "-OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more "
        $common_param += "information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216)."
        $parameter_lines.Add($common_param)
    } elseif ($Documentation.parameters.Count -eq 0) {
        # Add None to signify no parameters are beng set.
        $parameter_lines.Add("{0}None" -f $nl)
    }

    $input_lines = [System.Collections.Generic.List`1[System.String]]@()
    foreach ($cmdlet_input in $Documentation.inputs) {
        $description = $cmdlet_input.description | Format-IndentAndWrapping
        $input_lines.Add(
            "{0}### [{1}] - {2} ({3}){0}{0}{4}" -f
                ($nl, $cmdlet_input.type, $cmdlet_input.name, ($cmdlet_input.pipeline_types -join ", "), $description)
        )
    }

    if ($input_lines.Count -eq 0) {
        $input_lines.Add("{0}None" -f $nl)
    }

    # Build the markdown string
    $markdown_string = @"
# $($Documentation.name)
 
## SYNOPSIS
 
$($Documentation.synopsis | Format-IndentAndWrapping)
 
 
## SYNTAX
 
``````
$($syntax_lines -join ($nl * 2))
``````
 
 
## DESCRIPTION
 
$($Documentation.description | Format-IndentAndWrapping)
 
 
## EXAMPLES
$($example_lines -join $nl)
 
 
## PARAMETERS
$($parameter_lines -join $nl)
 
 
## INPUTS
$($input_lines -join $nl)
"@


    if ($Documentation.outputs.Count -gt 0) {
        $markdown_string += "{0}{0}{0}## OUTPUTS" -f $nl
    }
    foreach ($output in $Documentation.outputs) {
        $description = $output.description | Format-IndentAndWrapping

        $struct_lines = [System.Collections.Generic.List`1[System.String]]@()
        foreach ($struct_entry in $output.structure) {
            $prop_description = $struct_entry.description | Format-IndentAndWrapping
            $struct_lines.Add(
                "|{0}|{1}|{2}|{3}|" -f ($struct_entry.name, $prop_description, $struct_entry.type, $struct_entry.when)
            )
        }

        $output_structure = ""
        if ($struct_lines.Count -gt 0) {
            $output_structure = (
                "{0}{0}| Property | Description | Type | Output When |{0}|----------|-------------|------|-------------|{0}{1}" -f ($nl, ($struct_lines -join $nl))
            )
        }

        $markdown_string += (
            "{0}{0}### Parameter Sets - {1}{0}{0}Output Types: ``[{2}]``{0}{0}{3}{4}" -f
                ($nl, ($output.parameter_sets -join ", "), ($output.types -join "], ["), $description, $output_structure)
        )
    }

    if ($Documentation.notes.Count -gt 0) {
        $notes = $Documentation.notes | Format-IndentAndWrapping
        $markdown_string += (
            "{0}{0}{0}## NOTES{0}{0}{1}" -f ($nl, $notes)
        )
    }

    if ($Documentation.links.Count -gt 0) {
        $markdown_string += "{0}{0}{0}## RELATED LINKS{0}" -f $nl
    }
    foreach ($link in $Documentation.links) {
        if ($link.link.StartsWith('C(')) {
            $link_text = $link.link
        } elseif ([System.String]::IsNullOrEmpty($link.text)) {
            $link_text = "[{0}]({0})" -f $link.link
        } else {
            $link_text = "[{0}]({1})" -f ($link.text, $link.link)
        }
        $markdown_string += (
            "{0}* {1}" -f ($nl, $link_text)
        )
    }

    # Replace instances of C(cmdlet name) for Markdown docs.
    $markdown_string = [System.Text.RegularExpressions.Regex]::Replace(
        $markdown_string,
        'C\(([\w-]*)\)',
        '[$1]($1.md)'
    )

    return $markdown_string
}

Function New-PowerShellDoc {
    <#
    .SYNOPSIS
    Generate a PowerShell cmdlet doc string.
 
    .DESCRIPTION
    Generate a PowerShell doc string that fits the standard for a PowerShell cmdlet. This takes in the metadata as
    parsed by `PSDocHelper`.
 
    .PARAMETER Documentation
    [System.Collections.Hashtable]
    A hashtable that contains the cmdlet/function metadata which is translated into the PowerShell doc. This hashtable
    is generated by `PSDocHelper`.
 
    .PARAMETER Indent
    [System.Int32]
    The number of spaces to indent the doc string by.
 
    .EXAMPLE Generate PowerShell doc string.
    Generate the PowerShell function doc string based on the path to the cmdlet.
 
        $cmdlet = Get-CmdletFromPath -Path C:\ps_cmdlet.ps1
        $cmdlet_doc = Get-CmdletDocumentation -Cmdlet $cmdlet
        $cmdlet_meta = Get-CmdletMetadata -Cmdlet $cmdlet -Documenation $cmdlet_doc
        $ps_doc = New-PowerShellDoc -Documentation $cmdlet_meta
 
    .OUTPUTS
    ([System.String]) - Parameter Sets: (All)
    The PowerShell doc string generated from the metadata.
    #>

    [CmdletBinding(SupportsShouldProcess=$false)]
    [OutputType([System.String])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification='Not affecting system state, just outputting a string.'
    )]
    Param (
        [Parameter(Mandatory=$true)]
        [Hashtable]
        $Documentation,

        [System.Int32]
        $Indent = 4
    )

    $nl = [System.Environment]::NewLine
    $space_indent = " " * $Indent
    $format_params = @{
        Indent = $Indent
        MaxLength = 120
    }

    $synopsis = $Documentation.synopsis | Format-IndentAndWrapping @format_params
    $description = $Documentation.description | Format-IndentAndWrapping @format_params

    $doc_string = @"
$space_indent.SYNOPSIS
$synopsis
 
$space_indent.DESCRIPTION
$description
"@


    foreach ($parameter in $Documentation.parameters) {
        $parameter_description = $parameter.description | Format-IndentAndWrapping @format_params
        $doc_string += (
            "{0}{0}{1}.PARAMETER {2}{0}{1}[{3}]{0}{4}" -f
            ($nl, $space_indent, $parameter.name, $parameter.type, $parameter_description)
        )
    }

    foreach ($example in $Documentation.examples) {
        $example_description = $example.description | Format-IndentAndWrapping @format_params
        $code = $example.code | Format-IndentAndWrapping -Indent ($Indent * 2)
        $doc_string += (
            "{0}{0}{1}.EXAMPLE {2}{0}{3}{0}{0}{4}" -f
            ($nl, $space_indent, $example.name, $example_description, $code)
        )
    }

    foreach ($doc_input in $Documentation.inputs) {
        $input_description = $doc_input.description | Format-IndentAndWrapping @format_params
        $doc_string += (
            "{0}{0}{1}.INPUTS{0}{1}[{2}]`${3} - {4}{0}{5}" -f
            ($nl, $space_indent, $doc_input.type, $doc_input.name,
            ($doc_input.pipeline_types -join ", "), $input_description)
        )
    }

    foreach ($output in $Documentation.outputs) {
        $output_description = $output.description | Format-IndentAndWrapping @format_params

        $struct_lines = [System.Collections.Generic.List`1[System.String]]@()
        foreach ($struct_entry in $output.structure) {
            $prop_name = $struct_entry.name | Format-IndentAndWrapping @format_params
            $prop_description = $struct_entry.description | Format-IndentAndWrapping -Indent ($format_params.Indent + 4) -MaxLength $format_params.MaxLength

            $prop_type = ""
            if (-not [System.String]::IsNullOrEmpty($struct_entry.type)) {
                $prop_type = " - [{0}]" -f $struct_entry.type
            }

            $struct_lines.Add("{0}{1}{2}{3}" -f ($prop_name, $prop_type, $nl, $prop_description))
        }

        $output_structure = ""
        if ($struct_lines.Count -gt 0) {
            $output_structure = "{0}{0}{1}Contains:{0}{2}" -f ($nl, $space_indent, ($struct_lines -join $nl))
        }

        $doc_string += (
            "{0}{0}{1}.OUTPUTS{0}{1}([{2}]) - Parameter Sets: {3}{0}{4}{5}" -f
            ($nl, $space_indent, ($output.types -join "], ["), ($output.parameter_sets -join ", "), $output_description, $output_structure)
        )
    }

    if ($Documentation.notes.Count -gt 0) {
        $note = $Documentation.notes | Format-IndentAndWrapping @format_params
        $doc_string += ("{0}{0}{1}.NOTES{0}{2}" -f ($nl, $space_indent, $note))
    }

    foreach ($link in $Documentation.links) {
        $link_link = $link.link | Format-IndentAndWrapping -Indent $format_params.Indent
        $link_text = ''
        if (-not [System.String]::IsNullOrEmpty($link.text)) {
            $link_text = ("# $($link.text)" | Format-IndentAndWrapping -Indent $format_params.Indent) + $nl
        }
        $doc_string += ("{0}{0}{1}.LINK{0}{2}{3}" -f ($nl, $space_indent, $link_text, $link_link))
    }

    # Replace instances of C(cmdlet name) for PowerShell docs.
    $doc_string = [System.Text.RegularExpressions.Regex]::Replace(
        $doc_string, 'C\(([\w-]*)\)', '''$1'''
    )

    # Return the string with comments and proper indents.
    return $doc_string
}

$public_functions = @(
    'Format-FunctionWithDoc'
)

Export-ModuleMember -Function $public_functions