build/Build-DFReferenceDocs.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Generates docs/reference.md from the comment-based help of DotForge's exported functions and of the global functions its Tools/*.ps1 companions define. .DESCRIPTION The reference is generated, never hand-edited: change a function's help, then rerun this script. tests/Docs.Reference.Tests.ps1 fails when the committed file is out of date. Everything is read from source with the PowerShell parser: help text, parameter types, defaults and attributes, and Set-Alias calls. The module is imported only to list exported functions and to render their syntax. Sidecars are never run, so none of the tools need to be installed. Output is deterministic (sorted, LF line endings, no timestamps). .PARAMETER OutputPath Where to write the Markdown. Default: docs/reference.md in the repository. .EXAMPLE ./build/Build-DFReferenceDocs.ps1 Regenerates docs/reference.md. .OUTPUTS None. Writes the file and prints its path. #> [CmdletBinding()] param( [string]$OutputPath = (Join-Path $PSScriptRoot '..' 'docs' 'reference.md') ) $repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Ast = [System.Management.Automation.Language.Ast] # Public/*.ps1 basename -> reference section. $sectionByFile = [ordered]@{ 'Add-DFToPath' = 'Core' 'Complete-DFToolSetup' = 'Core' 'Find-DFTool' = 'Core' 'Get-DFRole' = 'Core' 'Get-DFTool' = 'Core' 'Get-DFToolGroup' = 'Core' 'Start-DFSession' = 'Core' 'Get-DFToolStatus' = 'Core' 'Install-DFTool' = 'Core' 'Invoke-DFToolSetup' = 'Core' 'Get-DFConfig' = 'Core' 'Invoke-DFPicker' = 'Core' 'New-DFDirectory' = 'Core' 'New-DFShim' = 'Core' 'Register-DFTool' = 'Core' 'DFHelpers.Help' = 'Help and discovery' 'DFHelpers.Navigation' = 'Navigation' 'DFHelpers.FileSystem' = 'Files' 'DFHelpers.Process' = 'Processes' 'DFHelpers.Environment' = 'Environment and profile' 'DFHelpers.Clipboard' = 'Clipboard, pager and utilities' 'DFHelpers.Pager' = 'Core' 'DFHelpers.Utility' = 'Clipboard, pager and utilities' 'Find-DFPackage' = 'Package catalog' 'Get-DFCategoryList' = 'Package catalog' 'Get-DFCommandConflict' = 'Core' 'Select-DFPackage' = 'Package catalog' 'Update-DFCategoryDb' = 'Package catalog' 'Update-DFPackageCache' = 'Package catalog' 'Update-DFToolIdentityGuide' = 'Package catalog' } $sectionOrder = @('Core', 'Help and discovery', 'Navigation', 'Files', 'Processes', 'Environment and profile', 'Clipboard, pager and utilities', 'Package catalog') function Get-Slug([string]$Heading) { # GitHub's heading anchors: lowercase, drop punctuation except '-' and ' ', spaces to '-'. ($Heading.ToLowerInvariant() -replace '[^\p{L}\p{Nd} _-]', '') -replace ' ', '-' } function ConvertTo-MdInline([string]$Text) { # Escape Markdown outside `code spans`, which are kept as written. $parts = $Text -split '(`[^`]*`)' ($parts | ForEach-Object { if ($_ -like '`*`') { $_ } else { $_ -replace '\\', '\\' -replace '\*', '\*' -replace '<', '<' -replace '>', '>' -replace '\|', '\|' } }) -join '' } function ConvertTo-MdBlock([string]$Text) { # Paragraphs with indented continuation lines (key tables, aligned lists) stay # preformatted; everything else is reflowed into one Markdown paragraph. $paras = ($Text.Trim() -replace '\r', '') -split '\n\s*\n' $out = foreach ($p in $paras) { $lines = $p -split '\n' if (@($lines | Select-Object -Skip 1 | Where-Object { $_ -match '^\s' }).Count -gt 0 -or $lines[0] -match '^\s') { "``````text`n$p`n``````" } else { ConvertTo-MdInline (($lines | ForEach-Object Trim) -join ' ') } } $out -join "`n`n" } function Get-TypeName($ParamAst) { $tc = $ParamAst.Attributes | Where-Object { $_ -is [System.Management.Automation.Language.TypeConstraintAst] } | Select-Object -First 1 if (-not $tc) { return 'Object' } $name = $tc.TypeName.Name switch -Regex ($name) { '^switch$|SwitchParameter' { return 'switch' } default { return ($name -replace '^System\.', '') } } } function Get-ParamRow($ParamAst, $Help) { $name = $ParamAst.Name.VariablePath.UserPath $attrs = @($ParamAst.Attributes | Where-Object { $_ -is [System.Management.Automation.Language.AttributeAst] }) $paramAttr = $attrs | Where-Object { $_.TypeName.Name -eq 'Parameter' } $named = @($paramAttr.NamedArguments) $isTrue = { param($arg) $arg -and ($arg.ExpressionOmitted -or $arg.Argument.Extent.Text -eq '$true') } $required = [bool](& $isTrue ($named | Where-Object ArgumentName -EQ 'Mandatory' | Select-Object -First 1)) $pipeline = @( if (& $isTrue ($named | Where-Object ArgumentName -EQ 'ValueFromPipeline' | Select-Object -First 1)) { 'value' } if (& $isTrue ($named | Where-Object ArgumentName -EQ 'ValueFromPipelineByPropertyName' | Select-Object -First 1)) { 'by name' } if (& $isTrue ($named | Where-Object ArgumentName -EQ 'ValueFromRemainingArguments' | Select-Object -First 1)) { 'remaining args' } ) -join ', ' $validateSet = $attrs | Where-Object { $_.TypeName.Name -eq 'ValidateSet' } | Select-Object -First 1 $desc = if ($Help -and $Help.Parameters[$name.ToUpperInvariant()]) { (($Help.Parameters[$name.ToUpperInvariant()].Trim() -replace '\r', '') -split '\s*\n\s*') -join ' ' } else { '' } if ($validateSet) { $values = $validateSet.PositionalArguments | ForEach-Object { $_.Value } $desc += " Allowed: $($values -join ', ')." } $default = if ($ParamAst.DefaultValue) { '`' + $ParamAst.DefaultValue.Extent.Text + '`' } else { '' } '| `-{0}` | {1} | {2} | {3} | {4} | {5} |' -f $name, (Get-TypeName $ParamAst), $default, ($required ? 'yes' : ''), $pipeline, (ConvertTo-MdInline $desc.Trim()) } function Get-FunctionMarkdown { param( [string]$Name, [System.Management.Automation.Language.ScriptBlockAst]$Body, [string[]]$Aliases, [string]$Syntax, [string]$HeadingLevel ) $help = $Body.GetHelpContent() $sb = [System.Text.StringBuilder]::new() $null = $sb.AppendLine("$HeadingLevel $Name").AppendLine() if ($Aliases) { $null = $sb.AppendLine("Alias: " + (($Aliases | ForEach-Object { "``$_``" }) -join ', ')).AppendLine() } $null = $sb.AppendLine((ConvertTo-MdInline (($help.Synopsis.Trim() -replace '\s*\r?\n\s*', ' ')))).AppendLine() if ($Syntax) { $null = $sb.AppendLine('```text').AppendLine($Syntax.Trim()).AppendLine('```').AppendLine() } if ($help.Description) { $null = $sb.AppendLine((ConvertTo-MdBlock $help.Description)).AppendLine() } $params = @($Body.ParamBlock?.Parameters) if ($params) { $null = $sb.AppendLine('| Parameter | Type | Default | Required | Pipeline | Description |') $null = $sb.AppendLine('| --- | --- | --- | --- | --- | --- |') foreach ($p in $params) { $null = $sb.AppendLine((Get-ParamRow $p $help)) } $null = $sb.AppendLine() } if ($help.Outputs) { $null = $sb.AppendLine('**Outputs:** ' + (ConvertTo-MdInline (($help.Outputs.Trim() -replace '\s*\r?\n\s*', ' ')))).AppendLine() } $i = 0 foreach ($ex in @($help.Examples)) { $i++ $pieces = ($ex.Trim() -replace '\r', '') -split '\n\s*\n', 2 $null = $sb.AppendLine("**Example $i**").AppendLine() $null = $sb.AppendLine('```powershell').AppendLine($pieces[0].TrimEnd()).AppendLine('```').AppendLine() if ($pieces.Count -gt 1 -and $pieces[1].Trim()) { $null = $sb.AppendLine((ConvertTo-MdBlock $pieces[1])).AppendLine() } } $links = @($help.Links | Where-Object { $_ -match '^https?://' }) if ($links) { $guide = $links | ForEach-Object { $page = $_ -replace '^https://github\.com/simsrw73/DotForge/blob/main/docs/', '' if ($page -ne $_) { "[$($page -replace '^guide/', '' -replace '\.md$', '')]($page)" } else { "<$_>" } } $null = $sb.AppendLine('**See also:** ' + ($guide -join ', ')).AppendLine() } $sb.ToString() } function Get-AliasMap([string[]]$Files) { # Set-Alias -Name <alias> -Value <function> calls -> function -> aliases. $map = @{} foreach ($f in $Files) { $ast = [System.Management.Automation.Language.Parser]::ParseFile($f, [ref]$null, [ref]$null) foreach ($cmd in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Set-Alias' }, $true)) { $els = $cmd.CommandElements $get = { param($pname) for ($i = 0; $i -lt $els.Count - 1; $i++) { if ($els[$i] -is [System.Management.Automation.Language.CommandParameterAst] -and $els[$i].ParameterName -eq $pname) { return $els[$i + 1].Extent.Text.Trim("'`"") } } } $alias = & $get 'Name'; $target = & $get 'Value' if ($alias -and $target) { if (-not $map[$target]) { $map[$target] = [System.Collections.Generic.List[string]]::new() } $map[$target].Add($alias) } } } $map } # ── Exported functions ───────────────────────────────────────────────────────── # Sections map functions by their source file, so load the separate files, not the core bundle. $Env:DF_NO_BUNDLE = '1' Import-Module (Join-Path $repo 'DotForge.psd1') -Force 3>$null # The core and its on-demand modules, loaded from this checkout. $moduleDirs = @($repo, (Join-Path $repo 'Modules' 'DotForge.Catalog'), (Join-Path $repo 'Modules' 'DotForge.Helpers')) foreach ($d in $moduleDirs | Select-Object -Skip 1) { Import-Module (Join-Path $d "$(Split-Path $d -Leaf).psd1") -Force 3>$null } $publicFiles = @(foreach ($d in $moduleDirs) { Get-ChildItem (Join-Path $d 'Public') -Filter '*.ps1' | ForEach-Object FullName }) $publicAliases = Get-AliasMap $publicFiles $exported = foreach ($cmd in Get-Command -Module DotForge, DotForge.Catalog, DotForge.Helpers -CommandType Function) { $file = [IO.Path]::GetFileNameWithoutExtension($cmd.ScriptBlock.File) [pscustomobject]@{ Name = $cmd.Name Section = $sectionByFile[$file] ?? $(throw "Public file '$file' is not mapped to a reference section in `$sectionByFile.") Body = $cmd.ScriptBlock.Ast.Body Aliases = @($publicAliases[$cmd.Name] | Sort-Object) Syntax = ((Get-Command $cmd.Name -Syntax) -replace '\r', '').Trim() } } # ── Sidecar globals ──────────────────────────────────────────────────────────── $sidecarFiles = @(Get-ChildItem (Join-Path $repo 'Tools') -Filter '*.ps1' | Sort-Object Name) $sidecarAliases = Get-AliasMap ($sidecarFiles.FullName) $sidecar = foreach ($file in $sidecarFiles) { $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$null, [ref]$null) $tool = $file.BaseName foreach ($fn in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -like 'global:*' }, $true)) { $name = $fn.Name -replace '^global:' [pscustomobject]@{ Tool = $tool; Name = $name; Body = $fn.Body; Aliases = @($sidecarAliases[$name] | Sort-Object) } } foreach ($cmd in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Set-Item' }, $true)) { $path = $cmd.CommandElements | Where-Object { $_ -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $_.Value -like 'function:global:*' } | Select-Object -First 1 $sbe = $cmd.Find({ param($n) $n -is [System.Management.Automation.Language.ScriptBlockExpressionAst] }, $true) if ($path -and $sbe) { $name = $path.Value -replace '^function:global:' [pscustomobject]@{ Tool = $tool; Name = $name; Body = $sbe.ScriptBlock; Aliases = @($sidecarAliases[$name] | Sort-Object) } } } } # ── Tool records ─────────────────────────────────────────────────────────────── function Get-ToolMarkdown($Tool, [string]$ToolsDir, [string[]]$ToolsWithFunctions) { $prop = { param($o, $n) if ($null -ne $o) { $o.PSObject.Properties[$n]?.Value } } $code = { param($v) '`' + (([string]$v -replace '\r?\n', ' ') -replace '`', "'") + '`' } $rows = [System.Collections.Generic.List[string]]::new() $add = { param($k, $v) if ($v) { $rows.Add("| $k | $v |") } } $type = & $prop $Tool 'type' & $add 'Detected by' ($(if ($type -eq 'module') { 'module ' } else { '' }) + (& $code $Tool.executable)) & $add 'Tags' ((@(& $prop $Tool 'tags') | ForEach-Object { & $code $_ }) -join ', ') $pk = & $prop $Tool 'packages' if ($pk) { & $add 'Install ids' (($pk.PSObject.Properties | ForEach-Object { $id = if ($_.Value -is [string]) { $_.Value } else { $_.Value.id } $feed = if ($_.Value -isnot [string] -and $_.Value.PSObject.Properties['feed']) { " (feed $(& $code $_.Value.feed.name))" } else { '' } "$($_.Name): $(& $code $id)$feed" }) -join '<br>') } $xdg = & $prop $Tool 'xdg' if ($xdg) { $method = & $prop $xdg 'method' & $add 'XDG method' (& $code $method) $vars = & $prop $xdg 'vars' if ($vars) { & $add 'XDG variables' (($vars.PSObject.Properties | ForEach-Object { "$(& $code $_.Name) = $(& $code $_.Value)" }) -join '<br>') } $dirs = & $prop $xdg 'dirs' if ($dirs) { & $add 'Creates' ((@($dirs) | ForEach-Object { & $code $_ }) -join '<br>') } } $env = & $prop $Tool 'env' if ($env) { & $add 'Environment' (($env.PSObject.Properties | ForEach-Object { "$(& $code $_.Name) = $(& $code $_.Value)" }) -join '<br>') } $aliases = & $prop $Tool 'aliases' if ($aliases -and @($aliases.PSObject.Properties).Count) { & $add 'Aliases' (($aliases.PSObject.Properties | ForEach-Object { $cmd = (@($_.Value.command) + @($_.Value.PSObject.Properties['args']?.Value) | Where-Object { $_ }) -join ' ' "$(& $code $_.Name) → $(& $code $cmd)" }) -join '<br>') } $picker = & $prop $Tool 'picker' if ($picker -is [pscustomobject]) { $action = & $prop $picker 'action' $what = if ($action -eq 'output') { 'outputs the selection' } else { 'runs ' + (& $code $action) } & $add 'Picker' "$(& $code (& $prop $picker 'alias')) ($(& $prop $picker 'function')): lists $(& $code (& $prop $picker 'list')); Enter $what" } $req = & $prop $Tool 'requires' if ($req) { & $add 'Requires' ((@($req) | ForEach-Object { & $code $_ }) -join ', ') } $deps = & $prop $Tool 'after' if ($deps) { & $add 'Registers after' ((@($deps) | ForEach-Object { & $code $_ }) -join ', ') } $seed = (& $prop $Tool 'setup')?.PSObject.Properties['seed']?.Value if ($seed) { & $add 'Seeds once' ((@($seed.PSObject.Properties.Name) | ForEach-Object { & $code $_ }) -join '<br>') } $roles = & $prop $Tool 'roles' $roleNames = @(if ($roles) { $roles.PSObject.Properties.Name }) if (& $prop $Tool 'role') { $roleNames += & $prop $Tool 'role' } & $add 'Roles' ((@($roleNames | Where-Object { $_ } | Sort-Object -Unique) | ForEach-Object { "[$(& $code $_)](#$(Get-Slug "$_ role"))" }) -join ', ') if ($roles) { foreach ($r in $roles.PSObject.Properties) { $renv = & $prop $r.Value 'env' if ($renv) { & $add "Sets, as the $($r.Name) tool" (($renv.PSObject.Properties | ForEach-Object { "$(& $code $_.Name) = $(& $code $_.Value)" }) -join '<br>') } $ral = & $prop $r.Value 'aliases' if ($ral -and @($ral.PSObject.Properties).Count) { & $add "Aliases, as the $($r.Name) tool" (($ral.PSObject.Properties | ForEach-Object { $cmd = (@($_.Value.command) + @($_.Value.PSObject.Properties['args']?.Value) | Where-Object { $_ }) -join ' ' "$(& $code $_.Name) → $(& $code $cmd)" }) -join '<br>') } } } $companion = Test-Path (Join-Path $ToolsDir "$($Tool.name).ps1") $setup = Test-Path (Join-Path $ToolsDir "$($Tool.name).setup.ps1") & $add 'Companion' (@( # Link only when the companion defines functions, so its section exists below. if ($companion -and $Tool.name -in $ToolsWithFunctions) { "[``Tools/$($Tool.name).ps1``](#$(Get-Slug "$($Tool.name) companion"))" } elseif ($companion) { "``Tools/$($Tool.name).ps1``" } if ($setup) { "one-time ``Tools/$($Tool.name).setup.ps1``" } ) -join ', ') $sb = [System.Text.StringBuilder]::new() # "<name> tool", so the anchor can't collide with a same-named companion function (glow, fastfetch). $null = $sb.AppendLine("### $($Tool.name) tool").AppendLine() $null = $sb.AppendLine((ConvertTo-MdInline ([string](& $prop $Tool 'description')))).AppendLine() $null = $sb.AppendLine('| | |').AppendLine('| --- | --- |') foreach ($r in $rows) { $null = $sb.AppendLine($r) } $sb.AppendLine().ToString() } $toolsDir = Join-Path $repo 'Tools' $toolRecords = Get-ChildItem $toolsDir -Filter '*.json' | ForEach-Object { Get-Content $_.FullName -Raw | ConvertFrom-Json } | Sort-Object { $_.name.ToLowerInvariant() } # ── Render ───────────────────────────────────────────────────────────────────── $md = [System.Text.StringBuilder]::new() $null = $md.AppendLine('<!-- Generated by build/Build-DFReferenceDocs.ps1 from comment-based help. Do not edit by hand. -->') $null = $md.AppendLine('# DotForge reference').AppendLine() $null = $md.AppendLine('**Audience:** anyone looking up a DotForge command, parameter or default. ') $null = $md.AppendLine('**Topic:** every exported cmdlet, and every function a tool companion defines when its tool is registered. ') $null = $md.AppendLine('**Goal:** find exact syntax, parameters, defaults and outputs. For walkthroughs, start with the [getting started guide](guide/getting-started.md).').AppendLine() $null = $md.AppendLine('Everything here is also available in the shell: `Get-Help <name> -Full`.').AppendLine() $null = $md.AppendLine('## Contents').AppendLine() foreach ($section in $sectionOrder) { $inSection = @($exported | Where-Object Section -EQ $section | Sort-Object Name) if (-not $inSection) { continue } $null = $md.AppendLine("**$section**").AppendLine() $null = $md.AppendLine('| Command | Alias | Summary |').AppendLine('| --- | --- | --- |') foreach ($f in $inSection) { $syn = ConvertTo-MdInline ($f.Body.GetHelpContent().Synopsis.Trim() -replace '\s*\r?\n\s*', ' ') $null = $md.AppendLine(('| [{0}](#{1}) | {2} | {3} |' -f $f.Name, (Get-Slug $f.Name), (($f.Aliases | ForEach-Object { "``$_``" }) -join ', '), $syn)) } $null = $md.AppendLine() } $null = $md.AppendLine('**Tool records**').AppendLine() $null = $md.AppendLine(($toolRecords | ForEach-Object { "[$($_.name)](#$(Get-Slug "$($_.name) tool"))" }) -join ' · ').AppendLine() $roleDefs = Get-Content (Join-Path $repo 'data' 'roles.json') -Raw | ConvertFrom-Json $roleNamesSorted = @($roleDefs.PSObject.Properties.Name | Sort-Object) $null = $md.AppendLine('**Roles**').AppendLine() $null = $md.AppendLine(($roleNamesSorted | ForEach-Object { "[$_](#$(Get-Slug "$_ role"))" }) -join ' · ').AppendLine() $null = $md.AppendLine('**Tool companion functions**').AppendLine() $null = $md.AppendLine('| Command | Alias | Tool | Summary |').AppendLine('| --- | --- | --- | --- |') foreach ($f in $sidecar | Sort-Object Tool, Name) { $syn = ConvertTo-MdInline ($f.Body.GetHelpContent().Synopsis.Trim() -replace '\s*\r?\n\s*', ' ') $null = $md.AppendLine(('| [{0}](#{1}) | {2} | {3} | {4} |' -f $f.Name, (Get-Slug $f.Name), (($f.Aliases | ForEach-Object { "``$_``" }) -join ', '), $f.Tool, $syn)) } $null = $md.AppendLine() foreach ($section in $sectionOrder) { $inSection = @($exported | Where-Object Section -EQ $section | Sort-Object Name) if (-not $inSection) { continue } $null = $md.AppendLine("## $section").AppendLine() foreach ($f in $inSection) { $null = $md.Append((Get-FunctionMarkdown -Name $f.Name -Body $f.Body -Aliases $f.Aliases -Syntax $f.Syntax -HeadingLevel '###')) } } $null = $md.AppendLine('## Roles').AppendLine() $null = $md.AppendLine('A role is a job several tools can do. For a *single* role, one installed tool wins (`$DFConfig.Defaults`, else the highest priority) and only it sets the role''s variables and aliases and installs its hooks. A *category* only groups tools. Run `Get-DFRole` to see the winners on your machine.').AppendLine() foreach ($roleName in $roleNamesSorted) { $def = $roleDefs.$roleName $members = @($toolRecords | Where-Object { $tr = $_.PSObject.Properties['roles']?.Value ($tr -and $tr.PSObject.Properties[$roleName]) -or $_.PSObject.Properties['role']?.Value -eq $roleName } | ForEach-Object { "[$($_.name)](#$(Get-Slug "$($_.name) tool"))" }) $kind = if ($def.kind -eq 'single') { if ($def.PSObject.Properties['exclusive']?.Value) { 'single, exclusive' } else { 'single' } } else { 'category' } $null = $md.AppendLine("### $roleName role").AppendLine() $null = $md.AppendLine((ConvertTo-MdInline $def.description)).AppendLine() $null = $md.AppendLine('| | |').AppendLine('| --- | --- |') $null = $md.AppendLine("| Kind | $kind |") $null = $md.AppendLine("| Members | $($members -join ', ') |") $res = $def.PSObject.Properties['reserved']?.Value $resNames = @(@(if ($res) { @($res.env) + @($res.aliases) }) | Where-Object { $_ } | ForEach-Object { "``$_``" }) if ($resNames) { $null = $md.AppendLine("| Only the winner sets | $($resNames -join ', ') |") } if ($def.PSObject.Properties['requires']) { $null = $md.AppendLine("| Requires | $(ConvertTo-MdInline $def.requires) |") } $null = $md.AppendLine() } $null = $md.AppendLine('## Tool records').AppendLine() $null = $md.AppendLine('What `Register-DFTool` does for each tool, read from its `Tools/<name>.json` record. A tool is configured only when it is detected: its executable is on `PATH`, or for a module, the module is installed. For the record format, see [Writing a tool record](guide/writing-a-tool.md).').AppendLine() $withFunctions = @($sidecar.Tool | Sort-Object -Unique) foreach ($t in $toolRecords) { $null = $md.Append((Get-ToolMarkdown $t $toolsDir $withFunctions)) } $null = $md.AppendLine('## Tool companion functions').AppendLine() $null = $md.AppendLine('These are defined when their tool is registered (`Register-DFTool`), not when the module is imported. Each needs its tool installed.').AppendLine() foreach ($group in $sidecar | Sort-Object Tool, Name | Group-Object Tool) { $null = $md.AppendLine("### $($group.Name) companion").AppendLine() foreach ($f in $group.Group) { $null = $md.Append((Get-FunctionMarkdown -Name $f.Name -Body $f.Body -Aliases $f.Aliases -HeadingLevel '####')) } } $text = ($md.ToString() -replace '\r', '').TrimEnd() + "`n" # Collapse runs of blank lines the section joins can leave behind. $text = $text -replace '\n{3,}', "`n`n" $dir = Split-Path $OutputPath -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } [IO.File]::WriteAllText($OutputPath, $text, [Text.UTF8Encoding]::new($false)) Write-Host "Wrote $OutputPath" |