allcommands.ps1
### DO NOT EDIT THIS FILE DIRECTLY ### #.ExternalHelp HelpOut-Help.xml function Get-MAML { [CmdletBinding(DefaultParameterSetName='CommandInfo')] [OutputType([string],[xml])] [Alias('ConvertTo-MAML')] param( # The name of or more commands. [Parameter(ParameterSetName='ByName',Position=0,ValueFromPipelineByPropertyName=$true)] [string[]] $Name, # The name of one or more modules. [Parameter(ParameterSetName='ByModule',ValueFromPipelineByPropertyName=$true)] [string[]] $Module, # The CommandInfo object (returned from Get-Command). [Parameter(Mandatory=$true,ParameterSetName='FromCommandInfo', ValueFromPipeline=$true)] [Management.Automation.CommandInfo[]] $CommandInfo, # If set, the generated MAML will be compact (no extra whitespace or indentation). If not set, the MAML will be indented. [switch] $Compact, # If set, will return the MAML as an XmlDocument. The default is to return the MAML as a string. [switch] $XML, # If set, the generate MAML will not contain a version number. # This slightly reduces the size of the MAML file, and reduces the rate of changes in the MAML file. [Alias('Unversioned')] [switch] $NoVersion) begin { # First, we need to create a list of all commands we encounter (so we can process them at the end) $allCommands = [Collections.ArrayList]::new() # Then, we want to get the type accelerators (so we don't have to keep getting them each time we're interested) $typeAccelerators = [PSOBject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get # Next up, we're going to declare a bunch of ScriptBlocks, which we'll call to construct the XML in pieces. # This way we can create a nested structure (in this case, XML), by calling the pieces we want and letting them return the XML in chunks #region Get TypeName $GetTypeName = {param([Type]$t) # We'll want to check to see if there are any accelerators. if (-not $typeAccelerators -and $typeAccelerators.GetEnumerator) { # If there weren't return $t.Fullname # return the fullname. } foreach ($_ in $typeAccelerators.GetEnumerator()) { # Loop through the accelerators. if ($_.Value -eq $t) { # If it's an accelrator for the target type return $_.Key.Substring(0,1).ToUpper() + $_.Key.Substring(1) # return the key (and fix it's casing) } } return $t.Fullname # If we didn't find it in the accelerators list, return the fullname. } #endregion Get TypeName #region Write Type # Both Inputs and Outputs have the same internal tag structure for a value, so one script block handles both cases. $WriteType = {param($t) $typename = $t.type[0].name $descriptionLines = $null if ($in.description) { # If we have a description, $descriptionLines = $in.Description[0].text -split "`n|`r`n" -ne '' # we we're good. } else { # If we didn't, it's probably because comment based help mangles things a bit (it puts everything in a long typename). # Let's fix this by assigning the inType from the first line, and setting the rest as description lines $typename, $descriptionLines = $t.type[0].Name -split "`n|`r`n" -ne '' } $typename = [Security.SecurityElement]::Escape("$typename".Trim()) "<dev:type><maml:name>$typename</maml:name><maml:uri/><maml:description /></dev:type>" # Write the type information if ($descriptionLines) { # If we had a description '<maml:description>' foreach ($line in $descriptionLines) { # Write each line in it's own para tag so that it renders right. $esc = [Security.SecurityElement]::Escape($line) "<maml:para>$esc</maml:para>" } '</maml:description>' } } #endregion Write Type #region Write Command Details $writeCommandDetails = { # The command.details tag has 5 parts we want to provide # * Name, # * Noun # * Verb # * Synopsis # * Version $Version = "<dev:version>$(if ($cmdInfo.Version) { $cmdInfo.Version.ToString() })</dev:version>" "<command:details> <command:name>$([Security.SecurityElement]::Escape($cmdInfo.Name))</command:name> <command:noun>$noun</command:noun> <command:verb>$verb</command:verb> <maml:description> <maml:para>$([Security.SecurityElement]::Escape($commandHelp.Synopsis))</maml:para> </maml:description> $(if (-not $NoVersion) { $Version}) </command:details> <maml:description> $( foreach ($line in @($commandHelp.Description)[0].text -split "`n|`r`n") { if (-not $line) { continue } "<maml:para>$([Security.SecurityElement]::Escape($Line))</maml:para>" } ) </maml:description> " } #endregion Write Command Details #region Write Parameter $WriteParameter = { # Prepare the command.parameter attributes: $position = if ($param.Position -ge 0) { $param.Position } else {"named" } #* Position $fromPipeline = #*FromPipeline if ($param.ValueFromPipeline) { "True (ByValue)" } elseif ($param.ValueFromPipelineByPropertyName) { "True (ByPropertyName)" } else { "False" } $isRequired = if ($param.IsMandatory) { "true" } else { "false" } #*Required # Pick out the help for a given parameter $paramHelp = foreach ($_ in $commandHelp.parameters.parameter) { if ( $_.Name -eq $param.Name ){ $_ break } } $paramTypeName = & $GetTypeName $param.ParameterType # and get the type name of the parameter type. "<command:parameter required='$isRequired' position='$position' pipelineInput='$fromPipeline' aliases='' variableLength='true' globbing='false'>" #* Echo the start tag "<maml:name>$($param.Name)</maml:name>" #* The maml.name tag '<maml:description>' #*The description tag foreach ($d in $paramHelp.Description) { "<maml:para>$([Security.SecurityElement]::Escape($d.Text))</maml:para>" } '</maml:description>' #*The parameterValue tag (which oddly enough, describes the parameter type) "<command:parameterValue required='$isRequired' variableLength='true'>$paramTypeName</command:parameterValue>" #*The type tag (which is also it's type) "<dev:type><maml:name>$paramTypeName</maml:name><maml:uri /></dev:type>" #*and an empty default value. '<dev:defaultValue></dev:defaultValue>' #* Then close the parameter tag. '</command:parameter>' } #endregion Write Parameter #region Write Parameters $WriteCommandParameters = { '<command:parameters>' # *Open the parameters tag; foreach ($param in ($cmdMd.Parameters.Values | Sort-Object Name)) { #*Loop through the command's parameters alphabetically & $WriteParameter #*Write each parameter. } '</command:parameters>' #*Close the parameters tag } #endregion Write Parameters #region Write Examples $WriteExamples = { # If there were no examples, return. if (-not $commandHelp.Examples.example) { return } "<command:examples>" foreach ($ex in $commandHelp.Examples.Example) { # For each example: '<command:example>' #*Start an example tag '<maml:title>' $ex.Title #*Put it's title in a maml:title tag '</maml:title>' '<maml:introduction>'#* Put it's introduction in a maml:introduction tag foreach ($i in $ex.Introduction) { '<maml:para>' [Security.SecurityElement]::Escape($i.Text) '</maml:para>' } '</maml:introduction>' '<dev:code>' #* Put it's code in a dev:code tag [Security.SecurityElement]::Escape($ex.Code) '</dev:code>' '<dev:remarks>' #* Put it's remarks in a dev:remarks tag foreach ($i in $ex.Remarks) { if (-not $i -or -not $i.Text.Trim()) { continue } '<maml:para>' [Security.SecurityElement]::Escape($i.Text) '</maml:para>' } '</dev:remarks>' '</command:example>' } '</command:examples>' } #endregion Write Examples #region Write Inputs $WriteInputs = { if (-not $commandHelp.inputTypes) { return } # If there were no input types, return. '<command:inputTypes>' #*Open the inputTypes Tag. foreach ($in in $commandHelp.inputTypes[0].inputType) { #*Walk thru each type in help. '<command:inputType>' & $WriteType $in #*Write the type information (in an inputType tag). '</command:inputType>' } '</command:inputTypes>' #*Close the Input Types Tag. } #endregion Write Inputs #region Write Outputs $WriteOutputs = { if (-not $commandHelp.returnValues) { return } # If there were no return values, return. '<command:returnValues>' # *Open the returnValues tag foreach ($rt in $commandHelp.returnValues[0].returnValue) { # *Walk thru each return value '<command:returnValue>' & $WriteType $rt # *write the type information (in an returnValue tag) '</command:returnValue>' } '</command:returnValues>' #*Close the returnValues tag } #endregion Write Outputs #region Write Notes $WriteNotes = { if (-not $commandHelp.alertSet) { return } # If there were no notes, return. "<maml:alertSet><maml:title></maml:title>" #*Open the alertSet tag and emit an empty title foreach ($note in $commandHelp.alertSet[0].alert) { #*Walk thru each note "<maml:alert><maml:para>" $([Security.SecurityElement]::Escape($note.Text)) #*Put each note in a maml:alert element "</maml:para></maml:alert>" } "</maml:alertSet>" #*Close the alertSet tag } #endregion Write Notes #region Write Syntax $WriteSyntax = { if (-not $cmdInfo.ParameterSets) { return } # If this command didn't have parameters, return "<command:syntax>" #*Open the syntax tag foreach ($syn in $cmdInfo.ParameterSets) {#*Walk thru each parameter set "<command:syntaxItem><maml:name>$($cmdInfo.Name)</maml:name>" #*Create a syntaxItem tag, with the name of the command. foreach ($param in $syn.Parameters) { #* Skip parameters that are not directly declared (e.g. -ErrorAction) if (-not $cmdMd.Parameters.ContainsKey($param.Name)) { continue } & $WriteParameter #* Write help for each parameter } "</command:syntaxItem>" #*Close the syntax item tag } "</command:syntax>"#*Close the syntax tag } #endregion Write Syntax #region Write Links $WriteLinks = { # If the command didn't have any links, return. if (-not $commandHelp.relatedLinks.navigationLink) { return } '<maml:relatedLinks>' #* Open a related Links tag foreach ($l in $commandHelp.relatedLinks.navigationLink) { #*Walk thru each link $linkText, $LinkUrl = "$($l.linkText)".Trim(), "$($l.Uri)".Trim() # and write it's tag. '<maml:navigationLink>' "<maml:linkText>$linkText</maml:linkText>" "<maml:uri>$LinkUrl</maml:uri>" '</maml:navigationLink>' } '</maml:relatedLinks>' #* Close the related Links tag } #endregion Write Links #- - - Now that we've declared all of these little ScriptBlock parts, we'll put them in a list in the order they'll run. $WriteMaml = $writeCommandDetails, $writeSyntax,$WriteCommandParameters,$WriteInputs,$writeOutputs, $writeNotes, $WriteExamples, $writeLinks #- - - } process { if ($PSCmdlet.ParameterSetName -eq 'ByName') { # If we're getting comamnds by name, $CommandInfo = @(foreach ($n in $name) { $ExecutionContext.InvokeCommand.GetCommands($N,'Function,Cmdlet', $true) # find each command (treating Name like a wildcard). }) } if ($PSCmdlet.ParameterSetName -eq 'ByModule') { # If we're getting commands by module $CommandInfo = @(foreach ($m in $module) { # find each module (Get-Module -Name $m).ExportedCommands.Values # and get it's exports. }) } $filteredCmds = @(foreach ($ci in $CommandInfo) { # Filter the list of commands if ($ci -is [Management.Automation.AliasInfo] -or # (throw out aliases and applications). $ci -is [Management.Automation.ApplicationInfo]) { continue } $ci }) if ($filteredCmds) { $null = $allCommands.AddRange($filteredCmds) } } end { $c, $t, $id, $maml = # Create some variables for our progress bar, 0, $allCommands.Count, [Random]::new().Next(), [Text.StringBuilder]::new('<helpItems schema="maml">') # and initialize our MAML. foreach ($cmdInfo in $allCommands) { # Walk thru each command. $commandHelp = $null $c++ $p = $c * 100 / $t Write-Progress 'Converting to MAML' "$cmdInfo [$c of $t]" -PercentComplete $p -Id $id # Write a progress message $commandHelp = $cmdInfo | Get-Help # get it's help $cmdMd = [Management.Automation.CommandMetaData]$cmdInfo # get it's command metadata if (-not $commandHelp -or $commandHelp -is [string]) { # (error if we couldn't Get-Help) Write-Error "$cmdInfo Must have a help topic to convert to MAML" return } $verb, $noun = $cmdInfo.Name -split "-" # and split out the noun and verb. # Now we're ready to run all of those script blocks we declared in begin. # All we need to do is append the command node, run each of the script blocks in $WriteMaml, and close the node. $mamlCommand = "<command:command xmlns:maml='http://schemas.microsoft.com/maml/2004/10' xmlns:command='http://schemas.microsoft.com/maml/dev/command/2004/10' xmlns:dev='http://schemas.microsoft.com/maml/dev/2004/10'> $(foreach ($_ in $WriteMaml) { & $_ }) </command:command>" $null = $maml.AppendLine($mamlCommand) } Write-Progress "Exporting Maml" " " -Completed -Id $id # Then we indicate we're done, $null = $maml.Append("</helpItems>") # close the opening tag. $mamlAsXml = [xml]"$maml" # and convert the whole thing to XML. if (-not $mamlAsXml) { return } # If we couldn't, return. if ($XML) { return $mamlAsXml } # If we wanted the XML, return it. $strWrite = [IO.StringWriter]::new() # Now for a little XML magic: # If we create a [IO.StringWriter], we can save it as pretty or compacted XML. $mamlAsXml.PreserveWhitespace = $Compact # Oddly enough, if we're compacting we're setting preserveWhiteSpace to true, which in turn strips all of the whitespace except that inside of your nodes. $mamlAsXml.Save($strWrite) # Anyways, we can save this to the string writer, and it will either make our XML perfectly balanced and indented or compact and free of most whitespace. # Unfortunately, it will not get it's encoding declaration "right". This is because $strWrite is Unicode, and in most cases we'll want our XML to be UTF8. # The next step of the pipeline needs to convert it as it is saved, which is as easy as | Out-File -Encoding UTF8. "$strWrite".Replace('<?xml version="1.0" encoding="utf-16"?>','<?xml version="1.0" encoding="utf-8"?>') $strWrite.Close() $strWrite.Dispose() } } #.ExternalHelp HelpOut-Help.xml function Get-MarkdownHelp { [Reflection.AssemblyMetadata("HelpOut.TellStory", $true)] [Reflection.AssemblyMetadata("HelpOut.Story.Process", "For each Command")] [OutputType('PowerShell.Markdown.Help')] param( # The name of the specified command or concept. [Parameter(Position=0, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [string] $Name, # If set, will generate a markdown wiki. Links will be relative to the current path, and will not include the .md extensions [switch] $Wiki, # If set, will interlink documentation as if it were for GitHub pages, beneath a given directory [Alias('GitHubPageRoot')] [string] $GitHubDocRoot, # If provided, will rename the help topic before getting markdown. [Parameter(ValueFromPipelineByPropertyName)] [string] $Rename, # The order of the sections. # If not provided, this will be the order they are defined in the formatter. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $SectionOrder, # If set, will not enumerate valid values and enums of parameters. [Parameter(ValueFromPipelineByPropertyName)] [switch] $NoValidValueEnumeration, # If set, will not attach a YAML header to the generated help. [Parameter(ValueFromPipelineByPropertyName)] [Alias('IncludeFrontMatter', 'IncludeHeader')] [switch] $IncludeYamlHeader, # The type of information to include in the YAML Header [ValidateSet('Command','Help','Metadata')] [Alias('YamlHeaderInfoType')] [string[]] $YamlHeaderInformationType, # The formatting used for unknown attributes. # Any key or property in this object will be treated as a potential typename # Any value will be the desired formatting. # If the value is a [ScriptBlock], the [ScriptBlock] will be run. # If the value is a [string], it will be expanded # In either context, `$_` will be the current attribute. [PSObject] $FormatAttribute ) process { # We start off by copying the bound parameters $myParams= @{} + $PSBoundParameters # and then we call Get-Help. $getHelp = @{name=$Name} $gotHelp = Get-Help @getHelp # If we could not Get-Help, if (-not $gotHelp) { Write-Error "Could not get help for $name" return # we error out. } # We need to decorate the output of Get-Help so it renders as markdown, # so we pipe thru all results from Get-Help. $gotHelp | & { process { # Get-Help can return either a help topic or command help. $in = $_ # Help topics will be returned as a string if ($in -is [string]) { $in # (which we will output as-is for now). } else { $helpObj = $_ # Command Help will be returned as an object # We decorate that object with the typename `PowerShell.Markdown.Help`. # $helpObj.pstypenames.clear() $helpObj.pstypenames.insert(0,'PowerShell.Markdown.Help') $IsHelpAboutAlias = $helpObj.Name -ne $gotHelp.Name $helpObj | Add-Member NoteProperty IsAlias $IsHelpAboutAlias -Force if ($IsHelpAboutAlias) { $aliasCommand = $ExecutionContext.SessionState.InvokeCommand.GetCommand($gotHelp.Name, 'Alias') $helpObj | Add-Member NoteProperty AliasCommand $aliasCommand -Force } # Then we attach parameters passed to this command to the help object. # * `-Rename` will become `[string] .Rename` if ($Rename) { $helpObj | Add-Member NoteProperty Rename $Rename -Force } elseif ($IsHelpAboutAlias) { $helpObj | Add-Member NoteProperty Rename $gotHelp.Name -Force } # * `-SectionOrder` will become `[string[]] .SectionOrder` if ($SectionOrder) { $helpObj | Add-Member NoteProperty SectionOrder $SectionOrder -Force } # * `-Wiki` will become `[bool] .WikiLink` $helpObj | Add-Member NoteProperty WikiLink ($Wiki -as [bool]) -Force # * `-GitHubDocRoot` will become `.DocLink` if ($myParams.ContainsKey("GitHubDocRoot")) { $helpObj | Add-Member NoteProperty DocLink $GitHubDocRoot -Force } # * `-NoValidValueEnumeration` $helpObj | Add-Member NoteProperty NoValidValueEnumeration $NoValidValueEnumeration -Force # * `-IncludeYamlHeader` $helpObj | Add-Member NoteProperty IncludeYamlHeader $IncludeYamlHeader -Force # * `-NoValidValueEnumeration` $helpObj | Add-Member NoteProperty YamlHeaderInformationType $YamlHeaderInformationType -Force if ($FormatAttribute) { $helpObj | Add-Member NoteProperty FormatAttribute $FormatAttribute -Force } # After we've attached all of the properties, we simply output the object. # PowerShell.Markdown.Help formatter will display it exactly as we'd like it. $helpObj } } } } } #requires -version 3.0 function Get-ScriptReference { [CmdletBinding(DefaultParameterSetName='FilePath')] param( # The path to a file [Parameter(Mandatory=$true,Position=0,ParameterSetName='FilePath',ValueFromPipelineByPropertyName=$true)] [Alias('Fullname')] [string[]] $FilePath, # One or more PowerShell ScriptBlocks [Parameter(Mandatory=$true,Position=0,ParameterSetName='ScriptBlock',ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)] [Alias('Definition')] [ScriptBlock[]] $ScriptBlock, # If set, will recursively find references. [switch] $Recurse ) begin { # Let's declare some collections we'll need: $allFiles = [Collections.ArrayList]::new() # * A list of all files (if any are piped in) $LookedUpCommands = @{} # * The commands we've already looked up (to save time) } process { #region Process Piped in Files if ($PSCmdlet.ParameterSetName -eq 'FilePath') { # If we're piping in files, $allFiles.AddRange($FilePath) # add them to the list and process them in the end, return # and stop processing for good measure. } #endregion Process Piped in Files #region Get the Script References # To start off with, take all of the scripts passed in and put them in a queue. $scriptBlockQueue = [Collections.Generic.Queue[ScriptBlock]]::new($ScriptBlock) $resolvedCmds = @{} # Then create a hashtable to store the resolved references $alreadyChecked = [Collections.Generic.List[ScriptBlock]]::new() # and a list of all of the ScriptBlock's we've already taken a look at. # Now it's time for some syntax trickery that should probably be explained. # We're going to want to be able to recursively find references too. # By putting this in a queue, we've already done part of the work, # because we can just enqueue the nested commands. # However, we also want to know _which nested command had which references_ # This means we have to collect all of the references as we go, # and output them in a different way if we're running recursively. # Got all that? # First, we need a tracking variable $CurrentCommand = '.' # for the current command. # Now the syntax trickery: We put the do loop inside of a lambda running in our scope (. {}). # This gives us all of our variables, but lets the results stream to the pipeline. # This is actually pretty important, since this way our tracking variable is accurate when we're outputting the results. # Now that we understand how it works, let's get to: #region Process the Queue of Script Blocks . { $alreadyChecked = [Collections.ArrayList]::new() do { $scriptBlock = $scriptBlockQueue.Dequeue() if ($alreadyChecked -contains $scriptBlock) { continue } $null= $alreadyChecked.Add($ScriptBlock) $foundRefs = $Scriptblock.Ast.FindAll({ param($ast) $ast -is [Management.Automation.Language.CommandAst] -or $ast -is [Management.Automation.Language.TypeConstraintAst] -or $ast -is [Management.Automation.Language.TypeExpressionAst] }, $true) $cmdRefs = [Collections.ArrayList]::new() $cmdStatements = [Collections.ArrayList]::new() $typeRefs = [Collections.ArrayList]::new() foreach ($ref in $foundRefs) { if ($ref -is [Management.Automation.Language.CommandAst]) { $null = $cmdStatements.Add($ref) if (-not $ref.CommandElements) { continue } $theCmd = $ref.CommandElements[0] if ($theCmd.Value) { if (-not $LookedUpCommands[$theCmd.Value]) { $LookedUpCommands[$thecmd.Value] = $ExecutionContext.InvokeCommand.GetCommand($theCmd.Value, 'Cmdlet, Function, Alias') } if ($cmdRefs -notcontains $LookedUpCommands[$theCmd.Value]) { $null = $cmdRefs.Add($LookedUpCommands[$thecmd.Value]) } } else { # referencing a lambda, leave it alone for now } } elseif ($ref.TypeName) { $refType = $ref.TypeName.Fullname -as [type] if ($typeRefs -notcontains $refType) { $null = $typeRefs.Add($refType) } } } [PSCustomObject][Ordered]@{ Commands = $cmdRefs.ToArray() Statements = $cmdStatements.ToArray() Types = $typeRefs.ToArray() } if ($Recurse) { $uniqueCmdRefs | & { process { if ($resolvedCmds.ContainsKey($_.Name)) { return } $nextScriptBlock = $_.ScriptBlock if (-not $nextScriptBlock -and $_.ResolvedCommand.ScriptBlock) { $nextScriptBlock = $_.ResolvedCommand.ScriptBlock } if ($nextScriptBlock) { $scriptBlockQueue.Enqueue($nextScriptBlock) $resolvedCmds[$_.Name] = $true } } } } } while ($ScriptBlockQueue.Count) } | #endregion Process the Queue of Script Blocks #region Handle Each Output & { begin { $refTable = @{} } process { if (-not $Recurse) { return $_ } } } #endregion Handle Each Output #endregion Get the Script References } end { $myParams = @{} + $PSBoundParameters if (-not $allFiles.Count) { return } $c, $t, $id = 0, $allFiles.Count, [Random]::new().Next() foreach ($file in $allFiles) { $c++ $resolvedFile= try { $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($file)} catch { $null } if (-not $resolvedFile) { continue } $resolvedFile = [IO.FileInfo]"$resolvedFile" if (-not $resolvedFile.Name) { continue } if (-not $resolvedFile.Length) { continue } if ('.ps1', '.psm1' -notcontains $resolvedFile.Extension) { continue } $p = $c * 100 / $t $text = [IO.File]::ReadAllText($resolvedFile.FullName) $scriptBlock= [ScriptBlock]::Create($text) Write-Progress "Getting References" " $($resolvedFile.Name) " -PercentComplete $p -Id $id if (-not $scriptBlock) { continue } Get-ScriptReference -ScriptBlock $scriptBlock | & { process { $_.psobject.properties.add([Management.Automation.PSNoteProperty]::new('FileName',$resolvedFile.Name)) $_.psobject.properties.add([Management.Automation.PSNoteProperty]::new('FilePath',$resolvedFile.Fullname)) $_.pstypenames.add('HelpOut.Script.Reference') $_ } } Write-Progress "Getting References" " " -Completed -Id $id } } } #.ExternalHelp HelpOut-Help.xml function Get-ScriptStory { [CmdletBinding(DefaultParameterSetName='ScriptBlock')] param( # A script block [Parameter(Mandatory=$true,Position=0,ValueFromPipeline=$true,ParameterSetName='ScriptBlock')] [ScriptBlock] $ScriptBlock, # A block of text [Parameter(Mandatory=$true,Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,ParameterSetName='ScriptText')] [Alias('ScriptContents', 'Definition')] [string] $Text, # The friendly names of code regions or begin,process, or end blocks. [Collections.IDictionary] $RegionName = @{ begin = "Before any input" process = "On Each Input" end = "After all input" }, [int] $HeadingSize = 3) process { function foo($x, $y) { # Documentation should be ignored } # First, we want to convert any text input to -ScriptBlock. if ($PSCmdlet.ParameterSetName -eq 'ScriptText') { $ScriptBlock = [ScriptBlock]::Create($Text) } # Next, we tokenize the script and force it into an array. $tokens = @([Management.Automation.PSParser]::Tokenize("$ScriptBlock", [ref]$null)) # We need to keep track of how many levels of regions we're in, so create a $RegionStack. $regionStack = [Collections.Stack]::new() # We'll also want to make a StringBuilder (because it will be faster). $sb= [text.stringbuilder]::new() # Last but not least, we'll want to keep track of a block depth, so initialize that to zero. $blockDepth = 0 #region Walk Thru Tokens for ($i =0; $i -lt $tokens.Length; $i++) { # As we pass GroupStarts and GroupEnds, nudge the block depth. if ($tokens[$i].Type -eq 'GroupStart') { $blockDepth++ } if ($tokens[$i].Type -eq 'GroupEnd') { $blockDepth-- } #region Handle natural regions # In addition to any regions specified in documentation, # we can treat the begin, process, and end blocks as effective regions. if ($tokens[$i].Type -eq 'keyword' -and 'begin', 'process', 'end' -contains $tokens[$i].content -and $blockDepth -le 1 ) { # When we encounter one of these regions, pop the region stack if ($regionStack.Count) { $null = $regionStack.Pop() } # and push the current region. $null =$regionStack.Push($tokens[$i].Content) # Generate the header, which consists of: $keywordHeader = # a newline, [Environment]::NewLine + # N Markdown headers, ('#' * ([Math]::Min(6, $regionStack.Count + $HeadingSize - 1))) + ' ' + # the friendly name for the region (or just it's content), $(if ($RegionName[$tokens[$i].Content]) { $RegionName[$tokens[$i].Content] } else { $tokens[$i].Content }) + # and another newline. [Environment]::NewLine # Then, append the header. $null = $sb.Append($keywordHeader) continue } #endregion Handle natural regions #region Skip Parameter Block # We don't want all of the documentation. # Specifically, we want to avoid any parameter documentation and nested functions. # To do this, we need to notice the param and function keyword when it shows up. if ($tokens[$i].Type -eq 'keyword' -and 'param', 'function' -contains $tokens[$i].Content) { # Once we've found it, we advance until we find the next GroupStart. $j = $i + 1 while ($tokens[$j].Type -ne 'GroupStart') { $j++ } $skipGroupCount = 1 if ($tokens[$j].Content -eq '(' -and # If the GroupStart was an open paranthesis $tokens[$i].Content -eq 'function'# and we're dealing with a nested function, ) { $skipGroupCount = 2 # we're going to need to this next bit twice. } foreach ($n in 1..$skipGroupCount) { # Look for the GroupStart. while ($tokens[$j].Type -ne 'GroupStart') { $j++ } # Then we set a variable to track depth $depth = 0 do { # and walk thru the tokens if ($tokens[$j].Type -eq 'GroupStart') { $depth++ } if ($tokens[$j].Type -eq 'GroupEnd') { $depth-- } $j++ } while ($depth -and $tokens[$j]) # until the depth is 0 again. } $i = $j # Finally we set the iterator to current position (thus skipping the param block). } #endregion Skip Parameter Block #region Check for Paragraph Breaks # Next we need to check for paragraph breaks. if ($i -ge 2 -and $tokens[$i].Type -eq 'Newline' -and # If the current token is a newline, $tokens[$i -1].Type -eq 'Newline') # and the token before that was also a newline, { # then it's probably a paragraph break if ($i -ge 3 -and $tokens[$i - 2].Type -eq 'GroupEnd') { # (Unless it followed a GroupEnd). continue } # When we encounter a paragraph break, output two newlines. $null = $sb.Append([Environment]::NewLine * 2) } #endregion Check for Paragraph Breaks #region Process Comments # At this point, we don't care about anything other than comments. # So if it's not a comment, continue past them. if ($tokens[$i].Type -ne 'Comment') { continue } $Comment = $tokens[$i].Content.Trim([Environment]::NewLine).Trim() if ($Comment.StartsWith('<')) { # If it's a block comment, # make sure it's not a special-purpose block comment (like inline help). $trimmedComment = $comment.Trim('<#').Trim([Environment]::NewLine).Trim() if ('?', '.', '{','-','|' -contains $trimmedComment[0]) { # If it was, continue # continue on. } # If it wasn't, trim the block comment and newlines. $Comment = $Comment.Trim().Trim("><#").Trim([Environment]::NewLine) } # We'll need to know if it's a region # so we'll use some fancy Regex to extract it's name # (and if it's an EndRegion or not). if ($Comment.Trim() -match '#(?<IsEnd>end){0,1}region(?<RegionName>.{1,})') { $thisRegionName = $Matches.RegionName.Trim() if ($Matches.IsEnd) { # If it was an EndRegion, pop it off of the Region Stack. $null = $regionStack.Pop() } else { # If it wasn't, push it onto the Region Stack. $null = $regionStack.Push($thisRegionName) # Then, output it's name a markdown header, # using the count of RegionStack to determine H1, H2, etc. $regionContent = [Environment]::NewLine + ('#' * ([Math]::Min(6, $regionStack.Count + $HeadingSize - 1))) + ' '+ $(if ($RegionName[$thisRegionName]) { $RegionName[$thisRegionName] } else { $Matches.RegionName.Trim() }) + [Environment]::NewLine $null = $sb.Append($regionContent) } # We still don't want the region name to become part of the story, # so continue to the next token. continue } # Whatever comment is left is new story content. $newStory = $Comment.TrimStart('#').Trim() # If there's any content already, if ($sb.Length) { # before we put it into the string, $null = if ($sb[-1] -eq '.') { # add a double space (after a period), $sb.Append(' ') } else { # or a single space. $sb.Append(' ') } } $shouldHaveNewline = $newStory.StartsWith('*') -or $newStory.StartsWith('-') -or ($lastStory -and ($lastStory.StartsWith('*') -or $lastStory.StartsWith('-'))) if ($shouldHaveNewline) { $null = $sb.Append([Environment]::NewLine) } # Finally, append the new story content. $null = $sb.Append($newStory) #endregion Process Comments } #endregion Walk Thru Tokens # After everything is done, output the content of the string builder. "$sb" } } #.ExternalHelp HelpOut-Help.xml function Install-MAML { [OutputType([Nullable], [IO.FileInfo])] param( # The name of one or more modules. [Parameter(Mandatory,Position=0,ParameterSetName='Module',ValueFromPipelineByPropertyName)] [string[]] $Module, # If set, will refresh the documentation for the module before generating the commands file. [Parameter(ValueFromPipelineByPropertyName)] [switch] $NoRefresh, # If set, will compact the generated MAML. This will be ignored if -Refresh is not passed, since no new MAML will be generated. [Parameter(ValueFromPipelineByPropertyName)] [switch] $Compact, # The name of the combined script. By default, allcommands.ps1. [Parameter(Position=1,ValueFromPipelineByPropertyName)] [string] $ScriptName = 'allcommands.ps1', # The root directories containing functions. If not provided, the function root will be the module root. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $FunctionRoot, # If set, the function roots will not be recursively searched. [Parameter(ValueFromPipelineByPropertyName)] [switch] $NoRecurse, # The encoding of the combined script. By default, UTF8. [Parameter(Position=2,ValueFromPipelineByPropertyName)] [ValidateNotNull()] [Text.Encoding] $Encoding = [Text.Encoding]::UTF8, # A list of wildcards to exclude. This list will always contain the ScriptName. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $Exclude, # If set, the generated MAML will not contain a version number. # This slightly reduces the size of the MAML file, and reduces the rate of changes in the MAML file. [Parameter(ValueFromPipelineByPropertyName)] [Alias('Unversioned')] [switch] $NoVersion, # If provided, will save the MAML to a different directory than the current UI culture. [Parameter(ValueFromPipelineByPropertyName)] [Globalization.CultureInfo] $Culture, # If set, will remove comments within functions when generating allcommands.ps1 [Parameter(ValueFromPipelineByPropertyName)] [Alias('NoBlockComments','NoBlockComment','NoComments')] [switch] $NoComment, # If set, will return the files that were generated. [Parameter(ValueFromPipelineByPropertyName)] [switch] $PassThru, # If set, will minify the code in allcommands.ps1. # By default, this will stream down the latest version of [PSMinifier](https://github.com/StartAutomating/PSMinifier). [Parameter(ValueFromPipelineByPropertyName)] [switch] $Minify, # If -MinifierSource is like `*Gallery*`, it will use the PowerShell Gallery to download. # If -MinifierSource is an alternate URL, it will download the contents of that URL (it must define a function named Compress-ScriptBlock). [Parameter(ValueFromPipelineByPropertyName)] [string] $MinifierSource ) process { if ($ScriptName -notlike '*.ps1') { # First, let's check that the scriptname is a .PS1. $ScriptName += '.ps1' # If it wasn't, add the extension. } $Exclude += $ScriptName # Then, add the script name to the list of exclusions. if (-not $Culture) { # If no culture was specified, $Culture = [Globalization.CultureInfo]::CurrentUICulture # use the current UI culture. } foreach ($m in $Module) { $theModule = Get-Module $m # Resolve the module. if (-not $theModule) { continue } # If we couldn't, continue to the next. $theModuleRoot = $theModule | Split-Path # Find the module's root. if ($PSBoundParameters.FunctionRoot) { # If we provided a function root parameter $functionRoot = foreach ($f in $FunctionRoot) { # then turn each root into an absolute path. if ([IO.File]::Exists($F)) { $f } else { Join-Path $theModuleRoot $f } } } else { $FunctionRoot = "$theModuleRoot" # otherwise, just use the module root. } $fileList = @(foreach ($f in $FunctionRoot) { # Walk thru each function root. Get-ChildItem -Path $f -Recurse:$(-not $Recurse) -Filter *.ps1 | # recursively find all .PS1s & { process { if ($_.Name -notlike '*-*' -or $_.Name -like '*.*.*') { return } foreach ($ex in $Exclude) { if ($_.Name -like $ex) { return } } return $_ } } }) #region Save the MAMLs if (-not $NoRefresh) { # If we're refreshing the MAML, $saveMamlCmd = # find the command Save-MAML if ($MyInvocation.MyCommand.ScriptBlock.Module) { $MyInvocation.MyCommand.ScriptBlock.Module.ExportedCommands['Save-MAML'] } else { $ExecutionContext.SessionState.InvokeCommand.GetCommand('Save-MAML', 'Function') } $saveMamlSplat = @{} + $PSBoundParameters # and pick out the parameters that this function and Save-MAML have in common. foreach ($k in @($saveMamlSplat.Keys)) { if (-not $saveMamlCmd.Parameters.ContainsKey($k)) { $saveMamlSplat.Remove($k) } } $saveMamlSplat.Module = $m # then, set the module Save-MAML @saveMamlSplat # and call Save-MAML } #endregion Save the MAMLs #region Generate the Combined Script # Prepare a regex to find function definitions. $regex = [Regex]::new(' (?<![-\s\#]{1,}) # not preceeded by a -, or whitespace, or a comment function # function keyword \s{1,1} # a single space or tab (?<Name>[^\-]{1,1}\S+) # any non-whitespace, starting with a non-dash \s{0,} # optional whitespace [\(\{] # opening parenthesis or brackets ', 'MultiLine,IgnoreCase,IgnorePatternWhitespace', '00:00:05') # If we want to minify $compressScriptBlockCmd = $null if ($Minify) { # check for the minifier $compressScriptBlockCmd = $($executionContext.SessionState.InvokeCommand.GetCommands("Compress-ScriptBlock*", "Function", $true)) if (-not $compressScriptBlockCmd) { if ($MinifierSource -eq 'Gallery') { $installedPSMinifier = Install-Module -Name PSMinifier -Scope CurrentUser -Force if ($?) { Import-Module PSMinifier -Global } } else { if ($MinifierSource -eq 'GitHub' -or -not $MinifierSource) { $MinifierSource = "https://raw.githubusercontent.com/StartAutomating/PSMinifier/master/Compress-ScriptBlock.min.ps1" } $downloadedMinifier = Invoke-RestMethod -Uri $MinifierSource . ([scriptblock]::Create($downloadedMinifier)) } $compressScriptBlockCmd = $($executionContext.SessionState.InvokeCommand.GetCommands("Compress-ScriptBlock*", "Function", $true)) } } $newFileContent = # We'll assign new file content by foreach ($f in $fileList) { # walking thru each file. $fCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand($f.FullName, 'ExternalScript') $fileContent = if ($Minify -and $compressScriptBlockCmd) { & $compressScriptBlockCmd $fCmd.ScriptBlock } else { "$($fCmd.ScriptBlock)" # and read it as a string. } $start = 0 do { $matched = $regex.Match($fileContent,$start) # See if we find a functon. if ($matched.Success) { # If we found one, $insert = ([Environment]::NewLine + "#.ExternalHelp $M-Help.xml" + [Environment]::NewLine) # insert a line for help. $fileContent = if ($matched.Index) { $fileContent.Insert($matched.Index - 1, $insert) } else { $insert + $fileContent } $start += $matched.Index + $matched.Length $start += $insert.Length # and update our starting position. } # Keep doing this until we've reached the end of the file or the end of the matches. } while ($start -le $filecontent.Length -and $matched.Success) # If -NoComment was passed if ($NoComment) { # Try replacing the block comments try { [Regex]::new('\<\#[\S\s]+?\#\>', 'Multiline,IgnoreCase,IgnorePatternWhitespace', [Timespan]'00:00:05').Replace($fileContent, '') } catch { # (if it fails, include the content normally) $fileContent } } else { # Then output the file content. $fileContent } } # Last but not least, we $combinedCommandsPath = Join-Path $theModuleRoot $ScriptName # determine the path for our combined commands file. "### DO NOT EDIT THIS FILE DIRECTLY ###" | Set-Content -Path $combinedCommandsPath -Encoding $Encoding.HeaderName.Replace('-','') # add a header [IO.File]::AppendAllText($combinedCommandsPath, $newFileContent, $Encoding) # and add our content. #endregion Generate the Combined Script if ($PassThru) { Get-Item -Path $combinedCommandsPath } } } } #.ExternalHelp HelpOut-Help.xml function Measure-Help { [CmdletBinding(DefaultParameterSetName='FilePath')] param( # The path to the file [Parameter(Mandatory,ValueFromPipelineByPropertyName,Position=0,ParameterSetName='FilePath')] [Alias('Fullname')] [string] $FilePath, # A PowerShell script block [Parameter(Mandatory,ParameterSetName='ScriptBlock',ValueFromPipelineByPropertyName)] [ScriptBlock] $ScriptBlock, # The name of the script being measured. [Parameter(ParameterSetName='ScriptBlock',ValueFromPipelineByPropertyName)] [string] $Name ) begin { $fileList = New-Object Collections.ArrayList $ScriptBlockList = New-Object Collections.ArrayList $NameList = New-Object Collections.ArrayList filter OutputDocRatio { $scriptText = $_ $scriptToken = [Management.Automation.PSParser]::Tokenize($scriptText, [ref]$null) # A quick tight little loop to sum # the lengths of different types in one # pass (Group-Object would work, but would be slower) $commentLength= 0 $otherLength = 0 $blockCommentLength = 0 $inlineCommentLength = 0 $blockComments = @() $inlineComments = @() $totalLength = 0 foreach ($token in $ScriptToken) { $totalLength+=$token.Length if ($token.Type -eq 'Comment') { if ($token.Content.StartsWith('<#')) { $blockComments+=$token $blockCommentLength += $token.Length } else { $inlineComments+=$token $inlineCommentLength += $token.Length } $commentLength+=$token.Length } else { $otherLength+=$token.Length } } # The percent is easy to calculate $percent =$commentLength * 100 / $totalLength @{ CommentLength = $commentLength TokenLength = $otherLength CommentPercent = $percent BlockComments = $blockComments BlockCommentLength = $blockCommentLength InlineComments = $inlineComments InlineCommentLength = $inlineCommentLength } } } process { if ($PSCmdlet.ParameterSetName -eq 'FilePath') { $fileList.AddRange(@($FilePath)) } elseif ($PSCmdlet.ParameterSetName -eq 'ScriptBlock') { $null = $ScriptBlockList.Add($ScriptBlock) $null = $NameList.Add($Name) } } end { if ($ScriptBlockList.Count) { $scriptBlockIndex =0 foreach ($sb in $ScriptBlockList) { [PSCustomObject]([Ordered]@{ PSTypeName = "Documentation.Percentage" Name = $NameList[$scriptBlockIndex] ScriptBlock = $sb } + ($sb | OutputDocRatio)) $scriptBlockIndex++ } } if ($fileList.Count) { foreach ($f in $fileList) { $RF = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($F) if (-not $rf) { continue } $fileItem = Get-Item -LiteralPath $RF $sb = $null $sb = try { [ScriptBlock]::Create([IO.File]::ReadAllText($RF)) } catch { $null } if ($sb) { [PSCustomObject]([Ordered]@{ PSTypeName = "File.Documentation.Percentage" Name = $fileItem.Name FilePath = "$rf" ScriptBlock = $sb } + ($sb | OutputDocRatio)) } } } } } #.ExternalHelp HelpOut-Help.xml function Save-MAML { [CmdletBinding(DefaultParameterSetName='CommandInfo',SupportsShouldProcess=$true)] [OutputType([Nullable], [IO.FileInfo])] param( # The name of one or more modules. [Parameter(ParameterSetName='ByModule',ValueFromPipelineByPropertyName)] [string[]] $Module, # If set, the generated MAML will be compact (no extra whitespace or indentation). If not set, the MAML will be indented. [Parameter(ValueFromPipelineByPropertyName)] [switch] $Compact, # If provided, will save the MAML to a different directory than the current UI culture. [Parameter(ValueFromPipelineByPropertyName)] [Globalization.CultureInfo] $Culture, # If set, the generated MAML will not contain a version number. # This slightly reduces the size of the MAML file, and reduces the rate of changes in the MAML file. [Alias('Unversioned')] [switch] $NoVersion, # If set, will return the files that were generated. [switch] $PassThru) begin { # First, let's cache a reference to Get-MAML $getMAML = if ($MyInvocation.MyCommand.ScriptBlock.Module) { $MyInvocation.MyCommand.ScriptBlock.Module.ExportedCommands['Get-MAML'] } else { $ExecutionContext.SessionState.InvokeCommand.GetCommand('Get-MAML', 'Function') } } process { if (-not $getMAML) { # If for whatever reason we don't have Get-MAML Write-Error "Could not Find Get-MAML" -Category ObjectNotFound -ErrorId Get-MAML.NotFound # error out. return } $c, $t, $id = 0, $Module.Length, [Random]::new().Next() $splat = @{} + $PSBoundParameters # Copy our parameters foreach ($k in @($splat.Keys)) { # then strip out any parameter if (-not $getMAML.Parameters.ContainsKey($k)) { # that wasn't in Get-MAML. $splat.Remove($k) } } if (-not $Culture) { # If -Culture wasn't provided, use the current culture $Culture = [Globalization.CultureInfo]::CurrentCulture } #region Save the MAMLs foreach ($m in $Module) { # Walk thru the list of module names. $splat.Module = $m if ($t -gt 1) { $c++ Write-Progress 'Saving MAML' $m -PercentComplete $p -Id $id } $theModule = Get-Module $m # Find the module if (-not $theModule) { continue } # (continue if we couldn't). $theModuleRoot = $theModule | Split-Path # Find the module's root, $theModuleCultureDir = Join-Path $theModuleRoot $Culture.Name # then find the culture folder. if (-not (Test-Path $theModuleCultureDir)) { # If that folder didn't exist, $null = New-Item -ItemType Directory -Path $theModuleCultureDir # create it. } $theModuleHelpFile = Join-Path $theModuleCultureDir "$m-Help.xml" # Construct the path to the module help file (e.g. en-us\Module-Help.xml) & $getMAML @splat | # Convert the module help to MAML, Set-Content -Encoding UTF8 -Path $theModuleHelpFile # and write the file. if ($Passthru) { Get-Item -Path $theModuleHelpFile } } if ($t -gt 1) { Write-Progress 'Saving MAML' 'Complete' -Completed -Id $id } #endregion Save the MAMLs } } #.ExternalHelp HelpOut-Help.xml function Save-MarkdownHelp { param( # The name of one or more modules. [Parameter(ParameterSetName='ByModule',ValueFromPipelineByPropertyName)] [Alias('Name')] [string[]] $Module, # The output path. # If not provided, will be assumed to be the "docs" folder of a given module (unless -Wiki is specified) [Parameter(ValueFromPipelineByPropertyName)] [string] $OutputPath, # If set, will interlink documentation as if it were a wiki. Implied when -OutputPath contains 'wiki'. # If provided without -OutputPath, will assume that a wiki resides in a sibling directory of the module. [Parameter(ValueFromPipelineByPropertyName)] [switch] $Wiki, # If provided, will generate documentation for additional commands. [Parameter(ValueFromPipelineByPropertyName)] [Management.Automation.CommandInfo[]] $Command, # Replaces parts of the names of the commands provided in the -Command parameter. # -ReplaceScriptName is treated as a regular expression. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $ReplaceCommandName, # If provided, will replace parts of the names of the scripts discovered in a -Command parameter with a given Regex replacement. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $ReplaceCommandNameWith = @(), # If provided, will generate documentation for any scripts found within these paths. # -ScriptPath can be either a file name or a full path. # If an exact match is not found -ScriptPath will also check to see if there is a wildcard match. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $ScriptPath, # If provided, will replace parts of the names of the scripts discovered in a -ScriptDirectory beneath a module. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $ReplaceScriptName, # If provided, will replace parts of the names of the scripts discovered in a -ScriptDirectory beneath a module with a given Regex replacement. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $ReplaceScriptNameWith = @(), # If provided, will replace links discovered in markdown content. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $ReplaceLink, # If provided, will replace links discovered in markdown content with a given Regex replacement. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $ReplaceLinkWith = @(), # If set, will output changed or created files. [switch] $PassThru, # The order of the sections. If not provided, this will be the order they are defined in the formatter. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $SectionOrder, # One or more topic files to include. # Topic files will be treated as markdown and directly copied inline. # By default ```\.help\.txt$``` and ```\.md$``` [Parameter(ValueFromPipelineByPropertyName)] [string[]] $IncludeTopic = @('\.help\.txt$', '\.md$'), # One or more topic file patterns to exclude. # Topic files that match this pattern will not be included. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $ExcludeTopic = @('\.ps1{0,1}\.md$'), # One or more files to exclude. # By default, this is treated as a wildcard. # If the file name starts and ends with slashes, it will be treated as a Regular Expression. [Parameter(ValueFromPipelineByPropertyName)] [Alias('ExcludePath','ExcludeDirectory','ExcludeFolder')] [string[]] $ExcludeFile, # A whitelist of files or directories to include. # If this is provided, only files that match these criteria will be included. [Parameter(ValueFromPipelineByPropertyName)] [Alias('IncludePath','IncludeDirectory','IncludeFolder')] [string[]] $IncludeFile, # One or more extensions to include. # By default, .css, .gif, .htm, .html, .js, .jpg, .jpeg, .mp4, .png, .svg [Parameter(ValueFromPipelineByPropertyName)] [string[]] $IncludeExtension = @('.css','.gif', '.htm', '.html','.js', '.jpg', '.jpeg', '.mp4', '.png', '.svg'), # One or more extensions to exclude. # By default, not extensions are specifically excluded. [string[]] $ExcludeExtension, # If set, will explicitly include submodule directories. [switch] $IncludeSubmodule, # If set, will explicitly exclude submodule directories. # This is the default. [switch] $ExcludeSubModule, # If set, will not enumerate valid values and enums of parameters. [Parameter(ValueFromPipelineByPropertyName)] [switch] $NoValidValueEnumeration, # If set, will not attach a YAML header to the generated help. [Parameter(ValueFromPipelineByPropertyName)] [Alias('IncludeFrontMatter', 'IncludeHeader')] [switch] $IncludeYamlHeader, # The type of information to include in the YAML Header [ValidateSet('Command','Help','Metadata')] [Alias('YamlHeaderInfoType')] [string[]] $YamlHeaderInformationType, # A list of command types to skip. # If not provided, all types of commands from the module will be saved as a markdown document. [Parameter(ValueFromPipelineByPropertyName)] [Alias('SkipCommandTypes','ExcludeCommandType','ExcludeCommandTypes')] [Management.Automation.CommandTypes[]] $SkipCommandType, # The formatting used for unknown attributes. # Any key or property in this object will be treated as a potential typename # Any value will be the desired formatting. # If the value is a [ScriptBlock], the [ScriptBlock] will be run. # If the value is a [string], it will be expanded # In either context, `$_` will be the current attribute. [PSObject] $FormatAttribute ) begin { # First, let's cache a reference to Get-MarkdownHelp $GetMarkdownHelp = if ($MyInvocation.MyCommand.ScriptBlock.Module) { $MyInvocation.MyCommand.ScriptBlock.Module.ExportedCommands['Get-MarkdownHelp'] } else { $ExecutionContext.SessionState.InvokeCommand.GetCommand('Get-MarkdownHelp', 'Function') } $NotExcluded = { if ($IncludeFile) { $shouldIncludePath = foreach ($include in $IncludeFile) { if ($include -match '^/' -and $include -match '/$') { if ([Regex]::New( $include -replace '^/' -replace '/$', 'IgnoreCase,IgnorePatternWhitespace' ).Match($_.FullName)) { $true;break } } else { if ($_.FullName -like $include -or $_.Name -like $include) { $true;break } } } if (-not $shouldIncludePath) { return $false } } if (-not $ExcludeFile) { return $true } foreach ($ex in $ExcludeFile) { if ($ex -match '^/' -and $ex -match '/$') { if ([Regex]::New( $ex -replace '^/' -replace '/$', 'IgnoreCase,IgnorePatternWhitespace' ).Match($_.FullName)) { return $false } } else { if ($_.FullName -like $ex -or $_.Name -like $ex) { return $false } } } return $true } $filesChanged = @() } process { $getMarkdownHelpSplatBase = @{} foreach ($param in $psBoundParameters.Keys) { if ($GetMarkdownHelp.Parameters[$param]) { $getMarkdownHelpSplatBase[$param] = $psBoundParameters[$param] } } $c = 0 $t = $Module.Count #region Save the Markdowns foreach ($m in $Module) { # Walk thru the list of module names. if ($t -gt 1) { $c++ $p = $c * 100 / $t Write-Progress 'Saving Markdown' $m -PercentComplete $p -Id $id } $theModule = Get-Module $module # Find the module if (-not $theModule) { continue } # (continue if we couldn't). $theModuleRoot = $theModule | Split-Path # Find the module's root. if (-not $psBoundParameters.OutputPath) { # If no -OutputPath was provided $OutputPath = if ($Wiki) { # set the default. If it's a wiki, it's a sibling directory Split-Path $theModuleRoot | Join-Path -ChildPath "$($theModule.Name).wiki" } else { Join-Path $theModuleRoot "docs" # Otherwise, it's the docs subdirectory. } } else { # If -OutputPath was provided, we want to make sure it becomes an absolute path $OutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) } # If the -OutputPath does not exist if (-not (Test-Path $OutputPath)) { $null = New-Item -ItemType Directory -Path $OutputPath # create it. } if ((-not $ExcludeSubModule) -and (-not $IncludeSubmodule)) { Push-Location $theModuleRoot $gitCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand('git','Alias,Application') $submoduleRoots = if ($gitCmd -is [Management.Automation.AliasInfo]) { git submodule | Select-Object -ExpandProperty Submodule } else { git submodule | & { process {@($_ -split '\s')[1]} } } if ($submoduleRoots) { $ExcludeFile += "$($pwd)$([io.path]::DirectorySeparatorChar)$submoduleRoot$([io.path]::DirectorySeparatorChar)*" } Pop-Location } $outputPathName = $OutputPath | Split-Path -Leaf $ReplaceLink += "^$outputPathName[\\/]" # Double-check that the output path $outputPathItem = Get-Item $OutputPath if ($outputPathItem -isnot [IO.DirectoryInfo]) { # is not a directory # (if it is, error out). Write-Error "-OutputPath '$outputPath' must point to a directory" return } # Next we're going to call Get-MarkdownHelp on each exported command. foreach ($cmd in $theModule.ExportedCommands.Values) { # If we specified command types to skip, skip them now. if ($SkipCommandType -and $SkipCommandType -contains $cmd.CommandType) { continue } # Determine the output path for each item. $docOutputPath = Join-Path $outputPath ($cmd.Name + '.md') # Prepare a splat for this command by copying out base splat. $getMarkdownHelpSplat = @{Name="$cmd"} + $getMarkdownHelpSplatBase # If -Wiki was passed, call Get-MarkDownHelp with -Wiki (this impacts link format) if ($Wiki) { $getMarkdownHelpSplat.Wiki = $Wiki } # otherwise, pass down the parent of $OutputPath. else { $getMarkdownHelpSplat.GitHubDocRoot = "$($outputPath|Split-Path -Leaf)"} $markdownTopic = Get-MarkdownHelp @getMarkdownHelpSplat $markdownFile = if ($markdownTopic.Save) { $markdownTopic.Save($docOutputPath) } else { $null } if ($markdownFile) { $filesChanged += $markdownFile if ($PassThru) { # If -PassThru was provided, get the path. $markdownFile } } } if ($Command) { foreach ($cmd in $Command) { # For each script that we find, prepare to call Get-MarkdownHelp $getMarkdownHelpSplat = @{ Name= if ($cmd.Source) { "$($cmd.Source)" } else { "$cmd" } } + $getMarkdownHelpSplatBase $replacedCmdName = if ($cmd.DisplayName) { $cmd.DisplayName } elseif ($cmd.Name -and $cmd.Name.Contains([IO.Path]::DirectorySeparatorChar)) { $cmd.Name } @(for ($ri = 0; $ri -lt $ReplaceCommandName.Length; $ri++) { # Walk over any -ReplaceScriptName(s) provided. # Replace it with the -ReplaceScriptNameWith parameter (if present). if ($ReplaceCommandNameWith -and $ReplaceCommandNameWith[$ri]) { $replacedCmdName = $replacedCmdName -replace $ReplaceCommandName[$ri], $ReplaceCommandNameWith[$ri] } else { # Otherwise, just remove the replacement. $replacedCmdName = $replacedCmdName -replace $ReplaceCommandName[$ri] } }) # Determine the output path for each item. $docOutputPath = Join-Path $outputPath ($replacedCmdName + '.md') $getMarkdownHelpSplat.Rename = $replacedCmdName if ($Wiki) { $getMarkdownHelpSplat.Wiki = $Wiki} else { $getMarkdownHelpSplat.GitHubDocRoot = "$($outputPath|Split-Path -Leaf)"} $markdownFile = $null try { $markdownTopic = Get-MarkdownHelp @getMarkdownHelpSplat $markdownFile = if ($markdownTopic.Save) { $markdownTopic.Save($docOutputPath) } else { $null } } catch { $ex = $_ Write-Error -Exception $ex.Exception -Message "Could not Get Help for $($cmd.Name): $($ex.Exception.Message)" -TargetObject $getMarkdownHelpSplat } if ($markdownFile) { $filesChanged += # add the file to the changed list. $markdownFile # If -PassThru was provided (and we're not going to change anything) if ($PassThru -and -not $ReplaceLink) { $filesChanged[-1] # output the file changed now. } } } } # If a -ScriptPath was provided if ($ScriptPath) { # get the child items beneath the module root. Get-ChildItem -Path $theModuleRoot -Recurse | Where-Object { # Any Script Path whose Name or FullName is foreach ($sp in $ScriptPath) { $_.Name -eq $sp -or # an exact match, $_.FullName -eq $sp -or $_.Name -like $sp -or # a wildcard match, $_.FullName -like $sp -or $( $spRegex = $sp -as [regex] $spRegex -and ( # or a regex match $_.Name -match $spRegex -or $_.FullName -match $spRegex ) ) } # will be included. } | # Any child items of that path will also be included Get-ChildItem -Recurse | Where-Object Extension -eq '.ps1' | # (as long as they're PowerShell Scripts). Where-Object $NotExcluded | # (and as long as they're not excluded) ForEach-Object { $ps1File = $_ # For each script that we find, prepare to call Get-MarkdownHelp $getMarkdownHelpSplat = @{Name="$($ps1File.FullName)"} + $getMarkdownHelpSplatBase # because not all file names will be valid (or good) topic names $replacedFileName = $ps1File.Name # prepare to replace the file. @(for ($ri = 0; $ri -lt $ReplaceScriptName.Length; $ri++) { # Walk over any -ReplaceScriptName(s) provided. if ($ReplaceScriptNameWith -and $ReplaceScriptNameWith[$ri]) { # Replace it with the -ReplaceScriptNameWith parameter (if present). $replacedFileName = $replacedFileName -replace $ReplaceScriptName[$ri], $ReplaceScriptNameWith[$ri] } else { # Otherwise, just remove the replacement. $replacedFileName = $replacedFileName -replace $ReplaceScriptName[$ri] } }) # Determine the output path $docOutputPath = Join-Path $outputPath ($replacedFileName + '.md') # and the relative path of this .ps1 to the module root. $relativePath = $ps1File.FullName.Substring("$theModuleRoot".Length).TrimStart('/\').Replace('\','/') # Then, rename the potential topic with it's relative path. $getMarkdownHelpSplat.Rename = $relativePath if ($Wiki) { $getMarkdownHelpSplat.Wiki = $Wiki} else { $getMarkdownHelpSplat.GitHubDocRoot = "$($outputPath|Split-Path -Leaf)"} # Call Get-MarkdownHelp, .Save it, and $markdownTopic = Get-MarkdownHelp @getMarkdownHelpSplat $markdownFile = if ($markdownTopic.Save) { $markdownTopic.Save($docOutputPath) } else { $null } if ($markdownFile) { $filesChanged += $markdownFile # add the file to the changed list. # If -PassThru was provided (and we're not going to change anything) if ($PassThru -and -not $ReplaceLink) { $markdownFile # output the file changed now. } } } } # If -IncludeTopic was provided if ($IncludeTopic) { # get all of the children beneath the module root $filesArray = @(Get-ChildItem -Path $theModuleRoot -Recurse -File) # then reverse that list, so that the most shallow items come last. [array]::reverse($filesArray) $filesArray | Where-Object $NotExcluded | # (and as long as they're not excluded) ForEach-Object { $fileInfo = $_ # Determine the relative path of the file. $relativePath = $fileInfo.FullName.Substring("$theModuleRoot".Length) -replace '^[\\/]' # If it is more than one layer deep, ignore it. if ([Regex]::Matches($relativePath, "[\\/]").Count -gt 1) { return } :NextTopicFile foreach ($inc in $IncludeTopic) { # find any files that should be included $matches = $null if ($fileInfo.Name -eq $inc -or $fileInfo.Name -like $inc -or $( $incRegex = $inc -as [regex] $incRegex -and $fileInfo.Name -match $incRegex ) ) { # Double-check that the file should not excluded. foreach ($exclude in $ExcludeTopic) { if ( $fileInfo.Name -eq $exclude -or $fileInfo.Name -like $exclude -or $( $exclude -as [regex] -and $fileInfo.Name -match $exclude ) ) { continue NextTopicFile } } $replacedName = if ($matches) { # If $inc was a regex $fileInfo.Name -replace $inc # just replace it } else { # Otherwise, strip the file of it's extension $fileInfo.Name.Substring(0, $fileInfo.name.Length - $fileInfo.Extension.Length) -replace '\.help$' # (and .help). } if ($replacedName -eq "about_$module") { # If the replaced named was "about_$Module" $replacedName = 'README' # treat it as the README } # Determine the output path $dest = Join-Path $OutputPath ($replacedName + '.md') # and make sure we're not overwriting ourselves if ($fileInfo.FullName -ne "$dest") { $filesChanged += # copy the file and add it to the change list. $fileInfo | Copy-Item -Destination $dest -PassThru } # If -PassThru was passed and we're not changing anything. if ($PassThru -and -not $ReplaceLink) { $filesChanged[-1] # output the file now. } } } } } # If -IncludeExtension was provided if ($IncludeExtension) { # get all files beneath the root Get-ChildItem -Path $theModuleRoot -Recurse -File | Where-Object $NotExcluded | # (and as long as they're not excluded) ForEach-Object { $fileInfo = $_ if ($ExcludeExtension) { foreach ($ext in $ExcludeExtension) { if ($fileInfo.Extension -eq $ext -or $fileInfo.Extension -eq ".$ext") { return } } } foreach ($ext in $IncludeExtension) { # and see if they are the right extension if ($fileInfo.Extension -eq $ext -or $fileInfo.Extension -eq ".$ext") { # Determine the relative path $relativePath = $fileInfo.FullName.Substring("$theModuleRoot".Length) -replace '^[\\/]' $outputPathLeaf = $outputPath | Split-Path -Leaf # and use that to determine the destination of this file. $dest = Join-Path $OutputPath $relativePath if ($fileInfo.FullName -ne "$dest" -and $relativePath -notlike "$outputPathLeaf$([IO.Path]::DirectorySeparatorChar)*") { # Create the file (so it creates the folder structure). $createdFile = New-Item -ItemType File -Path $dest -Force if (-not $createdFile) { # If we could not, write and error and stop trying for this file. Write-Error "Unable to initialize file: '$dest'" break } # Copy the file to the destination. if ($fileInfo.FullName -ne "$dest") { $filesChanged += # and add it to the change list. $fileInfo | Copy-Item -Destination $dest -PassThru:$PassThru } # If -PassThru was passed and we're not changing anything. if ($PassThru -and -not $ReplaceLink) { $filesChanged[-1] # output the file now. } } break } } } } #region Run Extensions to this Command $MyModule = $MyInvocation.MyCommand.ScriptBlock.Module $ScriptPattern = "$($MyModule.Name)\.$($MyInvocation.MyCommand.Name -replace '\p{P}')\.(?<Name>(?:.|\s){0,}?(?=\z|\.ps1))\.ps1" $commandWildcard = "$($MyModule.Name).$($MyInvocation.MyCommand.Name)*" $commandPattern = "$($myModule.Name)\.$($MyInvocation.MyCommand.Name -replace '\p{P}')\.(?<Name>(?:.|\s){0,}?(?=\z|\.ps1))" $myExtensions = @( foreach ($loadedModule in Get-Module){ if ($loadedModule -eq $MyModule -or $loadedModule -eq $m -or $loadedModule.Tags -contains $MyModule.Name) { foreach ($file in Get-ChildItem -Recurse -Filter *.ps1 ( Split-Path $loadedModule.Path )) { if ($file.Name -notmatch $ScriptPattern) { continue } $scriptCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand($file.FullName, 'ExternalScript') $scriptCmd.psobject.Members.Add([psnoteproperty]::new("ExtensionName", $Matches.Name), $true) if ($scriptCmd.pstypenames -notcontains "$($myModule.Name).Extension") { $scriptCmd.pstypenames.insert(0, "$($myModule.Name).Extension") } $scriptCmd } } } foreach ($cmdFound in $ExecutionContext.SessionState.InvokeCommand.GetCommands( $commandWildcard, 'Function,Alias,Cmdlet', $true ) -match $commandPattern) { $cmdFound.psobject.Members.Add([psnoteproperty]::new("ExtensionName", $Matches.Name), $true) if ($cmdFound.pstypenames -notcontains "$($myModule.Name).Extension") { $cmdFound.pstypenames.insert(0, "$($myModule.Name).Extension") } $cmdFound } ) $extensionOutputs = @(foreach ($extension in $myExtensions) { $extensionSplat = [Ordered]@{} if ($extension.Parameters.Module) { $extensionSplat.Module = $theModule } & $extension @extensionSplat }) if ($extensionOutputs) { foreach ($extensionOutput in $extensionOutputs) { if ($extensionOutput -is [IO.FileInfo]) { if ($extensionOutput.Extension -in '.md', '.markdown' -and $ReplaceLink) { $filesChanged += $extensionOutput } elseif ($PassThru) { $extensionOutput } } } } #endregion Run Extensions to this Command } if ($t -gt 1) { Write-Progress 'Saving Markdown' 'Complete' -Completed -Id $id } #endregion Save the Markdowns } end { if ($PassThru -and $ReplaceLink) { $linkFinder = [Regex]::new(" (?<IsImage>\!)? # If there is an exclamation point, then it is an image link \[ # Markdown links start with a bracket (?<Text>[^\]\r\n]+) \] # anything until the end bracket is the link text. \( # The link uri is within parenthesis (?<Uri>[^\)\r\n]+) \) ", 'IgnoreCase,IgnorePatternWhitespace') foreach ($file in $filesChanged) { if ($file.Extension -notin '.md', '.markdown') { $file continue } $fileContent = Get-Content $file.FullName -Raw $fileContent = $linkFinder.Replace($fileContent, { param($LinkMatch) $linkReplacementNumber = 0 $linkUri = $LinkMatch.Groups["Uri"].ToString() $linkText = $linkMatch.Groups["Text"].ToString() foreach ($linkToReplace in $ReplaceLink) { $replacement = "$($ReplaceLinkWith[$linkReplacementNumber])" $linkUri = $linkUri -replace $linkToReplace, $replacement $linkText = $linkText -replace $linkToReplace, $replacement } if ($linkUri -match '\#.+$') { $lowerCaseAnchor = ($matches.0).ToLower() $linkUri = $linkUri -replace '\#.+$', $lowerCaseAnchor } if ($LinkMatch.Groups["IsImage"].Length) { "![$linkText]($linkUri)" } else { "[$linkText]($linkUri)" } }) Set-Content $file.FullName -Encoding UTF8 -Value $fileContent.Trim() if ($PassThru) { Get-Item -LiteralPath $file.FullName } } } } } |