Get-MarkdownHelp.ps1
<#PSScriptInfo
.VERSION 1.0.8 .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 `String` (command name) .OUTPUTS `String[]` .PARAMETER CommandName Specifies the name of the cmdlet that contains the [comment based help][2]. .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" #> [CmdletBinding(DefaultParameterSetName='Html')][OutputType([Object[]])] param( [Parameter(ValueFromPipeLine = $True, ValueFromPipelineByPropertyName = $True, Mandatory = $True)] [Alias('Name')][String]$CommandName, [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 GetHelpItems([String[]]$Lines) { $Key = $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]$Script) { $Help = $Null $Lines = [Collections.Generic.List[String]]::new() foreach ($Token in [System.Management.Automation.PSParser]::Tokenize($Script, [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) { $TypeName = $Type.Name $TypeUri = 'https://docs.microsoft.com/en-us/dotnet/api/' + $Type.FullName "<a href=""$TypeUri"">$TypeName</a>" } else { $TypeName } } } process { $TempName = '__TempFunctionName' if ($CommandName.Contains('\') -or $CommandName.Contains('/')) { $Name = [System.IO.Path]::GetFileNameWithoutExtension($CommandName) $Command = New-Item -Path function: -Name $TempName -Value (Get-Content -Raw $CommandName) -Force } else { $Name = $CommandName $Command = Get-Command $Name } $Help = GetHelp $Command.ScriptBlock if (!$Help -and $Command.Module) { $TempCommand = New-Item -Path function: -Name $TempName -Value (Get-Content -Raw $Command.Module.Path) -Force $Help = GetHelp $TempCommand.ScriptBlock } if ($Help) { '<!-- markdownlint-disable MD033 -->' Write-Debug ($Help |ConvertTo-Json -Depth 9) $Ast = [System.Management.Automation.Language.Parser]::ParseInput($Command.ScriptBlock,[ref]$Null,[ref]$Null) $Params = @(if ($Ast.ParamBlock) { $Ast.ParamBlock.FindAll({$Args[0] -is [System.Management.Automation.Language.ParameterAst]}, $True) }) $ParamNames = @(if ($Params) { $Params.Name.VariablePath.UserPath }) "# $Name" '' GetMarkDown $Help.Synopsis $Syntax = Get-Command $Command.Name -Syntax if ($Syntax) { '' '## Syntax' '' '```JavaScript' foreach ($Line in ($Syntax -split '[\r\n]+')) { $SyntaxName, $Parameters = $Line -Split ' (?=\-|\[\-|\[\[|\[\<)' if ($SyntaxName -eq $Command.Name) { $Name foreach ($Parameter in $Parameters) { $Tab + $Parameter } } } '```' } if ($Help.Contains('Description')) { '' '## Description' '' GetMarkDown $Help.Description } if ($Help.Contains('Example')) { '' '## Examples' for ($i = 0; $i -lt $Help.Example.Count; $i++) { $Count = $Help.Example[$i].Count if ($Count -gt 1 -and $Help.Example[$i][0].Text.StartsWith('#')) { '' "### Example $($i + 1): " + $Help.Example[$i][0].Text.SubString(1).Trim() '' GetMarkDown $Help.Example[$i][1..($Count - 1)] } else { "### Example $($i + 1):" '' GetMarkDown $Help.Example[$i] '' } } } if ($Params) { '' '## Parameter' foreach ($Param in $Params) { $Name = $Param.Name.VariablePath.UserPath $Parameter = $Command.Parameters[$Name] $Type = $Parameter.parameterType.Name $_Type = if ($Type -ne 'SwitchParameter') { " <$Type>" } '' "### <a id=""-$($Name.ToLower())"">**``-$Name$_Type``**</a>" if ($Help.Contains('Parameter') -and $Help.Parameter.Contains($Name)) { "" GetMarkDown $Help.Parameter[$Name] } '' $Dictionary = [Ordered]@{} $Attributes = $Parameter.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($Parameter.parameterType) if ($Parameter.Aliases) { $Dictionary['Aliases'] = $Parameter.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 ($Param.DefaultValue) { "<code>$($Param.DefaultValue)</code>" } # https://stackoverflow.com/a/64358608/1701026 $Dictionary['Default value'] = $DefaultValue $Dictionary['Accept pipeline input'] = $Attributes.ValueFromPipelineByPropertyName $Globbing = ($Param.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{ $_ } } } } else { Write-Error "The comment based help for ""$CommandName"" could not be found" } if (Get-Item -Path function:$TempName -ErrorAction SilentlyContinue) { Remove-Item -Path function:$TempName } } |