Get-MarkdownHelp.ps1
<#PSScriptInfo
.VERSION 1.2.1 .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 document from the comment based help of a PowerShell 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 between squared brackets, e.g. `[my link]`, will be automatically linked to the element where the id is defined by the enclosed phrase and converting the consecutive non-word characters to a single hyphen (and removing any outer hyphens). In this example: `[my link](#my-link)` > [!WARNING] > There is no check whether the internal anchor id actually exists. #### Example links **Example links** are based on **Quick Links** but start with a the word "example" followed by the example index (possibly separated by a space) or the word "example" followed by the example caption. Where the caption is used to identify the link by converting its consecutive non-word characters to a single hyphen. **Examples:** * `[example 1]` will link to the first ([example 1]) in this document * `[example 2 with any caption]` will link to the second ([example 2 -regardless the caption-]) in this document * `[example "Save markdown help to file"]` will link to the ([example "Save markdown help to file"]) in this document #### Parameter Links **Parameter links** are similar to **Quick Links** but start with a hyphen 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 Path An embedded command that contains the parameters or actual commented help. .PARAMETER ScriptBlock The script content that contains the commented help. .PARAMETER Command A specific command or function contained by the script file of block. .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.Collections.Generic using NameSpace System.Management.Automation using NameSpace System.Management.Automation.Language [CmdletBinding(DefaultParameterSetName = 'Path')][OutputType([String[]])] param( [Parameter(Mandatory, ParameterSetName = 'Path', Position = 0)] [Parameter(ValueFromPipeLine = $True, ValueFromPipelineByPropertyName = $True)] [Alias('Source')]$Path, [Parameter(Mandatory, ParameterSetName = 'Script', Position = 0)] [Parameter(ValueFromPipelineByPropertyName = $True)] $ScriptBlock, [Parameter(ValueFromPipelineByPropertyName = $True)] $Command, [String]$PSCodePattern = 'PS.*\>\s?', [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) { { '.Synopsis', '.Description', '.Inputs', '.Outputs', '.Notes', '.Link' -eq $_ } { $Key = $_.SubString(1) $Item = $Help[$Key] = [List[Sentence]]::new() } '.Example' { if (!$Help.Contains('Example')) { $Help['Example'] = @{} } $Index = $Help['Example'].Count + 1 $Item = $Help['Example'][$Index] = [List[Sentence]]::new() } { $_ -Like '.Parameter *' } { if (!$Help.Contains('Parameter')) { $Help['Parameter'] = @{} } $Name = ($_ -Split '\.Parameter\s+', 2)[1] $Item = $Help['Parameter'][$Name] = [List[Sentence]]::new() } Default { if ($Null -ne $Item) { if ($Item.Count -Or $Sentence.Text) { $Item.add($Sentence) } } elseif (!$_) { break } } } } $Help } function GetCommentedHelp([String]$Content) { $Comments = [List[String]]::new() $Tokens = [PSParser]::Tokenize($Content, [Ref]$Null) $Index = 0 do { $Token = if ($Index -lt $Tokens.Count) { $Tokens[$Index++] } if ($Token -and $Token.Type -eq 'Comment') { if ($Token.Content.StartsWith('#') ) { $Comments.Add($Token.Content.SubString(1)) } else { #Block Comment $Lines = $Token.Content -split '\r?\n' if (-not $Lines) { Throw 'Expected comment to include comment markers.' } if (-not $Lines[0].StartsWith('<#')) { Throw 'Expected comment to start with "<#".' } $Start = 0 if ($Lines[0].Count -eq 2) { $Start++ } else { $Lines[0] = $Lines[0].SubString(2) } if (-not $Lines[-1].EndsWith('#>')) { Throw 'Expected comment to end with "#>".' } $End = $Lines.Count - 1 if ($Lines[0].Count -eq 2) { $End-- } else { $Lines[-1] = $Lines[-1].SubString(0, $Lines[-1].Length - 2) } $Help = GetHelpItems ($Lines[$Start..$End]) if ($Help.Contains('Synopsis')) { return $Help } $Comments.Clear() } } elseif (-not $Token -or $Token.Type -ne 'NewLine') { if ($Comments.Count -gt 2) { $Help = GetHelpItems $Comments if ($Help.Contains('Synopsis')) { return $Help } } $Comments.Clear() } } while ($Token) } 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 '^example\s*(\d+)' -and $Help.Example.Contains([int]$Matches[1])) { "[$Label](#example-$($Matches[1]))" } elseif ( $Label -Match '^(-\w+)(\s+parameter)?$' -and $Help.Parameter.Contains($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 -Replace '\W+', '-').Trim('-').ToLower()))" } } } $Index = 0 -Join @( foreach ($String in @(SplitInLineCode $Markdown)) { if ($Index++ -band 1) { $String } # Inline code else { ([regex]'(?<!\])\[[^\[\]\n]+\](?![\[\(])').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 } } function ToExpression($Object) { if ($null -eq $Object) { return '$Null' } $Object.foreach{ if ($null -eq $_) { '$Null' } elseif ($Object -is [Bool]) { "`$$_" } elseif ($Object -is [ValueType]) { $_ } elseif ($Object -is [String]) { "'$_'" } else { $_ } } -Join ', ' } } process { if ($PSBoundParameters.ContainsKey('ScriptBlock')) { $Ast = [Parser]::ParseInput($ScriptBlock, [ref]$Null, [ref]$Null) if (-not $Ast) { StopError "Cannot parse script." } } else { $File = try { Get-Item $Path } catch { $Null } if (-not $File) { StopError "Cannot find file '$Path'." } $Ast = [Parser]::ParseFile($File.FullName, [ref]$Null, [ref]$Null) if (-not $Ast) { StopError "Cannot parse file '$Path'." } } $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 any parameters." } $Help = GetCommentedHelp $Body if (-not $Help -and $Body.Parent) { $Help = GetCommentedHelp $Ast } if (-not $Help) { StopError "Cannot find comment base help in '$Source'" } $Parameters = $Body.ParamBlock.Parameters $ParameterSets = [Ordered]@{} $AllSets = [List[String]]::new() $Parameters.foreach{ $Name = $_.Name.VariablePath.UserPath $Sets = $_.Attributes.NamedArguments.where{ $_.ArgumentName -eq 'ParameterSetName' } if ($Sets) { foreach ($SetName in $Sets.Argument.Value) { if (-not $ParameterSets.Contains($SetName)) { $ParameterSets[$SetName] = [Ordered]@{} } $ParameterSets[$SetName][$Name] = $_ } } else { $AllSets.Add($Name) } } if ($ParameterSets.Count -eq 0) { $ParameterSets[''] = [Ordered]@{}} $Parameters.where{$_.Name.VariablePath.UserPath -in $AllSets}.foreach{ $Name = $_.Name.VariablePath.UserPath if ($Name -in $AllSets) { foreach ($SetName in $ParameterSets.get_Keys()) { $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' $Count = $Help.Example.Count for ($i = 1; $i -le $Count; $i++) { $Id = "example-$i" $MarkDown = GetMarkDown $Help.Example[$i] if ($Help.Example[$i].Count -and $Help.Example[$i][0].Text.StartsWith('#')) { $Caption = $Help.Example[$i][0].Text.SubString(1).Trim() $Anchor = ('example-' + $Caption -Replace '\W+', '-').Trim('-').ToLower() '' "### <a id=""$Id""><a id=""$Anchor"">Example $($i): $Caption</a></a>" '' GetMarkDown $Help.Example[$i] | Select-Object -Skip 1 $Help.Example[$('example-' + $Caption -Replace '\W+', '-').Trim('-')] = $Id } else { "### <a id=""$Id"">Example $($i):</a>" '' GetMarkDown $Help.Example[$i] '' } } } if ($Parameters) { '' '## Parameters' $Parameters.foreach{ $Name = $_.Name.VariablePath.UserPath $Type = if ($_.StaticType.Name -ne 'SwitchParameter') { $_.StaticType.Name -as [Type] } $TypeLink = if ($Type) { $TypeUri = 'https://docs.microsoft.com/en-us/dotnet/api/' + $Type.FullName " <a href=""$TypeUri""><$($Type.Name)></a>" } '' "### <a id=""-$($Name.ToLower())"">``-$Name``$TypeLink</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.MinLength } elseif ($Null -ne $Attributes.MaxLength) { $Dictionary['Maximal length'] = $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['Name'] = "-$Name" $Aliases = $_[0].Attributes.where{ $_.TypeName.Name -eq 'Alias' }.PositionalArguments.foreach{ "-$($_.Value)" } -Join ', ' $Dictionary['Aliases'] = if ($Aliases) { $Aliases } else { '# None' } $Dictionary['Type'] = "[$($_.StaticType.Name)]" $Dictionary['Value (default)'] = if ($_.DefaultValue) { ToExpression $_.DefaultValue } else { '# Undefined' } $ParameterSetNames = $_.Attributes.NamedArguments.where{ $_.ArgumentName -eq 'ParameterSetName' }.Argument.Value $Dictionary['Parameter sets'] = if ($ParameterSetNames) { $ParameterSetNames -Join ', ' } else { '# All' } $Dictionary['Mandatory'] = [bool]$Attributes.NamedArguments.where{ $_.ArgumentName -eq 'Mandatory' } $Dictionary['Position'] = if ($Attributes.Position -lt 0) { '# Named' } elseif ($Attributes.Position -ne $Attributes.Position[0]) { $Attributes.Position -Join ', ' } else { $Attributes.Position[0] } $Dictionary['Accept pipeline input'] = [Bool]$Attributes.ValueFromPipelineByPropertyName $Dictionary['Accept wildcard characters'] = ($_.Attributes.where{$_.TypeName.Name -eq 'SupportsWildcards'}).Count -gt 0 '```powershell' $Dictionary.get_Keys().ForEach{ -Join ("$($_):".PadRight(28), $Dictionary[$_]) } '```' } } if ($Help.Contains('Inputs')) { '' '## Inputs' '' GetMarkDown $Help.Inputs } if ($Help.Contains('Outputs')) { '' '## Outputs' '' GetMarkDown $Help.Outputs } if ($Help.Contains('Link')) { '' '## Related Links' '' $LinkReferences = [List[String]]::new() $InList = $null ForEach ($Sentence in $Help.Link) { $IsLink = $Sentence.Text -Match $ReferencePattern if ($IsLink) { if ($Matches.Contains('Label')) { $LinkReferences.Add($Matches[0]) } if ($Matches.Contains('Title') -and $Matches['Title']) { "* [$($Matches['Title'])]($($Matches['Uri']))" } elseif ($Matches.Contains('Uri')) { "* $($Matches['Uri'])" } } else { if ($InList) { '<!-- -->' } $Sentence.Text } $InList = $IsLink } if ($LinkReferences) { '' @($LinkReferences).ForEach{ $_ } } } '' '[comment]: <> (Created with Get-MarkdownHelp: Install-Script -Name Get-MarkdownHelp)' } |